diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs new file mode 100644 index 00000000000..cd24404d658 --- /dev/null +++ b/.git-blame-ignore-revs @@ -0,0 +1,6 @@ +# Code formatting. +d2cdcfa9863e405983ecafc47e2e7e5af9da68f4 +# Remove unnecessary quotes from variable annotations. +a9f153afce8495f66cf0f52b430beeaf346f7b48 +# Reformat stringified unions in annotations. +39ccb4364f5879a915f697f256f32e6919717eb0 diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000000..a9953cb64c4 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,9 @@ +# Set update schedule for GitHub Actions + +version: 2 +updates: + - package-ecosystem: "github-actions" + directory: "/" + schedule: + # Check for updates to GitHub Actions every weekday + interval: "daily" diff --git a/.github/workflows/acceptance_tests_cpython.yml b/.github/workflows/acceptance_tests_cpython.yml new file mode 100644 index 00000000000..8a0e8cd7a11 --- /dev/null +++ b/.github/workflows/acceptance_tests_cpython.yml @@ -0,0 +1,90 @@ +name: Acceptance tests (CPython + PyPy) + +on: + push: + branches: + - main + - master + - v*-maintenance + paths: + - '.github/workflows/**' + - 'src/**' + - 'atest/**' + - '!**/*.rst' + +jobs: + test_using_builtin_python: + + strategy: + fail-fast: false + matrix: + os: [ 'ubuntu-latest', 'windows-latest' ] + python-version: [ '3.8', '3.9', '3.10', '3.11', '3.12', '3.13', '3.14', '3.15-dev', 'pypy-3.11' ] + include: + - os: ubuntu-latest + set_display: export DISPLAY=:99; Xvfb :99 -screen 0 1024x768x24 -ac -noreset & sleep 3 + - python-version: '3.15-dev' + atest_args: '--exclude require-lxml' + exclude: + - os: windows-latest + python-version: 'pypy-3.11' + + runs-on: ${{ matrix.os }} + + name: Python ${{ matrix.python-version }} on ${{ matrix.os }} + steps: + - uses: actions/checkout@v7 + + - name: Setup python for starting the tests + uses: actions/setup-python@v7.0.0 + with: + python-version: '3.14' + architecture: 'x64' + + - name: Get test starter Python at Windows + run: echo "ATEST_PYTHON=$((get-command python.exe).Path)" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append + if: runner.os == 'Windows' + + - name: Get test starter Python + run: echo "ATEST_PYTHON=$(which python)" >> $GITHUB_ENV + if: runner.os != 'Windows' + + - name: Setup python ${{ matrix.python-version }} for running the tests + uses: actions/setup-python@v7.0.0 + with: + python-version: ${{ matrix.python-version }} + architecture: 'x64' + + - name: Get test runner Python at Windows + run: echo "BASE_PYTHON=$((get-command python.exe).Path)" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append + if: runner.os == 'Windows' + + - name: Get test runner Python + run: echo "BASE_PYTHON=$(which python)" >> $GITHUB_ENV + 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 other required libraries to Linux + run: | + sudo apt-get update + sudo apt-get -y -q install xvfb scrot libxml2-dev libxslt1-dev + if: contains(matrix.os, 'ubuntu') + + - name: Run acceptance tests + run: | + python -m pip install -r atest/requirements.txt + ${{ env.ATEST_PYTHON }} -m pip install -r atest/requirements-run.txt + ${{ matrix.set_display }} + ${{ env.ATEST_PYTHON }} atest/run.py --interpreter ${{ env.BASE_PYTHON }} --exclude no-ci ${{ matrix.atest_args }} atest/robot + + - name: Archive acceptances test results + uses: actions/upload-artifact@v7 + with: + name: at-results-${{ matrix.python-version }}-${{ matrix.os }} + path: atest/results + if: always() && job.status == 'failure' diff --git a/.github/workflows/acceptance_tests_cpython_pr.yml b/.github/workflows/acceptance_tests_cpython_pr.yml new file mode 100644 index 00000000000..a2b6ad5958a --- /dev/null +++ b/.github/workflows/acceptance_tests_cpython_pr.yml @@ -0,0 +1,75 @@ +name: Acceptance tests (CPython) + +on: + pull_request: + paths: + - '.github/workflows/**' + - 'src/**' + - 'atest/**' + - '!**/*.rst' + +jobs: + test_using_builtin_python: + + strategy: + fail-fast: true + matrix: + os: [ 'ubuntu-latest', 'windows-latest' ] + python-version: [ '3.8', '3.13' ] + include: + - os: ubuntu-latest + set_display: export DISPLAY=:99; Xvfb :99 -screen 0 1024x768x24 -ac -noreset & sleep 3 + + runs-on: ${{ matrix.os }} + + name: Python ${{ matrix.python-version }} on ${{ matrix.os }} + steps: + - uses: actions/checkout@v7 + + - name: Setup python for starting the tests + uses: actions/setup-python@v7.0.0 + with: + python-version: '3.13' + architecture: 'x64' + + - name: Get test starter Python at Windows + run: echo "ATEST_PYTHON=$((get-command python.exe).Path)" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append + if: runner.os == 'Windows' + + - name: Get test starter Python + run: echo "ATEST_PYTHON=$(which python)" >> $GITHUB_ENV + if: runner.os != 'Windows' + + - name: Setup python ${{ matrix.python-version }} for running the tests + uses: actions/setup-python@v7.0.0 + with: + python-version: ${{ matrix.python-version }} + architecture: 'x64' + + - name: Get test runner Python at Windows + run: echo "BASE_PYTHON=$((get-command python.exe).Path)" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append + if: runner.os == 'Windows' + + - name: Get test runner Python + run: echo "BASE_PYTHON=$(which python)" >> $GITHUB_ENV + if: runner.os != 'Windows' + + - name: Install screen and other required libraries to Linux + run: | + sudo apt-get update + sudo apt-get -y -q install xvfb scrot libxml2-dev libxslt1-dev + if: contains(matrix.os, 'ubuntu') + + - name: Run acceptance tests + run: | + python -m pip install -r atest/requirements.txt + ${{ env.ATEST_PYTHON }} -m pip install -r atest/requirements-run.txt + ${{ matrix.set_display }} + ${{ env.ATEST_PYTHON }} atest/run.py --interpreter ${{ env.BASE_PYTHON }} --exclude no-ci ${{ matrix.atest_args }} atest/robot + + - name: Archive acceptances test results + uses: actions/upload-artifact@v7 + with: + name: at-results-${{ matrix.python-version }}-${{ matrix.os }} + path: atest/results + if: always() && job.status == 'failure' diff --git a/.github/workflows/unit_tests.yml b/.github/workflows/unit_tests.yml new file mode 100644 index 00000000000..4cd4845c535 --- /dev/null +++ b/.github/workflows/unit_tests.yml @@ -0,0 +1,43 @@ +name: Unit tests + +on: + push: + branches: + - main + - master + - v*-maintenance + paths: + - '.github/workflows/**' + - 'src/**' + - 'utest/**' + - '!**/*.rst' + + +jobs: + test_using_builtin_python: + + strategy: + fail-fast: false + matrix: + os: [ 'ubuntu-latest', 'windows-latest' ] + python-version: [ '3.8', '3.9', '3.10', '3.11', '3.12', '3.13', '3.14', '3.15-dev', 'pypy-3.11' ] + exclude: + - os: windows-latest + python-version: 'pypy-3.11' + + runs-on: ${{ matrix.os }} + + name: Python ${{ matrix.python-version }} on ${{ matrix.os }} + steps: + - uses: actions/checkout@v7 + + - name: Setup python ${{ matrix.python-version }} + uses: actions/setup-python@v7.0.0 + with: + python-version: ${{ matrix.python-version }} + architecture: 'x64' + + - name: Run unit tests + run: | + python -m pip install -r utest/requirements.txt + python utest/run.py -v diff --git a/.github/workflows/unit_tests_pr.yml b/.github/workflows/unit_tests_pr.yml new file mode 100644 index 00000000000..4023d980d48 --- /dev/null +++ b/.github/workflows/unit_tests_pr.yml @@ -0,0 +1,35 @@ +name: Unit tests + +on: + pull_request: + paths: + - '.github/workflows/**' + - 'src/**' + - 'utest/**' + - '!**/*.rst' + +jobs: + test_using_builtin_python: + + strategy: + fail-fast: true + matrix: + os: [ 'ubuntu-latest', 'windows-latest' ] + python-version: [ '3.8', '3.13' ] + + runs-on: ${{ matrix.os }} + + name: Python ${{ matrix.python-version }} on ${{ matrix.os }} + steps: + - uses: actions/checkout@v7 + + - name: Setup python ${{ matrix.python-version }} + uses: actions/setup-python@v7.0.0 + with: + python-version: ${{ matrix.python-version }} + architecture: 'x64' + + - name: Run unit tests + run: | + python -m pip install -r utest/requirements.txt + python utest/run.py -v diff --git a/.github/workflows/web_tests.yml b/.github/workflows/web_tests.yml new file mode 100644 index 00000000000..07c0309b23a --- /dev/null +++ b/.github/workflows/web_tests.yml @@ -0,0 +1,30 @@ +name: Web tests with jest + +on: + push: + branches: + - main + - master + - v*-maintenance + + paths: + - '.github/workflows/**' + - 'src/web**' + - '!**/*.rst' + +jobs: + jest_tests: + + runs-on: 'ubuntu-latest' + + name: Jest tests for the web components + steps: + - uses: actions/checkout@v7 + + - name: Setup Node + uses: actions/setup-node@v6 + with: + node-version: "16" + - name: Run tests + working-directory: ./src/web + run: npm install && npm run test diff --git a/.gitignore b/.gitignore index c046f804d6a..5200647a289 100644 --- a/.gitignore +++ b/.gitignore @@ -1,12 +1,16 @@ tmp build dist +target +venv* +.venv* +.env *~ MANIFEST atest/results utest/jasmine-results -*~ .idea +.vscode .*project *.pyc *$py.class @@ -15,8 +19,18 @@ ext-lib .DS_Store doc/api/_build doc/libraries/*.html +doc/libraries/*.json doc/userguide/RobotFrameworkUserGuide.html src/robotframework.egg-info log.html output.xml report.html +__pycache__ +.classpath +.settings +.jython_cache +.mypy_cache/ +node_modules +.cache/ +.parcel-cache/ +.claude/ diff --git a/.readthedocs.yaml b/.readthedocs.yaml new file mode 100644 index 00000000000..bd5c3733053 --- /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: doc/api/requirements.txt diff --git a/AUTHORS.rst b/AUTHORS.rst new file mode 100644 index 00000000000..f38d70c7723 --- /dev/null +++ b/AUTHORS.rst @@ -0,0 +1,108 @@ +Robot Framework contributors +============================ + +This file lists contributors to Robot Framework during the time it was +developed at Nokia Networks. There are both `core team members`_ as well +as `other contributors`_. + +Nowadays contributors are acknowledged separately in the release notes +and this file is not anymore updated. Contributors can also be found easily +via GitHub__. + +__ https://github.com/robotframework/robotframework/graphs/contributors + +Core team members +----------------- + +The members of the Robot Framework core team during the Nokia Networks years +2005 - 2015. + +=========================== =========== + Name Years +=========================== =========== +Pekka Klärck (né Laukkanen) 2005 - 2015 +Petri Haapio 2005 - 2008 +Juha Rantanen 2005 - 2011 +Lasse Koskela 2005 - 2006 +Janne Härkönen 2006 - 2012 +Sami Honkonen 2006 - 2007 +Antti Tuomaala 2006 +Ran Nyman 2007 - 2010 +Heikki Hulkko 2007 - 2009 +Anna Tevaniemi 2008 +Kari Husa 2009 - 2011 +Jussi Malinen 2010 - 2015 +Mikko Korpela 2010 - 2014 +Ismo Aro 2010 - 2012 +Ilmari Kontulainen 2010 - 2013 +Tommi Asiala 2012 +Mika Hänninen 2012 - 2013 +Tatu Kairi 2012 - 2014 +Anssi Syrjäsalo 2013 +Janne Piironen 2013 - 2014 +=========================== =========== + +Other contributors +------------------ + +External contributors after Robot Framework was open sourced in 2008. +As already mentioned above, this list is not anymore updated. + +=========================== =============================================== + Name Contribution +=========================== =============================================== +Elisabeth Hendrickson | Quick Start Guide (2.0) +Marcin Michalak | String library (2.1) +Chris Prinos | reST (HTML) support (2.1) + | How-to debug execution with `pdb` (2.7.6) +Régis Desgroppes | Fixing installation paths (2.1.3) + | xUnit compatible outputs (2.5.5) +Robert Spielmann | Report background colors (2.5) +Xie Yanbo | Alignment of east asian characters (2.5.3) +JSXGraph Developers | JSXGraph tool and license changes for it (2.6) +Imran | Template names to listener API (2.6) + | Suite source to listener API (2.7) +Tatu Aalto | Get Time keyword enhancement (2.7.5) +Eemeli Kantola | Fix for non-breaking spaces (2.7.5) +Martti Haukijärvi | IronPython support to Screenshot library (2.7.5) +Guy Kisel | How-to use decorators when creating libraries (2.7.7) + | BuiltIn.Log pprint support (2.8.6) + | New pattern matching keywords in Collections (2.8.6) + | Keyword/variable not found recommendations (2.8.6) + | Tidy ELSE and ELSE IF onto separate lines (2.8.7) + | Initial contribution guidelines (2.9, #1805) +Mike Terzo | Better connection errors to Remote library (2.7.7) +Asko Soukka | reST (plain text) support (2.8.2) +Vivek Kumar Verma | reST (plain text) support (2.8.2) +Stefan Zimmermann | `**kwargs` support for dynamic libraries (2.8.2) + | `**kwargs` support for Java libraries (2.8.3) + | `*varargs` support using java.util.List (2.8.3) +Mirel Pehadzic | Terminal emulation for Telnet library (2.8.2) +Diogo Sa-Chaves De Oliveira | Terminal emulation for Telnet library (2.8.2) +Lionel Perrin | Giving custom seed to --randomize (2.8.5) +Michael Walle | Telnet.Write Control Character keyword (2.8.5) + | Telnet.Read Until Prompt strip_prompt option (2.8.7) + | String.Strip String (3.0) +Tero Kinnunen | BDD 'But' prefix ignored (2.8.7) +Heiko Thiery | Enable log level config option for TelnetLibrary (2.8.7) +Nicolae Chedea | Float parameters in FOR IN RANGE (2.8.7) +Jared Hellman | Custom keyword names (2.9) + | Embedded arguments for library keywords (2.9) +Vinicius K. Ruoso | Support multiple listeners per library (2.9) + | Allowing control over connection timeout in Telnet library (2.9.2) + | Suppress docutils errors/warnings with reST format (2.9.2) +Joseph Lorenzini | Exposed ERROR log level for keywords (2.9) +Guillaume Grossetie | Less flashy report and log styles (2.9, #1943) +Ed Brannin | `FOR ... IN ZIP`, `FOR ... IN ENUMERATE` (2.9, #1989) +Moon SungHoon | String.Get Regexp Matches keyword (2.9, #1985) +Hélio Guilherme | Support partial match with String.Get Lines Matching Regexp (2.9, #1836) +Jean-Charles Deville | Make variable errors not exit `Runner keywords` (2.9, #1869) +Laurent Bristiel | Convert examples in User Guide to plain text format (2.9, #1972) +Tim Orling | IronPython support for `Dialogs` library (2.9.2, #1235) +Jozef Behran | Fix `${TEST_MESSAGE}` to reflect current test message (3.0, #2188) +Joong-Hee Lee | Extend 'Repeat Keyword' to support timeout (3.0, #2245) +Anton Nikitin | Should (Not) Contain Any (3.0.1, #2120) +Yang Qian | Support to copy/deepcopy model objects (3.0.1, #2483) +Chris Callan | Case-insensitivity support to various comparison keywords (3.0.1, #2439) +Benjamin Einaudi | Add `--rerunfailedsuites` option (3.0.1, #2117) +=========================== =============================================== diff --git a/AUTHORS.txt b/AUTHORS.txt deleted file mode 100644 index 8f871fe370b..00000000000 --- a/AUTHORS.txt +++ /dev/null @@ -1,79 +0,0 @@ -The core team members over the years: -Pekka Klärck (né Laukkanen) 2005 - -Juha Rantanen 2005 - 2011 -Lasse Koskela 2005 - 2006 -Janne Härkönen 2006 - 2012 -Sami Honkonen 2006 - 2007 -Antti Tuomaala 2006 -Ran Nyman 2007 - 2010 -Heikki Hulkko 2007 - 2009 -Anna Tevaniemi 2008 -Kari Husa 2009 - 2011 -Jussi Malinen 2010 - 2015 -Mikko Korpela 2010 - 2014 -Ismo Aro 2010 - 2012 -Ilmari Kontulainen 2010 - 2013 -Tommi Asiala 2012 -Mika Hänninen 2012 - 2013 -Tatu Kairi 2012 - 2014 -Anssi Syrjäsalo 2013 -Janne Piironen 2013 - 2014 - -Other contributors: -Elisabeth Hendrickson Quick Start Guide (2.0) -Marcin Michalak String library (2.1) -Chris Prinos reST (HTML) support (2.1) - How-to debug execution with `pdb` (2.7.6) -Régis Desgroppes Fixing installation paths (2.1.3) - xUnit compatible outputs (2.5.5) -Robert Spielmann Report background colors (2.5) -Xie Yanbo Alignment of east asian characters (2.5.3) -JSXGraph Developers JSXGraph tool and license changes for it (2.6) -Imran Template names to listener API (2.6) - Suite source to listener API (2.7) -Tatu Aalto Get Time keyword enhancement (2.7.5) -Eemeli Kantola Fix for non-breaking spaces (2.7.5) -Martti Haukijärvi IronPython support to Screenshot library (2.7.5) -Guy Kisel How-to use decorators when creating libraries (2.7.7) - BuiltIn.Log pprint support (2.8.6) - New pattern matching keywords in Collections (2.8.6) - Keyword/variable not found recommendations (2.8.6) - Tidy ELSE and ELSE IF onto separate lines (2.8.7) - Initial contribution guidelines (2.9, #1805) -Mike Terzo Better connection errors to Remote library (2.7.7) -Asko Soukka reST (plain text) support (2.8.2) -Vivek Kumar Verma reST (plain text) support (2.8.2) -Stefan Zimmermann **kwargs support for dynamic libraries (2.8.2) - **kwargs support for Java libraries (2.8.3) - *varargs support using java.util.List (2.8.3) -Mirel Pehadzic Terminal emulation for Telnet library (2.8.2) -Diogo Sa-Chaves De Oliveira Terminal emulation for Telnet library (2.8.2) -Lionel Perrin Giving custom seed to --randomize (2.8.5) -Michael Walle Telnet.Write Control Character keyword (2.8.5) - Telnet.Read Until Prompt strip_prompt option (2.8.7) - String.Strip String (3.0) -Tero Kinnunen BDD 'But' prefix ignored (2.8.7) -Heiko Thiery Enable log level config option for TelnetLibrary (2.8.7) -Nicolae Chedea Float parameters in FOR IN RANGE (2.8.7) -Jared Hellman Custom keyword names (2.9) - Embedded arguments for library keywords (2.9) -Vinicius K. Ruoso Support multiple listeners per library (2.9) - Allowing control over connection timeout in Telnet library (2.9.2) - Supress docutils errors/warnings with reST format (2.9.2) -Joseph Lorenzini Exposed ERROR log level for keywords (2.9) -Guillaume Grossetie Less flashy report and log styles (2.9, #1943) -Ed Brannin FOR ... IN ZIP, FOR ... IN ENUMERATE (2.9, #1989) -Moon SungHoon String.Get Regexp Matches keyword (2.9, #1985) -Hélio Guilherme Support partial match with String.Get Lines Matching Regexp (2.9, #1836) -Jean-Charles Deville Make variable errors not exit `Runner keywords` (2.9, #1869) -Laurent Bristiel Convert examples in User Guide to plain text format (2.9, #1972) -Tim Orling IronPython support for `Dialogs` library (2.9.2, #1235) -Jozef Behran Fix ${TEST_MESSAGE} to reflect current test message (3.0, #2188) -Joong-Hee Lee Extend 'Repeat Keyword' to support timeout (3.0, #2245) -Anton Nikitin Should (Not) Contain Any (3.0.1, #2120) -Yang Qian Support to copy/deepcopy model objects (3.0.1, #2483) -Chris Callan Case-insensitivity support to various comparison keywords (3.0.1, #2439) -Benjamin Einaudi Add --rerunfailedsuites option (3.0.1, #2117) -Traitanit Huangsri Support more glob-like patterns for test filtering (3.1, #2471) -Brian-Williams Named-only arguments support with Python 3 libraries (3.1, #2555) -Élysson Mendes Rezende Add format_string keyword (3.1) diff --git a/BUILD.rst b/BUILD.rst index 489137e0496..c098c49c4b7 100644 --- a/BUILD.rst +++ b/BUILD.rst @@ -36,9 +36,9 @@ Many steps are automated using the generic `Invoke `_ tool with a help by our `rellu `_ utilities, but also other tools and modules are needed. A pre-condition is installing all these, and that's easiest done using `pip -`_ and the provided ``_ file:: +`_ and the provided ``_ file:: - pip install -r requirements-build.txt + pip install -r requirements-dev.txt Using Invoke ~~~~~~~~~~~~ @@ -66,10 +66,17 @@ Testing Make sure that adequate tests are executed before releases are created. See ``_ for details. +If output.xml `schema `_ 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 ----------- -1. Check that you are on the master branch and have nothing left to commit, +1. Check that you are on the right branch and have nothing left to commit, pull, or push:: git branch @@ -77,44 +84,54 @@ Preparation git pull --rebase git push -2. Clean up:: +2. Make sure code is formatted properly:: + + invoke format + git status + +3. Clean up:: invoke clean -3. Set version information to a shell variable to ease copy-pasting further +4. Set version information to a shell variable to ease copy-pasting further commands. Add ``aN``, ``bN`` or ``rcN`` postfix if creating a pre-release:: VERSION= - For example, ``VERSION=3.0.1`` or ``VERSION=3.1a2``. + For example, ``VERSION=7.1.1`` or ``VERSION=7.2a2``. No ``v`` prefix! Release notes ------------- -1. Set GitHub user information into shell variables to ease copy-pasting the - following command:: +1. Create a personal `GitHub access token`__ to be able to access issue tracker + programmatically. The token needs only the ``repo/public_repo`` scope. + +2. Set GitHub user information into shell variables to ease running the + ``invoke release-notes`` command in the next step:: GITHUB_USERNAME= - GITHUB_PASSWORD= + GITHUB_ACCESS_TOKEN= - Alternatively, supply the credentials when running that command. + ```` is your normal GitHub user name and ```` is the personal + access token generated in the previous step. Alternatively this information can + be given when running the command in the next step. -2. Generate a template for the release notes:: +3. Generate a template for the release notes:: - invoke release-notes -w -v $VERSION -u $GITHUB_USERNAME -p $GITHUB_PASSWORD + invoke release-notes -w -v $VERSION -u $GITHUB_USERNAME -p $GITHUB_ACCESS_TOKEN The ``-v $VERSION`` option can be omitted if `version is already set `__. Omit the ``-w`` option if you just want to get release notes printed to the console, not written to a file. - When generating release notes for a preview release like ``3.0.2rc1``, + When generating release notes for a preview release like ``7.2rc1``, the list of issues is only going to contain issues with that label (e.g. ``rc1``) or with a label of an earlier preview release (e.g. ``alpha1``, ``beta2``). -2. Fill the missing details in the generated release notes template. +4. Fill the missing details in the generated release notes template. -3. Make sure that issues have correct information: +5. Make sure that issues have correct information: - All issues should have type (bug, enhancement or task) and priority set. Notice that issues with the task type are automatically excluded from @@ -127,26 +144,44 @@ Release notes issue tracker than in the generated release notes. This allows re-generating the list of issues later if more issues are added. -4. Add, commit and push:: +6. Commit and push changes:: git add doc/releasenotes/rf-$VERSION.rst git commit -m "Release notes for $VERSION" doc/releasenotes/rf-$VERSION.rst git push -5. Update later if necessary. Writing release notes is typically the biggest +7. Update later if necessary. Writing release notes is typically the biggest task when generating releases, and getting everything done in one go is often impossible. +__ https://docs.github.com/en/free-pro-team@latest/github/authenticating-to-github/creating-a-personal-access-token + +Update Libdoc templates +----------------------- + +1. Prerequisites are listed in ``_. This step can be skipped + if there are no changes to Libdoc. + +2. Regenerate HTML template and update the list of supported localizations in + the ``--help`` text:: + + invoke build-libdoc + +3. Commit and push changes:: + + git commit -m "Update Libdoc templates" src/robot/htmldata/libdoc/libdoc.html src/robot/libdocpkg/languages.py + git push + Set version ----------- -1. Set version information in ``_ and in ``_:: +1. Set version information in ``_ and ``_:: invoke set-version $VERSION 2. Commit and push changes:: - git commit -m "Updated version to $VERSION" src/robot/version.py pom.xml + git commit -m "Updated version to $VERSION" src/robot/version.py setup.py git push Tagging @@ -175,73 +210,32 @@ Creating distributions invoke clean -3. Create and validate source distribution in zip format and universal (i.e. - Python 2 and 3 compatible) `wheel `_:: +4. Create and validate source distribution and `wheel `_:: - python setup.py sdist --formats zip bdist_wheel --universal + python setup.py sdist bdist_wheel ls -l dist twine check dist/* Distributions can be tested locally if needed. -4. Upload distributions to PyPI:: +5. Upload distributions to PyPI:: twine upload dist/* -5. Verify that project pages at `PyPI +6. Verify that project pages at `PyPI `_ look good. -6. Test installation:: +7. Test installation:: pip install --pre --upgrade robotframework -7. JAR distribution - - - Create:: - - invoke jar +8. Documentation - - Test that JAR is not totally borken:: - - java -jar dist/robotframework-$VERSION.jar --version - java -jar dist/robotframework-$VERSION.jar atest/testdata/misc/pass_and_fail.robot - -8. Upload JAR to Sonatype - - - Sonatype offers a service where users can upload JARs and they will be synced - to the maven central repository. Below are the instructions to upload the JAR. - - - Prequisites: - - - Install maven - - Create a `Sonatype account`__ - - Add these lines (filled with the Sonatype account information) to your ``settings.xml``:: - - - - sonatype-nexus-staging - - - - - - - Create `a PGP key`__ - - Apply for `publish rights`__ to org.robotframework project. This will - take some time from them to accept. - - - - Run command:: - - mvn gpg:sign-and-deploy-file -Dfile=dist/robotframework-$VERSION.jar -DpomFile=pom.xml -Durl=https://oss.sonatype.org/service/local/staging/deploy/maven2/ -DrepositoryId=sonatype-nexus-staging - - - Go to https://oss.sonatype.org/index.html#welcome, log in with Sonatype credentials, find the staging repository and do close & release - - After that, the released JAR is synced to Maven central within an hour. - -__ https://issues.sonatype.org/secure/Dashboard.jspa -__ https://central.sonatype.org/pages/working-with-pgp-signatures.html -__ https://docs.sonatype.org/display/Repository/Sonatype+OSS+Maven+Repository+Usage+Guide - -9. 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:: @@ -255,19 +249,19 @@ __ https://docs.sonatype.org/display/Repository/Sonatype+OSS+Maven+Repository+Us git checkout gh-pages invoke add-docs $VERSION --push - git checkout master + git checkout master # replace master with v*-maintenance if needed! Post actions ------------ 1. Back to master if needed:: - git checkout master + git checkout master # replace master with v*-maintenance if needed! 2. Set dev version based on the previous version:: invoke set-version dev - git commit -m "Back to dev version" src/robot/version.py pom.xml + git commit -m "Back to dev version" src/robot/version.py setup.py git push For example, ``1.2.3`` is changed to ``1.2.4.dev1`` and ``2.0.1a1`` @@ -282,28 +276,12 @@ Post actions Announcements ------------- -1. `robotframework-users `_ - and - `robotframework-announce `_ - lists. The latter is not needed with preview releases but should be used - at least with major updates. Notice that sending to it requires admin rights. - -2. Twitter. Either Tweet something yourself and make sure it's re-tweeted - by `@robotframework `_, or send the - message directly as `@robotframework`. This makes the note appear also - at http://robotframework.org. - - Should include a link to more information. Possibly a link to the full - release notes or an email to the aforementioned mailing lists. - -3. ``#devel`` and ``#general`` channels on Slack. +1. ``#announcements`` channel on `Slack `_. + Use ``@channel`` at least with major releases. -4. `Robot Framework LinkedIn - `_ group. +2. `Forum `_. -5. Consider sending announcements, at least with major releases, also to other - forums where we want to make the framework more well known. For example: +3. `LinkedIn group `_. A personal + LinkedIn post is a good idea at least with bigger releases. - - http://opensourcetesting.org - - http://tech.groups.yahoo.com/group/agile-testing - - http://lists.idyll.org/listinfo/testing-in-python +4. `robotframework-users `_ diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst index 693cec66219..37ff1c91fcc 100644 --- a/CONTRIBUTING.rst +++ b/CONTRIBUTING.rst @@ -8,14 +8,16 @@ There are also many other projects in the larger `Robot Framework ecosystem `_ that you can contribute to. If you notice a library or tool missing, there is hardly any better way to contribute than creating your own project. Other great ways to contribute include -answering questions and participating discussion on `robotframework-users -`_ mailing list -and other forums, as well as spreading the word about the framework one way or -the other. +answering questions and participating discussion on our +`Slack `_, +`Forum `_, +`LinkedIn group `_, +or other such discussion forum, speaking at conferences or local events, +and spreading the word about the framework otherwise. These guidelines expect readers to have a basic knowledge about open source -as well as why and how to contribute to open source projects. If you are -totally new to these topics, it may be a good idea to look at the generic +as well as why and how to contribute to an open source project. If you are +new to these topics, it may be a good idea to look at the generic `Open Source Guides `_ first. .. contents:: @@ -26,30 +28,30 @@ Submitting issues ----------------- Bugs and enhancements are tracked in the `issue tracker -`_. If you are -unsure if something is a bug or is a feature worth implementing, you can -first ask on `robotframework-users`_ mailing list, on `IRC -`_ -(#robotframework on irc.freenode.net), or on `Slack -`_. These and other similar -forums, not the issue tracker, are also places where to ask general questions. - -Before submitting a new issue, it is always a good idea to check is the -same bug or enhancement already reported. If it is, please add your comments +`_. If you are unsure +if something is a bug or is a feature worth implementing, you can +first ask on the ``#devel`` channel on our Slack_. Slack and other such forums, +not the issue tracker, are also places where to ask general questions about +the framework. + +Before submitting a new issue, it is always a good idea to check if the +same bug or enhancement is already reported. If it is, please add your comments to the existing issue instead of creating a new one. Reporting bugs ~~~~~~~~~~~~~~ -Explain the bug you have encountered so that others can understand it -and preferably also reproduce it. Key things to have in good bug report: +Explain the bug you have encountered so that others can understand it and +preferably also reproduce it. Key things to include in good bug report: 1. Version information - Robot Framework version - - Python interpreter type (Python, Jython, IronPython, PyPy) and version + - Python interpreter version - Operating system and its version + Typically including the output of ``robot --version`` is enough. + 2. Steps to reproduce the problem. With more complex problems it is often a good idea to create a `short, self contained, correct example (SSCCE) `_. @@ -64,9 +66,11 @@ Enhancement requests Describe the new feature and use cases for it in as much detail as possible. Especially with larger enhancements, be prepared to contribute the code -in form of a pull request as explained below or to pay someone for the work. -Consider also would it be better to implement this functionality as a separate -tool outside the core framework. +in the form of a pull request as explained below. If you would like to sponsor +a development of a certain feature, you can contact the `Robot Framework +Foundation `_. +Consider also would it be better to implement new functionality as a separate +library or tool outside the core framework. Code contributions ------------------ @@ -83,7 +87,7 @@ Often you already have a bug or an enhancement you want to work on in your mind, but you can also look at the `issue tracker`_ to find bugs and enhancements submitted by others. The issues vary significantly in complexity and difficulty, so you can try to find something that matches your skill level -and knowledge. There are two specific labels to look when looking for +and knowledge. There are two specific labels to look for when looking for something to contribute: `good first issue`__ @@ -101,9 +105,9 @@ Pull requests ~~~~~~~~~~~~~ On GitHub pull requests are the main mechanism to contribute code. They -are easy to use both for the contributor and for person accepting +are easy to use both for the contributor and for the person accepting the contribution, and with more complex contributions it is easy also -for others to join the discussion. Preconditions for creating a pull +for others to join the discussion. Preconditions for creating pull requests are having a `GitHub account `_, installing `Git `_ and forking the `Robot Framework project`_. @@ -117,39 +121,283 @@ create dedicated topic branches for pull requests instead of creating them based on the master branch. This is especially important if you plan to work on multiple pull requests at the same time. +Development dependencies +~~~~~~~~~~~~~~~~~~~~~~~~ + +Code formatting and other tasks require external tools to be installed. All +of them are listed in the ``_ file and you can install +them by running:: + + pip install -r requirements-dev.txt + Coding conventions ~~~~~~~~~~~~~~~~~~ -General guidelines -'''''''''''''''''' +Robot Framework follows the general Python code conventions defined in `PEP-8 +`_. Code is `automatically formatted`__, but +`manual adjustments`__ may sometimes be needed. -Robot Framework uses the general Python code conventions defined in `PEP-8 -`_. In addition to that, we try -to write `idiomatic Python -`_ -and follow the `SOLID principles -`_ with all -new code. An important guideline is that the code should be clear enough that -comments are generally not needed. +__ `Automatic formatting`_ +__ `Manual formatting adjustments`_ -All code, including test code, must be compatible with all supported Python -interpreters and versions. Most importantly this means that the code must -support both Python 2 and Python 3. +Automatic formatting +'''''''''''''''''''' -Whitespace -'''''''''' +The code is automatically linted and formatted using a combination of tools +that are driven by an `Invoke `_ task:: + + invoke format + +Make sure to run this command before creating a pull request! + +By default the task formats Python code under ``src``, ``atest`` and ``utest`` +directories, but it can be configured to format only certain directories +or files:: + + invoke format -t src -We are pretty picky about using whitespace. We follow `PEP-8`_ in how to use -blank lines and whitespace in general, but we also have some stricter rules: +Formatting is done in multiple phases: -- No blank lines inside functions. -- Indentation using spaces, not tabs. -- No trailing spaces. -- No extra empty lines at the end of the file. -- Files must end with a newline. + 1. Code is linted using `Ruff `_ . If linting + fails, the formatting process is stopped. + 2. Code is formatted code using `Black `_. + We plan to switch to Ruff as soon as they stop removing the + `empty row after the class declaration`__. + 3. Multiline imports are reformatted using `isort `_. + We use the "`hanging grid grouped`__" style to use less vertical space compared + to having each imported item on its own row. Public APIs using `redundant import + aliases`__ are not reformatted, though. -Most of these rules are such that any decent text editor or IDE can be -configured to automatically format files according to them. +Tool configurations are in the ``_ file. + +__ https://github.com/astral-sh/ruff/issues/9745 +__ https://pycqa.github.io/isort/docs/configuration/multi_line_output_modes.html#5-hanging-grid-grouped +__ https://typing.python.org/en/latest/spec/distributing.html#import-conventions + +Manual formatting adjustments +''''''''''''''''''''''''''''' + +Automatic formatting works pretty well, but there are some cases where the results +are suboptimal and manual adjustments are needed. + +.. note:: As a contributor, you do not need to care about this if you do not want to. + Maintainers can fix these issues themselves after merging your pull request. + Just running the aforementioned ``invoke format`` is enough. + +Force lists to have one item per row +```````````````````````````````````` + +Automatic formatting has three modes how to handle lists: + +- Short lists are formatted on a single row. This includes list items and opening + and closing braces and other markers. +- If all list items fit into a single row, but the whole list with opening and + closing markers does not, items are placed into a single row and opening and + closing markers are on their own rows. +- Long lists are formatted so that all list items are own their own rows and + opening and closing markers are on their own rows as well. + +In addition to lists and other containers, the above applies also to function +calls and function signatures: + +.. sourcecode:: python + + def short(first_arg: Iterable[int], second_arg: int = 0) -> int: + ... + + def medium( + first_arg: Iterable[float], second_arg: float = 0.0, third_arg: bool = True + ) -> int: + ... + + def long( + first_arg: Iterable[float], + second_arg: float = 0.0, + third_arg: bool = True, + fourth_arg: bool = False, + ) -> int: + ... + +This formatting is typically fine, but similar code being formatted differently +in a single file can look inconsistent. Having multiple items in a single row, as in +the ``medium`` example above, can also make the code hard to read. A simple fix +is forcing list items to own rows by adding a `magic trailing comma`__ and running +auto-formatter again: + +.. sourcecode:: python + + def short(first_arg: Iterable[int], second_arg: int = 0) -> int: + ... + + def medium( + first_arg: Iterable[float], + second_arg: float = 0.0, + third_arg: bool = True, + ) -> int: + ... + + def long( + first_arg: Iterable[float], + second_arg: float = 0.0, + third_arg: bool = True, + fourth_arg: bool = False, + ) -> int: + ... + +Lists and signatures fitting into a single line, such as the ``short`` example above, +should typically not be forced to multiple lines. + +__ https://black.readthedocs.io/en/stable/the_black_code_style/current_style.html#the-magic-trailing-comma + +Force multi-line lists to have multiple items per row +````````````````````````````````````````````````````` + +Automatically formatting all list items into own rows uses a lot of vertical space. +This is typically not a problem, but with long lists having simple items it can +be somewhat annoying: + +.. sourcecode:: python + + class Branches( + BaseBranches[ + 'Keyword', + 'For', + 'While', + 'Group', + 'If', + 'Try', + 'Var', + 'Return', + 'Continue', + 'Break', + 'Message', + 'Error', + IT, + ] + ): + __slots__ = () + + + 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", + } + +The best way to fix this is disabling formatting altogether with the ``# fmt: skip`` +pragma. The code should be formatted so that opening and closing list markers +are on their own rows, list items are wrapped, and the ``# fmt: skip`` pragma +is placed after the closing list marker: + +.. sourcecode:: python + + class Branches(BaseBranches[ + "Keyword", "For", "While", "Group", "If", "Try", "Var", "Return", "Continue", + "Break", "Message", "Error", IT, + ]): # fmt: skip + __slots__ = () + + + 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", + } # fmt: skip + +Handle Boolean expressions +`````````````````````````` + +Autoformatting handles Boolean expressions having two items that do not fit into +a single line *really* strangely: + +.. sourcecode:: python + + ext = getattr(self.parser, 'EXTENSION', None) or getattr( + self.parser, 'extension', None + ) + + return self._get_runner_from_resource_files( + name + ) or self._get_runner_from_libraries(name) + +Expressions having three or more items would be grouped with parentheses and +`there is an issue`__ about doing that also if there are two items. A workaround +is using parentheses and disabling formatting with the ``# fmt: skip`` pragma: + +.. sourcecode:: python + + ext = ( + getattr(self.parser, 'EXTENSION', None) + or getattr(self.parser, 'extension', None) + ) # fmt: skip + + return ( + self._get_runner_from_resource_files(name) + or self._get_runner_from_libraries(name) + ) # fmt: skip + +__ https://github.com/psf/black/issues/2156 + +Inline comment handling +``````````````````````` + +Autoformatting normalizes the number of spaces before an inline comment into two. +That is typically fine, but if subsequent lines use inline comments, the result +can be suboptimal__: + +.. sourcecode:: python + + TypeHint = Union[ + type, # Actual type. + str, # Type name or alias. + UnionType, # Union syntax (e.g. `int | float`). + 'tuple[TypeHint, ...]', # Tuple of type hints. Behaves like a union. + ] + +A solution is manually aligning comments and disabling autoformatting: + +.. sourcecode:: python + + TypeHint = Union[ + type, # Actual type. + str, # Type name or alias. + UnionType, # Union syntax (e.g. `int | float`). + "tuple[TypeHint, ...]" # Tuple of type hints. Behaves like a union. + ] # fmt: skip + +In the above example formatting is disabled with the ``# fmt: skip`` pragma, but +it does not work if inline comments are not related to a single statement. In such +cases the ``# fmt: off`` and ``# fmt: on`` pair can be used instead. In this example +formatting is disabled to allow aligning constant values in addition to comments: + +.. sourcecode:: python + + # fmt: off + INFO_PRINTED = 251 # --help or --version + DATA_ERROR = 252 # Invalid data or cli args + STOPPED_BY_USER = 253 # KeyboardInterrupt or SystemExit + FRAMEWORK_ERROR = 255 # Unexpected error + # fmt: on + +__ https://github.com/psf/black/issues/4651 Docstrings '''''''''' @@ -160,6 +408,27 @@ internal code. When docstrings are added, they should follow `PEP-257 section below for more details about documentation syntax, generating API docs, etc. +Type hints +'''''''''' + +All public APIs must have type hints and adding type hints also to new internal +code is recommended. Full type coverage is not a goal at the moment, though. + +Type hints should follow the Python `Typing Best Practices +`_ with the +following exceptions: + +- Annotation features are restricted to the minimum Python version supported by + Robot Framework. +- Annotations should use the stringified format for annotations not supported + by the minimum supported Python version. For example, ``"int | float"`` + instead of ``Union[int, float]`` and ``"list[int]"`` instead of ``List[int]``. + Type aliases are an exception to this rule. +- Keywords accepting either an integer or a float should typically be annotated as + ``int | float`` instead of just ``float``. This way argument conversion tries to + first convert arguments to an integer and only converts to a float if that fails. +- No ``-> None`` annotation on functions that do not explicitly return anything. + Documentation ~~~~~~~~~~~~~ @@ -172,7 +441,7 @@ User Guide Robot Framework's features are explained in the `User Guide `_. It is generated using a custom script based on the source in `reStructuredText -`_ format. For more details about +`_ format. For more details about editing and generating it see ``_. Libraries @@ -187,6 +456,10 @@ tool. Documentation must use Robot Framework's own `documentation formatting `_ and follow these guidelines: +- All new enhancements or changes should have a note telling when the change + was introduced. Often adding something like ``New in Robot Framework 7.3.`` + is enough. + - Other keywords and sections in the library introduction can be referenced with internal links created with backticks like ```Example Keyword```. @@ -196,25 +469,19 @@ and follow these guidelines: - Examples are recommended whenever the new keyword or enhanced functionality is not trivial. -- All new enhancements or changes should have a note telling when the change - was introduced. Often adding something like ``New in Robot Framework 3.1.`` - is enough. - -Library documentation can be generated using `Invoke `_ -by running command +Library documentation can be generated using Invoke_ by running command :: invoke library-docs -where ```` if a name of the library or its unique prefix. Run +where ```` is the name of the library or its unique prefix. Run :: invoke --help library-docs -for more information and see ``_ for details about installing and -using Invoke. +for more information. API documentation ''''''''''''''''' @@ -231,41 +498,30 @@ Documentation can be created locally using ``_ script that unfortunately creates a lot of errors on the console. Releases API docs are visible at https://robot-framework.readthedocs.org/. -Robot Framework's public API docs are lacking in many ways. All public -classes are not yet documented, existing documentation is somewhat scarce, -and there could be more examples. Documentation improvements are highly -appreciated! - Tests ~~~~~ When submitting a pull request with a new feature or a fix, you should always include tests for your changes. These tests prove that your changes work, help prevent bugs in the future, and help document what your changes -do. Depending an the change, you may need acceptance tests, unit tests +do. Depending on the change, you may need acceptance tests, unit tests or both. Make sure to run all of the tests before submitting a pull request to be sure that your changes do not break anything. If you can, test in multiple -environments and interpreters (Windows, Linux, OS X, Python, Jython, -IronPython, Python 3, etc). Pull requests are also automatically tested on -continuous integration. +environments and interpreters (Windows, Linux, OS X, different Python +versions etc). Pull requests are also automatically tested by GitHub Actions. Executing changed code '''''''''''''''''''''' If you want to manually verify the changes, an easy approach is directly running the ``_ script that is part of Robot Framework -itself. Alternatively you can use the ``_ script that sets +itself. Alternatively, you can use the ``_ script that sets some command line options and environment variables to ease executing tests under the ``_ directory. It also automatically creates a ``tmp`` directory in the project root and writes all outputs there. -If you want to install the current code locally, you can do it like -``python setup.py install`` as explained in ``_. For -instructions how to create a distribution that allows installing elsewhere -see ``_. - Acceptance tests '''''''''''''''' @@ -279,39 +535,3 @@ Unit tests Unit tests are great for testing internal logic and should be added when appropriate. For more details see ``_. - -Finalizing pull requests -~~~~~~~~~~~~~~~~~~~~~~~~ - -Once you have code, documentation and tests ready, it is time to finalize -the pull request. - -AUTHORS.txt -''''''''''' - -If you have done any non-trivial change and would like to be credited, -add yourself to ``_ file. - -Resolving conflicts -''''''''''''''''''' - -Conflicts can occur if there are new changes to the master that touch the -same code as your changes. In that case you should `sync your fork -`_ and `resolve conflicts -`_ -to allow for an easy merge. - -The most common conflicting file is the aforementioned `AUTHORS.txt`_, but -luckily fixing those conflicts is typically easy. - -Squashing commits -''''''''''''''''' - -If the pull request contains multiple commits, you may want to `squash them -into a single commit`__ before the pull request is merged. This is a good idea -especially if the pull request contains lots of temporary commits and changes -that have been later reverted or redone. Nowadays GitHub makes it easy to -squash commits when the pull request is merged, so squashing them beforehand -is not that important. - -__ http://eli.thegreenplace.net/2014/02/19/squashing-github-pull-requests-into-a-single-commit diff --git a/INSTALL.rst b/INSTALL.rst index 1627e271d57..739b68e4002 100644 --- a/INSTALL.rst +++ b/INSTALL.rst @@ -1,9 +1,10 @@ Installation instructions ========================= -These instructions cover installing and uninstalling Robot Framework and its -preconditions on different operating systems. If you already have `pip -`_ installed, it is enough to run:: +These instructions cover installing `Robot Framework `_ +and its preconditions on different operating systems. If you already have +`Python `_ installed, you can install Robot Framework using +the standard package manager `pip `_:: pip install robotframework @@ -12,415 +13,238 @@ preconditions on different operating systems. If you already have `pip :local: .. START USER GUIDE IGNORE -.. These instructions are included also in the User Guide. Following role -.. and link definitions are excluded when UG is built. +.. Installation instructions are included also in the User Guide. +.. Following content is excluded when the UG is built. .. default-role:: code .. role:: file(emphasis) .. role:: option(code) -.. _supporting tools: http://robotframework.org/robotframework/#built-in-tools -.. _post-process outputs: `supporting tools`_ .. END USER GUIDE IGNORE -Introduction ------------- +Python installation +------------------- -`Robot Framework `_ is implemented with `Python -`_ and supports also `Jython `_ (JVM), -`IronPython `_ (.NET) and `PyPy `_. -Before installing the framework, an obvious precondition_ is installing at -least one of these interpreters. - -Different ways to install Robot Framework itself are listed below and explained -more thoroughly in the subsequent sections. - -`Installing with pip`_ - Using pip_ is the recommended way to install Robot Framework. As the - standard Python package manager it is included in the latest Python, - Jython and IronPython versions. If you already have pip available, you - can simply execute:: - - pip install robotframework - -`Installing from source`_ - This approach works regardless the operating system and the Python - interpreter used. You can get the source code either by downloading a - source distribution from `PyPI `_ - and extracting it, or by cloning the - `GitHub repository `_ . - -`Standalone JAR distribution`_ - If running tests with Jython is enough, the easiest approach is downloading - the standalone ``robotframework-.jar`` from `Maven central - `_. - The JAR distribution contains both Jython and Robot Framework and thus - only requires having `Java `_ installed. - -`Manual installation`_ - If you have special needs and nothing else works, you can always do - a custom manual installation. - -.. note:: Prior to Robot Framework 3.0, there were also separate Windows - installers for 32bit and 64bit Python versions. Because Python 2.7.9 and - newer contain pip_ on Windows and Python 3 would have needed two - more installers, it was decided that `Windows installers are not - created anymore`__. The recommend installation approach also on - Windows is `using pip`_. - -__ https://github.com/robotframework/robotframework/issues/2218 - -Preconditions -------------- - -Robot Framework is supported on Python_ (both Python 2 and Python 3), Jython_ -(JVM) and IronPython_ (.NET) and PyPy_. The interpreter you want to use should -be installed before installing the framework itself. - -Which interpreter to use depends on the needed test libraries and test -environment in general. Some libraries use tools or modules that only work -with Python, while others may use Java tools that require Jython or need -.NET and thus IronPython. There are also many tools and libraries that run -fine with all interpreters. - -If you do not have special needs or just want to try out the framework, -it is recommended to use Python. It is the most mature implementation, -considerably faster than Jython or IronPython (especially start-up time is -faster), and also readily available on most UNIX-like operating systems. -Another good alternative is using the `standalone JAR distribution`_ that -only has Java as a precondition. - -Python 2 vs Python 3 -~~~~~~~~~~~~~~~~~~~~ - -Python 2 and Python 3 are mostly the same language, but they are not fully -compatible with each others. The main difference is that in Python 3 all -strings are Unicode while in Python 2 strings are bytes by default, but there -are also several other backwards incompatible changes. The last Python 2 -release is Python 2.7 that was released in 2010 and will be supported until -2020. See `Should I use Python 2 or 3?`__ for more information about the -differences, which version to use, how to write code that works with both -versions, and so on. - -Robot Framework 3.0 is the first Robot Framework version to support Python 3. -It supports also Python 2, and the plan is to continue Python 2 support as -long as Python 2 itself is officially supported. We hope that authors of the -libraries and tools in the wider Robot Framework ecosystem also start looking -at Python 3 support now that the core framework supports it. - -__ https://wiki.python.org/moin/Python2orPython3 +`Robot Framework`_ is implemented using Python_, and a precondition to install it +is having Python or its alternative implementation `PyPy `_ +installed. Another recommended precondition is having the pip_ package manager +available. -Python installation -~~~~~~~~~~~~~~~~~~~ +Robot Framework requires Python 3.8 or newer. The latest version that supports +Python 3.6 and 3.7 is `Robot Framework 6.1.1`__. If you need to use Python 2, +`Jython `_ or `IronPython `_, +you can use `Robot Framework 4.1.3`__. -On most UNIX-like systems such as Linux and OS X you have Python_ installed -by default. If you are on Windows or otherwise need to install Python yourself, -a good place to start is http://python.org. There you can download a suitable -installer and get more information about the installation process and Python -in general. +__ https://github.com/robotframework/robotframework/blob/v6.1.1/INSTALL.rst +__ https://github.com/robotframework/robotframework/blob/v4.1.3/INSTALL.rst -Robot Framework 3.0 supports Python 2.6, 2.7, 3.3 and newer, but the plan is -to `drop Python 2.6 and 3.3 support in RF 3.1`__. If you need to use older -versions, Robot Framework 2.5-2.8 support Python 2.5 and Robot Framework -2.0-2.1 support Python 2.3 and 2.4. +Installing Python on Linux +~~~~~~~~~~~~~~~~~~~~~~~~~~ -After installing Python, you probably still want to configure PATH_ to make -Python itself as well as the ``robot`` and ``rebot`` `runner scripts`_ -executable on the command line. +On Linux you should have suitable Python installation with pip_ available +by default. If not, you need to consult your distributions documentation +to learn how to install them. This is also true if you want to use some other +Python version than the one provided by your distribution by default. -.. tip:: Latest Python Windows installers allow setting ``PATH`` as part of - the installation. This is disabled by default, but `Add python.exe - to Path` can be enabled on the `Customize Python` screen. +To check what Python version you have installed, you can run `python --version` +command in a terminal: -__ https://github.com/robotframework/robotframework/issues/2276 +.. code:: bash -Jython installation -~~~~~~~~~~~~~~~~~~~ + $ python --version + Python 3.10.13 -Using test libraries implemented with Java_ or that use Java tools internally -requires running Robot Framework on Jython_, which in turn requires Java -Runtime Environment (JRE) or Java Development Kit (JDK). Installing either -of these Java distributions is out of the scope of these instructions, but -you can find more information, for example, from http://java.com. +Notice that if your distribution provides also older Python 2, running `python` +may use that. To use Python 3, you can use `python3` command or even more version +specific command like `python3.8`. You need to use these version specific variants +also if you have multiple Python 3 versions installed and need to pinpoint which +one to use: -Installing Jython is a fairly easy procedure, and the first step is getting -an installer from http://jython.org. The installer is an executable JAR -package, which you can run from the command line like `java -jar -jython_installer-.jar`. Depending on the system configuration, -it may also be possible to just double-click the installer. +.. code:: bash -Robot Framework 3.0 supports Jython 2.7 which requires Java 7 or newer. -If older Jython or Java versions are needed, Robot Framework 2.5-2.8 support -Jython 2.5 (requires Java 5 or newer) and Robot Framework 2.0-2.1 support -Jython 2.2. + $ python3.11 --version + Python 3.11.7 + $ python3.12 --version + Python 3.12.1 -After installing Jython, you probably still want to configure PATH_ to make -Jython itself as well as the ``robot`` and ``rebot`` `runner scripts`_ -executable on the command line. +Installing Robot Framework directly under the system provided Python +has a risk that possible problems can affect the whole Python installation +used also by the operating system itself. Nowadays +Linux distributions typically use `user installs`__ by default to avoid such +problems, but users can also themselves decide to use `virtual environments`_. -IronPython installation -~~~~~~~~~~~~~~~~~~~~~~~ +__ https://pip.pypa.io/en/stable/user_guide/#user-installs -IronPython_ allows running Robot Framework on the `.NET platform -`__ and interacting with C# and other .NET -languages and APIs. Only IronPython 2.7 is supported in general and -IronPython 2.7.9 or newer is highly recommended. +Installing Python on Windows +~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -If not using IronPython 2.7.9 or newer and Robot Framework 3.1 or newer, -an additional requirement is installing -`ElementTree `__ -module 1.2.7 preview release. This is required because the ElementTree -module distributed with older IronPython versions was broken. Once you -have `pip activated for IronPython`__, you can easily install ElementTree -using this command: +On Windows Python is not available by default, but it is easy to install. +The recommended way to install it is using the official Windows installers available +at http://python.org. For other alternatives, such as installing from the +Microsoft Store, see the `official Python documentation`__. -.. sourcecode:: bash +When installing Python on Windows, it is recommended to add Python to PATH_ +to make it and tools like pip and Robot Framework easier to execute from +the command line. When using the `official installer`__, you just need +to select the `Add Python 3.x to PATH` checkbox on the first dialog. - ipy -m pip install http://effbot.org/media/downloads/elementtree-1.2.7-20070827-preview.zip +To make sure Python installation has been successful and Python has been +added to `PATH`, you can open the command prompt and execute `python --version`: -Alternatively you can download the zip package, extract it, and install it by -running ``ipy setup.py install`` on the command prompt in the created directory. +.. code:: batch -After installing IronPython, you probably still want to configure PATH_ to make -IronPython itself as well as the ``robot`` and ``rebot`` `runner scripts`_ -executable on the command line. + C:\>python --version + Python 3.10.9 -__ `Installing pip for IronPython`_ +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`__: -PyPy installation -~~~~~~~~~~~~~~~~~ +.. code:: batch -PyPy_ is an alternative implementation of the Python language with both Python 2 -and Python 3 compatible versions available. Its main advantage over the -standard Python implementation is that it can be faster and use less memory, -but this depends on the context where and how it is used. If execution speed -is important, at least testing PyPY is probably a good idea. + C:\>py --version + Python 3.10.9 + C:\>py -3.12 --version + Python 3.12.1 -Installing PyPy is a straightforward procedure and you can find both installers -and installation instructions at http://pypy.org. After installation you -probably still want to configure PATH_ to make PyPy itself as well as the -``robot`` and ``rebot`` `runner scripts`_ executable on the command line. - -Configuring ``PATH`` -~~~~~~~~~~~~~~~~~~~~ - -The ``PATH`` environment variable lists locations where commands executed in -a system are searched from. To make using Robot Framework easier from the -command prompt, it is recommended to add the locations where the `runner -scripts`_ are installed into the ``PATH``. It is also often useful to have -the interpreter itself in the ``PATH`` to make executing it easy. - -When using Python on UNIX-like machines both Python itself and scripts -installed with should be automatically in the ``PATH`` and no extra actions -needed. On Windows and with other interpreters the ``PATH`` must be configured -separately. - -.. tip:: Latest Python Windows installers allow setting ``PATH`` as part of - the installation. This is disabled by default, but `Add python.exe - to Path` can be enabled on the `Customize Python` screen. It will - add both the Python installation directory and the :file:`Scripts` - directory to the ``PATH``. - -What directories to add to ``PATH`` -''''''''''''''''''''''''''''''''''' - -What directories you need to add to the ``PATH`` depends on the interpreter and -the operating system. The first location is the installation directory of -the interpreter (e.g. :file:`C:\\Python27`) and the other is the location -where scripts are installed with that interpreter. Both Python and IronPython -install scripts to :file:`Scripts` directory under the installation directory -on Windows (e.g. :file:`C:\\Python27\\Scripts`) and Jython uses :file:`bin` -directory regardless the operating system (e.g. :file:`C:\\jython2.7.0\\bin`). - -Notice that the :file:`Scripts` and :file:`bin` directories may not be created -as part of the interpreter installation, but only later when Robot Framework -or some other third party module is installed. - -Setting ``PATH`` on Windows -''''''''''''''''''''''''''' - -On Windows you can configure ``PATH`` by following the steps below. Notice -that the exact setting names may be different on different Windows versions, -but the basic approach should still be the same. - -1. Open `Control Panel > System > Advanced > Environment Variables`. There - are `User variables` and `System variables`, and the difference between - them is that user variables affect only the current users, whereas system - variables affect all users. - -2. To edit an existing ``PATH`` value, select `Edit` and add - `;;` at the end of the value (e.g. - `;C:\Python27;C:\Python27\Scripts`). Note that the semicolons (`;`) are - important as they separate the different entries. To add a new ``PATH`` - value, select `New` and set both the name and the value, this time without - the leading semicolon. - -3. Exit the dialog with `Ok` to save the changes. - -4. Start a new command prompt for the changes to take effect. - -Notice that if you have multiple Python versions installed, the executed -``robot`` or ``rebot`` `runner script`_ will always use the one that is -*first* in the ``PATH`` regardless under what Python version that script is -installed. To avoid that, you can always execute the `installed robot module -directly`__ like `C:\Python27\python.exe -m robot`. - -Notice also that you should not add quotes around directories you add into -the ``PATH`` (e.g. `"C:\Python27\Scripts"`). Quotes `can cause problems with -Python programs `_ and they are not needed -in this context even if the directory path would contain spaces. - -__ `Executing installed robot module`_ - -Setting ``PATH`` on UNIX-like systems -''''''''''''''''''''''''''''''''''''' - -On UNIX-like systems you typically need to edit either some system wide or user -specific configuration file. Which file to edit and how depends on the system, -and you need to consult your operating system documentation for more details. - -Setting ``https_proxy`` -~~~~~~~~~~~~~~~~~~~~~~~ - -If you are `installing with pip`_ and are behind a proxy, you need to set -the ``https_proxy`` environment variable. It is needed both when installing -pip itself and when using it to install Robot Framework and other Python -packages. - -How to set the ``https_proxy`` depends on the operating system similarly as -`configuring PATH`_. The value of this variable must be an URL of the proxy, -for example, `http://10.0.0.42:8080`. - -Installing with pip -------------------- +__ https://docs.python.org/3/using/windows.html +__ https://docs.python.org/3/using/windows.html#windows-full +__ https://docs.python.org/3/using/windows.html#launcher -The standard Python package manager is pip_, but there are also other -alternatives such as `Buildout `__ and `easy_install -`__. These instructions -only cover using pip, but other package managers ought be able to install -Robot Framework as well. +Installing Python on macOS +~~~~~~~~~~~~~~~~~~~~~~~~~~ -Latest Python, Jython, IronPython and PyPy versions contain pip bundled in. -Which versions contain it and how to possibly activate it is discussed in -sections below. See pip_ project pages if for the latest installation -instructions if you need to install it. +MacOS does not provide Python 3 compatible Python version by default, so it +needs to be installed separately. The recommended approach is using the official +macOS installers available at http://python.org. If you are using a package +manager like `Homebrew `_, installing Python via it is +possible as well. -.. note:: Robot Framework 3.1 and newer are distributed as `wheels - `_, but earlier versions are available only - as source distributions in tar.gz format. It is possible to install - both using pip, but installing wheels is a lot faster. +You can validate Python installation on macOS using `python --version` like on +other operating systems. -.. note:: Only Robot Framework 2.7 and newer can be installed using pip. If you - need an older version, you must use other installation approaches. +PyPy installation +~~~~~~~~~~~~~~~~~ -Installing pip for Python -~~~~~~~~~~~~~~~~~~~~~~~~~ +PyPy_ is an alternative Python implementation. Its main advantage over the +standard Python implementation is that it can be faster and use less memory, +but this depends on the context where and how it is used. If execution speed +is important, at least testing PyPy is probably a good idea. + +Installing PyPy is a straightforward procedure and you can find both installers +and installation instructions at http://pypy.org. To validate that PyPy installation +was successful, run `pypy --version` or `pypy3 --version`. -Starting from Python 2.7.9, the standard Windows installer by default installs -and activates pip. Assuming you also have configured PATH_ and possibly -set https_proxy_, you can run `pip install robotframework` right after -Python installation. With Python 3.4 and newer pip is officially part of the -interpreter and should be automatically available. +.. note:: Using Robot Framework with PyPy is officially supported only on Linux. -Outside Windows and with older Python versions you need to install pip yourself. -You may be able to do it using system package managers like Apt or Yum on Linux, -but you can always use the manual installation instructions found from the pip_ -project pages. +Configuring `PATH` +~~~~~~~~~~~~~~~~~~ -If you have multiple Python versions with pip installed, the version that is -used when the ``pip`` command is executed depends on which pip is first in the -PATH_. An alternative is executing the ``pip`` module using the selected Python -version directly: +The `PATH environment variable`__ lists directories where commands executed in +a system are searched from. To make using Python, pip_ and Robot Framework easier +from the command line, it is recommended to add the Python installation directory +as well as the directory where commands like `pip` and `robot` are installed +into `PATH`. -.. sourcecode:: bash +__ https://en.wikipedia.org/wiki/PATH_(variable) - python -m pip install robotframework - python3 -m pip install robotframework +When using Python on Linux or macOS, Python and tools installed with it should be +automatically in `PATH`. If you nevertheless need to update `PATH`, you +typically need to edit some system wide or user specific configuration file. +Which file to edit and how depends on the operating system and you need to +consult its documentation for more details. -Installing pip for Jython -~~~~~~~~~~~~~~~~~~~~~~~~~ +On Windows the easiest way to make sure `PATH` is configured correctly is +setting the `Add Python 3.x to PATH` checkbox when `running the installer`__. +To manually modify `PATH` on Windows, follow these steps: -Jython 2.7 contain pip bundled in, but it needs to be activated before using it -by running the following command: +1. Find `Environment Variables` under `Settings`. There are variables affecting + the whole system and variables affecting only the current user. Modifying + the former will require admin rights, but modifying the latter is typically + enough. -.. sourcecode:: bash +2. Select `PATH` (often written like `Path`) and click `Edit`. If you are + editing user variables and `PATH` does not exist, click `New` instead. - jython -m ensurepip +3. Add both the Python installation directory and the :file:`Scripts` directory + under the installation directory into `PATH`. -Jython installs its pip into :file:`/bin` directory. -Does running `pip install robotframework` actually use it or possibly some -other pip version depends on which pip is first in the PATH_. An alternative -is executing the ``pip`` module using Jython directly: +4. Exit the dialog with `Ok` to save the changes. -.. sourcecode:: bash +5. Start a new command prompt for the changes to take effect. - jython -m pip install robotframework +__ https://docs.python.org/3/using/windows.html#the-full-installer -Installing pip for IronPython -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Installing using pip +-------------------- -IronPython 2.7.5 and newer contain pip bundled in. With IronPython 2.7.9 and -newer pip also works out-of-the-box, but with earlier versions it needs to be -activated with `ipy -m ensurepip` similarly as with Jython. +These instructions cover installing Robot Framework using pip_, the standard +Python package manager. If you are using some other package manager like +`Conda `_, you can use it instead but need to study its +documentation for instructions. -With IronPython 2.7.7 and earlier you need to use `-X:Frames` command line -option when activating pip like `ipy -X:Frames -m ensurepip` and also -when using it. Prior to IronPython 2.7.9 there were problems creating -possible start-up scripts when installing modules. Using IronPython 2.7.9 -is highly recommended. +When installing Python, you typically get pip installed automatically. If +that is not the case, you need to check the documentation of that Python +installation for instructions how to install it separately. -IronPython installs pip into :file:`/Scripts` directory. -Does running `pip install robotframework` actually use it or possibly some -other pip version depends on which pip is first in the PATH_. An alternative -is executing the ``pip`` module using IronPython directly: +Running `pip` command +~~~~~~~~~~~~~~~~~~~~~ -.. sourcecode:: bash +Typically you use pip by running the `pip` command, but on Linux you may need +to use `pip3` or even more Python version specific variant like `pip3.8` +instead. When running `pip` or any of its variants, the pip version that is +found first in PATH_ will be used. If you have multiple Python versions +installed, you may need to pinpoint which exact version you want to use. +This is typically easiest done by running `python -m pip` and substituting +`python` with the Python version you want to use. - ipy -m pip install robotframework +To make sure you have pip available, you can run `pip --version` or equivalent. -Installing pip for PyPy -~~~~~~~~~~~~~~~~~~~~~~~ +Examples on Linux: -Also PyPy contains pip bundled in. It is not activated by default, but it can -be activated similarly as with the other interpreters: +.. code:: bash -.. sourcecode:: bash + $ pip --version + pip 23.2.1 from ... (python 3.10) + $ python3.12 -m pip --version + pip 23.3.1 from ... (python 3.12) - pypy -m ensurepip - pypy3 -m ensurepip +Examples on Windows: -If you have multiple Python versions with pip installed, the version that is -used when the ``pip`` command is executed depends on which pip is first in the -PATH_. An alternative is executing the ``pip`` module using PyPy directly: +.. code:: batch -.. sourcecode:: bash + C:\> pip --version + pip 23.2.1 from ... (python 3.10) + C:\> py -m 3.12 -m pip --version + pip 23.3.2 from ... (python 3.12) - pypy -m pip - pypy3 -m pip +In the subsequent sections pip is always run using the `pip` command. You may +need to use some of the other approaches explained above in your environment. -Using pip -~~~~~~~~~ +Installing and uninstalling Robot Framework +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -Once you have pip_ installed, and have set https_proxy_ if you are behind -a proxy, using pip on the command line is very easy. The easiest way to use -pip is by letting it find and download packages it installs from the -`Python Package Index (PyPI)`__, but it can also install packages -downloaded from the PyPI separately. The most common usages are shown below -and pip_ documentation has more information and examples. +The easiest way to use pip is by letting it find and download packages it +installs from the `Python Package Index (PyPI)`__, but it can also install +packages downloaded from the PyPI separately. The most common usages are +shown below and pip_ documentation has more information and examples. __ PyPI_ -.. sourcecode:: bash +.. code:: bash # Install the latest version (does not upgrade) pip install robotframework - # Upgrade to the latest version + # Upgrade to the latest stable version pip install --upgrade robotframework + # Upgrade to the latest version even if it is a pre-release + pip install --upgrade --pre robotframework + # Install a specific version - pip install robotframework==2.9.2 + pip install robotframework==7.0 # Install separately downloaded package (no network connection needed) - pip install robotframework-3.0.tar.gz + pip install robotframework-7.0-py3-none-any.whl # Install latest (possibly unreleased) code directly from GitHub pip install https://github.com/robotframework/robotframework/archive/master.zip @@ -428,310 +252,81 @@ __ PyPI_ # Uninstall pip uninstall robotframework -Notice that pip 1.4 and newer will only install stable releases by default. -If you want to install an alpha, beta or release candidate, you need to either -specify the version explicitly or use the :option:`--pre` option: - -.. sourcecode:: bash - - # Install 3.0 beta 1 - pip install robotframework==3.0b1 - - # Upgrade to the latest version even if it is a pre-release - pip install --pre --upgrade robotframework - -Notice that on Windows pip, by default, does not recreate `robot.bat and -rebot.bat`__ start-up scripts if the same Robot Framework version is installed -multiple times using the same Python version. This mainly causes problems -when `using virtual environments`_, but is something to take into account -also if doing custom installations using pip. A workaround if using the -``--no-cache-dir`` option like ``pip install --no-cache-dir robotframework``. -Alternatively it is possible to ignore the start-up scripts altogether and -just use ``python -m robot`` and ``python -m robot.rebot`` commands instead. - -__ `Executing Robot Framework`_ - Installing from source ---------------------- -This installation method can be used on any operating system with any of the -supported interpreters. Installing *from source* can sound a bit scary, but -the procedure is actually pretty straightforward. - -Getting source code -~~~~~~~~~~~~~~~~~~~ - -You typically get the source code by downloading a *source distribution* from -PyPI_. Starting from Robot Framework 3.1 the source distribution is a zip -package and with earlier versions it is in tar.gz format. Once you have -downloaded the package, you need to extract it somewhere and, as a result, -you get a directory named `robotframework-`. The directory contains -the source code and a ``setup.py`` script needed for installing it. +Another installation alternative is getting Robot Framework source code +and installing it using the provided `setup.py` script. This approach is +recommended only if you do not have pip_ available for some reason. -An alternative approach for getting the source code is cloning project's -`GitHub repository`_ directly. By default you will get the latest code, but -you can easily switch to different released versions or other tags. +You can get the source code by downloading a source distribution package +from PyPI_ and extracting it. An alternative is cloning the GitHub_ +repository and checking out the needed release tag. -Installation -~~~~~~~~~~~~ +Once you have the source code, you can install it with the following command: -Robot Framework is installed from source using Python's standard ``setup.py`` -script. The script is in the directory containing the sources and you can run -it from the command line using any of the supported interpreters: - -.. sourcecode:: bash +.. code:: bash python setup.py install - jython setup.py install - ipy setup.py install - pypy setup.py install -The ``setup.py`` script accepts several arguments allowing, for example, +The `setup.py` script accepts several arguments allowing, for example, installation into a non-default location that does not require administrative rights. It is also used for creating different distribution packages. Run `python setup.py --help` for more details. -Standalone JAR distribution ---------------------------- - -Robot Framework is also distributed as a standalone Java archive that contains -both Jython_ and Robot Framework and only requires Java_ a dependency. It is -an easy way to get everything in one package that requires no installation, -but has a downside that it does not work with the normal Python_ interpreter. - -The package is named ``robotframework-.jar`` and it is available -on the `Maven central`_. After downloading the package, you can execute tests -with it like: - -.. sourcecode:: bash - - java -jar robotframework-3.0.jar mytests.robot - java -jar robotframework-3.0.jar --variable name:value mytests.robot - -If you want to `post-process outputs`_ using Rebot or use other built-in -`supporting tools`_, you need to give the command name ``rebot``, ``libdoc``, -``testdoc`` or ``tidy`` as the first argument to the JAR file: - -.. sourcecode:: bash - - java -jar robotframework-3.0.jar rebot output.xml - java -jar robotframework-3.0.jar libdoc MyLibrary list - -For more information about the different commands, execute the JAR without -arguments. - -In addition to the Python standard library and Robot Framework modules, the -standalone JAR versions starting from 2.9.2 also contain the PyYAML dependency -needed to handle yaml variable files. - -Manual installation -------------------- - -If you do not want to use any automatic way of installing Robot Framework, -you can always install it manually following these steps: - -1. Get the source code. All the code is in a directory (a package in Python) - called :file:`robot`. If you have a `source distribution`_ or a version - control checkout, you can find it from the :file:`src` directory, but you - can also get it from an earlier installation. - -2. Copy the source code where you want to. - -3. Decide `how to run tests`__. - -__ `Executing Robot Framework`_ - Verifying installation ---------------------- -After a successful installation, you should be able to execute the created -`runner scripts`_ with :option:`--version` option and get both Robot Framework -and interpreter versions as a result: +To make sure that the correct Robot Framework version has been installed, run +the following command: -.. sourcecode:: bash +.. code:: bash $ robot --version - Robot Framework 3.0 (Python 2.7.10 on linux2) + Robot Framework 7.0 (Python 3.10.3 on linux) - $ rebot --version - Rebot 3.0 (Python 2.7.10 on linux2) - -If running the runner scripts fails with a message saying that the command is +If running these commands fails with a message saying that the command is not found or recognized, a good first step is double-checking the PATH_ -configuration. If that does not help, it is a good idea to re-read relevant -sections from these instructions before searching help from the Internet or -as asking help on `robotframework-users -`__ mailing list or -elsewhere. - -Where files are installed -~~~~~~~~~~~~~~~~~~~~~~~~~ - -When an automatic installer is used, Robot Framework source code is copied -into a directory containing external Python modules. On UNIX-like operating -systems where Python is pre-installed the location of this directory varies. -If you have installed the interpreter yourself, it is normally -:file:`Lib/site-packages` under the interpreter installation directory, for -example, :file:`C:\\Python27\\Lib\\site-packages`. The actual Robot -Framework code is in a directory named :file:`robot`. - -Robot Framework `runner scripts`_ are created and copied into another -platform-specific location. When using Python on UNIX-like systems, they -normally go to :file:`/usr/bin` or :file:`/usr/local/bin`. On Windows and -with Jython and IronPython, the scripts are typically either in :file:`Scripts` -or :file:`bin` directory under the interpreter installation directory. - -Uninstallation --------------- - -The easiest way to uninstall Robot Framework is using pip_: - -.. sourcecode:: bash - - pip uninstall robotframework - -A nice feature in pip is that it can uninstall packages even if they are -installed from the source. If you do not have pip available or have done -a `manual installation`_ to a custom location, you need to find `where files -are installed`_ and remove them manually. - -If you have set PATH_ or configured the environment otherwise, you need to -undo those changes separately. - -Upgrading ---------- - -If you are using pip_, upgrading to a new version requires either specifying -the version explicitly or using the :option:`--upgrade` option. If upgrading -to a preview release, :option:`--pre` option is needed as well. - -.. sourcecode:: bash - - # Upgrade to the latest stable version. This is the most common method. - pip install --upgrade robotframework - - # Upgrade to the latest version even if it would be a preview release. - pip install --upgrade --pre robotframework - - # Upgrade to the specified version. - pip install robotframework==2.9.2 - -When using pip, it automatically uninstalls previous versions before -installation. If you are `installing from source`_, it should be safe to -just install over an existing installation. If you encounter problems, -uninstallation_ before installation may help. - -When upgrading Robot Framework, there is always a change that the new version -contains backwards incompatible changes affecting existing tests or test -infrastructure. Such changes are very rare in minor versions like 2.8.7 or -2.9.2, but more common in major versions like 2.9 and 3.0. Backwards -incompatible changes and deprecated features are explained in the release -notes, and it is a good idea to study them especially when upgrading to -a new major version. +configuration. -Executing Robot Framework -------------------------- +If you have installed Robot Framework under multiple Python versions, +running `robot` will execute the one first in PATH_. To select explicitly, +you can run `python -m robot` and substitute `python` with the right Python +version. -Using ``robot`` and ``rebot`` scripts -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +.. code:: bash -Starting from Robot Framework 3.0, tests are executed using the ``robot`` -script and results post-processed with the ``rebot`` script: + $ python3.12 -m robot --version + Robot Framework 7.0 (Python 3.12.1 on linux) -.. sourcecode:: bash + C:\>py -3.11 -m robot --version + Robot Framework 7.0 (Python 3.11.7 on win32) - robot tests.robot - rebot output.xml - -Both of these scripts are installed as part of the normal installation and -can be executed directly from the command line if PATH_ is set correctly. -They are implemented using Python except on Windows where they are batch files. - -Older Robot Framework versions do not have the ``robot`` script and the -``rebot`` script is installed only with Python. Instead they have interpreter -specific scripts ``pybot``, ``jybot`` and ``ipybot`` for test execution and -``jyrebot`` and ``ipyrebot`` for post-processing outputs. These scripts still -work, but they will be deprecated and removed in the future. - -Executing installed ``robot`` module -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -An alternative way to run tests is executing the installed ``robot`` module -or its sub module ``robot.run`` directly using Python's `-m command line -option`__. This is especially useful if Robot Framework is used with multiple -Python versions: - -.. sourcecode:: bash - - python -m robot tests.robot - python3 -m robot.run tests.robot - jython -m robot tests.robot - /opt/jython/jython -m robot tests.robot - -The support for ``python -m robot`` approach is a new feature in Robot -Framework 3.0, but the older versions support ``python -m robot.run``. -The latter must also be used with Python 2.6. - -Post-processing outputs using the same approach works too, but the module to -execute is ``robot.rebot``: - -.. sourcecode:: bash - - python -m robot.rebot output.xml - -__ https://docs.python.org/2/using/cmdline.html#cmdoption-m - -Executing installed ``robot`` directory -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -If you know where Robot Framework is installed, you can also execute the -installed :file:`robot` directory or the :file:`run.py` file inside it -directly: - -.. sourcecode:: bash - - python path/to/robot/ tests.robot - jython path/to/robot/run.py tests.robot +Dependencies +------------ -Running the directory is a new feature in Robot Framework 3.0, but the older -versions support running the :file:`robot/run.py` file. +Robot Framework core functionality has no mandatory dependencies other than +Python and its standard library. Some individual features may require optional +dependencies to be installed, though. What dependencies are needed is always +explained in the relevant documentation. -Post-processing outputs using the :file:`robot/rebot.py` file works the same -way too: +Virtual environments +-------------------- -.. sourcecode:: bash +Python `virtual environments`__ allow Python packages to be installed in +an isolated location for a particular system or application, rather than +installing all packages into the same global location. They have +two main use cases: - python path/to/robot/rebot.py output.xml +- Install packages needed by different projects into their own environments. + This avoids conflicts if projects need different versions of same packages. -Executing Robot Framework this way is especially handy if you have done -a `manual installation`_. +- Avoid installing everything under the global Python installation. This is + especially important on Linux where the global Python installation may be + used by the distribution itself and messing it up can cause severe problems. -Using virtual environments --------------------------- +__ https://packaging.python.org/en/latest/guides/installing-using-pip-and-virtual-environments/#creating-a-virtual-environment -Python `virtual environments`__ allow Python packages to be installed in -an isolated location for a particular system or application, rather than -installing all packages into the same global location. Virtual environments -can be created using the virtualenv__ tool or, starting from Python 3.3, -using the standard venv__ module. - -Robot Framework in general works fine with virtual environments. The only -problem is that when `using pip`_ on Windows, ``robot.bat`` and ``rebot.bat`` -scripts are not recreated by default. This means that if Robot Framework is -installed into multiple virtual environments, the ``robot.bat`` and -``rebot.bat`` scripts in the latter ones refer to the Python installation -in the first virtual environment. A workaround is using the ``--no-cache-dir`` -option when installing. Alternatively the start-up scripts can be ignored -and ``python -m robot`` and ``python -m robot.rebot`` commands used instead. - -__ https://packaging.python.org/installing/#creating-virtual-environments -__ https://virtualenv.pypa.io -__ https://docs.python.org/3/library/venv.html - -.. These aliases need an explicit target to work in GitHub -.. _precondition: `Preconditions`_ -.. _PATH: `Configuring PATH`_ -.. _https_proxy: `Setting https_proxy`_ -.. _source distribution: `Getting source code`_ -.. _runner script: `Using robot and rebot scripts`_ -.. _runner scripts: `Using robot and rebot scripts`_ +.. _PATH: `Configuring path`_ +.. _PyPI: https://pypi.org/project/robotframework +.. _GitHub: https://github.com/robotframework/robotframework diff --git a/MANIFEST.in b/MANIFEST.in index 0d05ae2ea14..1bbe8b3fa9c 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,4 +1,3 @@ -include src/bin/*bot src/bin/*.bat include src/robot/htmldata/*/*.html src/robot/htmldata/*/*.js src/robot/htmldata/*/*.css -include AUTHORS.txt BUILD.rst CONTRIBUTING.rst COPYRIGHT.txt INSTALL.rst LICENSE.txt README.rst MANIFEST.in tasks.py +include BUILD.rst CONTRIBUTING.rst COPYRIGHT.txt INSTALL.rst LICENSE.txt README.rst MANIFEST.in tasks.py prune src/robot/htmldata/testdata diff --git a/README.rst b/README.rst index 4e1f3019897..a18d3ee8120 100644 --- a/README.rst +++ b/README.rst @@ -7,32 +7,28 @@ Robot Framework Introduction ------------ -`Robot Framework `_ is a generic open source +`Robot Framework `_ |r| is a generic open source automation framework for acceptance testing, acceptance test driven development (ATDD), and robotic process automation (RPA). It has simple plain -text syntax and it can be extended easily with libraries implemented using -Python or Java. - -Robot Framework is operating system and application independent. The core -framework is implemented using `Python `_, supports both -Python 2 and Python 3, and runs also on `Jython `_ (JVM), -`IronPython `_ (.NET) and `PyPy `_. -The framework has a rich ecosystem around it consisting of various generic -libraries and tools that are developed as separate projects. For more -information about Robot Framework and the ecosystem, see +text syntax and it can be extended easily with generic and custom libraries. + +Robot Framework is operating system and application independent. It is +implemented using `Python `_ which is also the primary +language to extend it. The framework has a rich ecosystem around it consisting +of various generic libraries and tools that are developed as separate projects. +For more information about Robot Framework and the ecosystem, see http://robotframework.org. Robot Framework project is hosted on GitHub_ where you can find source code, -an issue tracker, and some further documentation. See ``__ -if you are interested to contribute. Downloads are hosted on PyPI_, except -for the standalone JAR distribution that is on `Maven central`_. +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 +`_. If you are using the framework +and benefiting from it, consider joining the foundation to help maintaining +the framework and developing it further. .. _GitHub: https://github.com/robotframework/robotframework .. _PyPI: https://pypi.python.org/pypi/robotframework -.. _Maven central: http://search.maven.org/#search%7Cga%7C1%7Ca%3Arobotframework .. image:: https://img.shields.io/pypi/v/robotframework.svg?label=version :target: https://pypi.python.org/pypi/robotframework @@ -45,19 +41,21 @@ Robot Framework development is sponsored by `Robot Framework Foundation Installation ------------ -If you already have Python_ with `pip `_ installed, +If you already have Python_ with `pip `_ installed, you can simply run:: pip install robotframework -Alternatively you can get Robot Framework source code by downloading the source -distribution from PyPI_ and extracting it, or by cloning the project repository -from GitHub_. After that you can install the framework with:: +For more detailed installation instructions, including installing Python, see +``__. - python setup.py install +Robot Framework requires Python 3.8 or newer and runs also on `PyPy `_. +The latest version that supports Python 3.6 and 3.7 is `Robot Framework 6.1.1`__. +If you need to use Python 2, `Jython `_ or +`IronPython `_, you can use `Robot Framework 4.1.3`__. -For more detailed installation instructions, including installing Python, -Jython, IronPython and PyPy or installing from git, see ``__. +__ https://github.com/robotframework/robotframework/tree/v6.1.1#readme +__ https://github.com/robotframework/robotframework/tree/v4.1.3#readme Example ------- @@ -73,7 +71,7 @@ http://robotframework.org. ... ... This test has a workflow that is created using keywords in ... the imported resource file. - Resource resource.robot + Resource login.resource *** Test Cases *** Valid Login @@ -87,17 +85,16 @@ http://robotframework.org. Usage ----- -Starting from Robot Framework 3.0, tests are executed from the command line -using the ``robot`` script or by executing the ``robot`` module directly -like ``python -m robot`` or ``jython -m robot``. +Tests (or tasks) are executed from the command line using the ``robot`` +command or by executing the ``robot`` module directly like ``python -m robot`` . The basic usage is giving a path to a test (or task) file or directory as an argument with possible command line options before the path:: robot tests.robot - robot --variable HOST:example.com --outputdir results path/to/tests/ + robot --variable BROWSER:Firefox --outputdir results path/to/tests/ -Additionally there is ``rebot`` tool for combining results and otherwise +Additionally, there is the ``rebot`` tool for combining results and otherwise post-processing outputs:: rebot --name Example output1.xml output2.xml @@ -112,31 +109,43 @@ Documentation `_ - `Standard libraries `_ -- `Built-in tools - `_ -- `API documentation - `_ -- `General documentation and demos - `_ - -Support and contact +- `API documentation `_ +- `General documentation `_ + +Support and Contact ------------------- +- `Slack `_ +- `Forum `_ - `robotframework-users `_ mailing list -- `Slack `_ community -- `#robotframework `_ - IRC channel on freenode -- `@robotframework `_ on Twitter -- `Other forums `_ -License -------- +Contributing +------------ + +Interested to contribute to Robot Framework? Great! In that case it is a good +start by looking at the ``__. If you +do not already have an issue you would like to work on, you can check +issues with `good new issue`__ and `help wanted`__ labels. -Robot Framework is open source software provided under the `Apache License -2.0`__. Robot Framework documentation and other similar content use the +Remember also that there are many other tools and libraries in the wider +`Robot Framework ecosystem `_ that you can +contribute to! + +__ https://github.com/robotframework/robotframework/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22 +__ https://github.com/robotframework/robotframework/issues?q=is%3Aissue+is%3Aopen+label%3A%22help+wanted%22 + +License and Trademark +--------------------- + +Robot Framework is open source software provided under the `Apache License 2.0`__. +Robot Framework documentation and other similar content use the `Creative Commons Attribution 3.0 Unported`__ license. Most libraries and tools in the ecosystem are also open source, but they may use different licenses. +Robot Framework trademark is owned by `Robot Framework Foundation`_. + __ http://apache.org/licenses/LICENSE-2.0 __ http://creativecommons.org/licenses/by/3.0 + +.. |r| unicode:: U+00AE diff --git a/atest/README.rst b/atest/README.rst index d81a530b3b0..410ef5cdce8 100644 --- a/atest/README.rst +++ b/atest/README.rst @@ -1,3 +1,6 @@ +.. default-role:: code + + Robot Framework acceptance tests ================================ @@ -7,147 +10,143 @@ test data they need. .. contents:: :local: + :depth: 2 Directory contents ------------------ -run.py - A script for running acceptance tests. See `Running acceptance tests`_ +``_ + A script for executing acceptance tests. See `Running acceptance tests`_ for further instructions. -robot/ - Contains actual acceptance test cases. See `Test data`_ section for details. +``_ + Contains the actual acceptance tests. See the `Test data`_ section for details. -resources/ - Resources needed by acceptance tests in the ``robot`` folder. +``_ + Resources needed by acceptance tests in the `robot` folder. -testdata/ - Contains test cases that are run by actual acceptance tests in the - ``robot`` folder. See `Test data`_ section for details. +``_ + Contains tests that are run by the tests in the `robot` folder. See + the `Test data`_ section for details. -testresources/ - Contains resources needed by test cases in the ``testdata`` folder. +``_ + Contains resources needed by test cases in the `testdata` folder. Some of these resources are also used by `unit tests <../utest/README.rst>`_. -results/ +``_ The place for test execution results. This directory is generated when - acceptance tests are executed. It is in ``.gitignore`` and can be safely + acceptance tests are executed. It is in `.gitignore` and can be safely deleted any time. -genrunner.py - Script to generate acceptance test runners (i.e. files under the ``robot`` - directory) based on the test data files (i.e. files under the ``testdata`` +``_ + Script to generate acceptance test runners (i.e. files under the `robot` + directory) based on the test data files (i.e. files under the `testdata` directory). Mainly useful if there is one-to-one mapping between tests in - the ``testdata`` and ``robot`` directories. + the `testdata` and `robot` directories. - Usage: ``atest/genrunner.py atest/testdata/path/data.robot [atest/robot/path/runner.robot]`` + Usage: `atest/genrunner.py atest/testdata/path/data.robot [atest/robot/path/runner.robot]` Running acceptance tests ------------------------ Robot Framework's acceptance tests are executed using the ``__ -script. It has two mandatory arguments, the Python interpreter to use -when running tests and path to tests to be executed, and it accepts also -all same options as Robot Framework. The script itself should always be -executed with Python 3.6 or newer. Run it with ``--help`` or see -documentation in its `source code `__ for more information. +script. Its usage is as follows:: + + atest/run.py [--interpreter interpreter] [--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 +with `no-ci` are executed. See the `Test tags`_ section below for more +information about the `no-ci` tag and tagging tests in general. -To run all the acceptance tests, execute the ``atest/robot`` folder -entirely using the selected interpreter:: +Available `options` are the same that can be used with Robot Framework. +See its help (e.g. `robot --help`) for more information. - atest/run.py python atest/robot - atest/run.py jython atest/robot +By default tests are executed using the same Python interpreter that is used for +running the `run.py` 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.9"`). -The commands above will execute all tests, but you typically want to skip -`Telnet tests`_ and tests requiring manual interaction. These tests are marked -with the ``no-ci`` tag and can be easily excluded:: +`--schema-validation` can be used to enable `schema validation`_ for all output.xml +files. + +Examples: + +.. code:: bash - atest/run.py python --exclude no-ci atest/robot + # Execute all tests. + atest/run.py -A sub test suite can be executed simply by running the folder or file -containing it. On modern machines running all acceptance tests ought to -take less than ten minutes with Python, but with Jython the execution time -is considerably longer. This is due to Jython being somewhat slower than -Python in general, but the main reason is that the JVM is started by -acceptance dozens of times and that always takes few seconds. + # Execute all tests using a custom interpreter. + atest/run.py --interpreter pypy3 -Before a release tests should be executed separately using Python, Jython, -IronPython and PyPy to verify interoperability with all supported interpreters. -Tests should also be run using different interpreter versions (when applicable) -and on different operating systems. + # Exclude tests requiring lxml. See the Test tags section for more information. + atest/run.py --exclude require-lxml + + # Exclude tests requiring manual interaction or Telnet server. + # This is needed when executing a specified directory containing such tests. + # If data is not specified, these tests are excluded automatically. + atest/run.py --exclude no-ci atest/robot/standard_libraries The results of the test execution are written into an interpreter specific -directory under the ``atest/results`` directory. Temporary outputs created +directory under the `atest/results` directory. Temporary outputs created during the execution are created under the system temporary directory. Test data --------- -The test data is divided to two, test data part (``atest/testdata`` folder) and -running part (``atest/robot`` folder). Test data side contains test cases for -different features. Running side contains the actual acceptance test cases -that run the test cases on the test data side and verify their results. +The test data is divided into two sides, the execution side +(`atest/robot `_ directory) and the test data side +(`atest/testdata `_ directory). The test data side contains test +cases for different features. The execution side contains the actual acceptance +tests that run the tests on the test data side and verify their results. The basic mechanism to verify that a test case in the test data side is executed as expected is setting the expected status and possible error message in its documentation. By default tests are expected to pass, but -having ``FAIL`` (this and subsequent markers are case sensitive) in the -documentation changes the expectation. The text after the ``FAIL`` marker -is the expected error message, which, by default, must match the actual -error exactly. If the error message starts with ``REGEXP:``, ``GLOB:`` or -``STARTS:``, the expected error is considered to be a regexp or glob pattern +having `FAIL` or `SKIP` (these and subsequent markers are case sensitive) in +the documentation changes the expectation. The text after the `FAIL` or `SKIP` +marker is the expected error message, which, by default, must match the actual +error exactly. If the error message starts with `REGEXP:`, `GLOB:` or +`STARTS:`, the expected error is considered to be a regexp or glob pattern matching the actual error, or to contain the beginning of the error. All -other details can be tested also, but that logic is in the running side. +other details can be tested also, but that logic is in the execution side. Test tags --------- -The tests on the running side (``atest/robot``) contains tags that are used +The tests on the execution side (`atest/robot`) contain tags that are used to include or exclude them based on the platform and required dependencies. Selecting tests based on the platform is done automatically by the ``__ -script, but additional selection can be done by the user to avoid running -tests with `preconditions`_ that are not met. +script, but additional selection can be done by the user, for example, to +avoid running tests with dependencies_ that are not met. manual Require manual interaction from user. Used with Dialogs library tests. telnet - Require a telnet server with test account running at localhost. See + Require a Telnet server with test account running on localhost. See `Telnet tests`_ for details. no-ci Tests which are not executed at continuous integration. Contains all tests - tagged with ``manual`` or ``telnet``. + tagged with `manual` or `telnet`. -require-yaml, require-enum, require-docutils, require-pygments, require-lxml, require-screenshot, require-tools.jar +require-yaml, require-lxml, require-screenshot Require specified Python module or some other external tool to be installed. - See `Preconditions`_ for details and exclude like ``--exclude require-lxml`` - if needed. + Exclude like `--exclude require-lxml` if dependencies_ are not met. -require-windows, require-jython, require-py2, require-py3, ... +require-windows, require-py3.13, ... Tests that require certain operating system or Python interpreter. Excluded automatically outside these platforms. -no-windows, no-osx, no-jython, no-ipy, ... +no-windows, no-osx, ... Tests to be excluded on certain operating systems or Python interpreters. Excluded automatically on these platforms. -Examples: - -.. code:: bash - - # Exclude tests requiring manual interaction or running telnet server. - atest/run.py python --exclude no-ci atest/robot - - # Same as the above but also exclude tests requiring docutils and lxml - atest/run.py python -e no-ci -e require-docutils -e require-lxml atest/robot - - # Run only tests related to Java integration. This is considerably faster - # than running all tests on Jython. - atest/run.py jython --include require-jython atest/robot - -Preconditions -------------- +Dependencies +------------ Certain Robot Framework features require optional external modules or tools to be installed, and naturally tests related to these features require same @@ -155,64 +154,78 @@ modules/tools as well. This section lists what preconditions are needed to run all tests successfully. See `Test tags`_ for instructions how to avoid running certain tests if all preconditions are not met. -Required Python modules -~~~~~~~~~~~~~~~~~~~~~~~ +Execution side dependencies +~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +The execution side has some dependencies listed in ``__ +that needs to be installed before running tests. It is easiest to install +them all in one go using `pip`:: + + pip install -r atest/requirements-run.txt -These Python modules need to be installed: +Test data side dependencies +~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +The test data side contains the tests for various features and has more +dependencies than the execution side. + +Needed Python modules +''''''''''''''''''''' - `docutils `_ is needed with tests related to parsing test data in reStructuredText format and with Libdoc tests - for documentation in reST format. `Not compatible with IronPython - `__. + for documentation in reST format. - `Pygments `_ is needed by Libdoc tests for syntax highlighting. - `PyYAML `__ is required with tests related to YAML variable files. -- `enum34 `__ (or older - `enum `__) by enum conversion tests. - This module is included by default in Python 3.4 and newer. -- `lxml `__ is needed with XML library tests. Not compatible - with Jython or IronPython. - -It is possible to install the above modules using ``pip`` either -individually or by using the provided ``__ file: +- `Pillow `_ for taking screenshots on Windows. +- `lxml `__ is needed with XML library tests. -.. code:: bash - - # Install individually - pip install 'docutils>=0.9' - pip install pygments - pip install pyyaml - pip install enum34 # Needed only with Python 2. - pip install lxml +It is possible to install the above modules using `pip` individually, but +it is easiest to use the provided ``__ file that installs +needed packages conditionally depending on the platform:: - # Install using requirements.txt pip install -r atest/requirements.txt Notice that the lxml module may require compilation on Linux, which in turn may require installing development headers of lxml dependencies. Alternatively lxml can be installed using a system package manager with a command like -``sudo apt-get install python-lxml``. - -Because lxml is not compatible with Jython or IronPython, tests requiring it -are excluded automatically when using these interpreters. +`sudo apt-get install python-lxml`. Screenshot module or tool -~~~~~~~~~~~~~~~~~~~~~~~~~ +''''''''''''''''''''''''' Screenshot library tests require a platform dependent module or tool that can -take screenshots. See `Screenshot library documentation`__ for details. +take screenshots. The above instructions already covered installing Pillow_ +on Windows and on OSX it is possible to use tooling provided by the operating +system automatically. For Linux alternatives consult the +`Screenshot library documentation`__. __ http://robotframework.org/robotframework/latest/libraries/Screenshot.html -``tools.jar`` -~~~~~~~~~~~~~ +Schema validation +----------------- + +output.xml schema +~~~~~~~~~~~~~~~~~ + +Created output.xml has a `schema <../doc/schema>`_ that can be tested as part of +acceptance tests. The schema is always used to validate selected outputs (e.g. in +``_), but validating all outputs would slow down +execution a bit too much. + +It is, however, possible to enable validating all outputs by setting +`ATEST_VALIDATE_OUTPUT` environment variable to `TRUE` (case-insensitive) +or by using `--schema-validation` (`-S`) option with `atest/run.py`. +This is recommended especially if the schema is updated or output.xml changed. + +Libdoc XML and JSON spec schemas +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -When using Java 8 or earlier, Libdoc requires ``tools.jar``, which is part -of the standard JDK installation, to be in ``CLASSPATH`` when reading library -documentation from Java source files. In addition to setting ``CLASSPATH`` -explicitly, it is possible to put ``tools.jar`` into the ``ext-lib`` -directory in the project root and ``CLASSPATH`` is set automatically. +Libdoc can create spec files both in XML and JSON formats and they both have +`schemas <../doc/schema>`_. All generated Libdoc specs are validated automatically +in Libdoc tests. Telnet tests ------------ @@ -220,12 +233,12 @@ Telnet tests Running telnet tests requires some extra setup. Instructions how to run them can be found from ``_. If you don't want to run an unprotected telnet server on your machine, you can -always skip these tests by excluding tests with a tag ``telnet`` or ``no-ci``. +always skip these tests by excluding tests with a tag `telnet` or `no-ci`. License and copyright --------------------- -All content in the ``atest`` folder is under the following copyright:: +All content in the `atest` folder is under the following copyright:: Copyright 2008-2015 Nokia Networks Copyright 2016- Robot Framework Foundation diff --git a/atest/genrunner.py b/atest/genrunner.py index 67f5ef0ad6d..89d331dd1b7 100755 --- a/atest/genrunner.py +++ b/atest/genrunner.py @@ -1,25 +1,24 @@ -#!/usr/bin/env python3.6 +#!/usr/bin/env python """Script to generate atest runners based on plain text data files. Usage: {tool} testdata/path/data.robot [robot/path/runner.robot] """ -from __future__ import print_function -from os.path import abspath, basename, dirname, exists, join import os -import sys import re +import sys +from os.path import abspath, basename, dirname, exists, join -if len(sys.argv) not in [2, 3] or not all(a.endswith('.robot') for a in sys.argv[1:]): +if len(sys.argv) not in [2, 3] or not all(a.endswith(".robot") for a in sys.argv[1:]): sys.exit(__doc__.format(tool=basename(sys.argv[0]))) -SEPARATOR = re.compile(r'\s{2,}|\t') +SEPARATOR = re.compile(r"\s{2,}|\t") INPATH = abspath(sys.argv[1]) -if join('atest', 'testdata') not in INPATH: +if join("atest", "testdata") not in INPATH: sys.exit("Input not under 'atest/testdata'.") if len(sys.argv) == 2: - OUTPATH = INPATH.replace(join('atest', 'testdata'), join('atest', 'robot')) + OUTPATH = INPATH.replace(join("atest", "testdata"), join("atest", "robot")) else: OUTPATH = sys.argv[2] @@ -27,7 +26,7 @@ os.mkdir(dirname(OUTPATH)) -class TestCase(object): +class TestCase: def __init__(self, name, tags=None): self.name = name @@ -43,39 +42,45 @@ def __init__(self, name, tags=None): line = line.rstrip() if not line: continue - elif line.startswith('*'): - name = SEPARATOR.split(line)[0].replace('*', '').replace(' ', '').upper() - parsing_tests = name in ('TESTCASE', 'TESTCASES', 'TASK', 'TASKS') - parsing_settings = name in ('SETTING', 'SETTINGS') - elif parsing_tests and not SEPARATOR.match(line): - 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')): - name, value = line.split(' ', 1) - SETTINGS.append((name, value.strip())) - - -with open(OUTPATH, 'w') as output: - path = INPATH.split(join('atest', 'testdata'))[1][1:].replace(os.sep, '/') - output.write('''\ + elif line.startswith("*"): + name = SEPARATOR.split(line)[0].replace("*", "").replace(" ", "").upper() + parsing_tests = name in ("TESTCASES", "TASKS") + parsing_settings = name == "SETTINGS" + elif parsing_tests and not SEPARATOR.match(line) and line[0] != "#": + TESTS.append(TestCase(SEPARATOR.split(line)[0])) + elif parsing_tests and line.strip().startswith("[Tags]"): + TESTS[-1].tags = line.split("[Tags]", 1)[1].split() + elif parsing_settings and line.startswith("Test Tags"): + name, *values = SEPARATOR.split(line) + SETTINGS.append((name, values)) + + +with open(OUTPATH, "w") as output: + path = INPATH.split(join("atest", "testdata"))[1][1:].replace(os.sep, "/") + output.write( + f"""\ *** Settings *** -Suite Setup Run Tests ${EMPTY} %s -''' % path) - for name, value in SETTINGS: - output.write('%s%s\n' % (name.ljust(17), value)) - output.write('''\ -Resource atest_resource.robot +Suite Setup Run Tests ${{EMPTY}} {path} +""" + ) + for name, values in SETTINGS: + values = " ".join(values) + output.write(f"{name:18}{values}\n") + output.write( + """\ +Resource atest_resource.robot *** Test Cases *** -''') +""" + ) for test in TESTS: - output.write(test.name + '\n') + output.write(test.name + "\n") if test.tags: - output.write(' [Tags] %s\n' % ' '.join(test.tags)) - output.write(' Check Test Case ${TESTNAME}\n') + tags = " ".join(test.tags) + output.write(f" [Tags] {tags}\n") + output.write(" Check Test Case ${TESTNAME}\n") if test is not TESTS[-1]: - output.write('\n') + output.write("\n") print(OUTPATH) diff --git a/atest/interpreter.py b/atest/interpreter.py index 1f25c30943c..565ff9259c7 100644 --- a/atest/interpreter.py +++ b/atest/interpreter.py @@ -1,26 +1,15 @@ -from os.path import abspath, dirname, exists, join import os +import re import subprocess import sys - - -PROJECT_ROOT = dirname(dirname(abspath(__file__))) -ROBOT_PATH = join(PROJECT_ROOT, 'src', 'robot') +from pathlib import Path def get_variables(path, name=None, version=None): - interpreter = InterpreterFactory(path, name, version) - u = '' if interpreter.is_py3 or interpreter.is_ironpython else 'u' - return {'INTERPRETER': interpreter, 'UNICODE PREFIX': u} - + return {"INTERPRETER": Interpreter(path, name, version)} -def InterpreterFactory(path, name=None, version=None): - if path.endswith('.jar'): - return StandaloneInterpreter(path, name, version) - return Interpreter(path, name, version) - -class Interpreter(object): +class Interpreter: def __init__(self, path, name=None, version=None): self.path = path @@ -29,192 +18,98 @@ def __init__(self, path, name=None, version=None): name, version = self._get_name_and_version() self.name = name self.version = version - self.version_info = tuple(int(item) for item in version.split('.')) + self.version_info = tuple(int(item) for item in version.split(".")) + self.src_dir = Path(__file__).parent.parent / "src" def _get_interpreter(self, path): + path = path.replace("/", os.sep) return [path] if os.path.exists(path) else path.split() def _get_name_and_version(self): try: - output = subprocess.check_output(self.interpreter + ['-V'], - stderr=subprocess.STDOUT, - encoding='UTF-8') - except (subprocess.CalledProcessError, FileNotFoundError): - raise ValueError('Invalid interpreter: %s' % self.path) + output = subprocess.check_output( + self.interpreter + ["-V"], + stderr=subprocess.STDOUT, + encoding="UTF-8", + ) + except (subprocess.CalledProcessError, FileNotFoundError) as err: + raise ValueError(f"Failed to get interpreter version: {err}") name, version = output.split()[:2] - name = name if 'PyPy' not in output else 'PyPy' - version = '.'.join(version.split('.')[:2]) + name = name if "PyPy" not in output else "PyPy" + version = re.match(r"\d+\.\d+\.\d+", version).group() return name, version @property def os(self): - for condition, name in [(self.is_linux, 'Linux'), - (self.is_osx, 'OS X'), - (self.is_windows, 'Windows')]: + for condition, name in [ + (self.is_linux, "Linux"), + (self.is_osx, "OS X"), + (self.is_windows, "Windows"), + ]: if condition: return name return sys.platform @property - def excludes(self): - if self.is_jython: - yield 'no-jython' - yield 'require-lxml' - else: - yield 'require-jython' - if self.is_ironpython: - yield 'no-ipy' - yield 'require-lxml' - yield 'require-docutils' # https://github.com/IronLanguages/main/issues/1230 - else: - yield 'require-ipy' - for exclude in self._platform_excludes: - yield exclude + def output_name(self): + return f"{self.name}-{self.version}-{self.os}".replace(" ", "") @property - def _platform_excludes(self): - if self.is_py3: - yield 'require-py2' - else: - yield 'require-py3' - if self.version_info < (3, 5): - yield 'require-py3.5' - if self.version_info < (3, 7): - yield 'require-py3.7' + def excludes(self): + if self.is_pypy: + yield "no-pypy" + yield "require-lxml" + for require in [(3, 9), (3, 10), (3, 12), (3, 13), (3, 14)]: + if self.version_info < require: + yield "require-py%d.%d" % require if self.is_windows: - yield 'no-windows' - if self.is_jython: - yield 'no-windows-jython' + yield "no-windows" if not self.is_windows: - yield 'require-windows' + yield "require-windows" if self.is_osx: - yield 'no-osx' - if self.is_python: - yield 'no-osx-python' - - @property - def classpath(self): - if not self.is_jython: - return None - classpath = os.environ.get('CLASSPATH') - if classpath and 'tools.jar' in classpath: - return classpath - tools_jar = join(PROJECT_ROOT, 'ext-lib', 'tools.jar') - if not exists(tools_jar): - return classpath - if classpath: - return classpath + os.pathsep + tools_jar - return tools_jar + yield "no-osx" + if not self.is_linux: + yield "require-linux" @property def is_python(self): - return self.name == 'Python' - - @property - def is_jython(self): - return self.name == 'Jython' - - @property - def is_ironpython(self): - return self.name == 'IronPython' + return self.name == "Python" @property def is_pypy(self): - return self.name == 'PyPy' - - @property - def is_py2(self): - return self.version[0] == '2' - - @property - def is_py3(self): - return self.version[0] == '3' + return self.name == "PyPy" @property def is_linux(self): - return 'linux' in sys.platform + return "linux" in sys.platform @property def is_osx(self): - return sys.platform == 'darwin' + return sys.platform == "darwin" @property def is_windows(self): - return os.name == 'nt' + return os.name == "nt" @property def runner(self): - return self.interpreter + [join(ROBOT_PATH, 'run.py')] + return self.interpreter + [str(self.src_dir / "robot/run.py")] @property def rebot(self): - return self.interpreter + [join(ROBOT_PATH, 'rebot.py')] + return self.interpreter + [str(self.src_dir / "robot/rebot.py")] @property def libdoc(self): - return self.interpreter + [join(ROBOT_PATH, 'libdoc.py')] + return self.interpreter + [str(self.src_dir / "robot/libdoc.py")] @property def testdoc(self): - return self.interpreter + [join(ROBOT_PATH, 'testdoc.py')] + return self.interpreter + [str(self.src_dir / "robot/testdoc.py")] @property - def tidy(self): - return self.interpreter + [join(ROBOT_PATH, 'tidy.py')] + def underline(self): + return "-" * len(str(self)) def __str__(self): - return '%s %s on %s' % (self.name, self.version, self.os) - - -class StandaloneInterpreter(Interpreter): - - def __init__(self, path, name=None, version=None): - Interpreter.__init__(self, abspath(path), name or 'Standalone JAR', - version or '2.7') - - def _get_interpreter(self, path): - interpreter = ['java', '-jar', path] - classpath = self.classpath - if classpath: - interpreter.insert(1, '-Xbootclasspath/a:%s' % classpath) - return interpreter - - @property - def excludes(self): - for exclude in ['no-standalone', 'no-jython', 'require-lxml', - 'require-docutils', 'require-ipy']: - yield exclude - for exclude in self._platform_excludes: - yield exclude - - @property - def is_python(self): - return False - - @property - def is_jython(self): - return True - - @property - def is_ironpython(self): - return False - - @property - def runner(self): - return self.interpreter + ['run'] - - @property - def rebot(self): - return self.interpreter + ['rebot'] - - @property - def libdoc(self): - return self.interpreter + ['libdoc'] - - @property - def testdoc(self): - return self.interpreter + ['testdoc'] - - @property - def tidy(self): - return self.interpreter + ['tidy'] + return f"{self.name} {self.version} on {self.os}" diff --git a/atest/requirements-run.txt b/atest/requirements-run.txt new file mode 100644 index 00000000000..4dfae292ecc --- /dev/null +++ b/atest/requirements-run.txt @@ -0,0 +1,4 @@ +# Dependencies for the acceptance test runner. + +jsonschema >= 4.0 +xmlschema diff --git a/atest/requirements.txt b/atest/requirements.txt index 8df8037380f..dd2a51703d1 100644 --- a/atest/requirements.txt +++ b/atest/requirements.txt @@ -1,13 +1,9 @@ -# External Python modules required by acceptance tests. +# Dependencies required by acceptance tests. # See atest/README.rst for more information. -docutils >= 0.9; platform_python_implementation != 'IronPython' # https://github.com/IronLanguages/ironpython2/issues/113 pygments -pyyaml -enum34; python_version < '3.0' -pillow; platform_system == 'Windows' +lxml; python_version < '3.15' +pillow >= 7.1.0; platform_system == 'Windows' +telnetlib-313-and-up; python_version >= '3.13' -# On Linux installing lxml with pip may require compilation and development -# headers. Alternatively it can be installed using a package manager like -# `sudo apt-get install python-lxml`. -lxml; platform_python_implementation == 'CPython' or platform_python_implementation == 'PyPy' and platform_system != 'Windows' +-r ../utest/requirements.txt diff --git a/atest/resources/TestCheckerLibrary.py b/atest/resources/TestCheckerLibrary.py index 97ec3a68a84..af7001ea93a 100644 --- a/atest/resources/TestCheckerLibrary.py +++ b/atest/resources/TestCheckerLibrary.py @@ -1,81 +1,271 @@ +import json import os import re +from pathlib import Path -from robot import utils -from robot.utils.asserts import assert_equal -from robot.result import (ExecutionResultBuilder, Keyword, TestCase, TestSuite, - Result) +try: + from jsonschema import Draft202012Validator as JSONValidator +except ImportError: + JSONValidator = None +from xmlschema import XMLSchema + +from robot.api import logger from robot.libraries.BuiltIn import BuiltIn +from robot.libraries.Collections import Collections +from robot.result import ( + Break, Continue, Error, ExecutionResult, ExecutionResultBuilder, For, ForIteration, + Group, If, IfBranch, Keyword, Result, ResultVisitor, Return, TestCase, TestSuite, + Try, TryBranch, Var, While, WhileIteration +) +from robot.result.model import Body, Iterations +from robot.utils import eq, get_error_details, is_truthy, Matcher +from robot.utils.asserts import assert_equal + + +class WithBodyTraversing: + body: Body + + def __getitem__(self, index): + if isinstance(index, str): + index = tuple(int(i) for i in index.split(",")) + if isinstance(index, (int, slice)): + return self.body[index] + if isinstance(index, tuple): + item = self + for i in index: + item = item.body[int(i)] + return item + raise TypeError(f"Invalid index {repr(index)}.") + + @property + def keywords(self): + return self.body.filter(keywords=True) + + @property + def messages(self): + return self.body.filter(messages=True) + + @property + def non_messages(self): + return self.body.filter(messages=False) + + +class ATestKeyword(Keyword, WithBodyTraversing): + pass + + +class ATestFor(For, WithBodyTraversing): + pass -class NoSlotsKeyword(Keyword): +class ATestWhile(While, WithBodyTraversing): pass -class NoSlotsTestCase(TestCase): - keyword_class = NoSlotsKeyword -class NoSlotsTestSuite(TestSuite): - test_class = NoSlotsTestCase - keyword_class = NoSlotsKeyword +class ATestGroup(Group, WithBodyTraversing): + pass + + +class ATestIf(If, WithBodyTraversing): + pass + + +class ATestTry(Try, WithBodyTraversing): + pass + + +class ATestVar(Var, WithBodyTraversing): + pass + + +class ATestReturn(Return, WithBodyTraversing): + pass + + +class ATestBreak(Break, WithBodyTraversing): + pass + + +class ATestContinue(Continue, WithBodyTraversing): + pass + + +class ATestError(Error, WithBodyTraversing): + pass + + +class ATestBody(Body): + keyword_class = ATestKeyword + for_class = ATestFor + if_class = ATestIf + try_class = ATestTry + while_class = ATestWhile + group_class = ATestGroup + var_class = ATestVar + return_class = ATestReturn + break_class = ATestBreak + continue_class = ATestContinue + error_class = ATestError + + +class ATestIfBranch(IfBranch, WithBodyTraversing): + body_class = ATestBody + + +class ATestTryBranch(TryBranch, WithBodyTraversing): + body_class = ATestBody + + +class ATestForIteration(ForIteration, WithBodyTraversing): + body_class = ATestBody + + +class ATestWhileIteration(WhileIteration, WithBodyTraversing): + body_class = ATestBody + + +class ATestIterations(Iterations, WithBodyTraversing): + keyword_class = ATestKeyword + + +ATestKeyword.body_class = ATestVar.body_class = ATestReturn.body_class \ + = ATestBreak.body_class = ATestContinue.body_class \ + = ATestError.body_class = ATestGroup.body_class \ + = ATestBody # fmt: skip +ATestFor.iterations_class = ATestWhile.iterations_class = ATestIterations +ATestFor.iteration_class = ATestForIteration +ATestWhile.iteration_class = ATestWhileIteration +ATestIf.branch_class = ATestIfBranch +ATestTry.branch_class = ATestTryBranch + + +class ATestTestCase(TestCase, WithBodyTraversing): + fixture_class = ATestKeyword + body_class = ATestBody + + +class ATestTestSuite(TestSuite): + fixture_class = ATestKeyword + test_class = ATestTestCase class TestCheckerLibrary: + ROBOT_LIBRARY_SCOPE = "GLOBAL" + + def __init__(self): + self.xml_schema = XMLSchema("doc/schema/result.xsd") + self.json_schema = self._load_json_schema() - def process_output(self, path): + def _load_json_schema(self): + if not JSONValidator: + return None + with open("doc/schema/result.json", encoding="UTF-8") as f: + return JSONValidator(json.load(f)) + + def process_output(self, path: "None | Path", validate: "bool | None" = None): set_suite_variable = BuiltIn().set_suite_variable - if not path or path.upper() == 'NONE': - set_suite_variable('$SUITE', None) - print("Not processing output.") + if path is None: + set_suite_variable("$SUITE", None) + logger.info("Not processing output.") return - path = path.replace('/', os.sep) + if validate is None: + validate = is_truthy(os.getenv("ATEST_VALIDATE_OUTPUT", False)) + if validate: + if path.suffix.lower() == ".json": + self.validate_json_output(path) + else: + self._validate_output(path) try: - print("Processing output '%s'." % path) - result = Result(root_suite=NoSlotsTestSuite()) - ExecutionResultBuilder(path).build(result) - except: - set_suite_variable('$SUITE', None) - raise RuntimeError('Processing output failed: %s' - % utils.get_error_message()) - set_suite_variable('$SUITE', process_suite(result.suite)) - set_suite_variable('$STATISTICS', result.statistics) - set_suite_variable('$ERRORS', process_errors(result.errors)) + logger.info(f"Processing output '{path}'.") + if path.suffix.lower() == ".json": + result = self._build_result_from_json(path) + else: + result = self._build_result_from_xml(path) + except Exception: + set_suite_variable("$SUITE", None) + msg, details = get_error_details() + logger.info(details) + raise RuntimeError(f"Processing output failed: {msg}") + result.visit(ProcessResults()) + set_suite_variable("$SUITE", result.suite) + set_suite_variable("$STATISTICS", result.statistics) + set_suite_variable("$ERRORS", result.errors) + + def _build_result_from_xml(self, path): + result = Result(source=path, suite=ATestTestSuite()) + ExecutionResultBuilder(path).build(result) + return result + + def _build_result_from_json(self, path): + result = Result.from_json(path) + result.suite = ATestTestSuite.from_dict(result.suite.to_dict()) + return result + + def _validate_output(self, path): + version = self._get_schema_version(path) + if not version: + raise ValueError("Schema version not found from XML output.") + if version != self.xml_schema.version: + raise ValueError( + f"Incompatible schema versions. " + f'Schema has `version="{self.xml_schema.version}"` but ' + f'output file has `schemaversion="{version}"`.' + ) + self.xml_schema.validate(path) + + def _get_schema_version(self, path): + with open(path, encoding="UTF-8") as file: + for line in file: + if line.startswith("= 0 + ... Length Should Be ${item.body} ${children} Check Keyword Data - [Arguments] ${kw} ${name} ${assign}= ${args}= ${status}=PASS ${tags}= - Should be equal ${kw.name} ${name} - Should be equal ${kw.status} ${status} - ${kwassign}= Catenate SEPARATOR=,${SPACE} @{kw.assign} - Should be equal ${kwassign} ${assign} - ${kwargs}= Catenate SEPARATOR=,${SPACE} @{kw.args} - Should match ${kwargs} ${args} - ${kwtags}= Catenate SEPARATOR=,${SPACE} @{kw.tags} - Should be equal ${kwtags} ${tags} + [Arguments] ${kw} ${name} ${assign}= ${args}= ${status}=PASS ${tags}= ${doc}=* ${message}=* ${type}=KEYWORD ${children}=-1 + 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} + Should Be Equal ${{', '.join($kw.tags)}} ${tags} + Should Match ${kw.doc} ${doc} + Should Match ${kw.message} ${message} + Should Be Equal ${kw.type} ${type} + IF ${children} >= 0 + ... Length Should Be ${kw.body} ${children} + +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 ${allowed failure}= + ${tc} = Check Test Case ${name} + All Keywords Should Have Passed ${tc} ${allow not run} ${allowed failure} + +All Keywords Should Have Passed + [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. + 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 Get Output File [Arguments] ${path} - [Documentation] Output encoding avare helper - ${encoding} = Set Variable If ${INTERPRETER.is_ironpython} CONSOLE SYSTEM - ${encoding} = Set Variable If r'${path}' in [r'${STDERR FILE}',r'${STDOUT FILE}'] ${encoding} UTF-8 + IF $path in ($STDERR_FILE, $STDOUT_FILE) and $INTERPRETER.version_info < (3, 15) + VAR ${encoding} SYSTEM + ELSE + VAR ${encoding} UTF-8 + END ${file} = Get File ${path} ${encoding} - [Return] ${file} + RETURN ${file} File Should Contain - [Arguments] ${path} @{expected} + [Arguments] ${path} @{expected} ${count}=None ${exp} = Catenate @{expected} ${file} = Get Output File ${path} - Should Contain ${file} ${exp} + IF not ${count} + Should Contain ${file} ${exp} + ELSE + Should Contain X Times ${file} ${exp} ${count} + END File Should Not Contain [Arguments] ${path} @{expected} @@ -143,11 +194,11 @@ File Should Not Contain ${file} = Get Output File ${path} Should Not Contain ${file} ${exp} -Check File Matches Regexp +File Should Match Regexp [Arguments] ${path} @{expected} ${exp} = Catenate @{expected} ${file} = Get Output File ${path} - Should Match Regexp ${file.strip()} ^${exp}$ + Should Match Regexp ${file.strip()} (?s)^${exp}$ File Should Contain Regexp [Arguments] ${path} @{expected} @@ -201,65 +252,73 @@ Stderr Should Be Empty ${stderr} = Get Stderr Should Be Empty ${stderr} Errors in test execution:\n${stderr} -Check Stderr Contains - [Arguments] @{expected} - File Should Contain ${STDERR_FILE} @{expected} +Stderr Should Contain + [Arguments] @{expected} ${count}=None + File Should Contain ${STDERR_FILE} @{expected} count=${count} -Check Stderr Does Not Contain +Stderr Should Not Contain [Arguments] @{expected} File Should Not Contain ${STDERR_FILE} @{expected} -Check Stderr Matches Regexp +Stderr Should Match Regexp [Arguments] @{expected} - Check File Matches Regexp ${STDERR_FILE} @{expected} + File Should Match Regexp ${STDERR_FILE} @{expected} -Check Stderr Contains Regexp +Stderr Should Contain Regexp [Arguments] @{expected} File Should Contain Regexp ${STDERR_FILE} @{expected} -Check Stdout Contains +Stdout Should Be Equal To [Arguments] @{expected} - File Should Contain ${STDOUT_FILE} @{expected} + File Should Be Equal To ${STDOUT_FILE} @{expected} + +Stdout Should Contain + [Arguments] @{expected} ${count}=None + File Should Contain ${STDOUT_FILE} @{expected} count=${count} -Check Stdout Does Not Contain +Stdout Should Be Empty + ${stdout} = Get Stdout + Should Be Empty ${stdout} Unexpected console output:\n${stdout} + +Stdout Should Not Contain [Arguments] @{expected} File Should Not Contain ${STDOUT_FILE} @{expected} -Check Stdout Matches Regexp +Stdout Should Match Regexp [Arguments] @{expected} - Check File Matches Regexp ${STDOUT_FILE} @{expected} + File Should Match Regexp ${STDOUT_FILE} @{expected} -Check Stdout Contains Regexp +Stdout Should Contain Regexp [Arguments] @{expected} File Should Contain Regexp ${STDOUT_FILE} @{expected} 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} File Should Contain Match ${SYSLOG FILE} @{expected} -Check Syslog Contains +Syslog Should Contain [Arguments] @{expected} File Should Contain ${SYSLOG_FILE} @{expected} -Check Syslog Does Not Contain +Syslog Should Not Contain [Arguments] @{expected} File Should Not Contain ${SYSLOG_FILE} @{expected} Syslog Should Match Regexp [Arguments] @{expected} - Check File Matches Regexp ${SYSLOG_FILE} @{expected} + File Should Match Regexp ${SYSLOG_FILE} @{expected} Syslog Should Contain Regexp [Arguments] @{expected} @@ -275,15 +334,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) and ${time} >= 0 Not valid elapsed time + [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} @@ -293,22 +371,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} @@ -316,39 +394,50 @@ Tag Statistics Should Be Should Be Equal As Integers ${tag.attrib['pass']} ${pass} Should Be Equal As Integers ${tag.attrib['fail']} ${fail} -# TODO: Move next two closer to other test/kw status related kws -Test And All Keywords Should Have Passed - [Arguments] ${name}=${TESTNAME} - ${tc} = Check Test Case ${name} - All Keywords Should Have Passed ${tc} - -All Keywords Should Have Passed - [Arguments] ${tc or kw} - : FOR ${kw} IN @{tc or kw.kws} - \ Should Be Equal ${kw.status} PASS - \ All Keywords Should Have Passed ${kw} - Set PYTHONPATH [Arguments] @{values} ${value} = Catenate SEPARATOR=${:} @{values} Set Environment Variable PYTHONPATH ${value} - Set Environment Variable JYTHONPATH ${value} - Set Environment Variable IRONPYTHONPATH ${value} Reset PYTHONPATH Remove Environment Variable PYTHONPATH - Remove Environment Variable JYTHONPATH - Remove Environment Variable IRONPYTHONPATH - -Import should have failed - [Arguments] ${index} ${path} ${message} ${traceback}=* - ... ${stacktrace}= - ${path} = Normalize Path ${DATADIR}/${path} - ${error} = Set Variable Error in file '${path}': ${message} + +Error in file + [Arguments] ${index} ${path} ${lineno} @{message} ${traceback}= + ... ${stacktrace}= ${pattern}=True ${level}=ERROR + ${path} = Join Path ${DATADIR} ${path} + ${message} = Catenate @{message} + ${error} = Set Variable Error in file '${path}' on line ${lineno}: ${message} ${error} = Set Variable If $traceback and not $stacktrace ... ${error}\nTraceback (most recent call last):\n*${traceback}* ... ${error} ${error} = Set Variable If $stacktrace ... ${error}\n*${stacktrace}* ... ${error} - Check Log Message @{ERRORS}[${index}] ${error} level=ERROR pattern=yes + Check Log Message ${ERRORS}[${index}] ${error} level=${level} pattern=${pattern} + +Error in library + [Arguments] ${name} @{message} ${pattern}=False ${index}=0 + ${error} = Catenate + ... Error in library '${name}': + ... @{message} + Check Log Message ${ERRORS}[${index}] ${error} level=ERROR pattern=${pattern} + +Setup Should Not Be Defined + [Arguments] ${model_object} + Should Not Be True ${model_object.setup} + +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 + # Remove '~~~^^^' lines. + ${msg.message} = Evaluate '\\n'.join(line for line in $msg.message.splitlines() if line.strip('~^ ') or not line) + Check Log Message ${msg} ${exp}\n${error} DEBUG pattern=True traceback=True diff --git a/atest/resources/atest_variables.py b/atest/resources/atest_variables.py index 449bb457c73..cf4b4136f2e 100644 --- a/atest/resources/atest_variables.py +++ b/atest/resources/atest_variables.py @@ -1,24 +1,39 @@ -from os.path import abspath, dirname, join, normpath import locale import os import subprocess +import sys +from datetime import datetime, timedelta +from os.path import abspath, dirname, join, normpath import robot - -__all__ = ['ROBOTPATH', 'ROBOT_VERSION', 'DATADIR', 'SYSTEM_ENCODING', - 'CONSOLE_ENCODING'] +__all__ = [ + "ROBOTPATH", + "ROBOT_VERSION", + "DATADIR", + "SYSTEM_ENCODING", + "CONSOLE_ENCODING", + "datetime", + "timedelta", +] ROBOTPATH = dirname(abspath(robot.__file__)) ROBOT_VERSION = robot.version.get_version() -DATADIR = normpath(join(dirname(abspath(__file__)), '..', 'testdata')) +DATADIR = normpath(join(dirname(abspath(__file__)), "..", "testdata")) -SYSTEM_ENCODING = locale.getpreferredencoding(False) +if sys.version_info >= (3, 11): + SYSTEM_ENCODING = locale.getencoding() +else: + SYSTEM_ENCODING = locale.getpreferredencoding(False) # Python 3.6+ uses UTF-8 internally on Windows. We want real console encoding. -if os.name == 'nt': - output = subprocess.check_output('chcp', shell=True, encoding='ASCII', - errors='ignore') - CONSOLE_ENCODING = 'cp' + output.split()[-1] +if os.name == "nt": + output = subprocess.check_output( + "chcp", + shell=True, + encoding="ASCII", + errors="ignore", + ) + CONSOLE_ENCODING = "cp" + output.split()[-1] else: - CONSOLE_ENCODING = locale.getdefaultlocale()[-1] + CONSOLE_ENCODING = locale.getlocale()[-1] diff --git a/atest/resources/rebot_resource.robot b/atest/resources/rebot_resource.robot index 5faa9d5d468..83291b2cbd5 100644 --- a/atest/resources/rebot_resource.robot +++ b/atest/resources/rebot_resource.robot @@ -1,25 +1,19 @@ -*** 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} - 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/resources/unicode_vars.py b/atest/resources/unicode_vars.py index ac438bee7fd..00b35f9e162 100644 --- a/atest/resources/unicode_vars.py +++ b/atest/resources/unicode_vars.py @@ -1,12 +1,14 @@ -message_list = ['Circle is 360\u00B0', - 'Hyv\u00E4\u00E4 \u00FC\u00F6t\u00E4', - '\u0989\u09C4 \u09F0 \u09FA \u099F \u09EB \u09EA \u09B9'] +message_list = [ + "Circle is 360\xb0", + "Hyv\xe4\xe4 \xfc\xf6t\xe4", + "\u0989\u09c4 \u09f0 \u09fa \u099f \u09eb \u09ea \u09b9", +] message1 = message_list[0] message2 = message_list[1] message3 = message_list[2] -messages = ', '.join(message_list) +messages = ", ".join(message_list) sect = chr(167) auml = chr(228) diff --git a/atest/robot/cli/console/builtin_loggers.robot b/atest/robot/cli/console/builtin_loggers.robot new file mode 100644 index 00000000000..7e1d3fb8e89 --- /dev/null +++ b/atest/robot/cli/console/builtin_loggers.robot @@ -0,0 +1,79 @@ +*** Settings *** +Resource console_resource.robot + +*** Test Cases *** +Verbose + Run and verify tests --console verbose + Stdout Should Be warnings_and_errors_stdout.txt + Stderr Should Be warnings_and_errors_stderr.txt + +Dotted + Run and verify tests --Console Dotted + Stdout Should Be warnings_and_errors_stdout_dotted.txt + Stderr Should Be warnings_and_errors_stderr.txt + +Dotted with skip + Run tests --CONSOLE DOTTED --SKIPONFAILURE FAIL --NAME X misc/pass_and_fail.robot misc/suites + Stdout Should Be dotted_with_skip.txt + Stderr Should Be empty.txt + +Dotted with skip only + Run tests -. --skipon fail --skip pass misc/pass_and_fail.robot + Stdout Should Be dotted_with_skip_only.txt + Stderr Should Be empty.txt + +Dotted with width + Run tests --Console dotted --ConsoleWidth 10 misc/suites misc/suites + Stdout Should Be warnings_and_errors_stdout_dotted_10.txt + Stderr Should Be empty.txt + +Quiet + Run and verify tests --ConSole=QuiEt + Stdout Should Be empty.txt + Stderr Should Be warnings_and_errors_stderr.txt + +None + Run and verify tests --CONSOLE NONE + Stdout Should Be empty.txt + Stderr Should Be empty.txt + +--dotted + Run and verify tests --dotted + Stdout Should Be warnings_and_errors_stdout_dotted.txt + Stderr Should Be warnings_and_errors_stderr.txt + +--dotted with --rpa + Run and verify tests --dotted --rpa + Stdout Should Be warnings_and_errors_stdout_dotted.txt tests=tasks + Stderr Should Be warnings_and_errors_stderr.txt tests=tasks + +--quiet + Run and verify tests --Quiet + Stdout Should Be empty.txt + Stderr Should Be warnings_and_errors_stderr.txt + +Dotted does not show details for skipped after fatal error + Run tests -. running/fatal_exception + Stdout Should Be dotted_fatal_error.txt + Stderr Should Be empty.txt + +--Dotted --ExitOnFailure + Run tests --ExitOnFailure --Dotted misc/suites + Stdout Should Be dotted_exitonfailure.txt + Stderr Should Be empty.txt + +--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 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. + Should contain ${tc.tags} robot:exit + +*** Keywords *** +Run and verify tests + [Arguments] ${options} + Run Tests ${options} misc/warnings_and_errors.robot + Should Be Equal ${SUITE.status} PASS + Length Should Be ${SUITE.tests} 3 diff --git a/atest/robot/cli/console/colors_and_width.robot b/atest/robot/cli/console/colors_and_width.robot index b214fef5f2b..b0c973b576b 100644 --- a/atest/robot/cli/console/colors_and_width.robot +++ b/atest/robot/cli/console/colors_and_width.robot @@ -5,62 +5,81 @@ Resource console_resource.robot *** Test Cases *** Console Colors Auto - Run Tests With Colors --consolecolors auto - Outputs should not have ANSI colors + Run Tests With Warnings --consolecolors auto + Outputs should not have ANSI codes Console Colors Off - Run Tests With Colors --consolecolors OFF - Outputs should not have ANSI colors + Run Tests With Warnings --consolecolors OFF + Outputs should not have ANSI codes Console Colors On - Run Tests With Colors --ConsoleCol on + Run Tests With Warnings --ConsoleCol on Outputs should have ANSI colors when not on Windows Console Colors ANSI - Run Tests With Colors --ConsoleColors AnSi - Outputs should have ANSI colors + Run Tests With Warnings --Console-Colors AnSi + Outputs should have ANSI codes Invalid Console Colors Run Tests Without Processing Output -C InVaLid misc/pass_and_fail.robot - Stderr Should Be Equal To [ ERROR ] Invalid console color value 'InVaLid'. Available 'AUTO', 'ON', 'OFF' and 'ANSI'.${USAGE TIP}\n + Stderr Should Be Equal To [ ERROR ] Invalid console color 'InVaLid'. Available: 'AUTO', 'ON', 'OFF' and 'ANSI'.${USAGE TIP}\n Console Width ${name} = Evaluate 'Start-' + '0123456789' * 9 + '-end' Run Tests --consolewidth 105 --name ${name} --doc x misc/pass_and_fail.robot - Check Stdout Contains ${SEP_CHAR1 * 105}\n ${name} :: x\n ${SEP_CHAR1 * 105}\n - Check Stdout Contains ${SEP_CHAR2 * 105}\n ${name[:-7]}... | FAIL |\n ${MSG_211}\n ${SEP_CHAR1 * 105}\n + Stdout Should Contain ${SEP_CHAR1 * 105}\n${name} :: x\n${SEP_CHAR1 * 105}\n + Stdout Should Contain ${SEP_CHAR2 * 105}\n${name[:-7]}... | FAIL |\n${MSG_211}\n${SEP_CHAR1 * 105}\n ${statuts} = Create Status Line Pass 93 PASS - Check Stdout Contains ${SEP_CHAR1 * 105}\n ${statuts}\n ${SEP_CHAR2 * 105}\n + Stdout Should Contain ${SEP_CHAR1 * 105}\n${statuts}\n${SEP_CHAR2 * 105}\n ${statuts} = Create Status Line Fail :: FAIL Expected failure 68 FAIL - Check Stdout Contains ${SEP_CHAR2 * 105}\n ${statuts}\n Expected failure\n ${SEP_CHAR2 * 105}\n + Stdout Should Contain ${SEP_CHAR2 * 105}\n${statuts}\nExpected failure\n${SEP_CHAR2 * 105}\n Run Tests -W 20 --name ${name} misc/pass_and_fail.robot - Check Stdout Contains ${SEP_CHAR1 * 20}\n Start-01234567890...\n ${SEP_CHAR1 * 20}\n - Check Stdout Contains ${SEP_CHAR2 * 20}\n Start-01... | FAIL |\n ${MSG_211}\n ${SEP_CHAR1 * 20}\n - Check Stdout Contains ${SEP_CHAR1 * 20}\n Pass${SPACE * 8}| PASS |\n ${SEP_CHAR2 * 20}\n - Check Stdout Contains ${SEP_CHAR2 * 20}\n Fail :: ... | FAIL |\n Expected failure\n ${SEP_CHAR2 * 20}\n + Stdout Should Contain ${SEP_CHAR1 * 20}\nStart-01234567890...\n${SEP_CHAR1 * 20}\n + Stdout Should Contain ${SEP_CHAR2 * 20}\nStart-01... | FAIL |\n${MSG_211}\n${SEP_CHAR1 * 20}\n + Stdout Should Contain ${SEP_CHAR1 * 20}\nPass${SPACE * 8}| PASS |\n${SEP_CHAR2 * 20}\n + Stdout Should Contain ${SEP_CHAR2 * 20}\nFail :: ... | FAIL |\nExpected failure\n${SEP_CHAR2 * 20}\n Invalid Width Run Tests Without Processing Output -W InVaLid misc/pass_and_fail.robot - Stderr Should Be Equal To [ ERROR ] Option '--consolewidth' expected integer value but got 'InVaLid'.${USAGE TIP}\n + Stderr Should Be Equal To [ ERROR ] Invalid value for option '--consolewidth': Expected integer, got 'InVaLid'.${USAGE TIP}\n + +Console links off + [Documentation] Console links being enabled is tested as part of testing console colors. + Run Tests With Warnings --console-links oFF --console-colors on + Outputs should have ANSI colors when not on Windows links=False + +Invalid link config + Run Tests Without Processing Output --ConsoleLinks InVaLid misc/pass_and_fail.robot + Stderr Should Be Equal To [ ERROR ] Invalid console link 'InVaLid'. Available: 'AUTO' and 'OFF'.${USAGE TIP}\n *** Keywords *** -Run Tests With Colors - [Arguments] ${colors} - Run Tests ${colors} --variable LEVEL1:WARN misc/pass_and_fail.robot +Run Tests With Warnings + [Arguments] ${options} + Run Tests ${options} --variable LEVEL1:WARN misc/pass_and_fail.robot -Outputs should not have ANSI colors - Check Stdout Contains | PASS | - Check Stdout Contains | FAIL | - Check Stderr Contains [ WARN ] +Outputs should not have ANSI codes + Stdout Should Contain | PASS | + Stdout Should Contain | FAIL | + Stderr Should Contain [ WARN ] Outputs should have ANSI colors when not on Windows - Run Keyword If os.sep == '/' Outputs should have ANSI colors - Run Keyword Unless os.sep == '/' Outputs should not have ANSI colors + [Arguments] ${links}=True + IF os.sep == '/' + Outputs should have ANSI codes ${links} + ELSE + Outputs should not have ANSI codes + END -Outputs should have ANSI colors - Check Stdout Does Not Contain | PASS | - Check Stdout Does Not Contain | FAIL | - Check Stderr Does Not Contain [ WARN ] - Check Stdout Contains PASS - Check Stdout Contains FAIL - Check Stderr Contains WARN +Outputs should have ANSI codes + [Arguments] ${links}=True + Stdout Should Not Contain | PASS | + Stdout Should Not Contain | FAIL | + Stderr Should Not Contain [ WARN ] + Stdout Should Contain | \x1b[32mPASS\x1b[0m | + Stdout Should Contain | \x1b[31mFAIL\x1b[0m | + Stderr Should Contain [ \x1b[33mWARN\x1b[0m ] + IF ${links} + Stdout Should Contain \x1b]8;;file:// + ELSE + Stdout Should Not Contain \x1b]8;;file:// + END diff --git a/atest/robot/cli/console/console_resource.robot b/atest/robot/cli/console/console_resource.robot index 2a8d4f0e2fc..5003b40a338 100644 --- a/atest/robot/cli/console/console_resource.robot +++ b/atest/robot/cli/console/console_resource.robot @@ -7,13 +7,13 @@ ${SEP_CHAR1} = ${SEP_CHAR2} - ${SEP_LINE1} ${SEP_CHAR1 * 78} ${SEP_LINE2} ${SEP_CHAR2 * 78} -${MSG_211} 2 critical tests, 1 passed, 1 failed\n 2 tests total, 1 passed, 1 failed -${MSG_110} 1 critical test, 1 passed, 0 failed\n 1 test total, 1 passed, 0 failed +${MSG_211} 2 tests, 1 passed, 1 failed +${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/console_type.robot b/atest/robot/cli/console/console_type.robot deleted file mode 100644 index 04eb845b229..00000000000 --- a/atest/robot/cli/console/console_type.robot +++ /dev/null @@ -1,73 +0,0 @@ -*** Settings *** -Resource console_resource.robot - -*** Test Cases *** -Verbose - Run and verify tests --console verbose - Stdout Should Be warnings_and_errors_stdout.txt - Stderr Should Be warnings_and_errors_stderr.txt - -Dotted - Run and verify tests --Console Dotted - Stdout Should Be warnings_and_errors_stdout_dotted.txt - Stderr Should Be warnings_and_errors_stderr.txt - -Dotted with width - Run tests --Console dotted --ConsoleWidth 10 misc/suites misc/suites - Stdout Should Be warnings_and_errors_stdout_dotted_10.txt - Stderr Should Be empty.txt - -Quiet - Run and verify tests --ConSole=QuiEt - Stdout Should Be empty.txt - Stderr Should Be warnings_and_errors_stderr.txt - -None - Run and verify tests --CONSOLE NONE - Stdout Should Be empty.txt - Stderr Should Be empty.txt - -Invalid - Run Tests Without Processing Output --Console Invalid misc/pass_and_fail.robot - Stderr Should Be Equal To [ ERROR ] Invalid console output type 'Invalid'. Available 'VERBOSE', 'DOTTED', 'QUIET' and 'NONE'.${USAGE TIP}\n - ---dotted - Run and verify tests --dotted - Stdout Should Be warnings_and_errors_stdout_dotted.txt - Stderr Should Be warnings_and_errors_stderr.txt - ---dotted with --rpa - Run and verify tests --dotted --rpa - Stdout Should Be warnings_and_errors_stdout_dotted.txt tests=tasks - Stderr Should Be warnings_and_errors_stderr.txt - ---quiet - Run and verify tests --Quiet - Stdout Should Be empty.txt - Stderr Should Be warnings_and_errors_stderr.txt - -Dotted does not show details for skipped - Run tests -. running/fatal_exception - Stdout Should Be dotted_fatal_error.txt - Stderr Should Be empty.txt - ---Dotted --ExitOnFailure - Run tests --ExitOnFailure --Dotted misc/suites - Stdout Should Be dotted_exitonfailure.txt - Stderr Should Be empty.txt - ---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 - Check test tags ${EMPTY} - ${tc} = Check test case User Keyword Without Name FAIL - ... Critical failure occurred and exit-on-failure mode is in use. - Should contain ${tc.tags} robot:exit - -*** Keywords *** -Run and verify tests - [Arguments] ${options} - Run Tests ${options} misc/warnings_and_errors.robot - Should Be Equal ${SUITE.status} PASS - Length Should Be ${SUITE.tests} 3 diff --git a/atest/robot/cli/console/custom_loggers.robot b/atest/robot/cli/console/custom_loggers.robot new file mode 100644 index 00000000000..f47c4f6f9a2 --- /dev/null +++ b/atest/robot/cli/console/custom_loggers.robot @@ -0,0 +1,81 @@ +*** Settings *** +Resource console_resource.robot + +*** Variables *** +${CONSOLES} ${CURDIR}${/}..${/}..${/}..${/}testresources${/}consoles + +*** Test Cases *** +Custom console by path + Run Tests --console ${CONSOLES}${/}CustomConsole.py misc/pass_and_fail.robot + Stdout Should Contain DEFAULT: Suite 'Pass And Fail' started + Stdout Should Contain DEFAULT: Test 'Pass' PASS + Stdout Should Contain DEFAULT: Test 'Fail' FAIL + Stdout Should Contain DEFAULT: Output: + Stdout Should Contain DEFAULT: Closing + Stderr Should Be Empty + +Custom console by path with argument + Run Tests --console ${CONSOLES}${/}CustomConsole.py:ARGUMENT misc/pass_and_fail.robot + Stdout Should Contain ARGUMENT: Suite 'Pass And Fail' started + Stdout Should Contain ARGUMENT: Test 'Pass' PASS + Stdout Should Contain ARGUMENT: Closing + Stderr Should Be Empty + +Custom console by module name + Run Tests --console CustomConsole --pythonpath ${CONSOLES} misc/pass_and_fail.robot + Stdout Should Contain DEFAULT: Suite 'Pass And Fail' started + Stdout Should Contain DEFAULT: Test 'Pass' PASS + Stdout Should Contain DEFAULT: Closing + Stderr Should Be Empty + +Custom console as module with functions + Run Tests --console ${CONSOLES}${/}module_console.py misc/pass_and_fail.robot + Stdout Should Contain MODULE: Suite 'Pass And Fail' started + Stdout Should Contain MODULE: Test 'Pass' PASS + Stdout Should Contain MODULE: Closing + Stdout Should Not Contain Output: + Stdout Should Not Contain Report: + Stdout Should Not Contain Log: + Stderr Should Be Empty + +Custom console by dotted name + Run Tests --console CustomConsole.CustomConsole --pythonpath ${CONSOLES} misc/pass_and_fail.robot + Stdout Should Contain DEFAULT: Suite 'Pass And Fail' started + Stdout Should Contain DEFAULT: Test 'Pass' PASS + Stdout Should Contain DEFAULT: Closing + Stderr Should Be Empty + +Custom console with named argument + Run Tests --console ${CONSOLES}${/}CustomConsole.py:name=NAMED misc/pass_and_fail.robot + Stdout Should Contain NAMED: Suite 'Pass And Fail' started + Stdout Should Contain NAMED: Test 'Pass' PASS + Stdout Should Contain NAMED: Closing + Stderr Should Be Empty + +Custom console with wrong arguments + ${path} = Normalize Path ${CONSOLES}${/}CustomConsole.py + Run Tests Without Processing Output --console ${CONSOLES}${/}CustomConsole.py:too:many:args misc/pass_and_fail.robot + Stderr Should Be Equal To [ ERROR ] Taking console logger '${CONSOLES}${/}CustomConsole.py:too:many:args' into use failed: Importing console logger '${path}' failed: Console logger 'CustomConsole' expected 0 to 1 arguments, got 3.${USAGE TIP}\n + +Non-existing custom console + Run Tests Without Processing Output --console NonExistent misc/pass_and_fail.robot + Stderr Should Start With [ ERROR ] Taking console logger 'NonExistent' into use failed: Importing console logger 'NonExistent' failed: ModuleNotFoundError: + +Extend built-in console loggers + [Template] Validate extending + Base Output: output.xml + None + Quiet + Dotted .F\nOutput: output.xml + Verbose Pass :: Overwrite!${SPACE*52}| PASS |\n + +*** Keywords *** +Validate extending + [Arguments] ${extended} ${next_content}= + Run Tests --console extend_builtin.Extend${extended} --pythonpath ${CONSOLES} misc/pass_and_fail.robot + VAR ${content} ${extended.upper()}: Pass And Fail\n${next_content} + IF $next_content + Stdout Should Contain ${content} + ELSE + Stdout Should Be Equal To ${content} + END diff --git a/atest/robot/cli/console/disable_standard_streams.py b/atest/robot/cli/console/disable_standard_streams.py new file mode 100644 index 00000000000..f22de07454a --- /dev/null +++ b/atest/robot/cli/console/disable_standard_streams.py @@ -0,0 +1,4 @@ +import sys + +sys.stdin = sys.stdout = sys.stderr = None +sys.__stdin__ = sys.__stdout__ = sys.__stderr__ = None diff --git a/atest/robot/cli/console/encoding.robot b/atest/robot/cli/console/encoding.robot index ac457aa0075..00194b8e242 100644 --- a/atest/robot/cli/console/encoding.robot +++ b/atest/robot/cli/console/encoding.robot @@ -14,7 +14,6 @@ ${STDERR} %{TEMPDIR}/redirect_stderr.txt *** Test Cases *** PYTHONIOENCODING is honored in console output - [Tags] no-ipy ${result} = Run Process ... @{COMMAND} ... env:PYTHONIOENCODING=ISO-8859-5 @@ -27,7 +26,7 @@ PYTHONIOENCODING is honored in console output Should Contain ${result.stdout} ???-????? T??t ??d K?yw?rd N?m?s, Спасибо${SPACE*29}| PASS | Invalid encoding configuration - [Tags] no-windows + [Tags] no-windows no-osx no-pypy ${cmd} = Join command line ... LANG=invalid ... LC_TYPE=invalid diff --git a/atest/robot/cli/console/escape.robot b/atest/robot/cli/console/escape.robot deleted file mode 100644 index ffcab772e1d..00000000000 --- a/atest/robot/cli/console/escape.robot +++ /dev/null @@ -1,20 +0,0 @@ -*** Settings *** -Suite Setup Run tests with escaping -Resource console_resource.robot - -*** Test Cases *** ---escape options and arguments - Should be equal ${SUITE.name} * name * - Should be equal ${SUITE.doc} @@ - Should contain tests ${SUITE} Pass - ---escape is deprecated - Check log message ${ERRORS[0]} Option '--escape' is deprecated. Use console escape mechanism instead. WARN - Stderr Should Be Equal To [ WARN ] Option '--escape' is deprecated. Use console escape mechanism instead.\n - -*** Keywords *** -Run tests with escaping - ${opts} = Catenate - ... --escape space:SP -E star:STAR --esc at:AT --ESCAPE quest:Q - ... --name STARSPnameSPSTAR --doc ATAT --include pQQs - Run tests ${opts} misc/pass_and_fail.robot diff --git a/atest/robot/cli/console/expected_output/ExpectedOutputLibrary.py b/atest/robot/cli/console/expected_output/ExpectedOutputLibrary.py index f72854cf53d..d30e9a91ab9 100644 --- a/atest/robot/cli/console/expected_output/ExpectedOutputLibrary.py +++ b/atest/robot/cli/console/expected_output/ExpectedOutputLibrary.py @@ -1,31 +1,33 @@ -from os.path import abspath, dirname, join from fnmatch import fnmatchcase from operator import eq +from os.path import abspath, dirname, join from robot.api import logger +from robot.api.deco import keyword - +ROBOT_AUTO_KEYWORDS = False CURDIR = dirname(abspath(__file__)) +@keyword def output_should_be(actual, expected, **replaced): - actual = _read_file(actual, 'Actual') - expected = _read_file(join(CURDIR, expected), 'Expected', replaced) + actual = _read_file(actual, "Actual") + expected = _read_file(join(CURDIR, expected), "Expected", replaced) if len(expected) != len(actual): - raise AssertionError('Lengths differ. Expected %d lines but got %d' - % (len(expected), len(actual))) + raise AssertionError( + f"Lengths differ. Expected {len(expected)} lines, got {len(actual)}." + ) for exp, act in zip(expected, actual): - tester = fnmatchcase if '*' in exp else eq + tester = fnmatchcase if "*" in exp else eq if not tester(act.rstrip(), exp.rstrip()): - raise AssertionError('Lines differ.\nExpected: %s\nActual: %s' - % (exp, act)) + raise AssertionError(f"Lines differ.\nExpected: {exp}\nActual: {act}") def _read_file(path, title, replaced=None): - with open(path) as file: + with open(path, encoding="UTF-8") as file: content = file.read() if replaced: for item in replaced: content = content.replace(item, replaced[item]) - logger.debug('%s:\n%s' % (title, content)) + logger.debug(f"{title}:\n{content}") return content.splitlines() diff --git a/atest/robot/cli/console/expected_output/dotted_exitonfailure.txt b/atest/robot/cli/console/expected_output/dotted_exitonfailure.txt index 7d01e778b45..02d5a5d6ce7 100644 --- a/atest/robot/cli/console/expected_output/dotted_exitonfailure.txt +++ b/atest/robot/cli/console/expected_output/dotted_exitonfailure.txt @@ -1,14 +1,13 @@ -Running suite 'Suites' with 11 tests. +Running suite 'Suites' with 13 tests. ============================================================================== -Fxxxxxxxxxx +.Fxxxxxxxxxxx ------------------------------------------------------------------------------ FAIL: Suites.Fourth.Suite4 First Expected ============================================================================== -Run suite 'Suites' with 11 tests in *. +Run suite 'Suites' with 13 tests in *. FAILED -11 critical tests, 0 passed, 11 failed -11 tests total, 0 passed, 11 failed +13 tests, 1 passed, 12 failed Output: *.xml diff --git a/atest/robot/cli/console/expected_output/dotted_exitonfailure_empty_test.txt b/atest/robot/cli/console/expected_output/dotted_exitonfailure_empty_test.txt index e479798e10f..fb7e84334f4 100644 --- a/atest/robot/cli/console/expected_output/dotted_exitonfailure_empty_test.txt +++ b/atest/robot/cli/console/expected_output/dotted_exitonfailure_empty_test.txt @@ -1,14 +1,13 @@ -Running suite 'Empty Testcase And Uk' with 10 tests. +Running suite 'Empty Testcase And Uk' with 9 tests. ============================================================================== -Fxxxxxxxxx +Fxxxxxxxx ------------------------------------------------------------------------------ FAIL: Empty Testcase And Uk. -Test case name cannot be empty. +Test name cannot be empty. ============================================================================== -Run suite 'Empty Testcase And Uk' with 10 tests in *. +Run suite 'Empty Testcase And Uk' with 9 tests in *. FAILED -10 critical tests, 0 passed, 10 failed -10 tests total, 0 passed, 10 failed +9 tests, 0 passed, 9 failed Output: *.xml 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..c7397fa047f --- /dev/null +++ b/atest/robot/cli/console/expected_output/dotted_exitonfailure_empty_test_stderr.txt @@ -0,0 +1,3 @@ +[[] ERROR ] Error in file '*[/\]empty_testcase_and_uk.robot' on line 42: Creating keyword '' failed: User keyword name 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. diff --git a/atest/robot/cli/console/expected_output/dotted_fatal_error.txt b/atest/robot/cli/console/expected_output/dotted_fatal_error.txt index ed1a15686fb..3a7d4a4d607 100644 --- a/atest/robot/cli/console/expected_output/dotted_fatal_error.txt +++ b/atest/robot/cli/console/expected_output/dotted_fatal_error.txt @@ -3,12 +3,11 @@ Running suite 'Fatal Exception' with 6 tests. Fxxxxx ------------------------------------------------------------------------------ FAIL: Fatal Exception.Python Library Kw.Exit From Python Keyword -FatalCatastrophyException +FatalCatastrophyException: BANG! ============================================================================== Run suite 'Fatal Exception' with 6 tests in *. FAILED -6 critical tests, 0 passed, 6 failed -6 tests total, 0 passed, 6 failed +6 tests, 0 passed, 6 failed Output: *.xml diff --git a/atest/robot/cli/console/expected_output/dotted_with_skip.txt b/atest/robot/cli/console/expected_output/dotted_with_skip.txt new file mode 100644 index 00000000000..6016cedf4c5 --- /dev/null +++ b/atest/robot/cli/console/expected_output/dotted_with_skip.txt @@ -0,0 +1,13 @@ +Running suite 'X' with 15 tests. +============================================================================== +.s.F........... +------------------------------------------------------------------------------ +FAIL: X.Suites.Fourth.Suite4 First +Expected +============================================================================== +Run suite 'X' with 15 tests in *. + +FAILED +15 tests, 13 passed, 1 failed, 1 skipped + +Output: *.xml diff --git a/atest/robot/cli/console/expected_output/dotted_with_skip_only.txt b/atest/robot/cli/console/expected_output/dotted_with_skip_only.txt new file mode 100644 index 00000000000..9f3cce78e9e --- /dev/null +++ b/atest/robot/cli/console/expected_output/dotted_with_skip_only.txt @@ -0,0 +1,10 @@ +Running suite 'Pass And Fail' with 2 tests. +============================================================================== +ss +============================================================================== +Run suite 'Pass And Fail' with 2 tests in *. + +SKIPPED +2 tests, 0 passed, 0 failed, 2 skipped + +Output: *.xml diff --git a/atest/robot/cli/console/expected_output/warnings_and_errors_stderr.txt b/atest/robot/cli/console/expected_output/warnings_and_errors_stderr.txt index 9f94adcb19d..7dbcbfa1a42 100644 --- a/atest/robot/cli/console/expected_output/warnings_and_errors_stderr.txt +++ b/atest/robot/cli/console/expected_output/warnings_and_errors_stderr.txt @@ -1,6 +1,6 @@ -? ERROR ? Error in file '*': Non-existing setting 'Non-Existing'. +[[] ERROR ] Error in file '*' on line 4: Non-existing setting 'Non-Existing'. [ WARN ] Warning in suite setup [ WARN ] Warning in test case -[ WARN ] Multiple test cases with name 'Warning in test case' executed in test suite 'Warnings And Errors'. +[ WARN ] Multiple tests with name 'Warning in test case' executed in suite 'Warnings And Errors'. [ ERROR ] Logged errors supported since 2.9 [ WARN ] Warning in suite teardown diff --git a/atest/robot/cli/console/expected_output/warnings_and_errors_stdout.txt b/atest/robot/cli/console/expected_output/warnings_and_errors_stdout.txt index e7495a9f296..c228aaa12be 100644 --- a/atest/robot/cli/console/expected_output/warnings_and_errors_stdout.txt +++ b/atest/robot/cli/console/expected_output/warnings_and_errors_stdout.txt @@ -8,7 +8,6 @@ Warning in test case :: Duplicate name causes warning | PASS | Error in test case | PASS | ------------------------------------------------------------------------------ Warnings And Errors | PASS | -3 critical tests, 3 passed, 0 failed -3 tests total, 3 passed, 0 failed +3 tests, 3 passed, 0 failed ============================================================================== Output: * diff --git a/atest/robot/cli/console/expected_output/warnings_and_errors_stdout_dotted.txt b/atest/robot/cli/console/expected_output/warnings_and_errors_stdout_dotted.txt index bec7b532624..8946dd2c843 100644 --- a/atest/robot/cli/console/expected_output/warnings_and_errors_stdout_dotted.txt +++ b/atest/robot/cli/console/expected_output/warnings_and_errors_stdout_dotted.txt @@ -5,7 +5,6 @@ Running suite 'Warnings And Errors' with 3 tests. Run suite 'Warnings And Errors' with 3 tests in *. PASSED -3 critical tests, 3 passed, 0 failed -3 tests total, 3 passed, 0 failed +3 tests, 3 passed, 0 failed Output: * diff --git a/atest/robot/cli/console/expected_output/warnings_and_errors_stdout_dotted_10.txt b/atest/robot/cli/console/expected_output/warnings_and_errors_stdout_dotted_10.txt index 306ccb21baf..4a0de08d4a2 100644 --- a/atest/robot/cli/console/expected_output/warnings_and_errors_stdout_dotted_10.txt +++ b/atest/robot/cli/console/expected_output/warnings_and_errors_stdout_dotted_10.txt @@ -1,8 +1,8 @@ -Running suite 'Suites & Suites' with 22 tests. +Running suite 'Suites & Suites' with 26 tests. ========== -F......... .F........ -.. +....F..... +...... ---------- FAIL: Suites & Suites.Suites.Fourth.Suite4 First Expected @@ -10,10 +10,9 @@ Expected FAIL: Suites & Suites.Suites.Fourth.Suite4 First Expected ========== -Run suite 'Suites & Suites' with 22 tests in * +Run suite 'Suites & Suites' with 26 tests in * FAILED -22 critical tests, 20 passed, 2 failed -22 tests total, 20 passed, 2 failed +26 tests, 24 passed, 2 failed Output: * diff --git a/atest/robot/cli/console/markers.robot b/atest/robot/cli/console/markers.robot index aea0632aca0..f2be467c6d5 100644 --- a/atest/robot/cli/console/markers.robot +++ b/atest/robot/cli/console/markers.robot @@ -8,13 +8,13 @@ ${SEPARATOR} - *** Test Cases *** Few Pass Markers - Check Stdout Contains SEPARATOR=\r + Stdout Should Contain SEPARATOR=\r ... ${TESTNAME}${SPACE * 54}..... ... ${SPACE * 78} ... ${TESTNAME}${SPACE * 54}| PASS |\n Few Pass And Fail Markers - Check Stdout Contains SEPARATOR= + Stdout Should Contain SEPARATOR= ... ${TESTNAME}${SPACE * 45}..F.F..\r ... ${SPACE * 78}\r ... ${TESTNAME}${SPACE * 45}| FAIL |\n @@ -25,7 +25,7 @@ Few Pass And Fail Markers ... 2) AssertionError\n More Markers Than Fit Into Status Area - Check Stdout Contains SEPARATOR=\r + Stdout Should Contain SEPARATOR=\r ... ${TESTNAME}${SPACE * 32}........ ... ${SPACE * 78} ... ${TESTNAME}${SPACE * 32}........ @@ -37,7 +37,7 @@ More Markers Than Fit Into Status Area ... ${TESTNAME}${SPACE * 32}| PASS |\n Warnings Are Shown Correctly - Check Stdout Contains SEPARATOR=\r + Stdout Should Contain SEPARATOR=\r ... ${TESTNAME}${SPACE * 42}.... ... ${SPACE * 78} ... ${TESTNAME}${SPACE * 42}........ @@ -45,12 +45,12 @@ Warnings Are Shown Correctly ... ${TESTNAME}${SPACE * 42}.... ... ${SPACE * 78} ... ${TESTNAME}${SPACE * 42}| PASS |\n - Check Stderr Contains SEPARATOR=\n + Stderr Should Contain SEPARATOR=\n ... [ WARN ] Warning ... [ WARN ] Second warning Errors Are Shown Correctly - Check Stderr Contains [ ERROR ] Error in file + Stderr Should Contain [ ERROR ] Error in file Markers Can Be Disabled Run Tests And Verify That Markers Are Disabled -K OFF @@ -60,13 +60,13 @@ Markers Are Disabled By Default When Redirecting Output Invalid Markers Run Tests Without Processing Output -K InVaLid ${TEST FILE} - Stderr Should Be Equal To [ ERROR ] Invalid console marker value 'InVaLid'. Available 'AUTO', 'ON' and 'OFF'.${USAGE TIP}\n + Stderr Should Be Equal To [ ERROR ] Invalid console marker 'InVaLid'. Available: 'AUTO', 'ON' and 'OFF'.${USAGE TIP}\n *** Keywords *** Run Tests And Verify That Markers Are Disabled [Arguments] ${opt} Run Tests Without Processing Output ${opt} ${TEST FILE} - Check Stdout Contains SEPARATOR=\n + Stdout Should Contain SEPARATOR=\n ... Few Pass Markers${SPACE * 54}| PASS | ... ${SEPARATOR * 78} ... Few Pass And Fail Markers${SPACE * 45}| FAIL | diff --git a/atest/robot/cli/console/max_assign_length.robot b/atest/robot/cli/console/max_assign_length.robot new file mode 100644 index 00000000000..587c9f1b86f --- /dev/null +++ b/atest/robot/cli/console/max_assign_length.robot @@ -0,0 +1,78 @@ +*** Settings *** +Documentation Testing that long variable value are truncated +Test Template Assignment messages should be +Resource atest_resource.robot + +*** Variables *** +@{TESTS} 10 chars 200 chars 201 chars 1000 chars 1001 chars VAR + +*** Test Cases *** +Default limit + ${EMPTY} + ... '0123456789' + ... '0123456789' * 20 + ... '0123456789' * 20 + '...' + ... '0123456789' * 20 + '...' + ... '0123456789' * 20 + '...' + ... '0123456789' * 20 + '...' + +Custom limit + 10 + ... '0123456789' + ... '0123456789' + '...' + ... '0123456789' + '...' + ... '0123456789' + '...' + ... '0123456789' + '...' + ... '0123456789' + '...' + 1000 + ... '0123456789' + ... '0123456789' * 20 + ... '0123456789' * 20 + '0' + ... '0123456789' * 100 + ... '0123456789' * 100 + '...' + ... '0123456789' * 100 + 10000 + ... '0123456789' + ... '0123456789' * 20 + ... '0123456789' * 20 + '0' + ... '0123456789' * 100 + ... '0123456789' * 100 + '0' + ... '0123456789' * 100 + +Hide value + 0 + ... '...' + ... '...' + ... '...' + ... '...' + ... '...' + ... '...' + -666 + ... '...' + ... '...' + ... '...' + ... '...' + ... '...' + ... '...' + +Invalid + [Template] NONE + Run Tests Without Processing Output --maxass oops cli/console/max_assign_length.robot + Stderr Should Be Equal To + ... [ ERROR ] Invalid value for option '--maxassignlength': + ... Expected integer, got 'oops'.${USAGE TIP}\n + + +*** Keywords *** +Assignment messages should be + [Arguments] ${limit} @{messages} + IF $limit + Run Tests --maxassignlength ${limit} cli/console/max_assign_length.robot + ELSE + Run Tests ${EMPTY} cli/console/max_assign_length.robot + END + FOR ${name} ${msg} IN ZIP ${TESTS} ${messages} mode=STRICT + ${tc} = Check Test Case ${name} + ${msg} = Evaluate ${msg} + Check Log Message ${tc[0, 0]} \${value} = ${msg} + END diff --git a/atest/robot/cli/console/max_error_lines.robot b/atest/robot/cli/console/max_error_lines.robot index 7cbf7cca3c5..a2790f577cb 100644 --- a/atest/robot/cli/console/max_error_lines.robot +++ b/atest/robot/cli/console/max_error_lines.robot @@ -27,9 +27,9 @@ Max Error Lines None Invalid Values Run Tests Without Processing Output --maxerrorlines InVaLid misc/pass_and_fail.robot - Stderr Should Be Equal To [ ERROR ] Option '--maxerrorlines' expected integer value but got 'InVaLid'.${USAGE TIP}\n + Stderr Should Be Equal To [ ERROR ] Invalid value for option '--maxerrorlines': Expected integer, got 'InVaLid'.${USAGE TIP}\n Run Tests Without Processing Output --maxerrorlines -100 misc/pass_and_fail.robot - Stderr Should Be Equal To [ ERROR ] Option '--maxerrorlines' expected an integer value greater that 10 but got '-100'.${USAGE TIP}\n + Stderr Should Be Equal To [ ERROR ] Invalid value for option '--maxerrorlines': Expected integer greater than 10, got -100.${USAGE TIP}\n *** Keywords *** Has Been Cut @@ -38,19 +38,21 @@ Has Been Cut Should Contain ${test.message} ${EXPLANATION} 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} + Error Message In Log Should Not Have Been Cut ${test.body} + RETURN ${test} Error Message In Log Should Not Have Been Cut - [Arguments] ${kws} - @{keywords} = Set Variable ${kws} - : FOR ${kw} IN @{keywords} - \ Run Keyword If ${kw.msgs} Should Not Contain ${kw.msgs[-1].message} ${EXPLANATION} - \ Error Message In Log Should Not Have Been Cut ${kw.kws} + [Arguments] ${items} + FOR ${item} IN @{items} + FOR ${msg} IN @{item.messages} + Should Not Contain ${msg.message} ${EXPLANATION} + END + Error Message In Log Should Not Have Been Cut ${item.non_messages} + END 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/atest/robot/cli/console/non_ascii.robot b/atest/robot/cli/console/non_ascii.robot index 773ce8290f6..42e7e2c9945 100644 --- a/atest/robot/cli/console/non_ascii.robot +++ b/atest/robot/cli/console/non_ascii.robot @@ -1,6 +1,6 @@ *** Settings *** Suite Setup Run Tests --name "Hyvää Üötä €åppa" parsing/utf8_data.robot -Test Template Check stdout contains regexp +Test Template Stdout Should Contain Regexp Resource console_resource.robot *** Test Cases *** diff --git a/atest/robot/cli/console/piping.py b/atest/robot/cli/console/piping.py new file mode 100644 index 00000000000..9386a0d2d33 --- /dev/null +++ b/atest/robot/cli/console/piping.py @@ -0,0 +1,24 @@ +import sys + + +def read_all(): + fails = 0 + for line in sys.stdin: + if "FAIL" in line: + fails += 1 + print(f"{fails} lines with 'FAIL' found!") + + +def read_some(): + for line in sys.stdin: + if "FAIL" in line: + print("Line with 'FAIL' found!") + sys.stdin.close() + break + + +def read_none(): + sys.stdin.close() + + +globals()[sys.argv[1]]() diff --git a/atest/robot/cli/console/piping.robot b/atest/robot/cli/console/piping.robot new file mode 100644 index 00000000000..15a50291753 --- /dev/null +++ b/atest/robot/cli/console/piping.robot @@ -0,0 +1,37 @@ +*** Settings *** +Resource console_resource.robot + +*** Variables *** +@{COMMAND} @{INTERPRETER.runner} +... --output ${OUTPUT} +... --report NONE +... --log NONE +... --pythonpath ${CURDIR}${/}..${/}..${/}..${/}testresources${/}testlibs +... ${DATADIR}${/}misc +${OUTPUT} %{TEMPDIR}${/}piping.xml +${TARGET} ${CURDIR}${/}piping.py + +*** Test Cases *** +Pipe to command consuming all data + Run with pipe and validate results read_all + Should Be Equal ${STDOUT} 20 lines with 'FAIL' found! + +Pipe to command consuming some data + Run with pipe and validate results read_some + Should Be Equal ${STDOUT} Line with 'FAIL' found! + +Pipe to command consuming no data + Run with pipe and validate results read_none + Should Be Empty ${STDOUT} + +*** Keywords *** +Run with pipe and validate results + [Arguments] ${pipe style} + ${command} = Join Command Line @{COMMAND} + ${result} = Run Process ${command} | python ${TARGET} ${pipe style} shell=True + Log Many RC: ${result.rc} STDOUT:\n${result.stdout} STDERR:\n${result.stderr} + Should Be Equal ${result.rc} ${0} + Process Output ${OUTPUT} + Check Test Case Pass + Check Test Case Fail + Set Test Variable ${STDOUT} ${result.stdout} diff --git a/atest/robot/cli/console/standard_streams_disabled.robot b/atest/robot/cli/console/standard_streams_disabled.robot new file mode 100644 index 00000000000..44af45e78b2 --- /dev/null +++ b/atest/robot/cli/console/standard_streams_disabled.robot @@ -0,0 +1,23 @@ +*** Settings *** +Suite Setup Run tests with standard streams disabled +Resource console_resource.robot + +*** Test Cases *** +Execution succeeds + Should Be Equal ${SUITE.name} Log + +Console outputs are disabled + Stdout Should Be empty.txt + Stderr Should Be empty.txt + +Log To Console keyword succeeds + Check Test Case Log to console + +*** Keywords *** +Run tests with standard streams disabled + [Documentation] Streams are disabled by using the sitecustomize module: + ... https://docs.python.org/3/library/site.html#module-sitecustomize + Copy File ${CURDIR}/disable_standard_streams.py %{TEMPDIR}/sitecustomize.py + Set Environment Variable PYTHONPATH %{TEMPDIR} + Run Tests ${EMPTY} standard_libraries/builtin/log.robot + [Teardown] Remove File %{TEMPDIR}/sitecustomize.py diff --git a/atest/robot/cli/console/verbose_layout.robot b/atest/robot/cli/console/verbose_layout.robot index 8f3ca65c359..4cccad966be 100644 --- a/atest/robot/cli/console/verbose_layout.robot +++ b/atest/robot/cli/console/verbose_layout.robot @@ -4,45 +4,45 @@ Resource console_resource.robot *** Test Cases *** Top Level Suite Start - Check Stdout Contains ${SEP_LINE1}\n Subsuites${SPACE * 69}\n ${SEP_LINE1}\n + Stdout Should Contain ${SEP_LINE1}\nSubsuites${SPACE * 69}\n${SEP_LINE1}\n Top Level Suite End ${status} = Create Status Line Subsuites 61 FAIL - Check Stdout Contains ${SEP_LINE1}\n ${status}\n ${MSG_211}\n ${SEP_LINE1}\n + Stdout Should Contain ${SEP_LINE1}\n${status}\n${MSG_211}\n${SEP_LINE1}\n Nested Suite Start - Check Stdout Contains ${SEP_LINE1}\n Subsuites.Sub1 :: Normal test cases${SPACE * 43}\n ${SEP_LINE1}\n + Stdout Should Contain ${SEP_LINE1}\nSubsuites.Sub1 :: Normal test cases${SPACE * 43}\n${SEP_LINE1}\n Nested Suite End ${status} = Create Status Line Subsuites.Sub2 :: Normal test cases 35 PASS - Check Stdout Contains ${SEP_LINE2}\n ${status}\n ${MSG_110}\n ${SEP_LINE1}\n + Stdout Should Contain ${SEP_LINE2}\n${status}\n${MSG_110}\n${SEP_LINE1}\n Passing Test ${status} = Create Status Line SubSuite2 First 55 PASS - Check Stdout Contains ${SEP_LINE1}\n ${status}\n ${SEP_LINE2}\n + Stdout Should Contain ${SEP_LINE1}\n${status}\n${SEP_LINE2}\n Failing Test ${status} = Create Status Line SubSuite1 First 55 FAIL - Check Stdout Contains ${SEP_LINE1}\n ${status}\n This test was doomed to fail: YES != NO\n ${SEP_LINE2}\n + Stdout Should Contain ${SEP_LINE1}\n${status}\nThis test was doomed to fail: YES != NO\n${SEP_LINE2}\n Outputs ${stdout} = Get Stdout ${outputs} = Evaluate '''${stdout.replace('\\','\\\\')}'''.split('${SEP_LINE1}')[-1] - Should Match Regexp ${outputs} Debug: \ \ .*mydebug.txt\n Output: \ .*output.xml\n Log: \ \ \ \ .*mylog.html\n Report: \ .*myreport.html\n + Should Match Regexp ${outputs} Debug: \ \ .*mydebug.txt\nOutput: \ .*output.xml\nLog: \ \ \ \ .*mylog.html\nReport: \ .*myreport.html\n Long Documentation Should Be Cut ${doc} = Evaluate '0123456789' * 10 Run Tests --name "My Name" --doc start${doc}end misc/normal.robot ${expbase} = Evaluate 'My Name :: start' + '0123456789'*5 - Check Stdout Contains ${SEP_LINE1}\n ${expbase}012345678...\n ${SEP_LINE1}\n - Check Stdout Contains ${SEP_LINE2}\n ${expbase}... | PASS |\n + Stdout Should Contain ${SEP_LINE1}\n${expbase}012345678...\n${SEP_LINE1}\n + Stdout Should Contain ${SEP_LINE2}\n${expbase}... | PASS |\n Long Name Should Be Cut ${name} = Evaluate '0123456789' * 10 Run Tests --name start${name}end --doc whatever misc/normal.robot ${expbase} = Evaluate 'start' + '0123456789'*6 - Check Stdout Contains ${SEP_LINE1}\n ${expbase}0123456789...\n ${SEP_LINE1}\n - Check Stdout Contains ${SEP_LINE2}\n ${expbase}0... | PASS |\n + Stdout Should Contain ${SEP_LINE1}\n${expbase}0123456789...\n${SEP_LINE1}\n + Stdout Should Contain ${SEP_LINE2}\n${expbase}0... | PASS |\n Layout Is Not Broken When There Are Warnings Run Tests ${EMPTY} misc/warnings_and_errors.robot diff --git a/atest/robot/cli/dryrun/dryrun.robot b/atest/robot/cli/dryrun/dryrun.robot index fb4b792b762..f6e0c24269c 100644 --- a/atest/robot/cli/dryrun/dryrun.robot +++ b/atest/robot/cli/dryrun/dryrun.robot @@ -1,149 +1,142 @@ *** Settings *** Suite Setup Run Tests --dryrun cli/dryrun/dryrun.robot cli/dryrun/more_tests.robot Test Teardown Last keyword should have been validated -Resource atest_resource.robot +Resource dryrun_resource.robot *** Test Cases *** Passing keywords ${tc}= Check Test Case ${TESTNAME} - Should have correct number of keywords ${tc} 4 - Check Keyword Data ${tc.kws[0]} BuiltIn.Log status=NOT_RUN args=Hello from test - Check Keyword Data ${tc.kws[1]} OperatingSystem.List Directory status=NOT_RUN assign=\${contents} args=. - Check Keyword Data ${tc.kws[2]} resource.Simple UK - Check Keyword Data ${tc.kws[2].kws[0]} BuiltIn.Log status=NOT_RUN args=Hello from UK + Length Should Be ${tc.body} 4 + Check Keyword Data ${tc[0]} BuiltIn.Log status=NOT RUN args=Hello from test + Check Keyword Data ${tc[1]} OperatingSystem.List Directory status=NOT RUN assign=\${contents} args=. + Check Keyword Data ${tc[2]} resource.Simple UK + Check Keyword Data ${tc[2, 0]} BuiltIn.Log status=NOT RUN args=Hello from UK Keywords with embedded arguments ${tc}= Check Test Case ${TESTNAME} - Should have correct number of keywords ${tc} 3 - 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 + Length Should Be ${tc.body} 5 + Check Keyword Data ${tc[0]} Embedded arguments here + Check Keyword Data ${tc[0, 0]} BuiltIn.No Operation status=NOT RUN + Check Keyword Data ${tc[1]} Embedded args rock here + Check Keyword Data ${tc[1, 0]} BuiltIn.No Operation status=NOT RUN + Check Keyword Data ${tc[2]} Some embedded and normal args args=42 + Check Keyword Data ${tc[2, 0]} BuiltIn.No Operation status=NOT RUN + Check Keyword Data ${tc[3]} Some embedded and normal args args=\${does not exist} + Check Keyword Data ${tc[3, 0]} BuiltIn.No Operation status=NOT RUN + +Keywords with types + Check Test Case ${TESTNAME} + +Keywords with types that would fail + Check Test Case ${TESTNAME} + Error In File 1 cli/dryrun/dryrun.robot 214 + ... Creating keyword 'Invalid type' failed: Invalid argument specification: Invalid argument '\${arg: bad}': Unrecognized type 'bad'. Library keyword with embedded arguments ${tc}= Check Test Case ${TESTNAME} - Should have correct number of keywords ${tc} 2 - Check Keyword Data ${tc.kws[0]} EmbeddedArgs.Log 42 times status=NOT_RUN + Length Should Be ${tc.body} 2 + Check Keyword Data ${tc[0]} EmbeddedArgs.Log 42 times status=NOT RUN Keywords that would fail ${tc}= Check Test Case ${TESTNAME} - Should have correct number of keywords ${tc} 3 - Check Keyword Data ${tc.kws[0]} BuiltIn.Fail status=NOT_RUN args=Not actually executed so won't fail. - Check Keyword Data ${tc.kws[1]} resource.Fail In UK - Should have correct number of keywords ${tc.kws[1]} 2 - Check Keyword Data ${tc.kws[1].kws[0]} BuiltIn.Fail status=NOT_RUN args= - Check Keyword Data ${tc.kws[1].kws[1]} BuiltIn.Fail status=NOT_RUN args=And again + Length Should Be ${tc.body} 3 + Check Keyword Data ${tc[0]} BuiltIn.Fail status=NOT RUN args=Not actually executed so won't fail. + Check Keyword Data ${tc[1]} resource.Fail In UK + Length Should Be ${tc[1].body} 2 + Check Keyword Data ${tc[1, 0]} BuiltIn.Fail status=NOT RUN args= + Check Keyword Data ${tc[1, 1]} BuiltIn.Fail status=NOT RUN args=And again Scalar variables are not checked in keyword arguments [Documentation] Variables are too often set somehow dynamically that we cannot expect them to always exist. ${tc}= Check Test Case ${TESTNAME} - Check Keyword Data ${tc.kws[0]} BuiltIn.Log status=NOT_RUN args=\${TESTNAME} - Check Keyword Data ${tc.kws[1]} BuiltIn.Log status=NOT_RUN args=\${this does not exist} + Check Keyword Data ${tc[0]} BuiltIn.Log status=NOT RUN args=\${TESTNAME} + Check Keyword Data ${tc[1]} BuiltIn.Log status=NOT RUN args=\${this does not exist} List variables are not checked in keyword arguments [Documentation] See the doc of the previous test Check Test Case ${TESTNAME} +Dict variables are not checked in keyword arguments + [Documentation] See the doc of the previous test + Check Test Case ${TESTNAME} + Variables are not checked in when arguments are embedded [Documentation] See the doc of the previous test ${tc}= Check Test Case ${TESTNAME} - Check Keyword Data ${tc.kws[0]} Embedded \${TESTNAME} here - Check Keyword Data ${tc.kws[0].kws[0]} BuiltIn.No Operation status=NOT_RUN - Check Keyword Data ${tc.kws[1]} Embedded \${nonex} here - Check Keyword Data ${tc.kws[1].kws[0]} BuiltIn.No Operation status=NOT_RUN + Check Keyword Data ${tc[0]} Embedded \${TESTNAME} here + Check Keyword Data ${tc[0, 0]} BuiltIn.No Operation status=NOT RUN + Check Keyword Data ${tc[1]} Embedded \${nonex} here + Check Keyword Data ${tc[1, 0]} BuiltIn.No Operation status=NOT RUN Setup/teardown with non-existing variable is ignored ${tc} = Check Test Case ${TESTNAME} - Should Be Equal ${SUITE.setup} ${NONE} - Should Be Equal ${tc.setup} ${NONE} - Should Be Equal ${tc.teardown} ${NONE} + Setup Should Not Be Defined ${SUITE} + Setup Should Not Be Defined ${tc} + Teardown Should Not Be Defined ${tc} Setup/teardown with existing variable is resolved and executed ${tc} = Check Test Case ${TESTNAME} - Should Be Equal ${tc.setup.name} BuiltIn.No Operation - Should Be Equal ${tc.teardown.name} Teardown - ${args} = Create List \${nonex arg} - Lists Should Be Equal ${tc.teardown.args} ${args} - Should Be Equal ${tc.teardown.keywords[0].name} BuiltIn.Log + Check Keyword Data ${tc.setup} BuiltIn.No Operation status=NOT RUN type=SETUP + Check Keyword Data ${tc.teardown} Teardown args=\${nonex arg} type=TEARDOWN + Check Keyword Data ${tc.teardown[0]} BuiltIn.Log args=\${arg} status=NOT RUN User keyword return value Check Test Case ${TESTNAME} +Non-existing variable in user keyword return value + Check Test Case ${TESTNAME} + Test Setup and Teardown ${tc}= Check Test Case ${TESTNAME} - Should have correct number of keywords ${tc} 2 - Should Be Equal ${tc.setup.name} BuiltIn.Log - Should Be Equal ${tc.teardown.name} Does not exist + Length Should Be ${tc.body} 2 + Check Keyword Data ${tc.setup} BuiltIn.Log args=Hello Setup status=NOT RUN type=SETUP + Check Keyword Data ${tc.teardown} Does not exist status=FAIL type=TEARDOWN Keyword Teardown ${tc}= Check Test Case ${TESTNAME} - Should have correct number of keywords ${tc} 2 - Should Be Equal ${tc.kws[0].kws[1].name} Does not exist + Length Should Be ${tc.body} 2 + Check Keyword Data ${tc[0].teardown} Does not exist status=FAIL type=TEARDOWN -For Loops +Keyword teardown with non-existing variable is ignored + Check Test Case ${TESTNAME} + +Keyword teardown with existing variable is resolved and executed ${tc}= Check Test Case ${TESTNAME} - Should have correct number of keywords ${tc} 4 - Should have correct number of keywords ${tc.kws[0]} 1 - Should have correct number of keywords ${tc.kws[0].kws[0]} 2 - Should have correct number of keywords ${tc.kws[1]} 3 - Should have correct number of keywords ${tc.kws[1].kws[1]} 1 + Check Keyword Data ${tc[0].teardown} Teardown args=\${I DO NOT EXIST} type=TEARDOWN + Check Keyword Data ${tc[0].teardown[0]} BuiltIn.Log args=\${arg} status=NOT RUN Non-existing keyword name Check Test Case ${TESTNAME} Invalid syntax in UK Check Test Case ${TESTNAME} - ${source} = Normalize Path ${DATADIR}/cli/dryrun/dryrun.robot - ${message} = Catenate - ... Error in test case file '${source}': - ... Creating keyword 'Invalid Syntax UK' failed: - ... Invalid argument specification: - ... Invalid argument syntax '${arg'. - Check Log Message ${ERRORS[0]} ${message} ERROR + Error In File 0 cli/dryrun/dryrun.robot 210 + ... SEPARATOR=\n + ... Creating keyword 'Invalid Syntax UK' failed: Invalid argument specification: Multiple errors: + ... - Invalid argument syntax '\${oops'. + ... - Non-default argument after default arguments. Multiple Failures Check Test Case ${TESTNAME} Avoid keyword in dry-run ${tc} = Check Test Case ${TESTNAME} - Keyword should have been skipped with tag ${tc.kws[0]} Keyword not run in dry-run robot:no-dry-run - Keyword should have been skipped with tag ${tc.kws[1]} Another keyword not run in dry-run ROBOT: no-dry-run - Keyword should have been skipped with tag ${tc.kws[2].kws[0]} Keyword not run in dry-run robot:no-dry-run - Keyword should have been skipped with tag ${tc.kws[2].kws[1]} Another keyword not run in dry-run ROBOT: no-dry-run - Keyword should have been validated ${tc.kws[2].kws[2]} - Keyword should have been validated ${tc.kws[3]} + Keyword should have been skipped with tag ${tc[0]} Keyword not run in dry-run robot:no-dry-run + Keyword should have been skipped with tag ${tc[1]} Another keyword not run in dry-run ROBOT: no-dry-run + Keyword should have been skipped with tag ${tc[2, 0]} Keyword not run in dry-run robot:no-dry-run + Keyword should have been skipped with tag ${tc[2, 1]} Another keyword not run in dry-run ROBOT: no-dry-run + Keyword should have been validated ${tc[2, 2]} + Keyword should have been validated ${tc[3]} Invalid imports - Import should have failed 1 cli/dryrun/dryrun.robot - ... Importing test library 'DoesNotExist' failed: *Error: * - Import should have failed 2 cli/dryrun/dryrun.robot + Error in file 2 cli/dryrun/dryrun.robot 7 + ... Importing library 'DoesNotExist' failed: *Error: * + Error in file 3 cli/dryrun/dryrun.robot 8 ... Variable file 'wrong_path.py' does not exist. - ... traceback= - Import should have failed 3 cli/dryrun/dryrun.robot + Error in file 4 cli/dryrun/dryrun.robot 9 ... Resource file 'NonExisting.robot' does not exist. - ... traceback= [Teardown] NONE Test from other suite Check Test Case Some Other Test [Teardown] NONE - -*** Keywords *** -Should have correct number of keywords - [Arguments] ${test or uk} ${exp number of kws} - Log ${test or uk.kws} - Should Be Equal As Integers ${test or uk.kw_count} ${exp number of kws} - -Keyword should have been skipped with tag - [Arguments] ${kw} ${name} ${tags} - Check Keyword Data ${kw} ${name} status=PASS tags=${tags} - Should Be Empty ${kw.kws} - -Keyword should have been validated - [Arguments] ${kw} - Check Keyword Data ${kw} This is validated - Check Keyword Data ${kw.kws[0]} BuiltIn.Log status=NOT_RUN args=This is validated - -Last keyword should have been validated - ${tc} = Get test case ${TEST NAME} - Keyword should have been validated ${tc.kws[-1]} diff --git a/atest/robot/cli/dryrun/dryrun_resource.robot b/atest/robot/cli/dryrun/dryrun_resource.robot new file mode 100644 index 00000000000..3591a4168fd --- /dev/null +++ b/atest/robot/cli/dryrun/dryrun_resource.robot @@ -0,0 +1,16 @@ +*** Settings *** +Resource atest_resource.robot + +*** Keywords *** +Keyword should have been skipped with tag + [Arguments] ${kw} ${name} ${tags} + Check Keyword Data ${kw} ${name} status=PASS tags=${tags} children=0 + +Keyword should have been validated + [Arguments] ${kw} + Check Keyword Data ${kw} resource.This is validated + Check Keyword Data ${kw[0]} BuiltIn.Log status=NOT RUN args=This is validated + +Last keyword should have been validated + ${tc} = Get Test Case ${TEST NAME} + Keyword should have been validated ${tc[-1]} diff --git a/atest/robot/cli/dryrun/executed_builtin_keywords.robot b/atest/robot/cli/dryrun/executed_builtin_keywords.robot index b4e70d1dde5..7aa269847e3 100644 --- a/atest/robot/cli/dryrun/executed_builtin_keywords.robot +++ b/atest/robot/cli/dryrun/executed_builtin_keywords.robot @@ -4,12 +4,28 @@ Resource atest_resource.robot *** Test Cases *** Import Library - Check Test Case ${TESTNAME} - Check Syslog Contains Imported library 'String' with arguments [ ] - Check Syslog Contains Imported library 'ParameterLibrary' with arguments [ value | 42 ] + ${tc} = Check Test Case ${TESTNAME} + Check Keyword Data ${tc[0]} BuiltIn.Import Library args=String + Syslog Should Contain Imported library 'String' with arguments [ ] + Syslog Should Contain Imported library 'ParameterLibrary' with arguments [ value | 42 ] + +Import Resource + ${tc} = Check Test Case ${TESTNAME} + Check Keyword Data ${tc[0]} BuiltIn.Import Resource args=\${RESOURCE} + ${resource} = Normalize Path ${DATADIR}/cli/dryrun/resource.robot + Syslog Should Contain Imported resource file '${resource}' (6 keywords). 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 + Check Keyword Data ${tc[0]} BuiltIn.Set Library Search Order args=Second + Should Be Equal ${tc[1].full_name} Second.Parameters + Should Be Equal ${tc[2].full_name} First.Parameters + Should Be Equal ${tc[4].full_name} Dynamic.Parameters + +Set Tags + ${tc} = Check Test Tags ${TESTNAME} \${2} \${var} Tag0 Tag1 Tag2 + Check Keyword Data ${tc[0]} BuiltIn.Set Tags args=Tag1, Tag2, \${var}, \${2} + +Remove Tags + ${tc} = Check Test Tags ${TESTNAME} Tag1 Tag3 + Check Keyword Data ${tc[0]} BuiltIn.Remove Tags args=Tag2, \${var} diff --git a/atest/robot/cli/dryrun/for.robot b/atest/robot/cli/dryrun/for.robot new file mode 100644 index 00000000000..879609609d5 --- /dev/null +++ b/atest/robot/cli/dryrun/for.robot @@ -0,0 +1,34 @@ +*** Settings *** +Suite Setup Run Tests --dryrun cli/dryrun/for.robot +Test Teardown Last keyword should have been validated +Resource dryrun_resource.robot + +*** Test Cases *** +FOR + ${tc} = Check Test Case ${TESTNAME} + Validate loops ${tc} 4 + Length should be ${tc[2].body} 3 + Length should be ${tc[2, 0].body} 0 + Length should be ${tc[2, 1].body} 1 + Length should be ${tc[2, 2].body} 0 + +FOR IN RANGE + ${tc} = Check Test Case ${TESTNAME} + Validate loops ${tc} + +FOR IN ENUMERATE + ${tc} = Check Test Case ${TESTNAME} + Validate loops ${tc} + +FOR IN ZIP + ${tc} = Check Test Case ${TESTNAME} + Validate loops ${tc} + +*** Keywords *** +Validate loops + [Arguments] ${tc} ${kws}=3 + Length should be ${tc.body} ${kws} + Length should be ${tc[0].body} 1 + Length should be ${tc[0, 0].body} 2 + Length should be ${tc[1].body} 1 + Length should be ${tc[1, 0].body} 1 diff --git a/atest/robot/cli/dryrun/if.robot b/atest/robot/cli/dryrun/if.robot new file mode 100644 index 00000000000..31bf7359c26 --- /dev/null +++ b/atest/robot/cli/dryrun/if.robot @@ -0,0 +1,52 @@ +*** Settings *** +Suite Setup Run Tests --dryrun cli/dryrun/if.robot +Test Teardown Last keyword should have been validated +Resource dryrun_resource.robot + +*** Test Cases *** +IF will not recurse in dry run + ${tc}= Check Test Case ${TESTNAME} + Check Branch Statuses ${tc[0]} Recursive if PASS + Check Branch Statuses ${tc[0, 0, 0, 0]} Recursive if NOT RUN + +ELSE IF will not recurse in dry run + ${tc}= Check Test Case ${TESTNAME} + Check Branch Statuses ${tc[0]} Recursive else if PASS + Check Branch Statuses ${tc[0, 0, 1, 0]} Recursive else if NOT RUN + +ELSE will not recurse in dry run + ${tc}= Check Test Case ${TESTNAME} + Check Branch Statuses ${tc[0]} Recursive else PASS + Check Branch Statuses ${tc[0, 0, 2, 0]} Recursive else NOT RUN + +Dryrun fail inside of IF + Check Test Case ${TESTNAME} + +Dryrun fail inside of ELSE IF + Check Test Case ${TESTNAME} + +Dryrun fail inside of ELSE + Check Test Case ${TESTNAME} + +Dryrun fail invalid IF in non executed branch + Check Test Case ${TESTNAME} + +Dryrun fail invalid ELSE in non executed branch + Check Test Case ${TESTNAME} + +Dryrun fail invalid ELSE IF in non executed branch + Check Test Case ${TESTNAME} + +Dryrun fail empty IF in non executed branch + Check Test Case ${TESTNAME} + +*** Keywords *** +Check Branch Statuses + [Arguments] ${kw} ${name} ${status} + Should Be Equal ${kw.name} ${name} + Should Be Equal ${kw[0, 0].type} IF + Should Be Equal ${kw[0, 0].status} ${status} + Should Be Equal ${kw[0, 1].type} ELSE IF + Should Be Equal ${kw[0, 1].status} ${status} + Should Be Equal ${kw[0, 2].type} ELSE + Should Be Equal ${kw[0, 2].status} ${status} diff --git a/atest/robot/cli/dryrun/java_arguments.robot b/atest/robot/cli/dryrun/java_arguments.robot deleted file mode 100644 index d5e612fafad..00000000000 --- a/atest/robot/cli/dryrun/java_arguments.robot +++ /dev/null @@ -1,62 +0,0 @@ -*** Settings *** -Suite Setup Run Tests --dryrun keywords/java_arguments.robot -Force Tags require-jython -Resource atest_resource.robot - -*** Test Cases *** -Correct Number Of Arguments When No Defaults Or Varargs - Check Test Case ${TESTNAME} - -Too Few Arguments When No Defaults Or Varargs - Check Test Case ${TESTNAME} 1 - Check Test Case ${TESTNAME} 2 - -Too Many Arguments When No Defaults Or Varargs - Check Test Case ${TESTNAME} 1 - Check Test Case ${TESTNAME} 2 - Check Test Case ${TESTNAME} 3 - -Correct Number Of Arguments With Defaults - Check Test Case ${TESTNAME} - -Java Varargs Should Work - Check Test Case ${TESTNAME} - -Too Few Arguments With Defaults - Check Test Case ${TESTNAME} - -Too Many Arguments With Defaults - Check Test Case ${TESTNAME} 1 - Check Test Case ${TESTNAME} 2 - -Correct Number Of Arguments With Varargs - Check Test Case ${TESTNAME} - -Too Few Arguments With Varargs - Check Test Case ${TESTNAME} - -Too Few Arguments With Varargs List - Check Test Case ${TESTNAME} - -Varargs Work Also With Arrays - Check Test Case ${TESTNAME} - -Varargs Work Also With Lists - Check Test Case ${TESTNAME} - -Invalid Argument Types - Check Test Case ${TESTNAME} 1 - -Invalid Argument Values Are Not Checked - Check Test Case Invalid Argument Types 3 PASS ${EMPTY} - -Arguments with variables are not coerced - Check Test Case Invalid Argument Types 2 PASS ${EMPTY} - Check Test Case Invalid Argument Types 3 PASS ${EMPTY} - Check Test Case Invalid Argument Types 4 PASS ${EMPTY} - Check Test Case Invalid Argument Types 5 PASS ${EMPTY} - Check Test Case Invalid Argument Types 6 PASS ${EMPTY} - Check Test Case Invalid Argument Types 7 PASS ${EMPTY} - -Calling Using List Variables - Check Test Case ${TESTNAME} diff --git a/atest/robot/cli/dryrun/run_keyword_variants.robot b/atest/robot/cli/dryrun/run_keyword_variants.robot index fcbbfe40791..8d53a67d722 100644 --- a/atest/robot/cli/dryrun/run_keyword_variants.robot +++ b/atest/robot/cli/dryrun/run_keyword_variants.robot @@ -1,108 +1,130 @@ *** Settings *** -Suite Setup Run Tests --dryrun cli/dryrun/run_keyword_variants.robot -Resource atest_resource.robot +Suite Setup Run Tests --dryrun --listener ${LISTENER} cli/dryrun/run_keyword_variants.robot +Resource atest_resource.robot + +*** Variables *** +${LISTENER} ${DATADIR}/cli/dryrun/LinenoListener.py *** Test Cases *** Run Keyword With Keyword with Invalid Number of Arguments - Check Test Case ${TESTNAME} + ${tc} = Check Test Case ${TESTNAME} + Check Keyword Data ${tc[0]} BuiltIn.Run Keyword args=Log status=FAIL + Check Keyword Data ${tc[0, 0]} BuiltIn.Log args= status=FAIL Run Keyword With Missing Keyword - Check Test Case ${TESTNAME} + Check Test Case ${TESTNAME} Keywords with variable in name are ignored - Test Should Have Correct Keywords kw_index=0 - Test Should Have Correct Keywords BuiltIn.No Operation kw_index=1 - Test Should Have Correct Keywords kw_index=2 - Test Should Have Correct Keywords BuiltIn.No Operation kw_index=3 + Test Should Have Correct Keywords kw_index=0 + Test Should Have Correct Keywords BuiltIn.No Operation kw_index=1 + Test Should Have Correct Keywords kw_index=2 + Test Should Have Correct Keywords BuiltIn.No Operation kw_index=3 Keywords with variable in name are ignored also when variable is argument - Check Test Case ${TESTNAME} + Check Test Case ${TESTNAME} Run Keyword With UK - Check Test Case ${TESTNAME} + ${tc} = Check Test Case ${TESTNAME} + Check Keyword Data ${tc[0]} BuiltIn.Run Keyword If args=True, UK status=PASS + Check Keyword Data ${tc[0, 0]} UK status=PASS + Check Keyword Data ${tc[0, 0, 0]} BuiltIn.No Operation status=NOT RUN Run Keyword With Failing UK - Check Test Case ${TESTNAME} + Check Test Case ${TESTNAME} Comment - Check Test Case ${TESTNAME} + Check Test Case ${TESTNAME} Set Test/Suite/Global Variable - Check Test Case ${TESTNAME} + Check Test Case ${TESTNAME} Variable Should (Not) Exist - Check Test Case ${TESTNAME} + Check Test Case ${TESTNAME} Get Variable Value - Check Test Case ${TESTNAME} + Check Test Case ${TESTNAME} Set Variable If - Check Test Case ${TESTNAME} + Check Test Case ${TESTNAME} Run Keywords When All Keywords Pass - Check Test Case ${TESTNAME} + Check Test Case ${TESTNAME} Run Keywords When One Keyword Fails - Check Test Case ${TESTNAME} + Check Test Case ${TESTNAME} Run Keywords When Multiple Keyword Fails - Check Test Case ${TESTNAME} + Check Test Case ${TESTNAME} Run Keywords With Arguments When All Keywords Pass - Test Should Have Correct Keywords BuiltIn.Log Many BuiltIn.No Operation + Test Should Have Correct Keywords BuiltIn.Log Many BuiltIn.No Operation Run Keywords With Arguments When One Keyword Fails - Test Should Have Correct Keywords BuiltIn.Log BuiltIn.Log + Test Should Have Correct Keywords BuiltIn.Log BuiltIn.Log Run Keywords With Arguments When Multiple Keyword Fails - Test Should Have Correct Keywords BuiltIn.Log Unknown Keyword + Test Should Have Correct Keywords BuiltIn.Log Unknown Keyword Run Keywords With Arguments With Variables - Test Should Have Correct Keywords BuiltIn.Log + Test Should Have Correct Keywords BuiltIn.Log Run Keyword in For Loop Pass - Check Test Case ${TESTNAME} + Check Test Case ${TESTNAME} Run Keyword in For Loop Fail - Check Test Case ${TESTNAME} + Check Test Case ${TESTNAME} Wait Until Keyword Succeeds Pass - Check Test Case ${TESTNAME} + Check Test Case ${TESTNAME} Wait Until Keyword Succeeds Fail - Check Test Case ${TESTNAME} + Check Test Case ${TESTNAME} Run Keyword If Pass - Check Test Case ${TESTNAME} + Check Test Case ${TESTNAME} Run Keyword If Fail - Check Test Case ${TESTNAME} + Check Test Case ${TESTNAME} Run Keyword If with ELSE - Check Test Case ${TESTNAME} + Check Test Case ${TESTNAME} Run Keyword If with ELSE IF - Check Test Case ${TESTNAME} + Check Test Case ${TESTNAME} Run Keyword If with ELSE IF and ELSE - Check Test Case ${TESTNAME} + Check Test Case ${TESTNAME} Run Keyword If with ELSE IF and ELSE without keywords - Check Test Case ${TESTNAME} + Check Test Case ${TESTNAME} Run Keyword If with escaped or non-caps ELSE IF and ELSE - Check Test Case ${TESTNAME} + Check Test Case ${TESTNAME} Run Keyword If with list variable in ELSE IF and ELSE - Check Test Case ${TESTNAME} + Check Test Case ${TESTNAME} Test Teardown Related Run Keyword Variants - Check Test Case ${TESTNAME} + Check Test Case ${TESTNAME} Given/When/Then - ${tc} = Check Test Case ${TESTNAME} - Length Should Be ${tc.kws[0].kws} 1 - Length Should Be ${tc.kws[1].kws} 3 - Length Should Be ${tc.kws[2].kws} 2 - Length Should Be ${tc.kws[3].kws} 3 - Length Should Be ${tc.kws[4].kws} 3 + ${tc} = Check Test Case ${TESTNAME} + Length Should Be ${tc[0].body} 1 + Length Should Be ${tc[1].body} 3 + Length Should Be ${tc[2].body} 2 + Length Should Be ${tc[3].body} 3 + Length Should Be ${tc[4].body} 3 + +Line number + Should Be Empty ${ERRORS} + ${tc} = Check Test Case Run Keyword With Missing Keyword + Should Be Equal ${tc[0].doc} Keyword 'Run Keyword' on line 14. + Should Be Equal ${tc[0, 0].doc} Keyword 'Missing' on line 14. + ${tc} = Check Test Case Run Keywords When One Keyword Fails + Should Be Equal ${tc[0].doc} Keyword 'Run Keywords' on line 68. + Should Be Equal ${tc[0, 0].doc} Keyword 'Fail' on line 68. + Should Be Equal ${tc[0, 2].doc} Keyword 'Log' on line 68. + Should Be Equal ${tc[0, 3].doc} Keyword 'UK' on line 68. + ${tc} = Check Test Case Run Keyword If Pass + Should Be Equal ${tc[0].doc} Keyword 'Run Keyword If' on line 114. + Should Be Equal ${tc[0, 0].doc} Keyword 'No Operation' on line 114. diff --git a/atest/robot/cli/dryrun/try_except.robot b/atest/robot/cli/dryrun/try_except.robot new file mode 100644 index 00000000000..be64c9590eb --- /dev/null +++ b/atest/robot/cli/dryrun/try_except.robot @@ -0,0 +1,16 @@ +*** Settings *** +Suite Setup Run Tests --dryrun cli/dryrun/try_except.robot +Test Teardown Last keyword should have been validated +Resource dryrun_resource.robot + +*** Test Cases *** +TRY + ${tc} = Check Test Case ${TESTNAME} + Check TRY Data ${tc[0, 0]} + Check Keyword Data ${tc[0, 0, 0]} resource.Simple UK + Check Keyword Data ${tc[0, 0, 0, 0]} BuiltIn.Log args=Hello from UK status=NOT RUN + Check Keyword Data ${tc[0, 1, 0]} BuiltIn.Log args=handling it status=NOT RUN + Check Keyword Data ${tc[0, 2, 0]} BuiltIn.Log args=in the else status=NOT RUN + Check Keyword Data ${tc[0, 3, 0]} BuiltIn.Log args=in the finally status=NOT RUN + Check TRY Data ${tc[1, 0]} status=FAIL + Check Keyword Data ${tc[1, 0, 0]} resource.Anarchy in the UK status=FAIL args=1, 2 diff --git a/atest/robot/cli/dryrun/type_conversion.robot b/atest/robot/cli/dryrun/type_conversion.robot index 4d51d4613bd..3ed4f230cf4 100644 --- a/atest/robot/cli/dryrun/type_conversion.robot +++ b/atest/robot/cli/dryrun/type_conversion.robot @@ -3,16 +3,15 @@ Resource atest_resource.robot *** Test Cases *** Annotations - [Tags] require-py3 - Run Tests --dryrun keywords/type_conversion/annotations.robot + # Exclude test requiring Python 3.14 unconditionally to avoid a failure with + # older versions. It can be included once Python 3.14 is our minimum versoin. + Run Tests --dryrun --exclude require-py3.14 keywords/type_conversion/annotations.robot Should be equal ${SUITE.status} PASS -Keyword Decorator with Python 3 - [Tags] require-py3 +Keyword Decorator Run Tests --dryrun --exclude negative keywords/type_conversion/keyword_decorator.robot Should be equal ${SUITE.status} PASS -Keyword Decorator with Python 2 - [Tags] require-py2 - Run Tests --dryrun --exclude negative --exclude require-py3 keywords/type_conversion/keyword_decorator.robot +Custom converters + Run Tests --dryrun --exclude no-dry-run keywords/type_conversion/custom_converters.robot Should be equal ${SUITE.status} PASS diff --git a/atest/robot/cli/dryrun/while.robot b/atest/robot/cli/dryrun/while.robot new file mode 100644 index 00000000000..65a1bda2f29 --- /dev/null +++ b/atest/robot/cli/dryrun/while.robot @@ -0,0 +1,21 @@ +*** Settings *** +Suite Setup Run Tests --dryrun cli/dryrun/while.robot +Test Teardown Last keyword should have been validated +Resource dryrun_resource.robot + +*** Test Cases *** +WHILE + ${tc} = Check Test Case ${TESTNAME} + Length should be ${tc[1].body} 1 + Length should be ${tc[1, 0].body} 3 + Length should be ${tc[2].body} 1 + Length should be ${tc[1, 0].body} 3 + Length should be ${tc[3].body} 3 + Length should be ${tc[3, 0].body} 0 + Length should be ${tc[3, 1].body} 1 + Length should be ${tc[3, 2].body} 0 + +WHILE with BREAK and CONTINUE + ${tc} = Check Test Case ${TESTNAME} + Length should be ${tc[1].body} 1 + Length should be ${tc[2].body} 1 diff --git a/atest/robot/cli/model_modifiers/ModelModifier.py b/atest/robot/cli/model_modifiers/ModelModifier.py index 3de4462b6ee..f285434fa05 100644 --- a/atest/robot/cli/model_modifiers/ModelModifier.py +++ b/atest/robot/cli/model_modifiers/ModelModifier.py @@ -1,18 +1,81 @@ from robot.model import SuiteVisitor +from robot.running import TestCase as RunningTestCase +from robot.running.model import Argument class ModelModifier(SuiteVisitor): - def __init__(self, *tags): - self.config = tags or ('visited',) + def __init__(self, *tags, **extra): + if extra: + tags += tuple("-".join(item) for item in extra.items()) + self.config = tags or ("visited",) def start_suite(self, suite): - if self.config[0] == 'FAIL': - raise RuntimeError(' '.join(self.config[1:])) - if self.config == ('REMOVE', 'ALL', 'TESTS'): + config = self.config + if config[0] == "FAIL": + raise RuntimeError(" ".join(self.config[1:])) + elif config[0] == "CREATE": + tc = suite.tests.create(**dict(conf.split("-", 1) for conf in config[1:])) + tc.body.create_keyword("Log", args=["Hello", "level=INFO"]) + if isinstance(tc, RunningTestCase): + # robot.running.model.Argument is a private/temporary API for creating + # named arguments with non-string values programmatically. It was added + # in RF 7.0.1 (#5031) after a failed attempt to add an API for this + # purpose in RF 7.0 (#5000). + tc.body.create_keyword( + "Log", + args=[Argument(None, "Argument object"), Argument("level", "INFO")], + ) + tc.body.create_keyword( + "Should Contain", + args=[(1, 2, 3), Argument("item", 2)], + ) + # Passing named args separately is supported since RF 7.1 (#5143). + tc.body.create_keyword( + "Log", + args=["Named args separately"], + named_args={"html": True, "level": '${{"INFO"}}'}, + ) + self.config = [] + elif config == ("REMOVE", "ALL", "TESTS"): suite.tests = [] else: - suite.tests = [t for t in suite.tests if not t.tags.match('fail')] + suite.tests = [t for t in suite.tests if not t.tags.match("fail")] - def visit_test(self, test): + 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" + for_.values = ["FOR", "is", "modified!"] + + def start_for_iteration(self, iteration): + 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 == "'${x}' == 'wrong'": + branch.condition = "True" + # With Robot + if not hasattr(branch, "status"): + branch.body[0].config(name="Log", args=["going here!"]) + # With Rebot + elif branch.status == "NOT RUN": + branch.status = "PASS" + branch.condition = "modified" + branch.body[0].args = ["got here!"] + if branch.condition == "${i} == 9": + branch.condition = "False" diff --git a/atest/robot/cli/model_modifiers/modifier_resource.robot b/atest/robot/cli/model_modifiers/modifier_resource.robot index 88f3b34fe34..1fbe61d4001 100644 --- a/atest/robot/cli/model_modifiers/modifier_resource.robot +++ b/atest/robot/cli/model_modifiers/modifier_resource.robot @@ -6,6 +6,15 @@ ${TEST DATA} misc/pass_and_fail.robot ${LOG} %{TEMPDIR}/modified_log.html *** Keywords *** +Output and log should be modified + [Arguments] @{added tags} + Output should be modified @{added tags} + Log should be modified @{added tags} + +Output and log should not be modified + Output should not be modified + Log should not be modified + Output should be modified [Arguments] @{added tags} Check Test Tags Pass force pass @{added tags} @@ -28,11 +37,13 @@ Log should not be modified Log should contain strings [Arguments] @{strings} ${content} = Get File ${LOG} - :FOR ${string} IN @{strings} - \ Should Contain ${content} "*${string}" + FOR ${string} IN @{strings} + Should Contain ${content} "*${string}" + END Log should not contain strings [Arguments] @{strings} ${content} = Get File ${LOG} - :FOR ${string} IN @{strings} - \ Should Not Contain ${content} "*${string}" + FOR ${string} IN @{strings} + Should Not Contain ${content} "*${string}" + END diff --git a/atest/robot/cli/model_modifiers/pre_rebot.robot b/atest/robot/cli/model_modifiers/pre_rebot.robot index 40a716510eb..a648d50c498 100644 --- a/atest/robot/cli/model_modifiers/pre_rebot.robot +++ b/atest/robot/cli/model_modifiers/pre_rebot.robot @@ -10,41 +10,33 @@ ${MODIFIED OUTPUT} %{TEMPDIR}/pre_rebot_modified.xml *** Test Cases *** Modifier as path Run Rebot --prerebotmodifier ${CURDIR}/ModelModifier.py -l ${LOG} ${MODIFIED OUTPUT} - Output should be modified visited - Log should be modified visited + Output and log should be modified visited Modifier as name Run Rebot --prerebotmodifier ModelModifier --pythonpath ${CURDIR} -l ${LOG} ${MODIFIED OUTPUT} - Output should be modified visited - Log should be modified visited + Output and log should be modified visited Modifier with arguments separated with ':' - Run Rebot --PreRebotModifier ${CURDIR}/ModelModifier.py:new:tags -l ${LOG} ${MODIFIED OUTPUT} - Check Test Tags Pass force new pass tags - Output should be modified new tags - Log should be modified new tags + Run Rebot --PreRebotModifier ${CURDIR}/ModelModifier.py:new:tags:named=tag -l ${LOG} ${MODIFIED OUTPUT} + Output and log should be modified new tags named-tag Modifier with arguments separated with ';' - Run Rebot --prerebot "ModelModifier;1;2;3" --prere "ModelModifier;4;5" -P ${CURDIR} -l ${LOG} ${MODIFIED OUTPUT} - Output should be modified 1 2 3 4 5 - Log should be modified 1 2 3 4 5 + Run Rebot --prerebot "ModelModifier;1;2;3" --prere "ModelModifier;4;5;n=t" -P ${CURDIR} -l ${LOG} ${MODIFIED OUTPUT} + Output and log should be modified 1 2 3 4 5 n-t Non-existing modifier Run Rebot --prerebotmod NobodyHere -l ${LOG} ${MODIFIED OUTPUT} - ${quote} = Set Variable If ${INTERPRETER.is_py3} ' ${EMPTY} Stderr Should Match ... ? ERROR ? Importing model modifier 'NobodyHere' failed: *Error: - ... No module named ${quote}NobodyHere${quote}\nTraceback (most recent call last):\n* - Output should not be modified - Log should not be modified + ... No module named 'NobodyHere'\nTraceback (most recent call last):\n* + Output and log should not be modified Invalid modifier Run Rebot --prerebotmodifier ${CURDIR}/ModelModifier.py:FAIL:Message -l ${LOG} ${MODIFIED OUTPUT} Stderr Should Start With ... [ ERROR ] Executing model modifier 'ModelModifier' failed: ... Message\nTraceback (most recent call last):\n - Output should not be modified - Log should not be modified + Output and log should not be modified Error if all tests removed ${result} = Run Rebot Without Processing Output @@ -58,9 +50,38 @@ Error if all tests removed Stderr Should Be Empty Length Should Be ${SUITE.tests} 0 -Modifiers are used after normal configuration - ${result} = Run Rebot Without Processing Output - ... --include nonex --name Custom --prereb ${CURDIR}/ModelModifier.py:REMOVE:ALL:TESTS ${MODIFIED OUTPUT} - Stderr Should Be Equal To - ... [ ERROR ] Suite 'Custom' contains no tests matching tag 'nonex'.${USAGE TIP}\n - Should Be Equal ${result.rc} ${252} +Modifiers are used before normal configuration + Run Rebot --include added --prereb ${CURDIR}/ModelModifier.py:CREATE:name=Created:tags=added ${MODIFIED OUTPUT} + Stderr Should Be Empty + Length Should Be ${SUITE.tests} 1 + ${tc} = Check test case Created FAIL + Lists should be equal ${tc.tags} ${{['added']}} + +Modify FOR + [Setup] Modify FOR and IF + ${tc} = Check Test Case FOR IN RANGE + Should Be Equal ${tc[0].flavor} IN + Should Be Equal ${tc[0].values} ${{('FOR', 'is', 'modified!')}} + Should Be Equal ${tc[0, 0].assign['\${i}']} 0 (modified) + Should Be Equal ${tc[0, 0].assign['\${x}']} new + Check Log Message ${tc[0, 0, 0, 0]} 0 + Should Be Equal ${tc[0, 1].assign['\${i}']} 1 (modified) + Should Be Equal ${tc[0, 1].assign['\${x}']} new + Check Log Message ${tc[0, 1, 0, 0]} 1 + Should Be Equal ${tc[0, 2].assign['\${i}']} 2 (modified) + Should Be Equal ${tc[0, 2].assign['\${x}']} new + Check Log Message ${tc[0, 2, 0, 0]} 2 + +Modify IF + [Setup] Should Be Equal ${PREV TEST NAME} Modify FOR + ${tc} = Check Test Case If structure + Should Be Equal ${tc[1, 0].condition} modified + Should Be Equal ${tc[1, 0].status} PASS + Should Be Equal ${tc[1, 0, 0].args[0]} got here! + Should Be Equal ${tc[1, 1].status} PASS + +*** Keywords *** +Modify FOR and IF + Create Output With Robot ${MODIFIED OUTPUT} ${EMPTY} misc/for_loops.robot misc/if_else.robot + Run Rebot --prereb ${CURDIR}/ModelModifier.py ${MODIFIED OUTPUT} + Stderr Should Be Empty diff --git a/atest/robot/cli/model_modifiers/pre_rebot_when_running.robot b/atest/robot/cli/model_modifiers/pre_rebot_when_running.robot index b35113d57b4..b6b15dcecf8 100644 --- a/atest/robot/cli/model_modifiers/pre_rebot_when_running.robot +++ b/atest/robot/cli/model_modifiers/pre_rebot_when_running.robot @@ -30,10 +30,9 @@ Pre-run and pre-rebot modifiers together Non-existing modifier Run Tests --prerebotmodifier NobodyHere -l ${LOG} ${TEST DATA} - ${quote} = Set Variable If ${INTERPRETER.is_py3} ' ${EMPTY} Stderr Should Match ... ? ERROR ? Importing model modifier 'NobodyHere' failed: *Error: - ... No module named ${quote}NobodyHere${quote}\nTraceback (most recent call last):\n* + ... No module named 'NobodyHere'\nTraceback (most recent call last):\n* Output should not be modified Log should not be modified diff --git a/atest/robot/cli/model_modifiers/pre_run.robot b/atest/robot/cli/model_modifiers/pre_run.robot index 60316bcc1a0..f52626345f7 100644 --- a/atest/robot/cli/model_modifiers/pre_run.robot +++ b/atest/robot/cli/model_modifiers/pre_run.robot @@ -4,40 +4,33 @@ Resource modifier_resource.robot *** Test Cases *** Modifier as path Run Tests --prerunmodifier ${CURDIR}/ModelModifier.py -l ${LOG} ${TEST DATA} - Output should be modified visited - Log should be modified visited + Output and log should be modified visited Modifier as name Run Tests --prerunmodifier ModelModifier --pythonpath ${CURDIR} -l ${LOG} ${TEST DATA} - Output should be modified visited - Log should be modified visited + Output and log should be modified visited Modifier with arguments separated with ':' - Run Tests --PreRunModifier ${CURDIR}/ModelModifier.py:new:tags -l ${LOG} ${TEST DATA} - Output should be modified new tags - Log should be modified new tags + Run Tests --PreRunModifier ${CURDIR}/ModelModifier.py:new:tags:named=tag -l ${LOG} ${TEST DATA} + Output and log should be modified new tags named-tag Modifier with arguments separated with ';' - Run Tests --prerun "ModelModifier;1;2;3" --preru "ModelModifier;4;5" -P ${CURDIR} -l ${LOG} ${TEST DATA} - Output should be modified 1 2 3 4 5 - Log should be modified 1 2 3 4 5 + Run Tests --prerun "ModelModifier;1;2;3" --preru "ModelModifier;4;5;n=t" -P ${CURDIR} -l ${LOG} ${TEST DATA} + Output and log should be modified 1 2 3 4 5 n-t Non-existing modifier Run Tests --prerunmodifier NobodyHere -l ${LOG} ${TEST DATA} - ${quote} = Set Variable If ${INTERPRETER.is_py3} ' ${EMPTY} Stderr Should Match ... ? ERROR ? Importing model modifier 'NobodyHere' failed: *Error: - ... No module named ${quote}NobodyHere${quote}\nTraceback (most recent call last):\n* - Output should not be modified - Log should not be modified + ... No module named 'NobodyHere'\nTraceback (most recent call last):\n* + Output and log should not be modified Invalid modifier Run Tests --prerunmodifier ${CURDIR}/ModelModifier.py:FAIL:Message -l ${LOG} ${TEST DATA} Stderr Should Start With ... [ ERROR ] Executing model modifier 'ModelModifier' failed: ... Message\nTraceback (most recent call last):\n - Output should not be modified - Log should not be modified + Output and log should not be modified Error if all tests removed ${result} = Run Tests Without Processing Output @@ -51,9 +44,39 @@ Error if all tests removed Stderr Should Be Empty Length Should Be ${SUITE.tests} 0 -Modifiers are used after normal configuration - ${result} = Run Tests Without Processing Output - ... --include nonex --name Custom --prerun ${CURDIR}/ModelModifier.py:REMOVE:ALL:TESTS ${TEST DATA} - Stderr Should Be Equal To - ... [ ERROR ] Suite 'Custom' contains no tests matching tag 'nonex'.${USAGE TIP}\n - Should Be Equal ${result.rc} ${252} +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[0, 0]} Test body made non-empty by modifier + ${tc} = Check Test Case Empty User Keyword PASS ${EMPTY} + Check Log Message ${tc[0, 0, 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 + Run Tests --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 + Check Log Message ${tc[0, 0]} Hello + Check Keyword Data ${tc[0]} BuiltIn.Log args=Hello, level=INFO + Lists should be equal ${tc.tags} ${{['added']}} + +Modifiers can use special Argument objects in arguments + ${tc} = Check Test Case Created + Check Log Message ${tc[1, 0]} Argument object + Check Keyword Data ${tc[1]} BuiltIn.Log args=Argument object, level=INFO + Check Keyword Data ${tc[2]} BuiltIn.Should Contain args=(1, 2, 3), item=2 + +Modifiers can pass positional and named arguments separately + ${tc} = Check Test Case Created + Check Log Message ${tc[3, 0]} Named args separately html=True + Check Keyword Data ${tc[3]} BuiltIn.Log args=Named args separately, html=True, level=\${{"INFO"}} + +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 + Check Log Message ${tc[0, 0, 0, 0]} FOR + Check Log Message ${tc[0, 1, 0, 0]} is + Check Log Message ${tc[0, 2, 0, 0]} modified! + ${tc} = Check Test Case If structure + Check Log Message ${tc[1, 0, 0, 0]} going here! diff --git a/atest/robot/cli/rebot/argument_file.robot b/atest/robot/cli/rebot/argument_file.robot new file mode 100644 index 00000000000..6cf0f2fc52b --- /dev/null +++ b/atest/robot/cli/rebot/argument_file.robot @@ -0,0 +1,28 @@ +*** Settings *** +Test Setup Create Output Directory +Resource rebot_cli_resource.robot + +*** Variables *** +${ARG FILE} %{TEMPDIR}/arguments.txt + +*** Test Cases *** +Argument File + ${content} = Catenate SEPARATOR=\n + ... --name From Arg File + ... -D= Leading space is ignored + ... -M${SPACE*5}No:Spaces + ... \# comment line + ... ${EMPTY} + ... --log=none + ... -r=none + ... -o myout.xml + ... --outputdir ${CLI OUTDIR} + ... ${INPUT FILE} + Create File ${ARG FILE} ${content} + ${result} = Run Rebot --log disable_me.html --argumentfile ${ARG FILE} + ... output=${CLI OUTDIR}/myout.xml + Should Be Empty ${result.stderr} + Directory Should Contain ${CLI OUTDIR} myout.xml + Should Be Equal ${SUITE.name} From Arg File + Should Be Equal ${SUITE.doc} Leading space is ignored + Should Be Equal ${SUITE.metadata['No']} Spaces diff --git a/atest/robot/cli/rebot/argumentfile.robot b/atest/robot/cli/rebot/argumentfile.robot deleted file mode 100644 index 33b69dfe9ef..00000000000 --- a/atest/robot/cli/rebot/argumentfile.robot +++ /dev/null @@ -1,28 +0,0 @@ -*** Settings *** -Test Setup Create Output Directory -Resource rebot_cli_resource.robot - -*** Variables *** -${ARG FILE} %{TEMPDIR}/arguments.txt - -*** Test Cases *** -Argument File - ${content} = Catenate SEPARATOR=\n - ... --name From Arg File - ... -D= Leading space - ... -M${SPACE*5}No:Spaces - ... \# comment line - ... ${EMPTY} - ... --log=none - ... -r=none - ... -o myout.xml - ... --outputdir ${CLI OUTDIR} - ... ${INPUT FILE} - Create File ${ARG FILE} ${content} - ${result} = Run Rebot --log disable_me.html --argumentfile ${ARG FILE} - ... output=${CLI OUTDIR}/myout.xml - Should Be Empty ${result.stderr} - Directory Should Contain ${CLI OUTDIR} myout.xml - Should Be Equal ${SUITE.name} From Arg File - Should Be Equal ${SUITE.doc} ${SPACE}Leading space - Should Be Equal ${SUITE.metadata['No']} Spaces diff --git a/atest/robot/cli/rebot/console.robot b/atest/robot/cli/rebot/console.robot new file mode 100644 index 00000000000..68de2d646ee --- /dev/null +++ b/atest/robot/cli/rebot/console.robot @@ -0,0 +1,40 @@ +*** Settings *** +Suite Setup Run tests to create input file for Rebot +Resource rebot_cli_resource.robot + +*** Variables *** +${CONSOLES} ${CURDIR}${/}..${/}..${/}..${/}testresources${/}consoles + +*** Test Cases *** +Default is verbose + Run Rebot ${EMPTY} ${INPUT FILE} + Stdout Should Contain Output: + +Verbose + Run Rebot --console verbose ${INPUT FILE} + Stdout Should Contain Output: + +Quiet + Run Rebot --console quiet ${INPUT FILE} + Stdout Should Be Empty + Stderr Should Be Empty + +--quiet shortcut + Run Rebot --quiet ${INPUT FILE} + Stdout Should Be Empty + Stderr Should Be Empty + +None + Run Rebot --console none ${INPUT FILE} + Stdout Should Be Empty + Stderr Should Be Empty + +Custom console + Run Rebot --console CustomConsole --pythonpath ${CONSOLES} --log log.html --report report.html + ... ${INPUT FILE} + Stdout Should Contain DEFAULT: Output: + Stdout Should Contain DEFAULT: Closing + +Non-existing custom console + Run Rebot Without Processing Output --console NonExistent ${INPUT FILE} + Stderr Should Start With [ ERROR ] Taking console logger 'NonExistent' into use failed: diff --git a/atest/robot/cli/rebot/escape.robot b/atest/robot/cli/rebot/escape.robot deleted file mode 100644 index a709d9e70c4..00000000000 --- a/atest/robot/cli/rebot/escape.robot +++ /dev/null @@ -1,20 +0,0 @@ -*** Settings *** -Suite Setup Run tests with escaping -Resource rebot_cli_resource.robot - -*** Test Cases *** ---escape options and arguments - Should be equal ${SUITE.name} * name * - Should be equal ${SUITE.doc} @@ - Should contain tests ${SUITE} Pass - ---escape is deprecated - Stderr Should Be Equal To [ WARN ] Option '--escape' is deprecated. Use console escape mechanism instead.\n - -*** Keywords *** -Run tests with escaping - Run tests to create input file for Rebot misc/pass_and_fail.robot - ${opts} = Catenate - ... --escape space:SP -E star:STAR --esc at:AT --ESCAPE quest:Q - ... --name STARSPnameSPSTAR --doc ATAT --include pQQs - Run Rebot ${opts} ${INPUT FILE} diff --git a/atest/robot/cli/rebot/help_and_version.robot b/atest/robot/cli/rebot/help_and_version.robot index 0256801bb5d..d1b498e692c 100644 --- a/atest/robot/cli/rebot/help_and_version.robot +++ b/atest/robot/cli/rebot/help_and_version.robot @@ -2,14 +2,30 @@ Resource rebot_cli_resource.robot *** Test Cases *** -Help - ${result} = Run Rebot --help output=NONE - Should Be Equal ${result.rc} ${251} +---help + ${result} = Run Rebot --help output=NONE + Validate --help ${result} + +--help --no-status-rc + ${result} = Run Rebot --help --no-status-rc output=NONE + Validate --help ${result} rc=0 + +--version + ${result} = Run Rebot --version output=NONE + Validate --version ${result} + +--version --no-status-rc + ${result} = Run Rebot --VERSION --NoStatusRC output=NONE + Validate --version ${result} rc=0 + +*** Keywords *** +Validate --help + [Arguments] ${result} ${rc}=251 + Should Be Equal ${result.rc} ${rc} type=int Should Be Empty ${result.stderr} - ${help} = Set Variable ${result.stdout} - Log ${help} + VAR ${help} ${result.stdout} Should Start With ${help} Rebot -- Robot Framework report and log generator\n\nVersion: \ - Should End With ${help} \n$ jython path/robot/rebot.py -N Project_X -l none -r x.html output.xml\n + Should End With ${help} \n$ python -m robot.rebot --name Combined outputs/*.xml\n Should Not Contain ${help} \t Should Not Contain ${help} [ ERROR ] Should Not Contain ${help} [ WARN \ ] @@ -20,10 +36,10 @@ Help Log Many @{tail} Should Be Empty ${tail} Help lines with trailing spaces -Version - ${result} = Run Rebot --version output=NONE - Should Be Equal ${result.rc} ${251} +Validate --version + [Arguments] ${result} ${rc}=251 + Should Be Equal ${result.rc} ${rc} type=int Should Be Empty ${result.stderr} Should Match Regexp ${result.stdout} - ... ^Rebot 3\\.\\d(\\.\\d)?((a|b|rc)\\d)?(\\.dev\\d)? \\((Python|Jython|IronPython|PyPy) [23]\\.[\\d.]+.* on .+\\)$ + ... ^Rebot [567]\\.\\d(\\.\\d)?((a|b|rc)\\d)?(\\.dev\\d)? \\((Python|PyPy) 3\\.[\\d.]+.* on .+\\)$ Should Be True len($result.stdout) < 80 Too long version line diff --git a/atest/robot/cli/rebot/invalid_usage.robot b/atest/robot/cli/rebot/invalid_usage.robot index 0d894d5dd3f..57b5a0acfb1 100644 --- a/atest/robot/cli/rebot/invalid_usage.robot +++ b/atest/robot/cli/rebot/invalid_usage.robot @@ -1,8 +1,11 @@ *** Settings *** Resource rebot_cli_resource.robot -Suite Setup Run tests to create input file for Rebot +Suite Setup Run tests to create input file for Rebot misc/pass_and_fail.robot ${INPUT} Test Template Rebot Should Fail +*** Variables *** +${INPUT} %{TEMPDIR}/input-for-cli-rebot-invalid-usage.xml + *** Test Cases *** Invalid Options option --invalid not recognized --invalid option @@ -17,14 +20,28 @@ Non-Existing Input Existing And Non-Existing Input Reading XML source '.*nönéx.xml' failed: .* source=${INPUTFILE} nönéx.xml nonex2.xml +No tests in output + [Setup] Create File %{TEMPDIR}/no_tests.xml + Suite 'No Tests!' contains no tests. source=%{TEMPDIR}/no_tests.xml + Non-XML Input [Setup] Create File %{TEMPDIR}/invalid.robot Hello, world (\\[Fatal Error\\] .*: Content is not allowed in prolog.\\n)?Reading XML source '.*invalid.robot' failed: .* ... source=%{TEMPDIR}/invalid.robot -Incompatible XML +Wrong XML root element [Setup] Create File %{TEMPDIR}/invalid.xml type - Reading XML source '.*invalid.xml' failed: Incompatible XML element 'not'. + Reading XML source '.*invalid.xml' failed: Incompatible root element 'not'. + ... source=%{TEMPDIR}/invalid.xml + +Wrong XML child element + [Setup] Create File %{TEMPDIR}/invalid.xml + Reading XML source '.*invalid.xml' failed: Incompatible child element 'wrong' for 'test'. + ... source=%{TEMPDIR}/invalid.xml + +Incompatible XML child element + [Setup] Create File %{TEMPDIR}/invalid.xml + Reading XML source '.*invalid.xml' failed: Incompatible child element 'test' for 'test'. ... source=%{TEMPDIR}/invalid.xml Invalid Output Directory @@ -35,21 +52,21 @@ Invalid Output Directory ... -d %{TEMPDIR}/not-dir/dir -o out.xml -l none -r none Invalid --SuiteStatLevel - Option '--suitestatlevel' expected integer value but got 'not_int'. + Invalid value for option '--suitestatlevel': Expected integer, got 'not_int'. ... --suitestatlevel not_int Invalid --TagStatLink - Invalid format for option '--tagstatlink'. Expected 'tag:link:title' but got 'less_than_3x_:'. + Invalid value for option '--tagstatlink': Expected format 'tag:link:title', got 'less_than_3x_:'. ... --tagstatlink a:b:c --TagStatLink less_than_3x_: Invalid --RemoveKeywords - Invalid value for option '--removekeywords'. Expected 'ALL', 'PASSED', 'NAME:', 'TAG:', 'FOR', or 'WUKS' but got 'Invalid'. + Invalid value for option '--removekeywords': Expected 'ALL', 'PASSED', 'NAME:', 'TAG:', 'FOR' or 'WUKS', got 'Invalid'. ... --removekeywords wuks --removek name:xxx --RemoveKeywords Invalid *** Keywords *** Rebot Should Fail - [Arguments] ${error} ${options}= ${source}=${INPUTFILE} - ${result} = Run Rebot ${options} ${source} default options= output= - Should Be Equal As Integers ${result.rc} 252 + [Arguments] ${error} ${options}= ${source}=${INPUT} + ${result} = Run Rebot ${options} ${source} default options= output=None + Should Be Equal ${result.rc} 252 type=int Should Be Empty ${result.stdout} Should Match Regexp ${result.stderr} ^\\[ .*ERROR.* \\] ${error}${USAGETIP}$ diff --git a/atest/robot/cli/rebot/log_level.robot b/atest/robot/cli/rebot/log_level.robot index 5677f86d996..4282351e68d 100644 --- a/atest/robot/cli/rebot/log_level.robot +++ b/atest/robot/cli/rebot/log_level.robot @@ -7,29 +7,29 @@ ${LOG NAME} logfile.html *** Test Cases *** By default all messages are included ${tc} = Rebot - Check Log Message ${tc.kws[0].msgs[0]} Arguments: [ 'Test 1' ] TRACE - Check Log Message ${tc.kws[0].msgs[1]} Test 1 INFO - Check Log Message ${tc.kws[0].msgs[2]} Return: None TRACE - Check Log Message ${tc.kws[1].msgs[0]} Arguments: [ 'Logging with debug level' | 'DEBUG' ] TRACE - Check Log Message ${tc.kws[1].msgs[1]} Logging with debug level DEBUG - Check Log Message ${tc.kws[1].msgs[2]} Return: None TRACE + Check Log Message ${tc[0, 0]} Arguments: [ 'Test 1' ] TRACE + Check Log Message ${tc[0, 1]} Test 1 INFO + Check Log Message ${tc[0, 2]} Return: None TRACE + Check Log Message ${tc[1, 0]} Arguments: [ 'Logging with debug level' | 'DEBUG' ] TRACE + Check Log Message ${tc[1, 1]} Logging with debug level DEBUG + Check Log Message ${tc[1, 2]} Return: None TRACE Min level should be 'TRACE' and default 'TRACE' Levels below given level are ignored ${tc} = Rebot --loglevel debug - Check Log Message ${tc.kws[0].msgs[0]} Test 1 INFO - Check Log Message ${tc.kws[1].msgs[0]} Logging with debug level DEBUG + Check Log Message ${tc[0, 0]} Test 1 INFO + Check Log Message ${tc[1, 0]} Logging with debug level DEBUG Min level should be 'DEBUG' and default 'DEBUG' ${tc} = Rebot -L INFO - Check Log Message ${tc.kws[0].msgs[0]} Test 1 INFO - Should Be Empty ${tc.kws[1].msgs} - Should Be Empty ${tc.kws[2].kws[0].msgs} + Check Log Message ${tc[0, 0]} Test 1 INFO + Should Be Empty ${tc[1].body} + Should Be Empty ${tc[2, 0].body} Min level should be 'INFO' and default 'INFO' All messages are ignored when NONE level is used ${tc} = Rebot --loglevel NONE - Should Be Empty ${tc.kws[0].msgs} - Should Be Empty ${tc.kws[1].msgs} + Should Be Empty ${tc[0].body} + Should Be Empty ${tc[1].body} Min level should be 'NONE' and default 'NONE' Configure visible log level @@ -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/output_files.robot b/atest/robot/cli/rebot/output_files.robot index 19d5164c8e4..80be5e8d9bc 100644 --- a/atest/robot/cli/rebot/output_files.robot +++ b/atest/robot/cli/rebot/output_files.robot @@ -47,7 +47,7 @@ Non-writable Output File Should Be Equal ${result.rc} ${0} Directory Should Contain ${OUTDIR}/out l.html r.html Should Match Regexp ${result.stdout} ^Log: .*\nReport: .*$ - Should Match Regexp ${result.stderr} ^\\[ ERROR \\] Opening output file '.*diréctöry.xml' failed: .*$ + Should Match Regexp ${result.stderr} (?s)^\\[ ERROR \\] Opening output file '.*diréctöry.xml' failed: .*$ *** Keywords *** Correct outputs should be created diff --git a/atest/robot/cli/rebot/rebot_cli_resource.robot b/atest/robot/cli/rebot/rebot_cli_resource.robot index 37a58f45375..fb96e02d13f 100644 --- a/atest/robot/cli/rebot/rebot_cli_resource.robot +++ b/atest/robot/cli/rebot/rebot_cli_resource.robot @@ -4,10 +4,10 @@ Resource ../runner/cli_resource.robot *** Variables *** ${TEST FILE} misc/normal.robot ${INPUT FILE} %{TEMPDIR}${/}rebot-cli-input.xml -${M_211_211} 2 critical tests, 1 passed, 1 failed\n 2 tests total, 1 passed, 1 failed -${M_110_211} 1 critical test, 1 passed, 0 failed\n 2 tests total, 1 passed, 1 failed -${M_101_211} 1 critical test, 0 passed, 1 failed\n 2 tests total, 1 passed, 1 failed -${M_000_211} 0 critical tests, 0 passed, 0 failed\n 2 tests total, 1 passed, 1 failed +${M_211_211} 2 critical tests, 1 passed, 1 failed\n2 tests total, 1 passed, 1 failed +${M_110_211} 1 critical test, 1 passed, 0 failed\n2 tests total, 1 passed, 1 failed +${M_101_211} 1 critical test, 0 passed, 1 failed\n2 tests total, 1 passed, 1 failed +${M_000_211} 0 critical tests, 0 passed, 0 failed\n2 tests total, 1 passed, 1 failed *** Keywords *** Run tests to create input file for Rebot @@ -18,7 +18,7 @@ Run tests to create input file for Rebot Run rebot and return outputs [Arguments] ${options} Create Output Directory - ${result} = Run Rebot --outputdir ${CLI OUTDIR} ${options} ${INPUT FILE} default options= output= + ${result} = Run Rebot --outputdir ${CLI OUTDIR} ${options} ${INPUT FILE} default options= output=None Should Be Equal ${result.rc} ${0} @{outputs} = List Directory ${CLI OUTDIR} - [Return] @{outputs} + RETURN @{outputs} 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 78221827df3..9c8de17fbaa 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 @@ -6,133 +6,201 @@ Resource remove_keywords_resource.robot *** Test Cases *** All Mode [Setup] Run Rebot and set My Suite --RemoveKeywords ALL 0 - Keyword Should Be Empty ${MY SUITE.setup} My Keyword Suite Setup - Keyword Should Contain Removal Message ${MY SUITE.setup} ${tc1} = Check Test Case Pass ${tc2} = Check Test Case Fail - Length Should Be ${tc1.kws} 1 - Keyword Should Be Empty ${tc1.kws[0]} My Keyword Pass - Length Should Be ${tc2.kws} 2 - Keyword Should Be Empty ${tc2.kws[0]} My Keyword Fail - Keyword Should Be Empty ${tc2.kws[1]} BuiltIn.Fail Expected failure - Keyword Should Contain Removal Message ${tc2.kws[1]} Fails the test with the given message and optionally alters its tags. + ${tc3} = Check Test Case Test with setup and teardown + Keyword Should Be Empty ${MY SUITE.setup} My Keyword Suite Setup + Keyword Should Contain Removal Message ${MY SUITE.setup} + Length Should Be ${tc1.body} 3 + Keyword Should Be Empty ${tc1[0]} My Keyword Pass + Length Should Be ${tc2.body} 2 + Keyword Should Be Empty ${tc2[0]} My Keyword Fail + Keyword Should Be Empty ${tc2[1]} BuiltIn.Fail Expected failure + Keyword Should Contain Removal Message ${tc2[1]} Expected failure + Keyword Should Be Empty ${tc3.setup} Test Setup + Keyword Should Contain Removal Message ${tc3.setup} + Keyword Should Be Empty ${tc3.teardown} Test Teardown + Keyword Should Contain Removal Message ${tc3.teardown} Warnings Are Removed In All Mode [Setup] Verify previous test and set My Suite All Mode 1 - Keyword Should Be Empty ${MY SUITE.setup} Warning in suite setup - Keyword Should Be Empty ${MY SUITE.teardown} Warning in suite teardown ${tc1} ${tc2}= Set Variable ${MY SUITE.tests[:2]} - Length Should Be ${tc1.kws} 1 - Length Should Be ${tc2.kws} 1 - Keyword Should Be Empty ${tc1.kws[0]} Warning in test case - Keyword Should Be Empty ${tc2.kws[0]} No warning + Keyword Should Be Empty ${MY SUITE.setup} Warning in suite setup + Keyword Should Be Empty ${MY SUITE.teardown} Warning in suite teardown + Length Should Be ${tc1.body} 1 + Keyword Should Be Empty ${tc1[0]} Warning in test case + Length Should Be ${tc2.body} 1 + Keyword Should Be Empty ${tc2[0]} No warning 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.kws[0]} Error in test case + Keyword Should Be Empty ${tc[0]} Error in test case Logged Errors Are Preserved In Execution Errors +IF/ELSE in All mode + ${tc} = Check Test Case IF structure + Length Should Be ${tc.body} 2 + Length Should Be ${tc[1].body} 3 + IF Branch Should Be Empty ${tc[1, 0]} IF '\${x}' == 'wrong' + IF Branch Should Be Empty ${tc[1, 1]} ELSE IF '\${x}' == 'value' + IF Branch Should Be Empty ${tc[1, 2]} ELSE + +FOR in All mode + ${tc1} = Check Test Case FOR + ${tc2} = Check Test Case FOR IN RANGE + Length Should Be ${tc1.body} 1 + FOR Loop Should Be Empty ${tc1[0]} IN + Length Should Be ${tc2.body} 1 + FOR Loop Should Be Empty ${tc2[0]} IN RANGE + +TRY/EXCEPT in All mode + ${tc} = Check Test Case Everything + Length Should Be ${tc.body} 1 + Length Should Be ${tc[0].body} 5 + TRY Branch Should Be Empty ${tc[0, 0]} TRY Ooops!
+ TRY Branch Should Be Empty ${tc[0, 1]} EXCEPT + TRY Branch Should Be Empty ${tc[0, 2]} EXCEPT + TRY Branch Should Be Empty ${tc[0, 3]} ELSE + TRY Branch Should Be Empty ${tc[0, 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[1].type} WHILE + Should Be Empty ${tc[1].body} + Should Be Equal ${tc[1].message} *HTML* ${DATA REMOVED} + +VAR in All mode + ${tc1} = Check Test Case IF structure + ${tc2} = Check Test Case WHILE loop executed multiple times + Should Be Equal ${tc1[0].type} VAR + Should Be Empty ${tc1[0].body} + Should Be Equal ${tc1[0].message} *HTML* ${DATA REMOVED} + Should Be Equal ${tc2[0].type} VAR + Should Be Empty ${tc2[0].body} + Should Be Equal ${tc2[0].message} *HTML* ${DATA REMOVED} + Passed Mode [Setup] Run Rebot and set My Suite --removekeywords passed 0 - Keyword Should Not Be Empty ${MY SUITE.setup} My Keyword Suite Setup ${tc1} = Check Test Case Pass ${tc2} = Check Test Case Fail - Length Should Be ${tc1.kws} 1 - Keyword Should Be Empty ${tc1.keywords[0]} My Keyword Pass - Keyword Should Contain Removal Message ${tc1.keywords[0]} - Length Should Be ${tc2.kws} 2 - Keyword Should Not Be Empty ${tc2.kws[0]} My Keyword Fail - Keyword Should Not Be Empty ${tc2.kws[1]} BuiltIn.Fail Expected failure + ${tc3} = Check Test Case Test with setup and teardown + Keyword Should Not Be Empty ${MY SUITE.setup} My Keyword Suite Setup + Length Should Be ${tc1.body} 3 + Keyword Should Be Empty ${tc1[0]} My Keyword Pass + Keyword Should Contain Removal Message ${tc1[0]} + Length Should Be ${tc2.body} 4 + Check Log message ${tc2[0]} Hello 'Fail', says listener! + Keyword Should Not Be Empty ${tc2[1]} My Keyword Fail + Keyword Should Not Be Empty ${tc2[2]} BuiltIn.Fail Expected failure + Check Log message ${tc2[3]} Bye 'Fail', says listener! + Keyword Should Be Empty ${tc3.setup} Test Setup + Keyword Should Contain Removal Message ${tc3.setup} + Keyword Should Be Empty ${tc3.teardown} Test Teardown + Keyword Should Contain Removal Message ${tc3.teardown} Warnings Are Not Removed In Passed Mode [Setup] Verify previous test and set My Suite Passed Mode 1 - Keyword Should Not Be Empty ${MY SUITE.setup} Warning in suite setup - Keyword Should Not Be Empty ${MY SUITE.teardown} Warning in suite teardown ${tc1} ${tc2}= Set Variable ${MY SUITE.tests[:2]} - Length Should Be ${tc1.kws} 1 - Keyword Should Not Be Empty ${tc1.kws[0]} Warning in test case - Keyword Should Not Be Empty ${tc1.kws[0].kws[0]} BuiltIn.Log Warning in \${where} WARN - Length Should Be ${tc2.kws} 1 - Keyword Should Be Empty ${tc2.kws[0]} No warning + Keyword Should Not Be Empty ${MY SUITE.setup} Warning in suite setup + Keyword Should Not Be Empty ${MY SUITE.teardown} Warning in suite teardown + Length Should Be ${tc1.body} 3 + Check Log message ${tc1[0]} Hello 'Warning in test case', says listener! + Keyword Should Not Be Empty ${tc1[1]} Warning in test case + Check Log message ${tc1[2]} Bye 'Warning in test case', says listener! + Keyword Should Not Be Empty ${tc1[1, 0, 0, 0]} BuiltIn.Log Warning in \${where} WARN + Length Should Be ${tc2.body} 1 + Keyword Should Be Empty ${tc2[0]} No warning Logged Warnings Are Preserved In Execution Errors Errors Are Not Removed In Passed Mode [Setup] Previous test should have passed Warnings Are Not Removed In Passed Mode ${tc} = Check Test Case Error in test case - Check Log Message ${tc.kws[0].kws[0].msgs[0]} Logged errors supported since 2.9 ERROR + Length Should Be ${tc.body} 3 + Check Log message ${tc[0]} Hello 'Error in test case', says listener! + Check Log Message ${tc[1, 0, 0]} Logged errors supported since 2.9 ERROR + Check Log message ${tc[2]} Bye 'Error in test case', says listener! Logged Errors Are Preserved In Execution Errors Name Mode [Setup] Run Rebot and set My Suite ... --removekeywords name:BuiltIn.Fail --RemoveK NAME:??_KEYWORD --RemoveK NaMe:*WARN*IN* --removek name:errorin* 0 - Keyword Should Be Empty ${MY SUITE.setup} My Keyword Suite Setup - Keyword Should Contain Removal Message ${MY SUITE.setup} ${tc1} = Check Test Case Pass ${tc2} = Check Test Case Fail - Length Should Be ${tc1.kws} 1 - Keyword Should Be Empty ${tc1.kws[0]} My Keyword Pass - Keyword Should Contain Removal Message ${tc1.kws[0]} - Length Should Be ${tc2.kws} 2 - Keyword Should Be Empty ${tc2.kws[0]} My Keyword Fail - Keyword Should Contain Removal Message ${tc2.kws[0]} - Keyword Should Be Empty ${tc2.kws[1]} BuiltIn.Fail Expected failure - Keyword Should Contain Removal Message ${tc2.kws[0]} + Keyword Should Be Empty ${MY SUITE.setup} My Keyword Suite Setup + Keyword Should Contain Removal Message ${MY SUITE.setup} + Length Should Be ${tc1.body} 5 + Keyword Should Be Empty ${tc1[1]} My Keyword Pass + Keyword Should Contain Removal Message ${tc1[1]} + Length Should Be ${tc2.body} 4 + Keyword Should Be Empty ${tc2[1]} My Keyword Fail + Keyword Should Contain Removal Message ${tc2[1]} + Keyword Should Be Empty ${tc2[2]} BuiltIn.Fail Expected failure Warnings Are Not Removed In Name Mode [Setup] Verify previous test and set My Suite Name Mode 1 - Keyword Should Not Be Empty ${MY SUITE.setup} Warning in suite setup - Keyword Should Not Be Empty ${MY SUITE.teardown} Warning in suite teardown ${tc1} ${tc2}= Set Variable ${MY SUITE.tests[:2]} - Length Should Be ${tc1.kws} 1 - Length Should Be ${tc2.kws} 1 - Keyword Should Not Be Empty ${tc1.kws[0]} Warning in test case - Keyword Should Not Be Empty ${tc1.kws[0].kws[0]} BuiltIn.Log Warning in \${where} WARN - Keyword Should Be Empty ${tc2.kws[0]} No warning + Keyword Should Not Be Empty ${MY SUITE.setup} Warning in suite setup + Keyword Should Not Be Empty ${MY SUITE.teardown} Warning in suite teardown + Length Should Be ${tc1.body} 3 + Length Should Be ${tc2.body} 3 + Keyword Should Not Be Empty ${tc1[1]} Warning in test case + Keyword Should Not Be Empty ${tc1[1, 0, 0, 0]} BuiltIn.Log Warning in \${where} WARN + Keyword Should Be Empty ${tc2[1]} No warning Logged Warnings Are Preserved In Execution Errors Errors Are Not Removed In Name Mode [Setup] Previous test should have passed Warnings Are Not Removed In Name Mode ${tc} = Check Test Case Error in test case - Check Log Message ${tc.kws[0].kws[0].msgs[0]} Logged errors supported since 2.9 ERROR + Check Log Message ${tc[1, 0, 0]} Logged errors supported since 2.9 ERROR Logged Errors Are Preserved In Execution Errors Tag Mode [Setup] Run Rebot and set My Suite --removekeywords tag:force --RemoveK TAG:warn 0 - Keyword Should Be Empty ${MY SUITE.setup} My Keyword Suite Setup - Keyword Should Contain Removal Message ${MY SUITE.setup} ${tc1} = Check Test Case Pass ${tc2} = Check Test Case Fail - Length Should Be ${tc1.kws} 1 - Keyword Should Be Empty ${tc1.kws[0]} My Keyword Pass - Keyword Should Contain Removal Message ${tc1.kws[0]} - Length Should Be ${tc2.kws} 2 - Keyword Should Be Empty ${tc2.kws[0]} My Keyword Fail - Keyword Should Contain Removal Message ${tc2.kws[0]} - Keyword Should Not Be Empty ${tc2.kws[1]} BuiltIn.Fail Expected failure + Keyword Should Be Empty ${MY SUITE.setup} My Keyword Suite Setup + Keyword Should Contain Removal Message ${MY SUITE.setup} + Length Should Be ${tc1.body} 5 + Keyword Should Be Empty ${tc1[1]} My Keyword Pass + Keyword Should Contain Removal Message ${tc1[1]} + Length Should Be ${tc2.body} 4 + Keyword Should Be Empty ${tc2[1]} My Keyword Fail + Keyword Should Contain Removal Message ${tc2[1]} + Keyword Should Not Be Empty ${tc2[2]} BuiltIn.Fail Expected failure Warnings Are Not Removed In Tag Mode [Setup] Verify previous test and set My Suite Tag Mode 1 - Keyword Should Not Be Empty ${MY SUITE.setup} Warning in suite setup - Keyword Should Not Be Empty ${MY SUITE.teardown} Warning in suite teardown ${tc1} ${tc2}= Set Variable ${MY SUITE.tests[:2]} - Length Should Be ${tc1.kws} 1 - Length Should Be ${tc2.kws} 1 - Keyword Should Not Be Empty ${tc1.kws[0]} Warning in test case - Keyword Should Not Be Empty ${tc1.kws[0].kws[0]} BuiltIn.Log Warning in \${where} WARN - Keyword Should Be Empty ${tc2.kws[0]} No warning + Keyword Should Not Be Empty ${MY SUITE.setup} Warning in suite setup + Keyword Should Not Be Empty ${MY SUITE.teardown} Warning in suite teardown + Length Should Be ${tc1.body} 3 + Keyword Should Not Be Empty ${tc1[1]} Warning in test case + Keyword Should Not Be Empty ${tc1[1, 0, 0, 0]} BuiltIn.Log Warning in \${where} WARN + Length Should Be ${tc2.body} 3 + Keyword Should Be Empty ${tc2[1]} No warning Logged Warnings Are Preserved In Execution Errors Errors Are Not Removed In Tag Mode [Setup] Previous test should have passed Warnings Are Not Removed In Tag Mode ${tc} = Check Test Case Error in test case - Check Log Message ${tc.kws[0].kws[0].msgs[0]} Logged errors supported since 2.9 ERROR + Check Log Message ${tc[1, 0, 0]} Logged errors supported since 2.9 ERROR Logged Errors Are Preserved In Execution Errors *** Keywords *** Run Some Tests - Create Output With Robot ${INPUTFILE} ${EMPTY} misc/pass_and_fail.robot misc/warnings_and_errors.robot + VAR ${options} + ... --listener AddMessagesToTestBody + VAR ${suites} + ... misc/pass_and_fail.robot + ... misc/warnings_and_errors.robot + ... misc/if_else.robot + ... misc/for_loops.robot + ... misc/try_except.robot + ... misc/while.robot + ... misc/setups_and_teardowns.robot + Create Output With Robot ${INPUTFILE} ${options} ${suites} Run Rebot And Set My Suite [Arguments] ${rebot params} ${suite index} @@ -146,9 +214,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} 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/combinations.robot b/atest/robot/cli/rebot/remove_keywords/combinations.robot index 79434ff8f52..4858fc7f5e3 100644 --- a/atest/robot/cli/rebot/remove_keywords/combinations.robot +++ b/atest/robot/cli/rebot/remove_keywords/combinations.robot @@ -4,17 +4,15 @@ Test Template Run Rebot With RemoveKeywords Resource remove_keywords_resource.robot *** Test Cases *** rem1 rem2 etc. - Rational \FOR WUKS - PASSED FOR + PASSED FOR WHILE PASSED WUKS PASSED WUKS FOR -Irrational ALL WUKS +Irrational ALL WUKS WHILE ALL FOR PASSED WUKS FOR FOR WUKS - *** Keywords *** Run Rebot With RemoveKeywords [Arguments] @{options} @@ -26,12 +24,14 @@ Run Rebot With RemoveKeywords Validate Log [Arguments] @{options} ${LOG}= Get File ${OUTDIR}/log.html - : FOR ${item} IN @{options} - \ Should Not Contain ${LOG} -${item} + FOR ${item} IN @{options} + Should Not Contain ${LOG} -${item} + END Validate Tests Should Contain Tests ${SUITE} Passing Failing - ... For when test fails For when test passes + ... FOR when test fails FOR when test passes + ... WHILE when test fails WHILE when test passes ... WUKS when test fails WUKS when test passes ... NAME when test fails NAME when test passes ... NAME with * pattern when test fails NAME with * pattern when test passes @@ -40,4 +40,3 @@ Validate Tests Create Output Create Output With Robot ${INPUTFILE} ${EMPTY} cli/remove_keywords/all_combinations.robot - 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 47b1d1614d7..05bc9148bd2 100644 --- a/atest/robot/cli/rebot/remove_keywords/for_loop_keywords.robot +++ b/atest/robot/cli/rebot/remove_keywords/for_loop_keywords.robot @@ -4,48 +4,60 @@ Suite Teardown Remove File ${INPUTFILE} Resource remove_keywords_resource.robot *** Variables *** -${0 REMOVED} ${EMPTY} -${1 REMOVED} _1 passing step 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 - ${tc}= Check Test Case Simple For - Length Should Be ${tc.kws[1].kws} 1 - Should Be Equal ${tc.kws[1].doc} ${1 REMOVED} - Should Be Equal ${tc.kws[1].kws[0].status} PASS + ${tc}= Check Test Case Simple loop + Length Should Be ${tc[1].body} 1 + Should Be Equal ${tc[1].message} *HTML* ${1 REMOVED} + Should Be Equal ${tc[1, 0].status} PASS Failed Steps Are Not Removed - ${tc}= Check Test Case For Failing 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 - Length Should Be ${tc.kws[0].kws[0].kws} 2 - Should Be Equal ${tc.kws[0].kws[0].status} FAIL + ${tc}= Check Test Case Failure inside FOR 2 + Length Should Be ${tc[0].body} 1 + Should Be Equal ${tc[0].message} *HTML* Failure with <4>
${3 REMOVED} + Should Be Equal ${tc[0, 0].type} ITERATION + Should Be Equal ${tc[0, 0].assign['\${num}']} 4 + Should Be Equal ${tc[0, 0].status} FAIL + Length Should Be ${tc[0, 0].body} 3 + Should Be Equal ${tc[0, 0, -1].status} NOT RUN Steps With Warning Are Not Removed - ${tc}= Check Test Case Simple For 2 - Length Should Be ${tc.kws[0].kws} 2 - Should Be Equal ${tc.kws[0].doc} ${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 + ${tc}= Check Test Case Variables in values + Length Should Be ${tc[0].body} 2 + Should Be Equal ${tc[0].message} *HTML* ${4 REMOVED} + Check Log Message ${tc[0, 0, -1, 0, 0]} Presidential Candidate! WARN + Check Log Message ${tc[0, 1, -1, 0, 0]} Presidential Candidate! WARN Steps From Nested Loops Are Removed - ${tc}= Check Test Case Nested For In User Keywords - 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} + ${tc}= Check Test Case Nested Loop Syntax + Length Should Be ${tc[0].body} 1 + Should Be Equal ${tc[0].message} *HTML* ${2 REMOVED} + Length Should Be ${tc[0, 0, 1].body} 1 + Should Be Equal ${tc[0, 0, 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[0, 0].body} 1 + Should Be Equal ${tc[0, 0].message} This ought to be enough + Length Should Be ${tc[0, 0, 0, 0, 1].body} 1 + Should Be Equal ${tc[0, 0, 0, 0, 1].message} *HTML* ${1 REMOVED} + Length Should Be ${tc[0, 0, 0, 1, 0].body} 1 + Should Be Equal ${tc[0, 0, 0, 1, 0].message} *HTML* ${1 REMOVED} Empty Loops Are Handled Correctly - ${tc}= Check Test Case Empty For Body Fails 1 - Should Be Empty ${tc.kws[0].kws} - Should Be Equal ${tc.kws[0].doc} ${0 REMOVED} + ${tc}= Check Test Case Empty body + Should Be Equal ${tc[0].status} FAIL + Should Be Equal ${tc[0].message} FOR loop cannot be empty. + Should Be Equal ${tc[0, 0].type} ITERATION + Should Be Equal ${tc[0, 0].status} NOT RUN + Should Be Empty ${tc[0, 0].body} *** Keywords *** Remove For Loop Keywords With Rebot - Create Output With Robot ${INPUTFILE} ${EMPTY} running/for.robot + Create Output With Robot ${INPUTFILE} ${EMPTY} running/for/for.robot Run Rebot --removekeywords fOr ${INPUTFILE} 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 24f1c81fcd9..f11bebb58fe 100644 --- a/atest/robot/cli/rebot/remove_keywords/remove_keywords_resource.robot +++ b/atest/robot/cli/rebot/remove_keywords/remove_keywords_resource.robot @@ -3,22 +3,41 @@ Resource rebot_resource.robot *** Variables *** ${INPUTFILE} %{TEMPDIR}${/}rebot-test-rmkw.xml +${DATA REMOVED} Content removed using the --remove-keywords option. *** Keywords *** Keyword Should Be Empty [Arguments] ${kw} ${name} @{args} + Should End With ${kw.message} ${DATA REMOVED} Check Keyword Name And Args ${kw} ${name} @{args} - Should Be Empty ${kw.keywords} - Should Be Empty ${kw.messages} + Should Be Empty ${kw.body} + +IF Branch Should Be Empty + [Arguments] ${branch} ${type} ${condition}=${None} + 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.message} *HTML* ${DATA REMOVED} + Should Be Equal ${loop.type} FOR + 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} - ${num_keywords}= Get Length ${kw.keywords} - ${num_messages}= Get Length ${kw.messages} - Should Be True ${num_keywords} + ${num_messages} > 0 + Should Not Be Empty ${kw.body} 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/rebot/remove_keywords/wait_until_keyword_succeeds.robot b/atest/robot/cli/rebot/remove_keywords/wait_until_keyword_succeeds.robot index 7c90afd0766..8bdec7783e2 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,28 @@ 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 item* removed using the --remove-keywords option. + Should Match ${tc[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[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 - Should be Equal ${tc.kws[0].doc} ${DOC} + Should be Equal ${tc[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[0].messages} 1 + Length Should Be ${tc[0].non_messages} 1 + Length Should Be ${tc[0, 0].body} 1 *** Keywords *** Remove Wait Until Keyword Succeeds with Rebot @@ -30,8 +31,7 @@ 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[0].non_messages} ${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 new file mode 100644 index 00000000000..ea36561aef8 --- /dev/null +++ b/atest/robot/cli/rebot/remove_keywords/while_loop_keywords.robot @@ -0,0 +1,35 @@ +*** Settings *** +Suite Setup Remove WHILE Keywords With Rebot +Suite Teardown Remove File ${INPUTFILE} +Resource remove_keywords_resource.robot + +*** Variables *** +${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 + ${tc}= Check Test Case Loop executed multiple times + Length Should Be ${tc[0].body} 1 + Should Be Equal ${tc[0].message} *HTML* ${4 REMOVED} + Should Be Equal ${tc[0, 0].status} PASS + +Failed Steps Are Not Removed + ${tc}= Check Test Case Execution fails after some loops + Length Should Be ${tc[0].body} 1 + Should Be Equal ${tc[0].message} *HTML* Oh no, got 4
${2 REMOVED} + Should Be Equal ${tc[0, 0].status} FAIL + Length Should Be ${tc[0, 0].body} 3 + Should Be Equal ${tc[0, 0, -1].status} NOT RUN + +Steps From Nested Loops Are Removed + ${tc}= Check Test Case Loop in loop + Length Should Be ${tc[0].body} 1 + Should Be Equal ${tc[0].message} *HTML* ${4 REMOVED} + Length Should Be ${tc[0, 0, 2].body} 1 + Should Be Equal ${tc[0, 0, 2].message} *HTML* ${2 REMOVED} + +*** Keywords *** +Remove WHILE Keywords With Rebot + Create Output With Robot ${INPUTFILE} ${EMPTY} running/while/while.robot + Run Rebot --removekeywords while ${INPUTFILE} diff --git a/atest/robot/cli/rebot/status_rc.robot b/atest/robot/cli/rebot/status_rc.robot index 43f6a885add..a7fbac4741f 100644 --- a/atest/robot/cli/rebot/status_rc.robot +++ b/atest/robot/cli/rebot/status_rc.robot @@ -13,18 +13,14 @@ ${NO OUTPUTS} [ ERROR ] No outputs created.\n\nTry --help for usage informa Zero RC when all tests pass ${EMPTY} ${PASSING} rc=0 -Zero RC when all critical tests pass - --critical pass ${FAILING} rc=0 - --statusrc -c pass ${FAILING} rc=0 - -Non-zero RC when critical tests fail +Non-zero RC when tests fail ${EMPTY} ${FAILING} rc=1 --status ${FAILING} rc=1 Zero RC when all tests pass with --NoStatusRC --NoStatusRC ${PASSING} rc=0 -Zero RC when critical tests fail with --NoStatusRC +Zero RC when tests fail with --NoStatusRC --nostatusrc ${FAILING} rc=0 Error when no output is created 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 7dfb8072813..861dddf3cfe 100644 --- a/atest/robot/cli/rebot/suite_name_doc_and_metadata.robot +++ b/atest/robot/cli/rebot/suite_name_doc_and_metadata.robot @@ -5,9 +5,7 @@ Resource rebot_cli_resource.robot Default Name, Doc & Metadata [Documentation] Using default values (read from xml) for name, doc and metadata. Run Rebot ${EMPTY} ${INPUT FILE} - Check Names ${SUITE} Normal - Check Names ${SUITE.tests[0]} First One Normal. - Check Names ${SUITE.tests[1]} Second One Normal. + Check All Names ${SUITE} Normal Should Be Equal ${SUITE.doc} Normal test cases Should Be Equal ${SUITE.metadata['Something']} My Value @@ -15,18 +13,45 @@ Overriding Name, Doc & Metadata And Escaping [Documentation] Overriding name, doc and metadata. Also tests escaping values. ${options} = Catenate ... -N this_is_overridden_next - ... --name "my COOL Name!!" + ... --name "my COOL Name.!!." ... --doc "Even \\cooooler\\ doc!?" - ... --metadata something:New + ... --metadata something:New! ... --metadata "two parts:three parts here" ... -M path:c:\\temp\\new.txt ... -M esc:*?$&#!! Run Rebot ${options} ${INPUT FILE} - Check Names ${SUITE} my COOL Name!! - Check Names ${SUITE.tests[0]} First One my COOL Name!!. - Check Names ${SUITE.tests[1]} Second One my COOL Name!!. + Check All Names ${SUITE} my COOL Name.!!. Should Be Equal ${SUITE.doc} Even \\cooooler\\ doc!? - Should Be Equal ${SUITE.metadata['Something']} New + Should Be Equal ${SUITE.metadata['Something']} New! Should Be Equal ${SUITE.metadata['two parts']} three parts here Should Be Equal ${SUITE.metadata['path']} c:\\temp\\new.txt Should Be Equal ${SUITE.metadata['esc']} *?$&#!! + +Documentation and metadata from external file + ${path} = Normalize Path ${DATADIR}/cli/runner/doc.txt + ${value} = Get File ${path} + Run Rebot --doc ${path} --metadata name:${path} ${INPUT 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}" -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 + [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 + [Arguments] ${suite} ${name} + Check Names ${suite} ${name} + Check Names ${suite.tests[0]} First One ${name}. + Check Names ${suite.tests[1]} Second One ${name}. diff --git a/atest/robot/cli/rebot/timestamp_outputs.robot b/atest/robot/cli/rebot/timestamp_outputs.robot index 325fd1bfa07..54e3ff97ba3 100644 --- a/atest/robot/cli/rebot/timestamp_outputs.robot +++ b/atest/robot/cli/rebot/timestamp_outputs.robot @@ -7,11 +7,13 @@ Timestamped Outputs Length Should Be ${files} 2 ${timestamp} = Should Match Regexp ${files}[0] 20\\d{6}-\\d{6} FOR ${file} IN @{files} - \ Should Match Regexp ${file} ^(log|report)-${timestamp}\\.(html|xml)$ + Should Match Regexp ${file} ^(log|report)-${timestamp}\\.(html|xml)$ + END Timestamped Outputs With Custom Names @{files} = Run rebot and return outputs --timest -l l -r r.html -o o Length Should Be ${files} 3 ${timestamp} = Should Match Regexp ${files}[0] 20\\d{6}-\\d{6} FOR ${file} IN @{files} - \ Should Match Regexp ${file} ^(l|o|r)-${timestamp}\\.(html|xml)$ + Should Match Regexp ${file} ^(l|o|r)-${timestamp}\\.(html|xml)$ + END diff --git a/atest/robot/cli/runner/ROBOT_OPTIONS.robot b/atest/robot/cli/runner/ROBOT_OPTIONS.robot index a7b6771a65d..60a569aec2b 100644 --- a/atest/robot/cli/runner/ROBOT_OPTIONS.robot +++ b/atest/robot/cli/runner/ROBOT_OPTIONS.robot @@ -8,10 +8,10 @@ Use defaults Run Tests ${EMPTY} misc/pass_and_fail.robot Should Be Equal ${SUITE.name} Default ${tc} = Check Test Tags Pass force pass default with spaces - Should Be Equal ${tc.kws[0].kws[0].status} NOT_RUN + Should Be Equal ${tc[0, 0].status} NOT RUN Override defaults Run Tests -N Given -G given --nodryrun misc/pass_and_fail.robot Should Be Equal ${SUITE.name} Given ${tc} = Check Test Tags Pass force pass default with spaces given - Should Be Equal ${tc.kws[0].kws[0].status} PASS + Should Be Equal ${tc[0, 0].status} PASS diff --git a/atest/robot/cli/runner/argument_file.robot b/atest/robot/cli/runner/argument_file.robot new file mode 100644 index 00000000000..4f14fc7ec07 --- /dev/null +++ b/atest/robot/cli/runner/argument_file.robot @@ -0,0 +1,155 @@ +*** Settings *** +Test Setup Create Output Directory +Suite Teardown Remove Files ${ARGFILE} ${ARGFILE 2} ${ÄRGFÏLË} +Resource cli_resource.robot + +*** Variables *** +${ARGFILE} %{TEMPDIR}${/}arg_file_1.txt +${ARGFILE 2} %{TEMPDIR}${/}arg_file_2.txt +${ÄRGFÏLË} %{TEMPDIR}${/}ärg_fïlë_3.txt +${BOM} \uFEFF + +*** Test Cases *** +Argument file + Create Argument File ${ARGFILE} + ... --name=From Argfile With Spaces + ... --metadata${SPACE*10}Meta1:From AF + ... -M=Meta2:Overwritten + ... --RunEmptySuite + ... ${EMPTY} + ... \# comment line + ... ${EMPTY} + ... \# another comment + ... -d ${CLI OUTDIR} + ... -l=none + ... --report=none + ... -o${SPACE*5}output.xml + ${result} = Run Tests -M Meta1:Overwritten --argumentfile ${ARGFILE} -M Meta2:cli ${TESTFILE} + ... output=${CLI OUTDIR}/output.xml + Execution Should Have Succeeded ${result} + Should Be Equal ${SUITE.name} From Argfile With Spaces + Should Be Equal ${SUITE.metadata} {Meta1: From AF, Meta2: cli, Something: My Value} types=str + +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} --Argument-File ${ARGFILE2} ${TESTFILE} + Execution Should Have Succeeded ${result} + Should Be Equal ${SUITE.metadata}[A1] Value1 + Should Be Equal ${SUITE.metadata}[A2] Value2 + +Recursive argument file + Create Argument File ${ARGFILE} -M First:1 -M Second:overwritten --ARGUMENTFILE ${ARGFILE2} + Create Argument File ${ARGFILE2} --metadata Second:2 + ${result} = Run Tests -A ${ARGFILE} ${TESTFILE} + Execution Should Have Succeeded ${result} + Should Be Equal ${SUITE.metadata}[First] 1 + Should Be Equal ${SUITE.metadata}[Second] 2 + +Argument file with non-ASCII characters + ${path} = Copy File ${DATADIR}/parsing/non_ascii_paths/test-auml-ouml.robot %{TEMPDIR}/testäö.robot + Create Argument File ${ÄRGFÏLË} -D äëïöüÿ -C off ${path} + ${result} = Run Tests --argumentfile ${ÄRGFÏLË} + Execution Should Have Succeeded ${result} + Should Contain ${result.stdout} Testäö :: äëïöüÿ + +Argumentfile from stdin + ${test dir} = Normalize Path ${DATADIR}/misc/ + Create Argument File Without BOM ${ARG FILE} + ... --name My Name with Nön Äscii + ... ${test dir}${/}normal.robot + ${cmd} = Join Command Line + ... @{INTERPRETER.runner} + ... --output NONE + ... --report NONE + ... --log NONE + ... --doc from command line + ... --argumentfile stdin + ... ${test dir}${/}pass_and_fail.robot + ${result} = Run Process ${cmd} < ${ARG FILE} shell=True + ... stdout=${STDOUT FILE} stderr=${STDERR FILE} + Execution Should Have Succeeded ${result} rc=1 + Should Contain ${result.stdout} Normal + Should Contain ${result.stdout} Pass And Fail + Should Contain ${result.stdout} My Name with N + Should Contain ${result.stdout} from command line + +Option name and value joined together + Create Argument File ${ARGFILE} --name My name + Create Argument File ${ARGFILE2} --doc My docu + ${result} = Run Tests --argumentfile\=${ARGFILE} -A${ARGFILE2} ${TESTFILE} + Execution Should Have Succeeded ${result} + Should Be Equal ${SUITE.name} My name + Should Be Equal ${SUITE.doc} My docu + +Shortening --argumentfile is not possible + Create Argument File ${ARGFILE} --name My name + ${result} = Run Tests Without Processing Output --argumentfil ${ARGFILE} ${TESTFILE} + Execution Should Have Failed ${result} + ... Using '--argumentfile' option in shortened format like '--argumentf' is not supported. + +Expand environment variables + Set Environment Variable ROBOT_NAME Robot + Set Environment Variable ROBOT_NONA Räbät + Create Argument File ${ARGFILE} + ... \# expandvars: true + ... --name $ROBOT_NAME + ... --doc \${ROBOT_NAME}Framework + ... --metadata Non-ASCII:$ROBOT_NONA + ... --metadata Defaults:\${ROBOT_NAME=default not used} \${NON_EXISTING=Framework} + ... --metadata Escape:$ROBOT_NAME and $$ROBOT_NAME and $$$ROBOT_NAME + ... \${WHOLE_LINE=--metadata Whole line:True} + ... \${CONDITIONAL=} --metadata Conditional:True + ... \${CONDITIONAL=#} --metadata Conditional:False + Create Argument File ${ARGFILE2} + ... \# expandvars: false + ... --metadata Disabled:$ROBOT_NAME + ${result} = Run Tests --argument-file ${ARGFILE} -A ${ARGFILE2} ${TESTFILE} + Execution Should Have Succeeded ${result} + Should Be Equal ${SUITE.name} Robot + Should Be Equal ${SUITE.doc} RobotFramework + Should Be Equal ${SUITE.metadata}[Non-ASCII] Räbät + Should Be Equal ${SUITE.metadata}[Defaults] Robot Framework + Should Be Equal ${SUITE.metadata}[Escape] Robot and $ROBOT_NAME and $Robot + Should Be Equal ${SUITE.metadata}[Disabled] $ROBOT_NAME + Should Be Equal ${SUITE.metadata}[Whole line] True + Should Be Equal ${SUITE.metadata}[Conditional] True + [Teardown] Remove Environment Variable ROBOT_NAME ROBOT_NONA + +Non-existing environment variable + Create Argument File ${ARGFILE} + ... \# ExpandVars: True + ... --name $ROBOT_NAME + ${result} = Run Tests Without Processing Output -A ${ARGFILE} ${TESTFILE} + Execution Should Have Failed ${result} + ... Processing argument file '${ARGFILE}' failed: Variable 'ROBOT_NAME' does not exist. + +Invalid environment variable + Create Argument File ${ARGFILE} + ... \# EXPANDVARS: YES + ... --name $3m + ${result} = Run Tests Without Processing Output -A ${ARGFILE} ${TESTFILE} + Execution Should Have Failed ${result} + ... Processing argument file '${ARGFILE}' failed: Invalid placeholder in string: line 2, col 8 + +*** Keywords *** +Create Argument File + [Arguments] ${path} @{lines} + [Documentation] Writes also UTF8 BOM to the file to test it's ignored + VAR ${content} @{lines} separator=\n + Create File ${path} ${BOM}${content} + +Create Argument File Without BOM + [Arguments] ${path} @{lines} + VAR ${content} @{lines} separator=\n + Create File ${path} ${content} + +Execution Should Have Succeeded + [Arguments] ${result} ${rc}=0 + Should Be Equal ${result.rc} ${rc} type=int + Should Be Empty ${result.stderr} + +Execution Should Have Failed + [Arguments] ${result} ${error} ${rc}=252 + Should Be Equal ${result.rc} ${rc} type=int + Should Be Equal ${result.stderr} [ ERROR ] ${error}${USAGE TIP} diff --git a/atest/robot/cli/runner/argumentfile.robot b/atest/robot/cli/runner/argumentfile.robot deleted file mode 100644 index 122474cfc5d..00000000000 --- a/atest/robot/cli/runner/argumentfile.robot +++ /dev/null @@ -1,111 +0,0 @@ -*** Settings *** -Test Setup Create Output Directory -Suite Teardown Remove Files ${ARGFILE} ${ARGFILE 2} ${ÄRGFÏLË} -Resource cli_resource.robot - -*** Variables *** -${ARGFILE} %{TEMPDIR}/arg_file_1.txt -${ARGFILE 2} %{TEMPDIR}/arg_file_2.txt -${ÄRGFÏLË} %{TEMPDIR}/ärg_fïlë_3.txt -${BOM} \uFEFF - -*** Test Cases *** -Argument File - Create Argument File ${ARGFILE} - ... --name=From Argfile With Spaces - ... --metadata${SPACE*10}Meta1:From AF - ... -M=Meta2:Overwritten - ... --RunEmptySuite - ... ${EMPTY} - ... \# comment line - ... ${EMPTY} - ... \# another comment - ... -d ${CLI OUTDIR} - ... -l=none - ... --report=none - ... -o${SPACE*5}output.xml - ${result} = Run Tests -M Meta1:Overwritten --argumentfile ${ARGFILE} -M Meta2:cli ${TESTFILE} - ... output=${CLI OUTDIR}/output.xml - Execution Should Have Succeeded ${result} - Should Be Equal ${SUITE.name} From Argfile With Spaces - Should Be Equal as Strings ${SUITE.metadata} {Meta1: From AF, Meta2: cli, Something: My Value} - -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} - Execution Should Have Succeeded ${result} - Should Be Equal ${SUITE.metadata['A1']} Value1 - Should Be Equal ${SUITE.metadata['A2']} Value2 - -Recursive Argument File - Create Argument File ${ARGFILE} -M First:1 -M Second:overwritten --ARGUMENTFILE ${ARGFILE2} - Create Argument File ${ARGFILE2} --metadata Second:2 - ${result} = Run Tests -A ${ARGFILE} ${TESTFILE} - Execution Should Have Succeeded ${result} - Should Be Equal ${SUITE.metadata['First']} 1 - Should Be Equal ${SUITE.metadata['Second']} 2 - -Argument File with Non-ASCII Characters - ${path} = Copy File ${DATADIR}/parsing/non_ascii_paths/test-auml-ouml.robot %{TEMPDIR}/testäö.robot - Create Argument File ${ÄRGFÏLË} -D äëïöüÿ -C off ${path} - ${result} = Run Tests --argumentfile ${ÄRGFÏLË} - Execution Should Have Succeeded ${result} - Should Contain ${result.stdout} Testäö :: äëïöüÿ - -Arguments From Stdin - ${test dir} = Normalize Path ${DATADIR}/misc/ - Create Argument File Without BOM ${ARG FILE} - ... --name My Name with Nön Äscii - ... ${test dir}${/}normal.robot - ${cmd} = Join Command Line - ... @{INTERPRETER.runner} - ... --output NONE - ... --report NONE - ... --log NONE - ... --doc from command line - ... --argumentfile stdin - ... ${test dir}${/}pass_and_fail.robot - ${result} = Run Process ${cmd} < ${ARG FILE} shell=True - ... stdout=${STDOUT FILE} stderr=${STDERR FILE} - Execution Should Have Succeeded ${result} rc=1 - Should Contain ${result.stdout} Normal - Should Contain ${result.stdout} Pass And Fail - Should Contain ${result.stdout} My Name with N - Should Contain ${result.stdout} from command line - -Option And Argument File Together - Create Argument File ${ARGFILE} --name My name - Create Argument File ${ARGFILE2} --doc My docu - ${result} = Run Tests --argumentfile\=${ARGFILE} -A${ARGFILE2} ${TESTFILE} - Execution Should Have Succeeded ${result} - Should Be Equal ${SUITE.name} My name - Should Be Equal ${SUITE.doc} My docu - -Shortening --argumentfile is not possible - Create Argument File ${ARGFILE} --name My name - ${result} = Run Tests Without Processing Output --argumentfil ${ARGFILE} ${TESTFILE} - Execution Should Have Failed ${result} - ... Using '--argumentfile' option in shortened format like '--argumentf' is not supported. - -*** Keywords *** -Create Argument File - [Arguments] ${path} @{lines} - [Documentation] Writes also UTF8 BOM to the file to test it's ignored - ${content} = Catenate SEPARATOR=\n @{lines} - Create File ${path} ${BOM}${content} - -Create Argument File Without BOM - [Arguments] ${path} @{lines} - ${content} = Catenate SEPARATOR=\n @{lines} - Create File ${path} ${content} - -Execution Should Have Succeeded - [Arguments] ${result} ${rc}=0 - Should Be Equal As Integers ${result.rc} ${rc} - Should Be Empty ${result.stderr} - -Execution Should Have Failed - [Arguments] ${result} ${error} ${rc}=252 - Should Be Equal As Integers ${result.rc} ${rc} - Should Be Equal ${result.stderr} [ ERROR ] ${error}${USAGE TIP} diff --git a/atest/robot/cli/runner/cli_resource.robot b/atest/robot/cli/runner/cli_resource.robot index b538b718a50..9d060098af3 100644 --- a/atest/robot/cli/runner/cli_resource.robot +++ b/atest/robot/cli/runner/cli_resource.robot @@ -24,20 +24,24 @@ Output Directory Should Be Empty Run Some Tests [Arguments] ${options}=-l none -r none - ${result} = Run Tests -d ${CLI OUTDIR} ${options} ${TEST FILE} default options= output= + ${result} = Run Tests -d ${CLI OUTDIR} ${options} ${TEST FILE} default options= output=None 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} - ${result} = Run Tests ${options} default options= output= + [Arguments] ${options} ${error} ${regexp}=False + ${result} = Run Tests ${options} default options= output=None 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/debugfile.robot b/atest/robot/cli/runner/debugfile.robot index 8e0f76d5fd4..7f5018948df 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 @@ -13,31 +13,35 @@ Debugfile Debug file should contain ${content} + START SUITE: Normal Debug file should contain ${content} +- START TEST: First One Debug file should contain ${content} - ... ${TIMESTAMP} - INFO - +-- START KW: BuiltIn.Log ? Test 1 ? + ... ${TIMESTAMP} - INFO - +-- START KEYWORD: BuiltIn.Log ? Test 1 ? ... ${TIMESTAMP} - INFO - Test 1 - ... ${TIMESTAMP} - INFO - +-- END KW: BuiltIn.Log + ... ${TIMESTAMP} - INFO - +-- END KEYWORD: BuiltIn.Log Debug file should contain ${content} - ... ${TIMESTAMP} - INFO - +-- START KW: BuiltIn.Log ? Logging with debug level | DEBUG ? + ... ${TIMESTAMP} - INFO - +-- START KEYWORD: BuiltIn.Log ? Logging with debug level | DEBUG ? ... ${TIMESTAMP} - DEBUG - Logging with debug level - ... ${TIMESTAMP} - INFO - +-- END KW: BuiltIn.Log + ... ${TIMESTAMP} - INFO - +-- END KEYWORD: BuiltIn.Log Debug file should contain ${content} + END SUITE: Normal - Check Syslog Contains DebugFile: DeBug.TXT + Syslog Should Contain DebugFile: DeBug.TXT ${path} = Set Variable [:.\\w /\\\\~+-]*DeBug\\.TXT - Check Stdout Matches 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}.* + +Debug file messages are not delayed when timeouts are active + Run Tests -b debug.txt cli/runner/debugfile.robot + Check Test Case ${TEST NAME} Debugfile Log Level Should Always Be Debug [Documentation] --loglevel option should not affect what's written to debugfile Run Tests Without Processing Output --outputdir ${CLI OUTDIR} -b debug.txt -o o.xml --loglevel WARN ${TESTFILE} ${content}= Get File ${CLI OUTDIR}/debug.txt Debug file should contain ${content} - ... ${TIMESTAMP} - INFO - +-- START KW: BuiltIn.Log ? Test 1 ? + ... ${TIMESTAMP} - INFO - +-- START KEYWORD: BuiltIn.Log ? Test 1 ? ... ${TIMESTAMP} - INFO - Test 1 - ... ${TIMESTAMP} - INFO - +-- END KW: BuiltIn.Log + ... ${TIMESTAMP} - INFO - +-- END KEYWORD: BuiltIn.Log Debug file should contain ${content} - ... ${TIMESTAMP} - INFO - +-- START KW: BuiltIn.Log ? Logging with debug level | DEBUG ? + ... ${TIMESTAMP} - INFO - +-- START KEYWORD: BuiltIn.Log ? Logging with debug level | DEBUG ? ... ${TIMESTAMP} - DEBUG - Logging with debug level - ... ${TIMESTAMP} - INFO - +-- END KW: BuiltIn.Log + ... ${TIMESTAMP} - INFO - +-- END KEYWORD: BuiltIn.Log Debugfile timestamps are accurate Run Tests --outputdir ${CLI OUTDIR} -b debug.txt -t LibraryAddsTimestampAsInteger @@ -45,8 +49,8 @@ Debugfile timestamps are accurate ${tc} = Check Test Case LibraryAddsTimestampAsInteger ${content} = Get file ${CLI OUTDIR}/debug.txt Debug file should contain ${content} - ... ${tc.kws[0].msgs[0].timestamp} - INFO - Known timestamp - ... ${tc.kws[0].msgs[1].timestamp} - INFO - Current + ... ${tc[0, 0].timestamp} - INFO - Known timestamp + ... ${tc[0, 1].timestamp} - INFO - Current Writing Non-ASCII To Debugfile [Documentation] Tests also that '.txt' is appended if no extension given @@ -55,18 +59,18 @@ 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} Directory Should Contain ${CLI OUTDIR} o.xml - Check Syslog Contains DebugFile: None + Syslog Should Contain DebugFile: None Invalid Debugfile Create Directory %{TEMPDIR}/debug.txt Run Tests Without Processing Output --outputdir ${CLI OUTDIR} -b %{TEMPDIR}/debug.txt ${TESTFILE} - Check Stderr Matches Regexp \\[ ERROR \\] Opening debug file '.*debug.txt' failed: .* - Check Stdout Contains 2 critical tests, 2 passed, 0 failed\n 2 tests total, 2 passed, 0 failed + Stderr Should Match Regexp \\[ ERROR \\] Opening debug file '.*debug.txt' failed: .* + Stdout Should Contain 2 tests, 2 passed, 0 failed *** Keywords *** Debugfile should contain diff --git a/atest/robot/cli/runner/exit_on_error.robot b/atest/robot/cli/runner/exit_on_error.robot index 4147e909a32..b97367680f1 100644 --- a/atest/robot/cli/runner/exit_on_error.robot +++ b/atest/robot/cli/runner/exit_on_error.robot @@ -39,8 +39,9 @@ With --SkipTeardownOnExit *** Keywords *** Executed normally [Arguments] @{tests} - :FOR ${name} IN @{tests} - \ Check Test Case ${name} + FOR ${name} IN @{tests} + Check Test Case ${name} + END Failed due to error [Arguments] ${test} @@ -48,20 +49,21 @@ Failed due to error Skipped due to error [Arguments] @{tests} - :FOR ${name} IN @{tests} - \ ${tc} = Check Test Case ${name} FAIL ${MESSAGE} - \ Should Contain ${tc.tags} robot:exit + FOR ${name} IN @{tests} + ${tc} = Check Test Case ${name} FAIL ${MESSAGE} + Should Contain ${tc.tags} robot:exit + END Teardowns not executed [Arguments] ${name} ${suite} = Get Test Suite ${name} - Should Be Equal ${suite.teardown} ${None} + Teardown Should Not Be Defined ${suite} ${tc} = Check Test Case ${name} FAIL ${MESSAGE} - Should Be Equal ${tc.teardown} ${None} + Teardown Should Not Be Defined ${tc} 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 7de3042b603..c11932cfe3b 100644 --- a/atest/robot/cli/runner/exit_on_failure.robot +++ b/atest/robot/cli/runner/exit_on_failure.robot @@ -1,71 +1,107 @@ *** Settings *** +Suite Setup Run Tests +... --exitonfailure --skiponfailure skip-on-failure +... cli/runner/exit_on_failure.robot misc/suites running/fatal_exception/02__irrelevant.robot Resource atest_resource.robot *** Variables *** -${EXIT ON FAILURE} Critical failure occurred and exit-on-failure mode is in use. +${EXIT ON FAILURE} Failure occurred and exit-on-failure mode is in use. *** Test Cases *** -Exit On Failure - [Setup] Run Tests --exitonfailure misc/pass_and_fail.robot misc/suites running/fatal_exception/02__irrelevant.robot - Check Test Case Pass - Check Test Case Fail - Test Should Have Been Skipped SubSuite1 First - Test Should Have Been Skipped Suite3 First +Passing test does not initiate exit-on-failure + Check Test Case ${TEST NAME} -Imports Are Skipped On Exit - Previous test should have passed Exit On Failure - Should be empty ${ERRORS.messages} +Skipped test does not initiate exit-on-failure + Check Test Case ${TEST NAME} + +Test skipped in teardown does not initiate exit-on-failure + Check Test Case ${TEST NAME} + +Skip-on-failure test does not initiate exit-on-failure + Check Test Case ${TEST NAME} + +Test skipped-on-failure in teardown does not initiate exit-on-failure + Check Test Case ${TEST NAME} + +Failing test initiates exit-on-failure + Check Test Case ${TEST NAME} + Test Should Not Have Been Run Not executed + +Tests in subsequent suites are skipped + Test Should Not Have Been Run SubSuite1 First + Test Should Not Have Been Run Suite3 First + +Imports in subsequent suites are skipped + Should Be Equal ${SUITE.suites[-1].name} Irrelevant + Should Be Empty ${ERRORS.messages} 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 - Should Be Equal ${tsuite.teardown} ${None} + Teardown Should Not Be Defined ${tsuite} Exit On Failure With Skip Teardown On Exit [Setup] Run Tests --ExitOnFailure --SkipTeardownOnExit misc/suites ${tcase} = Check Test Case Suite4 First - Should Be Equal ${tcase.teardown} ${None} + Teardown Should Not Be Defined ${tcase} ${tsuite} = Get Test Suite Fourth - Should Be Equal ${tsuite.teardown} ${None} - Test Should Have Been Skipped SubSuite1 First - Test Should Have Been Skipped Suite3 First + Teardown Should Not Be Defined ${tsuite} + Test Should Not Have Been Run SubSuite1 First + Test Should Not Have Been Run Suite3 First Test setup fails [Setup] Run Tests -X misc/setups_and_teardowns.robot Check Test Case Test with setup and teardown Check Test Case Test with failing setup - Test Should Have Been Skipped Test with failing teardown - Test Should Have Been Skipped Failing test with failing teardown + Test Should Not Have Been Run Test with failing teardown + Test Should Not Have Been Run Failing test with failing teardown Test teardown fails - [Setup] Run Tests --ExitOnFail --variable TEST_TEARDOWN:NonExistingKeyword misc/setups_and_teardowns.robot + [Setup] Run Tests + ... --ExitOnFail --variable TEST_TEARDOWN:NonExistingKeyword + ... misc/setups_and_teardowns.robot Check Test Case Test with setup and teardown FAIL Teardown failed:\nNo keyword with name 'NonExistingKeyword' found. - Test Should Have Been Skipped Test with failing setup - Test Should Have Been Skipped Test with failing teardown - Test Should Have Been Skipped Failing test with failing teardown + Test Should Not Have Been Run Test with failing setup + Test Should Not Have Been Run Test with failing teardown + Test Should Not Have Been Run Failing test with failing teardown Suite setup fails - [Setup] Run Tests --ExitOnFail --variable SUITE_SETUP:Fail misc/setups_and_teardowns.robot misc/pass_and_fail.robot - Check Test Case Test with setup and teardown FAIL Parent suite setup failed:\nAssertionError - Test Should Have Been Skipped Test with failing setup - Test Should Have Been Skipped Test with failing teardown - Test Should Have Been Skipped Failing test with failing teardown - Test Should Have Been Skipped Pass - Test Should Have Been Skipped Fail + [Setup] Run Tests + ... --ExitOnFail --variable SUITE_SETUP:Fail + ... misc/setups_and_teardowns.robot misc/pass_and_fail.robot + Parent Setup Should Have Failed Test with setup and teardown + Test Should Not Have Been Run Test with failing setup + Test Should Not Have Been Run Test with failing teardown + Test Should Not Have Been Run Failing test with failing teardown + Test Should Not Have Been Run Pass + Test Should Not Have Been Run Fail Suite teardown fails - [Setup] Run Tests --ExitOnFail --variable SUITE_TEARDOWN:Fail --test TestWithSetupAndTeardown --test Pass --test Fail + [Setup] Run Tests + ... --ExitOnFail --variable SUITE_TEARDOWN:Fail --test TestWithSetupAndTeardown --test Pass --test Fail ... misc/setups_and_teardowns.robot misc/pass_and_fail.robot Check Test Case Test with setup and teardown FAIL Parent suite teardown failed:\nAssertionError - Test Should Have Been Skipped Pass - Test Should Have Been Skipped Fail + Test Should Not Have Been Run Pass + Test Should Not Have Been Run Fail + +Failure set by listener can initiate exit-on-failure + [Setup] Run Tests + ... --ExitOnFailure --Listener ${DATADIR}/cli/runner/failtests.py + ... misc/pass_and_fail.robot + Check Test Case Pass status=FAIL + Test Should Not Have Been Run Fail *** Keywords *** -Test Should Have Been Skipped +Parent Setup Should Have Failed + [Arguments] ${name} + ${tc} = Check Test Case ${name} FAIL Parent suite setup failed:\nAssertionError + Should Not Contain ${tc.tags} robot:exit + +Test Should Not Have Been Run [Arguments] ${name} ${tc} = Check Test Case ${name} FAIL ${EXIT ON FAILURE} Should Contain ${tc.tags} robot:exit diff --git a/atest/robot/cli/runner/exit_on_failure_with_skip_on_failure.robot b/atest/robot/cli/runner/exit_on_failure_with_skip_on_failure.robot new file mode 100644 index 00000000000..44ccf986f97 --- /dev/null +++ b/atest/robot/cli/runner/exit_on_failure_with_skip_on_failure.robot @@ -0,0 +1,49 @@ +*** Settings *** +Resource atest_resource.robot + +*** Test Cases *** +Exit-on-failure is not initiated if test fails and skip-on-failure is active + Run Tests --exit-on-failure --skip-on-failure skip-on-failure --include skip-on-failure running/skip/skip.robot + Should Contain Tests ${SUITE} + ... Skipped with --SkipOnFailure + ... Skipped with --SkipOnFailure when failure in setup + ... Skipped with --SkipOnFailure when failure in teardown + +Exit-on-failure is not initiated if suite setup fails and skip-on-failure is active with all tests + Run Tests --exit-on-failure --skip-on-failure tag1 --variable SUITE_SETUP:Fail + ... misc/setups_and_teardowns.robot misc/pass_and_fail.robot misc/pass_and_fail.robot + VAR ${message} + ... Failed test skipped using 'tag1' tag. + ... + ... Original failure: + ... Parent suite setup failed: + ... AssertionError + ... separator=\n + Should Contain Tests ${SUITE.suites[0]} + ... Test with setup and teardown=SKIP:${message} + ... Test with failing setup=SKIP:${message} + ... Test with failing teardown=SKIP:${message} + ... Failing test with failing teardown=SKIP:${message} + Should Contain Tests ${SUITE.suites[1]} + ... Pass + ... Fail + Should Contain Tests ${SUITE.suites[2]} + ... Pass=FAIL:Failure occurred and exit-on-failure mode is in use. + ... Fail=FAIL:Failure occurred and exit-on-failure mode is in use. + +Exit-on-failure is initiated if suite setup fails and skip-on-failure is not active with all tests + Run Tests --exit-on-failure --skip-on-failure tag2 --variable SUITE_SETUP:Fail + ... misc/setups_and_teardowns.robot misc/pass_and_fail.robot + VAR ${prefix} + ... Failed test skipped using 'tag2' tag. + ... + ... Original failure: + ... separator=\n + Should Contain Tests ${SUITE.suites[0]} + ... Test with setup and teardown=SKIP:${prefix}\nParent suite setup failed:\nAssertionError + ... Test with failing setup=FAIL:Parent suite setup failed:\nAssertionError + ... Test with failing teardown=SKIP:${prefix}\nFailure occurred and exit-on-failure mode is in use. + ... Failing test with failing teardown=SKIP:${prefix}\nFailure occurred and exit-on-failure mode is in use. + Should Contain Tests ${SUITE.suites[1]} + ... Pass=FAIL:Failure occurred and exit-on-failure mode is in use. + ... Fail=FAIL:Failure occurred and exit-on-failure mode is in use. diff --git a/atest/robot/cli/runner/extension.robot b/atest/robot/cli/runner/extension.robot index 3653883328a..23a02db95e0 100644 --- a/atest/robot/cli/runner/extension.robot +++ b/atest/robot/cli/runner/extension.robot @@ -7,16 +7,15 @@ ${DATA FORMATS} ${DATADIR}/parsing/data_formats *** Test Cases *** One extension - --extension robot 21 - --EXTENSION .TXT 25 + --extension robot 31 + --EXTENSION .TXT 23 Multiple extensions - -F robot:txt:.ROBOT 46 + -F robot:txt:.ROBOT 54 -Invalid extension - [Template] NONE - Run Tests Without Processing Output --extension invalid ${DATA FORMATS} - Stderr Should Be Equal To [ ERROR ] Invalid extension to limit parsing 'invalid'.${USAGE TIP}\n +Any extension is accepted + --extension bar 1 + --extension FoO:bar 2 *** Keywords *** Expected number of tests should be run diff --git a/atest/robot/cli/runner/help_and_version.robot b/atest/robot/cli/runner/help_and_version.robot index c04e1e366b6..32ace5b2edd 100644 --- a/atest/robot/cli/runner/help_and_version.robot +++ b/atest/robot/cli/runner/help_and_version.robot @@ -2,19 +2,35 @@ Resource cli_resource.robot *** Test Cases *** -Help - [Tags] no-standalone - ${result} = Run Tests --help output=NONE - Should Be Equal ${result.rc} ${251} +---help + ${result} = Run Tests --help output=NONE + Validate --help ${result} + +--help --no-status-rc + ${result} = Run Tests --help --no-status-rc output=NONE + Validate --help ${result} rc=0 + +--version + ${result} = Run Tests --version output=NONE + Validate --version ${result} + +--version --no-status-rc + ${result} = Run Tests --VERSION --NoStatusRC output=NONE + Validate --version ${result} rc=0 + +*** Keywords *** +Validate --help + [Arguments] ${result} ${rc}=251 + Should Be Equal ${result.rc} ${rc} type=int Should Be Empty ${result.stderr} - ${help} = Set Variable ${result.stdout} - Log ${help} + VAR ${help} ${result.stdout} Should Start With ${help} Robot Framework -- A generic automation framework\n\nVersion: \ - ${end} = Catenate SEPARATOR=\n + VAR ${end} ... \# Setting default options and syslog file before running tests. - ... $ export ROBOT_OPTIONS="--critical regression --suitestatlevel 2" + ... $ export ROBOT_OPTIONS="--outputdir results --suitestatlevel 2" ... $ export ROBOT_SYSLOG_FILE=/tmp/syslog.txt ... $ robot tests.robot + ... separator=\n Should End With ${help} \n\n${end}\n Should Not Contain ${help} \t Should Not Contain ${help} [ ERROR ] @@ -26,10 +42,10 @@ Help Log Many @{tail} Should Be Empty ${tail} Help lines with trailing spaces -Version - ${result} = Run Tests --version output=NONE - Should Be Equal ${result.rc} ${251} +Validate --version + [Arguments] ${result} ${rc}=251 + Should Be Equal ${result.rc} ${rc} type=int Should Be Empty ${result.stderr} Should Match Regexp ${result.stdout} - ... ^Robot Framework 3\\.\\d(\\.\\d)?((a|b|rc)\\d)?(\\.dev\\d)? \\((Python|Jython|IronPython|PyPy) [23]\\.[\\d.]+.* on .+\\)$ + ... ^Robot Framework [78]\\.\\d(\\.\\d)?((a|b|rc)\\d)?(\\.dev\\d)? \\((Python|PyPy) 3\\.[\\d.]+.* on .+\\)$ Should Be True len($result.stdout) < 80 Too long version line diff --git a/atest/robot/cli/runner/included_files.robot b/atest/robot/cli/runner/included_files.robot new file mode 100644 index 00000000000..90518b09335 --- /dev/null +++ b/atest/robot/cli/runner/included_files.robot @@ -0,0 +1,37 @@ +*** Settings *** +Test Template Expected number of tests should be run +Resource atest_resource.robot + +*** Test Cases *** +File name + --parseinclude sample.robot 18 + +File path + -I ${DATADIR}/parsing/data_formats${/}robot${/}SAMPLE.robot 18 + +Pattern with name + --ParseInclude *.robot --parse-include sample.rb? -I no.match 49 + +Pattern with path + --parse-include ${DATADIR}/parsing/data_formats/*/[st]???le.ROBOT 18 + +Single '*' is not recursive + --parse-include ${DATADIR}/*/sample.robot 0 + +Recursive glob requires '**' + --parse-include ${DATADIR}/**/sample.robot 18 + +Directories are recursive + --parse-include ${DATADIR}/parsing/data_formats/robot 20 + --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 1 + +*** Keywords *** +Expected number of tests should be run + [Arguments] ${options} ${expected} + Run Tests ${options} --run-empty-suite ${DATADIR}/parsing/data_formats + Should Be Equal As Integers ${SUITE.test_count} ${expected} diff --git a/atest/robot/cli/runner/invalid_usage.robot b/atest/robot/cli/runner/invalid_usage.robot index af39b5a4f61..739b6ea9be9 100644 --- a/atest/robot/cli/runner/invalid_usage.robot +++ b/atest/robot/cli/runner/invalid_usage.robot @@ -3,42 +3,51 @@ Test Setup Create Output Directory Resource cli_resource.robot Test Template Run Should Fail +*** Variables *** +${VALID} ${DATA DIR}/${TEST FILE} + *** Test Cases *** No Input - [Tags] no-standalone - ${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.html Parsing 'nonexisting\\.html' 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ä.txt Parsing 'eitäällä\\.txt' 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 ${VALID} + ... Creating output file directory '.*not-dir.dir' failed: .* regexp=True + -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 - --name valid -Q tests.txt option -Q not recognized + --name valid -Q tests.robot option -Q not recognized Invalid --SuiteStatLevel - --suitestatlevel not_int tests.txt - ... Option '--suitestatlevel' expected integer value but got 'not_int'. + --suitestatlevel not_int tests.robot + ... Invalid value for option '--suitestatlevel': Expected integer, got 'not_int'. Invalid --TagStatLink - --tagstatlink a:b:c --TagStatLi less_than_3x_: tests.txt - ... Invalid format for option '--tagstatlink'. Expected 'tag:link:title' but got 'less_than_3x_:'. + --tagstatlink a:b:c --TagStatLi less_than_3x_: tests.robot + ... Invalid value for option '--tagstatlink': Expected format 'tag:link:title', got 'less_than_3x_:'. Invalid --RemoveKeywords - --removekeywords wuks --removek name:xxx --RemoveKeywords Invalid tests.txt - ... Invalid value for option '--removekeywords'. Expected 'ALL', 'PASSED', 'NAME:', 'TAG:', 'FOR', or 'WUKS' but got 'Invalid'. - -Deprecated --WarnOnSkipped - [Template] NONE - Run Tests --warnonskippedfiles misc/pass_and_fail.robot - Check Test Case Pass - Check Test Case Fail - ${msg} = Catenate - ... Option '--warnonskippedfiles' is deprecated and has no effect. - ... Nowadays all skipped files are reported. - Check Log Message ${ERRORS[0]} ${msg} WARN + --removekeywords wuks --removek name:xxx --RemoveKeywords Invalid tests.robot + ... Invalid value for option '--removekeywords': Expected 'ALL', 'PASSED', 'NAME:', 'TAG:', 'FOR' or 'WUKS', got 'Invalid'. + +Invalid --loglevel + --loglevel bad tests.robot + ... Invalid value for option '--loglevel': Invalid log level 'BAD'. + --loglevel INFO:INV tests.robot + ... Invalid value for option '--loglevel': Invalid log level 'INV'. + -L INFO:DEBUG tests.robot + ... Invalid value for option '--loglevel': Level in log 'DEBUG' is lower than execution level 'INFO'. diff --git a/atest/robot/cli/runner/log_level.robot b/atest/robot/cli/runner/log_level.robot index 12443c61fcf..76531894932 100644 --- a/atest/robot/cli/runner/log_level.robot +++ b/atest/robot/cli/runner/log_level.robot @@ -1,71 +1,73 @@ *** Settings *** -Documentation Tests for setting log level from command line with --loglevel option. Setting log level while executing tests (BuiltIn.Set Log Level) is tested with BuiltIn library keywords. -Resource atest_resource.robot +Documentation Tests for setting log level from command line with --loglevel option. +... Setting log level while executing tests (BuiltIn.Set Log Level) is +... tested with BuiltIn library keywords. +Resource atest_resource.robot *** Variables *** -${TESTDATA} misc/pass_and_fail.robot -${LOG NAME} logfile.html +${TESTDATA} misc/pass_and_fail.robot +${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][0, 0, 0]} Hello says "Pass"! INFO + Should Be Empty ${SUITE.tests[0][0, 1].messages} + Check Log Message ${SUITE.tests[1][1, 0]} Expected failure FAIL Trace Level - Run Tests --loglevel TRACE ${TESTDATA} + Run Tests --loglevel TRACE ${TESTDATA} Should Log On Trace Level Debug Level - Run Tests --loglevel debug --log ${LOG NAME} ${TESTDATA} + Run Tests --loglevel debug --log ${LOG NAME} ${TESTDATA} Should Log On Debug Level Min level should be 'DEBUG' and default 'DEBUG' Debug Level With Default Info - Run Tests --loglevel dEBug:iNfo --log ${LOG NAME} ${TESTDATA} + Run Tests --loglevel dEBug:iNfo --log ${LOG NAME} ${TESTDATA} Should Log On Debug Level Min level should be 'DEBUG' and default 'INFO' Trace Level With Default Debug - Run Tests --loglevel trace:Debug --log ${LOG NAME} ${TESTDATA} + Run Tests --loglevel trace:Debug --log ${LOG NAME} ${TESTDATA} Should Log On Trace Level 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][0, 0, 0]} Hello says "Pass"! INFO + Should Be Empty ${SUITE.tests[0][0, 1].messages} + Check Log Message ${SUITE.tests[1][1, 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][0, 0, 0]} Hello says "Pass"! WARN + Should Be Empty ${SUITE.tests[0][0, 1].messages} + Check Log Message ${SUITE.tests[1][1, 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 - Check Syslog Contains | WARN \ | Hello says "Suite Setup"! - Check Syslog Contains | WARN \ | Hello says "Pass"! - Check Syslog Contains | WARN \ | Hello says "Fail"! + Should Be Equal ${PREV TEST NAME} Warn Level + Check Log Message ${ERRORS[0]} Hello says "Suite Setup"! WARN + Check Log Message ${ERRORS[1]} Hello says "Pass"! WARN + Check Log Message ${ERRORS[2]} Hello says "Fail"! WARN + Length Should Be ${ERRORS.messages} 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][0, 0, 0]} Hello says "Pass"! ERROR + Should Be Empty ${SUITE.tests[0][0, 1].messages} + Check Log Message ${SUITE.tests[1][1, 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][0, 0].message} + Should Be Empty ${SUITE.tests[0][0, 1].messages} + Should Be Empty ${SUITE.tests[1][1].messages} Min level should be 'NONE' and default 'NONE' *** Keywords *** @@ -75,14 +77,14 @@ Min level should be '${min}' and default '${default}' Should contain ${log} "defaultLevel":"${default}" Should Log On Debug Level - Check Log Message ${SUITE.tests[0].kws[0].kws[0].msgs[0]} Hello says "Pass"! INFO - Check Log Message ${SUITE.tests[0].kws[0].kws[1].msgs[0]} Debug message DEBUG - Check Log Message ${SUITE.tests[1].kws[1].msgs[0]} Expected failure FAIL + Check Log Message ${SUITE.tests[0][0, 0, 0]} Hello says "Pass"! INFO + Check Log Message ${SUITE.tests[0][0, 1, 0]} Debug message DEBUG + Check Log Message ${SUITE.tests[1][1, 0]} Expected failure FAIL Should Log On Trace Level - Check Log Message ${SUITE.tests[0].kws[0].kws[0].msgs[0]} Arguments: [ 'Hello says "Pass"!' | 'INFO' ] TRACE - Check Log Message ${SUITE.tests[0].kws[0].kws[0].msgs[1]} Hello says "Pass"! INFO - Check Log Message ${SUITE.tests[0].kws[0].kws[0].msgs[2]} Return: None TRACE - Check Log Message ${SUITE.tests[0].kws[0].kws[1].msgs[1]} Debug message DEBUG - Check Log Message ${SUITE.tests[1].kws[1].msgs[0]} Arguments: [ 'Expected failure' ] TRACE - Check Log Message ${SUITE.tests[1].kws[1].msgs[1]} Expected failure FAIL + Check Log Message ${SUITE.tests[0][0, 1, 0]} Arguments: [ 'Hello says "Pass"!' | 'INFO' ] TRACE + Check Log Message ${SUITE.tests[0][0, 1, 1]} Hello says "Pass"! INFO + Check Log Message ${SUITE.tests[0][0, 1, 2]} Return: None TRACE + Check Log Message ${SUITE.tests[0][0, 2, 1]} Debug message DEBUG + Check Log Message ${SUITE.tests[1][1, 0]} Arguments: [ 'Expected failure' ] TRACE + Check Log Message ${SUITE.tests[1][1, 1]} Expected failure FAIL diff --git a/atest/robot/cli/runner/multisource.robot b/atest/robot/cli/runner/multisource.robot index 781cf674bb7..6781c3b9a3e 100644 --- a/atest/robot/cli/runner/multisource.robot +++ b/atest/robot/cli/runner/multisource.robot @@ -45,13 +45,31 @@ 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 - Check Stderr Contains [ ERROR ] Parsing '${nönex}' failed: File or directory to execute does not exist. + Stderr Should Contain [ ERROR ] Parsing '${nönex}' failed: File or directory to execute does not exist. File Should Not Exist ${OUTDIR}${/}output.xml 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 - Check Stderr Contains [ 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/atest/robot/cli/runner/output_files.robot b/atest/robot/cli/runner/output_files.robot index 6b1ad374a72..1718bd5ba9a 100644 --- a/atest/robot/cli/runner/output_files.robot +++ b/atest/robot/cli/runner/output_files.robot @@ -15,13 +15,24 @@ Output And Log Run Tests Without Processing Output --outputdir ${CLI OUTDIR} --output myoutput.xml --report none --log mylog.html ${TESTFILE} Output Directory Should Contain mylog.html myoutput.xml -Disabling output XML only disables log with a warning +Disabling only output file disables log with a warning Run Tests Without Processing Output --outputdir ${CLI OUTDIR} -o nOnE -r report.html -l mylog.html ${TESTFILE} + Stdout Should Contain Output: \ NONE\nReport: + Stderr Should Match Regexp \\[ ERROR \\] Log file cannot be created if output.xml is disabled. Output Directory Should Contain report.html - Check Stderr Matches Regexp \\[ ERROR \\] Log file is not created if output.xml is disabled. All output files disabled - Run Tests Without Processing Output --outputdir ${CLI OUTDIR} -o nOnE -r NONE -l none ${TESTFILE} + [Documentation] Turning colors on turns also hyperlinks on console and `NONE` cannot be linked. + Run Tests Without Processing Output --outputdir ${CLI OUTDIR} -o nOnE -r NONE -l none --console-colors ON ${TESTFILE} + Stdout Should Contain Output: \ NONE\n + Stderr Should Be Empty + Output Directory Should Be Empty + +All output files disabled with dotted console + [Documentation] Turning colors on turns also hyperlinks on console and `NONE` cannot be linked. + Run Tests Without Processing Output --dotted --outputdir ${CLI OUTDIR} -o nOnE -r NONE -l none --console-colors ON ${TESTFILE} + Stdout Should Contain Output: \ NONE\n + Stderr Should Be Empty Output Directory Should Be Empty Debug, Xunit And Report File Can Be Created When Output Is NONE @@ -49,27 +60,33 @@ Outputs Into Different Dirs Split Log Run Tests Without Processing Output --outputdir ${CLI OUTDIR} --output o.xml --report r.html --log l.html --splitlog ${TESTFILE} Directory Should Contain ${CLI OUTDIR} l-1.js l-2.js l.html o.xml r.html + FOR ${name} IN l-1.js l-2.js + File Should Contain ${CLI OUTDIR}/${name} window.fileLoading.notify("${name}"); + END Non-writable Output File Create Directory ${CLI OUTDIR}/diréctöry.xml Run Tests Without Processing Output --output ${CLI OUTDIR}/diréctöry.xml ${TESTFILE} - Check Stderr Matches Regexp \\[ ERROR \\] Opening output file '.*diréctöry.xml' failed: .*${USAGE_TIP} + Stderr Should Match Regexp + ... \\[ ERROR \\] Opening output file '.*diréctöry.xml' failed: [^ ]+Error: .*${USAGE_TIP} Non-writable Log and Report ${directory} = Normalize Path ${CLI OUTDIR}/diréctöry.html Create Directory ${directory} Run Tests --log ${directory} --report ${directory} ${TESTFILE} Should Be Equal ${SUITE.status} PASS - Check Stderr Matches Regexp SEPARATOR=\n \\[ ERROR \\] Writing log file '.*diréctöry.html' failed: .* \\[ ERROR \\] Writing report file '.*diréctöry.html' failed: .* - Check Stdout Contains Output: - Check Stdout Does Not Contain Log: - Check Stdout Does Not Contain Report: + Stderr Should Match Regexp SEPARATOR=\n + ... \\[ ERROR \\] Opening log file '.*diréctöry.html' failed: [^ ]+Error: .* + ... \\[ ERROR \\] Opening report file '.*diréctöry.html' failed: [^ ]+Error: .* + Stdout Should Contain Output: + Stdout Should Not Contain Log: + Stdout Should Not Contain Report: Non-writable Split Log Create Directory ${CLI OUTDIR}/dir-1.js Run Tests --splitlog --log ${CLI OUTDIR}/dir.html --report r.html ${TESTFILE} Should Be Equal ${SUITE.status} PASS - Check Stderr Matches Regexp \\[ ERROR \\] Writing log file '.*dir.html' failed: .* - Check Stdout Contains Output: - Check Stdout Does Not Contain Log: - Check Stdout Contains Report: + Stderr Should Match Regexp \\[ ERROR \\] Opening log file '.*dir-1.js' failed: [^ ]+Error: .* + Stdout Should Contain Output: + Stdout Should Not Contain Log: + Stdout Should Contain Report: diff --git a/atest/robot/cli/runner/randomize.robot b/atest/robot/cli/runner/randomize.robot index c4640c7da88..6b9c080fc71 100644 --- a/atest/robot/cli/runner/randomize.robot +++ b/atest/robot/cli/runner/randomize.robot @@ -3,8 +3,11 @@ Suite Setup Check That Default Orders Are Correct Resource cli_resource.robot *** Variables *** -${DEFAULT SUITE ORDER} [Suite First, Sub.Suite.1, Suite3, Suite4, Suite5, Suite10, Suite 6, SUite7, suiTe 8, Suite 9 Name] -${DEFAULT TEST ORDER} [test1, test2, test3, test4, test5, test6, test7, test8, test9, test10, test11, test12] +@{DEFAULT SUITE ORDER} Suite First Sub.Suite.1 Suite3 Suite4 Suite5 +... Suite10 Suite 6 SUite7 suiTe 8 Suite 9 Name +@{DEFAULT TEST ORDER} test1 test2 test3 test4 test5 +... test6 test7 test8 test9 test10 +... test11 test12 *** Test Cases *** Randomizing tests @@ -61,40 +64,41 @@ Randomizing suites and tests with seed Order should be same ${tests1} ${tests2} Last option overrides all previous - [Setup] Run Tests --randomize suites --randomize tests --randomize none misc/multiple_suites - Should Be Equal As Strings ${SUITE.suites} ${DEFAULT SUITE ORDER} - ${tests} = Get Tests - Should Be Equal As Strings ${tests} ${DEFAULT TEST ORDER} + [Setup] Run Tests --randomize suites --randomize tests --randomize none misc/multiple_suites + Suites should be in default order + Tests should be in default order Invalid option value - Run Should Fail --randomize INVALID ${TESTFILE} Option '--randomize' does not support value 'INVALID'. + Run Should Fail --randomize INVALID ${TESTFILE} + ... Invalid value for option '--randomize': Expected 'TESTS', 'SUITES', 'ALL' or 'NONE', got 'INVALID'. Invalid seed value - Run Should Fail --randomize all:test ${TESTFILE} Option '--randomize' does not support value 'all:test'. + Run Should Fail --randomize all:bad ${TESTFILE} + ... Invalid value for option '--randomize': Seed should be integer, got 'BAD'. *** Keywords *** Check That Default Orders Are Correct - Run Tests ${EMPTY} misc/multiple_suites + Run Tests ${EMPTY} misc/multiple_suites Suites should be in default order Tests should be in default order Suites Should Be Randomized - Should Not Be Equal As Strings ${SUITE.suites} ${DEFAULT SUITE ORDER} - [Return] ${SUITE.suites} + Should Not Be Equal ${{[suite.name for suite in $SUITE.suites]}} ${DEFAULT SUITE ORDER} + RETURN ${SUITE.suites} Suites should be in default order - Should Be Equal As Strings ${SUITE.suites} ${DEFAULT SUITE ORDER} - [Return] ${SUITE.suites} + Should Be Equal ${{[suite.name for suite in $SUITE.suites]}} ${DEFAULT SUITE ORDER} + RETURN ${SUITE.suites} Tests Should Be Randomized ${tests} = Get Tests - Should Not Be Equal As Strings ${tests} ${DEFAULT TEST ORDER} - [Return] ${tests} + Should Not Be Equal ${{[test.name for test in $tests]}} ${DEFAULT TEST ORDER} + RETURN ${tests} Tests should be in default order ${tests} = Get Tests - Should Be Equal As Strings ${tests} ${DEFAULT TEST ORDER} - [Return] ${tests} + Should Be Equal ${{[test.name for test in $tests]}} ${DEFAULT TEST ORDER} + RETURN ${tests} Order should be same [Arguments] ${first} ${second} @@ -103,10 +107,11 @@ Order should be same Get Tests # This keyword is needed because 'Sub.Suite.1' is directory and thus doesn't itself have tests ${tests} = Set Variable If '${SUITE.suites[0].name}' == 'Sub.Suite.1' ${SUITE.suites[0].suites[0].tests} ${SUITE.suites[0].tests} - [Return] ${tests} + RETURN ${tests} Randomized metadata is added [Arguments] ${what} ${seed}=* Should Match ${SUITE.metadata['Randomized']} ${what} (seed ${seed}) - Run Keyword If ${SUITE.suites} - ... Should Be Equal ${SUITE.suites[0].metadata.get('Randomized')} ${NONE} + FOR ${child} IN @{SUITE.suites} + Should Not Contain ${child.metadata} Randomized + END diff --git a/atest/robot/cli/runner/remove_keywords.robot b/atest/robot/cli/runner/remove_keywords.robot index 10e55552aea..05d1dca3f6a 100644 --- a/atest/robot/cli/runner/remove_keywords.robot +++ b/atest/robot/cli/runner/remove_keywords.robot @@ -3,69 +3,80 @@ Suite Setup Run Tests And Remove Keywords Resource atest_resource.robot *** Variables *** -${PASS MESSAGE} -PASSED -ALL -${FAIL MESSAGE} -ALL +PASSED -${REMOVED FOR MESSAGE} -FOR -ALL -${KEPT FOR MESSAGE} +FOR -ALL -${REMOVED WUKS MESSAGE} -WUKS -ALL -${KEPT WUKS MESSAGE} +WUKS -ALL -${REMOVED BY NAME MESSAGE} -BYNAME -ALL -${KEPT BY NAME MESSAGE} +BYNAME -ALL +${PASS MESSAGE} -PASSED -ALL +${FAIL MESSAGE} -ALL +PASSED +${REMOVED FOR MESSAGE} -FOR -ALL +${KEPT FOR MESSAGE} +FOR -ALL +${REMOVED WHILE MESSAGE} -WHILE -ALL +${KEPT WHILE MESSAGE} +WHILE -ALL +${REMOVED WUKS MESSAGE} -WUKS -ALL +${KEPT WUKS MESSAGE} +WUKS -ALL +${REMOVED BY NAME MESSAGE} -BYNAME -ALL +${KEPT BY NAME MESSAGE} +BYNAME -ALL ${REMOVED BY PATTERN MESSAGE} -BYPATTERN -ALL -${KEPT BY PATTERN MESSAGE} +BYPATTERN -ALL +${KEPT BY PATTERN MESSAGE} +BYPATTERN -ALL *** Test Cases *** PASSED option when test passes Log should not contain ${PASS MESSAGE} Output should contain pass message + Messages from body are removed Passing PASSED option when test fails - Log should contain ${FAIL MESSAGE} + Log should contain ${FAIL MESSAGE} Output should contain fail message + Messages from body are not removed Failing FOR option Log should not contain ${REMOVED FOR MESSAGE} - Log should contain ${KEPT FOR MESSAGE} + Log should contain ${KEPT FOR MESSAGE} Output should contain for messages +WHILE option + Log should not contain ${REMOVED WHILE MESSAGE} + Log should contain ${KEPT WHILE MESSAGE} + Output should contain while messages + WUKS option Log should not contain ${REMOVED WUKS MESSAGE} - Log should contain ${KEPT WUKS MESSAGE} + Log should contain ${KEPT WUKS MESSAGE} Output should contain WUKS messages NAME option Log should not contain ${REMOVED BY NAME MESSAGE} - Log should contain ${KEPT BY NAME MESSAGE} + Log should contain ${KEPT BY NAME MESSAGE} Output should contain NAME messages NAME option with pattern Log should not contain ${REMOVED BY PATTERN MESSAGE} - Log should contain ${KEPT BY PATTERN MESSAGE} + Log should contain ${KEPT BY PATTERN MESSAGE} Output should contain NAME messages with patterns TAGged keywords - Log should contain This is not removed by TAG + Log should contain This is not removed by TAG Log should not contain This is removed by TAG Warnings and errors are preserved + Log should contain Keywords with warnings are not removed + Log should contain Keywords with errors are not removed Output should contain warning and error - Log should contain Keywords with warnings are not removed - Log should contain Keywords with errors are not removed *** Keywords *** Run tests and remove keywords - ${opts} = Catenate + VAR ${opts} ... --removekeywords passed ... --RemoveKeywords FoR + ... --RemoveKeywords whiLE ... --removek WUKS ... --removekeywords name:RemoveByName ... --removekeywords name:Thisshouldbe* ... --removekeywords name:Remove??? ... --removekeywords tag:removeANDkitty + ... --listener AddMessagesToTestBody ... --log log.html Run tests ${opts} cli/remove_keywords/all_combinations.robot - ${LOG} = Get file ${OUTDIR}/log.html - Set suite variable $LOG + ${log} = Get file ${OUTDIR}/log.html + VAR ${LOG} ${log} scope=SUITE Log should not contain [Arguments] ${msg} @@ -75,26 +86,47 @@ Log should contain [Arguments] ${msg} Should contain ${LOG} ${msg} +Messages from body are removed + [Arguments] ${name} + Log should not contain Hello '${name}', says listener! + Log should not contain Bye '${name}', says listener! + +Messages from body are not removed + [Arguments] ${name} + Log should contain Hello '${name}', says listener! + Log should contain Bye '${name}', says listener! + Output should contain pass message ${tc} = Check test case Passing - Check Log Message ${tc.kws[0].msgs[0]} ${PASS MESSAGE} + Check Log Message ${tc[1, 0]} ${PASS MESSAGE} Output should contain fail message ${tc} = Check test case Failing - Check Log Message ${tc.kws[0].msgs[0]} ${FAIL MESSAGE} + Check Log Message ${tc[1, 0]} ${FAIL MESSAGE} Output should contain for messages - Test should contain for messages For when test passes - Test should contain for messages For when test fails + Test should contain for messages FOR when test passes + Test should contain for messages FOR when test fails Test should contain for messages [Arguments] ${name} ${tc} = Check test case ${name} - ${for} = Set Variable ${tc.kws[0].kws[0]} - Check log message ${for.kws[0].kws[0].kws[0].msgs[0]} ${REMOVED FOR MESSAGE} one - Check log message ${for.kws[1].kws[0].kws[0].msgs[0]} ${REMOVED FOR MESSAGE} two - Check log message ${for.kws[2].kws[0].kws[0].msgs[0]} ${REMOVED FOR MESSAGE} three - Check log message ${for.kws[3].kws[0].kws[0].msgs[0]} ${KEPT FOR MESSAGE} LAST + Check log message ${tc[1, 0, 0, 0, 1, 0, 0]} ${REMOVED FOR MESSAGE} one + Check log message ${tc[1, 0, 1, 0, 1, 0, 0]} ${REMOVED FOR MESSAGE} two + Check log message ${tc[1, 0, 2, 0, 1, 0, 0]} ${REMOVED FOR MESSAGE} three + Check log message ${tc[1, 0, 3, 0, 0, 0, 0]} ${KEPT FOR MESSAGE} LAST + +Output should contain while messages + Test should contain while messages WHILE when test passes + Test should contain while messages WHILE when test fails + +Test should contain while messages + [Arguments] ${name} + ${tc} = Check test case ${name} + Check log message ${tc[1, 1, 0, 0, 1, 0, 0]} ${REMOVED WHILE MESSAGE} 1 + Check log message ${tc[1, 1, 1, 0, 1, 0, 0]} ${REMOVED WHILE MESSAGE} 2 + Check log message ${tc[1, 1, 2, 0, 1, 0, 0]} ${REMOVED WHILE MESSAGE} 3 + Check log message ${tc[1, 1, 3, 0, 0, 0, 0]} ${KEPT WHILE MESSAGE} 4 Output should contain WUKS messages Test should contain WUKS messages WUKS when test passes @@ -103,9 +135,9 @@ Output should contain WUKS messages Test should contain WUKS messages [Arguments] ${name} ${tc} = Check test case ${name} - Check log message ${tc.kws[0].kws[0].kws[1].kws[0].msgs[0]} ${REMOVED WUKS MESSAGE} FAIL - Check log message ${tc.kws[0].kws[8].kws[1].kws[0].msgs[0]} ${REMOVED WUKS MESSAGE} FAIL - Check log message ${tc.kws[0].kws[9].kws[2].kws[0].msgs[0]} ${KEPT WUKS MESSAGE} FAIL + Check log message ${tc[1, 0, 1, 0, 0]} ${REMOVED WUKS MESSAGE} FAIL + Check log message ${tc[1, 8, 1, 0, 0]} ${REMOVED WUKS MESSAGE} FAIL + Check log message ${tc[1, 9, 2, 0, 0]} ${KEPT WUKS MESSAGE} FAIL Output should contain NAME messages Test should contain NAME messages NAME when test passes @@ -114,10 +146,10 @@ Output should contain NAME messages Test should contain NAME messages [Arguments] ${name} ${tc}= Check test case ${name} - Check log message ${tc.kws[0].kws[0].msgs[0]} ${REMOVED BY NAME MESSAGE} - Check log message ${tc.kws[1].kws[0].msgs[0]} ${REMOVED BY NAME MESSAGE} - Check log message ${tc.kws[2].kws[0].kws[0].msgs[0]} ${REMOVED BY NAME MESSAGE} - Check log message ${tc.kws[2].kws[1].msgs[0]} ${KEPT BY NAME MESSAGE} + Check log message ${tc[1, 0, 0]} ${REMOVED BY NAME MESSAGE} + Check log message ${tc[2, 0, 0]} ${REMOVED BY NAME MESSAGE} + Check log message ${tc[3, 0, 0, 0]} ${REMOVED BY NAME MESSAGE} + Check log message ${tc[3, 1, 0]} ${KEPT BY NAME MESSAGE} Output should contain NAME messages with patterns Test should contain NAME messages with * pattern NAME with * pattern when test passes @@ -128,20 +160,20 @@ Output should contain NAME messages with patterns Test should contain NAME messages with * pattern [Arguments] ${name} ${tc}= Check test case ${name} - Check log message ${tc.kws[0].kws[0].msgs[0]} ${REMOVED BY PATTERN MESSAGE} - Check log message ${tc.kws[1].kws[0].msgs[0]} ${REMOVED BY PATTERN MESSAGE} - Check log message ${tc.kws[2].kws[0].msgs[0]} ${REMOVED BY PATTERN MESSAGE} - Check log message ${tc.kws[3].kws[0].kws[0].msgs[0]} ${REMOVED BY PATTERN MESSAGE} - Check log message ${tc.kws[3].kws[1].msgs[0]} ${KEPT BY PATTERN MESSAGE} + Check log message ${tc[1, 0, 0]} ${REMOVED BY PATTERN MESSAGE} + Check log message ${tc[2, 0, 0]} ${REMOVED BY PATTERN MESSAGE} + Check log message ${tc[3, 0, 0]} ${REMOVED BY PATTERN MESSAGE} + Check log message ${tc[4, 0, 0, 0]} ${REMOVED BY PATTERN MESSAGE} + Check log message ${tc[4, 1, 0]} ${KEPT BY PATTERN MESSAGE} Test should contain NAME messages with ? pattern [Arguments] ${name} ${tc}= Check test case ${name} - Check log message ${tc.kws[0].kws[0].msgs[0]} ${REMOVED BY PATTERN MESSAGE} - Check log message ${tc.kws[1].kws[0].kws[0].msgs[0]} ${REMOVED BY PATTERN MESSAGE} - Check log message ${tc.kws[1].kws[1].msgs[0]} ${KEPT BY PATTERN MESSAGE} + Check log message ${tc[1, 0, 0]} ${REMOVED BY PATTERN MESSAGE} + Check log message ${tc[2, 0, 0, 0]} ${REMOVED BY PATTERN MESSAGE} + Check log message ${tc[2, 1, 0]} ${KEPT BY PATTERN MESSAGE} Output should contain warning and error ${tc} = Check Test Case ${TEST NAME} - Check Log Message ${tc.kws[0].kws[0].kws[0].msgs[0]} Keywords with warnings are not removed WARN - Check Log Message ${tc.kws[1].kws[0].msgs[0]} Keywords with errors are not removed ERROR + Check Log Message ${tc[1, 0, 0, 0]} Keywords with warnings are not removed WARN + Check Log Message ${tc[2, 0, 0]} Keywords with errors are not removed ERROR diff --git a/atest/robot/cli/runner/report_background.robot b/atest/robot/cli/runner/report_background.robot index 0ac6382fa1e..422042a9e91 100644 --- a/atest/robot/cli/runner/report_background.robot +++ b/atest/robot/cli/runner/report_background.robot @@ -8,19 +8,19 @@ Default colors Two custom colors [Template] Report should have correct background - --reportbackground blue:red blue blue red + --reportbackground blue:red blue red Three custom colors [Template] Report should have correct background - --reportback green:yellow:red green yellow red + --reportback green:red:yellow green red yellow Invalid Colors - Run Should Fail --reportback invalid ${SUITE_SOURCE} - ... Invalid report background colors 'invalid'. + Run Should Fail --reportback invalid ${SUITE_SOURCE} + ... Invalid value for option '--reportbackground': Expected format 'pass:fail:skip' or 'pass:fail', got 'invalid'. *** Keywords *** Report should have correct background - [Arguments] ${opt} ${pass}=#9e9 ${noncrit}=#9e9 ${fail}=#f66 + [Arguments] ${opt} ${pass}=#9e9 ${fail}=#f66 ${skip}=#fed84f Run Tests ${opt} --report rep.html misc/pass_and_fail.robot ${report} = Get File ${OUTDIR}/rep.html - Should Contain ${report} "background":{"fail":"${fail}","nonCriticalFail":"${noncrit}","pass":"${pass}"}, + Should Contain ${report} "background":{"fail":"${fail}","pass":"${pass}","skip":"${skip}"}, diff --git a/atest/robot/cli/runner/rerunfailed.robot b/atest/robot/cli/runner/rerunfailed.robot index 9b1b4bed201..68d263d8d34 100644 --- a/atest/robot/cli/runner/rerunfailed.robot +++ b/atest/robot/cli/runner/rerunfailed.robot @@ -14,14 +14,24 @@ Passing is not re-executed Test Should Not Have Been Executed Passing Test Should Not Have Been Executed Not Failing +Skipping test in not re-executed + Test Should Not Have Been Executed Skipped + Failing is re-executed - Test Should Have Been Executed Failing + Test Should Have Been Executed Failing Failing from subsuite is executed - Test Should Have Been Executed Really Failing + Test Should Have Been Executed Really Failing + +Glob meta characters are ignored in names + Test Should Have Been Executed Glob [seq] + Test Should Have Been Executed Glob question? + Test Should Have Been Executed Glob asterisk* + Test Should Not Have Been Executed Glob question! + Test Should Not Have Been Executed Glob asteriskXXX Explicitly selected is executed - Test Should Have Been Executed Selected + Test Should Have Been Executed Selected Excluded failing is not executed Test Should Not Have Been Executed Failing with tag @@ -30,16 +40,16 @@ Non-existing failing from output file is not executed Test Should Not Have Been Executed Only in one suite Suite teardown failures are noticed - Test Should Have Been Executed Test passed but suite teardown fails + Test Should Have Been Executed Test passed but suite teardown fails *** Keywords *** Suite initialization Copy Directory ${ORIG DIR}/suite ${SUITE DIR} - Copy File ${ORIG DIR}/runfailed1.robot ${SUITE DIR}/runfailed.txt + Copy File ${ORIG DIR}/runfailed1.robot ${SUITE DIR}/runfailed.robot Run Tests ${EMPTY} ${SUITE DIR} Copy File ${OUTFILE} ${RUN FAILED FROM} - Copy File ${ORIG DIR}/runfailed2.robot ${SUITE DIR}/runfailed.txt - Run Tests --rerunfailed ${RUN FAILED FROM} --test Selected --exclude excluded_tag ${SUITE DIR} + Copy File ${ORIG DIR}/runfailed2.robot ${SUITE DIR}/runfailed.robot + Run Tests --rerunfailed ${RUN FAILED FROM} --test Selected --include common --exclude excluded_tag ${SUITE DIR} Test Should Have Been Executed [Arguments] ${name} diff --git a/atest/robot/cli/runner/rerunfailed_corners.robot b/atest/robot/cli/runner/rerunfailed_corners.robot index df61098831f..16f524d2a8d 100644 --- a/atest/robot/cli/runner/rerunfailed_corners.robot +++ b/atest/robot/cli/runner/rerunfailed_corners.robot @@ -17,6 +17,13 @@ Stops on error when output contains only passing test cases Stderr Should Be Equal To ... [ ERROR ] Collecting failed tests from '${RUN FAILED FROM}' failed: All tests passed.${USAGE TIP}\n +Runs when there are only passing tests and using --RunEmptySuite + [Setup] File Should Exist ${RUN FAILED FROM} + Run Tests -R ${RUN FAILED FROM} --runemptysuite cli/runfailed/onlypassing + Should Be Equal ${SUITE.status} SKIP + Length Should Be ${SUITE.suites} 0 + Length Should Be ${SUITE.tests} 0 + Stops on error when output contains only passing tasks Generate output cli/runfailed/onlypassing options=--rpa Run Tests Without Processing Output -R ${RUN FAILED FROM} cli/runfailed/onlypassing @@ -41,7 +48,7 @@ Stops on error when output is invalid Stderr Should Be Equal To ... [ ERROR ] Collecting failed tests or tasks from '${RUN FAILED FROM}' failed: ... Reading XML source '${RUN FAILED FROM}' failed: - ... Incompatible XML element 'xml'.${USAGE TIP}\n + ... Incompatible root element 'xml'.${USAGE TIP}\n *** Keywords *** Generate Output diff --git a/atest/robot/cli/runner/rerunfailedsuites.robot b/atest/robot/cli/runner/rerunfailedsuites.robot index b4b40dd3b86..2c59e9f559e 100644 --- a/atest/robot/cli/runner/rerunfailedsuites.robot +++ b/atest/robot/cli/runner/rerunfailedsuites.robot @@ -29,11 +29,11 @@ Non-existing failing from output file is not executed *** Keywords *** Suite initialization Copy Directory ${ORIG DIR}/suite ${SUITE DIR} - Copy File ${ORIG DIR}/runfailed1.robot ${SUITE DIR}/runfailed.txt + Copy File ${ORIG DIR}/runfailed1.robot ${SUITE DIR}/runfailed.robot Run Tests ${SUITE DIR} Copy File ${OUTFILE} ${RUN FAILED FROM} - Copy File ${ORIG DIR}/runfailed2.robot ${SUITE DIR}/runfailed.txt - Run Tests --rerunfailedsuites ${RUN FAILED FROM} --noncritical non_critical --exclude excluded_tag ${SUITE DIR} + Copy File ${ORIG DIR}/runfailed2.robot ${SUITE DIR}/runfailed.robot + Run Tests --rerunfailedsuites ${RUN FAILED FROM} --exclude excluded_tag ${SUITE DIR} Test Should Have Been Executed [Arguments] ${name} diff --git a/atest/robot/cli/runner/rerunfailedsuites_corners.robot b/atest/robot/cli/runner/rerunfailedsuites_corners.robot index ba3862e5e4d..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 @@ -17,6 +17,13 @@ Stops on error when output contains only passing test cases Stderr Should Be Equal To ... [ ERROR ] Collecting failed suites from '${RUN FAILED FROM}' failed: All suites passed.${USAGE TIP}\n +Runs when there are only passing tests and using --RunEmptySuite + [Setup] File Should Exist ${RUN FAILED FROM} + Run Tests -S ${RUN FAILED FROM} --RunEmpty cli/runfailed/onlypassing + Should Be Equal ${SUITE.status} SKIP + Length Should Be ${SUITE.suites} 0 + Length Should Be ${SUITE.tests} 0 + Stops on error when output contains only non-existing failing test cases Generate output cli/runfailed/runfailed1.robot Run Tests Without Processing Output --RERUNFAILEDSUITES ${RUN FAILED FROM} cli/runfailed/onlypassing @@ -30,12 +37,12 @@ Stops on error when output does not exist ... Reading XML source 'nonex.xml' failed:*${USAGE TIP}\n Stops on error when output is invalid - Create File ${RUN FAILED FROM} + Create File ${RUN FAILED FROM} Run Tests Without Processing Output --rerunfailedsuites ${RUN FAILED FROM} cli/runfailed/onlypassing Stderr Should Be Equal To ... [ ERROR ] Collecting failed suites from '${RUN FAILED FROM}' failed: ... Reading XML source '${RUN FAILED FROM}' failed: - ... Incompatible XML element 'xml'.${USAGE TIP}\n + ... Incompatible child element 'but' for 'robot'.${USAGE TIP}\n *** Keywords *** Generate Output 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/atest/robot/cli/runner/status_rc.robot b/atest/robot/cli/runner/status_rc.robot index f6ed994d069..674407b30cb 100644 --- a/atest/robot/cli/runner/status_rc.robot +++ b/atest/robot/cli/runner/status_rc.robot @@ -11,11 +11,7 @@ Zero RC when all tests pass ${EMPTY} ${PASSING} rc=0 --statusrc ${PASSING} rc=0 -Zero RC when all critical tests pass - --critical pass ${FAILING} rc=0 - -c pass --status ${FAILING} rc=0 - -Non-zero RC when critical tests fail +Non-zero RC when tests fail ${EMPTY} ${FAILING} rc=1 -r report.html ${FAILING} rc=1 -l log.html --statusrc ${FAILING} rc=1 @@ -23,7 +19,7 @@ Non-zero RC when critical tests fail Zero RC when all tests pass with --NoStatusRC --NoStatusRC ${PASSING} rc=0 -Zero RC when critical tests fail with --NoStatusRC +Zero RC when tests fail with --NoStatusRC --nostatusrc ${FAILING} rc=0 --StatusRC and --NoStatusRC together 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 3d6607595b4..4a300f7248a 100644 --- a/atest/robot/cli/runner/suite_name_doc_and_metadata.robot +++ b/atest/robot/cli/runner/suite_name_doc_and_metadata.robot @@ -3,10 +3,8 @@ Resource cli_resource.robot *** Test Cases *** Default Name, Doc & Metadata - Run tests ${EMPTY} ${TESTFILE} - Check Names ${SUITE} Normal - Check Names ${SUITE.tests[0]} First One Normal. - Check Names ${SUITE.tests[1]} Second One Normal. + Run Tests ${EMPTY} ${TESTFILE} + Check All Names ${SUITE} Normal Should Be Equal ${SUITE.doc} Normal test cases Should Be Equal ${SUITE.metadata['Something']} My Value @@ -16,18 +14,45 @@ Overriding Name, Doc & Metadata And Escaping ... -N this_is_overridden_next ... --name "my COOL Name.!!." ... --doc "Even \\cooooler\\ doc!?" - ... --metadata something:new + ... --metadata something:new! ... --metadata "Two Parts:three part VALUE" ... -M path:c:\\temp\\new.txt ... -M esc:*?$&#!! Run Tests ${options} ${TESTFILE} - Check Names ${SUITE} my COOL Name.!!. - Check Names ${SUITE.tests[0]} First One my COOL Name.!!.. - Check Names ${SUITE.tests[1]} Second One my COOL Name.!!.. + Check All Names ${SUITE} my COOL Name.!!. Should Be Equal ${SUITE.doc} Even \\cooooler\\ doc!? - Should Be Equal ${SUITE.metadata['Something']} new + Should Be Equal ${SUITE.metadata['Something']} new! Should Be Equal ${SUITE.metadata['Two Parts']} three part VALUE Should Be Equal ${SUITE.metadata['path']} c:\\temp\\new.txt Should Be Equal ${SUITE.metadata['esc']} *?$&#!! File Should Contain ${OUTDIR}/log.html Something File Should Not Contain ${OUTDIR}/log.html something + +Documentation and metadata from external file + ${path} = Normalize Path ${DATADIR}/cli/runner/doc.txt + ${value} = Get File ${path} + Run Tests --doc ${path} --metadata name:${path} ${TEST 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}" -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 + [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 + [Arguments] ${suite} ${name} + Check Names ${suite} ${name} + Check Names ${suite.tests[0]} First One ${name}. + Check Names ${suite.tests[1]} Second One ${name}. diff --git a/atest/robot/cli/runner/syslog.robot b/atest/robot/cli/runner/syslog.robot index fab4ec6a3d0..753ead0120b 100644 --- a/atest/robot/cli/runner/syslog.robot +++ b/atest/robot/cli/runner/syslog.robot @@ -1,11 +1,14 @@ *** Settings *** -Suite Teardown Reset syslog -Test Setup Run Keywords Create Output Directory -... AND Reset syslog -Resource cli_resource.robot +Test Setup Run Keywords +... Create Output Directory AND +... Reset syslog +Suite Teardown Reset syslog +Resource cli_resource.robot *** Variables *** -${SYSLOG} %{TEMPDIR}${/}syslog.txt +${SYSLOG} %{TEMPDIR}${/}syslog.txt +${SYSLOG IN EXECDIR} ${INTERPRETER.output_name}-syslog.txt +${SYSLOG IN NEW DIR} %{TEMPDIR}/new-dir-and/also-unix-separator-always/with/sys.log *** Test Cases *** No syslog environment variable file @@ -19,10 +22,24 @@ Setting syslog sile File Should Not Be Empty ${SYSLOG} File Should Have Correct Line Separators ${SYSLOG} +Syslog as name only + Set Environment Variable ROBOT_SYSLOG_FILE ${SYSLOG IN EXECDIR} + Run Some Tests + File Should Not Be Empty ${EXECDIR}/${SYSLOG IN EXECDIR} + File Should Have Correct Line Separators ${EXECDIR}/${SYSLOG IN EXECDIR} + +Syslog directory is automatically created + Set Environment Variable ROBOT_SYSLOG_FILE ${SYSLOG IN NEW DIR} + Run Some Tests + File Should Not Be Empty ${SYSLOG IN NEW DIR} + File Should Have Correct Line Separators ${SYSLOG IN NEW DIR} + Syslog file set to NONE Set Environment Variable ROBOT_SYSLOG_FILE none Run Some Tests File Should Not Exist ${SYSLOG} + File Should Not Exist ${SYSLOG IN EXECDIR} + File Should Not Exist ${SYSLOG IN NEW DIR} Invalid syslog file Set Environment Variable ROBOT_SYSLOG_FILE ${CLI OUTDIR} @@ -53,4 +70,6 @@ Reset syslog Set Suite Variable ${SET SYSLOG} False Remove Environment Variable ROBOT_SYSLOG_FILE Remove Environment Variable ROBOT_SYSLOG_LEVEL - Remove File ${SYSLOG} + Remove Files ${SYSLOG} ${EXECDIR}/${SYSLOG IN EXECDIR} + ${syslog dir} ${_} = Split Path ${SYSLOG IN NEW DIR} + Remove Directory ${syslog dir} recursive=True diff --git a/atest/robot/cli/runner/timestamp_outputs.robot b/atest/robot/cli/runner/timestamp_outputs.robot index 206f08209a0..794523167e5 100644 --- a/atest/robot/cli/runner/timestamp_outputs.robot +++ b/atest/robot/cli/runner/timestamp_outputs.robot @@ -9,7 +9,8 @@ Timestamped Outputs Length Should Be ${files} 3 ${timestamp} = Should Match Regexp ${files}[0] 20\\d{6}-\\d{6} FOR ${file} IN @{files} - \ Should Match Regexp ${file} ^(log|output|report)-${timestamp}\\.(html|xml)$ + Should Match Regexp ${file} ^(log|output|report)-${timestamp}\\.(html|xml)$ + END Timestamped Outputs With Names And Split Log Run Some Tests -T -l l -r r -o o --split @@ -17,11 +18,13 @@ Timestamped Outputs With Names And Split Log Length Should Be ${files} 5 ${timestamp} = Should Match Regexp ${files}[0] 20\\d{6}-\\d{6} FOR ${file} IN @{files} - \ Should Match Regexp ${file} ^(l|o|r)-${timestamp}(\\.(html|xml)|-(1|2)\\.js)$ + Should Match Regexp ${file} ^(l|o|r)-${timestamp}(\\.(html|xml)|-(1|2)\\.js)$ + END Override with --NoTimeStampOutputs Run Some Tests --TimeStampOutputs -T --timestamp --NoTimeStampOutputs @{files} = List Directory ${CLI OUTDIR} Length Should Be ${files} 3 FOR ${file} IN @{files} - \ Should Match Regexp ${file} ^(log|output|report)\\.(html|xml)$ + Should Match Regexp ${file} ^(log|output|report)\\.(html|xml)$ + END diff --git a/atest/robot/core/binary_data.robot b/atest/robot/core/binary_data.robot index 495b690ced9..f6c70ccc3bb 100644 --- a/atest/robot/core/binary_data.robot +++ b/atest/robot/core/binary_data.robot @@ -1,54 +1,55 @@ *** Settings *** -Documentation Tests for libraries writes binary data into STDOUT and raising exceptions containing it. -Suite Setup My Run Tests -Resource atest_resource.robot -Variables ../../resources/unicode_vars.py +Documentation Tests for libraries writes binary data into STDOUT and raising exceptions containing it. +Suite Setup My Run Tests +Resource atest_resource.robot +Variables ../../resources/unicode_vars.py *** Test Cases *** Print Bytes - [Documentation] Check that bytes 0-255, incl. control chars, can be printed ok. - ... The actual result depends on the interpreter and operating system. - ${tc} = Check Test Case ${TESTNAME} + [Documentation] Check that bytes 0-255, incl. control chars, can be printed ok. + ... The actual result depends on the interpreter and operating system. + ${tc} = Check Test Case ${TESTNAME} # First check some printable bytes and control chars. # Control chars should result to an empty string. - :FOR ${index} ${exp} IN - ... 0 ${EMPTY} - ... 1 ${EMPTY} - ... 7 ${EMPTY} - ... 8 ${EMPTY} - ... 9 \t - ... 10 \n - ... 32 ${SPACE} - ... 39 ' - ... 92 \\ - ... 82 R - ... 111 o - ... 98 b - ... 111 o - ... 116 t - ... 123 { - ... 127 \x7f - \ Check Log Message ${tc.kws[0].msgs[${index}]} Byte ${index}: '${exp}' - # Check that all bytes were really written without errors. - :FOR ${index} IN RANGE 256 - \ Should Start With ${tc.kws[0].msgs[${index}].message} Byte ${index}: - Check Log Message ${tc.kws[0].msgs[-1]} All bytes printed successfully + FOR ${index} ${exp} IN + ... 0 ${EMPTY} + ... 1 ${EMPTY} + ... 7 ${EMPTY} + ... 8 ${EMPTY} + ... 9 \t + ... 10 \n + ... 32 ${SPACE} + ... 39 ' + ... 92 \\ + ... 82 R + ... 111 o + ... 98 b + ... 111 o + ... 116 t + ... 123 { + ... 127 \x7f + Check Log Message ${tc[0, ${index}]} Byte ${index}: '${exp}' + END + # Check that all bytes were really written without errors. + FOR ${index} IN RANGE 256 + Should Start With ${tc[0, ${index}].message} Byte ${index}: + END + Check Log Message ${tc[0, -1]} All bytes printed successfully Byte Error - [Documentation] Check an exception containing control chars is handled ok - Check Test Case ${TESTNAME} + [Documentation] Check an exception containing control chars is handled ok + Check Test Case ${TESTNAME} Byte Error In Setup And Teardown - Check Test Case ${TESTNAME} + Check Test Case ${TESTNAME} Binary Data - [Documentation] Make sure even totally binary data doesn't break anything - ${tc} = Check Test Case ${TESTNAME} - Check Log Message ${tc.kws[0].msgs[1]} Binary data printed successfully + [Documentation] Make sure even totally binary data doesn't break anything + ${tc} = Check Test Case ${TESTNAME} + Check Log Message ${tc[0, 1]} Binary data printed successfully *** Keywords *** My Run Tests - Run Tests ${EMPTY} core/binary_data.robot - ${stderr} = Get Stderr - Should Be Empty ${stderr} - + Run Tests ${EMPTY} core/binary_data.robot + ${stderr} = Get Stderr + Should Be Empty ${stderr} diff --git a/atest/robot/core/empty_tc_and_uk.robot b/atest/robot/core/empty_tc_and_uk.robot index bf016dd07af..bacff42b7db 100644 --- a/atest/robot/core/empty_tc_and_uk.robot +++ b/atest/robot/core/empty_tc_and_uk.robot @@ -1,35 +1,35 @@ *** 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} - -User Keyword Without Name - Check Test Case ${TESTNAME} + 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 42 + ... Creating keyword '' failed: User keyword name cannot be empty. Empty User Keyword - Check Test Case ${TESTNAME} + Check Test Case ${TESTNAME} 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} + 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/robot/core/filter_by_names.robot b/atest/robot/core/filter_by_names.robot index ee535fe3c26..221f274127c 100644 --- a/atest/robot/core/filter_by_names.robot +++ b/atest/robot/core/filter_by_names.robot @@ -20,7 +20,7 @@ ${SUITE DIR} misc/suites --test not matching Run Failing Test ... Suite 'Many Tests' contains no tests matching name 'notexists'. - ... --test notexists ${SUITE FILE} + ... --test notexists --test not matching with multiple inputs Run Failing Test @@ -30,6 +30,18 @@ ${SUITE DIR} misc/suites ... Suite 'My Name' contains no tests matching name 'notexists'. ... --name "My Name" --test notexists ${SUITE FILE} ${SUITE DIR} +--test and --include must both match + Run And Check Tests --test first --include t1 -i f1 First + Run Failing Test + ... Suite 'Many Tests' contains no tests matching name 'fifth' and matching tag 't1'. + ... --test fifth --include t1 + +--exclude wins over --test + Run And Check Tests --test fi* --exclude t1 Fifth + Run Failing Test + ... Suite 'Many Tests' contains no tests matching name 'first' and not matching tag 'f1'. + ... --test first --exclude f1 + --suite once Run Suites --suite tsuite1 Should Contain Suites ${SUITE} TSuite1 @@ -37,7 +49,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 @@ -54,50 +66,13 @@ ${SUITE DIR} misc/suites Parent suite init files are processed Previous Test Should Have Passed --suite with patterns 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} - Check Syslog Contains Parsing directory '${root}'. - Check Syslog Contains Parsing file '${root}${/}tsuite1.robot'. - Check Syslog Contains Ignoring file or directory '${root}${/}tsuite2.robot'. - Check Syslog Contains Parsing file '${root}${/}tsuite3.robot'. - Check Syslog Contains Parsing file '${root}${/}fourth.robot'. - Check Syslog Contains Parsing directory '${root}${/}subsuites'. - Check Syslog Contains Ignoring file or directory '${root}${/}subsuites${/}sub1.robot'. - Check Syslog Contains Ignoring file or directory '${root}${/}subsuites${/}sub2.robot'. - Check Syslog Contains Parsing directory '${root}${/}subsuites2'. - Check Syslog Contains Ignoring file or directory '${root}${/}subsuites2${/}subsuite3.robot'. - Check Syslog Contains Ignoring file or directory '${root}${/}subsuites2${/}sub.suite.4.robot'. - Syslog Should Not Contain Regexp Ignoring file or directory '.*__init__.robot'. + Check log message ${SUITE.teardown[0]} Default suite teardown --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} - Check Syslog Contains Parsing directory '${root}'. - Check Syslog Contains Ignoring file or directory '${root}${/}tsuite1.robot'. - Check Syslog Contains Ignoring file or directory '${root}${/}tsuite2.robot'. - Check Syslog Contains Ignoring file or directory '${root}${/}tsuite3.robot'. - Check Syslog Contains Ignoring file or directory '${root}${/}fourth.robot'. - Check Syslog Contains Parsing directory '${root}${/}subsuites'. - Check Syslog Contains Parsing file '${root}${/}subsuites${/}sub1.robot'. - Check Syslog Contains Parsing file '${root}${/}subsuites${/}sub2.robot'. - Check Syslog Contains Parsing directory '${root}${/}subsuites2'. - Check Syslog Contains Ignoring file or directory '${root}${/}subsuites2${/}subsuite3.robot'. - Check Syslog Contains 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 @@ -110,18 +85,19 @@ 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 ---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 "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 @@ -145,10 +121,10 @@ Unnecessary files are not parsed when --suite matches directory ... --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 @@ -156,17 +132,17 @@ Unnecessary files are not parsed when --suite matches directory ... --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 + 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, --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 +--suite, --test, --include and --exclude + Run Suites --suite sub* --suite "custom name *" --test "subsuite3 second" -t *first -s nomatch -t nomatch --include f1 --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 + 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 @@ -175,10 +151,14 @@ Unnecessary files are not parsed when --suite matches directory 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} @@ -188,6 +168,6 @@ Run Suites Stderr Should Be Empty Run Failing Test - [Arguments] ${error} ${options} ${sources} + [Arguments] ${error} ${options} ${sources}=${SUITE FILE} Run Tests Without Processing Output ${options} ${sources} Stderr Should Be Equal To [ ERROR ] ${error}${USAGE TIP}\n diff --git a/atest/robot/core/keyword_setup.robot b/atest/robot/core/keyword_setup.robot new file mode 100644 index 00000000000..1f8f91963f4 --- /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[0].setup[0]} Hello, setup! + +Failing setup + ${tc} = Check Test Case ${TESTNAME} + Check Log Message ${tc[0].setup[0]} Hello, setup! FAIL + Should Be Equal ${tc[0, 0].status} NOT RUN + +Failing setup and passing teardown + ${tc} = Check Test Case ${TESTNAME} + Check Log Message ${tc.setup.setup[0]} Hello, setup! FAIL + Should Be Equal ${tc.setup[0].status} NOT RUN + Check Log Message ${tc.setup.teardown[0]} Hello, teardown! INFO + +Failing setup and teardown + ${tc} = Check Test Case ${TESTNAME} + Check Log Message ${tc[0].setup[0]} Hello, setup! FAIL + Should Be Equal ${tc[0, 0].status} NOT RUN + Check Log Message ${tc[0].teardown[0]} Hello, teardown! FAIL + +Continue-on-failure mode is not enabled in setup + ${tc} = Check Test Case ${TESTNAME} + Check Log Message ${tc.setup.setup[0, 0]} Hello, setup! INFO + Check Log Message ${tc.setup.setup[1, 0]} Hello again, setup! FAIL + Should Be Equal ${tc.setup.setup[2].status} NOT RUN + +NONE is same as no setup + ${tc} = Check Test Case ${TESTNAME} + Should Be Equal ${tc[0].setup.name} ${None} + +Empty [Setup] is same as no setup + ${tc} = Check Test Case ${TESTNAME} + Should Be Equal ${tc[0].setup.name} ${None} + +Using variable + ${tc} = Check Test Case ${TESTNAME} + Should Be Equal ${tc[0].setup.name} Log + Should Be Equal ${tc[1].setup.name} ${None} + Should Be Equal ${tc[2].setup.name} ${None} + Should Be Equal ${tc[3].setup.name} Fail diff --git a/atest/robot/core/keyword_teardown.robot b/atest/robot/core/keyword_teardown.robot index 6d0a2318145..d15cfe8bb28 100644 --- a/atest/robot/core/keyword_teardown.robot +++ b/atest/robot/core/keyword_teardown.robot @@ -1,69 +1,67 @@ *** Settings *** -Resource atest_resource.robot -Suite Setup Run Tests ${EMPTY} core/keyword_teardown.robot +Resource atest_resource.robot +Suite Setup Run Tests ${EMPTY} core/keyword_teardown.robot *** Test Cases *** Passing Keyword with Teardown - ${tc}= Check Test Case ${TESTNAME} - Check Log Message ${tc.kws[0].kws[0].msgs[0]} In UK - Check Log Message ${tc.kws[0].kws[1].msgs[0]} In UK Teardown + ${tc} = Check Test Case ${TESTNAME} + Check Log Message ${tc[0, 0, 0]} In UK + Check Log Message ${tc[0].teardown[0]} In UK Teardown Failing Keyword with Teardown - ${tc}= Check Test Case ${TESTNAME} - Check Log Message ${tc.kws[0].kws[0].msgs[0]} Expected Failure! FAIL - Check Log Message ${tc.kws[0].kws[1].msgs[0]} In Failing UK Teardown + ${tc} = Check Test Case ${TESTNAME} + Check Log Message ${tc[0, 0, 0]} Expected Failure! FAIL + Check Log Message ${tc[0].teardown[0]} In Failing UK Teardown Teardown in keyword with embedded arguments - ${tc}= Check Test Case ${TESTNAME} - Check Log Message ${tc.kws[0].kws[0].msgs[0]} In UK with Embedded Arguments - Check Log Message ${tc.kws[0].kws[1].msgs[0]} In Teardown of UK with Embedded Arguments - Check Log Message ${tc.kws[1].kws[0].msgs[0]} Expected Failure in UK with Embedded Arguments FAIL - Check Log Message ${tc.kws[1].kws[1].msgs[0]} In Teardown of Failing UK with Embedded Arguments + ${tc} = Check Test Case ${TESTNAME} + Check Log Message ${tc[0, 0, 0]} In UK with Embedded Arguments + Check Log Message ${tc[0].teardown[0]} In Teardown of UK with Embedded Arguments + Check Log Message ${tc[1, 0, 0]} Expected Failure in UK with Embedded Arguments FAIL + Check Log Message ${tc[1].teardown[0]} In Teardown of Failing UK with Embedded Arguments Failure in Keyword Teardown - ${tc}= Check Test Case ${TESTNAME} - Check Log Message ${tc.kws[0].kws[0].msgs[0]} In UK - Check Log Message ${tc.kws[0].kws[1].msgs[0]} Failing in UK Teardown FAIL + ${tc} = Check Test Case ${TESTNAME} + Check Log Message ${tc[0, 0, 0]} In UK + Check Log Message ${tc[0].teardown[0]} Failing in UK Teardown FAIL Failures in Keyword and Teardown - ${tc}= Check Test Case ${TESTNAME} - Check Log Message ${tc.kws[0].kws[0].msgs[0]} Expected Failure! FAIL - Check Log Message ${tc.kws[0].kws[1].msgs[0]} Failing in UK Teardown FAIL + ${tc} = Check Test Case ${TESTNAME} + Check Log Message ${tc[0, 0, 0]} Expected Failure! FAIL + Check Log Message ${tc[0].teardown[0]} Failing in UK Teardown FAIL Multiple Failures in Keyword Teardown - ${tc}= Check Test Case ${TESTNAME} - Check Log Message ${tc.kws[0].kws[1].kws[0].msgs[0]} Failure in Teardown FAIL - Check Log Message ${tc.kws[0].kws[1].kws[1].kws[0].msgs[0]} Expected Failure! FAIL - Check Log Message ${tc.kws[0].kws[1].kws[1].kws[1].msgs[0]} Executed if in nested Teardown - Check Log Message ${tc.kws[0].kws[1].kws[2].msgs[0]} Third failure in Teardown FAIL + ${tc} = Check Test Case ${TESTNAME} + Check Log Message ${tc[0].teardown[0, 0]} Failure in Teardown FAIL + Check Log Message ${tc[0].teardown[1, 0, 0]} Expected Failure! FAIL + Check Log Message ${tc[0].teardown[1, 1, 0]} Executed if in nested Teardown + Check Log Message ${tc[0].teardown[2, 0]} Third failure in Teardown FAIL Nested Keyword Teardowns - ${tc}= Check Test Case ${TESTNAME} - Check Log Message ${tc.kws[0].kws[0].kws[0].msgs[0]} In UK - Check Log Message ${tc.kws[0].kws[0].kws[1].msgs[0]} In UK Teardown - Check Log Message ${tc.kws[0].kws[1].kws[0].msgs[0]} In UK - Check Log Message ${tc.kws[0].kws[1].kws[1].msgs[0]} In UK Teardown + ${tc} = Check Test Case ${TESTNAME} + Check Log Message ${tc[0, 0, 0, 0]} In UK + Check Log Message ${tc[0, 0].teardown[0]} In UK Teardown + Check Log Message ${tc[0].teardown[0, 0]} In UK + Check Log Message ${tc[0].teardown.teardown[0]} In UK Teardown Nested Keyword Teardown Failures - ${tc}= Check Test Case ${TESTNAME} - Check Log Message ${tc.kws[0].kws[0].kws[1].msgs[0]} Failing in UK Teardown FAIL - Check Log Message ${tc.kws[0].kws[1].msgs[0]} Failing in outer UK Teardown FAIL + ${tc} = Check Test Case ${TESTNAME} + Check Log Message ${tc[0, 0].teardown[0]} Failing in UK Teardown FAIL + Check Log Message ${tc[0].teardown[0]} Failing in outer UK Teardown FAIL Continuable Failure in Keyword - ${tc}= Check Test Case ${TESTNAME} - Check Log Message ${tc.kws[0].kws[0].kws[0].msgs[0]} Please continue FAIL - Check Log Message ${tc.kws[0].kws[1].msgs[0]} After continuable failure - Check Log Message ${tc.kws[0].kws[2].msgs[0]} In UK Teardown + ${tc} = Check Test Case ${TESTNAME} + Check Log Message ${tc[0, 0, 0, 0]} Please continue FAIL + Check Log Message ${tc[0, 1, 0]} After continuable failure + Check Log Message ${tc[0].teardown[0]} In UK Teardown Non-ASCII Failure in Keyword Teardown - ${tc}= Check Test Case ${TESTNAME} - Check Log Message ${tc.kws[0].kws[0].msgs[0]} åäö - Check Log Message ${tc.kws[0].kws[1].msgs[0]} Hyvää äitienpäivää! FAIL + ${tc} = Check Test Case ${TESTNAME} + Check Log Message ${tc[0, 0, 0]} åäö + Check Log Message ${tc[0].teardown[0]} Hyvää äitienpäivää! FAIL Keyword cannot have only teardown - Check Test Case ${TESTNAME} + Check Test Case ${TESTNAME} Replacing Variables in Keyword Teardown Fails - Check Test Case ${TESTNAME} - - + Check Test Case ${TESTNAME} diff --git a/atest/robot/core/non_ascii.robot b/atest/robot/core/non_ascii.robot index acc9bba682c..732acc79e9b 100644 --- a/atest/robot/core/non_ascii.robot +++ b/atest/robot/core/non_ascii.robot @@ -6,41 +6,41 @@ Variables unicode_vars.py *** Test Cases *** Non-ASCII Log Messages ${tc} = Check Test Case ${TESTNAME} - Check Log Message ${tc.kws[0].msgs[0]} ${MESSAGE1} - Check Log Message ${tc.kws[0].msgs[1]} ${MESSAGE2} - Check Log Message ${tc.kws[0].msgs[2]} ${MESSAGE3} + Check Log Message ${tc[0, 0]} ${MESSAGE1} + Check Log Message ${tc[0, 1]} ${MESSAGE2} + Check Log Message ${tc[0, 2]} ${MESSAGE3} Non-ASCII Return Value ${tc} = Check Test Case ${TESTNAME} - Check Log Message ${tc.kws[2].msgs[0]} Français + Check Log Message ${tc[2, 0]} Français Non-ASCII In Return Value Attributes ${tc} = Check Test Case ${TESTNAME} - Check Log Message ${tc.kws[0].msgs[0]} ${MESSAGES} - Check Log Message ${tc.kws[0].msgs[1]} \${obj} = ${MESSAGES} - Check Log Message ${tc.kws[1].msgs[0]} ${MESSAGES} + Check Log Message ${tc[0, 0]} ${MESSAGES} + Check Log Message ${tc[0, 1]} \${obj} = ${MESSAGES} + Check Log Message ${tc[1, 0]} ${MESSAGES} Non-ASCII Failure ${tc} = Check Test Case ${TESTNAME} FAIL ${MESSAGES} - Check Log Message ${tc.kws[0].msgs[0]} ${MESSAGES} FAIL + Check Log Message ${tc[0, 0]} ${MESSAGES} FAIL Non-ASCII Failure In Setup - ${tc} = Check Test Case ${TESTNAME} FAIL Setup failed:\n ${MESSAGES} - Check Log Message ${tc.setup.msgs[0]} ${MESSAGES} FAIL + ${tc} = Check Test Case ${TESTNAME} FAIL Setup failed:\n${MESSAGES} + Check Log Message ${tc.setup[0]} ${MESSAGES} FAIL Non-ASCII Failure In Teardown - ${tc} = Check Test Case ${TESTNAME} FAIL Teardown failed:\n ${MESSAGES} - Check Log Message ${tc.teardown.msgs[0]} ${MESSAGES} FAIL + ${tc} = Check Test Case ${TESTNAME} FAIL Teardown failed:\n${MESSAGES} + Check Log Message ${tc.teardown[0]} ${MESSAGES} FAIL Non-ASCII Failure In Teardown After Normal Failure - Check Test Case ${TESTNAME} FAIL Just ASCII here\n\n Also teardown failed:\n ${MESSAGES} + Check Test Case ${TESTNAME} FAIL Just ASCII here\n\nAlso teardown failed:\n${MESSAGES} Ñöñ-ÄŚÇÏÏ Tëśt äņd Këywörd Nämës, Спасибо ${tc} = Check Test Case ${TESTNAME} - Should Be Equal ${tc.kws[0].name} Ñöñ-ÄŚÇÏÏ Këywörd Nämë - Check Log Message ${tc.kws[0].kws[0].msgs[0]} Hyvää päivää + Should Be Equal ${tc[0].name} Ñöñ-ÄŚÇÏÏ Këywörd Nämë + Check Log Message ${tc[0, 0, 0]} Hyvää päivää Non-ASCII Failure In Suite Setup and Teardown Check Test Case ${TESTNAME} - Check Log Message ${SUITE.suites[1].setup.msgs[0]} ${MESSAGES} FAIL - Check Log Message ${SUITE.suites[1].teardown.msgs[0]} ${MESSAGES} FAIL + Check Log Message ${SUITE.suites[1].setup[0]} ${MESSAGES} FAIL + Check Log Message ${SUITE.suites[1].teardown[0]} ${MESSAGES} FAIL diff --git a/atest/robot/core/overriding_default_settings_with_none.robot b/atest/robot/core/overriding_default_settings_with_none.robot index e106e65a8e9..627d11d77b0 100644 --- a/atest/robot/core/overriding_default_settings_with_none.robot +++ b/atest/robot/core/overriding_default_settings_with_none.robot @@ -6,31 +6,31 @@ Resource atest_resource.robot Overriding Test Setup ${tc}= Check Test Case ${TESTNAME} - Should Be Equal ${tc.setup} ${NONE} + Setup Should Not Be Defined ${tc} Overriding Test Setup from Command Line ${tc}= Check Test Case ${TESTNAME} - Should Be Equal ${tc.setup} ${NONE} + Setup Should Not Be Defined ${tc} Overriding Test Teardown ${tc}= Check Test Case ${TESTNAME} - Should Be Equal ${tc.teardown} ${NONE} + Teardown Should Not Be Defined ${tc} Overriding Test Teardown from Command Line ${tc}= Check Test Case ${TESTNAME} - Should Be Equal ${tc.teardown} ${NONE} + Teardown Should Not Be Defined ${tc} Overriding Test Template ${tc}= Check Test Case ${TESTNAME} - Should Be Equal ${tc.keywords[0].name} BuiltIn.No Operation + Should Be Equal ${tc[0].full_name} BuiltIn.No Operation Overriding Test Timeout ${tc}= Check Test Case ${TESTNAME} - Check Log Message ${tc.keywords[0].msgs[0]} Slept 300 milliseconds + Check Log Message ${tc[0, 0]} Slept 123 milliseconds. Overriding Test Timeout from Command Line ${tc}= Check Test Case ${TESTNAME} - Check Log Message ${tc.keywords[0].msgs[0]} Slept 300 milliseconds + Check Log Message ${tc[0, 0]} Slept 123 milliseconds. Overriding Default Tags ${tc}= Check Test Case ${TESTNAME} @@ -42,7 +42,7 @@ Overriding Default Tags from Command Line Overriding Is Case Insensitive ${tc}= Check Test Case ${TESTNAME} - Should Be Equal ${tc.setup} ${NONE} - Should Be Equal ${tc.teardown} ${NONE} - Should Be Equal ${tc.keywords[0].name} BuiltIn.No Operation + Setup Should Not Be Defined ${tc} + Teardown Should Not Be Defined ${tc} + Should Be Equal ${tc[0].full_name} BuiltIn.No Operation Should Be Empty ${tc.tags} diff --git a/atest/robot/core/resource_and_variable_imports.robot b/atest/robot/core/resource_and_variable_imports.robot index 022af096974..b089fb86f92 100644 --- a/atest/robot/core/resource_and_variable_imports.robot +++ b/atest/robot/core/resource_and_variable_imports.robot @@ -1,79 +1,97 @@ *** Settings *** -Suite Setup Run Tests With Non-ASCII Items In PYTHONPATH -Resource atest_resource.robot +Suite Setup Run Tests With Non-ASCII Items In PYTHONPATH +Resource atest_resource.robot *** Variables *** -${DATAFILE} core/resource_and_variable_imports.robot -${RESDIR} ${DATADIR}/core/resources_and_variables -${PPATH_RESDIR} ${DATADIR}/../testresources/res_and_var_files +${DATAFILE} core/resource_and_variable_imports.robot +${RESDIR} ${DATADIR}/core/resources_and_variables +${PPATH_RESDIR} ${DATADIR}/../testresources/res_and_var_files *** Test Cases *** Normal Resource Import - [Documentation] Test that path given in resource import is relative to the current - ... directory and that '/' is converted to correct path separator depending on os. - Check Test Case ${TEST NAME} + [Documentation] Test that path given in resource import is relative to + ... the current directory and that '/' is converted to + ... correct path separator depending on the OS. + Check Test Case ${TEST NAME} Resource Import With Variables - [Documentation] Test that variables can be used in resource import path. - ... Also test that path can be absolute. - Check Test Case ${TEST NAME} + [Documentation] Test that variables can be used in resource import path. + ... Also test that path can be absolute. + Check Test Case ${TEST NAME} Normal Variable Import - [Documentation] Test that path given in variable import is relative to the current - ... directory and that '/' is converted to correct path separator depending on os. - Check Test Case ${TEST NAME} + [Documentation] Test that path given in variable import is relative to + ... the current directory and that '/' is converted to + ... correct path separator depending on the OS. + Check Test Case ${TEST NAME} Not Included In __all__ - Check Test Case ${TEST NAME} + Check Test Case ${TEST NAME} Variable Import With Variables - [Documentation] Test that variables can be used in variable import path. - ... Also test that path can be absolute. - Check Test Case ${TEST NAME} + [Documentation] Test that variables can be used in variable import path. + ... Also test that path can be absolute. + Check Test Case ${TEST NAME} 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 - Stderr Should Contain Error ${DATAFILE} - ... Processing variable file '${path}' failed: - ... Invalid variable '\@{invalid_list}': Expected list-like value, got string. + [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 17 ${DATAFILE} 48 + ... Processing variable file '${path}' failed: + ... Invalid variable 'LIST__invalid_list': Expected a list-like value, got string. Dynamic Variable File - [Documentation] Test for getting variables dynamically from a variable file - ... using get_variables or getVariables and arguments. - 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} - -Invalid Arguments To Dynamic Variable File - ${path} = Normalize Path ${RESDIR}/dynamic_variables.py - Stderr Should Contain Error ${DATAFILE} - ... Processing variable file '${path}' with arguments [ Two args | returns invalid ] failed: - ... Expected 'get_variables' to return dict-like value, got None. - Stderr Should Contain Error ${DATAFILE} - ... Processing variable file '${path}' with arguments [ More | args | raises | exception ] failed: - ... Invalid arguments for get_variables + 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 ['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 ['Four', 'args', 'raises', 'exception'] failed: + ... Ooops! + ... pattern=False Non-Existing Variable In Arguments To Dynamic Variable File - ${path} = Normalize Path ${RESDIR}/dynamicVariables.py - Stderr Should Contain Error ${DATAFILE} - ... Replacing variables from setting 'Variables' failed: - ... Variable '\${non_existing_var_as_arg}' not found. + ${path} = Normalize Path ${RESDIR}/dynamicVariables.py + Error in file 16 ${DATAFILE} 47 + ... Replacing variables from setting 'Variables' failed: + ... Variable '\${non_existing_var_as_arg}' not found. Resource Importing Resources - [Documentation] Test that resource file can import more resources. - Check Test Case ${TEST NAME} + Check Test Case ${TEST NAME} Resource Importing Variables - [Documentation] Test that resource file can import variables - Check Test Case ${TEST NAME} + Check Test Case ${TEST NAME} Resource Importing Library - [Documentation] Test that resource file can import libraries - Check Test Case ${TEST NAME} + Check Test Case ${TEST NAME} Re-Import Resource File [Template] File Should Have Already Been Imported @@ -86,79 +104,83 @@ Re-Import Variable File Variable variables.py Variable variables2.py Variable variables_imported_by_resource.py - Variable dynamic_variables.py ${SPACE}with arguments [ One arg works ] + Variable dynamic_variables.py ${SPACE}with arguments [ One arg works ] Non-Existing Resource File - Stderr Should Contain Error ${DATAFILE} - ... Resource file 'non_existing.robot' does not exist + Error in file 9 ${DATAFILE} 39 + ... Resource file 'non_existing.robot' does not exist. Non-Existing Variable File - Stderr Should Contain Error ${DATAFILE} - ... Variable file 'non_existing.py' does not exist + 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 Stderr Contains [ WARN ] Imported resource file '${path}' is empty + Check log message ${ERRORS}[11] + ... Imported resource file '${path}' is empty. WARN Invalid Resource Import Parameters - Stderr Should Contain Error ${DATAFILE} - ... Resource file 'resources_and_variables${/}resources.robot only one parameter allowed' does not exist + 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 - Stderr Should Contain Error ${DATAFILE} - ... Initialization file '${path}' cannot be imported as a resource file. + 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 - Stderr Should Contain Error ${DATAFILE} - ... Initialization file '${path}' cannot be imported as a resource file. + Error in file 13 ${DATAFILE} 44 + ... Initialization file '${path}' cannot be imported as a resource file. Invalid Setting In Resource File - Stderr Should Contain Error ${RESDIR}/resources.robot - ... Non-existing setting 'Test Setup'. - Stderr Should Contain Error ${RESDIR}/resources.robot - ... Non-existing setting 'Non Existing'. - ${invres} = Normalize Path ${RESDIR}/resource_with_testcase_table.robot - Stderr Should Contain Error ${RESDIR}/resources.robot - ... Resource file '${invres}' cannot contain tests or tasks. - Check Stderr Does Not Contain AttributeError: + Error in file 1 ${RESDIR}/resources.robot 8 + ... Setting 'Test Setup' is not allowed in resource file. + Error in file 2 ${RESDIR}/resources.robot 9 + ... Non-existing setting 'Non Existing'. + +Resource cannot contain tests + ${path} = Normalize Path ${RESDIR}/resource_with_testcase_table.robot + Error in file 3 ${RESDIR}/resources.robot 6 + ... Error in file '${path}' on line 4: + ... Resource file with 'Test Cases' section is invalid. Invalid Variable File - ${path} = Normalize Path ${RESDIR}/invalid_variable_file.py - Stderr Should Contain Error ${DATAFILE} + ${path} = Normalize Path ${RESDIR}/invalid_variable_file.py + 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 - Stderr Should Contain Error ${DATAFILE} - ... Resource setting requires a name + Error in file 14 ${DATAFILE} 45 + ... Resource setting requires value. Variable Import Without Path - Stderr Should Contain Error ${DATAFILE} - ... Variables setting requires a name + Error in file 18 ${DATAFILE} 49 + ... Variables setting requires value. Resource File In PYTHONPATH - Check Test Case ${TEST NAME} + Check Test Case ${TEST NAME} Variable File In PYTHONPATH - Check Test Case ${TEST NAME} + Check Test Case ${TEST NAME} *** Keywords *** Run Tests With Non-ASCII Items In PYTHONPATH Create Directory %{TEMPDIR}/nön-äscïï Set PYTHONPATH %{TEMPDIR}/nön-äscïï ${PPATH_RESDIR} - Run Tests ${EMPTY} ${DATAFILE} - [Teardown] Run Keywords - ... Remove Directory %{TEMPDIR}/nön-äscïï AND + Run Tests ${EMPTY} ${DATAFILE} + [Teardown] Run Keywords + ... Remove Directory %{TEMPDIR}/nön-äscïï AND ... Reset PYTHONPATH Stderr Should Contain Error [Arguments] ${path} @{error parts} - ${path} = Join Path ${DATADIR} ${path} - ${error} = Catenate @{error parts} - Check Stderr Contains [ ERROR ] Error in file '${path}': ${error} + ${path} = Join Path ${DATADIR} ${path} + ${error} = Catenate @{error parts} + Stderr Should Contain [ ERROR ] Error in file '${path}': ${error} File Should Have Already Been Imported [Arguments] ${type} ${path} ${arguments}= ${suite}=Resource And Variable Imports - ${path} = Join Path ${RESDIR} ${path} - Check Syslog Contains | INFO \ | ${type} file '${path}'${arguments} already imported by suite '${suite}' + ${path} = Join Path ${RESDIR} ${path} + Syslog Should Contain | INFO \ | ${type} file '${path}'${arguments} already imported by suite '${suite}' diff --git a/atest/robot/core/same_test_multiple_times_in_suite.robot b/atest/robot/core/same_test_multiple_times_in_suite.robot deleted file mode 100644 index f0b879b6be6..00000000000 --- a/atest/robot/core/same_test_multiple_times_in_suite.robot +++ /dev/null @@ -1,30 +0,0 @@ -*** Setting *** -Suite Setup Run Tests --exclude exclude core/same_test_multiple_times_in_suite.robot -Resource atest_resource.robot - -*** Test Case *** -Tests With Same Name Should Be Executed - Should Contain Tests ${SUITE} - ... Same Test Multiple Times - ... Same Test Multiple Times - ... Same Test Multiple Times - ... Same Test With Different Case And Spaces - ... SameTestwith Different CASE and s p a c e s - ... Same Test In Data But Only One Executed - -There Should Be Warning When Multiple Tests With Same Name Are Executed - Check Multiple Tests Log Message ${ERRORS[0]} Same Test Multiple Times - Check Multiple Tests Log Message ${ERRORS[1]} Same Test Multiple Times - Check Multiple Tests Log Message ${ERRORS[2]} SameTestwith Different CASE and s p a c e s - -There Should Be No Warning When There Are Multiple Tests With Same Name In Data But Only One Is Executed - ${tc} = Check Test Case Same Test In Data But Only One Executed - Check Log Message ${tc.kws[0].msgs[0]} This is executed! - Length Should Be ${ERRORS} 3 - -*** Keywords *** -Check Multiple Tests Log Message - [Arguments] ${error} ${test} - ${message} = Catenate Multiple test cases with name '${test}' - ... executed in test suite 'Same Test Multiple Times In Suite'. - Check Log Message ${error} ${message} WARN diff --git a/atest/robot/core/suite_setup_and_teardown.robot b/atest/robot/core/suite_setup_and_teardown.robot index 8e5f5270a68..7c5ebb1d8e2 100644 --- a/atest/robot/core/suite_setup_and_teardown.robot +++ b/atest/robot/core/suite_setup_and_teardown.robot @@ -1,35 +1,32 @@ *** Settings *** -Resource atest_resource.robot +Resource atest_resource.robot *** Variables *** -${1 PASS MSG} 1 critical test, 1 passed, 0 failed\n 1 test total, 1 passed, 0 failed -${1 FAIL MSG} 1 critical test, 0 passed, 1 failed\n 1 test total, 0 passed, 1 failed -${2 FAIL MSG} 2 critical tests, 0 passed, 2 failed\n 2 tests total, 0 passed, 2 failed -${4 FAIL MSG} 4 critical tests, 0 passed, 4 failed\n 4 tests total, 0 passed, 4 failed -${5 FAIL MSG} 5 critical tests, 0 passed, 5 failed\n 5 tests total, 0 passed, 5 failed -${12 FAIL MSG} 12 critical tests, 0 passed, 12 failed\n 12 tests total, 0 passed, 12 failed -${ALSO} \n\nAlso teardown of the parent suite failed. +${1 PASS MSG} 1 test, 1 passed, 0 failed +${1 FAIL MSG} 1 test, 0 passed, 1 failed +${2 FAIL MSG} 2 tests, 0 passed, 2 failed +${4 FAIL MSG} 4 tests, 0 passed, 4 failed +${5 FAIL MSG} 5 tests, 0 passed, 5 failed +${12 FAIL MSG} 12 tests, 0 passed, 12 failed +${ALSO} \n\nAlso teardown of the parent suite failed. ${EXECUTED FILE} %{TEMPDIR}/robot-suite-teardown-executed.txt *** Test Cases *** Passing Suite Setup Run Tests ${EMPTY} core/passing_suite_setup.robot - Check Suite Status ${SUITE} PASS ${1 PASS MSG} - ... Verify Suite Setup + Check Suite Status ${SUITE} PASS ${1 PASS MSG} Verify Suite Setup Passing Suite Teardown [Setup] Remove File ${EXECUTED FILE} Run Tests ${EMPTY} core/passing_suite_teardown.robot - Check Suite Status ${SUITE} PASS ${1 PASS MSG} - ... Test + Check Suite Status ${SUITE} PASS ${1 PASS MSG} Test File Should Exist ${EXECUTED FILE} [Teardown] Remove File ${EXECUTED FILE} Passing Suite Setup And Teardown [Setup] Remove File ${EXECUTED FILE} Run Tests ${EMPTY} core/passing_suite_setup_and_teardown.robot - Check Suite Status ${SUITE} PASS ${1 PASS MSG} - ... Verify Suite Setup + Check Suite Status ${SUITE} PASS ${1 PASS MSG} Verify Suite Setup File Should Exist ${EXECUTED FILE} [Teardown] Remove File ${EXECUTED FILE} @@ -38,11 +35,10 @@ Failing Suite Setup Check Suite Status ${SUITE} FAIL ... Suite setup failed:\nExpected failure\n\n${2 FAIL MSG} ... Test 1 Test 2 - Should Be Equal ${SUITE.setup.status} FAIL - Should Be Equal ${SUITE.teardown.status} PASS - Length Should Be ${SUITE.teardown.msgs} 1 + Should Be Equal ${SUITE.setup.status} FAIL + Should Be Equal ${SUITE.teardown.status} PASS + Length Should Be ${SUITE.teardown.body} 1 Check Log Message ${SUITE.teardown.messages[0]} Suite teardown executed - Should Be Empty ${SUITE.teardown.kws} Erroring Suite Setup Run Tests ${EMPTY} core/erroring_suite_setup.robot @@ -50,15 +46,15 @@ Erroring Suite Setup ... Suite setup failed:\nNo keyword with name 'Non-Existing Keyword' found.\n\n${2 FAIL MSG} ... Test 1 Test 2 Should Be Equal ${SUITE.setup.status} FAIL - ${td} = Set Variable ${SUITE.teardown} - Should Be Equal ${td.name} My TD - Should Be Equal ${td.status} PASS - Should Be Empty ${td.msgs} - Length Should Be ${td.kws} 2 - 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 + VAR ${td} ${SUITE.teardown} + Should Be Equal ${td.name} My TD + Should Be Equal ${td.status} PASS + Length Should Be ${td.body} 2 + Length Should Be ${td.messages} 0 + Length Should Be ${td[0].body} 1 + Length Should Be ${td[0].messages} 1 + Check Log Message ${td[0, 0]} Hello from suite teardown! + Should Be Equal ${td[1].full_name} BuiltIn.No Operation Failing Higher Level Suite Setup Run Tests ${EMPTY} core/failing_higher_level_suite_setup @@ -73,25 +69,41 @@ Failing Higher Level Suite Setup ... Test 2 Stderr Should Be Empty -Failing Suite Teardown When All Tests Pass +Failing Suite Teardown Run Tests ${EMPTY} core/failing_suite_teardown.robot ${error} = Catenate SEPARATOR=\n\n ... Several failures occurred: ... 1) first ... 2) second Check Suite Status ${SUITE} FAIL - ... Suite teardown failed:\n${error}\n\n${2 FAIL MSG} - ... Test 1 Test 2 + ... Suite teardown failed:\n${error}\n\n3 tests, 0 passed, 2 failed, 1 skipped + ... Passing Failing Skipping Should Be Equal ${SUITE.teardown.status} FAIL Output should contain teardown error ${error} -Failing Suite Teardown When Also Tests Fail - Run Tests ${EMPTY} core/failing_suite_teardown_2.robot +Failing Suite Teardown when using JSON + Run Tests --output output.json core/failing_suite_teardown.robot output=${OUTDIR}/output.json + ${error} = Catenate SEPARATOR=\n\n + ... Several failures occurred: + ... 1) first + ... 2) second Check Suite Status ${SUITE} FAIL - ... Suite teardown failed:\nExpected failure\n\n${5 FAIL MSG} - ... Test Passes Test Fails Setup Fails Teardown Fails Test and Teardown Fail + ... Suite teardown failed:\n${error}\n\n3 tests, 0 passed, 2 failed, 1 skipped + ... Passing Failing Skipping Should Be Equal ${SUITE.teardown.status} FAIL - Output should contain teardown error Expected failure + JSON output should contain teardown error ${error} + +Failing Suite Teardown when using JSON and all tests pass + Run Tests --output output.json --test passing core/failing_suite_teardown.robot output=${OUTDIR}/output.json + ${error} = Catenate SEPARATOR=\n\n + ... Several failures occurred: + ... 1) first + ... 2) second + Check Suite Status ${SUITE} FAIL + ... Suite teardown failed:\n${error}\n\n1 test, 0 passed, 1 failed + ... Passing + Should Be Equal ${SUITE.teardown.status} FAIL + JSON output should contain teardown error ${error} Erroring Suite Teardown Run Tests ${EMPTY} core/erroring_suite_teardown.robot @@ -112,7 +124,7 @@ Failing Suite Setup And Teardown ... in two lines Check Suite Status ${SUITE} FAIL ${error}\n\n${2 FAIL MSG} ... Test 1 Test 2 - Should Be Equal ${SUITE.setup.status} FAIL + Should Be Equal ${SUITE.setup.status} FAIL Should Be Equal ${SUITE.teardown.status} FAIL Output should contain teardown error Teardown failure\nin two lines @@ -169,10 +181,14 @@ Long Error Messages *** Keywords *** Check Suite Status [Arguments] ${suite or name} ${status} ${message} @{tests} - ${is string} = Run Keyword And Return Status Should Be String ${suite or name} - ${suite} = Run Keyword If ${is string} Get Test Suite ${suite or name} - ... ELSE Set Variable ${suite or name} - Should Be Equal ${suite.status} ${status} Wrong suite status + TRY + Should Be String ${suite or name} + EXCEPT + VAR ${suite} ${suite or name} + ELSE + ${suite} = Get Test Suite ${suite or name} + END + Should Be Equal ${suite.status} ${status} Wrong suite status Should Be Equal ${suite.full_message} ${message} Wrong suite message Should Contain Tests ${suite} @{tests} @@ -181,3 +197,8 @@ Output should contain teardown error ${keywords} = Get Elements ${OUTFILE} suite/kw Element Text Should Be ${keywords[-1]} ${error} xpath=status +JSON output should contain teardown error + [Arguments] ${error} + ${path} = Normalize Path ${OUTDIR}/output.json + ${data} = Evaluate json.load(open($path, 'rb')) + Should Be Equal ${data}[suite][teardown][message] ${error} diff --git a/atest/robot/core/test_case_status.robot b/atest/robot/core/test_case_status.robot deleted file mode 100644 index 6a9e686a741..00000000000 --- a/atest/robot/core/test_case_status.robot +++ /dev/null @@ -1,52 +0,0 @@ -*** Setting *** -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} core/test_case_status.robot -Resource atest_resource.robot - -*** Variable *** - -*** Test Case *** -Test Passes - Check Testcase Test Pass - -Test Fails - Check Testcase Test Fail - -Non-Existing Keyword Error - Check Testcase Non-Existing Keyword Error - -Test Setup Passes - Check Testcase Tests Setup Pass - -Test Setup Fails - [Documentation] Checks also that test is not run if setup fails - Check Testcase Tests Setup Fail - -Test Setup Errors - Check Testcase Tests Setup Error Non Existing KW - -Test Teardown Passes - Check Testcase Tests Teardown Pass - -Test Teardown Fails - Check Testcase Tests Teardown Fail - -Test Teardown Errors - Check Testcase Tests Teardown Error Non Existing KW - -Test And Teardown Fails - Check Testcase Test And Teardown Fails - -Test Setup And Teardown Pass - Check Testcase Test Setup And Teardown Pass - -Test Teardown is Run When Setup Fails - ${test} Check Testcase Test Teardown is Run When Setup Fails - ${td} = Set Variable ${test.teardown} - Should Not Be Equal ${td} ${None} Teardown not run No values - Length Should Be ${td.msgs} 1 - Check Log Message ${td.msgs[0]} Hello from teardown! - Length Should Be ${td.kws} 0 - -Test Setup And Teardown Fails - Check Testcase Test Setup And Teardown Fails diff --git a/atest/robot/core/test_suite_dir.robot b/atest/robot/core/test_suite_dir.robot index 931d11a45da..880c9c095bc 100644 --- a/atest/robot/core/test_suite_dir.robot +++ b/atest/robot/core/test_suite_dir.robot @@ -3,9 +3,9 @@ Suite Setup Run Tests ${EMPTY} core/test_suite_dir Resource atest_resource.robot *** Variables *** -${1_test_msg} 1 critical test, 1 passed, 0 failed\n 1 test total, 1 passed, 0 failed -${2_test_msg} 2 critical tests, 2 passed, 0 failed\n 2 tests total, 2 passed, 0 failed -${3_test_msg} 3 critical tests, 3 passed, 0 failed\n 3 tests total, 3 passed, 0 failed +${1_test_msg} 1 test, 1 passed, 0 failed +${2_test_msg} 2 tests, 2 passed, 0 failed +${3_test_msg} 3 tests, 3 passed, 0 failed *** Test Cases *** Main Suite Executed @@ -38,12 +38,12 @@ Child Suites Not Containing Tests Not Executed File Without Extension ${path} = Normalize Path ${DATADIR}/core/test_suite_dir/no_extension - Check Syslog Contains Ignoring file or directory '${path}'. + Syslog Should Contain Ignoring file or directory '${path}'. File and Directory Starting with _ ${path} = Normalize Path ${DATADIR}/core/test_suite_dir - Check Syslog Contains Ignoring file or directory '${path}${/}_ignore_this_file.robot'. - Check Syslog Contains Ignoring file or directory '${path}${/}_ignore_this_dir'. + Syslog Should Contain Ignoring file or directory '${path}${/}_ignore_this_file.robot'. + Syslog Should Contain Ignoring file or directory '${path}${/}_ignore_this_dir'. *** Keywords *** Suite Passed diff --git a/atest/robot/core/test_suite_init_file.robot b/atest/robot/core/test_suite_init_file.robot index 67cd96635a1..fa952fba835 100644 --- a/atest/robot/core/test_suite_init_file.robot +++ b/atest/robot/core/test_suite_init_file.robot @@ -21,20 +21,27 @@ Suite Documentation Suite Setup [Documentation] Setting and not setting setup using suite file - Check Log Message ${suite.setup.kws[0].msgs[0]} Setup of test suite directory - Should Be Equal ${subsuite_with_init.setup} ${None} - Should Be Equal ${subsuite_without_init.setup} ${None} + Check Log Message ${suite.setup[0, 0]} Setup of test suite directory + Setup Should Not Be Defined ${subsuite_with_init} + Setup Should Not Be Defined ${subsuite_without_init} Suite Teardown [Documentation] Setting and not setting teardown using suite file - Check Log Message ${suite.teardown.kws[1].msgs[0]} Teardown of test suite directory - Check Log Message ${subsuite_with_init.teardown.kws[1].msgs[0]} Teardown of sub test suite directory - Should Be Equal ${subsuite_without_init.teardown} ${None} + Check Log Message ${suite.teardown[1, 0]} Teardown of test suite directory + Check Log Message ${subsuite_with_init.teardown[1, 0]} Teardown of sub test suite directory + Teardown Should Not Be Defined ${subsuite_without_init} Invalid Suite Setting - ${base} = Normalize Path ${CURDIR}/../../testdata/core/test_suite_dir_with_init_file - Check Log Message ${ERRORS[0]} Error in file '${base}${/}__init__.robot': Non-existing setting 'Invalid'. ERROR - Check Log Message ${ERRORS[1]} Error in file '${base}${/}sub_suite_with_init_file${/}__INIT__.robot': Non-existing setting 'Invalid In Sub'. ERROR + Error In File 0 core/test_suite_dir_with_init_file/__init__.robot 10 + ... Non-existing setting 'Invalid'. + Error In File 3 core/test_suite_dir_with_init_file/sub_suite_with_init_file/__INIT__.robot 7 + ... Non-existing setting 'Megadata'. Did you mean:\n${SPACE*4}Metadata + +Default Tags and Test Template are not allowed in init files + Error In File 1 core/test_suite_dir_with_init_file/__init__.robot 11 + ... Setting 'Default Tags' is not allowed in suite initialization file. + Error In File 2 core/test_suite_dir_with_init_file/__init__.robot 12 + ... Setting 'Test Template' is not allowed in suite initialization file. Test Tags [Documentation] Settings tags for tests using Force and Default Tags in different suite levels and also [Tags] in tests @@ -43,13 +50,12 @@ Test Tags Check Test Tags TC2 No Metadata suite force Check Test Tags TC2 Tags suite force Check Test Tags S1TC1 No Metadata sub suite force suite force test default test force - Comment Following two steps are for issue 152 + # Following two steps are for issue 152 Check Test Tags S1TC1 Fixture sub suite force suite force test default test force Check Test Tags S1TC1 Timeout sub suite force suite force test default test force Check Test Tags S1TC1 Tags sub suite force suite force test force test tag 1 test tag 2 Check Test Tags S1TC2 No Metadata sub suite force suite force - Check Test Tags S1TC2 Tags sub suite force suite force t1 t2 t3 t4 - ... t5 + Check Test Tags S1TC2 Tags sub suite force suite force t1 t2 t3 t4 t5 Check Test Tags S2TC1 No Metadata suite force test default test force Check Test Tags S2TC1 Tags suite force test force test tag 1 test tag 2 test tag 3 Check Test Tags S2TC2 No Metadata suite force @@ -64,7 +70,7 @@ Test Fixture Check Test Fixture S1TC1 No Metadata Default setup from test file Default teardown from test file Check Test Fixture S1TC1 Fixture Setup defined in test Teardown defined in test Check Test Fixture S1TC2 No Metadata Default setup from sub suite file Default teardown from suite file - Comment Following step is for issue 152 + # Following step is for issue 152 Check Test Fixture S1TC2 Tags Default setup from sub suite file Default teardown from suite file Check Test Fixture S1TC2 Fixture Setup defined in test Teardown defined in test Check Test Fixture S2TC1 No Metadata Default setup from test file Default teardown from test file @@ -75,17 +81,17 @@ Test Fixture Test Timeout [Documentation] Setting timeout for tests using Test Timeout in different suite levels and also [Timeout] in tests Check Test Timeout TC1 No Metadata 1 hour 2 minutes 3 seconds - Check Test Timeout TC1 Timeout 1 second + Check Test Timeout TC1 Timeout 100 milliseconds Check Test Timeout TC2 No Metadata 13 days 6 hours 50 minutes Check Test Timeout TC2 Timeout 1 hour Check Test Timeout S1TC1 No Metadata 4 hours 5 minutes 6 seconds - Check Test Timeout S1TC1 Timeout 1 second + Check Test Timeout S1TC1 Timeout 101 milliseconds Check Test Timeout S1TC2 No Metadata 1 minute 52 seconds - Comment Following step is for issue 152 + # Following step is for issue 152 Check Test Timeout S1TC2 Tags 1 minute 52 seconds Check Test Timeout S1TC2 Timeout 1 day Check Test Timeout S2TC1 No Metadata 7 hours 8 minutes 9 seconds - Check Test Timeout S2TC1 Timeout 1 second + Check Test Timeout S2TC1 Timeout 99 milliseconds Check Test Timeout S2TC2 No Metadata 13 days 6 hours 50 minutes Check Test Timeout S2TC2 Timeout 1 day diff --git a/atest/robot/core/unicode_with_java_libs.robot b/atest/robot/core/unicode_with_java_libs.robot deleted file mode 100644 index c14c9a8ec39..00000000000 --- a/atest/robot/core/unicode_with_java_libs.robot +++ /dev/null @@ -1,21 +0,0 @@ -*** Setting *** -Suite Setup Run Tests ${EMPTY} core/unicode_with_java_libs.robot -Force Tags require-jython -Resource atest_resource.robot -Variables ../../resources/unicode_vars.py - -*** Test Case *** -Unicode In Xml Output - ${test} = Check Test Case Unicode - Check Log Message ${test.kws[0].msgs[0]} ${MESSAGE1} - Check Log Message ${test.kws[0].msgs[1]} ${MESSAGE2} - Check Log Message ${test.kws[0].msgs[2]} ${MESSAGE3} - -Unicode Object - ${test} = Check Test Case Unicode Object - Check Log Message ${test.kws[0].msgs[0]} ${MESSAGES} - Check Log Message ${test.kws[0].msgs[1]} \${obj} = ${MESSAGES} - Check Log Message ${test.kws[1].msgs[0]} ${MESSAGES} - -Unicode Error - Check Test Case Unicode Error FAIL ${MESSAGES} diff --git a/atest/robot/external/unit_tests.robot b/atest/robot/external/unit_tests.robot index b292aa542c5..961febcecdc 100644 --- a/atest/robot/external/unit_tests.robot +++ b/atest/robot/external/unit_tests.robot @@ -1,5 +1,4 @@ *** Settings *** -Force Tags no-standalone Resource atest_resource.robot Suite Setup Create Directory ${OUTDIR} @@ -11,5 +10,5 @@ Unit Tests ${result} = Run Process @{INTERPRETER.interpreter} ${TESTPATH} --quiet ... stdout=${STDOUT FILE} stderr=STDOUT Log ${result.stdout} - Should Be Equal As Integers ${result.rc} 0 - ... Unit tests failed with RC ${result.rc}. values=False + Should Be True ${result.rc} == 0 + ... Unit tests failed with RC ${result.rc}:\n${result.stdout} 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/robot/keywords/dots_in_keyword_name.robot b/atest/robot/keywords/dots_in_keyword_name.robot index ac30e4bf2c8..1ea6962e9a1 100644 --- a/atest/robot/keywords/dots_in_keyword_name.robot +++ b/atest/robot/keywords/dots_in_keyword_name.robot @@ -44,5 +44,5 @@ Dots in library name and keyword name with full name Conflicting names with dots ${tc} = Check Test Case ${TESTNAME} - Check log message ${tc.kws[0].msgs[0]} Running keyword 'Conflict'. - Check log message ${tc.kws[1].msgs[0]} Executing keyword 'In.name.conflict'. + Check log message ${tc[0, 0]} Running keyword 'Conflict'. + Check log message ${tc[1, 0]} Executing keyword 'In.name.conflict'. diff --git a/atest/robot/keywords/duplicate_dynamic_keywords.robot b/atest/robot/keywords/duplicate_dynamic_keywords.robot index 20d4fc39c7b..15e1a07854b 100644 --- a/atest/robot/keywords/duplicate_dynamic_keywords.robot +++ b/atest/robot/keywords/duplicate_dynamic_keywords.robot @@ -5,22 +5,15 @@ 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 - Creating keyword should have failed 0 DEFINED TWICE + Should Be Equal ${tc[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[0].full_name} Embedded twice Length Should Be ${ERRORS} 1 Exact duplicate is accepted Check Test Case ${TESTNAME} - -*** Keywords *** -Creating keyword should have failed - [Arguments] ${index} ${name} - ${message} = Catenate - ... Error in test library 'DupeDynamicKeywords': - ... Creating keyword '${name}' failed: - ... Keyword with same name defined multiple times. - Check Log Message ${ERRORS[${index}]} ${message} ERROR diff --git a/atest/robot/keywords/duplicate_hybrid_keywords.robot b/atest/robot/keywords/duplicate_hybrid_keywords.robot index 862ac6b4ad6..3269b524a3c 100644 --- a/atest/robot/keywords/duplicate_hybrid_keywords.robot +++ b/atest/robot/keywords/duplicate_hybrid_keywords.robot @@ -5,22 +5,15 @@ 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 - Creating keyword should have failed 0 DEFINED TWICE + Should Be Equal ${tc[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[0].full_name} Embedded twice Length Should Be ${ERRORS} 1 Exact duplicate is accepted Check Test Case ${TESTNAME} - -*** Keywords *** -Creating keyword should have failed - [Arguments] ${index} ${name} - ${message} = Catenate - ... Error in test library 'DupeHybridKeywords': - ... Creating keyword '${name}' failed: - ... Keyword with same name defined multiple times. - Check Log Message ${ERRORS[${index}]} ${message} ERROR diff --git a/atest/robot/keywords/duplicate_static_keywords.robot b/atest/robot/keywords/duplicate_static_keywords.robot index e93b62405f6..c2d9ce15ff7 100644 --- a/atest/robot/keywords/duplicate_static_keywords.robot +++ b/atest/robot/keywords/duplicate_static_keywords.robot @@ -5,27 +5,26 @@ 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[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[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[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[0].full_name} Embedded \${arguments match} twice Length Should Be ${ERRORS} 3 *** Keywords *** Creating keyword should have failed [Arguments] ${index} ${name} - ${message} = Catenate - ... Error in test library 'DupeKeywords': - ... Creating keyword '${name}' failed: + Error in library DupeKeywords + ... Adding keyword '${name}' failed: ... Keyword with same name defined multiple times. - Check Log Message ${ERRORS[${index}]} ${message} ERROR + ... index=${index} diff --git a/atest/robot/keywords/duplicate_user_keywords.robot b/atest/robot/keywords/duplicate_user_keywords.robot index e7cb2d24aa8..b468b2a60dd 100644 --- a/atest/robot/keywords/duplicate_user_keywords.robot +++ b/atest/robot/keywords/duplicate_user_keywords.robot @@ -5,12 +5,12 @@ Resource atest_resource.robot *** Test Cases *** Using keyword defined twice fails Check Test Case ${TESTNAME} - Creating keyword should have failed 0 Defined Twice + Creating keyword should have failed 0 Defined Twice 45 Using keyword defined thrice fails as well Check Test Case ${TESTNAME} - Creating keyword should have failed 1 Defined Thrice - Creating keyword should have failed 2 DEFINED THRICE + Creating keyword should have failed 1 Defined Thrice 51 + Creating keyword should have failed 2 DEFINED THRICE 54 Keyword with embedded arguments defined twice fails at run-time Check Test Case ${TESTNAME}: Called with embedded args @@ -19,8 +19,8 @@ Keyword with embedded arguments defined twice fails at run-time Using keyword defined multiple times in resource fails Check Test Case ${TESTNAME} - Creating keyword should have failed 3 Defined Twice In Resource - ... dupe_keywords.robot resource + Creating keyword should have failed 3 Defined Twice In Resource 5 + ... dupe_keywords.resource Keyword with embedded arguments defined multiple times in resource fails at run-time Check Test Case ${TESTNAME} @@ -28,10 +28,7 @@ Keyword with embedded arguments defined multiple times in resource fails at run- *** Keywords *** Creating keyword should have failed - [Arguments] ${index} ${name} ${source}=duplicate_user_keywords.robot ${source type}=test case - ${source} = Normalize Path ${DATADIR}/keywords/${source} - ${message} = Catenate - ... Error in ${source type} file '${source}': + [Arguments] ${index} ${name} ${lineno} ${source}=duplicate_user_keywords.robot + Error In File ${index} keywords/${source} ${lineno} ... Creating keyword '${name}' failed: ... Keyword with same name defined multiple times. - Check Log Message ${ERRORS[${index}]} ${message} ERROR 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..98ead1c7c55 --- /dev/null +++ b/atest/robot/keywords/dynamic_positional_only_args.robot @@ -0,0 +1,53 @@ +*** Settings *** +Suite Setup Run Tests ${EMPTY} keywords/dynamic_positional_only_args.robot +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} + +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/robot/keywords/embedded_arguments.robot b/atest/robot/keywords/embedded_arguments.robot index 6f9135d7d43..328e5a43a4a 100644 --- a/atest/robot/keywords/embedded_arguments.robot +++ b/atest/robot/keywords/embedded_arguments.robot @@ -5,34 +5,70 @@ Resource atest_resource.robot *** Test Cases *** Embedded Arguments In User Keyword Name ${tc} = Check Test Case ${TEST NAME} - Check Log Message ${tc.kws[0].kws[0].msgs[0]} This is always executed - Check Keyword Data ${tc.kws[0]} User Peke Selects Advanced Python From Webshop \${name}, \${book} - Check Log Message ${tc.kws[2].kws[0].msgs[0]} This is always executed - Check Keyword Data ${tc.kws[2]} User Juha Selects Playboy From Webshop \${name}, \${book} + Check Log Message ${tc[0, 0, 0]} This is always executed + Check Keyword Data ${tc[0]} User Peke Selects Advanced Python From Webshop \${name}, \${book} + Check Log Message ${tc[2, 0, 0]} This is always executed + Check Keyword Data ${tc[2]} User Juha Selects Playboy From Webshop \${name}, \${book} + File Should Contain ${OUTFILE} name="User Peke Selects Advanced Python From Webshop" + File Should Contain ${OUTFILE} source_name="User \${user} Selects \${item} From Webshop" + File Should Not Contain ${OUTFILE} source_name="Log" + +Embedded arguments with type conversion + [Documentation] This is tested more thorougly in 'variables/variable_types.robot'. + Check Test Case ${TEST NAME} 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! + Check Log Message ${tc[0, 0, 0]} feature-works + Check Log Message ${tc[1, 0, 0]} test case-is *executed* + Check Log Message ${tc[2, 0, 0]} issue-is about to be done! + 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} - Check Keyword Data ${tc.kws[0]} Given user x selects y from webshop - Check Keyword Data ${tc.kws[1]} When user x selects y from webshop - Check Keyword Data ${tc.kws[2]} Then user x selects y from webshop \${x}, \${y} + Check Keyword Data ${tc[0]} Given user x selects y from webshop + Check Keyword Data ${tc[1]} When user x selects y from webshop + Check Keyword Data ${tc[2]} 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} 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} source_name="My embedded \${var}" + File Should Not Contain ${OUTFILE} source_name="Log" Embedded Arguments as Variables ${tc} = Check Test Case ${TEST NAME} - Check Keyword Data ${tc.kws[0]} User \${42} Selects \${EMPTY} From Webshop \${name}, \${item} - Check Keyword Data ${tc.kws[2]} User \${name} Selects \${SPACE * 10} From Webshop \${name}, \${item} + Check Keyword Data ${tc[0]} User \${42} Selects \${EMPTY} From Webshop \${name}, \${item} + Check Keyword Data ${tc[2]} User \${name} Selects \${SPACE * 100}[:10] From Webshop \${name}, \${item} + File Should Contain ${OUTFILE} name="User \${42} Selects \${EMPTY} From Webshop" + File Should Contain ${OUTFILE} name="User \${name} Selects \${SPACE * 100}[:10] From Webshop" + File Should Contain ${OUTFILE} source_name="User \${user} Selects \${item} From Webshop" + File Should Not Contain ${OUTFILE} source_name="Log"> + +Embedded arguments as variables and other content + ${tc} = Check Test Case ${TEST NAME} + Check Keyword Data ${tc[0]} User \${foo}\${EMPTY}\${bar} Selects \${foo}, \${bar} and \${zap} From Webshop \${name}, \${item} + +Embedded arguments as variables containing characters that exist also in keyword name + Check Test Case ${TEST NAME} + +Embedded Arguments as List And Dict Variables + ${tc} = Check Test Case ${TEST NAME} + Check Keyword Data ${tc[1]} User \@{i1} Selects \&{i2} From Webshop \${o1}, \${o2} Non-Existing Variable in Embedded Arguments ${tc} = Check Test Case ${TEST NAME} - Check Keyword Data ${tc.kws[0]} User \${non existing} Selects \${variables} From Webshop status=FAIL + Check Keyword Data ${tc[0]} User \${non existing} Selects \${variables} From Webshop status=FAIL + +Invalid List Variable as Embedded Argument + Check Test Case ${TEST NAME} + +Invalid Dict Variable as Embedded Argument + Check Test Case ${TEST NAME} Non-Existing Variable in Embedded Arguments and Positional Arguments Check Test Case ${TEST NAME} @@ -52,36 +88,50 @@ Custom Regexp With Escape Chars Grouping Custom Regexp Check Test Case ${TEST NAME} +Custom Regex With Leading And Trailing Spaces + Check Test Case ${TEST NAME} + Custom Regexp Matching Variables Check Test Case ${TEST NAME} -Custom Regexp Matching Variables When Regexp Does No Match Them +Custom regexp with inline Python evaluation + Check Test Case ${TEST NAME} + +Non Matching Variable Is Accepted With Custom Regexp (But Not For Long) + ${tc} = Check Test Case ${TEST NAME} + Check Log Message ${tc[0, 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) + ${tc} = Check Test Case ${TEST NAME} + Check Log Message ${tc[0, 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[0, 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} -Regexp Extensions Are Not Supported +Custom regexp with inline flag Check Test Case ${TEST NAME} - Creating Keyword Failed 1 - ... 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 + Creating Keyword Failed 0 350 ... Invalid \${x:(} Regexp ... Compiling embedded arguments regexp failed: * - ... pattern=yes Escaping Values Given As Embedded Arguments ${tc} = Check Test Case ${TEST NAME} - Check Keyword Data ${tc.kws[0]} User \\\${nonex} Selects \\\\ From Webshop \${name}, \${item} - Check Keyword Data ${tc.kws[2]} User \\ Selects \\ \\ From Webshop \${name}, \${item} + Check Keyword Data ${tc[0]} User \\\${nonex} Selects \\\\ From Webshop \${name}, \${item} + Check Keyword Data ${tc[2]} User \\ Selects \\ \\ From Webshop \${name}, \${item} Embedded Arguments Syntax Is Case Insensitive ${tc} = Check Test Case ${TEST NAME} - Check Keyword Data ${tc.kws[0]} x Gets y From The z - Check Keyword Data ${tc.kws[1]} x gets y from the z - Check Keyword Data ${tc.kws[2]} x GETS y from the z - Check Keyword Data ${tc.kws[3]} x gets y FROM THE z + Check Keyword Data ${tc[0]} x Gets y From The z + Check Keyword Data ${tc[1]} x gets y from the z + Check Keyword Data ${tc[2]} x GETS y from the z + Check Keyword Data ${tc[3]} x gets y FROM THE z Embedded Arguments Syntax is Space Sensitive Check Test Case ${TEST NAME} @@ -91,48 +141,51 @@ Embedded Arguments Syntax is Underscore Sensitive Embedded Arguments In Resource File ${tc} = Check Test Case ${TEST NAME} - Check Keyword Data ${tc.kws[0]} embedded_args_in_uk_1.Juha Uses Resource File \${ret} + Check Keyword Data ${tc[0]} embedded_args_in_uk_1.Juha Uses Resource File \${ret} 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} + Check Keyword Data ${tc[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} -Creating keyword with both normal and embedded arguments fails - Creating Keyword Failed 0 - ... Keyword with \${embedded} and normal args is invalid - ... Keyword cannot have both normal and embedded arguments. +Keyword with both embedded and normal arguments + ${tc} = Check Test Case ${TEST NAME} + Check Log message ${tc[0, 0, 0]} 2 horses are walking + Check Log message ${tc[1, 0, 0]} 2 horses are swimming + Check Log message ${tc[2, 0, 0]} 3 dogs are walking + +Keyword with both embedded and normal arguments with too few arguments Check Test Case ${TEST NAME} Keyword matching multiple keywords in test case file ${tc} = Check Test Case ${TEST NAME} - Check Log Message ${tc.kws[0].kws[0].msgs[0]} foo+tc+bar - Check Log Message ${tc.kws[1].kws[0].msgs[0]} foo-tc-bar - Check Log Message ${tc.kws[2].kws[0].msgs[0]} foo+tc+bar+tc+zap + Check Log Message ${tc[0, 0, 0]} foo+tc+bar + Check Log Message ${tc[1, 0, 0]} foo-tc-bar + Check Log Message ${tc[2, 0, 0]} foo+tc+bar+tc+zap Keyword matching multiple keywords in one resource file ${tc} = Check Test Case ${TEST NAME} - Check Log Message ${tc.kws[0].kws[0].msgs[0]} foo+r1+bar - Check Log Message ${tc.kws[1].kws[0].msgs[0]} foo-r1-bar + Check Log Message ${tc[0, 0, 0]} foo+r1+bar + Check Log Message ${tc[1, 0, 0]} foo-r1-bar Keyword matching multiple keywords in different resource files ${tc} = Check Test Case ${TEST NAME} - Check Log Message ${tc.kws[0].kws[0].msgs[0]} foo-r1-bar - Check Log Message ${tc.kws[1].kws[0].msgs[0]} foo-r2-bar + Check Log Message ${tc[0, 0, 0]} foo-r1-bar + Check Log Message ${tc[1, 0, 0]} foo-r2-bar Keyword matching multiple keywords in one and different resource files Check Test Case ${TEST NAME} Same name with different regexp works ${tc} = Check Test Case ${TEST NAME} - Check Log Message ${tc.kws[0].kws[0].msgs[0]} a car - Check Log Message ${tc.kws[1].kws[0].msgs[0]} a dog - Check Log Message ${tc.kws[2].kws[0].msgs[0]} a cow + Check Log Message ${tc[0, 0, 0]} a car + Check Log Message ${tc[1, 0, 0]} a dog + Check Log Message ${tc[2, 0, 0]} a cow Same name with different regexp matching multiple fails Check Test Case ${TEST NAME} @@ -145,9 +198,6 @@ Match all allowed *** Keywords *** Creating Keyword Failed - [Arguments] ${index} ${name} ${error} ${pattern}= - ${source} = Normalize Path ${DATADIR}/keywords/embedded_arguments.robot - ${message} = Catenate - ... Error in test case file '${source}': + [Arguments] ${index} ${lineno} ${name} ${error} + Error In File ${index} keywords/embedded_arguments.robot ${lineno} ... Creating keyword '${name}' failed: ${error} - Check Log Message ${ERRORS[${index}]} ${message} ERROR pattern=${pattern} diff --git a/atest/robot/keywords/embedded_arguments_conflicts.robot b/atest/robot/keywords/embedded_arguments_conflicts.robot new file mode 100644 index 00000000000..68779178bf0 --- /dev/null +++ b/atest/robot/keywords/embedded_arguments_conflicts.robot @@ -0,0 +1,86 @@ +*** 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} + +Search order wins over best match in resource + Check Test Case ${TESTNAME} + +Search order resolves conflict with libraries + Check Test Case ${TESTNAME} + +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} + +Match in same resource wins over better match elsewhere + Check Test Case ${TESTNAME} + +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/robot/keywords/embedded_arguments_library_keywords.robot b/atest/robot/keywords/embedded_arguments_library_keywords.robot index c4b77e75dc7..67da8ca77ce 100755 --- a/atest/robot/keywords/embedded_arguments_library_keywords.robot +++ b/atest/robot/keywords/embedded_arguments_library_keywords.robot @@ -1,40 +1,73 @@ *** Settings *** -Suite Setup Run Tests ${EMPTY} keywords/embedded_arguments_library_keywords.robot +Suite Setup Run Tests ${EMPTY} keywords/embedded_arguments_library_keywords.robot Resource atest_resource.robot *** Test Cases *** Embedded Arguments In Library Keyword Name ${tc} = Check Test Case ${TEST NAME} - Check Log Message ${tc.kws[0].msgs[0]} This is always executed - Check Keyword Data ${tc.kws[0]} embedded_args_in_lk_1.User Peke Selects Advanced Python From Webshop \${name}, \${book} - Check Log Message ${tc.kws[2].msgs[0]} This is always executed - Check Keyword Data ${tc.kws[2]} embedded_args_in_lk_1.User Juha selects Playboy from webshop \${name}, \${book} + Check Log Message ${tc[0, 0]} This is always executed + Check Keyword Data ${tc[0]} embedded_args_in_lk_1.User Peke Selects Advanced Python From Webshop \${name}, \${book} + Check Log Message ${tc[2, 0]} This is always executed + Check Keyword Data ${tc[2]} embedded_args_in_lk_1.User Juha selects Playboy from webshop \${name}, \${book} + File Should Contain ${OUTFILE} name="User Peke Selects Advanced Python From Webshop" + 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" 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! + Check Log Message ${tc[0, 0]} feature-works + Check Log Message ${tc[1, 0]} test case-is *executed* + Check Log Message ${tc[2, 0]} issue-is about to be done! + 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} - Check Keyword Data ${tc.kws[0]} embedded_args_in_lk_1.Given user x selects y from webshop - 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} + Check Keyword Data ${tc[0]} embedded_args_in_lk_1.Given user x selects y from webshop + Check Keyword Data ${tc[1]} embedded_args_in_lk_1.When user x selects y from webshop + Check Keyword Data ${tc[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} 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} 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} owner="embedded_args_in_lk_1" + File Should Contain ${OUTFILE} source_name="User \${user} Selects \${item} From Webshop" + File Should Contain ${OUTFILE} name="User \${name} Selects \${SPACE * 100}[:10] From Webshop" + File Should Not Contain ${OUTFILE} source_name="Log" + +Embedded arguments as variables and other content + ${tc} = Check Test Case ${TEST NAME} + Check Keyword Data ${tc[0]} embedded_args_in_lk_1.User \${foo}\${EMPTY}\${bar} Selects \${foo}, \${bar} and \${zap} From Webshop \${name}, \${item} + +Embedded arguments as variables containing characters in keyword name + Check Test Case ${TEST NAME} + +Embedded Arguments as List And Dict Variables + ${tc} = Check Test Case ${TEST NAME} + Check Keyword Data ${tc[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} - Check Keyword Data ${tc.kws[0]} embedded_args_in_lk_1.User \${non existing} Selects \${variables} From Webshop status=FAIL + Check Keyword Data ${tc[0]} embedded_args_in_lk_1.User \${non existing} Selects \${variables} From Webshop status=FAIL Custom Embedded Argument Regexp Check Test Case ${TEST NAME} +Custom regexp with inline flags + Check Test Case ${TEST NAME} + Custom Regexp With Curly Braces Check Test Case ${TEST NAME} @@ -47,7 +80,22 @@ Grouping Custom Regexp Custom Regexp Matching Variables Check Test Case ${TEST NAME} -Custom Regexp Matching Variables When Regexp Does No Match Them +Custom regexp with inline Python evaluation + Check Test Case ${TEST NAME} + +Non Matching Variable Is Accepted With Custom Regexp (But Not For Long) + ${tc} = Check Test Case ${TEST NAME} + Check Log Message ${tc[0, 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) + ${tc} = Check Test Case ${TEST NAME} + Check Log Message ${tc[0, 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[0, 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} Embedded Arguments Syntax is Space Sensitive @@ -58,40 +106,70 @@ Embedded Arguments Syntax is Underscore Sensitive Keyword matching multiple keywords in library file ${tc} = Check Test Case ${TEST NAME} - Check Log Message ${tc.kws[0].msgs[0]} foo+lib+bar - Check Log Message ${tc.kws[1].msgs[0]} foo-lib-bar - Check Log Message ${tc.kws[2].msgs[0]} foo+lib+bar+lib+zap + Check Log Message ${tc[0, 0]} foo+lib+bar + Check Log Message ${tc[1, 0]} foo-lib-bar + Check Log Message ${tc[2, 0]} foo+lib+bar+lib+zap 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[0, 0]} 2 horses are walking + Check Log message ${tc[1, 0]} 2 horses are swimming + Check Log message ${tc[2, 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} - ${msg} = Catenate - ... Adding keyword 'Wrong \${number} of embedded \${args}' to library 'embedded_args_in_lk_1' failed: - ... Embedded argument count does not match number of accepted arguments. - Check Log Message ${ERRORS[0]} ${msg} ERROR + Error in library embedded_args_in_lk_1 + ... Adding keyword 'Wrong \${number} of embedded \${args}' failed: + ... Keyword must accept at least as many positional arguments as it has embedded arguments. + ... index=2 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} + +Lists are not expanded when keyword accepts varargs Check Test Case ${TESTNAME} Same name with different regexp works ${tc} = Check Test Case ${TEST NAME} - Check Log Message ${tc.kws[0].msgs[0]} a car - Check Log Message ${tc.kws[1].msgs[0]} a dog - Check Log Message ${tc.kws[2].msgs[0]} a cow + Check Log Message ${tc[0, 0]} a car + Check Log Message ${tc[1, 0]} a dog + Check Log Message ${tc[2, 0]} a cow Same name with different regexp matching multiple fails Check Test Case ${TEST NAME} Same name with same regexp fails Check Test Case ${TEST NAME} + +Embedded arguments cannot have type information + Check Test Case ${TEST NAME} + Error in library embedded_args_in_lk_1 + ... Adding keyword 'Embedded \${arg: int} with type is not supported' failed: + ... Library keywords do not support type information with embedded arguments like '\${arg: int}'. + ... Use type hints with function arguments instead. + ... index=1 + +Embedded type can nevertheless be invalid + Check Test Case ${TEST NAME} + Error in library embedded_args_in_lk_1 + ... Adding keyword 'embedded_types_can_be_invalid' failed: + ... Invalid embedded argument '\${invalid: bad}': Unrecognized type 'bad'. + ... index=0 diff --git a/atest/robot/keywords/java_argument_type_coercion.robot b/atest/robot/keywords/java_argument_type_coercion.robot deleted file mode 100644 index f4afb50ad6a..00000000000 --- a/atest/robot/keywords/java_argument_type_coercion.robot +++ /dev/null @@ -1,23 +0,0 @@ -*** Settings *** -Suite Setup Run Tests ${EMPTY} keywords/java_argument_type_coercion.robot -Force Tags require-jython -Resource atest_resource.robot - -*** Test Cases *** -Coercing Integer Arguments - Check Test Case ${TESTNAME} - -Coercing Boolean Arguments - Check Test Case ${TESTNAME} - -Coercing Real Number Arguments - Check Test Case ${TESTNAME} - -Coercing Multiple Arguments - Check Test Case ${TESTNAME} - -Coercing Fails With Conflicting Signatures - Check Test Case ${TESTNAME} - -It Is Possible To Coerce Only Some Arguments - Check Test Case ${TESTNAME} diff --git a/atest/robot/keywords/java_arguments.robot b/atest/robot/keywords/java_arguments.robot deleted file mode 100644 index 6c63b35f530..00000000000 --- a/atest/robot/keywords/java_arguments.robot +++ /dev/null @@ -1,98 +0,0 @@ -*** Settings *** -Documentation Handling valid and invalid arguments with Java keywords. -... Related tests also in test_libraries/java_libraries.robot. -Suite Setup Run Tests ${EMPTY} keywords/java_arguments.robot -Force Tags require-jython -Resource atest_resource.robot - -*** Test Cases *** -Correct Number Of Arguments When No Defaults Or Varargs - Check Test Case ${TESTNAME} - -Too Few Arguments When No Defaults Or Varargs - Check Test Case ${TESTNAME} 1 - Check Test Case ${TESTNAME} 2 - -Too Many Arguments When No Defaults Or Varargs - Check Test Case ${TESTNAME} 1 - Check Test Case ${TESTNAME} 2 - Check Test Case ${TESTNAME} 3 - -Correct Number Of Arguments With Defaults - Check Test Case ${TESTNAME} - -Too Few Arguments With Defaults - Check Test Case ${TESTNAME} - -Too Many Arguments With Defaults - Check Test Case ${TESTNAME} 1 - Check Test Case ${TESTNAME} 2 - -Correct Number Of Arguments With Varargs - Check Test Case ${TESTNAME} - -Java Varargs Should Work - Check Test Case ${TESTNAME} - -Too Few Arguments With Varargs - Check Test Case ${TESTNAME} - -Too Few Arguments With Varargs List - Check Test Case ${TESTNAME} - -Varargs Work Also With Arrays - [Documentation] Make sure varargs support doesn't make it impossible to used Java arrays and Python lists with Java keyword expecting arrays. - Check Test Case ${TESTNAME} - -Varargs Work Also With Lists - [Documentation] Make sure varargs support doesn't make it impossible to used Java arrays and Python lists with Java keyword expecting arrays. - Check Test Case ${TESTNAME} - -Kwargs - Check Test Case ${TESTNAME} - -Normal and Kwargs - Check Test Case ${TESTNAME} - -Varargs and Kwargs - Check Test Case ${TESTNAME} - -All args - Check Test Case ${TESTNAME} - -Too many positional with kwargs - Check Test Case ${TESTNAME} 1 - Check Test Case ${TESTNAME} 2 - -Java kwargs wont be interpreted as values for positional arguments - Check Test Case ${TESTNAME} - -Map can be given as an argument still - Check Test Case ${TESTNAME} - -Dict can be given as an argument still - Check Test Case ${TESTNAME} - -Hashmap is not kwargs - Check Test Case ${TESTNAME} - -Valid Arguments For Keyword Expecting Non String Scalar Arguments - Check Test Case ${TESTNAME} - -Valid Arguments For Keyword Expecting Non String Array Arguments - Check Test Case ${TESTNAME} - -Valid Arguments For Keyword Expecting Non String List Arguments - Check Test Case ${TESTNAME} - -Invalid Argument Types - Check Test Case ${TESTNAME} 1 - Check Test Case ${TESTNAME} 2 - Check Test Case ${TESTNAME} 3 - Check Test Case ${TESTNAME} 4 - Check Test Case ${TESTNAME} 5 - Check Test Case ${TESTNAME} 6 - Check Test Case ${TESTNAME} 7 - -Calling Using List Variables - Check Test Case ${TESTNAME} diff --git a/atest/robot/keywords/keyword_documentation.robot b/atest/robot/keywords/keyword_documentation.robot index 8cc336b170e..2ae9f37eeba 100644 --- a/atest/robot/keywords/keyword_documentation.robot +++ b/atest/robot/keywords/keyword_documentation.robot @@ -26,4 +26,4 @@ Multiline documentation with split short doc Verify Documentation [Arguments] ${doc} ${test}=${TEST NAME} ${tc} = Check Test Case ${test} - Should Be Equal ${tc.kws[0].doc} ${doc} + Should Be Equal ${tc[0].doc} ${doc} diff --git a/atest/robot/keywords/keyword_names.robot b/atest/robot/keywords/keyword_names.robot index 8eed5b17f1e..dccf6c6afa7 100644 --- a/atest/robot/keywords/keyword_names.robot +++ b/atest/robot/keywords/keyword_names.robot @@ -20,38 +20,38 @@ 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.keywords[0]} Using Test Case File User Keywords Keyword Only In Test Case File - Should Be Equal ${test.keywords[1].name} Using Test Case File User Keywords Nested - Check Name and Three Keyword Names ${test.keywords[1].keywords[0]} Using Test Case File User Keywords Keyword Only In Test Case File - Check Name and Three Keyword Names ${test.keywords[1].keywords[1]} Using Test Case File User Keywords Keyword Only In Test Case File + Check Name and Three Keyword Names ${test[0]} Using Test Case File User Keywords Keyword Only In Test Case File + Should Be Equal ${test[1].full_name} Using Test Case File User Keywords Nested + Check Name and Three Keyword Names ${test[1, 0]} Using Test Case File User Keywords Keyword Only In Test Case File + Check Name and Three Keyword Names ${test[1, 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.keywords[0]} Using Resource File User Keywords my_resource_1.Keyword Only In Resource 1 - Should Be Equal ${test.keywords[1].name} Using Resource File User Keywords Nested - Check Name and Three Keyword Names ${test.keywords[1].keywords[0]} Using Resource File User Keywords my_resource_1.Keyword Only In Resource 1 - Check Name and Three Keyword Names ${test.keywords[1].keywords[1]} Using Resource File User Keywords my_resource_1.Keyword Only In Resource 1 + Check Name and Three Keyword Names ${test[0]} Using Resource File User Keywords my_resource_1.Keyword Only In Resource 1 + Should Be Equal ${test[1].full_name} Using Resource File User Keywords Nested + Check Name and Three Keyword Names ${test[1, 0]} Using Resource File User Keywords my_resource_1.Keyword Only In Resource 1 + Check Name and Three Keyword Names ${test[1, 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.keywords[0]} Using Base Keywords MyLibrary1.Keyword Only In Library 1 - Should Be Equal ${test.keywords[1].name} Using Base Keywords Nested - Check Name and Three Keyword Names ${test.keywords[1].keywords[0]} Using Base Keywords MyLibrary1.Keyword Only In Library 1 - Check Name and Three Keyword Names ${test.keywords[1].keywords[1]} Using Base Keywords MyLibrary1.Keyword Only In Library 1 + Check Name and Three Keyword Names ${test[0]} Using Base Keywords MyLibrary1.Keyword Only In Library 1 + Should Be Equal ${test[1].full_name} Using Base Keywords Nested + Check Name and Three Keyword Names ${test[1, 0]} Using Base Keywords MyLibrary1.Keyword Only In Library 1 + Check Name and Three Keyword Names ${test[1, 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.keywords[0].name} my_resource_1.Using Test Case File User Keywords In Resource - Check Name and Three Keyword Names ${test.keywords[0].keywords[0]} Using Test Case File User Keywords Keyword Only In Test Case File + Should Be Equal ${test[0].full_name} my_resource_1.Using Test Case File User Keywords In Resource + Check Name and Three Keyword Names ${test[0, 0]} Using Test Case File User Keywords Keyword Only In Test Case File Resource File User Keyword Names In Resource File User Keyword ${test} = Check Test Case Resource File User Keyword Names In Resource File User Keyword - Check Name and Three Keyword Names ${test.keywords[0]} my_resource_1.Using Resource File User Keywords In Resource 1 my_resource_1.Keyword Only In Resource 1 - Check Name and Three Keyword Names ${test.keywords[1]} my_resource_1.Using Resource File User Keywords In Resource 2 my_resource_2.Keyword Only In Resource 2 + Check Name and Three Keyword Names ${test[0]} my_resource_1.Using Resource File User Keywords In Resource 1 my_resource_1.Keyword Only In Resource 1 + Check Name and Three Keyword Names ${test[1]} my_resource_1.Using Resource File User Keywords In Resource 2 my_resource_2.Keyword Only In Resource 2 Base Keyword Names In Resource File User Keyword ${test} = Check Test Case Base Keyword Names In Resource File User Keyword - Check Name and Three Keyword Names ${test.keywords[0]} my_resource_1.Using Base Keywords In Resource MyLibrary1.Keyword Only In Library 1 + Check Name and Three Keyword Names ${test[0]} my_resource_1.Using Base Keywords In Resource MyLibrary1.Keyword Only In Library 1 User Keyword Name Containing Dots Check Test And Three Keyword Names User Keyword Name Containing Dots User Keyword.Name @@ -61,50 +61,52 @@ 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 - Check Log Message ${tc.kws[0].msgs[0]} My name was set using 'robot_name' attribute! + Should Be Equal ${tc[0].full_name} MyLibrary1.Name set using 'robot_name' attribute + Check Log Message ${tc[0, 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 - Check Log Message ${tc.kws[0].msgs[0]} My name was set using 'robot.api.deco.keyword' decorator! + Should Be Equal ${tc[0].full_name} MyLibrary1.Name set using 'robot.api.deco.keyword' decorator + Check Log Message ${tc[0, 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[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[0].full_name} MyLibrary1.No Custom Name Given 1 + Should Be Equal ${tc[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 - 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 - Check Log Message ${tc.kws[1].msgs[0]} Real name set by @keyword + Should Be Equal ${tc[0].full_name} MyLibrary1.I Start With An Underscore And I Am Ok + Check Log Message ${tc[0, 0]} I'm marked with @keyword + Should Be Equal ${tc[1].full_name} MyLibrary1.Function name can be whatever + Check Log Message ${tc[1, 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[0]} BuiltIn.Log args=No assignment + Check Keyword Data ${tc[1]} BuiltIn.Set Variable assign=\${var} args=value + Check Keyword Data ${tc[2]} BuiltIn.Set Variable assign=\${v1}, \${v2} args=1, 2 + Check Keyword Data ${tc[3]} BuiltIn.Evaluate assign=\${first}, \@{rest} args=range(10) Library name and keyword name are separate ${tc} = Check Test Case ${TESTNAME} - Keyword and library names should be ${tc.kws[0]} Keyword Only In Test Case File - Keyword and library names should be ${tc.kws[1]} Keyword Only In Resource 1 my_resource_1 - Keyword and library names should be ${tc.kws[2]} Keyword Only In Resource 1 my_resource_1 - Keyword and library names should be ${tc.kws[3]} Log BuiltIn - Keyword and library names should be ${tc.kws[4]} Log BuiltIn + Keyword and library names should be ${tc[0]} Keyword Only In Test Case File + Keyword and library names should be ${tc[1]} Keyword Only In Resource 1 my_resource_1 + Keyword and library names should be ${tc[2]} Keyword Only In Resource 1 my_resource_1 + Keyword and library names should be ${tc[3]} Log BuiltIn + Keyword and library names should be ${tc[4]} Log BuiltIn Empty keyword name is not allowed - Check log message ${ERRORS[0]} Adding keyword '__' to library 'MyLibrary1' failed: Keyword name cannot be empty. ERROR + Error in library MyLibrary1 + ... Adding keyword '__' failed: + ... Keyword name cannot be empty. *** Keywords *** Check Test And Three Keyword Names @@ -114,25 +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.keywords[0].name} ${exp_kw_name} - Should Be Equal ${item.keywords[1].name} ${exp_kw_name} - Should Be Equal ${item.keywords[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[0].full_name} ${exp_kw_name} + Should Be Equal ${item[1].full_name} ${exp_kw_name} + Should Be Equal ${item[2].full_name} ${exp_kw_name} Keyword and library names should be - [Arguments] ${kw} ${kwname} ${libname}= - Should Be Equal ${kw.kwname} ${kwname} - Should Be Equal ${kw.libname} ${libname} - Run Keyword If "${libname}" - ... Should Be Equal ${kw.name} ${libname}.${kwname} - ... ELSE - ... Should Be Equal ${kw.name} ${kwname} + [Arguments] ${kw} ${kwname} ${libname}=${None} + Should Be Equal ${kw.name} ${kwname} + Should Be Equal ${kw.owner} ${libname} + IF $libname is None + Should Be Equal ${kw.full_name} ${kwname} + ELSE + Should Be Equal ${kw.full_name} ${libname}.${kwname} + END diff --git a/atest/robot/keywords/keyword_namespaces.robot b/atest/robot/keywords/keyword_namespaces.robot index 082ec10f77e..b18f7f4fd36 100644 --- a/atest/robot/keywords/keyword_namespaces.robot +++ b/atest/robot/keywords/keyword_namespaces.robot @@ -25,43 +25,75 @@ 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 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 8.0. + Check Log Message ${tc[0, 0, 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[0, 0, 0, 0]} Keyword in resource 1 + Check Log Message ${tc[1, 0, 0, 0]} Keyword in resource 2 + +Search order has precedence over local keyword in resource file + ${tc} = Check Test Case ${TEST NAME} + Check Log Message ${tc[0, 0, 0, 0]} Keyword in resource 1 + Check Log Message ${tc[1, 0, 0, 0]} Keyword in resource 1 + 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[0]} Comment BuiltIn + Verify Override Message ${ERRORS}[3] ${tc[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[1]} BuiltIn.Comment args=Used from BuiltIn + Verify Override Message ${ERRORS}[4] ${tc[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[1]} MyLibrary1.Comment + Check Log Message ${tc[1, 0]} Overrides keyword from BuiltIn library + Check Keyword Data ${tc[2]} MyLibrary1.Copy Directory + Check Log Message ${tc[2, 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[3]} ${tc.kws[0].msgs[0]} Replace String + Verify Override Message ${ERRORS}[5] ${tc[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 Check Test Case ${TEST NAME} - Check Stderr Does Not Contain Run Keyword If + Stderr Should Not Contain Run Keyword If Keyword In More Than One Custom Library And Standard Library Check Test Case ${TEST NAME} - Check Syslog Does Not Contain BuiltIn.No Operation + Syslog Should Not Contain BuiltIn.No Operation Keywords are first searched from test case file even if they contain dot ${tc} = Check Test Case ${TESTNAME} - Check log message ${tc.kws[0].kws[0].msgs[0]} Keyword in test case file overriding keyword in my_resource_1 - Check log message ${tc.kws[0].kws[1].kws[0].msgs[0]} Keyword in resource 1 - Check log message ${tc.kws[1].kws[0].msgs[0]} Keyword in test case file overriding keyword in BuiltIn - Check log message ${tc.kws[1].kws[1].msgs[0]} Using keyword in test case file here! + Check log message ${tc[0, 0, 0]} Keyword in test case file overriding keyword in my_resource_1 + Check log message ${tc[0, 1, 0, 0]} Keyword in resource 1 + Check log message ${tc[1, 0, 0]} Keyword in test case file overriding keyword in BuiltIn + Check log message ${tc[1, 1, 0]} Using keyword in test case file here! *** 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 test 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[0]} ${expected} WARN + Check Log Message ${kw[1]} Overrides keyword from ${standard} library diff --git a/atest/robot/keywords/keyword_recommendations.robot b/atest/robot/keywords/keyword_recommendations.robot index 5bbec04516d..5de5e8ced00 100644 --- a/atest/robot/keywords/keyword_recommendations.robot +++ b/atest/robot/keywords/keyword_recommendations.robot @@ -78,9 +78,6 @@ Misspelled Keyword Spacing Misspelled Keyword No Whitespace Check Test Case ${TESTNAME} -Keyword With Period - Check Test Case ${TESTNAME} - Keyword With Periods Check Test Case ${TESTNAME} @@ -112,9 +109,6 @@ Substring of Long Keyword Similar To Really Long Keyword Check Test Case ${TESTNAME} -Keyword With Arguments Without Correct Spacing - Check Test Case ${TESTNAME} - Misspelled Keyword With Arguments Check Test Case ${TESTNAME} @@ -163,5 +157,8 @@ Explicit Substring Of Many Keywords Implicit Substring Of Many Keywords Check Test Case ${TESTNAME} -Excluded Library +Missing separator between keyword and arguments + Check Test Case ${TESTNAME} + +Missing separator between keyword and arguments with multiple matches Check Test Case ${TESTNAME} diff --git a/atest/robot/keywords/keyword_tags.robot b/atest/robot/keywords/keyword_tags.robot index 3c6da231cc2..93d54b48f7a 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 Resource atest_resource.robot Test Template Keyword tags should be @@ -19,9 +19,9 @@ Library keyword tags with documentation and attribute Invalid library keyword tags [Template] NONE Check Test Case ${TESTNAME} - Check log message ${ERRORS[0]} - ... Adding keyword 'invalid_library_keyword_tags' to library 'LibraryWithKeywordTags' failed: Expected tags to be list-like, got integer. - ... level=ERROR + Error in library LibraryWithKeywordTags + ... Adding keyword 'invalid_library_keyword_tags' failed: + ... Expected tags to be list-like, got integer. User keyword tags with `[Tags]` setting first two words @@ -41,8 +41,25 @@ 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 + in doc in resource index=2 + +Keyword tags setting in test case file + first second + first own second index=1 + doc first in second index=2 + +Keyword tags setting in init file + in init kw=${SUITE.setup} + in init own kw=${SUITE.teardown} + *** Keywords *** Keyword tags should be - [Arguments] @{tags} - ${tc}= Check Test Case ${TESTNAME} - Lists should be equal ${tc.kws[0].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/robot/keywords/keywords_implemented_in_c.robot b/atest/robot/keywords/keywords_implemented_in_c.robot new file mode 100644 index 00000000000..99bd99a9164 --- /dev/null +++ b/atest/robot/keywords/keywords_implemented_in_c.robot @@ -0,0 +1,17 @@ +*** Settings *** +Suite Setup Run Tests ${EMPTY} keywords/keywords_implemented_in_c.robot +Resource atest_resource.robot + +*** Test Cases *** +Use with correct arguments + ${tc} = Check Test Case ${TEST NAME} + Check Log Message ${tc[-1, 0]} This is a bit weird ... + +Use with incorrect arguments + ${error} = Set Variable If ${INTERPRETER.is_pypy} or ${INTERPRETER.version_info} >= (3, 7) + ... Keyword 'KeywordsImplementedInC.Eq' expected 2 arguments, got 3. + ... STARTS: TypeError: + Check Test Case ${TEST NAME} FAIL ${error} + +Built-ins not set to attributes are not exposes + Check Test Case ${TEST NAME} diff --git a/atest/robot/keywords/named_args/dynamic_without_kwargs.robot b/atest/robot/keywords/named_args/dynamic_without_kwargs.robot index 04f35bd85c4..94c0e9d5590 100644 --- a/atest/robot/keywords/named_args/dynamic_without_kwargs.robot +++ b/atest/robot/keywords/named_args/dynamic_without_kwargs.robot @@ -22,6 +22,7 @@ Values with defaults can be omitted at the end Check Test Case ${TESTNAME} Values with defaults can be omitted in the middle + [Documentation] Default values are used to fill the gaps. Check Test Case ${TESTNAME} Non-string values diff --git a/atest/robot/keywords/named_args/variables_in_names.robot b/atest/robot/keywords/named_args/variables_in_names.robot index 80e372cea2b..9f71fc34264 100644 --- a/atest/robot/keywords/named_args/variables_in_names.robot +++ b/atest/robot/keywords/named_args/variables_in_names.robot @@ -32,3 +32,6 @@ Variable with non-string value as named arg name Variable with non-string value as kwargs name Check Test Case ${TESTNAME} + +Equal sign in variable name + Check Test Case ${TESTNAME} diff --git a/atest/robot/keywords/named_only_args/dynamic.robot b/atest/robot/keywords/named_only_args/dynamic.robot index 6c7fa8141a4..74e5de7db46 100644 --- a/atest/robot/keywords/named_only_args/dynamic.robot +++ b/atest/robot/keywords/named_only_args/dynamic.robot @@ -39,7 +39,6 @@ With other arguments Using kw-only arguments is not possible if 'run_keyword' accepts no kwargs Check Test Case ${TESTNAME} - ${message} = CATENATE - ... Adding keyword 'No kwargs' to library 'DynamicKwOnlyArgsWithoutKwargs' failed: - ... Too few 'run_keyword' method parameters for keyword-only arguments support. - Check Log Message ${ERRORS[0]} ${message} ERROR + Error In Library DynamicKwOnlyArgsWithoutKwargs + ... Adding keyword 'No kwargs' failed: + ... Too few 'run_keyword' method parameters to support named-only arguments. diff --git a/atest/robot/keywords/named_only_args/python.robot b/atest/robot/keywords/named_only_args/python.robot index 54c5569370c..4e98f48d46d 100644 --- a/atest/robot/keywords/named_only_args/python.robot +++ b/atest/robot/keywords/named_only_args/python.robot @@ -1,6 +1,5 @@ *** Settings *** Suite Setup Run Tests ${EMPTY} keywords/named_only_args/python.robot -Force Tags require-py3 Resource atest_resource.robot *** Test Cases *** diff --git a/atest/robot/keywords/optional_given_when_then.robot b/atest/robot/keywords/optional_given_when_then.robot index 50a5ac48259..4b1b88656f1 100644 --- a/atest/robot/keywords/optional_given_when_then.robot +++ b/atest/robot/keywords/optional_given_when_then.robot @@ -1,48 +1,91 @@ *** 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 *** 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[0].full_name} Given we don't drink too many beers + Should Be Equal ${tc[1].full_name} When we are in + Should Be Equal ${tc[2].full_name} But we don't drink too many beers + Should Be Equal ${tc[3].full_name} And time + Should Be Equal ${tc[4].full_name} Then we get this feature ready today + Should Be Equal ${tc[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[0].full_name} Given we are in Berlin city + Should Be Equal ${tc[1].full_name} When it does not rain + Should Be Equal ${tc[2].full_name} And we get this feature implemented + Should Be Equal ${tc[3].full_name} Then we go to walking tour + Should Be Equal ${tc[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[0].full_name} BuiltIn.Given Should Be Equal + Should Be Equal ${tc[1].full_name} BuiltIn.And Should Not Match + Should Be Equal ${tc[2].full_name} BuiltIn.But Should Match + Should Be Equal ${tc[3].full_name} BuiltIn.When set test variable + Should Be Equal ${tc[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[0].full_name} optional_given_when_then.Given Keyword Is In Resource File + Should Be Equal ${tc[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[0].full_name} GiveN we don't drink too many beers + Should Be Equal ${tc[1].full_name} and we don't drink too many beers + Should Be Equal ${tc[2].full_name} We don't drink too many beers + Should Be Equal ${tc[3].full_name} When time + Should Be Equal ${tc[4].full_name} Time + Should Be Equal ${tc[5].full_name} Then we are in Berlin city + Should Be Equal ${tc[6].full_name} we are in Berlin city +Only one prefix is processed + ${tc} = Check Test Case ${TEST NAME} + Should Be Equal ${tc[0].full_name} Given we are in Berlin city + Should Be Equal ${tc[1].full_name} but then we are in Berlin city + +First word of a keyword can be a prefix + ${tc} = Check Test Case ${TEST NAME} + Should Be Equal ${tc[0].full_name} Given the prefix is part of the keyword + +First word in a keyword can be an argument + ${tc} = Check Test Case ${TEST NAME} + Should Be Equal ${tc[0].full_name} Given we don't drink too many beers + Should Be Equal ${tc[1].full_name} Then Pekka drinks lonkero instead + Should Be Equal ${tc[2].full_name} and Miikka drinks water instead + Should Be Equal ${tc[3].full_name} Étant donné Miikka drinks water instead + +Localized prefixes + ${tc} = Check Test Case ${TEST NAME} + Should Be Equal ${tc[0].full_name} Oletetaan we don't drink too many beers + Should Be Equal ${tc[1].full_name} Kun we are in + Should Be Equal ${tc[2].full_name} mutta we don't drink too many beers + Should Be Equal ${tc[3].full_name} Ja time + Should Be Equal ${tc[4].full_name} Niin we get this feature ready today + Should Be Equal ${tc[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[0].full_name} Étant donné que multipart prefixes didn't work with RF 6.0 + Should Be Equal ${tc[1].full_name} Zakładając, że multipart prefixes didn't work with RF 6.0 + Should Be Equal ${tc[2].full_name} Diyelim ki multipart prefixes didn't work with RF 6.0 + Should Be Equal ${tc[3].full_name} Eğer ki multipart prefixes didn't work with RF 6.0 + Should Be Equal ${tc[4].full_name} O zaman multipart prefixes didn't work with RF 6.0 + Should Be Equal ${tc[5].full_name} В случай че multipart prefixes didn't work with RF 6.0 + Should Be Equal ${tc[6].full_name} Fie ca multipart prefixes didn't work with RF 6.0 + +Prefix being part of another prefix + ${tc} = Check Test Case ${TEST NAME} + Should Be Equal ${tc[0].full_name} Étant donné que l'utilisateur se trouve sur la page de connexion + Should Be Equal ${tc[1].full_name} étant Donné QUE l'utilisateur SE trouve sur la pAGe de connexioN + Should Be Equal ${tc[2].full_name} Étant donné que if multiple prefixes match, longest prefix wins + +Prefix must be followed by space + Check Test Case ${TEST NAME} diff --git a/atest/robot/keywords/positional_only_args.robot b/atest/robot/keywords/positional_only_args.robot new file mode 100644 index 00000000000..2644754cd9b --- /dev/null +++ b/atest/robot/keywords/positional_only_args.robot @@ -0,0 +1,29 @@ +*** Settings *** +Suite Setup Run Tests ${EMPTY} keywords/positional_only_args.robot +Resource atest_resource.robot + +*** Test Cases *** +Normal usage + Check Test Case ${TESTNAME} + +Default values + Check Test Case ${TESTNAME} + +Positional only value can contain '=' without it being considered named argument + Check Test Case ${TESTNAME} + +Name of positional only argument can be used with kwargs + Check Test Case ${TESTNAME} + +Type conversion + Check Test Case ${TESTNAME} + +Too few arguments + Check Test Case ${TESTNAME} 1 + Check Test Case ${TESTNAME} 2 + Check Test Case ${TESTNAME} 3 + +Too many arguments + Check Test Case ${TESTNAME} 1 + Check Test Case ${TESTNAME} 2 + Check Test Case ${TESTNAME} 3 diff --git a/atest/robot/keywords/private.robot b/atest/robot/keywords/private.robot new file mode 100644 index 00000000000..f8a13aef81c --- /dev/null +++ b/atest/robot/keywords/private.robot @@ -0,0 +1,56 @@ +*** Settings *** +Suite Setup Run Tests ${EMPTY} keywords/private.robot +Resource atest_resource.robot + +*** Test Cases *** +Valid Usage With Local Keyword + ${tc}= Check Test Case ${TESTNAME} + Length Should Be ${tc[0].body} 1 + +Invalid Usage With Local Keyword + ${tc}= Check Test Case ${TESTNAME} + Private Call Warning Should Be Private Keyword ${tc[0, 0]} ${ERRORS[0]} + Length Should Be ${tc[0].body} 2 + +Valid Usage With Resource Keyword + ${tc}= Check Test Case ${TESTNAME} + Length Should Be ${tc[0].body} 1 + +Invalid Usage With Resource Keyword + ${tc}= Check Test Case ${TESTNAME} + Private Call Warning Should Be private.Private Keyword In Resource ${tc[0, 0]} ${ERRORS[1]} + Length Should Be ${tc[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[0, 0, 0]} ${ERRORS[2]} + Length Should Be ${tc[0, 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[0, 0, 0, 0]} private.resource + Check Log Message ${tc[0, 1, 0, 0]} private.resource + +Search Order Has Precedence Over Local Private Keyword In Resource File + ${tc}= Check Test Case ${TESTNAME} + Check Log Message ${tc[0, 0, 0, 0]} private2.resource + +Imported Public Keyword Has Precedence Over Imported Private Keywords + ${tc}= Check Test Case ${TESTNAME} + Check Log Message ${tc[0, 0, 0]} private2.resource + Check Log Message ${tc[1, 0, 0, 0]} private2.resource + +If All Keywords Are Private Raise Multiple Keywords Found + Check Test Case ${TESTNAME} + +If More Than Two Keywords Are Public Raise Multiple Keywords Found + Check Test Case ${TESTNAME} + +*** 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 diff --git a/atest/robot/keywords/python_arguments.robot b/atest/robot/keywords/python_arguments.robot index 012ac434960..6e7d63f739b 100644 --- a/atest/robot/keywords/python_arguments.robot +++ b/atest/robot/keywords/python_arguments.robot @@ -42,9 +42,14 @@ Calling Using List Variables Check Test Case ${TESTNAME} Calling Using Annotations - [Tags] require-py3 Check Test Case ${TESTNAME} Calling Using Annotations With Defaults - [Tags] require-py3 + Check Test Case ${TESTNAME} + +Dummy decorator does not preserve arguments + Check Test Case ${TESTNAME} 1 + Check Test Case ${TESTNAME} 2 + +Decorator using functools.wraps preserves arguments Check Test Case ${TESTNAME} diff --git a/atest/robot/keywords/trace_log_keyword_arguments.robot b/atest/robot/keywords/trace_log_keyword_arguments.robot index b2a69012bb5..23523614e19 100644 --- a/atest/robot/keywords/trace_log_keyword_arguments.robot +++ b/atest/robot/keywords/trace_log_keyword_arguments.robot @@ -2,13 +2,6 @@ Suite Setup Run Tests --loglevel TRACE keywords/trace_log_keyword_arguments.robot Resource atest_resource.robot -*** Variables *** -${NON ASCII PY 2} "Hyv\\xe4\\xe4 'P\\xe4iv\\xe4\\xe4'\\n" -${NON ASCII PY 3} "Hyvää 'Päivää'\\n" -${OBJECT REPR PY 2} u'Circle is 360\\xb0, Hyv\\xe4\\xe4 \\xfc\\xf6t\\xe4, -... \\u0989\\u09c4 \\u09f0 \\u09fa \\u099f \\u09eb \\u09ea \\u09b9' -${OBJECT REPR PY 3} 'Circle is 360°, Hyvää üötä, \u0989\u09c4 \u09f0 \u09fa \u099f \u09eb \u09ea \u09b9' - *** Test Cases *** Only Mandatory Arguments Check Argument Value Trace @@ -44,6 +37,11 @@ Variable Number of Arguments ... \${mand}='mandatory' | \@{vargs}=[] ... 'mandatory' +Named only + Check Argument Value Trace + ... \${no1}='a' | \${no2}='b' + ... no1='a' | no2='b' + Kwargs Check Argument Value Trace ... \&{kwargs}={} @@ -53,8 +51,8 @@ Kwargs All args Check Argument Value Trace - ... \${positional}='1' | \@{varargs}=['2', '3'] | \&{kwargs}={'d': '4'} - ... '1' | '2' | '3' | d='4' + ... \${positional}='1' | \@{varargs}=['2', '3'] | \${named_only}='4' | \&{kwargs}={'free': '5'} + ... '1' | '2' | '3' | named_only='4' | free='5' Non String Object as Argument Check Argument Value Trace @@ -67,33 +65,36 @@ None as Argument Check UKW Default, LKW Default, UKW Varargs, and LKW Varargs None Non Ascii String as Argument - ${expected} = Set variable if ${INTERPRETER.is_py2} - ... ${NON ASCII PY 2} ${NON ASCII PY 3} - Check UKW Default, LKW Default, UKW Varargs, and LKW Varargs ${expected} + Check UKW Default, LKW Default, UKW Varargs, and LKW Varargs "Hyvää 'Päivää'\\n" Object With Unicode Repr as Argument - ${expected} = Set variable if ${INTERPRETER.is_py2} - ... ${OBJECT REPR PY 2} ${OBJECT REPR PY 3} - Check UKW Default, LKW Default, UKW Varargs, and LKW Varargs ${expected} + Check UKW Default, LKW Default, UKW Varargs, and LKW Varargs + ... 'Circle is 360°, Hyvää üötä, \u0989\u09c4 \u09f0 \u09fa \u099f \u09eb \u09ea \u09b9' 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].kws[0].msgs[0]} Arguments: [ 'a' | 'b' | 'c' | 'd' ] TRACE + Check Log Message ${tc[1, 0]} Arguments: [ '\${keyword name}' | '\@{VALUES}' ] TRACE + Check Log Message ${tc[1, 1, 0]} Arguments: [ 'a' | 'b' | 'c' | 'd' ] TRACE 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[0, 0]} Arguments: [ \${first}='foo' | \${second}=42 | \${what}='UK' ] TRACE + Check Log Message ${tc[1, 0]} Arguments: [ 'bar' | 'Embedded Arguments' ] TRACE + Check Log Message ${tc[2, 0]} Arguments: [ \${embedded}='embedded' | \${normal}='argument' ] TRACE + Check Log Message ${tc[3, 0]} Arguments: [ \${embedded}='embedded' | \${normal}='argument' ] TRACE + FOR ${kw} IN @{tc.body} + Check Log Message ${kw[-1]} Return: None TRACE + Length Should Be ${kw.messages} 2 + END *** Keywords *** Check Argument Value Trace [Arguments] @{expected} ${tc} = Check Test Case ${TEST NAME} ${length} = Get Length ${expected} - : FOR ${index} IN RANGE 0 ${length} - \ Check Log Message ${tc.kws[${index}].msgs[0]} - ... Arguments: [ @{expected}[${index}] ] TRACE + FOR ${index} IN RANGE 0 ${length} + Check Log Message ${tc[${index}, 0]} Arguments: [ ${expected}[${index}] ] TRACE + END Check UKW Default, LKW Default, UKW Varargs, and LKW Varargs [Arguments] ${value} diff --git a/atest/robot/keywords/trace_log_return_value.robot b/atest/robot/keywords/trace_log_return_value.robot index b7f98f86a07..acf1d128701 100644 --- a/atest/robot/keywords/trace_log_return_value.robot +++ b/atest/robot/keywords/trace_log_return_value.robot @@ -2,59 +2,38 @@ Suite Setup Run Tests --loglevel TRACE keywords/trace_log_return_value.robot Resource atest_resource.robot -*** Variables *** -${NON ASCII PY 2} "Hyv\\xe4\\xe4 'P\\xe4iv\\xe4\\xe4'\\n" -${NON ASCII PY 3} "Hyvää 'Päivää'\\n" -${OBJECT REPR PY 2} u'Circle is 360\\xb0, Hyv\\xe4\\xe4 \\xfc\\xf6t\\xe4, -... \\u0989\\u09c4 \\u09f0 \\u09fa \\u099f \\u09eb \\u09ea \\u09b9' -${OBJECT REPR PY 3} 'Circle is 360°, Hyvää üötä, \u0989\u09c4 \u09f0 \u09fa \u099f \u09eb \u09ea \u09b9' - *** Test Cases *** -Return from Userkeyword +Return from user keyword ${test} = Check Test Case ${TESTNAME} - Check Log Message ${test.kws[0].msgs[1]} Return: 'value' TRACE - Check Log Message ${test.kws[0].kws[0].msgs[1]} Return: 'value' TRACE + Check Log Message ${test[0, 3]} Return: 'value' TRACE + Check Log Message ${test[0, 1, 1]} Return: 'value' TRACE -Return from Library Keyword +Return from library keyword ${test} = Check Test Case ${TESTNAME} - Check Log Message ${test.kws[0].msgs[1]} Return: 'value' TRACE + Check Log Message ${test[0, 1]} Return: 'value' TRACE Return from Run Keyword ${test} = Check Test Case ${TESTNAME} - Check Log Message ${test.kws[0].msgs[1]} Return: 'value' TRACE - Check Log Message ${test.kws[0].kws[0].msgs[1]} Return: 'value' TRACE + Check Log Message ${test[0, 2]} Return: 'value' TRACE + Check Log Message ${test[0, 1, 1]} Return: 'value' TRACE -Return Non String Object +Return non-string value ${test} = Check Test Case ${TESTNAME} - Check Log Message ${test.kws[0].msgs[2]} Return: 1 TRACE + Check Log Message ${test[0, 2]} Return: 1 TRACE Return None ${test} = Check Test Case ${TESTNAME} - Check Log Message ${test.kws[0].msgs[1]} Return: None TRACE - -Return Non Ascii String - ${test} = Check Test Case ${TESTNAME} - ${expected} = Set variable if ${INTERPRETER.is_py2} - ... ${NON ASCII PY 2} ${NON ASCII PY 3} - Check Log Message ${test.kws[0].msgs[1]} Return: ${expected} TRACE + Check Log Message ${test[0, 1]} Return: None TRACE -Return Object With Unicode Repr +Return non-ASCII string ${test} = Check Test Case ${TESTNAME} - ${expected} = Set variable if ${INTERPRETER.is_py2} - ... ${OBJECT REPR PY 2} ${OBJECT REPR PY 3} - Check Log Message ${test.kws[0].msgs[2]} - ... Return: ${expected} TRACE + Check Log Message ${test[0, 1]} Return: "Hyvää 'Päivää'\\n" TRACE -Return Object with Unicode Repr With Non Ascii Chars - [Documentation] How the return value is logged depends on the interpreter. +Return object with non-ASCII repr ${test} = Check Test Case ${TESTNAME} - ${ret} = Set Variable If ($INTERPRETER.is_python or $INTERPRETER.is_pypy) and $INTERPRETER.is_py2 - ... TRACE diff --git a/atest/robot/keywords/type_conversion/annotations.robot b/atest/robot/keywords/type_conversion/annotations.robot index f269314ffa0..3a4bf8325fb 100644 --- a/atest/robot/keywords/type_conversion/annotations.robot +++ b/atest/robot/keywords/type_conversion/annotations.robot @@ -1,12 +1,20 @@ *** Settings *** Suite Setup Run Tests ${EMPTY} keywords/type_conversion/annotations.robot -Force Tags require-py3 Resource atest_resource.robot *** Test Cases *** Integer Check Test Case ${TESTNAME} +Integer as hex + Check Test Case ${TESTNAME} + +Integer as octal + Check Test Case ${TESTNAME} + +Integer as binary + Check Test Case ${TESTNAME} + Invalid integer Check Test Case ${TESTNAME} @@ -37,22 +45,25 @@ Invalid decimal Boolean Check Test Case ${TESTNAME} -Invalid boolean is accepted as-is +Invalid boolean string is accepted as-is + Check Test Case ${TESTNAME} + +Invalid boolean Check Test Case ${TESTNAME} String Check Test Case ${TESTNAME} -Bytes +String sub type Check Test Case ${TESTNAME} -Invalid bytes +Invalid string Check Test Case ${TESTNAME} -Bytestring +Bytes Check Test Case ${TESTNAME} -Invalid bytesstring +Invalid bytes Check Test Case ${TESTNAME} Bytearray @@ -61,15 +72,24 @@ Bytearray Invalid bytearray Check Test Case ${TESTNAME} +Bytestring replacement + Check Test Case ${TESTNAME} + Datetime Check Test Case ${TESTNAME} +Datetime with now and today + Check Test Case ${TESTNAME} + Invalid datetime Check Test Case ${TESTNAME} Date Check Test Case ${TESTNAME} +Date with now and today + Check Test Case ${TESTNAME} + Invalid date Check Test Case ${TESTNAME} @@ -79,15 +99,45 @@ Timedelta Invalid timedelta Check Test Case ${TESTNAME} +Path + Check Test Case ${TESTNAME} + +Invalid Path + Check Test Case ${TESTNAME} + Enum Check Test Case ${TESTNAME} +Flag + Check Test Case ${TESTNAME} + +IntEnum + Check Test Case ${TESTNAME} + +IntFlag + Check Test Case ${TESTNAME} + +Normalized enum member match + Check Test Case ${TESTNAME} + +Normalized enum member match with multiple matches + Check Test Case ${TESTNAME} + Invalid Enum Check Test Case ${TESTNAME} +Invalid IntEnum + Check Test Case ${TESTNAME} + +None + Check Test Case ${TESTNAME} + NoneType Check Test Case ${TESTNAME} +Invalid None + Check Test Case ${TESTNAME} + List Check Test Case ${TESTNAME} @@ -97,6 +147,9 @@ Invalid list Sequence (abc) Check Test Case ${TESTNAME} +MutableSequence (abc) + Check Test Case ${TESTNAME} + Invalid sequence (abc) Check Test Case ${TESTNAME} @@ -115,6 +168,9 @@ Invalid dictionary Mapping (abc) Check Test Case ${TESTNAME} +MutableMapping (abc) + Check Test Case ${TESTNAME} + Invalid mapping (abc) Check Test Case ${TESTNAME} @@ -139,6 +195,9 @@ Invalid frozenset Unknown types are not converted Check Test Case ${TESTNAME} +Unknown types are not converted in union + Check Test Case ${TESTNAME} + Non-type values don't cause errors Check Test Case ${TESTNAME} @@ -166,20 +225,22 @@ Kwonly Invalid kwonly Check Test Case ${TESTNAME} -Non-strings are not converted +Return value annotation causes no error Check Test Case ${TESTNAME} -String None is converted to None object +None as default with known type Check Test Case ${TESTNAME} -Return value annotation causes no error +None as default with unknown type Check Test Case ${TESTNAME} -None as default +Empty string when None is used as default but not as explicit type Check Test Case ${TESTNAME} Forward references - [Tags] require-py3.5 + Check Test Case ${TESTNAME} + +Unknown forward references Check Test Case ${TESTNAME} @keyword decorator overrides annotations @@ -188,5 +249,20 @@ Forward references Type information mismatch caused by decorator Check Test Case ${TESTNAME} +Decorator with wraps + Check Test Case ${TESTNAME} + +Decorator with wraps mismatched type + Check Test Case ${TESTNAME} + Value contains variable Check Test Case ${TESTNAME} + +Default value is not used if explicit type conversion succeeds + Check Test Case ${TESTNAME} + +Default value is used if explicit type conversion fails + Check Test Case ${TESTNAME} + +Explicit conversion failure is used if both conversions fail + Check Test Case ${TESTNAME} diff --git a/atest/robot/keywords/type_conversion/annotations_with_aliases.robot b/atest/robot/keywords/type_conversion/annotations_with_aliases.robot index b28a1dfc275..d90ecb386cf 100644 --- a/atest/robot/keywords/type_conversion/annotations_with_aliases.robot +++ b/atest/robot/keywords/type_conversion/annotations_with_aliases.robot @@ -1,6 +1,5 @@ *** Settings *** Suite Setup Run Tests ${EMPTY} keywords/type_conversion/annotations_with_aliases.robot -Force Tags require-py3 Resource atest_resource.robot *** Test Cases *** diff --git a/atest/robot/keywords/type_conversion/annotations_with_typing.robot b/atest/robot/keywords/type_conversion/annotations_with_typing.robot index c00ebbeddb5..071361be44d 100644 --- a/atest/robot/keywords/type_conversion/annotations_with_typing.robot +++ b/atest/robot/keywords/type_conversion/annotations_with_typing.robot @@ -1,31 +1,57 @@ *** Settings *** -Suite Setup Run Tests ${EMPTY} keywords/type_conversion/annotations_with_typing.robot -Force Tags require-py3.5 -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} Invalid dictionary @@ -34,23 +60,62 @@ 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 Check Test Case ${TESTNAME} +TypedDict + Check Test Case ${TESTNAME} + +Stringified TypedDict types + Check Test Case ${TESTNAME} + +Optional TypedDict keys can be omitted (total=False) + Check Test Case ${TESTNAME} + +Not required TypedDict keys can be omitted (NotRequired/Required) + 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} -Set with params +Set with types + Check Test Case ${TESTNAME} + +Set with incompatible types Check Test Case ${TESTNAME} Invalid Set Check Test Case ${TESTNAME} +Any + Check Test Case ${TESTNAME} + +object + Check Test Case ${TESTNAME} + None as default Check Test Case ${TESTNAME} +None as default with Any + Check Test Case ${TESTNAME} + Forward references Check Test Case ${TESTNAME} + +Type hint not liking `isinstance` + Check Test Case ${TESTNAME} diff --git a/atest/robot/keywords/type_conversion/custom_converters.robot b/atest/robot/keywords/type_conversion/custom_converters.robot new file mode 100644 index 00000000000..28a076e43c1 --- /dev/null +++ b/atest/robot/keywords/type_conversion/custom_converters.robot @@ -0,0 +1,89 @@ +*** Settings *** +Suite Setup Run Tests ${EMPTY} keywords/type_conversion/custom_converters.robot +Resource atest_resource.robot + +*** Test Cases *** +New conversion + Check Test Case ${TESTNAME} + +Override existing conversion + Check Test Case ${TESTNAME} + +Subclasses + Check Test Case ${TESTNAME} + +Class as converter + Check Test Case ${TESTNAME} + +Custom in Union + Check Test Case ${TESTNAME} + +Accept subscripted generics + Check Test Case ${TESTNAME} + +With generics + Check Test Case ${TESTNAME} + +With TypedDict + Check Test Case ${TESTNAME} + +Failing conversion + Check Test Case ${TESTNAME} + +`None` as strict converter + Check Test Case ${TESTNAME} + +Only vararg + 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 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'. + +Non-type annotation + Check Test Case ${TESTNAME} + +Using library decorator + Check Test Case ${TESTNAME} + +With embedded arguments + Check Test Case ${TESTNAME} + +Failing conversion with embedded arguments + Check Test Case ${TESTNAME} + +With dynamic library + Check Test Case ${TESTNAME} + +Failing conversion with dynamic library + Check Test Case ${TESTNAME} + +Invalid converter dictionary + Check Test Case ${TESTNAME} + Check Log Message ${ERRORS}[-1] + ... Error in library 'InvalidCustomConverters': Argument converters must be given as a dictionary, got integer. + ... ERROR + +*** Keywords *** +Validate Errors + [Arguments] @{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/keywords/type_conversion/default_values.robot b/atest/robot/keywords/type_conversion/default_values.robot index 1e17eee1ccf..bad1658932f 100644 --- a/atest/robot/keywords/type_conversion/default_values.robot +++ b/atest/robot/keywords/type_conversion/default_values.robot @@ -9,6 +9,15 @@ Integer Integer as float Check Test Case ${TESTNAME} +Integer as hex + Check Test Case ${TESTNAME} + +Integer as octal + Check Test Case ${TESTNAME} + +Integer as binary + Check Test Case ${TESTNAME} + Invalid integer Check Test Case ${TESTNAME} @@ -34,11 +43,9 @@ String Check Test Case ${TESTNAME} Bytes - [Tags] require-py3 Check Test Case ${TESTNAME} Invalid bytes - [Tags] require-py3 Check Test Case ${TESTNAME} Bytearray @@ -65,11 +72,26 @@ Timedelta Invalid timedelta Check Test Case ${TESTNAME} +Path + Check Test Case ${TESTNAME} + +Invalid Path + Check Test Case ${TESTNAME} + Enum - [Tags] require-enum + Check Test Case ${TESTNAME} + +Flag + Check Test Case ${TESTNAME} + +IntEnum + Check Test Case ${TESTNAME} + +IntFlag Check Test Case ${TESTNAME} Invalid enum + [Tags] require-enum Check Test Case ${TESTNAME} None @@ -94,41 +116,27 @@ Invalid dictionary Check Test Case ${TESTNAME} Set - [Tags] require-py3 Check Test Case ${TESTNAME} Invalid set Check Test Case ${TESTNAME} Frozenset - [Tags] require-py3 Check Test Case ${TESTNAME} Invalid frozenset Check Test Case ${TESTNAME} -Sets are not supported in Python 2 - [Tags] require-py2 - Check Test Case ${TESTNAME} - Unknown types are not converted Check Test Case ${TESTNAME} -String None is converted to None object - Check Test Case ${TESTNAME} - Positional as named Check Test Case ${TESTNAME} -Invalid positional as named - Check Test Case ${TESTNAME} - Kwonly - [Tags] require-py3 Check Test Case ${TESTNAME} Invalid kwonly - [Tags] require-py3 Check Test Case ${TESTNAME} @keyword decorator overrides default values diff --git a/atest/robot/keywords/type_conversion/deferred_annotations.robot b/atest/robot/keywords/type_conversion/deferred_annotations.robot new file mode 100644 index 00000000000..3d8c0a55a27 --- /dev/null +++ b/atest/robot/keywords/type_conversion/deferred_annotations.robot @@ -0,0 +1,21 @@ +*** Settings *** +Documentation https://peps.python.org/pep-0649 +Suite Setup Run Tests ${EMPTY} keywords/type_conversion/deferred_annotations.robot +Test Tags require-py3.14 +Resource atest_resource.robot + +*** Test Cases *** +Annotation created later + Check Test Case ${TESTNAME} + +Annotation not available during execution + Check Test Case ${TESTNAME} + +Annotation not available during execution but is known + Check Test Case ${TESTNAME} + +Non-existing annotation + Check Test Case ${TESTNAME} + +Invalid annotation + Check Log Message ${ERRORS}[0] Error in library 'DeferredAnnotations': Adding keyword 'invalid' failed: division by zero ERROR diff --git a/atest/robot/keywords/type_conversion/dynamic.robot b/atest/robot/keywords/type_conversion/dynamic.robot index b02fbcc999e..cfabbaecc56 100644 --- a/atest/robot/keywords/type_conversion/dynamic.robot +++ b/atest/robot/keywords/type_conversion/dynamic.robot @@ -15,6 +15,11 @@ List of aliases Dict of aliases Check Test Case ${TESTNAME} -Java types - [Tags] require-jython +Default values + Check Test Case ${TESTNAME} + +Kwonly defaults + Check Test Case ${TESTNAME} + +Default values are not used if `get_keyword_types` returns `None` Check Test Case ${TESTNAME} diff --git a/atest/robot/keywords/type_conversion/embedded_arguments.robot b/atest/robot/keywords/type_conversion/embedded_arguments.robot index b61bb874ebc..3dfcbba68af 100644 --- a/atest/robot/keywords/type_conversion/embedded_arguments.robot +++ b/atest/robot/keywords/type_conversion/embedded_arguments.robot @@ -4,7 +4,6 @@ Resource atest_resource.robot *** Test Cases *** Types via annotations - [Tags] require-py3 Check Test Case ${TESTNAME} Types via @keyword diff --git a/atest/robot/keywords/type_conversion/internal_conversion_using_typeinfo.robot b/atest/robot/keywords/type_conversion/internal_conversion_using_typeinfo.robot new file mode 100644 index 00000000000..023acb09d63 --- /dev/null +++ b/atest/robot/keywords/type_conversion/internal_conversion_using_typeinfo.robot @@ -0,0 +1,16 @@ +*** Settings *** +Suite Setup Run Tests ${EMPTY} keywords/type_conversion/internal_conversion_using_typeinfo.robot +Resource atest_resource.robot + +*** Test Cases *** +Internal conversion + Check Test Case ${TESTNAME} + +Custom converters + Check Test Case ${TESTNAME} + +Language configuration + Check Test Case ${TESTNAME} + +Default language configuration + 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 221341b5dcb..3766b587390 100644 --- a/atest/robot/keywords/type_conversion/keyword_decorator.robot +++ b/atest/robot/keywords/type_conversion/keyword_decorator.robot @@ -6,6 +6,15 @@ Resource atest_resource.robot Integer Check Test Case ${TESTNAME} +Integer as hex + Check Test Case ${TESTNAME} + +Integer as octal + Check Test Case ${TESTNAME} + +Integer as binary + Check Test Case ${TESTNAME} + Invalid integer Check Test Case ${TESTNAME} @@ -36,24 +45,22 @@ Invalid decimal Boolean Check Test Case ${TESTNAME} -Invalid boolean is accepted as-is +Invalid boolean string is accepted as-is Check Test Case ${TESTNAME} -String +Invalid boolean Check Test Case ${TESTNAME} -Bytes +String Check Test Case ${TESTNAME} -Invalid bytes +Invalid string Check Test Case ${TESTNAME} -Bytestring - [Tags] require-py3 +Bytes Check Test Case ${TESTNAME} -Invalid bytesstring - [Tags] require-py3 +Invalid bytes Check Test Case ${TESTNAME} Bytearray @@ -62,6 +69,9 @@ Bytearray Invalid bytearray Check Test Case ${TESTNAME} +Bytestring replacement + Check Test Case ${TESTNAME} + Datetime Check Test Case ${TESTNAME} @@ -80,17 +90,48 @@ Timedelta Invalid timedelta Check Test Case ${TESTNAME} +Path + Check Test Case ${TESTNAME} + +Invalid Path + Check Test Case ${TESTNAME} + Enum - [Tags] require-enum + Check Test Case ${TESTNAME} + +Flag + Check Test Case ${TESTNAME} + +IntEnum + Check Test Case ${TESTNAME} + +IntFlag + Check Test Case ${TESTNAME} + +Normalized enum member match + Check Test Case ${TESTNAME} + +Normalized enum member match with multiple matches Check Test Case ${TESTNAME} Invalid Enum - [Tags] require-enum + Check Test Case ${TESTNAME} + +Invalid IntEnum Check Test Case ${TESTNAME} NoneType Check Test Case ${TESTNAME} +Invalid NoneType + Check Test Case ${TESTNAME} + +None + Check Test Case ${TESTNAME} + +Invalid None + Check Test Case ${TESTNAME} + List Check Test Case ${TESTNAME} @@ -122,31 +163,21 @@ Invalid mapping (abc) Check Test Case ${TESTNAME} Set - [Tags] require-py3 Check Test Case ${TESTNAME} Invalid set - [Tags] require-py3 Check Test Case ${TESTNAME} Set (abc) - [Tags] require-py3 Check Test Case ${TESTNAME} Invalid set (abc) - [Tags] require-py3 Check Test Case ${TESTNAME} Frozenset - [Tags] require-py3 Check Test Case ${TESTNAME} Invalid frozenset - [Tags] require-py3 - Check Test Case ${TESTNAME} - -Sets are not supported in Python 2 - [Tags] require-py2 Check Test Case ${TESTNAME} Unknown types are not converted @@ -174,32 +205,24 @@ Invalid Kwargs Check Test Case ${TESTNAME} Kwonly - [Tags] require-py3 Check Test Case ${TESTNAME} Invalid kwonly - [Tags] require-py3 - Check Test Case ${TESTNAME} - -Non-strings are not converted - Check Test Case ${TESTNAME} - -String None is converted to None object Check Test Case ${TESTNAME} Invalid type spec causes error Check Test Case ${TESTNAME} - ${error} = Catenate - ... Adding keyword 'invalid_type_spec' to library 'KeywordDecorator' failed: + Error In Library KeywordDecorator + ... Adding keyword 'invalid_type_spec' failed: ... Type information must be given as a dictionary or a list, got string. - Check Log Message ${ERRORS[0]} ${error} ERROR + ... index=0 Non-matching argument name causes error Check Test Case ${TESTNAME} - ${error} = Catenate - ... Adding keyword 'non_matching_name' to library 'KeywordDecorator' failed: + Error In Library KeywordDecorator + ... Adding keyword 'non_matching_name' failed: ... Type information given to non-existing arguments 'no_match' and 'xxx'. - Check Log Message ${ERRORS[1]} ${error} ERROR + ... index=1 Type can be given to `return` without an error [Documentation] `return` isn't used for anything yet, though. @@ -207,3 +230,24 @@ Type can be given to `return` without an error Value contains variable Check Test Case ${TESTNAME} + +Default value is not used if explicit type conversion succeeds + Check Test Case ${TESTNAME} + +Default value is used if explicit type conversion fails + Check Test Case ${TESTNAME} + +Explicit conversion failure is used if both conversions fail + Check Test Case ${TESTNAME} + +Multiple types using Union + Check Test Case ${TESTNAME} + +Argument not matching Union tupes + Check Test Case ${TESTNAME} + +Multiple types using tuple + Check Test Case ${TESTNAME} + +Argument not matching tuple tupes + Check Test Case ${TESTNAME} diff --git a/atest/robot/keywords/type_conversion/keyword_decorator_with_aliases.robot b/atest/robot/keywords/type_conversion/keyword_decorator_with_aliases.robot index 00b76286153..b9ae554b9d6 100644 --- a/atest/robot/keywords/type_conversion/keyword_decorator_with_aliases.robot +++ b/atest/robot/keywords/type_conversion/keyword_decorator_with_aliases.robot @@ -79,17 +79,13 @@ Invalid dictionary Check Test Case ${TESTNAME} Set - [Tags] require-py3 Check Test Case ${TESTNAME} Invalid set - [Tags] require-py3 Check Test Case ${TESTNAME} Frozenset - [Tags] require-py3 Check Test Case ${TESTNAME} Invalid frozenset - [Tags] require-py3 Check Test Case ${TESTNAME} diff --git a/atest/robot/keywords/type_conversion/keyword_decorator_with_list.robot b/atest/robot/keywords/type_conversion/keyword_decorator_with_list.robot index e3161ebf9b6..d7676b1151c 100644 --- a/atest/robot/keywords/type_conversion/keyword_decorator_with_list.robot +++ b/atest/robot/keywords/type_conversion/keyword_decorator_with_list.robot @@ -12,23 +12,29 @@ None means no type Falsy types mean no type Check Test Case ${TESTNAME} +NoneType + Check Test Case ${TESTNAME} + +None as string is None + Check Test Case ${TESTNAME} + +None in tuple is alias for NoneType + Check Test Case ${TESTNAME} + Less types than arguments is ok Check Test Case ${TESTNAME} More types than arguments causes error Check Test Case ${TESTNAME} - ${error} = Catenate - ... Adding keyword 'too_many_types' to library 'KeywordDecoratorWithList' failed: + Error In Library KeywordDecoratorWithList + ... Adding keyword 'too_many_types' failed: ... Type information given to 2 arguments but keyword has only 1 argument. - Check Log Message ${ERRORS[0]} ${error} ERROR Varargs and kwargs Check Test Case ${TESTNAME} Kwonly - [Tags] require-py3 Check Test Case ${TESTNAME} Kwonly with kwargs - [Tags] require-py3 Check Test Case ${TESTNAME} diff --git a/atest/robot/keywords/type_conversion/literal.robot b/atest/robot/keywords/type_conversion/literal.robot new file mode 100644 index 00000000000..5dbffab6212 --- /dev/null +++ b/atest/robot/keywords/type_conversion/literal.robot @@ -0,0 +1,70 @@ +*** Settings *** +Suite Setup Run Tests ${EMPTY} keywords/type_conversion/literal.robot +Resource atest_resource.robot + +*** Test Cases *** +Integers + Check Test Case ${TESTNAME} + +Invalid integers + Check Test Case ${TESTNAME} + +Strings + Check Test Case ${TESTNAME} + +Strings are case, space, etc. insensitive + Check Test Case ${TESTNAME} + +Invalid strings + Check Test Case ${TESTNAME} + +Bytes + Check Test Case ${TESTNAME} + +Invalid bytes + Check Test Case ${TESTNAME} + +Booleans + Check Test Case ${TESTNAME} + +Booleans are localized + Check Test Case ${TESTNAME} + +Invalid booleans + Check Test Case ${TESTNAME} + +None + Check Test Case ${TESTNAME} + +Invalid None + Check Test Case ${TESTNAME} + +Enums + Check Test Case ${TESTNAME} + +Invalid enums + Check Test Case ${TESTNAME} + +Int enums + Check Test Case ${TESTNAME} + +Invalid int enums + Check Test Case ${TESTNAME} + +Multiple matches with exact match + Check Test Case ${TESTNAME} + +Multiple matches with not exact match + Check Test Case ${TESTNAME} + +In parameters + Check Test Case ${TESTNAME} + +In parameters invalid + Check Test Case ${TESTNAME} + +typing_extensions.Literal + Check Test Case ${TESTNAME} + +typing_extensions.Literal invalid + Check Test Case ${TESTNAME} diff --git a/atest/robot/keywords/type_conversion/secret.robot b/atest/robot/keywords/type_conversion/secret.robot new file mode 100644 index 00000000000..cf201f6946f --- /dev/null +++ b/atest/robot/keywords/type_conversion/secret.robot @@ -0,0 +1,166 @@ +*** Settings *** +Resource atest_resource.robot +Suite Setup Run Tests --variable "CLI: Secret:From command line" -L trace keywords/type_conversion/secret.robot + +*** Test Cases *** +Command line + Check Test Case ${TESTNAME} + +Variable section: Based on existing variable + Check Test Case ${TESTNAME} + +Variable section: Based on environment variable + Check Test Case ${TESTNAME} + +Variable section: Joined + Check Test Case ${TESTNAME} + +Variable section: Scalar fail + Check Test Case ${TESTNAME} + Error In File + ... 6 keywords/type_conversion/secret.robot 11 + ... Setting variable '\${LITERAL: Secret}' failed: + ... Value must have type 'Secret', got string. + Error In File + ... 0 keywords/type_conversion/secret.robot 12 + ... Setting variable '\${BAD: Secret}' failed: + ... Value must have type 'Secret', got integer. + Error In File + ... 3 keywords/type_conversion/secret.robot 20 + ... Setting variable '\${JOIN4: Secret}' failed: + ... Value must have type 'Secret', got string. + Error In File + ... 7 keywords/type_conversion/secret.robot 14 + ... Setting variable '\${SECRET_BOOL: bool}' failed: + ... Value '' (Secret) cannot be converted to boolean. + Error In File + ... 8 keywords/type_conversion/secret.robot 13 + ... Setting variable '\${SECRET_STR: str}' failed: + ... Value '' (Secret) cannot be converted to string. + +Variable section: List + Check Test Case ${TESTNAME} + +Variable section: List fail + Check Test Case ${TESTNAME} + Error In File + ... 4 keywords/type_conversion/secret.robot 23 + ... Setting variable '\@{LIST3: Secret}' failed: + ... Value '['this', Secret(value=), 'fails']' (list) + ... cannot be converted to list[Secret]: + ... Item '0' must have type 'Secret', got string. + ... pattern=False + Error In File + ... 5 keywords/type_conversion/secret.robot 24 + ... Setting variable '\@{LIST4: Secret}' failed: + ... Value '[Secret(value=), 'this', 'fails', Secret(value=)]' (list) + ... cannot be converted to list[Secret]: + ... Item '1' must have type 'Secret', got string. + ... pattern=False + +Variable section: Dict + Check Test Case ${TESTNAME} + +Variable section: Dict fail + Check Test Case ${TESTNAME} + Error In File + ... 1 keywords/type_conversion/secret.robot 28 + ... Setting variable '\&{DICT4: Secret}' failed: + ... Value '{'ok': Secret(value=), 'this': 'fails'}' (DotDict) + ... cannot be converted to dict[Any, Secret]: + ... Item 'this' must have type 'Secret', got string. + ... pattern=False + Error In File + ... 2 keywords/type_conversion/secret.robot 29 + ... Setting variable '\&{DICT5: str=Secret}' failed: + ... Value '{'ok': Secret(value=), 'var': Secret(value=), + ... 'env': Secret(value=), 'join': Secret(value=), 'this': 'fails'}' (DotDict) + ... cannot be converted to dict[str, Secret]: + ... Item 'this' must have type 'Secret', got string. + ... pattern=False + +VAR: Based on existing variable + ${tc} = Check Test Case ${TESTNAME} + Check Log Message ${tc[0, 0]} \${x} = + +VAR: Based on environment variable + ${tc} = Check Test Case ${TESTNAME} + Check Log Message ${tc[1, 0]} \${secret} = + +VAR: Joined + ${tc} = Check Test Case ${TESTNAME} + Check Log Message ${tc[2, 0]} \${x} = + +VAR: Broken variable + Check Test Case ${TESTNAME} + +VAR: List + ${tc} = Check Test Case ${TESTNAME} + Check Log Message ${tc[0, 0]} \@{x} = [ | | ] + +VAR: Dict + ${tc} = Check Test Case ${TESTNAME} 1 + Check Log Message ${tc[0, 0]} \&{x} = { var= | end= | join= } + Check Test Case ${TESTNAME} 2 + +Return value: User keyword + ${tc} = Check Test Case ${TESTNAME} + Check Log Message ${tc[0, -2]} Return: Secret(value=) TRACE + Check Log Message ${tc[0, -1]} \${x} = + +Return value: Library keyword + ${tc} = Check Test Case ${TESTNAME} + Check Log Message ${tc[0, -2]} Return: Secret(value=) TRACE + Check Log Message ${tc[0, -1]} \${x} = + +Arguments: User keyword + ${tc} = Check Test Case ${TESTNAME} + Check Log Message ${tc[0, 0]} Arguments: [ \${secret}=Secret(value=) | \${expected}='Secret value' ] TRACE + +Arguments: User keyword non-secret + Check Test Case ${TESTNAME} 1 + Check Test Case ${TESTNAME} 2 + +Arguments: User keyword fail str + Check Test Case ${TESTNAME} + +Arguments: User keyword fail bool + Check Test Case ${TESTNAME} + +Arguments: User keyword Any + Check Test Case ${TESTNAME} + +Arguments: User keyword object + Check Test Case ${TESTNAME} + +Arguments: Library keyword + ${tc} = Check Test Case ${TESTNAME} + Check Log Message ${tc[0, 0]} Arguments: [ Secret(value=) ] TRACE + +Arguments: Library keyword non-secret + Check Test Case ${TESTNAME} 1 + Check Test Case ${TESTNAME} 2 + +Arguments: Library keyword fail str + Check Test Case ${TESTNAME} + +Arguments: Library keyword fail bool + Check Test Case ${TESTNAME} + +Arguments: Library keyword fail list str + Check Test Case ${TESTNAME} 1 + Check Test Case ${TESTNAME} 2 + +Arguments: Library keyword any + Check Test Case ${TESTNAME} + +Arguments: Library keyword object + Check Test Case ${TESTNAME} + +Arguments: List of secrets + ${tc} = Check Test Case ${TESTNAME} + Check Log Message ${tc[1, 0]} Arguments: [ [Secret(value=), Secret(value=)] ] TRACE + +Arguments: TypedDict + ${tc} = Check Test Case ${TESTNAME} + Check Log Message ${tc[1, 0]} Arguments: [ {'username': 'login@email.com', 'password': Secret(value=)} ] TRACE 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..ced9d2f5d93 --- /dev/null +++ b/atest/robot/keywords/type_conversion/standard_generics.robot @@ -0,0 +1,110 @@ +*** Settings *** +Suite Setup Run Tests ${EMPTY} keywords/type_conversion/standard_generics.robot +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} + +Sequence + Check Test Case ${TESTNAME} + +MutableSequence + Check Test Case ${TESTNAME} + +Invalid Sequence + 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} + +Mapping + Check Test Case ${TESTNAME} + +MutableMapping + Check Test Case ${TESTNAME} + +Invalid Mapping + 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} + Check Log Message ${ERRORS[1]} + ... Error in library 'StandardGenerics': Adding keyword 'invalid_list' failed: 'list[]' requires exactly 1 parameter, '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 parameter, '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 parameters, '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 parameter, '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 new file mode 100644 index 00000000000..f9224d36b28 --- /dev/null +++ b/atest/robot/keywords/type_conversion/stringly_types.robot @@ -0,0 +1,46 @@ +*** 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} + +Literal + Check Test Case ${TESTNAME} + +Union + Check Test Case ${TESTNAME} + +Nested + Check Test Case ${TESTNAME} + +Aliases + Check Test Case ${TESTNAME} + +TypedDict items + Check Test Case ${TESTNAME} + +Invalid + Check Test Case ${TESTNAME} + 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 parameter, 'list[int, str]' has 2. + ... ERROR 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..746fff37efd --- /dev/null +++ b/atest/robot/keywords/type_conversion/translated_boolean_values.robot @@ -0,0 +1,10 @@ +*** 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} + +Via Run Keyword + Check Test Case ${TESTNAME} diff --git a/atest/robot/keywords/type_conversion/type_statement.robot b/atest/robot/keywords/type_conversion/type_statement.robot new file mode 100644 index 00000000000..137e8d9940d --- /dev/null +++ b/atest/robot/keywords/type_conversion/type_statement.robot @@ -0,0 +1,47 @@ +*** Settings *** +Suite Setup Run Tests ${EMPTY} keywords/type_conversion/type_statement.robot +Test Tags require-py3.12 +Resource atest_resource.robot + +*** Test Cases *** +Simple value + Check Test Case ${TESTNAME} + +Params in value + Check Test Case ${TESTNAME} + +Union value + Check Test Case ${TESTNAME} + +Forward reference + Check Test Case ${TESTNAME} + +Recursion + Check Test Case ${TESTNAME} + +Failing recursive conversion + Check Test Case ${TESTNAME} + +Generic simple + Check Test Case ${TESTNAME} + +Generic with params in value + Check Test Case ${TESTNAME} + +Generic with union + Check Test Case ${TESTNAME} + +Generic with defaults + [Tags] require-py3.13 + Check Test Case ${TESTNAME} + +Generic forward reference + Check Test Case ${TESTNAME} + +Invalid + VAR ${message} + ... Error in library 'TypeStatement': + ... Adding keyword 'invalid' failed: + ... Resolving type alias 'Invalid' failed: + ... name 'NonExisting' is not defined + Check Log Message ${ERRORS}[-1] ${message} ERROR diff --git a/atest/robot/keywords/type_conversion/unions.robot b/atest/robot/keywords/type_conversion/unions.robot new file mode 100644 index 00000000000..c7c56199014 --- /dev/null +++ b/atest/robot/keywords/type_conversion/unions.robot @@ -0,0 +1,90 @@ +*** Settings *** +Suite Setup Run Tests ${EMPTY} keywords/type_conversion/unions.robot +Resource atest_resource.robot + +*** Test Cases *** +Union + Check Test Case ${TESTNAME} + +Union with None and without str + Check Test Case ${TESTNAME} + +Union with None and str + Check Test Case ${TESTNAME} + +Union with ABC + Check Test Case ${TESTNAME} + +Union with Any + Check Test Case ${TESTNAME} + +Union with object + Check Test Case ${TESTNAME} + +Union with subscripted generics + Check Test Case ${TESTNAME} + +Union with subscripted generics and str + Check Test Case ${TESTNAME} + +Union with TypedDict + Check Test Case ${TESTNAME} + +Union with str and TypedDict + Check Test Case ${TESTNAME} + +Union with item not liking isinstance + Check Test Case ${TESTNAME} + +Argument not matching union + Check Test Case ${TESTNAME} + +Union with unrecognized type + Check Test Case ${TESTNAME} + +Union with only unrecognized types + Check Test Case ${TESTNAME} + +Multiple types using tuple + Check Test Case ${TESTNAME} + +Argument not matching tuple types + Check Test Case ${TESTNAME} + +Optional argument + Check Test Case ${TESTNAME} + +Optional argument with default + Check Test Case ${TESTNAME} + +Optional string with None default + Check Test Case ${TESTNAME} + +String with None default + Check Test Case ${TESTNAME} + +Avoid unnecessary conversion + Check Test Case ${TESTNAME} + +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} + +Tuple with invalid types + Check Test Case ${TESTNAME} + +Union without types + Check Test Case ${TESTNAME} + Check Log Message ${ERRORS}[1] Error in library 'unions': Adding keyword 'union_without_types' failed: Union 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 cannot be empty. ERROR diff --git a/atest/robot/keywords/type_conversion/unionsugar.robot b/atest/robot/keywords/type_conversion/unionsugar.robot new file mode 100644 index 00000000000..7a1736dce68 --- /dev/null +++ b/atest/robot/keywords/type_conversion/unionsugar.robot @@ -0,0 +1,50 @@ +*** Settings *** +Suite Setup Run Tests ${EMPTY} keywords/type_conversion/unionsugar.robot +Force Tags require-py3.10 +Resource atest_resource.robot + +*** Test Cases *** +Union + Check Test Case ${TESTNAME} + +Union with None and without str + Check Test Case ${TESTNAME} + +Union with None and str + Check Test Case ${TESTNAME} + +Union with ABC + Check Test Case ${TESTNAME} + +Union with Any + Check Test Case ${TESTNAME} + +Union with object + Check Test Case ${TESTNAME} + +Union with subscripted generics + Check Test Case ${TESTNAME} + +Union with subscripted generics and str + Check Test Case ${TESTNAME} + +Union with TypedDict + Check Test Case ${TESTNAME} + +Union with item not liking isinstance + Check Test Case ${TESTNAME} + +Argument not matching union + Check Test Case ${TESTNAME} + +Union with unrecognized type + Check Test Case ${TESTNAME} + +Union with only unrecognized types + Check Test Case ${TESTNAME} + +Avoid unnecessary conversion + Check Test Case ${TESTNAME} + +Avoid unnecessary conversion with ABC + Check Test Case ${TESTNAME} diff --git a/atest/robot/keywords/user_keyword_arguments.robot b/atest/robot/keywords/user_keyword_arguments.robot index 52dfe92b7a0..bed9232e19f 100644 --- a/atest/robot/keywords/user_keyword_arguments.robot +++ b/atest/robot/keywords/user_keyword_arguments.robot @@ -71,6 +71,9 @@ Default With Dict Variable Default With Invalid Dict Variable Check Test Case ${TESTNAME} +Argument With `=` In Name + Check Test Case ${TESTNAME} + Calling Using List Variables Check Test Case ${TESTNAME} @@ -82,17 +85,27 @@ Caller does not see modifications to varargs Invalid Arguments Spec [Template] Verify Invalid Argument Spec - 0 Invalid argument syntax Invalid argument syntax 'no deco'. - 1 Non-default after defaults Non-default argument after default arguments. - 2 Kwargs not last Only last argument can be kwargs. + 0 Invalid argument syntax Invalid argument syntax 'no deco'. + 1 Non-default after default Non-default argument after default arguments. + 2 Non-default after default w/ types Non-default argument after default arguments. + 3 Default with varargs Only normal arguments accept default values, list arguments like '\@{varargs}' do not. + 4 Default with kwargs Only normal arguments accept default values, dictionary arguments like '\&{kwargs}' do not. + 5 Multiple varargs Cannot have multiple varargs. + 6 Multiple varargs w/ types Cannot have multiple varargs. + 7 Kwargs not last Only last argument can be kwargs. + 8 Kwargs not last w/ types Only last argument can be kwargs. + 9 Multiple errors Multiple errors: + ... - Invalid argument syntax 'invalid'. + ... - Non-default argument after default arguments. + ... - Cannot have multiple varargs. + ... - Only last argument can be kwargs. *** Keywords *** Verify Invalid Argument Spec - [Arguments] ${index} ${name} ${error} + [Arguments] ${index} ${name} @{error} Check Test Case ${TEST NAME} - ${name} - ${source} = Normalize Path ${DATADIR}/keywords/user_keyword_arguments.robot - ${message} = Catenate - ... Error in test case file '${source}': + VAR ${error} @{error} separator=\n + VAR ${lineno} ${{358 + ${index} * 4}} + Error In File ${index} keywords/user_keyword_arguments.robot ${lineno} ... Creating keyword '${name}' failed: ... Invalid argument specification: ${error} - Check Log Message ${ERRORS[${index}]} ${message} ERROR diff --git a/atest/robot/keywords/user_keyword_kwargs.robot b/atest/robot/keywords/user_keyword_kwargs.robot index 146e1077282..69b8b5b7ebd 100644 --- a/atest/robot/keywords/user_keyword_kwargs.robot +++ b/atest/robot/keywords/user_keyword_kwargs.robot @@ -47,16 +47,13 @@ Caller does not see modifications to kwargs Invalid arguments spec [Template] Verify Invalid Argument Spec - 0 Positional after kwargs Only last argument can be kwargs. - 1 Varargs after kwargs Only last argument can be kwargs. + 0 182 Positional after kwargs Only last argument can be kwargs. + 1 186 Varargs after kwargs Only last argument can be kwargs. *** Keywords *** Verify Invalid Argument Spec - [Arguments] ${index} ${name} ${error} + [Arguments] ${index} ${lineno} ${name} ${error} Check Test Case ${TEST NAME}: ${name} - ${source} = Normalize Path ${DATADIR}/keywords/user_keyword_kwargs.robot - ${message} = Catenate - ... Error in test case file '${source}': + Error In File ${index} keywords/user_keyword_kwargs.robot ${lineno} ... Creating keyword '${name}' failed: ... Invalid argument specification: ${error} - Check Log Message ${ERRORS[${index}]} ${message} ERROR diff --git a/atest/robot/keywords/wrapping_decorators.robot b/atest/robot/keywords/wrapping_decorators.robot new file mode 100644 index 00000000000..8bd9a3abcea --- /dev/null +++ b/atest/robot/keywords/wrapping_decorators.robot @@ -0,0 +1,16 @@ +*** Settings *** +Suite Setup Run Tests ${EMPTY} keywords/wrapping_decorators.robot +Resource atest_resource.robot + +*** Test Cases *** +Wrapped functions + Check Test Case ${TESTNAME} + +Wrapped function with wrong number of arguments + Check Test Case ${TESTNAME} + +Wrapped methods + Check Test Case ${TESTNAME} + +Wrapped method with wrong number of arguments + Check Test Case ${TESTNAME} diff --git a/atest/robot/libdoc/LibDocLib.py b/atest/robot/libdoc/LibDocLib.py index d06c291cafd..f3f6612ef9d 100644 --- a/atest/robot/libdoc/LibDocLib.py +++ b/atest/robot/libdoc/LibDocLib.py @@ -2,47 +2,114 @@ import os import pprint import shlex -from os.path import join, dirname, abspath -from subprocess import run, PIPE, STDOUT +from pathlib import Path +from subprocess import PIPE, run, STDOUT + +try: + from jsonschema import Draft202012Validator as JSONValidator +except ImportError: + JSONValidator = None +from xmlschema import XMLSchema from robot.api import logger -from robot.utils import CONSOLE_ENCODING, SYSTEM_ENCODING +from robot.running.arguments import ArgInfo, TypeInfo +from robot.utils import NOT_SET, SYSTEM_ENCODING + +ROOT = Path(__file__).absolute().parent.parent.parent.parent -ROBOT_SRC = join(dirname(abspath(__file__)), '..', '..', '..', 'src') +class LibDocLib: + def __init__(self, interpreter=None): + self.interpreter = interpreter + self.xml_schema = XMLSchema(str(ROOT / "doc/schema/libdoc.xsd")) + self.json_schema = self._load_json_schema() -class LibDocLib(object): + def _load_json_schema(self): + if not JSONValidator: + return None + with open(ROOT / "doc/schema/libdoc.json", encoding="UTF-8") as f: + return JSONValidator(json.load(f)) - def __init__(self, interpreter): - self._libdoc = interpreter.libdoc - self._encoding = SYSTEM_ENCODING \ - if not interpreter.is_ironpython else CONSOLE_ENCODING + @property + def libdoc(self): + return self.interpreter.libdoc def run_libdoc(self, args): - cmd = self._libdoc + self._split_args(args) - cmd[-1] = cmd[-1].replace('/', os.sep) - logger.info(' '.join(cmd)) - result = run(cmd, cwd=ROBOT_SRC, stdout=PIPE, stderr=STDOUT, - encoding=self._encoding, universal_newlines=True) + cmd = self.libdoc + self._split_args(args) + cmd[-1] = cmd[-1].replace("/", os.sep) + logger.info(" ".join(cmd)) + if self.interpreter.version_info >= (3, 15): + encoding = "UTF-8" + else: + encoding = SYSTEM_ENCODING + result = run( + cmd, + cwd=ROOT / "src", + stdout=PIPE, + stderr=STDOUT, + encoding=encoding, + timeout=120, + text=True, + ) logger.info(result.stdout) return result.stdout def _split_args(self, args): lexer = shlex.shlex(args, posix=True) - lexer.escape = '' + lexer.escape = "" lexer.whitespace_split = True return list(lexer) def get_libdoc_model_from_html(self, path): - with open(path, encoding='UTF-8') as html_file: + with open(path, encoding="UTF-8") as html_file: model_string = self._find_model(html_file) - model = json.loads(model_string.replace('\\x3c/', ' ... non ascii doc + ... With embedded \${arg: int} and normal arg Run Libdoc And Verify Output ${TESTDATADIR}/resource.robot LIST KW? C*R + ... ${DEPRECATION} ... curdir ... kw 3 ... kw 4 @@ -29,16 +41,17 @@ List some keywords Show whole library Run Libdoc And Set Output ${TESTDATADIR}/module.py show - Should Contain Intro module + Should Contain ${OUTPUT} \## Keywords + Should Contain Intro \# module ... Version=0.1-alpha - ... Scope=global - ... Named arguments=supported - Should Contain Keyword Get Hello ${EMPTY} + ... Scope=GLOBAL + Should Contain Keyword Get Hello ${EMPTY} ${EMPTY} ... Get hello. ... ${EMPTY} ... See `importing` for explanation of nothing ... and `introduction` for no more information - Should Contain Keyword Keyword a1=d, *a2 + VAR @{args} `a1` (default: `d`) `*a2` + Should Contain Keyword Keyword ${args} ${EMPTY} ... A keyword. ... ${EMPTY} ... See `get hello` for details. @@ -46,12 +59,20 @@ Show whole library Show intro only Run Libdoc and set output Telnet SHOW intro Should Contain Intro Telnet Version= - ${args} = Catenate SEPARATOR=\n${SPACE*12} - ... timeout=3 seconds, newline=CRLF, prompt=None, - ... prompt_is_regexp=False, encoding=UTF-8, encoding_errors=ignore, - ... default_log_level=INFO, window_size=None, environ_user=None, - ... terminal_emulation=False, terminal_type=None, - ... telnetlib_log_level=TRACE, connection_timeout=None + VAR @{args} + ... `timeout` (default: `3 seconds`) + ... `newline` (default: `CRLF`) + ... `prompt` (default: `None`) + ... `prompt_is_regexp` (default: `False`) + ... `encoding` (default: `UTF-8`) + ... `encoding_errors` (default: `ignore`) + ... `default_log_level` (default: `INFO`) + ... `window_size` (default: `None`) + ... `environ_user` (default: `None`) + ... `terminal_emulation` (default: `False`) + ... `terminal_type` (default: `None`) + ... `telnetlib_log_level` (default: `TRACE`) + ... `connection_timeout` (default: `None`) Should Contain Importing ${args} ... Telnet library can be imported with optional configuration parameters. Should Not Contain Keyword Open Connection @@ -59,42 +80,77 @@ Show intro only Show intro and keywords Run Libdoc and set output ${TESTDATADIR}/resource.robot SHOW NONASC* INTRO - Should Contain Intro resource Named arguments=supported + VAR @{tags} `common` Should Contain Keyword non ascii doc ${EMPTY} + ... ${tags} ... Hyvää yötä. ... ${EMPTY} # Cannot test does output contain `Спасибо!` because consoles may not be able to show it. # Actually all consoles cannot show `Hyvää yötä` either but we expect western config. +Show markdown libary + Run Libdoc and set output ${TESTDATADIR}/MarkdownLibrary.py show + Compare Against Golden Image ${TESTDATADIR}/MarkdownLibrary.txt + Show version - Run Libdoc And Verify Output ${TESTDATADIR}/module.py version 0.1-alpha - Run Libdoc And Verify Output ${TESTDATADIR}/resource.robot version N/A + Run Libdoc And Verify Output ${TESTDATADIR}/module.py version + ... 0.1-alpha + Run Libdoc And Verify Output ${TESTDATADIR}/resource.robot version + ... ${DEPRECATION} + ... N/A *** Keywords *** Should Contain Intro [Arguments] ${name} &{meta} - ${underline} = Evaluate '=' * len($name) - @{meta} = Evaluate [(n+':').ljust(18) + v for n, v in $meta.items()] + @{meta} = Evaluate [f"* {n}: {v}" for n, v in $meta.items()] ${expected} = Catenate SEPARATOR=\n ... ${name} - ... ${underline} + ... ${EMPTY} ... @{meta} Should Contain ${OUTPUT} ${expected} +Compare Against Golden Image + [Arguments] ${file} + ${expected} = Get File ${file} + Should Be Equal ${OUTPUT} ${expected} + Should Contain Keyword - [Arguments] ${name} ${args} @{doc} - ${underline} = Evaluate '-'*len('${name}') + [Arguments] ${name} ${args} ${tags} @{doc} + IF $name == 'Importing' + VAR ${heading} \## + ELSE + VAR ${heading} \### + END ${expected} = Catenate SEPARATOR=\n - ... ${name} - ... ${underline} - ... Arguments:${SPACE * 2}\[${args}] + ... ${heading} ${name} + IF $args + ${args} Evaluate [f"* {a}" for a in $args] + ${expected} Catenate SEPARATOR=\n + ... ${expected} + ... ${EMPTY} + ... **Arguments:** + ... ${EMPTY} + ... @{args} + END + IF $tags + ${tags} Evaluate [f"* {t}" for t in $tags] + ${expected} Catenate SEPARATOR=\n + ... ${expected} + ... ${EMPTY} + ... **Tags:** + ... ${EMPTY} + ... @{tags} + END + + ${expected} Catenate SEPARATOR=\n + ... ${expected} ... ${EMPTY} ... @{doc} Should Contain ${OUTPUT} ${expected} Should Contain Importing [Arguments] ${args} @{doc} - Should Contain Keyword Importing ${args} @{doc} + Should Contain Keyword Importing ${args} ${EMPTY} @{doc} Should Not Contain Keyword [Arguments] ${name} diff --git a/atest/robot/libdoc/datatypes_json-xml.robot b/atest/robot/libdoc/datatypes_json-xml.robot new file mode 100644 index 00000000000..553ac9b4b00 --- /dev/null +++ b/atest/robot/libdoc/datatypes_json-xml.robot @@ -0,0 +1,104 @@ +*** Settings *** +Resource libdoc_resource.robot +Suite Setup Run Libdoc And Parse Output ${TESTDATADIR}/DataTypesLibrary.json + +*** Test Cases *** +Enum + DataType Enum Should Be 0 + ... AssertionOperator + ...

This is some Doc

\n

This 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

This was defined within the class definition.

+ ... {"name": "one","value": "1"} + ... {"name": "two","value": "2"} + ... {"name": "three","value": "3"} + ... {"name": "four","value": "4"} + +TypedDict + 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
\n

Example 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"} + +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, + DataType Standard Should Be 6 + ... Literal + ...

Only specified values are accepted. + +Standard with generics + DataType Standard Should Be 2 + ... dictionary + ...

Strings must be Python Strings must be Python This Library has Data Types.

+ ...

It has some in __init__ and others in the Keywords.

+ ...

Custom data types include GeoLocation and Small.

+ +Init Arguments + [Template] Verify Argument Models + ${MODEL}[inits][0][args] credentials: Small = one + +Init docs + ${MODEL}[inits][0][doc]

This is the init Docs.

+ ...

It links to Set Location keyword and to GeoLocation data type.

+ +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[object] + ${MODEL}[keywords][5][args] arg: Literal[1, 'xxx', b'yyy', True, None, one] + +TypedDict + ${MODEL}[typedocs][7][type] TypedDict + ${MODEL}[typedocs][7][name] GeoLocation + ${MODEL}[typedocs][7][doc]

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}

+ +TypedDict Items + [Template] NONE + VAR ${required} ${Model}[typedocs][7][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}[typedocs][7][items] + IF $exp['key'] == $item['key'] + Dictionaries Should Be Equal ${item} ${exp} + BREAK + END + END + END + +Enum + ${MODEL}[typedocs][1][type] Enum + ${MODEL}[typedocs][1][name] AssertionOperator + ${MODEL}[typedocs][1][doc]

This is some Doc

+ ...

This has was defined by assigning to __doc__.

+ +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}[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]

Converter method doc is used when defined.

+ ${MODEL}[typedocs][4][type] Custom + ${MODEL}[typedocs][4][name] CustomType2 + ${MODEL}[typedocs][4][doc]

Class doc is used when converter method has no doc.

+ +Standard types + ${MODEL}[typedocs][0][type] Standard + ${MODEL}[typedocs][0][name] Any + ${MODEL}[typedocs][0][doc]

Any value is accepted. No conversion is done.

+ ${MODEL}[typedocs][2][type] Standard + ${MODEL}[typedocs][2][name] boolean + ${MODEL}[typedocs][2][doc]

Strings TRUE, YES, start=True + ${MODEL}[typedocs][10][name] Literal + ${MODEL}[typedocs][10][doc]

Only specified values are accepted. start=True + +Standard types with generics + ${MODEL}[typedocs][5][type] Standard + ${MODEL}[typedocs][5][name] dictionary + ${MODEL}[typedocs][5][doc]

Strings must be Python Strings must be Python ","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`, `1` and + DataType Standard Should Be 6 + ... Literal + ... Only specified values are accepted. + +Standard with generics + DataType Standard Should Be 2 + ... dictionary + ... Strings must be Python [[]dictionary] literals. + DataType Standard Should Be 5 + ... list + ... Strings must be Python [[]list] or [[]tuple] literals. + +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 10 Standard Literal + ... Any + 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 13 Enum Small + ... string integer + +Usages + Usages Should Be 0 Standard Any + ... Typing Types + Usages Should Be 5 Standard dictionary + ... Typing Types + Usages Should Be 14 Standard string + ... Assert Something Funny Unions Typing Types + Usages Should Be 3 Custom CustomType + ... Custom + Usages Should be 7 TypedDict GeoLocation + ... Funny Unions Set Location + Usages Should Be 13 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 + ... object diff --git a/atest/robot/libdoc/datatypes_xml-json.robot b/atest/robot/libdoc/datatypes_xml-json.robot new file mode 100644 index 00000000000..f21f1c426c9 --- /dev/null +++ b/atest/robot/libdoc/datatypes_xml-json.robot @@ -0,0 +1,182 @@ +*** Settings *** +Resource libdoc_resource.robot +Suite Setup Run Libdoc And Parse Model From JSON ${TESTDATADIR}/DataTypesLibrary.xml +Test Template Should Be Equal Multiline +Test Tags require-jsonschema + +*** Test Cases *** +Documentation + ${MODEL}[doc]

This Library has Data Types.

+ ...

It has some in __init__ and others in the Keywords.

+ ...

Custom data types include GeoLocation and Small.

+ +Init Arguments + [Template] Verify Argument Models + ${MODEL}[inits][0][args] credentials: Small = one + +Init docs + ${MODEL}[inits][0][doc]

This is the init Docs.

+ ...

It links to Set Location keyword and to GeoLocation data type.

+ +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[object] + ${MODEL}[keywords][5][args] arg: Literal[1, 'xxx', b'yyy', True, None, one] + +TypedDict + ${MODEL}[typedocs][7][type] TypedDict + ${MODEL}[typedocs][7][name] GeoLocation + ${MODEL}[typedocs][7][doc]

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}

+ +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}[typedocs][7][items] + IF $exp['key'] == $item['key'] + Dictionaries Should Be Equal ${item} ${exp} + BREAK + END + END + END + +Enum + ${MODEL}[typedocs][1][type] Enum + ${MODEL}[typedocs][1][name] AssertionOperator + ${MODEL}[typedocs][1][doc]

This is some Doc

+ ...

This has was defined by assigning to __doc__.

+ +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}[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]

Converter method doc is used when defined.

+ ${MODEL}[typedocs][4][type] Custom + ${MODEL}[typedocs][4][name] CustomType2 + ${MODEL}[typedocs][4][doc]

Class doc is used when converter method has no doc.

+ +Standard types + ${MODEL}[typedocs][0][type] Standard + ${MODEL}[typedocs][0][name] Any + ${MODEL}[typedocs][0][doc]

Any value is accepted. No conversion is done.

+ ${MODEL}[typedocs][2][type] Standard + ${MODEL}[typedocs][2][name] boolean + ${MODEL}[typedocs][2][doc]

Strings TRUE, YES, start=True + ${MODEL}[typedocs][10][name] Literal + ${MODEL}[typedocs][10][doc]

Only specified values are accepted. start=True + +Standard types with generics + ${MODEL}[typedocs][5][type] Standard + ${MODEL}[typedocs][5][name] dictionary + ${MODEL}[typedocs][5][doc]

Strings must be Python Strings must be Python ${EXAMPLE URL} +${EXAMPLE LINK} http://example.com +${RAW DOC} *bold* or bold http://example.com +${HTML DOC} bold or <b>bold</b> ${EXAMPLE LINK} *** Test Cases *** Robot format - bold or <b>bold</b> ${EXAMPLE LINK} --docformat Robot + ${HTML DOC} --docformat Robot Text format *bold* or <b>bold</b> ${EXAMPLE LINK} --DocFormat TEXT HTML format - *bold* or bold ${EXAMPLE URL} -F html + *bold* or bold http://example.com -F html shortdoc=*bold* or *bold* http://example.com + +Markdown format + [Template] NONE + [Tags] require-markdown + Test Format in HTML

bold or bold http://example.com

+ ... --docformat markdown + ... doc2=

Link to Keyword or not?

+ Should Contain ${MODEL}[keywords][2][doc] + ... This link to Keyword + Should Contain ${MODEL}[keywords][2][doc] + ... *** Test Cases *** reST format [Template] NONE [Tags] require-docutils require-pygments Test Format in HTML bold or <b>bold</b> Keyword. - Should Contain ${MODEL['keywords'][2]['doc']} - ... This link to Keyword - Should Contain ${MODEL['keywords'][2]['doc']} - ... *** Test Cases ***\x3c/span> + ... --docformat rest + ... doc2=Link to Keyword or not? + Should Contain ${MODEL}[keywords][3][doc] + ... This link to Keyword + Should Contain ${MODEL}[keywords][3][doc] + ... *** Test Cases *** Format from Python library - *bold* or bold ${EXAMPLE URL} lib=DocFormatHtml.py + *bold* or bold http://example.com lib=DocFormatHtml.py shortdoc=*bold* or *bold* http://example.com Format from CLI overrides format from library - bold or <b>bold</b> ${EXAMPLE LINK} -F robot DocFormatHtml.py - -Format from Java library - [Tags] require-jython require-tools.jar - *bold* or bold ${EXAMPLE URL} ${EMPTY} DocFormatHtml.java - bold or <b>bold</b> ${EXAMPLE LINK} -F robot DocFormatHtml.java + ${HTML DOC} -F robot DocFormatHtml.py Format in XML [Template] Test Format in XML - TEXT -F TEXT DocFormat.py - ROBOT --docfor RoBoT DocFormatHtml.py - HTML ${EMPTY} DocFormatHtml.py + ${RAW DOC} TEXT -F TEXT DocFormat.py + ${RAW DOC} ROBOT --docfor RoBoT DocFormatHtml.py + ${RAW DOC} HTML ${EMPTY} DocFormatHtml.py + +Format in JSON RAW + [Template] Test Format in JSON + [Tags] require-jsonschema + ${RAW DOC} TEXT -F TEXT --specdocformat rAw DocFormat.py + ${RAW DOC} ROBOT --docfor RoBoT -s RAW DocFormatHtml.py + ${RAW DOC} HTML -s raw DocFormatHtml.py + +Format in LIBSPEC + [Template] Test Format in XML +

${HTML DOC}

HTML --format xMl --specdocformat hTML DocFormat.py +

${HTML DOC}

HTML --format LiBSpec DocFormat.py +

${HTML DOC}

HTML --docfor RoBoT -f XML -s HTML DocFormatHtml.py +

${HTML DOC}

HTML -F ROBOT --format xml -s html DocFormat.py + +Format in JSON + [Template] Test Format in JSON + [Tags] require-jsonschema +

${HTML DOC}

HTML --format jSoN --specdocformat hTML DocFormat.py +

${HTML DOC}

HTML --format jSoN DocFormat.py +

${HTML DOC}

HTML --docfor RoBoT -f JSON -s HTML DocFormatHtml.py +

${HTML DOC}

HTML -F ROBOT --format JSON -s html DocFormat.py Format from XML spec [Template] NONE - Run Libdoc -F HTML ${TESTDATADIR}/DocFormat.py ${OUTXML} - Copy File ${OUTXML} ${OUTPREFIX}-2.xml - Test Format In XML HTML lib=${OUTPREFIX}-2.xml + Test Format In XML ${RAW DOC} HTML -F HTML lib=DocFormat.py + Copy File ${OUTXML} ${OUTBASE}-2.xml + Test Format In XML ${RAW DOC} HTML lib=${OUTBASE}-2.xml + +Format from JSON RAW spec + [Template] NONE + [Tags] require-jsonschema + Test Format In JSON ${RAW DOC} ROBOT -F Robot -s RAW lib=DocFormat.py + Copy File ${OUTJSON} ${OUTBASE}-2.json + Test Format In JSON

${HTML DOC}

HTML lib=${OUTBASE}-2.json + +Format from LIBSPEC spec + [Template] NONE + Test Format In XML

${HTML DOC}

HTML -F ROBOT --format XML -s HTML lib=DocFormat.py + Copy File ${OUTXML} ${OUTBASE}-2.xml + Test Format In XML

${HTML DOC}

HTML lib=${OUTBASE}-2.xml + +Format from JSON spec + [Template] NONE + [Tags] require-jsonschema + Test Format In JSON

${HTML DOC}

HTML -F Robot lib=DocFormat.py + Copy File ${OUTJSON} ${OUTBASE}-2.json + Test Format In JSON

${HTML DOC}

HTML lib=${OUTBASE}-2.json + +Compare HTML from LIBSPEC + [Template] NONE + Run Libdoc -F ROBOT --format XML -s HTML ${TESTDATADIR}/DocFormat.py ${OUTXML} + Test Format In HTML ${HTML DOC} + ... lib=${OUTXML} *** Keywords *** Test Format In HTML - [Arguments] ${expected} ${cli}= ${lib}=DocFormat.py - ... ${expected2}=Link to Keyword. + [Arguments] ${doc} ${cli}= ${lib}=DocFormat.py + ... ${doc2}=Link to Keyword or not? + ... ${shortdoc}=*bold* or bold http://example.com ${lib} = Join Path ${TESTDATADIR} ${lib} Run Libdoc And Parse Model From HTML ${cli} ${lib} - Should Contain ${MODEL['doc']} ${expected} - Should Contain ${MODEL['keywords'][0]['doc']} ${expected} - Should Contain ${MODEL['keywords'][1]['doc']} ${expected2} + Should Contain ${MODEL}[doc] ${doc} + Should Contain ${MODEL}[keywords][0][doc] ${doc} + Should Contain ${MODEL}[keywords][1][doc] ${doc2} + Should Be Equal ${MODEL}[keywords][0][shortdoc] ${shortdoc} Test Format In XML - [Arguments] ${expected} ${cli}= ${lib}=DocFormat.py + [Arguments] ${expected} ${format} ${cli}= ${lib}=DocFormat.py ${lib} = Join Path ${TESTDATADIR} ${lib} Run Libdoc And Parse Output ${cli} ${lib} - Format should be ${expected} - Keyword Doc Should Be 0 *bold* or bold http://example.com + Format should be ${format} + Keyword Doc Should Be 0 ${expected} -Format should be - [Arguments] ${expected} - Element Attribute Should Be ${LIBDOC} format ${expected} +Test Format In JSON + [Arguments] ${expected} ${format} ${cli}= ${lib}=DocFormat.py + ${lib} = Join Path ${TESTDATADIR} ${lib} + Run Libdoc And Parse Model From JSON ${cli} ${lib} + Should Be Equal ${MODEL}[docFormat] ${format} + Should Be Equal ${MODEL}[keywords][0][doc] ${expected} diff --git a/atest/robot/libdoc/doc_string.robot b/atest/robot/libdoc/doc_string.robot new file mode 100644 index 00000000000..8f391f917c5 --- /dev/null +++ b/atest/robot/libdoc/doc_string.robot @@ -0,0 +1,212 @@ +*** Settings *** +Resource libdoc_resource.robot + +*** Variables *** +${LIBRARY} ${TESTDATADIR}/DocStringParsing.py +${INXML} ${OUTBASE}-2.xml +${INJSON} ${OUTBASE}-2.json + +*** Test Cases *** +Python to XML with HTML docs + Run Libdoc And Parse Output --spec-doc-format HTML ${LIBRARY} + Validate XML Spec With HTML Docs + +Python to XML with raw docs + Run Libdoc And Parse Output ${LIBRARY} + Validate XML Spec With Raw Docs + [Teardown] Copy File ${OUTXML} ${INXML} + +XML as input + [Setup] File Should Exist ${INXML} + Run Libdoc And Parse Output ${INXML} + Validate XML Spec With Raw Docs + +Python to JSON with HTML docs + Run Libdoc And Parse Model From JSON ${LIBRARY} + Validate JSON Spec With HTML Docs + +Python to JSON with raw docs + Run Libdoc And Parse Model From JSON --spec-doc-format RAW ${LIBRARY} + Validate JSON Spec With Raw Docs + [Teardown] Copy File ${OUTJSON} ${INJSON} + +JSON as input + [Setup] File Should Exist ${INJSON} + Run Libdoc And Parse Model From JSON ${INJSON} + Validate JSON Spec With HTML Docs + +Dynamic library as input + Run Libdoc And Parse Output ${TESTDATADIR}/DynamicLibrary.py::arg + Validate XML Spec From Dynamic Library + +*** Keywords *** +Validate XML Spec With HTML Docs + Init Doc Should Be 0 + ... ${EMPTY} + Argument Doc Should Be init 0 arg + ...

Documentation for __init__ argument arg.

+ Keyword Doc Should Be 0 + ...

Example with everything!

+ ...

This is the second paragraph. We also have an indented + ... example:

+ ...
keyword(1, 2, kwonly=3, extra=4)
+    ...    
+ ... + ...

The end.

+ Argument Doc Should Be 0 0 own_line + ...

Documentation on same line.

+ Argument Doc Should Be 0 1 multiline + ...

Longer documentation that + ... spans multiple lines.

+ ...
Indentation is preserved.
+    ...    
+ Argument Doc Should Be 0 2 next_line + ...

Documentation on next line.

+ Argument Doc Should Be 0 3 empty_doc + Return Doc Should Be 0 + ...

Something useless.

+ ...

On multiple lines + ... ${SPACE*8}with indentation.

+ Keyword Doc Should Be 1 + ...

Creating keyword failed: Documentation given to non-existing argument 'non_existing'.

+ Argument Doc Should Be 1 0 not_set + Return Doc Should Be 1 + ...

Zero

+ Raises Should Be 0 + ... ValueError=

If something goes wrong.

+ ... TypeError=

Should not happen.

+ Raises Should Be 1 + +Validate XML Spec With Raw Docs + Init Doc Should Be 0 + ... ${EMPTY} + Argument Doc Should Be init 0 arg + ... Documentation for `__init__` argument `arg`. + Keyword Doc Should Be 0 + ... Example with *everything*! + ... + ... This is the second paragraph. We also have an indented + ... example: + ... + ... ${SPACE*4}keyword(1, 2, kwonly=3, extra=4) + ... + ... The end. + Argument Doc Should Be 0 0 own_line + ... Documentation on same line. + Argument Doc Should Be 0 1 multiline + ... Longer documentation that + ... spans multiple lines. + ... + ... ${SPACE*4}Indentation is preserved. + Argument Doc Should Be 0 2 next_line + ... Documentation on next line. + Argument Doc Should Be 0 3 empty_doc + Return Doc Should Be 0 + ... Something useless. + ... + ... On multiple lines + ... ${SPACE*8}with indentation. + Keyword Doc Should Be 1 + ... *Creating keyword failed:* Documentation given to non-existing argument 'non_existing'. + Argument Doc Should Be 1 0 not_set + Return Doc Should Be 1 + ... Zero + Raises Should Be 0 + ... ValueError=If something goes wrong. + ... TypeError=Should *not* happen. + Raises Should Be 1 + +Validate JSON Spec With HTML Docs + Should Be Equal Multiline ${MODEL}[inits][0][doc] + Should Be Equal Multiline ${MODEL}[inits][0][args][0][doc] + ...

Documentation for __init__ argument arg.

+ Should Be Equal Multiline ${MODEL}[keywords][0][doc] + ...

Example with everything!

+ ...

This is the second paragraph. We also have an indented + ... example:

+ ...
keyword(1, 2, kwonly=3, extra=4)
+    ...    
+ ... + ...

The end.

+ Should Be Equal Multiline ${MODEL}[keywords][0][args][0][doc] + ...

Documentation on same line.

+ Should Be Equal Multiline ${MODEL}[keywords][0][args][1][doc] + ...

Longer documentation that + ... spans multiple lines.

+ ...
Indentation is preserved.
+    ...    
+ Should Be Equal Multiline ${MODEL}[keywords][0][args][2][doc] + ...

Documentation on next line.

+ Should Be Equal Multiline ${MODEL}[keywords][0][args][3][doc] + Should Be Equal Multiline ${MODEL}[keywords][0][returnDoc] + ...

Something useless.

+ ...

On multiple lines + ... ${SPACE*8}with indentation.

+ Should Be Equal Multiline ${MODEL}[keywords][1][doc] + ...

Creating keyword failed: Documentation given to non-existing argument 'non_existing'.

+ Should Be Equal Multiline ${MODEL}[keywords][1][args][0][doc] + Should Be Equal Multiline ${MODEL}[keywords][1][returnDoc] + ...

Zero

+ Length Should Be ${MODEL}[keywords][0][raises] 2 + Length Should Be ${MODEL}[keywords][1][raises] 0 + Should Be Equal Multiline ${MODEL}[keywords][0][raises][ValueError] + ...

If something goes wrong.

+ Should Be Equal Multiline ${MODEL}[keywords][0][raises][TypeError] + ...

Should not happen.

+ +Validate JSON Spec With Raw Docs + Should Be Equal Multiline ${MODEL}[inits][0][doc] + Should Be Equal Multiline ${MODEL}[inits][0][args][0][doc] + ... Documentation for `__init__` argument `arg`. + Should Be Equal Multiline ${MODEL}[keywords][0][doc] + ... Example with *everything*! + ... + ... This is the second paragraph. We also have an indented + ... example: + ... + ... ${SPACE*4}keyword(1, 2, kwonly=3, extra=4) + ... + ... The end. + Should Be Equal Multiline ${MODEL}[keywords][0][args][0][doc] + ... Documentation on same line. + Should Be Equal Multiline ${MODEL}[keywords][0][args][1][doc] + ... Longer documentation that + ... spans multiple lines. + ... + ... ${SPACE*4}Indentation is preserved. + Should Be Equal Multiline ${MODEL}[keywords][0][args][2][doc] + ... Documentation on next line. + Should Be Equal Multiline ${MODEL}[keywords][0][args][3][doc] + Should Be Equal Multiline ${MODEL}[keywords][0][returnDoc] + ... Something useless. + ... + ... On multiple lines + ... ${SPACE*8}with indentation. + Should Be Equal Multiline ${MODEL}[keywords][1][doc] + ... *Creating keyword failed:* Documentation given to non-existing argument 'non_existing'. + Should Be Equal Multiline ${MODEL}[keywords][1][args][0][doc] + Should Be Equal Multiline ${MODEL}[keywords][1][returnDoc] + ... Zero + Length Should Be ${MODEL}[keywords][0][raises] 2 + Length Should Be ${MODEL}[keywords][1][raises] 0 + Should Be Equal Multiline ${MODEL}[keywords][0][raises][ValueError] + ... If something goes wrong. + Should Be Equal Multiline ${MODEL}[keywords][0][raises][TypeError] + ... Should *not* happen. + +Validate XML Spec From Dynamic Library + Init Doc Should Be 0 + ... Dummy documentation for `__init__`. + Argument Doc Should Be init 0 arg1 + ... Doc for `arg1`. + Argument Doc Should Be init 1 arg2 + ... Doc for `arg2`. + Keyword Doc Should Start With 6 + ... Dummy documentation for `Keyword-only args`. + ... + ... Neither `Keyword 1` or `KW 2` do anything really interesting. + Argument Doc Should Be 6 0 ${EMPTY} + Argument Doc Should Be 6 1 kwo + ... Doc for `kwo`. + Argument Doc Should Be 6 2 another + ... Doc for `another`. diff --git a/atest/robot/libdoc/dynamic_library.robot b/atest/robot/libdoc/dynamic_library.robot index 2acc2be3b00..f5375745be6 100644 --- a/atest/robot/libdoc/dynamic_library.robot +++ b/atest/robot/libdoc/dynamic_library.robot @@ -13,60 +13,118 @@ Version Version Should Match 0.1 Type - Type Should Be library + Type Should Be LIBRARY Generated Generated Should Be Defined Scope - Scope Should Be test case + Scope Should Be TEST -Named Args - Named Args Should Be yes +Source info + Source should be ${TESTDATADIR}/DynamicLibrary.py + Lineno should be 5 -Init Documentation - Init Doc Should Start With 0 Dummy documentation for `__init__`. +Spec version + Spec version should be correct -Init Arguments - Init Arguments Should Be 0 arg1 arg2=This is shown in docs +Library Tags + Specfile Tags Should Be hyvää my tägs yötä -Keyword Names - Keyword Name Should Be 0 0 - Keyword Name Should Be 3 Keyword 1 - Keyword Name Should Be 5 KW 2 +Init documentation + Init Doc Should Start With 0 Dummy documentation for `__init__`. -Keyword Arguments +Init arguments + Init Arguments Should Be 0 arg1 arg2=These args are shown in docs + +Init Source Info + Keyword Should Not Have Source 0 xpath=inits/init + Keyword Lineno Should Be 0 10 xpath=inits/init + +Keyword names + Keyword Name Should Be 0 0 + Keyword Name Should Be 5 Keyword 1 + Keyword Name Should Be 7 KW 2 + +Keyword arguments Keyword Arguments Should Be 0 - Keyword Arguments Should Be 3 arg1 - Keyword Arguments Should Be 5 arg1 arg2 + Keyword Arguments Should Be 5 arg1 + Keyword Arguments Should Be 7 arg1 arg2 -Keyword-only Arguments - Keyword Arguments Should Be 4 * kwo another=default - Keyword Arguments Should Be 6 *varargs a b=2 c **kws +Default values + Keyword Arguments Should Be 1 old=style new=style cool=True +Keyword-only arguments + Keyword Arguments Should Be 6 * kwo another=default + Keyword Arguments Should Be 8 *varargs a b=2 c **kws -Embedded Arguments - Keyword Name Should Be 1 Em\${bed}ed \${args} 2 - Keyword Name Should Be 2 Embedded \${args} 1 - Keyword Arguments Should Be 1 +Embedded arguments + Keyword Name Should Be 2 Em\${bed}ed \${args} 2 + Keyword Name Should Be 3 Embedded \${args} 1 Keyword Arguments Should Be 2 + Keyword Arguments Should Be 3 -Keyword Documentation - Keyword Doc Should Start With 0 Dummy documentation for `0`. - Keyword Doc Should Start With 3 Dummy documentation for `Keyword 1`. - Keyword Doc Should Start With 5 Dummy documentation for `KW2`. +Keyword documentation + Keyword Doc Should Start With 0 Dummy documentation for `0`. + Keyword Doc Should Start With 5 Dummy documentation for `Keyword 1`. + Keyword Doc Should Start With 7 Dummy documentation for `KW2`. Non-ASCII Unicode - Keyword Name Should Be 8 Nön-äscii Ünicöde - Keyword Doc Should Be 8 Hyvää yötä.\n\nСпасибо! (Unicode) + Keyword Name Should Be 12 Nön-äscii Ünicöde + Keyword Doc Should Be 12 Hyvää yötä.\n\nСпасибо! (Unicode) Non-ASCII UTF-8 - Keyword Name Should Be 9 Nön-äscii ÜTF-8 - Keyword Doc Should Be 9 Hyvää yötä.\n\nСпасибо! (UTF-8) - -No Argspec - Keyword Arguments Should be 7 *varargs **kwargs - -Keyword Tags - Keyword Tags Should Be 8 hyvää yötä - Keyword Tags Should Be 9 hyvää yötä + Keyword Name Should Be 13 Nön-äscii ÜTF-8 + Keyword Doc Should Be 13 Hyvää yötä.\n\nСпасибо! (UTF-8) + +No argspec + Keyword Arguments Should be 9 *varargs **kwargs + +Keyword tags + Keyword Tags Should Be 17 my tägs + +Keyword tags from documentation + Keyword Tags Should Be 12 hyvää yötä + Keyword Tags Should Be 13 hyvää yötä + +Keyword types + Keyword Arguments Should Be 18 integer: int no type boolean: bool = True + +Return type + Return Type Should Be 18 int + +No keyword source info + Keyword Name Should Be 0 0 + Keyword Should Not Have Source 0 + Keyword Should Not Have Lineno 0 + +Keyword source info + Keyword Name Should Be 14 Source Info + Keyword Should Not Have Source 14 + Keyword Lineno Should Be 14 104 + +Keyword source info with different path than library + Keyword Name Should Be 16 Source Path Only + Keyword Source Should Be 16 ${TESTDATADIR}/Annotations.py + Keyword Should Not Have Lineno 16 + +Keyword source info with lineno only + Keyword Name Should Be 15 Source Lineno Only + Keyword Should Not Have Source 15 + Keyword Lineno Should Be 15 12345 + +Non-existing source info + Keyword Name Should Be 10 Non-existing Source Path And Lineno + Keyword Source Should Be 10 whatever:xxx + Keyword Should Not Have Lineno 10 + Keyword Name Should Be 11 Non-existing Source Path With Lineno + Keyword Source Should Be 11 everwhat + Keyword Lineno Should Be 11 42 + +Invalid source info + ${error} = Catenate + ... [ ERROR ] Error in library 'DynamicLibrary': + ... Getting source information for keyword 'Invalid Source Info' failed: + ... Calling dynamic method 'get_keyword_source' failed: + ... Return value must be a string, got integer. + Should Start With ${OUTPUT} ${error} diff --git a/atest/robot/libdoc/dynamic_library_without_get_kw_args_and_doc.robot b/atest/robot/libdoc/dynamic_library_without_get_kw_args_and_doc.robot index 5fd8b34c51d..7895829c574 100644 --- a/atest/robot/libdoc/dynamic_library_without_get_kw_args_and_doc.robot +++ b/atest/robot/libdoc/dynamic_library_without_get_kw_args_and_doc.robot @@ -13,16 +13,13 @@ Version Version Should Be ${EMPTY} Type - Type Should Be library + Type Should Be LIBRARY Generated Generated Should Be Defined Scope - Scope Should Be test case - -Named Args - Named Args Should Be yes + Scope Should Be TEST Init Init Doc Should Be 0 Static __init__ doc. diff --git a/atest/robot/libdoc/html_output.robot b/atest/robot/libdoc/html_output.robot index 19e9bc78b24..dddae307f3e 100644 --- a/atest/robot/libdoc/html_output.robot +++ b/atest/robot/libdoc/html_output.robot @@ -1,69 +1,136 @@ *** Settings *** Resource libdoc_resource.robot Suite Setup Run Libdoc And Parse Model From HTML ${TESTDATADIR}/module.py -Test Template Should Be Equal +Test Template Should Be Equal Multiline *** Test Cases *** Name - ${MODEL['name']} module + ${MODEL}[name] module Documentation - ${MODEL['doc']}

Module test library.

+ ${MODEL}[doc]

Module test library.

Version - ${MODEL['version']} 0.1-alpha + ${MODEL}[version] 0.1-alpha 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 - -Named Args - ${MODEL['named_args']} ${True} + ${MODEL}[scope] GLOBAL Inits [Template] Should Be Empty - ${MODEL['inits']} + ${MODEL}[inits] Keyword Names - ${MODEL['keywords'][0]['name']} Get Hello - ${MODEL['keywords'][1]['name']} Keyword - ${MODEL['keywords'][9]['name']} Set Name Using Robot Name Attribute + ${MODEL}[keywords][0][name] Get Hello + ${MODEL}[keywords][1][name] Keyword + ${MODEL}[keywords][12][name] Set Name Using Robot Name Attribute Keyword Arguments - [Template] Should Be Equal As Strings - ${MODEL['keywords'][0]['args']} [] - ${MODEL['keywords'][1]['args']} ['a1=d', '*a2'] - ${MODEL['keywords'][9]['args']} ['a', 'b', '*args', '**kwargs'] + [Template] Verify Argument Models + ${MODEL}[keywords][0][args] + ${MODEL}[keywords][1][args] a1=d *a2 + ${MODEL}[keywords][6][args] arg=hyvä + ${MODEL}[keywords][9][args] arg=hyvä + ${MODEL}[keywords][10][args] a=1 b=True c=(1, 2, None) + ${MODEL}[keywords][11][args] arg=\\ robot \\ escapers\\n\\t\\r \\ \\ + ${MODEL}[keywords][12][args] a b *args **kwargs Embedded Arguments [Template] NONE - Should Be Equal ${MODEL['keywords'][10]['name']} Takes \${embedded} \${args} - Should Be Empty ${MODEL['keywords'][10]['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']}

A keyword.

\n

See get hello for details.

- ${MODEL['keywords'][0]['doc']}

Get hello.

\n

See importing for explanation of nothing and introduction for no more information.

- ${MODEL['keywords'][5]['doc']}

This is short doc. It can span multiple physical lines.

\n

This is body. It can naturally also contain multiple lines.

\n

And paragraphs.

+ ${MODEL}[keywords][1][doc] + ...

A keyword.

+ ...

See get hello for details.

+ ${MODEL}[keywords][0][doc] + ...

Get hello.

+ ...

See importing for explanation of nothing and introduction for no more information.

+ ${MODEL}[keywords][5][doc] + ...

This is short doc. It can span multiple physical lines and contain formatting.

+ ...

This is body. It can naturally also contain multiple lines.

+ ...

And paragraphs.

Non-ASCII Keyword Documentation - ${MODEL['keywords'][6]['doc']}

Hyvää yötä.

\n

Спасибо!

- ${MODEL['keywords'][8]['doc']}

Hyvää yötä.

+ ${MODEL}[keywords][7][doc]

Hyvää yötä.

\n

Спасибо!

+ ${MODEL}[keywords][8][doc]

Hyvää yötä.

Keyword Short Doc - ${MODEL['keywords'][1]['shortdoc']} A keyword. - ${MODEL['keywords'][0]['shortdoc']} Get hello. - ${MODEL['keywords'][6]['shortdoc']} Hyvää yötä. - ${MODEL['keywords'][8]['shortdoc']} Hyvää yötä. + ${MODEL}[keywords][0][shortdoc] Get hello. + ${MODEL}[keywords][1][shortdoc] A keyword. + ${MODEL}[keywords][7][shortdoc] Hyvää yötä. + ${MODEL}[keywords][8][shortdoc] Hyvää yötä. Keyword Short Doc Spanning Multiple Physical Lines - ${MODEL['keywords'][5]['shortdoc']} This is short doc. It can span multiple physical lines. + ${MODEL}[keywords][5][shortdoc] This is short doc. It can span multiple physical lines and contain *formatting*. Keyword tags [Template] Should Be Equal As Strings - ${MODEL['keywords'][1]['tags']} [] - ${MODEL['keywords'][2]['tags']} ['1', 'one', 'yksi'] - ${MODEL['keywords'][3]['tags']} ['2', 'kaksi', 'two'] - ${MODEL['keywords'][4]['tags']} ['tag1', 'tag2'] + ${MODEL}[keywords][1][tags] [] + ${MODEL}[keywords][2][tags] ['1', 'one', 'yksi'] + ${MODEL}[keywords][3][tags] ['2', 'kaksi', 'two'] + ${MODEL}[keywords][4][tags] ['tag1', 'tag2'] + +User keyword documentation formatting + [Setup] Run Libdoc And Parse Model From HTML ${TESTDATADIR}/resource.robot + ${MODEL}[keywords][0][doc]

$\{CURDIR}

+ ${MODEL}[keywords][1][doc]

DEPRECATED for some reason.

+ ${MODEL}[keywords][2][doc] + ${MODEL}[keywords][10][doc] + ...

Hyvää yötä.

+ ...

Спасибо!

+ ${MODEL}[keywords][8][doc] + ...

foo bar kw.

+ ...

FIRST \${a1} alskdj alskdjlajd askf laskdjf asldkfj alsdkfj alsdkfjasldkfj END

+ ...

SECOND askf laskdjf asldkfj alsdkfj alsdkfjasldkfj askf laskdjf END

+ ...

THIRD asldkfj introduction alsdkfj http://foo.bar END

+ ...
    + ...
  • aaa
  • + ...
  • bbb
  • + ...
+ ...
+ ... + ... + ... + ... + ... + ... + ... + ... + ... + ...
firstsecond
foobar
+ ...

Tags not only on the last line is new in RF 7.5.

+ +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 + +All tags does not include tags from private keywords + ${MODEL}[tags] ['\${3}', '?!?!??', 'a', 'b', 'bar', 'common', 'dar', 'foo', 'Has', 'kw4', 'tags'] + +*** 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/html_output_from_json.robot b/atest/robot/libdoc/html_output_from_json.robot new file mode 100644 index 00000000000..a070cacdbda --- /dev/null +++ b/atest/robot/libdoc/html_output_from_json.robot @@ -0,0 +1,74 @@ +*** Settings *** +Resource libdoc_resource.robot +Suite Setup Run Libdoc to JSON and to HTML and Parse Models ${TESTDATADIR}/module.py +Test Template Should Be Equal As Strings + +*** Test Cases *** +Name + ${JSON-MODEL}[name] ${MODEL}[name] + +Documentation + ${JSON-MODEL}[doc] ${MODEL}[doc] + +Version + ${JSON-MODEL}[version] ${MODEL}[version] + +Scope + ${JSON-MODEL}[scope] ${MODEL}[scope] + +Inits + ${JSON-MODEL}[inits] ${MODEL}[inits] + +Keyword Names + ${JSON-MODEL}[keywords][0][name] ${MODEL}[keywords][0][name] + ${JSON-MODEL}[keywords][1][name] ${MODEL}[keywords][1][name] + ${JSON-MODEL}[keywords][11][name] ${MODEL}[keywords][11][name] + +Keyword Arguments + [Template] List of Dict Should Be Equal + ${JSON-MODEL}[keywords][0][args] ${MODEL}[keywords][0][args] + ${JSON-MODEL}[keywords][1][args] ${MODEL}[keywords][1][args] + ${JSON-MODEL}[keywords][6][args] ${MODEL}[keywords][6][args] + ${JSON-MODEL}[keywords][9][args] ${MODEL}[keywords][9][args] + ${JSON-MODEL}[keywords][10][args] ${MODEL}[keywords][10][args] + ${JSON-MODEL}[keywords][11][args] ${MODEL}[keywords][11][args] + ${JSON-MODEL}[keywords][12][args] ${MODEL}[keywords][12][args] + +Embedded Arguments names + ${JSON-MODEL}[keywords][13][name] ${MODEL}[keywords][13][name] + +Embedded Arguments arguments + [Template] List of Dict Should Be Equal + ${JSON-MODEL}[keywords][13][args] ${MODEL}[keywords][13][args] + +Keyword Documentation + ${JSON-MODEL}[keywords][0][doc] ${MODEL}[keywords][0][doc] + ${JSON-MODEL}[keywords][1][doc] ${MODEL}[keywords][1][doc] + ${JSON-MODEL}[keywords][5][doc] ${MODEL}[keywords][5][doc] + ${JSON-MODEL}[keywords][7][doc] ${MODEL}[keywords][7][doc] + ${JSON-MODEL}[keywords][8][doc] ${MODEL}[keywords][8][doc] + +Keyword Short Doc + ${JSON-MODEL}[keywords][0][shortdoc] ${MODEL}[keywords][0][shortdoc] + ${JSON-MODEL}[keywords][1][shortdoc] ${MODEL}[keywords][1][shortdoc] + ${JSON-MODEL}[keywords][7][shortdoc] ${MODEL}[keywords][7][shortdoc] + ${JSON-MODEL}[keywords][8][shortdoc] ${MODEL}[keywords][8][shortdoc] + +Keyword tags + ${JSON-MODEL}[keywords][1][tags] ${MODEL}[keywords][1][tags] + ${JSON-MODEL}[keywords][2][tags] ${MODEL}[keywords][2][tags] + ${JSON-MODEL}[keywords][3][tags] ${MODEL}[keywords][3][tags] + ${JSON-MODEL}[keywords][4][tags] ${MODEL}[keywords][4][tags] + +TOC doc + [Template] None + Run Libdoc to JSON and to HTML and Parse Models ${TESTDATADIR}/TOCWithInitsAndKeywords.py + Should Be Equal As Strings ${JSON-MODEL}[doc] ${MODEL}[doc] + +*** Keywords *** +Run Libdoc to JSON and to HTML and Parse Models + [Arguments] ${library_path} + Run Libdoc And Set Output ${library_path} ${OUTJSON} + Run Libdoc And Parse Model From HTML ${OUTJSON} + Set Suite Variable ${JSON-MODEL} ${MODEL} + Run Libdoc And Parse Model From HTML ${library_path} diff --git a/atest/robot/libdoc/html_output_from_libspec.robot b/atest/robot/libdoc/html_output_from_libspec.robot new file mode 100644 index 00000000000..11d276c8eaa --- /dev/null +++ b/atest/robot/libdoc/html_output_from_libspec.robot @@ -0,0 +1,74 @@ +*** Settings *** +Resource libdoc_resource.robot +Suite Setup Run Libdoc to XML with HTML docs and to HTML and Parse Models ${TESTDATADIR}/module.py +Test Template Should Be Equal As Strings + +*** Test Cases *** +Name + ${XML-MODEL}[name] ${MODEL}[name] + +Documentation + ${XML-MODEL}[doc] ${MODEL}[doc] + +Version + ${XML-MODEL}[version] ${MODEL}[version] + +Scope + ${XML-MODEL}[scope] ${MODEL}[scope] + +Inits + ${XML-MODEL}[inits] ${MODEL}[inits] + +Keyword Names + ${XML-MODEL}[keywords][0][name] ${MODEL}[keywords][0][name] + ${XML-MODEL}[keywords][1][name] ${MODEL}[keywords][1][name] + ${XML-MODEL}[keywords][11][name] ${MODEL}[keywords][11][name] + +Keyword Arguments + [Template] List of Dict Should Be Equal + ${XML-MODEL}[keywords][0][args] ${MODEL}[keywords][0][args] + ${XML-MODEL}[keywords][1][args] ${MODEL}[keywords][1][args] + ${XML-MODEL}[keywords][6][args] ${MODEL}[keywords][6][args] + ${XML-MODEL}[keywords][9][args] ${MODEL}[keywords][9][args] + ${XML-MODEL}[keywords][10][args] ${MODEL}[keywords][10][args] + ${XML-MODEL}[keywords][11][args] ${MODEL}[keywords][11][args] + ${XML-MODEL}[keywords][12][args] ${MODEL}[keywords][12][args] + +Embedded Arguments names + ${XML-MODEL}[keywords][13][name] ${MODEL}[keywords][13][name] + +Embedded Arguments arguments + [Template] List of Dict Should Be Equal + ${XML-MODEL}[keywords][13][args] ${MODEL}[keywords][13][args] + +Keyword Documentation + ${XML-MODEL}[keywords][0][doc] ${MODEL}[keywords][0][doc] + ${XML-MODEL}[keywords][1][doc] ${MODEL}[keywords][1][doc] + ${XML-MODEL}[keywords][5][doc] ${MODEL}[keywords][5][doc] + ${XML-MODEL}[keywords][7][doc] ${MODEL}[keywords][7][doc] + ${XML-MODEL}[keywords][8][doc] ${MODEL}[keywords][8][doc] + +Keyword Short Doc + ${XML-MODEL}[keywords][0][shortdoc] ${MODEL}[keywords][0][shortdoc] + ${XML-MODEL}[keywords][1][shortdoc] ${MODEL}[keywords][1][shortdoc] + ${XML-MODEL}[keywords][7][shortdoc] ${MODEL}[keywords][7][shortdoc] + ${XML-MODEL}[keywords][8][shortdoc] ${MODEL}[keywords][8][shortdoc] + +Keyword tags + ${XML-MODEL}[keywords][1][tags] ${MODEL}[keywords][1][tags] + ${XML-MODEL}[keywords][2][tags] ${MODEL}[keywords][2][tags] + ${XML-MODEL}[keywords][3][tags] ${MODEL}[keywords][3][tags] + ${XML-MODEL}[keywords][4][tags] ${MODEL}[keywords][4][tags] + +TOC doc + [Template] None + Run Libdoc to XML with HTML docs and to HTML and Parse Models ${TESTDATADIR}/TOCWithInitsAndKeywords.py + Should Be Equal As Strings ${XML-MODEL}[doc] ${MODEL}[doc] + +*** Keywords *** +Run Libdoc to XML with HTML docs and to HTML and Parse Models + [Arguments] ${library_path} + Run Libdoc And Set Output --format XML --specdocformat HTML ${library_path} ${OUTXML} + Run Libdoc And Parse Model From HTML ${OUTXML} + Set Suite Variable ${XML-MODEL} ${MODEL} + Run Libdoc And Parse Model From HTML ${library_path} diff --git a/atest/robot/libdoc/internal_linking.robot b/atest/robot/libdoc/internal_linking.robot index 9980c0cbf5b..b9b41003dd7 100644 --- a/atest/robot/libdoc/internal_linking.robot +++ b/atest/robot/libdoc/internal_linking.robot @@ -5,78 +5,146 @@ Test Template Doc Should Contain Link *** Test Cases *** Linking to sections in introduction - ${MODEL} Introduction Introduction - ${MODEL} Introduction Library INTROduction - ${MODEL} Importing importing - ${MODEL} Importing Library Importing - ${MODEL} Shortcuts ShortCuts - ${MODEL} Keywords Keywords + ${MODEL} + ... Introduction + ... Introduction + ... "Introduction" section + ${MODEL} + ... Library INTROduction + ... Introduction + ... "Introduction" section + ${MODEL} + ... importing + ... Importing + ... "Importing" section + ${MODEL} + ... Library Importing + ... Importing + ... "Importing" section + ${MODEL} + ... Keywords + ... Keywords + ... "Keywords" section Linking to sections in importing and keywords - ${MODEL['inits'][0]} Introduction introduction - ${MODEL['keywords'][1]} Importing Importing - ${MODEL['keywords'][2]} Shortcuts shortcuts + ${MODEL}[inits][0] + ... introduction + ... Introduction + ... "Introduction" section + ${MODEL}[keywords][1] + ... Importing + ... Importing + ... "Importing" section Linking to keywords in introduction - ${MODEL} Keyword Keyword - ${MODEL} Second%20Keyword secoNd kEywoRD + ${MODEL} + ... Keyword + ... Keyword + ... "Keyword" keyword + ${MODEL} + ... secoNd kEywoRD + ... Second%20Keyword + ... "Second Keyword" keyword Linking to keywords in importing and keywords - ${MODEL['inits'][0]} Keyword keyword - ${MODEL['keywords'][1]} Second%20Keyword Second Keyword + ${MODEL}[inits][0] + ... keyword + ... Keyword + ... "Keyword" keyword + ${MODEL}[keywords][1] + ... Second Keyword + ... Second%20Keyword + ... "Second Keyword" keyword + +Linking to types + ${MODEL} + ... str + ... type-string + ... "string" type + ${MODEL} + ... float + ... type-float + ... "float" type Non-matching text in backticks gets formatting [Template] Doc Should Contain Name - ${MODEL} backticks - ${MODEL['keywords'][2]} arg - ${MODEL['keywords'][2]} no link + ${MODEL} backticks + ${MODEL}[keywords][2] arg + ${MODEL}[keywords][2] no link Linking to first level headers in introduction [Template] NONE - Doc Should Contain Link ${MODEL} Linking%20to%20headers linking to headers - Doc Should Contain Link ${MODEL} First%20%3D%20Level%20%3D first = level = + Doc Should Contain Link ${MODEL} + ... linking to headers + ... Linking%20to%20headers + ... "Linking to headers" section + Doc Should Contain Link ${MODEL} + ... first = level = + ... First%20%3D%20Level%20%3D + ... "First = Level =" section Doc Should Contain ${MODEL}

Linking to headers

Doc Should Contain ${MODEL}

First = Level =

Linking to first level headers in importing and keywords - ${MODEL['inits'][0]} Formatting formatting - ${MODEL['keywords'][2]} Linking%20to%20headers linking to headers + ${MODEL}[inits][0] + ... formatting + ... Formatting + ... "Formatting" section + ${MODEL}[keywords][2] + ... linking to headers + ... Linking%20to%20headers + ... "Linking to headers" section Linking to second and third level headers [Template] NONE - Doc Should Contain Link ${MODEL} Second%20level Second level - Doc Should Contain Link ${MODEL} Third%20level third level - Doc Should Contain Link ${MODEL['keywords'][2]} Second%20level Second LEVEL + Doc Should Contain Link ${MODEL} + ... Second level + ... Second%20level + ... "Second level" section + Doc Should Contain Link ${MODEL} + ... third level + ... Third%20level + ... "Third level" section + Doc Should Contain Link ${MODEL}[keywords][2] + ... Second LEVEL + ... Second%20level + ... "Second level" section Doc Should Contain ${MODEL}

Second level

Doc Should Contain ${MODEL}

Third level

Only headers in introduction are linkable + [Documentation] Starting from RF 7.5 all headers get id, though. [Template] NONE - Doc Should Contain Name ${MODEL['keywords'][2]} not linkable - Doc Should Contain ${MODEL['keywords'][2]}

Not linkable

+ Doc Should Contain Name ${MODEL}[keywords][2] not linkable + Doc Should Contain ${MODEL}[keywords][2]

Not linkable

Special characters are percent encoded - ${MODEL['keywords'][0]} - ... Percent%20encoding%3A%20!%22%23%25%2F()%3D%3F%7C%2B-_.!~*'() + ${MODEL}[keywords][0] ... Percent encoding: !"#%/()=?|+-_.!~*'() + ... Percent%20encoding%3A%20!%22%23%25%2F()%3D%3F%7C%2B-_.!~*'() + ... "Percent encoding: !"#%/()=?|+-_.!~*'()" section HTML entities are escaped also in name - ${MODEL['keywords'][0]} - ... HTML%20entities%3A%20%26%3C%3E HTML entities: &<> + ${MODEL}[keywords][0] + ... HTML entities: &<> + ... HTML%20entities%3A%20%26%3C%3E + ... "HTML entities: &<>" section Non-ASCII is encoded - ${MODEL['keywords'][0]} - ... Non-ASCII%3A%20%C3%A4%E2%98%83 Non-ASCII: ä\u2603 + ${MODEL}[keywords][0] + ... Non-ASCII: ä\u2603 + ... Non-ASCII%3A%20%C3%A4%E2%98%83 + ... "Non-ASCII: ä☃" section *** Keywords *** Doc Should Contain Link - [Arguments] ${object} ${target} ${text} - Doc Should Contain ${object} ${text} + [Arguments] ${object} ${text} ${target} ${title} + Doc Should Contain ${object} ${text} Doc Should Contain Name [Arguments] ${object} ${text} - Doc Should Contain ${object} ${text} + Doc Should Contain ${object} ${text} Doc Should Contain [Arguments] ${object} ${text} - Should Contain ${object['doc']} ${text} + Should Contain ${object}[doc] ${text} diff --git a/atest/robot/libdoc/invalid_library_keywords.robot b/atest/robot/libdoc/invalid_library_keywords.robot index 520fa4f013c..f923179c717 100644 --- a/atest/robot/libdoc/invalid_library_keywords.robot +++ b/atest/robot/libdoc/invalid_library_keywords.robot @@ -6,7 +6,9 @@ Resource libdoc_resource.robot Dublicate name Keyword Name Should Be 0 Duplicate Name Keyword Doc Should Be 0 *Creating keyword failed:* Keyword with same name defined multiple times. - Stdout should contain error Duplicate Name Keyword with same name defined multiple times + Stdout should contain adding keyword error + ... Duplicate Name + ... Keyword with same name defined multiple times. Dublicate name with embedded arguments Keyword Name Should Be 1 Same \${embedded} @@ -16,15 +18,12 @@ Dublicate name with embedded arguments Invalid embedded arguments Keyword Count Should Be 3 - ${error} = Catenate - ... [ ERROR ] Adding keyword 'Invalid embedded \${args}' to library 'InvalidKeywords' failed: - ... Embedded argument count does not match number of accepted arguments. - Should Contain ${OUTPUT} ${error} + Stdout should contain adding keyword error + ... Invalid embedded \${args} + ... Keyword must accept at least as many positional arguments as it has embedded arguments. *** Keywords *** -Stdout should contain error +Stdout should contain adding keyword error [Arguments] ${name} ${error} - ${message} = Catenate - ... [ ERROR ] Error in test library 'InvalidKeywords': - ... Creating keyword '${name}' failed: ${error} - Should Contain ${OUTPUT} ${message} + Should Contain ${OUTPUT} + ... [ ERROR ] Error in library 'InvalidKeywords': Adding keyword '${name}' failed: ${error} diff --git a/atest/robot/libdoc/invalid_usage.robot b/atest/robot/libdoc/invalid_usage.robot index c3dfa4249d5..19650d97fe1 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 @@ -18,20 +17,34 @@ Invalid option --invalid option --invalid not recognized Invalid format - -f XXX BuiltIn ${OUT HTML} Format must be 'HTML' or 'XML', got 'XXX'. - BuiltIn out.ext Format must be 'HTML' or 'XML', got 'EXT'. + -f XXX BuiltIn ${OUT HTML} Format must be 'HTML', 'XML', 'JSON' or 'LIBSPEC', got 'XXX'. + --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'. + --specdocformat MD BuiltIn ${OUT HTML} Spec doc format must be 'RAW' or 'HTML', got 'MD'. + +Invalid specdocformat for HTML output format + --specdocformat HTML BuiltIn ${OUT HTML} The --specdocformat option is not applicable with HTML outputs. Invalid doc format - --docformat inv BuiltIn ${OUT HTML} Doc format must be 'ROBOT', 'TEXT', 'HTML' or 'REST', got 'INV'. + --docformat inv BuiltIn ${OUT HTML} Doc format must be 'ROBOT', 'MARKDOWN', 'TEXT', 'HTML' or 'REST', got 'INV'. Invalid doc format in library - ${TESTDATADIR}/DocFormatInvalid.py ${OUT HTML} Invalid documentation format 'INVALID'. + ${TESTDATADIR}/DocFormatInvalid.py ${OUT HTML} Invalid documentation format 'INVALID'. Available: 'ROBOT', 'MARKDOWN', 'HTML', 'TEXT' and 'REST'. + +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 test library 'NonExistingLib' failed: * + NonExistingLib ${OUT HTML} Importing library 'NonExistingLib' failed: * Non-existing spec - nonex.xml ${OUT HTML} Spec file 'nonex.xml' does not exist. + nonex.xml ${OUT HTML} Importing library 'nonex.xml' failed: * Invalid spec [Setup] Create File ${OUT XML} @@ -44,13 +57,39 @@ Non-XML spec [Teardown] Remove File ${OUT XML} Invalid resource - ${CURDIR}/invalid_usage.robot ${OUT HTML} - ... ? ERROR ? *: Non-existing setting 'Test Setup'. - ... ? ERROR ? *: Non-existing setting 'Test Template'. - ... ? ERROR ? *: Non-existing setting 'Test Teardown'. - ... Resource file '*' cannot contain tests or tasks. + ${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 + ... Remove File ${OUT HTML} AND + ... Create Directory ${OUT HTML} AND + ... Create Directory ${OUT XML} + String ${OUT HTML} Opening Libdoc output file '${OUT HTML}' failed: * + String ${OUT XML} Opening Libdoc spec file '${OUT XML}' failed: * + [Teardown] Run Keywords + ... 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, 4, 5, 6 and 7. *** 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/invalid_user_keywords.robot b/atest/robot/libdoc/invalid_user_keywords.robot index f2152df2443..9dcec3a8cf7 100644 --- a/atest/robot/libdoc/invalid_user_keywords.robot +++ b/atest/robot/libdoc/invalid_user_keywords.robot @@ -6,14 +6,16 @@ Resource libdoc_resource.robot Invalid arg spec Keyword Name Should Be 0 Invalid arg spec Keyword Doc Should Be 0 *Creating keyword failed:* Invalid argument specification: Only last argument can be kwargs. - Stdout should contain error Invalid arg spec Invalid argument specification: Only last argument can be kwargs. + Stdout should contain error Invalid arg spec 3 + ... Invalid argument specification: Only last argument can be kwargs. -Dublicate name - Keyword Name Should Be 3 Same twice +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 Keyword with same name defined multiple times + 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} @@ -21,9 +23,9 @@ Dublicate name with embedded arguments *** Keywords *** Stdout should contain error - [Arguments] ${name} ${error} + [Arguments] ${name} ${lineno} ${error} ${path} = Normalize Path ${DATADIR}/libdoc/invalid_user_keywords.robot ${message} = Catenate - ... [ ERROR ] Error in resource file '${path}': + ... [ ERROR ] Error in file '${path}' on line ${lineno}: ... Creating keyword '${name}' failed: ${error} Should Contain ${OUTPUT} ${message} diff --git a/atest/robot/libdoc/java_library.robot b/atest/robot/libdoc/java_library.robot deleted file mode 100644 index 52ee90bff59..00000000000 --- a/atest/robot/libdoc/java_library.robot +++ /dev/null @@ -1,89 +0,0 @@ -*** Settings *** -Suite Setup Run Libdoc And Parse Output ${TESTDATADIR}/Example.java -Force Tags require-jython require-tools.jar -Resource libdoc_resource.robot - -*** Test Cases *** -Name - Name Should Be Example - -Documentation - Doc Should Start With - ... Library for `libdoc.py` testing purposes.\n\n - ... This library is only used in an example and it doesn't do anything useful. - -Version - Version Should Be 1.0 - -Type - Type Should Be library - -Generated - Generated Should Be Defined - -Scope - Scope Should Be global - -Named Args - Named Args Should Be no - -Init Documentation - Init Doc Should Start With 0 Creates new Example test library 1 - Init Doc Should Start With 1 Creates new Example test library 2 - Init Doc Should Start With 2 Creates new Example test library 3 - -Init Arguments - Init Arguments Should Be 0 - Init Arguments Should Be 1 arg - Init Arguments Should Be 2 i - -Keyword Names - Keyword Name Should Be 0 Keyword - Keyword Name Should Be 4 My Keyword - -Keyword Arguments - Keyword Arguments Should Be 0 arg - Keyword Arguments Should Be 4 - Keyword Arguments Should Be -4 *varargs - Keyword Arguments Should Be -3 normal *varargs - -Keyword Documentation - Keyword Doc Should Start With 0 - ... Takes one `arg` and *does nothing* with it.\n\n - ... Example:\n - ... | Your Keyword | xxx |\n - ... | Your Keyword | yyy |\n\n - ... See `My Keyword` for no more information. - Keyword Doc Should Start With 4 - ... Does nothing & has "stuff" to 'escape'!!\n - ... ${SPACE * 4}We also got some\n - ... ${SPACE * 8}indentation\n - ... ${SPACE * 8}here.\n - ... Back in the normal indentation level. - -Non ASCII - Keyword Doc Should Be 5 Hyvää yötä.\n\nСпасибо! - -Lists as varargs - Keyword Arguments Should Be -1 *varargsList - -Kwargs - Keyword Arguments Should Be 1 normal *varargs **kwargs - -Only last map is kwargs - Keyword Arguments Should Be 2 normal **kwargs - -Only last list is varargs - Keyword Arguments Should Be -2 normalArray *varargs - -Last argument overrides - Keyword Arguments Should Be 3 normalArray normalMap normal - -Keyword tags - Keyword Tags Should Be 4 bar foo - -Private constructors are ignored - Keyword Count Should Be 3 type=init - -Private keywords are ignored - Keyword Count Should Be 10 diff --git a/atest/robot/libdoc/json_output.robot b/atest/robot/libdoc/json_output.robot new file mode 100644 index 00000000000..afae6b137d5 --- /dev/null +++ b/atest/robot/libdoc/json_output.robot @@ -0,0 +1,141 @@ +*** Settings *** +Resource libdoc_resource.robot +Suite Setup Run Libdoc And Parse Model From JSON ${TESTDATADIR}/module.py +Test Template Should Be Equal Multiline +Test Tags require-jsonschema + +*** Test Cases *** +Name + ${MODEL}[name] module + +Documentation + ${MODEL}[doc]

Module test library.

+ +Version + ${MODEL}[version] 0.1-alpha + +Generated + [Template] Should Match Regexp + ${MODEL}[generated] \\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}[+-]\\d{2}:\\d{2} + +Scope + ${MODEL}[scope] GLOBAL + +Inits + [Template] Should Be Empty + ${MODEL}[inits] + +Keyword Names + ${MODEL}[keywords][0][name] Get Hello + ${MODEL}[keywords][1][name] Keyword + ${MODEL}[keywords][12][name] Set Name Using Robot Name Attribute + +Keyword Arguments + [Template] Verify Argument Models + ${MODEL}[keywords][0][args] + ${MODEL}[keywords][1][args] a1=d *a2 + ${MODEL}[keywords][6][args] arg=hyvä + ${MODEL}[keywords][9][args] arg=hyvä + ${MODEL}[keywords][10][args] a=1 b=True c=(1, 2, None) + ${MODEL}[keywords][11][args] arg=\\ robot \\ escapers\\n\\t\\r \\ \\ + ${MODEL}[keywords][12][args] a b *args **kwargs + +Embedded Arguments + [Template] NONE + 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] + ...

A keyword.

+ ...

See get hello for details.

+ ${MODEL}[keywords][0][doc] + ...

Get hello.

+ ...

See importing for explanation of nothing and introduction for no more information.

+ ${MODEL}[keywords][5][doc] + ...

This is short doc. It can span multiple physical lines and contain formatting.

+ ...

This is body. It can naturally also contain multiple lines.

+ ...

And paragraphs.

+ +Non-ASCII Keyword Documentation + ${MODEL}[keywords][7][doc]

Hyvää yötä.

\n

Спасибо!

+ ${MODEL}[keywords][8][doc]

Hyvää yötä.

+ +Keyword Short Doc + ${MODEL}[keywords][0][shortdoc] Get hello. + ${MODEL}[keywords][1][shortdoc] A keyword. + ${MODEL}[keywords][7][shortdoc] Hyvää yötä. + ${MODEL}[keywords][8][shortdoc] Hyvää yötä. + +Keyword Short Doc Spanning Multiple Physical Lines + ${MODEL}[keywords][5][shortdoc] This is short doc. It can span multiple physical lines and contain *formatting*. + +Keyword tags + [Template] Should Be Equal As Strings + ${MODEL}[keywords][1][tags] [] + ${MODEL}[keywords][2][tags] ['1', 'one', 'yksi'] + ${MODEL}[keywords][3][tags] ['2', 'kaksi', 'two'] + ${MODEL}[keywords][4][tags] ['tag1', 'tag2'] + +User keyword documentation formatting + [Setup] Run Libdoc And Parse Model From HTML ${TESTDATADIR}/resource.robot + ${MODEL}[keywords][0][doc]

$\{CURDIR}

+ ${MODEL}[keywords][1][doc]

DEPRECATED for some reason.

+ ${MODEL}[keywords][2][doc] + ${MODEL}[keywords][10][doc] + ...

Hyvää yötä.

+ ...

Спасибо!

+ ${MODEL}[keywords][8][doc] + ...

foo bar kw.

+ ...

FIRST \${a1} alskdj alskdjlajd askf laskdjf asldkfj alsdkfj alsdkfjasldkfj END

+ ...

SECOND askf laskdjf asldkfj alsdkfj alsdkfjasldkfj askf laskdjf END

+ ...

THIRD asldkfj introduction alsdkfj http://foo.bar END

+ ...
    + ...
  • aaa
  • + ...
  • bbb
  • + ...
+ ...
+ ... + ... + ... + ... + ... + ... + ... + ... + ... + ...
firstsecond
foobar
+ ...

Tags not only on the last line is new in RF 7.5.

+ +Private user keyword should be included + [Setup] Run Libdoc And Parse Model From JSON ${TESTDATADIR}/resource.robot + ${MODEL}[keywords][-2][name] Private + ${MODEL}[keywords][-2][tags] ['common', 'robot:private', 'tag-in-private', 'tags'] + ${MODEL}[keywords][-2][private] True + ${MODEL['keywords'][0].get('private')} None + +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} + [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/libdoc_resource.robot b/atest/robot/libdoc/libdoc_resource.robot index 1d9d182d10a..89c571a34ee 100644 --- a/atest/robot/libdoc/libdoc_resource.robot +++ b/atest/robot/libdoc/libdoc_resource.robot @@ -1,13 +1,16 @@ *** Settings *** Resource atest_resource.robot Library LibDocLib.py ${INTERPRETER} -Library OperatingSystem *** Variables *** ${TESTDATADIR} ${DATADIR}/libdoc -${OUTPREFIX} %{TEMPDIR}${/}robot-libdoc-test-file -${OUTXML} ${OUTPREFIX}.xml -${OUTHTML} ${OUTPREFIX}.html +${LIBNAME} robot-libdoc-test-file +${OUTBASE} %{TEMPDIR}${/}${LIBNAME} +${OUTXML} ${OUTBASE}.xml +${OUTHTML} ${OUTBASE}.html +${OUTJSON} ${OUTBASE}.json +${NEWDIR_XML} %{TEMPDIR}${/}tempdir${/}${LIBNAME}.xml +${NEWDIR_HTML} %{TEMPDIR}${/}tempdir${/}${LIBNAME}.html *** Keywords *** Run Libdoc And Set Output @@ -19,36 +22,49 @@ Run Libdoc And Parse Output [Arguments] ${arguments} Remove File ${OUTXML} Run Libdoc And Set Output ${arguments} ${OUTXML} - Should Not Contain ${OUTPUT} --help Execution failed:\n\n${output} no values + Should Not Contain ${OUTPUT} --help Execution failed:\n\n${OUTPUT} values=False Log File ${OUTXML} + Validate XML Spec ${OUTXML} ${LIBDOC}= Parse Xml ${OUTXML} Set Suite Variable ${LIBDOC} Run Libdoc And Verify Output [Arguments] ${args} @{expected} + VAR ${expected} @{expected} separator=\n ${output}= Run Libdoc ${args} - ${expected}= Catenate SEPARATOR=\n @{expected} Should Match ${output} ${expected}\n Run Libdoc And Parse Model From HTML [Arguments] ${args} - Run Libdoc ${args} ${OUT HTML} - ${MODEL} = Get Libdoc Model From HTML ${OUT HTML} + Run Libdoc ${args} ${OUTHTML} + ${MODEL} = Get Libdoc Model From HTML ${OUTHTML} + Set Suite Variable ${MODEL} + +Run Libdoc And Parse Model From JSON + [Arguments] ${args} + Run Libdoc ${args} ${OUTJSON} + Validate JSON Spec ${OUTJSON} + ${model_string}= Get File ${OUTJSON} + ${MODEL} = Evaluate json.loads($model_string) Set Suite Variable ${MODEL} Name Should Be [Arguments] ${name} Element Attribute Should Be ${LIBDOC} name ${name} +Format Should Be + [Arguments] ${format} + Element Attribute Should Be ${LIBDOC} format ${format} + Doc Should Start With - [Arguments] @{doc} - ${doc}= Catenate SEPARATOR= @{doc} - Element Text Should Match ${LIBDOC} ${doc}* doc + [Arguments] @{expected} + VAR ${expected} @{expected} separator=\n + Element Text Should Match ${LIBDOC} ${expected}* doc Doc Should Be - [Arguments] @{doc} - ${doc}= Catenate SEPARATOR= @{doc} - Element Text Should Be ${LIBDOC} ${doc} doc + [Arguments] @{expected} + VAR ${expected} @{expected} separator=\n + Element Text Should Be ${LIBDOC} ${expected} doc Version Should Match [Arguments] ${version} @@ -64,76 +80,350 @@ Type Should Be Scope Should Be [Arguments] ${scope} - Element Text Should Be ${LIBDOC} ${scope} scope + Element Attribute Should Be ${LIBDOC} scope ${scope} + +Source Should Be + [Arguments] ${source} + ${source} = Normalize Path ${source} + Element Attribute Should Be ${LIBDOC} source ${source} -Named Args Should Be - [Arguments] ${namedargs} - Element Text Should Be ${LIBDOC} ${namedargs} namedargs +Lineno Should Be + [Arguments] ${lineno} + Element Attribute Should Be ${LIBDOC} lineno ${lineno} Generated Should Be Defined - Element Attribute Should Match ${LIBDOC} generated * + # 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 7 Should Have No Init - ${inits} = Get Elements ${LIBDOC} xpath=init + ${inits} = Get Elements ${LIBDOC} xpath=inits/init Should Be Empty ${inits} +# TODO: Remove "Init ..." keywords and make "Keyword ..." keywords generic +# similarly as "Argument Doc Should Be". Init Doc Should Start With - [Arguments] ${index} @{doc} - ${inits}= Get Elements ${LIBDOC} xpath=init - ${doc}= Catenate SEPARATOR= @{doc} - ${text} = Get Element Text ${inits[${index}]} xpath=doc - Should Start With ${text} ${doc} + [Arguments] ${index} @{expected} + VAR ${expected} @{expected} separator=\n + ${inits}= Get Elements ${LIBDOC} xpath=inits/init + ${text} = Get Element Text ${inits}[${index}] xpath=doc + Should Start With ${text} ${expected} Init Doc Should Be - [Arguments] ${index} @{doc} - ${kws}= Get Elements ${LIBDOC} xpath=init - ${doc}= Catenate SEPARATOR= @{doc} - Element Text Should Be ${kws[${index}]} ${doc} xpath=doc + [Arguments] ${index} @{expected} + VAR ${expected} @{expected} separator=\n + ${kws}= Get Elements ${LIBDOC} xpath=inits/init + Element Text Should Be ${kws}[${index}] ${expected} xpath=doc Init Arguments Should Be [Arguments] ${index} @{expected} - ${args}= Get Keyword Arguments ${index} type=init - Should Be Equal ${args} ${expected} + Verify Arguments Structure ${index} inits/init ${expected} Keyword Name Should Be [Arguments] ${index} ${name} - ${elements}= Get Elements ${LIBDOC} xpath=kw - Element Attribute Should Be ${elements[${index}]} name ${name} + ${kws}= Get Elements ${LIBDOC} xpath=keywords/kw + Element Attribute Should Be ${kws}[${index}] name ${name} Keyword Arguments Should Be [Arguments] ${index} @{expected} - ${args}= Get Keyword Arguments ${index} - Should Be Equal ${args} ${expected} + Verify Arguments Structure ${index} keywords/kw ${expected} + +Argument Doc Should Be + [Arguments] ${kw_index} ${arg_index} ${name} @{expected} + VAR ${expected} @{expected} separator=\n + IF $kw_index == "init" + VAR ${kw_index} 0 + ${kws}= Get Elements ${LIBDOC} xpath=inits/init + ELSE + ${kws}= Get Elements ${LIBDOC} xpath=keywords/kw + END + ${args}= Get Elements ${kws}[${kw_index}] xpath=arguments/arg + VAR ${arg} ${args}[${arg_index}] + IF $name + Element Text Should Be ${arg} ${name} xpath=name + ELSE + Element Should Not Exist ${arg} xpath=name + END + IF $expected + Element Text Should Be ${arg} ${expected} xpath=doc + ELSE + Element Should Not Exist ${arg} xpath=doc + END + +Verify Arguments Structure + [Arguments] ${index} ${xpath} ${expected} + ${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} mode=STRICT + ${kind}= Get Element Attribute ${arg_elem} kind + ${required}= Get Element Attribute ${arg_elem} required + ${repr}= Get Element Attribute ${arg_elem} repr + ${name}= Get Element Optional Text ${arg_elem} name + ${types}= Get Elements ${arg_elem} type + IF not $types + ${type}= Set Variable ${None} + ELSE IF len($types) == 1 + ${type}= Get Type ${types}[0] + ELSE + Fail Cannot have more than one element + END + ${default}= Get Element Optional Text ${arg_elem} default + ${arg_model}= Create Dictionary + ... kind=${kind} + ... name=${name} + ... type=${type} + ... default=${default} + ... repr=${repr} + Verify Argument Model ${arg_model} ${exp_repr} + Should Be Equal ${repr} ${exp_repr} + END + +Raises Should Be + [Arguments] ${index} &{expected} + ${kws}= Get Elements ${LIBDOC} xpath=keywords/kw + ${types}= Get Elements ${kws}[${index}] xpath=raises/type + FOR ${elem} ${name} IN ZIP ${types} ${expected} mode=strict + Element Attribute Should Be ${elem} name ${name} + Element Text Should Be ${elem} ${expected}[${name}] + END + +Return Doc Should Be + [Arguments] ${index} @{expected} + VAR ${expected} @{expected} separator=\n + ${kws}= Get Elements ${LIBDOC} xpath=keywords/kw + Element Text Should Be ${kws}[${index}] ${expected} xpath=returndoc + +Return Type Should Be + [Arguments] ${index} ${name} @{nested} + ${kws}= Get Elements ${LIBDOC} xpath=keywords/kw + VAR ${kw} ${kws}[${index}] + IF $name == 'NOT SET' + Element Should Not Exist ${kw} returntype + RETURN + END + Element Attribute Should Be ${kw} name ${name} xpath=returntype + ${type_elems} = Get Elements ${kw} returntype/type + FOR ${elem} ${expected} IN ZIP ${type_elems} ${nested} mode=STRICT + Element Attribute Should Be ${elem} name ${expected} + END + +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' + VAR ${type} @{nested} separator=${SPACE}|${SPACE} + ELSE IF $nested + VAR ${args} @{nested} separator=,${SPACE} + VAR ${type} ${type}\[${args}] + END + RETURN ${type} + +Get Element Optional Text + [Arguments] ${source} ${xpath} + ${elems}= Get Elements ${source} ${xpath} + ${text}= IF len($elems) == 1 Get Element Text ${elems}[0] + RETURN ${text} + +Verify Argument Model + [Arguments] ${arg_model} ${expected_repr} ${json}=False + IF ${json} + ${repr}= Get Repr From Json Arg Model ${arg_model} + ELSE + ${repr}= Get Repr From Arg Model ${arg_model} + END + Should Be Equal As Strings ${repr} ${expected_repr} + Should Be Equal As Strings ${arg_model}[repr] ${expected_repr} Keyword Doc Should Start With - [Arguments] ${index} @{doc} - ${kws}= Get Elements ${LIBDOC} xpath=kw - ${doc}= Catenate SEPARATOR= @{doc} - ${text} = Get Element Text ${kws[${index}]} xpath=doc - Should Start With ${text} ${doc} + [Arguments] ${index} @{expected} + VAR ${expected} @{expected} separator=\n + ${kws}= Get Elements ${LIBDOC} xpath=keywords/kw + ${text}= Get Element Text ${kws}[${index}] xpath=doc + Should Start With ${text} ${expected} Keyword Doc Should Be - [Arguments] ${index} @{doc} - ${kws}= Get Elements ${LIBDOC} xpath=kw - ${doc}= Catenate SEPARATOR= @{doc} - Element Text Should Be ${kws[${index}]} ${doc} xpath=doc + [Arguments] ${index} @{expected} + VAR ${expected} @{expected} separator=\n + ${kws}= Get Elements ${LIBDOC} xpath=keywords/kw + Element Text Should Be ${kws}[${index}] ${expected} xpath=doc + +Keyword Shortdoc Should Be + [Arguments] ${index} @{expected} + VAR ${expected} @{expected} separator=\n + ${kws}= Get Elements ${LIBDOC} xpath=keywords/kw + Element Text Should Be ${kws}[${index}] ${expected} xpath=shortdoc Keyword Tags Should Be [Arguments] ${index} @{expected} - ${kws}= Get Elements ${LIBDOC} xpath=kw - ${tags}= Get Elements Texts ${kws[${index}]} xpath=tags/tag + ${kws}= Get Elements ${LIBDOC} xpath=keywords/kw + ${tags}= Get Elements Texts ${kws}[${index}] xpath=tags/tag Should Be Equal ${tags} ${expected} -Get Keyword Arguments - [Arguments] ${index} ${type}=kw - ${kws}= Get Elements ${LIBDOC} xpath=${type} - ${args}= Get Elements Texts ${kws[${index}]} xpath=arguments/arg - [Return] ${args} +Specfile Tags Should Be + [Arguments] @{expected} + ${tags}= Get Elements Texts ${LIBDOC} xpath=tags/tag + Should Be Equal ${tags} ${expected} + +Keyword Source Should Be + [Arguments] ${index} ${source} ${xpath}=keywords/kw + ${kws}= Get Elements ${LIBDOC} xpath=${xpath} + ${source} = Normalize Path ${source} + Element Attribute Should Be ${kws}[${index}] source ${source} + +Keyword Should Not Have Source + [Arguments] ${index} ${xpath}=keywords/kw + ${kws}= Get Elements ${LIBDOC} xpath=${xpath} + Element Should Not Have Attribute ${kws}[${index}] source + +Keyword Lineno Should Be + [Arguments] ${index} ${lineno} ${xpath}=keywords/kw + ${kws}= Get Elements ${LIBDOC} xpath=${xpath} + Element Attribute Should Be ${kws}[${index}] lineno ${lineno} + +Keyword Should Not Have Lineno + [Arguments] ${index} ${xpath}=keywords/kw + ${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 + Element Attribute Should be ${kws}[${index}] deprecated true + +Keyword Should Not Be Deprecated + [Arguments] ${index} + ${kws}= Get Elements ${LIBDOC} xpath=keywords/kw + Element Should Not Have Attribute ${kws}[${index}] deprecated Keyword Count Should Be - [Arguments] ${expected} ${type}=kw + [Arguments] ${expected} ${type}=keywords/kw ${kws}= Get Elements ${LIBDOC} xpath=${type} Length Should Be ${kws} ${expected} Remove Output Files - Remove Files ${OUTPREFIX}* + Remove Files ${OUTBASE}* + +Should Be Equal Multiline + [Arguments] ${actual} @{expected} ${start}=False + VAR ${expected} @{expected} separator=\n + IF not ${start} + Should Be Equal As Strings ${actual} ${expected} + ELSE + Should Start With ${actual} ${expected} + END + +List of Dict Should Be Equal + [Arguments] ${list1} ${list2} + FOR ${dict1} ${dict2} IN ZIP ${list1} ${list2} + Dictionaries Should Be Equal ${dict1} ${dict2} + END + +DataType Enum Should Be + [Arguments] ${index} ${name} ${doc} @{exp_members} + ${enums}= Get Elements ${LIBDOC} xpath=typedocs/type[@type='Enum'] + Element Attribute Should Be ${enums}[${index}] name ${name} + Element Text Should Be ${enums}[${index}] ${doc} xpath=doc + ${members}= Get Elements ${enums}[${index}] xpath=members/member + FOR ${member} ${exp_member} IN ZIP ${members} ${exp_members} + ${attrs}= Get Element Attributes ${member} + Element Attribute Should Be ${member} name ${{${exp_member}}}[name] + Element Attribute Should Be ${member} value ${{${exp_member}}}[value] + END + +DataType TypedDict Should Be + [Arguments] ${index} ${name} ${doc} @{exp_items} + ${dicts}= Get Elements ${LIBDOC} xpath=typedocs/type[@type='TypedDict'] + Element Attribute Should Be ${dicts}[${index}] name ${name} + Element Text Should Be ${dicts}[${index}] ${doc} xpath=doc + ${items}= Get Elements ${dicts}[${index}] xpath=items/item + FOR ${exp_item} IN @{exp_items} + ${exp}= Evaluate json.loads($exp_item) + FOR ${item} IN @{items} + ${cur}= Get Element Attributes ${item} + IF $cur['key'] == $exp['key'] + Should Be Equal ${cur}[key] ${exp}[key] + Should Be Equal ${cur}[type] ${exp}[type] + IF 'required' in $exp + Should Be Equal ${cur}[required] ${exp}[required] + END + BREAK + END + END + END + +DataType Custom Should Be + [Arguments] ${index} ${name} ${doc} + ${types}= Get Elements ${LIBDOC} xpath=typedocs/type[@type='Custom'] + Element Attribute Should Be ${types}[${index}] name ${name} + Element Text Should Be ${types}[${index}] ${doc} xpath=doc + +DataType Standard Should Be + [Arguments] ${index} ${name} ${doc} + ${types}= Get Elements ${LIBDOC} xpath=typedocs/type[@type='Standard'] + Element Attribute Should Be ${types}[${index}] name ${name} + Element Text Should Match ${types}[${index}] ${doc}* xpath=doc + +Usages Should Be + [Arguments] ${index} ${type} ${name} @{expected} + ${elem} = Get Element ${LIBDOC} xpath=typedocs/type[${{${index} + 1}}] + Element Attribute Should Be ${elem} type ${type} + Element Attribute Should Be ${elem} name ${name} + @{usages} = Get Elements ${elem} usages/usage + Should Be Equal ${{len($usages)}} ${{len($expected)}} + FOR ${usage} ${kw} IN ZIP ${usages} ${expected} + Element Text Should Be ${usage} ${kw} + END + +Accepted Types Should Be + [Arguments] ${index} ${type} ${name} @{expected} + ${elem} = Get Element ${LIBDOC} xpath=typedocs/type[${{${index} + 1}}] + Element Attribute Should Be ${elem} type ${type} + Element Attribute Should Be ${elem} name ${name} + @{accepts} = Get Elements ${elem} accepts/type + Should Be Equal ${{len($accepts)}} ${{len($expected)}} + FOR ${acc} ${type} IN ZIP ${accepts} ${expected} + Element Text Should Be ${acc} ${type} + END + +Typedoc links should be + [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/library_decorator.robot b/atest/robot/libdoc/library_decorator.robot new file mode 100644 index 00000000000..e142edb965a --- /dev/null +++ b/atest/robot/libdoc/library_decorator.robot @@ -0,0 +1,17 @@ +*** Settings *** +Suite Setup Run Libdoc And Parse Output ${TESTDATADIR}/LibraryDecorator.py +Resource libdoc_resource.robot + +*** Test Cases *** +Version + Version should be 3.2b1 + +Scope + Scope Should Be GLOBAL + +Doc format + Format Should Be HTML + +Keywords + Keyword Name Should Be 0 Kw + Keyword Count Should Be 1 diff --git a/atest/robot/libdoc/library_version.robot b/atest/robot/libdoc/library_version.robot index eef173f5920..2bbbad1ec6e 100644 --- a/atest/robot/libdoc/library_version.robot +++ b/atest/robot/libdoc/library_version.robot @@ -2,9 +2,7 @@ Resource libdoc_resource.robot Test Template Run Libdoc And Verify Version - *** Test Cases *** - Version defined with ROBOT_LIBRARY_VERSION in Python library DynamicLibrary.py::arg 0.1 @@ -14,17 +12,7 @@ Version defined with __version__ in Python library No version defined in Python library NewStyleNoInit.py ${EMPTY} -Version defined with ROBOT_LIBRARY_VERSION in Java library - [Tags] require-jython require-tools.jar - Example.java 1.0 - -No version defined in Java library - [Tags] require-jython require-tools.jar - NoConstructor.java ${EMPTY} - - *** Keywords *** - Run Libdoc And Verify Version [Arguments] ${library} ${version} Run Libdoc And Parse Output ${TESTDATADIR}/${library} diff --git a/atest/robot/libdoc/markdown_format.robot b/atest/robot/libdoc/markdown_format.robot new file mode 100644 index 00000000000..85c4b99f2b9 --- /dev/null +++ b/atest/robot/libdoc/markdown_format.robot @@ -0,0 +1,174 @@ +*** Settings *** +Suite Setup Generate doc using Markdown source +Test Template Doc should contain +Resource libdoc_resource.robot + +*** Test Cases *** +Basic formatting + Basic Markdown formatting such as bold, italics and code works as expected. + +Linking + Normal Markdown inline and reference links are supported. + ... No title is needed. + URLs like http://example.com are automatically linkified as a custom feature. + ... Surrounding URLs with angle brackets like http://example.com works too. + +Automatic reference targets + Keywords like References and admonitions. + Headers in the library introduction like linking, Basics + ... \ \ and Reference w/ :special: "chars"?. + Predefined targets like Introduction and keywords. + We can link to predefined targets like introduction, to intro headers + ... like linking, to keywords like Admonitions and to types like int + ... and list. + ... model=${MODEL}[keywords][2] + +Custom references defined in introduction work also with keywords + Custom references defined in introduction like reference work too! + ... model=${MODEL}[keywords][2] + +Unordered lists +
    \n
  • First unordered item.
  • \n
  • Second item.
  • \n
+ ... model=${MODEL}[keywords][1] + +Ordered lists +
    \n
  1. First ordered item.
  2. \n
  3. Second item.
  4. \n
+ ... model=${MODEL}[keywords][1] + +Nested lists +
    + ...
  1. First item in an ordered list.
      + ...
    • Nested unordered item.
    • + ...
    • Another nested item.
    • + ...
    + ...
  2. + ...
  3. Second item.
      + ...
    1. Nested ordered item.
    2. + ...
    3. Another nested item.
    4. + ...
    + ...
  4. + ...
+ ... model=${MODEL}[keywords][1] +
    + ...
  • First item in an unordered list.
      + ...
    • Nested unordered item.
    • + ...
    • Another nested item.
    • + ...
    + ...
  • + ...
  • Second item.
      + ...
    1. Nested ordered item.
    2. + ...
    3. Another nested item.
    4. + ...
    + ...
  • + ...
+ ... model=${MODEL}[keywords][1] + +Tables + + ... + ... + ... + ... + ... + ... + ... + ... + ... + ... + ... + ... + ... + ... + ... + ... + ... + ... + ... + ...
Header 1Header 2Header 3
item 1.1item 2.1item 3.1
item 1.2item 2.2item 3.2
+ ... model=${MODEL}[keywords][4] + + ... + ... + ... + ... + ... + ... + ... + ... + ... + ... + ... + ... + ... + ... + ...
LeftCenterRight
123456789012345678901234567890
+ ... model=${MODEL}[keywords][4] + +Syntax highlighting +
*** Test Cases ***
+    ...    Example
+    ...     \ \ \ Keyword \ \ \ arg
+    ...    
+ ... model=${MODEL}[keywords][3] + # This is comment in code, not a Markdown header! + ... model=${MODEL}[keywords][3] +
print(
+    ...    model=${MODEL}[keywords][3]
+
+Admonitions
+    
+ ...

Note

+ ...

Admonitions are provided by the admonition plugin.

+ ...

We need to make sure to add custom styles to make them render nicely.

+ ...
+ ... model=${MODEL}[keywords][0] +
+ ...

Interoperability risk!

+ ...

Admonitions are not standard Markdown. Don't use them if you want good + ... interoperability with other Markdown tools.

+ ...
+ ... model=${MODEL}[keywords][0] + +Table of contents + + +Lines starting with `#` in code blocks are not considered headers + # This is not a header! + ... + # This is not a header either! + ... + +Table of contents in keyword documentation +
+ ... + ...
+ ... model=${MODEL}[keywords][5] + +*** Keywords *** +Generate doc using Markdown source + Run Libdoc And Parse Model From HTML ${TESTDATADIR}/MarkdownFormat.py + +Doc should contain + [Arguments] @{content} ${model}=${MODEL} + VAR ${content} @{content} separator=\n + Should Contain ${model}[doc] ${content} diff --git a/atest/robot/libdoc/module_library.robot b/atest/robot/libdoc/module_library.robot index 81aff4193a5..deb44bffdb7 100644 --- a/atest/robot/libdoc/module_library.robot +++ b/atest/robot/libdoc/module_library.robot @@ -1,75 +1,108 @@ *** Settings *** -Suite Setup Run Libdoc And Parse Output ${TESTDATADIR}/module.py -Resource libdoc_resource.robot +Suite Setup Run Libdoc And Parse Output ${TESTDATADIR}/module.py +Resource libdoc_resource.robot *** Test Cases *** Name - Name Should Be module + Name Should Be module Documentation - Doc Should Be Module test library. + Doc Should Be Module test library. Version - Version Should Be 0.1-alpha + Version Should Be 0.1-alpha Type - Type Should Be library + Type Should Be LIBRARY Generated Generated Should Be Defined Scope - Scope Should Be global + Scope Should Be GLOBAL -Named Args - Named Args Should Be yes +Source info + Source should be ${TESTDATADIR}/module.py + Lineno should be 1 + +Spec version + Spec version should be correct Has No Inits Should Have No Init Keyword Names - Keyword Name Should Be 0 Get Hello - Keyword Name Should Be 1 Keyword - Keyword Name Should Be 9 Set Name Using Robot Name Attribute + Keyword Name Should Be 0 Get Hello + Keyword Name Should Be 1 Keyword + Keyword Name Should Be 12 Set Name Using Robot Name Attribute Keyword Arguments - Keyword Arguments Should Be 0 - Keyword Arguments Should Be 1 a1=d *a2 - Keyword Arguments Should Be 9 a b *args **kwargs + Keyword Arguments Should Be 0 + Keyword Arguments Should Be 1 a1=d *a2 + Keyword Arguments Should Be 10 a=1 b=True c=(1, 2, None) + Keyword Arguments Should Be 11 arg=\\ robot \\ escapers\\n\\t\\r \\ \\ + Keyword Arguments Should Be 12 a b *args **kwargs + +Non-ASCII Bytes Defaults + Keyword Arguments Should Be 6 arg=hyvä + +Non-ASCII String Defaults + Keyword Arguments Should Be 9 arg=hyvä Embedded Arguments - Keyword Name Should Be 10 Takes \${embedded} \${args} - Keyword Arguments Should Be 10 + 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 Doc Should Be 0 Get hello.\n\nSee `importing` for explanation of nothing\nand `introduction` for no more information. - Keyword Doc Should Be 4 Set tags in documentation. + Keyword Doc Should Be 1 A keyword.\n\nSee `get hello` for details. + Keyword Shortdoc Should Be 1 A keyword. + Keyword Doc Should Be 0 Get hello.\n\nSee `importing` for explanation of nothing\nand `introduction` for no more information. + Keyword Shortdoc Should Be 0 Get hello. + Keyword Doc Should Be 4 Set tags in documentation. + Keyword Shortdoc Should Be 4 Set tags in documentation. Multiline Documentation With Split Short Doc ${doc} = Catenate SEPARATOR=\n ... This is short doc. ... It can span multiple ... physical - ... lines. - ... ${EMPTY} + ... lines and contain *formatting*. + ... ... This is body. It can naturally also ... contain multiple lines. - ... ${EMPTY} + ... ... And paragraphs. - Keyword Doc Should Be 5 ${doc} + Keyword Doc Should Be 5 ${doc} + Keyword Shortdoc Should Be 5 This is short doc. It can span multiple physical lines and contain *formatting*. -Valid non-ASCII - Keyword Doc Should Be 6 Hyvää yötä.\n\nСпасибо! - Keyword Doc Should Be 8 Hyvää yötä. +Non-ASCII doc + Keyword Doc Should Be 7 Hyvää yötä.\n\nСпасибо! + Keyword Shortdoc Should Be 7 Hyvää yötä. -Invalid non-ASCII - ${expected} = Set Variable If $INTERPRETER.is_py3 or $INTERPRETER.is_ironpython - ... Hyvää yötä. Hyv\\xe4\\xe4 y\\xf6t\\xe4. - Keyword Doc Should Be 7 ${expected} +Non-ASCII string doc with escapes + Keyword Doc Should Be 8 Hyvää yötä. + Keyword Shortdoc Should Be 8 Hyvää yötä. Keyword tags - Keyword Tags Should Be 1 - Keyword Tags Should Be 2 1 one yksi - Keyword Tags Should Be 3 2 kaksi two - Keyword Tags Should Be 4 tag1 tag2 + Keyword Tags Should Be 1 + Keyword Tags Should Be 2 1 one yksi + Keyword Tags Should Be 3 2 kaksi two + Keyword Tags Should Be 4 tag1 tag2 + +Keyword source info + Keyword Name Should Be 0 Get Hello + Keyword Should Not Have Source 0 + Keyword Lineno Should Be 0 16 + +Keyword source info with decorated function + Keyword Name Should Be 13 Takes \${embedded} \${args} + Keyword Should Not Have Source 13 + Keyword Lineno Should Be 13 70 diff --git a/atest/robot/libdoc/no_inits.robot b/atest/robot/libdoc/no_inits.robot index b18fef226b3..da35358181a 100644 --- a/atest/robot/libdoc/no_inits.robot +++ b/atest/robot/libdoc/no_inits.robot @@ -9,21 +9,12 @@ New Style Python Class With No Init Old Style Python Class With No Argument Init no_arg_init.py -Java Class With No Constructor - [Tags] require-jython require-tools.jar - NoConstructor.java - -Java Class With Default and Private Constructors - [Tags] require-jython require-tools.jar - NoArgConstructor.java - - *** Keywords *** Library Should Have No Init - [Arguments] ${library} + [Arguments] ${library} @{posonly marker} Run Libdoc And Parse Output ${TESTDATADIR}/${library} Should Have No Init - Doc Should Be No inits here! + Doc Should Be No inits here! Keyword Name Should Be 0 Keyword - Keyword Arguments Should Be 0 arg1 arg2 + Keyword Arguments Should Be 0 arg1 arg2 @{posonly marker} Keyword Doc Should Be 0 The only lonely keyword. diff --git a/atest/robot/libdoc/python_library.robot b/atest/robot/libdoc/python_library.robot index bc318612b51..a7cdc944038 100644 --- a/atest/robot/libdoc/python_library.robot +++ b/atest/robot/libdoc/python_library.robot @@ -8,27 +8,33 @@ Name Documentation Doc Should Start With - ... A test library providing communication over Telnet connections.\n\n - ... ``Telnet`` is Robot Framework's standard library that makes it possible to\n + ... A library providing communication over Telnet connections. + ... + ... ``Telnet`` is Robot Framework's standard library that makes it possible to Version - Version Should Match 3.* + Version Should Match [6789].* Type - Type Should Be library + Type Should Be LIBRARY Generated Generated Should Be Defined Scope - Scope Should Be test suite + Scope Should Be SUITE -Named Args - Named Args Should Be yes +Source info + Source should be ${CURDIR}/../../../src/robot/libraries/Telnet.py + Lineno should be 37 + +Spec version + Spec version should be correct Init Documentation Init Doc Should Start With 0 - ... Telnet library can be imported with optional configuration parameters.\n\n + ... Telnet library can be imported with optional configuration parameters. + ... ... Configuration parameters are used as default values when new\nconnections are Init Arguments @@ -38,37 +44,99 @@ Init Arguments ... environ_user=None terminal_emulation=False terminal_type=None ... telnetlib_log_level=TRACE connection_timeout=None +Init Source Info + Keyword Should Not Have Source 0 xpath=inits/init + Keyword Lineno Should Be 0 287 xpath=inits/init + Keyword Names Keyword Name Should Be 0 Close All Connections Keyword Name Should Be 1 Close Connection 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.\n\n - ... This keyword requires the prompt to be [#Configuration|configured]\n - ... either in `importing` or with `Open Connection` or `Set Prompt` keyword.\n\n - ... This is a convenience keyword that uses `Write` and `Read Until Prompt`\n - ... internally. Following two examples are thus functionally identical:\n\n - ... | \${out} = | `Execute Command`${SPACE*3}| pwd |\n\n - ... | `Write`${SPACE*2}| pwd${SPACE*17}|\n - ... | \${out} = | `Read Until Prompt` |\n\n + ... Executes the given ``command`` and reads, logs, and returns everything until the prompt. + ... + ... This keyword requires the prompt to be [#Configuration|configured] + ... either in `importing` or with `Open Connection` or `Set Prompt` keyword. + ... + ... This is a convenience keyword that uses `Write` and `Read Until Prompt` + ... internally. Following two examples are thus functionally identical:\ + ... + ... | \${out} = | `Execute Command`${SPACE*3}| pwd | + ... + ... | `Write`${SPACE*2}| pwd${SPACE*17}| + ... | \${out} = | `Read Until Prompt` | + ... + +Keyword Source Info + # This keyword is from the "main library". + Keyword Name Should Be 0 Close All Connections + Keyword Should Not Have Source 0 + Keyword Lineno Should Be 0 517 + # This keyword is from an external library component. + Keyword Name Should Be 7 Read Until Prompt + Keyword Should Not Have Source 7 + Keyword Lineno Should Be 7 1087 KwArgs and VarArgs - Run Libdoc And Parse Output Process - Keyword Name Should Be 7 Run Process - Keyword Arguments Should Be 7 command *arguments **configuration + Run Libdoc And Parse Output ${TESTDATADIR}/KwArgs.py + Keyword Arguments Should Be 2 *varargs **kwargs + Keyword Arguments Should Be 3 a / b c=d *e f g=h **i Keyword-only Arguments - [Tags] require-py3 - Run Libdoc And Parse Output ${TESTDATADIR}/KeywordOnlyArgs.py + Run Libdoc And Parse Output ${TESTDATADIR}/KwArgs.py Keyword Arguments Should Be 0 * kwo Keyword Arguments Should Be 1 *varargs kwo another=default +Positional-only Arguments + Run Libdoc And Parse Output ${DATADIR}/keywords/PositionalOnly.py + Keyword Arguments Should Be 1 arg / + Keyword Arguments Should Be 5 posonly / normal + Keyword Arguments Should Be 0 required optional=default / + Keyword Arguments Should Be 3 first: int second: float / + +Decorators + Run Libdoc And Parse Output ${TESTDATADIR}/Decorators.py + Keyword Name Should Be 0 Keyword Using Decorator + Keyword Arguments Should Be 0 *args **kwargs + Keyword Should Not Have Source 0 + Keyword Lineno Should Be 0 7 + Keyword Name Should Be 1 Keyword Using Decorator With Wraps + Keyword Arguments Should Be 1 args are preserved=True + Keyword Lineno Should Be 1 27 + 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 + Keyword Doc Should Be 0 *DEPRECATED* + Keyword Should Be Deprecated 0 + Keyword Name Should Be 1 Deprecated With Message + Keyword Doc Should Be 1 *DEPRECATED for some good reason!* Yes it is. For sure. + Keyword Should Be Deprecated 1 + Keyword Name Should Be 2 No Deprecation Whatsoever + Keyword Doc Should Be 2 + Keyword Should Not Be Deprecated 2 + Keyword Name Should Be 3 Silent Deprecation + Keyword Doc Should Be 3 *Deprecated* but not yet loudly. + ... + ... RF and Libdoc don't consider this being deprecated. + Keyword Should Not Be Deprecated 3 + +NOT_SET as default value + Run Libdoc And Parse Output Collections + Keyword Name Should Be 17 Get From Dictionary + Keyword Arguments Should Be 17 dictionary: Mapping key: object default: object = \ diff --git a/atest/robot/libdoc/resource_file.robot b/atest/robot/libdoc/resource_file.robot index f0518c65338..dfff414f854 100644 --- a/atest/robot/libdoc/resource_file.robot +++ b/atest/robot/libdoc/resource_file.robot @@ -7,86 +7,143 @@ Name Name Should Be resource Documentation - Doc Should Start With - ... This resource file has documentation.\n\n - ... And it is even set in multiple cells with _formatting_.\n - ... This should be in the same paragraph as the sentence above.\n\n - ... Here is a literal\nnewline\n\n - ... -------------\n\n - ... | *TABLE* |\n - ... | \${NONEX} | $\{CURDIR} | \${TEMPDIR} |\n - ... \n + Doc Should Be + ... This resource file has documentation. + ... + ... And it is even set in multiple cells with _formatting_. + ... This should be in the same paragraph as the sentence above. + ... + ... Here is a literal\nnewline + ... + ... ------------- + ... + ... | *TABLE* | + ... | \${NONEX} | $\{CURDIR} | \${TEMPDIR} | + ... | foo${SPACE*6}|${SPACE*4}bar${SPACE*4}| ... tabs \t\t\t here Version Version Should Be ${EMPTY} Type - Type Should Be resource + Type Should Be RESOURCE Generated Generated Should Be Defined Scope - Scope Should Be ${EMPTY} + Scope Should Be GLOBAL -Named Args - Named Args Should Be yes +Source Info + Source Should Be ${TESTDATADIR}/resource.robot + Lineno Should Be 1 + +Spec version + Spec version should be correct + +Resource Tags + Specfile Tags Should Be \${3} ?!?!?? a b bar common + ... dar foo Has kw4 robot:private + ... tag-in-private tags Resource Has No Inits Should Have No Init Keyword Names Keyword Name Should Be 0 curdir - Keyword Name Should Be 3 Keyword with some "stuff" to + Keyword Name Should Be 4 Keyword with some "stuff" to Keyword Arguments Keyword Arguments Should Be 0 - Keyword Arguments Should Be 3 a1 a2 - Keyword Arguments Should Be 6 positional=default *varargs **kwargs + Keyword Arguments Should Be 4 a1 a2 + Keyword Arguments Should Be 7 positional=default *varargs **kwargs Different Argument Types - Keyword Arguments Should Be 1 mandatory optional=default *varargs - ... kwo=default another **kwargs + Keyword Arguments Should Be 2 mandatory optional=default *varargs + ... kwo: int = default another **kwargs Embedded Arguments - Keyword Name Should Be 2 Embedded \${arguments} - Keyword Arguments Should Be 2 + Keyword Name Should Be 3 Embedded \${arguments} + Keyword Arguments Should Be 3 + Keyword Name Should Be 12 With embedded \${arg: int} and normal arg + Keyword Arguments Should Be 12 normal + +Argument Types + Keyword Arguments Should Be 9 a: int b: Literal['R', 'F'] c: int | None = None + +Type Docs + DataType Standard Should Be 0 integer Conversion is done using + Usages Should Be 0 Standard integer Different argument types kw 6 + DataType Standard Should Be 1 Literal Only specified values are accepted. + Usages Should Be 1 Standard Literal kw 6 + DataType Standard Should Be 2 None String `NONE` (case-insensitive) and + Usages Should Be 2 Standard None kw 6 Keyword Documentation Keyword Doc Should Be 0 $\{CURDIR} - Keyword Doc Should Be 3 foo bar `kw` & some "stuff" to .\n\n baa `\${a1}` - Keyword Doc Should Be 5 literal\nnewline - Keyword Doc Should Be 7 - ... foo bar `kw`.\n\n - ... baa `\${a1}` alskdj alskdjlajd\n - ... askf laskdjf asldkfj alsdkfj alsdkfjasldkfj\n - ... askf laskdjf _asldkfj_ alsdkfj alsdkfjasldkfj\n - ... askf *laskdjf* asldkfj `introduction` alsdkfj\n - ... http://foo.bar\n - ... - aaa\n - ... - bbb\n - ... \n - ... -------------\n - ... \n - ... | *1* | *2* |\n - ... | foo | bar | + Keyword Doc Should Be 4 foo bar `kw` & some "stuff" to .\n\nbaa `\${a1}` + Keyword Doc Should Be 6 literal\nnewline Keyword Doc Should Be 8 - ... Summary line\n - ... \n - ... Another line. + ... foo bar `kw`. + ... + ... FIRST `\${a1}` alskdj alskdjlajd + ... askf laskdjf asldkfj alsdkfj alsdkfjasldkfj END + ... + ... SECOND askf laskdjf _asldkfj_ alsdkfj alsdkfjasldkfj + ... askf *laskdjf* END + ... + ... THIRD asldkfj `introduction` alsdkfj http://foo.bar END + ... - aaa + ... - bbb + ... + ... ------------- + ... + ... | = first = | = second = | + ... | foo${SPACE*7}|${SPACE*4}bar${SPACE*5}| + ... + ... Tags not only on the last line is new in RF 7.5. + Keyword Doc Should Be 9 + ... Summary line + ... + ... Second doc paragraph. Not having an empty row + ... before tags is deprecated since RF 7.5. + +Deprecation + Keyword Doc Should Be 1 *DEPRECATED* for some reason. + Keyword Should Be Deprecated 1 + FOR ${index} IN RANGE 2 11 + Keyword Should Not Be Deprecated ${index} + END Keyword tags - Keyword Tags Should Be 5 - Keyword Tags Should Be 6 ?!?!?? Has kw4 tags - Keyword Tags Should Be 7 \${3} a b - Keyword Tags Should Be 8 bar dar foo + Keyword Tags Should Be 6 common + Keyword Tags Should Be 7 ?!?!?? Has kw4 tags + Keyword Tags Should Be 8 \${3} a b + Keyword Tags Should Be 9 bar common dar foo + +Not having empty row before `Tags:` in documentation is deprecated + Should Start With ${OUTPUT} + ... [ WARN ] Invalid documentation in 'kw 6': Not having an empty row before 'Tags:' is deprecated. Non ASCII - Keyword Doc Should Be 9 Hyvää yötä.\n\nСпасибо! + Keyword Doc Should Be 10 Hyvää yötä.\n\nСпасибо! + +Keyword Source Info + Keyword Name Should Be 0 curdir + Keyword Should Not Have Source 0 + Keyword Lineno Should Be 0 82 '*.resource' extension is accepted - Run Libdoc And Parse Output ${TESTDATADIR}/resource.resource - Keyword Name Should Be 0 Yay, I got new extension! - Keyword Arguments Should Be 0 Awesome!! - Keyword Doc Should Be 0 Yeah!!! + Run Libdoc And Parse Output ${TESTDATADIR}/resource.resource + Source Should Be ${TESTDATADIR}/resource.resource + Lineno Should Be 1 + 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/robot/libdoc/return_type.robot b/atest/robot/libdoc/return_type.robot new file mode 100644 index 00000000000..1e31e43dd61 --- /dev/null +++ b/atest/robot/libdoc/return_type.robot @@ -0,0 +1,53 @@ +*** Settings *** +Suite Setup Run Libdoc And Parse Output ${TESTDATADIR}/ReturnType.py +Test Template Return Type Should Be +Resource libdoc_resource.robot + +*** Test Cases *** +No return type + 0 NOT SET + +Never and NoReturn + 7 NOT SET + 8 NOT SET + +None return + 1 None + +Simple return + 2 int + +Parameterized return + 3 List int + +Union return + 4 Union int float + +Stringified return + 5 Union int float + +Unknown return + 6 Unknown + +Invalid return + [Template] NONE + VAR ${error} + ... [ ERROR ] Error in library 'ReturnType': + ... Adding keyword 'H_invalid_return' failed: + ... Parsing type 'list[int' failed: + ... Error at end: + ... Closing ']' missing. + Should Start With ${OUTPUT} ${error} + +Return types are in typedocs + [Template] Usages Should Be + 0 Standard float + ... E Union Return + ... F Stringified Return + 1 Standard integer + ... C Simple Return + ... D Parameterized Return + ... E Union Return + ... F Stringified Return + 2 Standard list + ... D Parameterized Return diff --git a/atest/robot/libdoc/return_type_json.robot b/atest/robot/libdoc/return_type_json.robot new file mode 100644 index 00000000000..9d9b46a7421 --- /dev/null +++ b/atest/robot/libdoc/return_type_json.robot @@ -0,0 +1,59 @@ +*** Settings *** +Suite Setup Run Libdoc And Parse Model From JSON ${TESTDATADIR}/ReturnType.py +Test Template Return type should be +Resource libdoc_resource.robot +Test Tags require-jsonschema + +*** Test Cases *** +No return + 0 None + +None return + 1 {'name': 'None', 'typedoc': 'None', 'nested': [], 'union': False} + +Simple return + 2 {'name': 'int', 'typedoc': 'integer', 'nested': [], 'union': False} + +Parameterized return + 3 {'name': 'List', + ... 'typedoc': 'list', + ... 'nested': [{'name': 'int', 'typedoc': 'integer', 'nested': [], 'union': False}], + ... 'union': False} + +Union return + 4 {'name': 'Union', + ... 'typedoc': None, + ... 'nested': [{'name': 'int', 'typedoc': 'integer', 'nested': [], 'union': False}, + ... {'name': 'float', 'typedoc': 'float', 'nested': [], 'union': False}], + ... 'union': True} + +Stringified return + 5 {'name': 'Union', + ... 'typedoc': None, + ... 'nested': [{'name': 'int', 'typedoc': 'integer', 'nested': [], 'union': False}, + ... {'name': 'float', 'typedoc': 'float', 'nested': [], 'union': False}], + ... 'union': True} + +Unknown return + 6 {'name': 'Unknown', 'typedoc': None, 'nested': [], 'union': False} + +Return types are in typedocs + [Template] Should Be Equal + ${MODEL}[typedocs][0][name] float + ${MODEL}[typedocs][0][usages][0] E Union Return + ${MODEL}[typedocs][0][usages][1] F Stringified Return + ${MODEL}[typedocs][1][name] integer + ${MODEL}[typedocs][1][usages][0] C Simple Return + ${MODEL}[typedocs][1][usages][1] D Parameterized Return + ${MODEL}[typedocs][1][usages][2] E Union Return + ${MODEL}[typedocs][1][usages][3] F Stringified Return + ${MODEL}[typedocs][2][name] list + ${MODEL}[typedocs][2][usages][0] D Parameterized Return + +*** Keywords *** +Return type should be + [Arguments] ${index} @{expected} + VAR ${expected} @{expected} + Should Be Equal As Strings + ... ${MODEL}[keywords][${index}][returnType] + ... ${expected} diff --git a/atest/robot/libdoc/spec_library.robot b/atest/robot/libdoc/spec_library.robot index e6896ba4a69..20c8ac20b13 100644 --- a/atest/robot/libdoc/spec_library.robot +++ b/atest/robot/libdoc/spec_library.robot @@ -4,61 +4,173 @@ Resource libdoc_resource.robot *** Test Cases *** Name - Name Should Be Example + Name Should Be Example Documentation Doc Should Start With - ... Library for `libdoc.py` testing purposes.\n\n + ... Library for `libdoc.py` testing purposes. + ... ... This library is only used in an example and it doesn't do anything useful. Version - Version Should Match 1.0 + Version Should Be 42 Type - Type Should Be library + Type Should Be LIBRARY Generated Generated Should Be Defined Scope - Scope Should Be global + Scope Should Be GLOBAL -Named Args - Named Args Should Be no +Source info + Source should be Example.py + Lineno should be 8 + +Spec version + Spec version should be correct + +Library Tags + Specfile Tags Should Be tag1 tag2 Init Documentation - Init Doc Should Be 0 Creates new Example test library 1 - Init Doc Should Be 1 Creates new Example test library 2 - Init Doc Should Be 2 Creates new Example test library 3 + Init Doc Should Be 0 Creates new Example test library 1 + Init Doc Should Be 1 Creates new Example test library 2 + Init Doc Should Be 2 Creates new Example test library 3 Init Arguments - Init Arguments Should Be 0 - Init Arguments Should Be 1 arg - Init Arguments Should Be 2 i + Init Arguments Should Be 0 + Init Arguments Should Be 1 arg + Init Arguments Should Be 2 i + +Init Source Info + Keyword Should Not Have Source 0 xpath=inits/init + Keyword Lineno Should Be 0 12 xpath=inits/init + Keyword Should Not Have Source 1 xpath=inits/init + Keyword Should Not Have Lineno 1 xpath=inits/init Keyword Names - Keyword Name Should Be 0 Keyword - Keyword Name Should Be 1 My Keyword + Keyword Name Should Be 0 All kinds of arguments + Keyword Name Should Be 1 Keyword + Keyword Name Should Be 2 My Keyword + Keyword Name Should Be 3 Non Ascii Doc Keyword Arguments - Keyword Arguments Should Be 0 arg - Keyword Arguments Should Be 1 + Keyword Arguments Should Be 0 a / b c=d *e f g=h **i + Keyword Arguments Should Be 1 arg + Keyword Arguments Should Be 2 + Keyword Arguments Should Be 3 Keyword Documentation - Keyword Doc Should Be 0 - ... Takes one `arg` and *does nothing* with it.\n\n - ... Example:\n - ... | Your Keyword | xxx |\n - ... | Your Keyword | yyy |\n\n + Keyword Doc Should Be 0 + Keyword Short Doc Should be 0 + Keyword Doc Should Be 1 + ... Takes one `arg` and *does nothing* with it. + ... + ... Example: + ... | Your Keyword | xxx | + ... | Your Keyword | yyy | + ... ... See `My Keyword` for no more information. - Keyword Doc Should Be 1 - ... Does nothing & has "stuff" to 'escape'!! and ignored indentation\n + Keyword Short Doc Should be 1 Takes one `arg` and *does nothing* with it. + Keyword Doc Should Be 2 + ... Does nothing & has "stuff" to 'escape'!! and ignored indentation ... Tags: in spec these wont become tags + Keyword Short Doc Should be 2 + ... Does nothing & has "stuff" to 'escape'!! and ignored indentation Tags: in spec these wont become tags + Keyword Doc Should Be 3 Hyvää yötä.\n\nСпасибо! + Keyword Shortdoc Should Be 3 Hyvää yötä. Keyword Tags - Keyword Tags Should Be 0 tag1 tag2 - Keyword Tags Should Be 1 - Keyword Tags Should Be 2 + Keyword Tags Should Be 0 + Keyword Tags Should Be 1 tag1 tag2 + Keyword Tags Should Be 2 + Keyword Tags Should Be 3 + +Private Keywords + Keyword Should Not Be Private 0 + Keyword Should Not Be Private 1 + Keyword Should Be Private 2 + Keyword Should Not Be Private 3 + +Keyword Deprecation + Keyword Should Not Be Deprecated 0 + Keyword Should Not Be Deprecated 1 + Keyword Should Be Deprecated 2 + Keyword Should Not Be Deprecated 3 -Non ASCII - Keyword Doc Should Be 2 Hyvää yötä.\n\nСпасибо! +Keyword Source Info + Keyword Should Not Have Source 0 + Keyword Lineno Should Be 0 13 + Keyword Should Not Have Source 1 + Keyword Should Not Have Lineno 1 + Keyword Should Not Have Source 2 + Keyword Lineno Should Be 2 42 + Keyword Source Should Be 3 Different! + Keyword Lineno Should Be 3 666 + +'*.libspec' extension + Copy File ${TESTDATADIR}/ExampleSpec.xml %{TEMPDIR}/Example.libspec + 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 + Doc Should Start With Library for `libdoc.py` testing purposes. + Version Should Be 42 + Type Should Be LIBRARY + Generated Should Be Defined + Scope Should Be GLOBAL + Source should be Example.py + Lineno should be 8 + Init Doc Should Be 0 Creates new Example test library 1 + Init Doc Should Be 1 Creates new Example test library 2 + Init Doc Should Be 2 Creates new Example test library 3 + Init Arguments Should Be 0 + Init Arguments Should Be 1 arg + Init Arguments Should Be 2 i + Keyword Should Not Have Source 0 xpath=inits/init + Keyword Lineno Should Be 0 12 xpath=inits/init + Keyword Should Not Have Source 1 xpath=inits/init + Keyword Should Not Have Lineno 1 xpath=inits/init + Keyword Name Should Be 0 All kinds of arguments + Keyword Name Should Be 1 Keyword + Keyword Name Should Be 2 My Keyword + Keyword Name Should Be 3 Non Ascii Doc + Keyword Arguments Should Be 0 a / b c=d *e f g=h **i + Keyword Arguments Should Be 1 arg + Keyword Arguments Should Be 2 + Keyword Arguments Should Be 3 + Keyword Doc Should Be 0 + Keyword Doc Should Start With 1 Takes one `arg` and *does nothing* with it. + Keyword Doc Should Start With 2 Does nothing & has "stuff" to 'escape'!! and ignored indentation + Keyword Doc Should Be 3 Hyvää yötä.\n\nСпасибо! + Keyword Tags Should Be 0 + Keyword Tags Should Be 1 tag1 tag2 + Keyword Tags Should Be 2 + Keyword Tags Should Be 3 + Keyword Should Not Be Private 0 + Keyword Should Not Be Private 1 + Keyword Should Be Private 2 + Keyword Should Not Be Private 3 + Keyword Should Not Be Deprecated 0 + Keyword Should Not Be Deprecated 1 + Keyword Should Be Deprecated 2 + Keyword Should Not Be Deprecated 3 + Keyword Should Not Have Source 0 + Keyword Lineno Should Be 0 13 + Keyword Should Not Have Source 1 + Keyword Should Not Have Lineno 1 + Keyword Should Not Have Source 2 + Keyword Lineno Should Be 2 42 + Keyword Source Should Be 3 Different! + Keyword Lineno Should Be 3 666 diff --git a/atest/robot/libdoc/suite_file.robot b/atest/robot/libdoc/suite_file.robot new file mode 100644 index 00000000000..9de82cc644d --- /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 + +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 + +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 .\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/robot/libdoc/suite_init_file.robot b/atest/robot/libdoc/suite_init_file.robot new file mode 100644 index 00000000000..0d1abb685ea --- /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 + +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 + +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 .\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/robot/libdoc/toc.robot b/atest/robot/libdoc/toc.robot new file mode 100644 index 00000000000..609c7943d51 --- /dev/null +++ b/atest/robot/libdoc/toc.robot @@ -0,0 +1,77 @@ +*** Settings *** +Resource libdoc_resource.robot + +*** Test Cases *** +TOC is not replaced in model + Run Libdoc And Parse Output ${TESTDATADIR}/toc.py + Doc should be + ... == Table of contents == + ... %TOC% + ... + ... = First level = + ... + ... First level headers are included in TOC. + ... + ... = First level again = + ... + ... Yes, also this header is included. + ... + ... == Second level == + ... + ... Also second level headers are included starting from RF 7.5. + ... + ... === Third level === + ... + ... Third level headers aren't included. + ... + ... ${SPACE * 3}== Second level again == + ... + ... This is included. Header indentation doesn't matter. + ... + ... = First level once more = + ... == Second level once more == + ... + ... These are included. + ... + ... = Just = text + ... here = + ... + ... %TOC% isn't replaced when not alone. + ... + ... Not even here: + ... %TOC% + +TOC is replaced in HTML + Run Libdoc And Parse Model From HTML ${TESTDATADIR}/toc.py + Should Be Equal Multiline ${MODEL}[doc] + ...

Table of contents

+ ... + ...

First level

+ ...

First level headers are included in TOC.

+ ...

First level again

+ ...

Yes, also this header is included.

+ ...

Second level

+ ...

Also second level headers are included starting from RF 7.5.

+ ...

Third level

+ ...

Third level headers aren't included.

+ ...

Second level again

+ ...

This is included. Header indentation doesn't matter.

+ ...

First level once more

+ ...

Second level once more

+ ...

These are included.

+ ...

= Just = text here =

+ ...

%TOC% isn't replaced when not alone.

+ ...

Not even here: %TOC%

diff --git a/atest/robot/libdoc/type_annotations.robot b/atest/robot/libdoc/type_annotations.robot index 9296a60bac6..2dcb74a947c 100644 --- a/atest/robot/libdoc/type_annotations.robot +++ b/atest/robot/libdoc/type_annotations.robot @@ -1,24 +1,50 @@ *** Settings *** Suite Setup Run Libdoc And Parse Output ${TESTDATADIR}/Annotations.py -Force Tags require-py3 Resource libdoc_resource.robot *** Test Cases *** Basics Keyword Arguments Should Be 0 integer: int boolean: bool string: str +Enums + Keyword Arguments Should Be 1 small: Small + ... many_small: ManySmall + ... big: Big + With defaults - Keyword Arguments Should Be 1 integer: int=42 list_: list=None + Keyword Arguments Should Be 2 i: int = 42 t: tuple = () e: Small = one Keyword-only arguments - Keyword Arguments Should Be 2 * kwo: int with_default: str=value + Keyword Arguments Should Be 3 * kwo: int with_default: str = value Varargs and kwargs - Keyword Arguments Should Be 3 *varargs: int **kwargs: bool + Keyword Arguments Should Be 4 *varargs: int **kwargs: bool Unknown types - Keyword Arguments Should Be 4 unknown: UnknownType unrecognized: Ellipsis + Keyword Arguments Should Be 5 unknown: UnknownType unrecognized: ... Non-type annotations - Keyword Arguments Should Be 5 arg: One of the usages in PEP-3107 + Keyword Arguments Should Be 6 arg: One of the usages in PEP-3107 ... *varargs: But surely feels odd... + +Drop `typing.` prefix + 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 | None = None + +Nested + Keyword Arguments Should Be 10 a: List[int] b: List[int | float] c: Tuple[Tuple[UnknownType], Dict[str, Tuple[float]]] + + +Literal + Keyword Arguments Should Be 11 a: Literal['on', 'off', 'int'] b: Literal[1, 2, 3] c: Literal[one, True, None] + +Secret type + Keyword Arguments Should Be 12 token: Secret + +Union syntax + [Tags] require-py3.10 + Keyword Arguments Should Be 13 a: int | str | list | tuple + Keyword Arguments Should Be 14 a: int | str | list | None = None diff --git a/atest/robot/libdoc/types_via_keyword_decorator.robot b/atest/robot/libdoc/types_via_keyword_decorator.robot index 182b2269a7f..8f0c9ebf8ac 100644 --- a/atest/robot/libdoc/types_via_keyword_decorator.robot +++ b/atest/robot/libdoc/types_via_keyword_decorator.robot @@ -7,19 +7,25 @@ Basics Keyword Arguments Should Be 0 integer: int boolean: bool string: str With defaults - Keyword Arguments Should Be 1 integer: int=42 list_: list=None + Keyword Arguments Should Be 1 integer: int = 42 list_: list = None 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 ... *varargs: But surely feels odd... Keyword-only arguments - [Tags] require-py3 - Keyword Arguments Should Be 5 * kwo: int with_default: str=value + Keyword Arguments Should Be 5 * kwo: int with_default: str = value +Return type + Keyword Arguments Should Be 6 + Return Type Should Be 6 int + +Return type as tuple + Keyword Arguments Should Be 7 arg: int + Return Type Should Be 7 Union int float diff --git a/atest/robot/output/LegacyOutputHelper.py b/atest/robot/output/LegacyOutputHelper.py new file mode 100644 index 00000000000..f9e558a5ccf --- /dev/null +++ b/atest/robot/output/LegacyOutputHelper.py @@ -0,0 +1,13 @@ +import re + + +def mask_changing_parts(path): + with open(path, encoding="UTF-8") as file: + content = file.read() + for pattern, replace in [ + (r'"20\d{6} \d{2}:\d{2}:\d{2}\.\d{3}"', '"[timestamp]"'), + (r'generator=".*?"', 'generator="[generator]"'), + (r'source=".*?"', 'source="[source]"'), + ]: + content = re.sub(pattern, replace, content) + return content diff --git a/atest/robot/output/LogDataFinder.py b/atest/robot/output/LogDataFinder.py new file mode 100644 index 00000000000..98d731cf595 --- /dev/null +++ b/atest/robot/output/LogDataFinder.py @@ -0,0 +1,56 @@ +import json + +from robot.api import logger + + +class WrongStat(AssertionError): + ROBOT_CONTINUE_ON_FAILURE = True + + +def get_total_stats(path): + return get_all_stats(path)[0] + + +def get_tag_stats(path): + return get_all_stats(path)[1] + + +def get_suite_stats(path): + return get_all_stats(path)[2] + + +def get_all_stats(path): + stats = _get_output_line(path, 'window.output["stats"]') + total, tags, suite = json.loads(stats) + return total, tags, suite + + +def _get_output_line(path, prefix): + logger.info( + f"Getting '{prefix}' from '{path}'.", + html=True, + ) + prefix += " = " + with open(path, encoding="UTF-8") as file: + for line in file: + if line.startswith(prefix): + logger.info(f"Found: {line}") + return line[len(prefix) : -2] + + +def verify_stat(stat, *attrs): + stat.pop("elapsed") + expected = dict(_get_expected_stat(attrs)) + if stat != expected: + raise WrongStat(f"\nGot : {stat}\nExpected : {expected}") + + +def _get_expected_stat(attrs): + for key, value in (a.split(":", 1) for a in attrs): + value = int(value) if value.isdigit() else str(value) + yield str(key), value + + +def get_expand_keywords(path): + expand = _get_output_line(path, 'window.output["expand_keywords"]') + return json.loads(expand) diff --git a/atest/robot/output/expand_keywords.robot b/atest/robot/output/expand_keywords.robot new file mode 100644 index 00000000000..475584c25ca --- /dev/null +++ b/atest/robot/output/expand_keywords.robot @@ -0,0 +1,81 @@ +*** Settings *** +Suite Setup Run tests with expanding +Suite Teardown No extra keyword should have been expanded +Test Template Keyword should have been expanded +Library LogDataFinder.py +Resource atest_resource.robot + +*** Variables *** +@{EXPANDED} Set by Suite Setup +@{VALIDATED} + +*** Test Cases *** +Keyword name + s1-s1-k1 s1-s1-t1-k1 s1-s1-t2-k1 # name:MyKeyword + +Keyword name with library prefix + s1-s2-t1-k3 s1-s4-t2-k2-k1 # NAME:BuiltIn.Sleep + +Non-ASCII name + s1-s2-t8-k1 # Name:Ñöñ-ÄŚÇÏÏ Këywörd Nämë + +Name with special characters + s1-s3-t2-k1 # name:NO + +Name as pattern + s1-s2-t1-k1 s1-s2-t3-k1 # name:nonasciilib????.PRINT* + +Keyword tag + s1-s4-t1-k3 # tag:tags + +Tag as pattern + s1-s4-t2-k3-k1 s1-s4-t2-k4 # TAG:Nest*2 + +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 + ... --ExpandKeywords NAME:BuiltIn.Skip + ... --expand "Name:???-Ä* K?ywörd Näm?" + ... --expandkeywords name:NO + ... --expandkeywords name:nonasciilib????.Print* + ... --expandkeywords name:NoMatchHere + ... --expandkeywords tag:tags + ... --ExpandKeywords TAG:Nest*2 + ... --expandkeywords tag:NoMatch + ${paths} = Catenate + ... misc/pass_and_fail.robot + ... misc/non_ascii.robot + ... misc/formatting_and_escaping.robot + ... misc/normal.robot + ... misc/if_else.robot + ... misc/for_loops.robot + ... misc/try_except.robot + ... misc/while.robot + ... misc/skip.robot + Run Tests ${options} ${paths} + ${EXPANDED} = Get Expand Keywords ${OUTDIR}/log.html + Set Suite Variable ${EXPANDED} + +Keyword should have been expanded + [Arguments] @{ids} + List Should Contain Sub List ${EXPANDED} ${ids} + Append To List ${VALIDATED} @{ids} + +No extra keyword should have been expanded + Sort List ${EXPANDED} + Sort List ${VALIDATED} + Log List ${EXPANDED} + Log list ${VALIDATED} + Lists Should Be Equal ${EXPANDED} ${VALIDATED} diff --git a/atest/robot/output/flatten_keyword.robot b/atest/robot/output/flatten_keyword.robot deleted file mode 100644 index 03cad932719..00000000000 --- a/atest/robot/output/flatten_keyword.robot +++ /dev/null @@ -1,106 +0,0 @@ -*** Settings *** -Suite Setup Run And Rebot Flattened -Resource atest_resource.robot - -*** Variables *** -${FLATTEN} --FlattenKeywords NAME:Keyword3 --flat name:key*others --FLAT name:builtin.* --flat TAG:flattenNOTkitty --log log.html -${FLAT TEXT} _*Keyword content flattened.*_ -${FLAT HTML}

Keyword content flattened.\\x3c/b>\\x3c/i>\\x3c/p> -${ERROR} [ ERROR ] Invalid value for option '--flattenkeywords'. Expected 'FOR', 'FORITEM', 'TAG:', or 'NAME:' but 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 - -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 - -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 - -Tag match - Should Be Equal ${TC.kws[5].doc} Doc of flat tag\n\n${FLAT TEXT} - Length Should Be ${TC.kws[5].kws} 0 - Length Should Be ${TC.kws[5].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 - 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} Keyword content flattened 4 - 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} - -Flatten for loops - 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 - :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 - \ Check Log Message ${tc.kws[0].msgs[${index * 6 + 2}]} 2 - \ Check Log Message ${tc.kws[0].msgs[${index * 6 + 3}]} 1 - \ Check Log Message ${tc.kws[0].msgs[${index * 6 + 4}]} 2 - \ Check Log Message ${tc.kws[0].msgs[${index * 6 + 5}]} 1 - -Flatten for loop items - 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 - Should Be Empty ${tc.kws[0].msgs} - :FOR ${index} IN RANGE 10 - \ Should Be Equal ${tc.kws[0].kws[${index}].type} foritem - \ 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 - \ 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 - \ Check Log Message ${tc.kws[0].kws[${index}].msgs[3]} 1 - \ Check Log Message ${tc.kws[0].kws[${index}].msgs[4]} 2 - \ Check Log Message ${tc.kws[0].kws[${index}].msgs[5]} 1 - -Invalid usage - Run Rebot Without Processing Output ${FLATTEN} --FlattenKeywords invalid ${OUTFILE COPY} - Stderr Should Be Equal To ${ERROR} - Run Tests Without Processing Output ${FLATTEN} --FlattenKeywords invalid output/flatten_keywords.robot - Stderr Should Be Equal To ${ERROR} - -*** Keywords *** -Run And Rebot Flattened - Run Tests Without Processing Output ${FLATTEN} output/flatten_keywords.robot - ${LOG} = Get File ${OUTDIR}/log.html - Set Suite Variable $LOG - Copy Previous Outfile - Run Rebot ${FLATTEN} ${OUTFILE COPY} - ${TC} = Check Test Case Flatten stuff - Set Suite Variable $TC diff --git a/atest/robot/output/flatten_keywords.robot b/atest/robot/output/flatten_keywords.robot new file mode 100644 index 00000000000..fa66e863a4b --- /dev/null +++ b/atest/robot/output/flatten_keywords.robot @@ -0,0 +1,279 @@ +*** Settings *** +Suite Setup Run And Rebot Flattened +Resource atest_resource.robot + +*** Variables *** +${FLATTEN} --FlattenKeywords NAME:Keyword3 +... --flat name:key*others +... --FLAT name:builtin.* +... --flat TAG:flattenNOTkitty +... --flatten "name:Flatten controls in keyword" +${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[0].message} ${EMPTY} + Should Be Equal ${TC[0].doc} Doc of keyword 2 + Should Have Tags ${TC[0]} kw2 + Should Be Equal ${TC[0].timeout} 2 minutes + Check Counts ${TC[0]} 0 2 + Check Log Message ${TC[0, 0, 0]} 2 + Check Log Message ${TC[0, 1, 1, 0]} 1 + +Exact match + Should Be Equal ${TC[1].message} *HTML* ${FLATTENED} + Should Be Equal ${TC[1].doc} Doc of keyword 3 + Should Have Tags ${TC[1]} kw3 + Should Be Equal ${TC[1].timeout} 3 minutes + Check Counts ${TC[1]} 3 + Check Log Message ${TC[1, 0]} 3 + Check Log Message ${TC[1, 1]} 2 + Check Log Message ${TC[1, 2]} 1 + +Pattern match + Should Be Equal ${TC[2].message} *HTML* ${FLATTENED} + Should Be Equal ${TC[2].doc} ${EMPTY} + Should Have Tags ${TC[2]} + Should Be Equal ${TC[2].timeout} ${NONE} + Check Counts ${TC[2]} 6 + Check Log Message ${TC[2, 0]} 3 + Check Log Message ${TC[2, 1]} 2 + Check Log Message ${TC[2, 2]} 1 + Check Log Message ${TC[2, 3]} 2 + Check Log Message ${TC[2, 4]} 1 + Check Log Message ${TC[2, 5]} 1 + +Tag match when keyword has no message + Should Be Equal ${TC[5].message} *HTML* ${FLATTENED} + Should Be Equal ${TC[5].doc} ${EMPTY} + Should Have Tags ${TC[5]} flatten hi + Check Counts ${TC[5]} 1 + +Tag match when keyword has message + Should Be Equal ${TC[6].message} *HTML* Expected e&<aped failure!


${FLATTENED} + Should Be Equal ${TC[6].doc} Doc of flat keyword. + Should Have Tags ${TC[6]} flatten hello + Check Counts ${TC[6]} 1 + +Match full name + Should Be Equal ${TC[3].message} *HTML* ${FLATTENED} + Should Be Equal ${TC[3].doc} Logs the given message with the given level. + Check Counts ${TC[3]} 1 + Check Log Message ${TC[3, 0]} Flatten me too!! + +Flattened in log after execution + Should Contain ${LOG} "*Content flattened." + +Flatten controls in keyword + ${tc} = Check Test Case ${TEST NAME} + @{expected} = Create List + ... Outside IF Inside IF 1 Nested IF + ... 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 + ... Inside GROUP \${x} = Using VAR + FOR ${msg} ${exp} IN ZIP ${tc[0].body} ${expected} mode=STRICT + Check Log Message ${msg} ${exp} level=IGNORE + END + +Flatten FOR + Run Rebot --flatten For ${OUTFILE COPY} + ${tc} = Check Test Case FOR loop + Should Be Equal ${tc[0].type} FOR + Should Be Equal ${tc[0].message} *HTML* ${FLATTENED} + Check Counts ${tc[0]} 60 + FOR ${index} IN RANGE 10 + Check Log Message ${tc[0, ${index * 6 + 0}]} index: ${index} + Check Log Message ${tc[0, ${index * 6 + 1}]} 3 + Check Log Message ${tc[0, ${index * 6 + 2}]} 2 + Check Log Message ${tc[0, ${index * 6 + 3}]} 1 + Check Log Message ${tc[0, ${index * 6 + 4}]} 2 + Check Log Message ${tc[0, ${index * 6 + 5}]} 1 + END + +Flatten FOR iterations + Run Rebot --flatten ForItem ${OUTFILE COPY} + ${tc} = Check Test Case FOR loop + Should Be Equal ${tc[0].type} FOR + Should Be Equal ${tc[0].message} ${EMPTY} + Check Counts ${tc[0]} 0 10 + FOR ${index} IN RANGE 10 + Should Be Equal ${tc[0, ${index}].type} ITERATION + Should Be Equal ${tc[0, ${index}].message} *HTML* ${FLATTENED} + Check Counts ${tc[0, ${index}]} 6 + Check Log Message ${tc[0, ${index}, 0]} index: ${index} + Check Log Message ${tc[0, ${index}, 1]} 3 + Check Log Message ${tc[0, ${index}, 2]} 2 + Check Log Message ${tc[0, ${index}, 3]} 1 + Check Log Message ${tc[0, ${index}, 4]} 2 + Check Log Message ${tc[0, ${index}, 5]} 1 + END + +Flatten WHILE + Run Rebot --flatten WHile ${OUTFILE COPY} + ${tc} = Check Test Case WHILE loop + Should Be Equal ${tc[1].type} WHILE + Should Be Equal ${tc[1].message} *HTML* ${FLATTENED} + Check Counts ${tc[1]} 70 + FOR ${index} IN RANGE 10 + Check Log Message ${tc[1, ${index * 7 + 0}]} index: ${index} + Check Log Message ${tc[1, ${index * 7 + 1}]} 3 + Check Log Message ${tc[1, ${index * 7 + 2}]} 2 + Check Log Message ${tc[1, ${index * 7 + 3}]} 1 + Check Log Message ${tc[1, ${index * 7 + 4}]} 2 + Check Log Message ${tc[1, ${index * 7 + 5}]} 1 + ${i}= Evaluate $index + 1 + Check Log Message ${tc[1, ${index * 7 + 6}]} \${i} = ${i} + END + +Flatten WHILE iterations + Run Rebot --flatten iteration ${OUTFILE COPY} + ${tc} = Check Test Case WHILE loop + Should Be Equal ${tc[1].type} WHILE + Should Be Equal ${tc[1].message} ${EMPTY} + Check Counts ${tc[1]} 0 10 + FOR ${index} IN RANGE 10 + Should Be Equal ${tc[1, ${index}].type} ITERATION + Should Be Equal ${tc[1, ${index}].message} *HTML* ${FLATTENED} + Check Counts ${tc[1, ${index}]} 7 + Check Log Message ${tc[1, ${index}, 0]} index: ${index} + Check Log Message ${tc[1, ${index}, 1]} 3 + Check Log Message ${tc[1, ${index}, 2]} 2 + Check Log Message ${tc[1, ${index}, 3]} 1 + Check Log Message ${tc[1, ${index}, 4]} 2 + Check Log Message ${tc[1, ${index}, 5]} 1 + ${i}= Evaluate $index + 1 + Check Log Message ${tc[1, ${index}, 6]} \${i} = ${i} + END + +Flatten with JSON + GROUP Run tests + VAR ${flatten} + ... --flattenkeywords name:Keyword3 + ... --flatten-keywords tag:flattenNOTkitty + ... --flatten FOR + ... --flatten WHILE + Run Tests Without Processing Output ${flatten} --output output.json --log log.html output/flatten_keywords.robot + END + GROUP Check flattening in log after afecution. + ${log} = Get File ${OUTDIR}/log.html + Should Contain ${log} "*Content flattened." + END + GROUP Run Rebot + Copy File ${OUTDIR}/output.json %{TEMPDIR}/output.json + Run Rebot ${flatten} %{TEMPDIR}/output.json + END + GROUP Check flattening by keyword name and tags + ${tc} = Check Test Case Flatten stuff + Should Be Equal ${tc[0].message} ${EMPTY} + Should Be Equal ${tc[0].doc} Doc of keyword 2 + Should Have Tags ${tc[0]} kw2 + Should Be Equal ${tc[0].timeout} 2 minutes + Check Counts ${tc[0]} 0 2 + Check Log Message ${tc[0, 0, 0]} 2 + Check Log Message ${tc[0, 1, 1, 0]} 1 + Should Be Equal ${tc[1].message} *HTML* ${FLATTENED} + Should Be Equal ${tc[1].doc} Doc of keyword 3 + Should Have Tags ${tc[1]} kw3 + Should Be Equal ${tc[1].timeout} 3 minutes + Check Counts ${tc[1]} 3 + Check Log Message ${tc[1, 0]} 3 + Check Log Message ${tc[1, 1]} 2 + Check Log Message ${tc[1, 2]} 1 + Should Be Equal ${tc[5].message} *HTML* ${FLATTENED} + Should Be Equal ${tc[5].doc} ${EMPTY} + Should Have Tags ${tc[5]} flatten hi + Check Counts ${tc[5]} 1 + Should Be Equal ${tc[6].message} *HTML* Expected e&<aped failure!
${FLATTENED} + Should Be Equal ${tc[6].doc} Doc of flat keyword. + Should Have Tags ${tc[6]} flatten hello + Check Counts ${tc[6]} 1 + END + GROUP Check flattening FOR loop + ${tc} = Check Test Case FOR loop + Should Be Equal ${tc[0].type} FOR + Should Be Equal ${tc[0].message} *HTML* ${FLATTENED} + Check Counts ${tc[0]} 60 + FOR ${index} IN RANGE 10 + Check Log Message ${tc[0, ${index * 6 + 0}]} index: ${index} + Check Log Message ${tc[0, ${index * 6 + 1}]} 3 + Check Log Message ${tc[0, ${index * 6 + 2}]} 2 + Check Log Message ${tc[0, ${index * 6 + 3}]} 1 + Check Log Message ${tc[0, ${index * 6 + 4}]} 2 + Check Log Message ${tc[0, ${index * 6 + 5}]} 1 + END + END + GROUP Check flattening WHILE loop + ${tc} = Check Test Case WHILE loop + Should Be Equal ${tc[1].type} WHILE + Should Be Equal ${tc[1].message} *HTML* ${FLATTENED} + Check Counts ${tc[1]} 70 + FOR ${index} IN RANGE 10 + Check Log Message ${tc[1, ${index * 7 + 0}]} index: ${index} + Check Log Message ${tc[1, ${index * 7 + 1}]} 3 + Check Log Message ${tc[1, ${index * 7 + 2}]} 2 + Check Log Message ${tc[1, ${index * 7 + 3}]} 1 + Check Log Message ${tc[1, ${index * 7 + 4}]} 2 + Check Log Message ${tc[1, ${index * 7 + 5}]} 1 + ${i}= Evaluate $index + 1 + Check Log Message ${tc[1, ${index * 7 + 6}]} \${i} = ${i} + END + END + GROUP Check flattening FOR and WHILE iterations + Run Rebot --flatten ITERATION %{TEMPDIR}/output.json + ${tc} = Check Test Case FOR loop + Should Be Equal ${tc[0].type} FOR + Should Be Equal ${tc[0].message} ${EMPTY} + Check Counts ${tc[0]} 0 10 + FOR ${index} IN RANGE 10 + Should Be Equal ${tc[0, ${index}].type} ITERATION + Should Be Equal ${tc[0, ${index}].message} *HTML* ${FLATTENED} + Check Counts ${tc[0, ${index}]} 6 + Check Log Message ${tc[0, ${index}, 0]} index: ${index} + Check Log Message ${tc[0, ${index}, 1]} 3 + Check Log Message ${tc[0, ${index}, 2]} 2 + Check Log Message ${tc[0, ${index}, 3]} 1 + Check Log Message ${tc[0, ${index}, 4]} 2 + Check Log Message ${tc[0, ${index}, 5]} 1 + END + ${tc} = Check Test Case WHILE loop + Should Be Equal ${tc[1].type} WHILE + Should Be Equal ${tc[1].message} ${EMPTY} + Check Counts ${tc[1]} 0 10 + FOR ${index} IN RANGE 10 + Should Be Equal ${tc[1, ${index}].type} ITERATION + Should Be Equal ${tc[1, ${index}].message} *HTML* ${FLATTENED} + Check Counts ${tc[1, ${index}]} 7 + Check Log Message ${tc[1, ${index}, 0]} index: ${index} + Check Log Message ${tc[1, ${index}, 1]} 3 + Check Log Message ${tc[1, ${index}, 2]} 2 + Check Log Message ${tc[1, ${index}, 3]} 1 + Check Log Message ${tc[1, ${index}, 4]} 2 + Check Log Message ${tc[1, ${index}, 5]} 1 + ${i}= Evaluate $index + 1 + Check Log Message ${tc[1, ${index}, 6]} \${i} = ${i} + END + END + +Invalid usage + Run Rebot Without Processing Output ${FLATTEN} --FlattenKeywords invalid ${OUTFILE COPY} + Stderr Should Be Equal To ${ERROR} + Run Tests Without Processing Output ${FLATTEN} --FlattenKeywords invalid output/flatten_keywords.robot + Stderr Should Be Equal To ${ERROR} + +*** Keywords *** +Run And Rebot Flattened + Run Tests Without Processing Output ${FLATTEN} --log log.html output/flatten_keywords.robot + ${LOG} = Get File ${OUTDIR}/log.html + Set Suite Variable $LOG + Copy Previous Outfile + Run Rebot ${FLATTEN} ${OUTFILE COPY} + ${TC} = Check Test Case Flatten stuff + Set Suite Variable $TC + +Check Counts + [Arguments] ${item} ${messages} ${non_messages}=0 + Length Should Be ${item.messages} ${messages} + Length Should Be ${item.non_messages} ${non_messages} diff --git a/atest/robot/output/html_output_stats.py b/atest/robot/output/html_output_stats.py deleted file mode 100644 index feec28c3a1b..00000000000 --- a/atest/robot/output/html_output_stats.py +++ /dev/null @@ -1,41 +0,0 @@ -from robot.api import logger - - -class WrongStat(AssertionError): - ROBOT_CONTINUE_ON_FAILURE = True - - -def get_total_stats(path): - return get_all_stats(path)[0] - -def get_tag_stats(path): - return get_all_stats(path)[1] - -def get_suite_stats(path): - return get_all_stats(path)[2] - -def get_all_stats(path): - logger.info('Getting stats from %s' % (path, path), - html=True) - stats_line = _get_stats_line(path) - logger.debug('Stats line: %s' % stats_line) - total, tags, suite = eval(stats_line) - return total, tags, suite - -def _get_stats_line(path): - prefix = 'window.output["stats"] = ' - with open(path, encoding='UTF-8') as file: - for line in file: - if line.startswith(prefix): - return line[len(prefix):-2] - -def verify_stat(stat, *attrs): - stat.pop('elapsed') - expected = dict(_get_expected_stat(attrs)) - if stat != expected: - raise WrongStat('\n%-9s: %s\n%-9s: %s' % ('Got', stat, 'Expected', expected)) - -def _get_expected_stat(attrs): - for key, value in (a.split(':', 1) for a in attrs): - value = int(value) if value.isdigit() else str(value) - yield str(key), value 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/json_output.robot b/atest/robot/output/json_output.robot new file mode 100644 index 00000000000..03f09aa4de8 --- /dev/null +++ b/atest/robot/output/json_output.robot @@ -0,0 +1,42 @@ +*** Settings *** +Documentation JSON output is tested in detailed level using unit tests. +Resource atest_resource.robot + +*** Variables *** +${JSON} %{TEMPDIR}/output.json +${XML} %{TEMPDIR}/output.xml + +*** Test Cases *** +JSON output contains same suite information as XML output + Run Tests ${EMPTY} misc + Copy File ${OUTFILE} ${XML} + Run Tests Without Processing Output -o ${JSON} misc + Outputs Should Contain Same Data ${JSON} ${XML} ignore_timestamps=True + +JSON output structure + [Documentation] Full JSON schema validation would be good, but it's too slow with big output files. + ... The test after this one validates a smaller suite against a schema. + ${data} = Evaluate json.load(open($JSON, encoding='UTF-8')) + Lists Should Be Equal ${data} ${{['generator', 'generated', 'rpa', 'suite', 'statistics', 'errors']}} + Should Match ${data}[generator] Robot ?.* (* on *) + Should Match ${data}[generated] 20??-??-??T??:??:??.?????? + Should Be Equal ${data}[rpa] ${False} + Should Be Equal ${data}[suite][name] Misc + Should Be Equal ${data}[suite][suites][2][name] Everything + Should Be Equal ${data}[statistics][total][skip] ${3} + Should Be Equal ${data}[statistics][tags][4][label] f1 + Should Be Equal ${data}[statistics][suites][-1][id] s1-s18 + Should Be Equal ${data}[errors][0][level] ERROR + +JSON output matches schema + [Tags] require-jsonschema + Run Tests Without Processing Output -o OUT.JSON misc/everything.robot + Validate JSON Output ${OUTDIR}/OUT.JSON + +Invalid JSON output file + ${path} = Normalize Path ${JSON} + Remove File ${path} + Create Directory ${path} + Run Tests Without Processing Output -o ${path} misc/pass_and_fail.robot + Stderr Should Match [[] ERROR ] Opening output file '${path}' failed: *${USAGE TIP}\n + [Teardown] Remove Directory ${JSON} diff --git a/atest/robot/output/legacy_output.robot b/atest/robot/output/legacy_output.robot new file mode 100644 index 00000000000..017cf0cc68a --- /dev/null +++ b/atest/robot/output/legacy_output.robot @@ -0,0 +1,28 @@ +*** Settings *** +Library LegacyOutputHelper.py +Resource atest_resource.robot + +*** Test Cases *** +Legacy output with Robot + Run Tests --legacyoutput output/legacy.robot validate output=False + Validate output + +Legacy output with Rebot + Run Tests ${EMPTY} output/legacy.robot + Copy Previous Outfile + Run Rebot --legacy-output ${OUTFILE COPY} validate output=False + Validate output + +Legacy output with Rebot when all times are not set + Run Rebot --legacy-output --test Passing ${OUTFILE COPY} validate output=False + Should Be Equal ${SUITE.start_time} ${None} + Should Be Equal ${SUITE.end_time} ${None} + Should Contain Tests ${SUITE} Passing + +*** Keywords *** +Validate output + Should Contain Tests ${SUITE} Passing Failing Failing setup + ... Failing teardown Controls Embedded Warning + ${output} = Mask Changing Parts ${OUTFILE} + ${expected} = Mask Changing Parts ${DATADIR}/output/legacy.xml + Elements Should Be Equal ${output} ${expected} sort_children=True diff --git a/atest/robot/output/listener_interface/body_items_v3.robot b/atest/robot/output/listener_interface/body_items_v3.robot new file mode 100644 index 00000000000..fab0a6ee538 --- /dev/null +++ b/atest/robot/output/listener_interface/body_items_v3.robot @@ -0,0 +1,75 @@ +*** Settings *** +Suite Setup Run Tests --listener ${DATADIR}/${MODIFIER} ${SOURCE} +Resource atest_resource.robot + +*** Variables *** +${SOURCE} output/listener_interface/body_items_v3/tests.robot +${MODIFIER} output/listener_interface/body_items_v3/Modifier.py +@{ALL TESTS} Library keyword User keyword Non-existing keyword +... Empty keyword Duplicate keyword Invalid keyword +... IF TRY FOR WHILE WHILE with modified limit VAR RETURN +... Invalid syntax Run Keyword + +*** Test Cases *** +Modify library keyword + Check Test Case Library keyword FAIL Expected state to be 'initial', but it was 'set by listener'. + +Modify user keyword + Check Test Case User keyword FAIL Failed by listener once! + Check Test Case Empty keyword PASS ${EMPTY} + +Modify invalid keyword + Check Test Case Non-existing keyword PASS ${EMPTY} + Check Test Case Duplicate keyword PASS ${EMPTY} + Check Test Case Invalid keyword PASS ${EMPTY} + +Modify keyword results + ${tc} = Get Test Case Invalid keyword + Check Keyword Data ${tc[0]} Invalid keyword + ... args=\${secret} + ... tags=end, fixed, start + ... doc=Results can be modified both in start and end! + +Modify FOR + ${tc} = Check Test Case FOR FAIL Listener failed me at 'b'! + Length Should Be ${tc[0].body} 2 + Should Be Equal ${tc[0].assign}[0] secret + Should Be Equal ${tc[0, 0].assign}[\${x}] xxx + Should Be Equal ${tc[0, 1].assign}[\${x}] xxx + +Modify WHILE + ${tc} = Check Test Case WHILE FAIL Fail at iteration 10. + Length Should Be ${tc[0].body} 10 + +Modify WHILE limit + ${tc} = Check Test Case WHILE with modified limit PASS ${EMPTY} + Length Should Be ${tc[1].body} 3 + Check Log Message ${tc[1, 0, 0, 0]} \${x} = 1 + Check Log Message ${tc[1, 1, 0, 0]} \${x} = 2 + Check Log Message ${tc[1, 2]} Modified limit message. + +Modify IF + ${tc} = Check Test Case IF FAIL Executed! + Should Be Equal ${tc[0, 0].message} Secret message! + Should Be Equal ${tc[0, 1].message} Secret message! + Should Be Equal ${tc[0, 2].message} Executed! + +Modify TRY + ${tc} = Check Test Case TRY FAIL Not caught! + Length Should Be ${tc[0].body} 3 + +Modify VAR + ${tc} = Check Test Case VAR FAIL value != VAR by listener + Should Be Equal ${tc[0].value}[0] secret + Should Be Equal ${tc[1].value}[0] secret + +Modify RETURN + ${tc} = Check Test Case RETURN FAIL RETURN by listener != value + Should Be Equal ${tc[0, 1].values}[0] secret + +Validate that all methods are called correctly + Run Tests --variable VALIDATE_EVENTS:True ${SOURCE} + Should contain tests ${SUITE} @{ALL TESTS} + Check Log Message ${SUITE.teardown.messages[0]} Listener StartEndBobyItemOnly is OK. + Check Log Message ${SUITE.teardown.messages[1]} Listener SeparateMethods is OK. + Check Log Message ${SUITE.teardown.messages[2]} Listener SeparateMethodsAlsoForKeywords is OK. diff --git a/atest/robot/output/listener_interface/change_status.robot b/atest/robot/output/listener_interface/change_status.robot new file mode 100644 index 00000000000..be97fe5db3f --- /dev/null +++ b/atest/robot/output/listener_interface/change_status.robot @@ -0,0 +1,73 @@ +*** Settings *** +Suite Setup Run Tests --listener ${DATADIR}/${MODIFIER} ${SOURCE} +Resource atest_resource.robot + +*** Variables *** +${SOURCE} output/listener_interface/body_items_v3/change_status.robot +${MODIFIER} output/listener_interface/body_items_v3/ChangeStatus.py + +*** Test Cases *** +Fail to pass + ${tc} = Check Test Case ${TEST NAME} + Check Keyword Data ${tc[0]} BuiltIn.Fail args=Pass me! status=PASS message=Failure hidden! + Check Log Message ${tc[0, 0]} Pass me! level=FAIL + Check Keyword Data ${tc[1]} BuiltIn.Log args=I'm run. status=PASS message= + +Pass to fail + ${tc} = Check Test Case ${TEST NAME} + Check Keyword Data ${tc[0]} BuiltIn.Log args=Fail me! status=FAIL message=Ooops!! + Check Log Message ${tc[0, 0]} Fail me! level=INFO + Check Keyword Data ${tc[1]} BuiltIn.Log args=I'm not run. status=NOT RUN message= + +Pass to fail without a message + ${tc} = Check Test Case ${TEST NAME} + Check Keyword Data ${tc[0]} BuiltIn.Log args=Silent fail! status=FAIL message= + Check Keyword Data ${tc[1]} BuiltIn.Log args=I'm not run. status=NOT RUN message= + +Skip to fail + ${tc} = Check Test Case ${TEST NAME} + Check Keyword Data ${tc[0]} BuiltIn.Skip args=Fail me! status=FAIL message=Failing! + Check Log Message ${tc[0, 0]} Fail me! level=SKIP + Check Keyword Data ${tc[1]} BuiltIn.Log args=I'm not run. status=NOT RUN message= + +Fail to skip + ${tc} = Check Test Case ${TEST NAME} + Check Keyword Data ${tc[0]} BuiltIn.Fail args=Skip me! status=SKIP message=Skipping! + Check Log Message ${tc[0, 0]} Skip me! level=FAIL + Check Keyword Data ${tc[1]} BuiltIn.Log args=I'm not run. status=NOT RUN message= + +Not run to fail + ${tc} = Check Test Case ${TEST NAME} + Check Keyword Data ${tc[0]} BuiltIn.Log args=Fail me! status=FAIL message=Ooops!! + Check Keyword Data ${tc[1]} BuiltIn.Log args=I'm not run. status=NOT RUN message= + Check Keyword Data ${tc[2]} BuiltIn.Log args=Fail me! status=FAIL message=Failing without running! + Check Keyword Data ${tc[3]} BuiltIn.Log args=I'm not run. status=NOT RUN message= + +Pass and fail to not run + ${tc} = Check Test Case ${TEST NAME} + Check Keyword Data ${tc[0]} BuiltIn.Log args=Mark not run! status=NOT RUN message= + Check Keyword Data ${tc[1]} BuiltIn.Fail args=Mark not run! status=NOT RUN message=Mark not run! + Check Keyword Data ${tc[2]} BuiltIn.Fail args=I fail! status=FAIL message=I fail! + +Only message + ${tc} = Check Test Case ${TEST NAME} + Check Keyword Data ${tc[0]} BuiltIn.Fail args=Change me! status=FAIL message=Changed! + Check Keyword Data ${tc[1]} Change message status=NOT RUN message=Changed! + +Control structures + ${tc} = Check Test Case ${TEST NAME} + Check Control Structure ${tc[0]} FOR + Check Control Structure ${tc[1]} WHILE + Check Control Structure ${tc[2]} IF/ELSE ROOT + Check Control Structure ${tc[3]} TRY/EXCEPT ROOT + +*** Keywords *** +Check Control Structure + [Arguments] ${item} ${type} + VAR ${msg} Handled on ${type} level. + Should Be Equal ${item.type} ${type} + Should Be Equal ${item.status} PASS + Should Be Equal ${item.message} ${msg} + Should Be Equal ${item[0].status} FAIL + Should Be Equal ${item[0].message} ${msg} + Check Keyword Data ${item[0, 0]} BuiltIn.Fail args=${msg} status=FAIL message=${msg} diff --git a/atest/robot/output/listener_interface/importing_listeners.robot b/atest/robot/output/listener_interface/importing_listeners.robot index 934c08871dd..6ceabe874b1 100644 --- a/atest/robot/output/listener_interface/importing_listeners.robot +++ b/atest/robot/output/listener_interface/importing_listeners.robot @@ -14,71 +14,77 @@ Python Class Listener From A Module With Different Name Python Module Listener module module_listener module_listener +Listener Versions + [Template] NONE + Check Listener File listener-versions.txt + ... V2 + ... V2AsNonInt + ... V3Implicit + ... V3Explicit + ... V3AsNonInt + Listener With Arguments - class listeners.WithArgs listeners 5 + class listeners.WithArgs listeners 6 [Teardown] Check Listener File ${ARGS_FILE} ... I got arguments 'value' and 'default' ... I got arguments 'a1' and 'a;2' ... I got arguments 'semi' and 'colons:here' + ... I got arguments 'named' and 'args' + +Listener With Argument Conversion + class listeners.WithArgConversion listeners 1 Listener With Path class ${LISTENERS}${/}ListenAll.py ListenAll - [Teardown] File Should Exist %{TEMPDIR}${/}${ALL_FILE2} + [Teardown] File Should Exist %{TEMPDIR}${/}${ALL_FILE2} Listener With Wrong Number Of Arguments - [Template] Check Syslog Contains - Taking listener 'listeners.WithArgs' into use failed: - ... Importing listener 'listeners.WithArgs' failed: - ... Creating instance failed: TypeError: - Taking listener 'listeners.WithArgs:1:2:3' into use failed: - ... Importing listener 'listeners.WithArgs' failed: - ... Creating instance failed: TypeError: - + [Template] Importing Listener Failed + 0 listeners.WithArgs Listener 'WithArgs' expected 1 to 2 arguments, got 0. + 1 listeners.WithArgs:1:2:3 Listener 'WithArgs' expected 1 to 2 arguments, got 3. Non Existing Listener - [Template] Check Syslog Contains - Taking listener 'NonExistingListener' into use failed: - ... Importing listener 'NonExistingListener' failed: - -Java Listener - [Tags] require-jython - class JavaListener - -Java Listener With Arguments - [Tags] require-jython - class JavaListenerWithArgs count=3 - [Teardown] Check Listener File ${JAVA_ARGS_FILE} - ... I got arguments 'Hello' and 'world' - -Java Listener With Wrong Number Of Arguments - [Tags] require-jython - [Template] Check Syslog Contains - Taking listener 'JavaListenerWithArgs' into use failed: - ... Importing listener 'JavaListenerWithArgs' failed: - ... Creating instance failed: - ... TypeError: JavaListenerWithArgs(): expected 2 args; got 0${EMPTY TB} - Taking listener 'JavaListenerWithArgs:b:a:r' into use failed: - ... Importing listener 'JavaListenerWithArgs' failed: - ... Creating instance failed: - ... TypeError: JavaListenerWithArgs(): expected 2 args; got 3${EMPTY TB} + [Template] Importing Listener Failed + 2 NonExistingListener *${EMPTY TB}PYTHONPATH:* pattern=True +Unsupported version + [Template] Taking Listener Into Use Failed + 3 unsupported_listeners.V1Listener Unsupported API version '1'. + 4 unsupported_listeners.V4Listener Unsupported API version '4'. + 5 unsupported_listeners.InvalidVersionListener Unsupported API version 'kekkonen'. *** Keywords *** - Run Tests With Listeners - ${listeners} = Catenate - ... --listener ListenAll - ... --listener listeners.ListenSome - ... --listener module_listener - ... --listener listeners.WithArgs:value - ... --listener "listeners.WithArgs:a1:a;2" - ... --listener "listeners.WithArgs;semi;colons:here" - ... --listener ${LISTENERS}${/}ListenAll.py:%{TEMPDIR}${/}${ALL_FILE2} - ... --listener listeners.WithArgs - ... --listener listeners.WithArgs:1:2:3 - ... --listener JavaListener - ... --listener JavaListenerWithArgs:Hello:world - ... --listener JavaListenerWithArgs - ... --listener JavaListenerWithArgs:b:a:r - ... --listener NonExistingListener - Run Tests ${listeners} misc/pass_and_fail.robot + ${args} = Join Command Line + ... --listener ListenAll + ... --listener listeners.ListenSome + ... --listener module_listener + ... --listener listener_versions.V2 + ... --listener listener_versions.V2AsNonInt + ... --listener listener_versions.V3Implicit + ... --listener listener_versions.V3Explicit + ... --listener listener_versions.V3AsNonInt + ... --listener listeners.WithArgs:value + ... --listener listeners.WithArgs:a1:a;2 + ... --listener listeners.WithArgs;semi;colons:here + ... --listener listeners.WithArgs:arg2=args:arg1=named + ... --listener listeners.WithArgConversion:42:yes + ... --listener ${LISTENERS}${/}ListenAll.py${:}%{TEMPDIR}${/}${ALL_FILE2} + ... --listener listeners.WithArgs + ... --listener listeners.WithArgs:1:2:3 + ... --listener NonExistingListener + ... --listener unsupported_listeners.V1Listener + ... --listener unsupported_listeners.V4Listener + ... --listener unsupported_listeners.InvalidVersionListener + Run Tests ${args} misc/pass_and_fail.robot + +Importing Listener Failed + [Arguments] ${index} ${name} ${error} ${pattern}=False + VAR ${error} Importing listener '${name.split(':')[0]}' failed: ${error} + Taking Listener Into Use Failed ${index} ${name} ${error} ${pattern} +Taking Listener Into Use Failed + [Arguments] ${index} ${name} ${error} ${pattern}=False + Check Log Message + ... ${ERRORS}[${index}] + ... Taking listener '${name}' into use failed: ${error} + ... ERROR pattern=${pattern} diff --git a/atest/robot/output/listener_interface/keyword_arguments_v3.robot b/atest/robot/output/listener_interface/keyword_arguments_v3.robot new file mode 100644 index 00000000000..09b8b7d26b1 --- /dev/null +++ b/atest/robot/output/listener_interface/keyword_arguments_v3.robot @@ -0,0 +1,52 @@ +*** Settings *** +Suite Setup Run Tests --listener ${DATADIR}/${MODIFIER} ${SOURCE} +Resource atest_resource.robot + +*** Variables *** +${SOURCE} output/listener_interface/body_items_v3/keyword_arguments.robot +${MODIFIER} output/listener_interface/body_items_v3/ArgumentModifier.py + +*** Test Cases *** +Library keyword arguments + ${tc} = Check Test Case ${TEST NAME} + Check Keyword Data ${tc[0]} Library.Library Keyword + ... args=\${STATE}, number=\${123}, obj=None, escape=c:\\\\temp\\\\new + Check Keyword Data ${tc[1]} Library.Library Keyword + ... args=new, 123, c:\\\\temp\\\\new, NONE + Check Keyword Data ${tc[2]} Library.Library Keyword + ... args=new, number=\${42}, escape=c:\\\\temp\\\\new, obj=Object(42) + Check Keyword Data ${tc[3]} Library.Library Keyword + ... args=number=1.0, escape=c:\\\\temp\\\\new, obj=Object(1), state=new + +User keyword arguments + ${tc} = Check Test Case ${TEST NAME} + Check Keyword Data ${tc[0]} User keyword + ... args=A, B, C, D + Check Keyword Data ${tc[1]} User keyword + ... args=A, B, d=D, c=\${{"c".upper()}} + +Invalid keyword arguments + ${tc} = Check Test Case Library keyword arguments + Check Keyword Data ${tc[4]} Non-existing + ... args=p, n=1 status=FAIL + +Too many arguments + ${tc} = Check Test Case ${TEST NAME} + Check Keyword Data ${tc[0]} Library.Library Keyword + ... args=a, b, c, d, e, f, g status=FAIL + Check Keyword Data ${tc[1]} User keyword + ... args=a, b, c, d, e, f, g status=FAIL + Check Keyword Data ${tc[2]} Library.Library Keyword + ... args=${{', '.join(str(i) for i in range(100))}} status=FAIL + +Conversion error + ${tc} = Check Test Case ${TEST NAME} + Check Keyword Data ${tc[0]} Library.Library Keyword + ... args=whatever, not a number status=FAIL + Check Keyword Data ${tc[1]} Library.Library Keyword + ... args=number=bad status=FAIL + +Positional after named + ${tc} = Check Test Case ${TEST NAME} + Check Keyword Data ${tc[0]} Library.Library Keyword + ... args=positional, number=-1, ooops status=FAIL diff --git a/atest/robot/output/listener_interface/lineno_and_source.robot b/atest/robot/output/listener_interface/lineno_and_source.robot new file mode 100644 index 00000000000..cc61cbe667d --- /dev/null +++ b/atest/robot/output/listener_interface/lineno_and_source.robot @@ -0,0 +1,283 @@ +*** Settings *** +Suite Setup Run Tests --listener ${LISTENER DIR}/LinenoAndSource.py ${TEST CASE FILE} +Test Template Expect +Test Teardown Validate keywords +Resource listener_resource.robot + +*** Variables *** +${TEST CASE FILE} ${LISTENER DIR}/lineno_and_source.robot +${RESOURCE FILE} ${LISTENER DIR}/lineno_and_source.resource +@{EXPECTED} + +*** Test Cases *** +Keyword + START KEYWORD No Operation 6 NOT SET + \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 + 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 + 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 + +Not run keyword + START KEYWORD Fail 16 NOT SET + \END KEYWORD Fail 16 FAIL + START 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 + +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 + 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 + +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 + +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 + +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 + +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 + \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 \ 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 + +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 + 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 + +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 + +TRY + START TRY ${EMPTY} 65 NOT SET + START KEYWORD Fail 66 NOT SET + \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 + 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 + START ELSE ${EMPTY} 73 NOT RUN + START KEYWORD Fail 74 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 + 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 + 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} + 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 + +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 + +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 + [Teardown] Validate tests + +*** Keywords *** +Expect + [Arguments] ${event} ${type} ${name} ${lineno}=-1 ${status}= ${source}=${TEST CASE FILE} + ${source} = Normalize Path ${source} + ${status} = Set Variable IF "${status}" \t${status} ${EMPTY} + Set test variable @EXPECTED @{EXPECTED} ${event}\t${type}\t${name}\t${lineno}\t${source}${status} + +Validate keywords + Check Test Case ${TESTNAME} + Check Listener File ${TEST NAME}.txt @{EXPECTED} + +Expect test + [Arguments] ${name} ${lineno} ${status}=PASS + Expect START TEST ${name} ${lineno} + Expect END TEST ${name} ${lineno} ${status} + +Validate tests + Check Listener File LinenoAndSourceTests.txt @{EXPECTED} + +Validate suite + Check Listener File LinenoAndSourceSuite.txt @{EXPECTED} diff --git a/atest/robot/output/listener_interface/listener_failing.robot b/atest/robot/output/listener_interface/listener_failing.robot index c0c11c5cf25..da2f6870fb2 100644 --- a/atest/robot/output/listener_interface/listener_failing.robot +++ b/atest/robot/output/listener_interface/listener_failing.robot @@ -16,11 +16,11 @@ Failing library listeners don't affect execution otherwise *** Keywords *** Run Tests With Failing Listener - ${path} = Normalize Path ${DATADIR}/output/listeners/failing_listener.py + ${path} = Normalize Path ${LISTENER DIR}/failing_listener.py Run Tests --listener ${path} -l l.html -r r.html misc/pass_and_fail.robot Run Tests With Failing Library Listener - Run Tests -l l.html -r r.html output/listeners/failing_library_listener.robot + Run Tests -l l.html -r r.html ${LISTENER DIR}/failing_library_listener.robot Test statuses should be correct Check Test Case Pass @@ -31,19 +31,22 @@ Log and report should be created File Should Not Be Empty ${OUTDIR}/r.html Listener errors should be reported - ${path} = Normalize Path ${DATADIR}/output/listeners/failing_listener.py - : FOR ${index} ${method} IN ENUMERATE + ${path} = Normalize Path ${LISTENER DIR}/failing_listener.py + FOR ${index} ${method} IN ENUMERATE ... message start_suite start_keyword log_message end_keyword ... start_test end_test end_suite - \ Error should be reported in execution errors ${index} ${method} ${path} - : FOR ${method} IN output_file log_file report_file close - \ Error should be reported in stderr ${method} ${path} + Error should be reported in execution errors ${index} ${method} ${path} + END + FOR ${method} IN output_file log_file report_file close + Error should be reported in stderr ${method} ${path} + END Library listener errors should be reported - : FOR ${index} ${method} IN ENUMERATE - ... start_suite start_test start_keyword log_message + FOR ${index} ${method} IN ENUMERATE + ... message start_suite start_test start_keyword log_message ... end_keyword end_test end_suite - \ Error should be reported in execution errors ${index} ${method} failing_listener + Error should be reported in execution errors ${index} ${method} failing_listener + END Error should be reported in stderr close failing_listener Error should be reported in execution errors @@ -51,11 +54,11 @@ Error should be reported in execution errors ${error} = Catenate ... Calling method '${method}' of listener '${listener}' failed: ... Expected failure in ${method}! - Check log message @{ERRORS}[${index}] ${error} ERROR + Check log message ${ERRORS}[${index}] ${error} ERROR Error should be reported in stderr [Arguments] ${method} ${listener} ${error} = Catenate ... Calling method '${method}' of listener '${listener}' failed: ... Expected failure in ${method}! - Check stderr contains [ ERROR ] ${error} + Stderr Should Contain [ ERROR ] ${error} diff --git a/atest/robot/output/listener_interface/listener_logging.robot b/atest/robot/output/listener_interface/listener_logging.robot index 1c3d4ecd850..35d400088f4 100644 --- a/atest/robot/output/listener_interface/listener_logging.robot +++ b/atest/robot/output/listener_interface/listener_logging.robot @@ -10,16 +10,29 @@ Logging from listener does not break output file All start and end methods can log warnings to execution errors Correct warnings should be shown in execution errors -Methods inside start_keyword and end_keyword can log normal messages +Methods under tests can log normal messages Correct messages should be logged to normal log -Methods outside start_keyword and end_keyword can log messages to syslog +Methods outside tests can log messages to syslog + Correct messages should be logged to syslog + +Logging from listener when using JSON output + [Setup] Run Tests With Logging Listener format=json + Test statuses should be correct + Log and report should be created + Correct messages should be logged to normal log + Correct warnings should be shown in execution errors Correct messages should be logged to syslog *** Keywords *** Run Tests With Logging Listener - ${path} = Normalize Path ${DATADIR}/output/listeners/logging_listener.py - Run Tests --listener ${path} -l l.html -r r.html misc/pass_and_fail.robot + [Arguments] ${format}=xml + Should Be True $format in ('xml', 'json') + VAR ${output} ${OUTDIR}/output.${format} + VAR ${options} + ... --listener ${LISTENER DIR}/logging_listener.py + ... -o ${output} -l l.html -r r.html + Run Tests ${options} misc/pass_and_fail.robot output=${output} Test statuses should be correct Check Test Case Pass @@ -34,66 +47,135 @@ Correct warnings should be shown in execution errors Correct start/end warnings should be shown in execution errors Execution errors should have messages from message and log_message methods - Check Log Message ${ERRORS[0]} message: INFO Robot Framework * WARN pattern=yes - Check Log Message ${ERRORS[-4]} log_message: FAIL Expected failure WARN + Check Log Message ${ERRORS[0]} message: INFO Robot Framework * WARN pattern=yes + Check Log Message ${ERRORS[-7]} log_message: FAIL Expected failure WARN Correct start/end warnings should be shown in execution errors - ${msgs} = Get start/end messages ${ERRORS.msgs} - ${index} = Set Variable ${0} - @{kw} = Create List start_keyword end_keyword - @{uk} = Create List start_keyword @{kw} @{kw} @{kw} end_keyword - : FOR ${method} IN start_suite @{uk} start_test @{uk} - ... end_test start_test @{uk} @{kw} end_test end_suite - \ Check Log Message ${msgs[${index}]} ${method} WARN - \ ${index} = Set Variable ${index + 1} - Length Should Be ${msgs} ${index} + ${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} @{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} + ... start_test + ... @{uk} + ... start keyword start keyword end keyword end keyword + ... @{kw} + ... start teardown end teardown + ... end_test + ... start_test + ... @{uk} + ... @{kw} + ... start teardown end teardown + ... end_test + ... end_suite + Check Log Message ${msgs}[${index}] ${method} WARN + END + Length Should Be ${msgs} ${index + 1} Get start/end messages - [Arguments] ${all msgs} - @{all msgs} = Set Variable ${all msgs} - ${return} = Create List - : FOR ${msg} IN @{all msgs} - \ Run Keyword Unless "message: " in r"""${msg.message}""" Append To List ${return} ${msg} - [Return] ${return} + [Arguments] ${messages} + ${result} = Create List + FOR ${msg} IN @{messages} + IF "message: " not in $msg.message + ... Append To List ${result} ${msg} + END + RETURN ${result} Correct messages should be logged to normal log 'My Keyword' has correct messages ${SUITE.setup} Suite Setup ${tc} = Check Test Case Pass - 'My Keyword' has correct messages ${tc.kws[0]} Pass + Check Log Message ${tc[0]} start_test INFO + Check Log Message ${tc[1]} start_test WARN + 'My Keyword' has correct messages ${tc[2]} Pass + Check Log Message ${tc[5]} end_test INFO + Check Log Message ${tc[6]} end_test WARN + Teardown has correct messages ${tc.teardown} ${tc} = Check Test Case Fail - 'My Keyword' has correct messages ${tc.kws[0]} Fail - 'Fail' has correct messages ${tc.kws[1]} + Check Log Message ${tc[0]} start_test INFO + Check Log Message ${tc[1]} start_test WARN + 'My Keyword' has correct messages ${tc[2]} Fail + 'Fail' has correct messages ${tc[3]} + Check Log Message ${tc[4]} end_test INFO + Check Log Message ${tc[5]} end_test WARN + Teardown has correct messages ${tc.teardown} 'My Keyword' has correct messages [Arguments] ${kw} ${name} - Check Log Message ${kw.kws[0].msgs[0]} start_keyword INFO - Check Log Message ${kw.kws[0].msgs[1]} start_keyword WARN - Check Log Message ${kw.kws[0].msgs[2]} log_message: INFO Hello says "${name}"! INFO - Check Log Message ${kw.kws[0].msgs[3]} log_message: INFO Hello says "${name}"! WARN - Check Log Message ${kw.kws[0].msgs[4]} Hello says "${name}"! INFO - Check Log Message ${kw.kws[0].msgs[5]} end_keyword INFO - Check Log Message ${kw.kws[0].msgs[6]} end_keyword WARN - Check Log Message ${kw.kws[1].msgs[0]} start_keyword INFO - Check Log Message ${kw.kws[1].msgs[1]} start_keyword WARN - Check Log Message ${kw.kws[1].msgs[2]} end_keyword INFO - Check Log Message ${kw.kws[1].msgs[3]} end_keyword WARN - Check Log Message ${kw.msgs[0]} start_keyword INFO - Check Log Message ${kw.msgs[1]} start_keyword WARN - Check Log Message ${kw.msgs[2]} end_keyword INFO - Check Log Message ${kw.msgs[3]} end_keyword WARN + IF '${name}' == 'Suite Setup' + VAR ${type} setup + ELSE + VAR ${type} keyword + END + Check Log Message ${kw[0]} start ${type} INFO + Check Log Message ${kw[1]} start ${type} WARN + Check Log Message ${kw[2, 0]} start keyword INFO + Check Log Message ${kw[2, 1]} start keyword WARN + Check Log Message ${kw[2, 2]} log_message: INFO Hello says "${name}"! INFO + Check Log Message ${kw[2, 3]} log_message: INFO Hello says "${name}"! WARN + Check Log Message ${kw[2, 4]} Hello says "${name}"! INFO + Check Log Message ${kw[2, 5]} end keyword INFO + Check Log Message ${kw[2, 6]} end keyword WARN + Check Log Message ${kw[3, 0]} start keyword INFO + Check Log Message ${kw[3, 1]} start keyword WARN + Check Log Message ${kw[3, 2]} end keyword INFO + Check Log Message ${kw[3, 3]} end keyword WARN + Check Log Message ${kw[4, 0]} start keyword INFO + Check Log Message ${kw[4, 1]} start keyword WARN + Check Log Message ${kw[4, 2]} log_message: INFO \${assign} = JUST TESTING... INFO + Check Log Message ${kw[4, 3]} log_message: INFO \${assign} = JUST TESTING... WARN + Check Log Message ${kw[4, 4]} \${assign} = JUST TESTING... INFO + Check Log Message ${kw[4, 5]} end keyword INFO + Check Log Message ${kw[4, 6]} end keyword WARN + Check Log Message ${kw[5, 0]} start var INFO + Check Log Message ${kw[5, 1]} start var WARN + Check Log Message ${kw[5, 2]} log_message: INFO \${expected} = JUST TESTING... INFO + Check Log Message ${kw[5, 3]} log_message: INFO \${expected} = JUST TESTING... WARN + Check Log Message ${kw[5, 4]} \${expected} = JUST TESTING... INFO + Check Log Message ${kw[5, 5]} end var INFO + Check Log Message ${kw[5, 6]} end var WARN + Check Log Message ${kw[6, 0]} start keyword INFO + Check Log Message ${kw[6, 1]} start keyword WARN + Check Log Message ${kw[6, 2]} end keyword INFO + Check Log Message ${kw[6, 3]} end keyword WARN + Check Log Message ${kw[7, 0]} start return INFO + Check Log Message ${kw[7, 1]} start return WARN + Check Log Message ${kw[7, 2]} end return INFO + Check Log Message ${kw[7, 3]} end return WARN + Check Log Message ${kw[8]} end ${type} INFO + Check Log Message ${kw[9]} end ${type} WARN + +Teardown has correct messages + [Arguments] ${teardown} + Check Log Message ${teardown[0]} start teardown INFO + Check Log Message ${teardown[1]} start teardown WARN + Check Log Message ${teardown[2]} log_message: INFO Teardown! INFO + Check Log Message ${teardown[3]} log_message: INFO Teardown! WARN + Check Log Message ${teardown[4]} Teardown! + Check Log Message ${teardown[5]} end teardown INFO + Check Log Message ${teardown[6]} end teardown WARN 'Fail' has correct messages [Arguments] ${kw} - Check Log Message ${kw.msgs[0]} start_keyword INFO - Check Log Message ${kw.msgs[1]} start_keyword WARN - Check Log Message ${kw.msgs[2]} log_message: FAIL Expected failure INFO - Check Log Message ${kw.msgs[3]} log_message: FAIL Expected failure WARN - Check Log Message ${kw.msgs[4]} Expected failure FAIL - Check Log Message ${kw.msgs[5]} end_keyword INFO - Check Log Message ${kw.msgs[6]} end_keyword WARN + Check Log Message ${kw[0]} start keyword INFO + Check Log Message ${kw[1]} start keyword WARN + Check Log Message ${kw[2]} log_message: FAIL Expected failure INFO + Check Log Message ${kw[3]} log_message: FAIL Expected failure WARN + Check Log Message ${kw[4]} Expected failure FAIL + Check Log Message ${kw[5]} end keyword INFO + Check Log Message ${kw[6]} end keyword WARN Correct messages should be logged to syslog - : FOR ${msg} IN message: INFO Robot Framework start_suite end_suite - ... start_test end_test output_file log_file report_file - \ Check syslog contains | INFO \ | ${msg} - \ Check syslog contains | WARN \ | ${msg} + FOR ${msg} IN + ... message: INFO Robot Framework + ... start_suite + ... end_suite + ... output_file + ... log_file + ... report_file + Syslog Should Contain | INFO \ | ${msg} + Syslog Should Contain | WARN \ | ${msg} + END diff --git a/atest/robot/output/listener_interface/listener_methods.robot b/atest/robot/output/listener_interface/listener_methods.robot index 8d80f8d1722..7b89725bc43 100644 --- a/atest/robot/output/listener_interface/listener_methods.robot +++ b/atest/robot/output/listener_interface/listener_methods.robot @@ -3,9 +3,6 @@ Suite Setup Run Tests With Listeners Suite Teardown Remove Listener Files Resource listener_resource.robot -*** Variables *** -${LISTENER DIR} ${DATADIR}/output/listeners - *** Test Cases *** Listen All [Documentation] Listener listening all methods. Method names with underscore. @@ -22,56 +19,14 @@ Listen Some @{expected} = Create List Pass Fail ${SUITE_MSG} Check Listener File ${SOME_FILE} @{expected} -Java Listener - [Documentation] Listener listening all methods implemented with Java - [Tags] require-jython - @{expected} = Create List Got settings on level: INFO - ... START SUITE: Pass And Fail 'Some tests here' [ListenerMeta: Hello] - ... START KW: My Keyword [Suite Setup] - ... START KW: BuiltIn.Log [Hello says "\${who}"!\${LEVEL1}] - ... LOG MESSAGE: [INFO] Hello says "Suite Setup"! - ... START KW: BuiltIn.Log [Debug message\${LEVEL2}] - ... START KW: BuiltIn.Set Variable [Just testing...] - ... LOG MESSAGE: [INFO] \${assign} = Just testing... - ... START TEST: Pass '' [forcepass] - ... START KW: My Keyword [Pass] - ... START KW: BuiltIn.Log [Hello says "\${who}"!\${LEVEL1}] - ... LOG MESSAGE: [INFO] Hello says "Pass"! - ... START KW: BuiltIn.Log [Debug message\${LEVEL2}] - ... START KW: BuiltIn.Set Variable [Just testing...] - ... LOG MESSAGE: [INFO] \${assign} = Just testing... - ... END TEST: PASS - ... START TEST: Fail 'FAIL Expected failure' [failforce] - ... START KW: My Keyword [Fail] - ... START KW: BuiltIn.Log [Hello says "\${who}"!\${LEVEL1}] - ... LOG MESSAGE: [INFO] Hello says "Fail"! - ... START KW: BuiltIn.Log [Debug message\${LEVEL2}] - ... START KW: BuiltIn.Set Variable [Just testing...] - ... LOG MESSAGE: [INFO] \${assign} = Just testing... - ... START KW: BuiltIn.Fail [Expected failure] - ... LOG MESSAGE: [FAIL] Expected failure - ... END TEST: FAIL: Expected failure - ... END SUITE: PASS: 1 critical test, 1 passed, 0 failed - ... 2 tests total, 1 passed, 1 failed - ... Output (java): output.xml The End - Check Listener File ${JAVA_FILE} @{expected} - Correct Attributes To Listener Methods ${status} = Log File %{TEMPDIR}/${ATTR_TYPE_FILE} - Check Stderr Does Not Contain attributeverifyinglistener - Should Contain X Times ${status} FAILED 0 - Should Contain X Times ${status} PASSED 384 - -Correct Attributes To Java Listener Methods - [Tags] require-jython - ${status} = Log File %{TEMPDIR}/${JAVA_ATTR_TYPE_FILE} - Check Stderr Does Not Contain JavaAttributeVerifyingListener - Should Contain X Times ${status} FAILED 0 - Should Contain X Times ${status} PASSED 306 + Stderr Should Not Contain VerifyAttributes + Should Not Contain ${status} FAILED 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 @@ -82,100 +37,139 @@ Suite Source Stderr Should Be Empty Keyword Type - Run Tests --listener listeners.KeywordType misc/setups_and_teardowns.robot misc/for_loops.robot + Run Tests --listener listeners.KeywordType misc/setups_and_teardowns.robot misc/for_loops.robot misc/if_else.robot Stderr Should Be Empty -Suite And Test Counts With Java - [Tags] require-jython - Run Tests --listener JavaSuiteAndTestCountListener misc/suites/subsuites misc/suites/subsuites2 +Keyword Status + Run Tests --listener listeners.KeywordStatus misc/pass_and_fail.robot misc/if_else.robot Stderr Should Be Empty Executing Keywords from Listeners Run Tests --listener listeners.KeywordExecutingListener misc/pass_and_fail.robot ${tc}= Get Test Case Pass - Check Log Message ${tc.kws[0].msgs[0]} Start Pass - Check Log Message ${tc.kws[2].msgs[0]} End Pass + Check Log Message ${tc[0, 0]} Start Pass + Check Log Message ${tc[4, 0]} End Pass Test Template - ${listener} = Normalize Path ${DATADIR}/output/listeners/verify_template_listener.py - File Should Exist ${listener} - Run Tests --listener ${listener} output/listeners/test_template.robot + ${listener} = Normalize Path ${LISTENER DIR}/verify_template_listener.py + Run Tests --listener ${listener} ${LISTENER DIR}/test_template.robot Stderr Should Be Empty Keyword Arguments Are Always Strings - ${result} = Run Tests --listener attributeverifyinglistener output/listeners/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 Contain X Times ${status} FAILED 0 - Should Contain X Times ${status} PASSED 211 + 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 misc/if_else.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 ... emulates the situation by explicitly raising TimeoutError. - Run Tests --listener ${LISTENER DIR}/timeouting_listener.py output/listeners/timeouting_listener.robot + Run Tests --listener ${LISTENER DIR}/timeouting_listener.py ${LISTENER DIR}/timeouting_listener.robot Check Test Case Timeout in test case level Check Test Case Timeout inside user keyword Stderr Should Be Empty *** Keywords *** Run Tests With Listeners - ${args}= Catenate - ... --listener ListenAll --listener ListenAll:%{TEMPDIR}${/}${ALL_FILE2} - ... --listener module_listener --listener listeners.ListenSome --listener JavaListener - ... --listener attributeverifyinglistener --listener JavaAttributeVerifyingListener - ... --metadata ListenerMeta:Hello --critical pass + ${args} = Join Command Line + ... --listener ListenAll + ... --listener ListenAll${:}%{TEMPDIR}${/}${ALL_FILE2} + ... --listener module_listener + ... --listener listeners.ListenSome + ... --listener VerifyAttributes + ... --metadata ListenerMeta:Hello Run Tests ${args} misc/pass_and_fail.robot Check Listen All File [Arguments] ${filename} @{expected}= Create List Got settings on level: INFO ... SUITE START: Pass And Fail (s1) 'Some tests here' [ListenerMeta: Hello] - ... KW START: My Keyword ['Suite Setup'] - ... KW START: BuiltIn.Log ['Hello says "\${who}"!', '\${LEVEL1}'] + ... SETUP START: My Keyword ['Suite Setup'] (line 3) + ... KEYWORD START: BuiltIn.Log ['Hello says "\${who}"!', '\${LEVEL1}'] (line 32) ... LOG MESSAGE: [INFO] Hello says "Suite Setup"! - ... KW END: PASS - ... KW START: BuiltIn.Log ['Debug message', '\${LEVEL2}'] - ... KW END: PASS - ... KW START: \${assign} = BuiltIn.Set Variable ['Just testing...'] - ... LOG MESSAGE: [INFO] \${assign} = Just testing... - ... KW END: PASS - ... KW END: PASS - ... TEST START: Pass (s1-t1) '' ['force', 'pass'] crit: yes - ... KW START: My Keyword ['Pass'] - ... KW START: BuiltIn.Log ['Hello says "\${who}"!', '\${LEVEL1}'] + ... KEYWORD END: PASS + ... KEYWORD START: BuiltIn.Log ['Debug message', '\${LEVEL2}'] (line 33) + ... KEYWORD END: PASS + ... KEYWORD START: \${assign} = String.Convert To Upper Case ['Just testing...'] (line 34) + ... LOG MESSAGE: [INFO] \${assign} = JUST TESTING... + ... KEYWORD END: PASS + ... VAR START: \${expected}${SPACE*4}JUST TESTING... (line 35) + ... LOG MESSAGE: [INFO] \${expected} = JUST TESTING... + ... VAR END: PASS + ... KEYWORD START: BuiltIn.Should Be Equal ['\${assign}', '\${expected}'] (line 36) + ... KEYWORD END: PASS + ... RETURN START: (line 37) + ... RETURN END: PASS + ... SETUP END: PASS + ... TEST START: Pass (s1-t1, line 15) '' ['force', 'pass'] + ... KEYWORD START: My Keyword ['Pass'] (line 18) + ... KEYWORD START: BuiltIn.Log ['Hello says "\${who}"!', '\${LEVEL1}'] (line 32) ... LOG MESSAGE: [INFO] Hello says "Pass"! - ... KW END: PASS - ... KW START: BuiltIn.Log ['Debug message', '\${LEVEL2}'] - ... KW END: PASS - ... KW START: \${assign} = BuiltIn.Set Variable ['Just testing...'] - ... LOG MESSAGE: [INFO] \${assign} = Just testing... - ... KW END: PASS - ... KW END: PASS - ... TEST END: PASS crit: yes - ... TEST START: Fail (s1-t2) 'FAIL Expected failure' ['fail', 'force'] crit: no - ... KW START: My Keyword ['Fail'] - ... KW START: BuiltIn.Log ['Hello says "\${who}"!', '\${LEVEL1}'] + ... KEYWORD END: PASS + ... KEYWORD START: BuiltIn.Log ['Debug message', '\${LEVEL2}'] (line 33) + ... KEYWORD END: PASS + ... KEYWORD START: \${assign} = String.Convert To Upper Case ['Just testing...'] (line 34) + ... LOG MESSAGE: [INFO] \${assign} = JUST TESTING... + ... KEYWORD END: PASS + ... VAR START: \${expected}${SPACE*4}JUST TESTING... (line 35) + ... LOG MESSAGE: [INFO] \${expected} = JUST TESTING... + ... VAR END: PASS + ... KEYWORD START: BuiltIn.Should Be Equal ['\${assign}', '\${expected}'] (line 36) + ... KEYWORD END: PASS + ... RETURN START: (line 37) + ... RETURN END: PASS + ... KEYWORD END: PASS + ... KEYWORD START: example.Resource Keyword (line 19) + ... KEYWORD START: BuiltIn.Log ['Hello, resource!'] (line 3) + ... LOG MESSAGE: [INFO] Hello, resource! + ... KEYWORD END: PASS + ... KEYWORD END: PASS + ... KEYWORD START: BuiltIn.Should Be Equal ['\${VARIABLE}', 'From variables.py with arg 1'] (line 20) + ... KEYWORD END: PASS + ... TEARDOWN START: BuiltIn.Log ['Teardown!'] (line 4) + ... LOG MESSAGE: [INFO] Teardown! + ... TEARDOWN END: PASS + ... TEST END: PASS + ... TEST START: Fail (s1-t2, line 22) 'FAIL Expected failure' ['fail', 'force'] + ... KEYWORD START: My Keyword ['Fail'] (line 25) + ... KEYWORD START: BuiltIn.Log ['Hello says "\${who}"!', '\${LEVEL1}'] (line 32) ... LOG MESSAGE: [INFO] Hello says "Fail"! - ... KW END: PASS - ... KW START: BuiltIn.Log ['Debug message', '\${LEVEL2}'] - ... KW END: PASS - ... KW START: \${assign} = BuiltIn.Set Variable ['Just testing...'] - ... LOG MESSAGE: [INFO] \${assign} = Just testing... - ... KW END: PASS - ... KW END: PASS - ... KW START: BuiltIn.Fail ['Expected failure'] + ... KEYWORD END: PASS + ... KEYWORD START: BuiltIn.Log ['Debug message', '\${LEVEL2}'] (line 33) + ... KEYWORD END: PASS + ... KEYWORD START: \${assign} = String.Convert To Upper Case ['Just testing...'] (line 34) + ... LOG MESSAGE: [INFO] \${assign} = JUST TESTING... + ... KEYWORD END: PASS + ... VAR START: \${expected}${SPACE*4}JUST TESTING... (line 35) + ... LOG MESSAGE: [INFO] \${expected} = JUST TESTING... + ... VAR END: PASS + ... KEYWORD START: BuiltIn.Should Be Equal ['\${assign}', '\${expected}'] (line 36) + ... KEYWORD END: PASS + ... RETURN START: (line 37) + ... RETURN END: PASS + ... KEYWORD END: PASS + ... KEYWORD START: BuiltIn.Fail ['Expected failure'] (line 26) ... LOG MESSAGE: [FAIL] Expected failure - ... KW END: FAIL - ... TEST END: FAIL Expected failure crit: no - ... SUITE END: PASS 1 critical test, 1 passed, 0 failed - ... 2 tests total, 1 passed, 1 failed + ... KEYWORD END: FAIL + ... TEARDOWN START: BuiltIn.Log ['Teardown!'] (line 4) + ... LOG MESSAGE: [INFO] Teardown! + ... TEARDOWN END: PASS + ... TEST END: FAIL Expected failure + ... SUITE END: FAIL 2 tests, 1 passed, 1 failed ... Output: output.xml Closing... Check Listener File ${filename} @{expected} + Stderr Should Be Empty Calling listener failed [Arguments] ${method} ${error} - Check Stderr Contains [ ERROR ] Calling listener method '${method}' of + Stderr Should Contain [ ERROR ] Calling listener method '${method}' of ... listener 'listeners.InvalidMethods' failed: ${error} diff --git a/atest/robot/output/listener_interface/listener_order.robot b/atest/robot/output/listener_interface/listener_order.robot new file mode 100644 index 00000000000..4e910a9ffcc --- /dev/null +++ b/atest/robot/output/listener_interface/listener_order.robot @@ -0,0 +1,57 @@ +*** Settings *** +Suite Setup Run Tests With Ordered Listeners +Resource atest_resource.robot + +*** Variables *** +${LISTENER} ${DATADIR}/output/listener_interface/ListenerOrder.py + +*** Test Cases *** +Validate normal order + VAR ${expected} + ... LIB 3 (999.9): start_suite + ... CLI 2 (3.14): start_suite + ... CLI 3 (None): start_suite + ... LIB 1 (0): start_suite + ... LIB 2 (None): start_suite + ... CLI 1 (-1): start_suite + ... LIB 3 (999.9): log_message + ... CLI 2 (3.14): log_message + ... CLI 3 (None): log_message + ... LIB 1 (0): log_message + ... LIB 2 (None): log_message + ... CLI 1 (-1): log_message + ... LIB 3 (999.9): end_test + ... CLI 2 (3.14): end_test + ... CLI 3 (None): end_test + ... LIB 1 (0): end_test + ... LIB 2 (None): end_test + ... CLI 1 (-1): end_test + ... separator=\n + File Should Be Equal To %{TEMPDIR}/listener_order.log ${expected}\n + +Validate close order + [Documentation] Library listeners are closed when libraries go out of scope. + VAR ${expected} + ... LIB 1 (0): close + ... LIB 2 (None): close + ... LIB 3 (999.9): close + ... CLI 2 (3.14): close + ... CLI 3 (None): close + ... CLI 1 (-1): close + ... separator=\n + File Should Be Equal To %{TEMPDIR}/listener_close_order.log ${expected}\n + +Invalid priority + ${listener} = Normalize Path ${LISTENER} + Check Log Message ${ERRORS}[0] Taking listener '${listener}:NOT USED:invalid' into use failed: Invalid listener priority 'invalid'. ERROR + Check Log Message ${ERRORS}[1] Error in library 'BAD': Registering listeners failed: Taking listener 'ListenerOrder' into use failed: Invalid listener priority 'bad'. ERROR + +*** Keywords *** +Run Tests With Ordered Listeners + ${listener} = Normalize Path ${LISTENER} + VAR ${options} + ... --listener "${listener}:CLI 1:-1" + ... --listener "${listener}:CLI 2:3.14" + ... --listener "${listener}:NOT USED:invalid" + ... --listener "${listener}:CLI 3" + Run Tests ${options} output/listener_interface/listener_order.robot diff --git a/atest/robot/output/listener_interface/listener_resource.robot b/atest/robot/output/listener_interface/listener_resource.robot index 68427f76ad9..23d4da59ca3 100644 --- a/atest/robot/output/listener_interface/listener_resource.robot +++ b/atest/robot/output/listener_interface/listener_resource.robot @@ -5,16 +5,14 @@ Resource atest_resource.robot ${ALL_FILE} listen_all.txt ${ALL_FILE2} listen_all2.txt ${SOME_FILE} listen_some.txt -${JAVA_FILE} listen_java.txt ${ARGS_FILE} listener_with_args.txt -${JAVA_ARGS_FILE} java_listener_with_args.txt ${MODULE_FILE} listen_by_module.txt ${ATTR_TYPE_FILE} listener_attrs.txt -${JAVA_ATTR_TYPE_FILE} listener_attrs_java.txt -${SUITE_MSG} 1 critical test, 1 passed, 0 failed\n 2 tests total, 1 passed, 1 failed -${SUITE_MSG_2} 2 critical tests, 1 passed, 1 failed\n 2 tests total, 1 passed, 1 failed +${SUITE_MSG} 2 tests, 1 passed, 1 failed +${SUITE_MSG_2} 2 tests, 1 passed, 1 failed ${LISTENERS} ${CURDIR}${/}..${/}..${/}..${/}testresources${/}listeners ${EMPTY TB} \nTraceback (most recent call last):\n${SPACE*2}None\n +${LISTENER DIR} ${DATADIR}/output/listener_interface *** Keywords *** Listener Import Message Should Be In Syslog @@ -29,13 +27,10 @@ Remove Listener Files Remove Files ... %{TEMPDIR}/${ALL_FILE} ... %{TEMPDIR}/${SOME_FILE} - ... %{TEMPDIR}/${JAVA_FILE} ... %{TEMPDIR}/${ARGS_FILE} ... %{TEMPDIR}/${ALL_FILE2} ... %{TEMPDIR}/${MODULE_FILE} - ... %{TEMPDIR}/${JAVA_ARGS_FILE} ... %{TEMPDIR}/${ATTR_TYPE_FILE} - ... %{TEMPDIR}/${JAVA_ATTR_TYPE_FILE} Check Listener File [Arguments] ${file} @{expected} @@ -43,8 +38,8 @@ Check Listener File ${expected} = Catenate SEPARATOR=\n @{expected} ${EMPTY} Should Be Equal ${content} ${expected} -Get Listener FIle +Get Listener File [Arguments] ${file} ${path} = Join Path %{TEMPDIR} ${file} ${content} = Get File ${path} - [Return] ${content} + RETURN ${content} diff --git a/atest/robot/output/listener_interface/listener_v3.robot b/atest/robot/output/listener_interface/listener_v3.robot index de1094f48ab..b5d3213759a 100644 --- a/atest/robot/output/listener_interface/listener_v3.robot +++ b/atest/robot/output/listener_interface/listener_v3.robot @@ -1,68 +1,108 @@ *** Settings *** -Resource atest_resource.robot -Suite Setup Run Tests --listener ${DATADIR}/output/listeners/v3.py -l l -r r -b d -x x misc/pass_and_fail.robot +Suite Setup Run Tests --listener ${LISTENER DIR}/v3.py -l l -r r -b d -x x -L trace misc/pass_and_fail.robot +Resource listener_resource.robot + +*** Variables *** +${SEPARATOR} ${EMPTY + '-' * 78} *** Test Cases *** -New tests and keywords can be added to suite - ${tc} = Check test case New [start suite] FAIL Message: [start] [end] - Check Stdout Contains SEPARATOR=\n - ... New [start suite] :: [start suite] ${SPACE*34} | FAIL | - ... Message: [start] [end] - Check keyword data ${tc.kws[0]} BuiltIn.No Operation +New tests and keywords can be added + ${tc} = Check test case Added by start_suite [start suite] FAIL [start] [end] + Check keyword data ${tc[0]} BuiltIn.No Operation + ${tc} = Check test case Added by startTest PASS Dynamically added! [end] + Check keyword data ${tc[0]} BuiltIn.Fail args=Dynamically added! status=FAIL + ${tc} = Check test case Added by end_Test FAIL [start] [end] + Check keyword data ${tc[0]} BuiltIn.Log args=Dynamically added!, INFO + Stdout Should Contain SEPARATOR=\n + ... Added by start_suite [start suite] :: [start suite] ${SPACE*17} | FAIL | + ... [start] [end] + ... ${SEPARATOR} + ... Added by startTest ${SPACE*50} | PASS | + ... Dynamically added! [end] + ... ${SEPARATOR} + ... Added by end_test :: Dynamic ${SPACE*40} | FAIL | + ... [start] [end] + ... ${SEPARATOR} Test status and message can be changed - Check Test case Pass [start suite] FAIL Message: [start] [end] + Check Test case Pass [start suite] FAIL [start] [end] Check Test case Fail [start suite] PASS Expected failure [end] - Check Stdout Contains SEPARATOR=\n + Stdout Should Contain SEPARATOR=\n ... Pass [start suite] :: [start suite] ${SPACE*33} | FAIL | - ... Message: [start] [end] - Check Stdout Contains SEPARATOR=\n + ... [start] [end] + Stdout Should Contain SEPARATOR=\n ... Fail [start suite] :: FAIL Expected failure [start suite] ${SPACE*11} | PASS | ... Expected failure [end] Changing test status in end suite changes console output, but not output.xml - Check stdout contains SEPARATOR=\n - ... 3 critical tests, 3 passed, 0 failed - ... 3 tests total, 3 passed, 0 failed - ${from output.xml} = Catenate SEPARATOR=\n - ... 3 critical tests, 1 passed, 2 failed - ... 3 tests total, 1 passed, 2 failed - Should be equal ${SUITE.stat_message} ${from output.xml} + Stdout Should Contain SEPARATOR=\n + ... 5 tests, 5 passed, 0 failed + ${from output.xml} = Catenate SEPARATOR=\n + ... 5 tests, 2 passed, 3 failed + Should be equal ${SUITE.stat_message} ${from output.xml} Test tags can be modified - Check Test Tags Fail [start suite] [end] [start] [start suite] fail force + Check Test Tags Fail [start suite] [end] [start] [start suite] fail force Metadata can be modified - Should be equal ${SUITE.metadata['suite']} [start] [end] - Should be equal ${SUITE.metadata['tests']} xxx + Should be equal ${SUITE.metadata['suite']} [start] [end] + Should be equal ${SUITE.metadata['tests']} xxxxx Changing current element name is not possible - [Documentation] But start_suite can change test names - Should be equal ${SUITE.name} Pass And Fail - Check stdout contains Pass And Fail :: Some tests here - Check stdout contains Pass [start suite] :: - Should be equal ${SUITE.tests[0].name} Pass [start suite] + [Documentation] But start_suite can change test names + Should be equal ${SUITE.name} Pass And Fail + Stdout Should Contain Pass And Fail :: Some tests here + Stdout Should Contain Pass [start suite] :: + Should be equal ${SUITE.tests[0].name} Pass [start suite] Changing current element docs does not change console output, but does change output.xml - [Documentation] But start_suite can change test docs - Check stdout contains Pass And Fail :: Some tests here - Should be equal ${SUITE.doc} Some tests here [start suite] [end suite] - Check stdout contains Pass [start suite] :: [start suite] ${SPACE*33} | FAIL | - Check Test Doc Pass [start suite] [start suite] [start test] [end test] + [Documentation] But start_suite can change test docs + Stdout Should Contain Pass And Fail :: Some tests here + Should be equal ${SUITE.doc} Some tests here [start suite] [end suite] + Stdout Should Contain Pass [start suite] :: [start suite] ${SPACE*33} | FAIL | + Check Test Doc Pass [start suite] [start suite] [start test] [end test] 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 + ${tc} = Get Test Case Pass [start suite] + Check Keyword Data ${tc[0, 0]} BuiltIn.Log args=Hello says "\${who}"!, \${LEVEL1} + Check Log Message ${tc[0, 0, 0]} HELLO SAYS "PASS"! + Should Be Equal ${tc[0, 0, 0].timestamp} ${datetime(2015, 12, 16, 15, 51, 20, 141000)} + +Log message can be removed by setting message to `None` + ${tc} = Get Test Case Fail [start suite] + Check Keyword Data ${tc[0, 0]} BuiltIn.Log args=Hello says "\${who}"!, \${LEVEL1} + Should Be Empty ${tc[0, 0].body} + File Should Not Contain ${OUTDIR}/d.txt HELLO SAYS "FAIL"! + File Should Not Contain ${OUTDIR}/d.txt None + +Syslog messages can be changed + Syslog Should Contain Match 2015-12-16 15:51:20.141000 | INFO \ | TESTS EXECUTION ENDED. STATISTICS: -Message to syslog can be changed - Syslog Should Contain Match 20151216 15:51:20.141 | INFO \ | TESTS EXECUTION ENDED. STATISTICS: +Library import + Stdout Should Contain Imported library 'BuiltIn' with 109 keywords. + Stdout Should Contain Imported library 'String' with 32 keywords. + ${tc} = Get Test Case Pass [start suite] + Check Keyword Data ${tc[0, 0]} BuiltIn.Log doc=Changed! args=Hello says "\${who}"!, \${LEVEL1} + +Resource import + Stdout Should Contain Imported resource 'example' with 2 keywords. + ${tc} = Get Test Case Pass [start suite] + Check Keyword Data ${tc[1, 1]} example.New! doc=Dynamically created. + +Variables import + Stdout Should Contain Imported variables 'variables.py' without much info. File methods and close are called - Stderr Should Be Equal To SEPARATOR=\n - ... Debug: d.txt - ... Output: output.xml - ... Xunit: x.xml - ... Log: l.html - ... Report: r.html - ... Close\n + Stderr Should Be Equal To SEPARATOR=\n + ... Debug: d.txt + ... Output: output.xml + ... Xunit: x.xml + ... Log: l.html + ... Report: r.html + ... Close\n + +File methods when files are disabled + Run Tests Without Processing Output --listener ${LISTENER DIR}/v3.py -o NONE -r NONE -l NONE misc/pass_and_fail.robot + Stderr Should Be Equal To SEPARATOR=\n + ... Output: None + ... Close\n diff --git a/atest/robot/output/listener_interface/listening_imports.robot b/atest/robot/output/listener_interface/listening_imports.robot index b0ad84f70a2..e66c38abb59 100644 --- a/atest/robot/output/listener_interface/listening_imports.robot +++ b/atest/robot/output/listener_interface/listening_imports.robot @@ -1,5 +1,5 @@ *** Settings *** -Suite Setup Run Tests --listener "ListenImports;${IMPORTS FILE}" output/listeners/imports/imports.robot +Suite Setup Run Tests --listener "ListenImports;${IMPORTS FILE}" ${LISTENER DIR}/imports/imports.robot Suite Teardown Remove Listener Files Resource listener_resource.robot @@ -12,29 +12,92 @@ All imports are usable Listen Imports Init Expect - Expect Library BuiltIn args: [] importer: None originalname: BuiltIn source: //BuiltIn.py - Expect Resource another_resource importer: //resource_with_imports.robot source: //another_resource.robot - Expect Library Process args: [] importer: //resource_with_imports.robot originalname: Process source: //Process.py - Expect Resource resource_with_imports importer: //imports.robot source: //resource_with_imports.robot - Expect Library String args: [] importer: //imports.robot originalname: String source: //String.py - Expect Library Aliased args: [\${2}] importer: //imports.robot originalname: local_lib source: //local_lib.py - Java Expect Library ExampleJavaLibrary args: [] importer: //imports.robot originalname: ExampleJavaLibrary source: None - Expect Library pythonmodule args: [] importer: //imports.robot originalname: pythonmodule source: //pythonmodule/__init__.py - Expect Variables vars.py args: [name, value] importer: //imports.robot source: //vars.py - Expect Variables vars.py args: [] importer: //imports.robot source: //vars.py - Expect Library OperatingSystem args: [] importer: None originalname: OperatingSystem source: //OperatingSystem.py - Expect Resource dynamically_imported_resource importer: None source: //dynamically_imported_resource.robot - Expect Variables vars.py args: [new, args] importer: None source: //vars.py + Expect + ... Library + ... BuiltIn + ... args: [] + ... importer: None + ... originalname: BuiltIn + ... source: //BuiltIn.py + Expect + ... Resource + ... another_resource + ... importer: //resource_with_imports.robot + ... source: //another_resource.robot + Expect + ... Library + ... Process + ... args: [] + ... importer: //resource_with_imports.robot + ... originalname: Process + ... source: //Process.py + Expect + ... Resource + ... resource_with_imports + ... importer: //imports.robot + ... source: //resource_with_imports.robot + Expect + ... Library + ... String + ... args: [] + ... importer: //imports.robot + ... originalname: String + ... source: //String.py + Expect + ... Library + ... Aliased + ... args: [\${2}] + ... importer: //imports.robot + ... originalname: local_lib + ... source: //local_lib.py + Expect + ... Library + ... pythonmodule + ... args: [] + ... importer: //imports.robot + ... originalname: pythonmodule + ... source: //pythonmodule/__init__.py + Expect + ... Variables + ... vars.py + ... args: [name, value] + ... importer: //imports.robot + ... source: //vars.py + Expect + ... Variables + ... vars.py + ... args: [] + ... importer: //imports.robot + ... source: //vars.py + Expect + ... Library + ... OperatingSystem + ... args: [] + ... importer: //imports.robot + ... originalname: OperatingSystem + ... source: //OperatingSystem.py + Expect + ... Resource + ... dynamically_imported_resource + ... importer: //imports.robot + ... source: //dynamically_imported_resource.robot + Expect + ... Variables + ... vars.py + ... args: [new, args] + ... importer: //imports.robot + ... source: //vars.py Verify Expected Failed Impors Are Listed In Errors - ${path} = Normalize Path ${DATADIR}/output/listeners/imports/imports.robot - Check Syslog Contains | ERROR | Error in file '${path}': + ${path} = Normalize Path ${LISTENER DIR}/imports/imports.robot + Error in file 0 ${path} 9 ... Resource file 'resource that does not exist and fails' does not exist. - Check Syslog Contains | ERROR | Error in file '${path}': - ... Importing test library 'LibraryThatDoesNotExist' failed: - Check Syslog Contains | ERROR | Error in file '${path}': - ... Variable file 'variables which dont exist' does not exist. + Error in file 1 ${path} 10 + ... Importing library 'LibraryThatDoesNotExist' failed: * + ... traceback=None + Error in file 2 ${path} 11 + ... Variable file 'variables which dont exist.py' does not exist. *** Keywords *** Init expect @@ -42,12 +105,11 @@ Init expect Expect [Arguments] ${type} ${name} @{attrs} - ${entry} = Catenate SEPARATOR=\n\t Imported ${type} name: ${name} @{attrs} + ${entry} = Catenate SEPARATOR=\n\t + ... Imported ${type} + ... name: ${name} + ... @{attrs} Set test variable @{EXPECTED} @{EXPECTED} ${entry} -Java Expect - [Arguments] ${type} ${name} @{attrs} - Run keyword if $INTERPRETER.is_jython Expect ${type} ${name} @{attrs} - Verify Expected Check Listener File listener_imports.txt @{EXPECTED} diff --git a/atest/robot/output/listener_interface/log_levels.robot b/atest/robot/output/listener_interface/log_levels.robot index e61924004a1..0cc5d59efe0 100644 --- a/atest/robot/output/listener_interface/log_levels.robot +++ b/atest/robot/output/listener_interface/log_levels.robot @@ -10,28 +10,53 @@ Log messages are collected on INFO level by default Run Tests --listener listeners.Messages;${MESSAGE FILE} misc/pass_and_fail.robot Logged messages should be ... INFO: Hello says "Suite Setup"! - ... INFO: \${assign} = Just testing... + ... INFO: \${assign} = JUST TESTING... + ... INFO: \${expected} = JUST TESTING... ... INFO: Hello says "Pass"! - ... INFO: \${assign} = Just testing... + ... INFO: \${assign} = JUST TESTING... + ... INFO: \${expected} = JUST TESTING... + ... INFO: Hello, resource! + ... INFO: Teardown! ... INFO: Hello says "Fail"! - ... INFO: \${assign} = Just testing... + ... INFO: \${assign} = JUST TESTING... + ... INFO: \${expected} = JUST TESTING... ... FAIL: Expected failure + ... INFO: Teardown! Log messages are collected on specified level Run Tests -L DEBUG --listener listeners.Messages;${MESSAGE FILE} misc/pass_and_fail.robot Logged messages should be ... INFO: Hello says "Suite Setup"! ... DEBUG: Debug message - ... INFO: \${assign} = Just testing... + ... INFO: \${assign} = JUST TESTING... + ... INFO: \${expected} = JUST TESTING... + ... DEBUG: Argument types are: + ... + ... ... INFO: Hello says "Pass"! ... DEBUG: Debug message - ... INFO: \${assign} = Just testing... + ... INFO: \${assign} = JUST TESTING... + ... INFO: \${expected} = JUST TESTING... + ... DEBUG: Argument types are: + ... + ... + ... INFO: Hello, resource! + ... DEBUG: Argument types are: + ... + ... + ... INFO: Teardown! ... INFO: Hello says "Fail"! ... DEBUG: Debug message - ... INFO: \${assign} = Just testing... + ... INFO: \${assign} = JUST TESTING... + ... INFO: \${expected} = JUST TESTING... + ... DEBUG: Argument types are: + ... + ... ... FAIL: Expected failure ... DEBUG: Traceback (most recent call last): ... ${SPACE*2}None + ... AssertionError: Expected failure + ... INFO: Teardown! *** Keywords *** Logged messages should be diff --git a/atest/robot/output/listener_interface/output_files.robot b/atest/robot/output/listener_interface/output_files.robot deleted file mode 100644 index d6310657e14..00000000000 --- a/atest/robot/output/listener_interface/output_files.robot +++ /dev/null @@ -1,42 +0,0 @@ -*** Settings *** -Documentation Testing that listener gets information about different output files. Tests also that the listener can be taken into use with path. -Suite Setup Run Some Tests -Suite Teardown Remove Listener Files -Resource listener_resource.robot - -*** Variables *** -${LISTENERS} ${CURDIR}${/}..${/}..${/}..${/}testresources${/}listeners - -*** Test Cases *** -Output Files - ${file} = Get Listener File ${ALL_FILE} - ${expected} = Catenate SEPARATOR=\n - ... Debug: mydeb.txt - ... Output: myout.xml - ... Log: mylog.html - ... Report: myrep.html - ... Closing...\n - Should End With ${file} ${expected} - -Output Files With Java - [Tags] require-jython - ${file} = Get Listener File ${JAVA_FILE} - ${expected} = Catenate SEPARATOR=\n - ... Debug (java): mydeb.txt - ... Output (java): myout.xml - ... Log (java): mylog.html - ... Report (java): myrep.html - ... The End\n - Should End With ${file} ${expected} - -*** Keywords *** -Run Some Tests - ${options} = Catenate - ... --listener "${LISTENERS}${/}ListenAll.py" - ... --listener "${LISTENERS}${/}JavaListener.java" - ... --log mylog.html - ... --report myrep.html - ... --output myout.xml - ... --debugfile mydeb.txt - Run Tests ${options} misc/pass_and_fail.robot output=${OUTDIR}/myout.xml - Should Be Equal ${SUITE.name} Pass And Fail diff --git a/atest/robot/output/listener_interface/recursion.robot b/atest/robot/output/listener_interface/recursion.robot new file mode 100644 index 00000000000..2b951649ca0 --- /dev/null +++ b/atest/robot/output/listener_interface/recursion.robot @@ -0,0 +1,41 @@ +*** Settings *** +Suite Setup Run Tests --listener ${LISTENER DIR}/Recursion.py ${LISTENER DIR}/recursion.robot +Resource listener_resource.robot + +*** Test Cases *** +Limited recursion in start_keyword, end_keyword and log_message + ${tc} = Check Test Case Limited recursion + Length Should Be ${tc.body} 1 + VAR ${kw} ${tc[0]} + Check Keyword Data ${kw} BuiltIn.Log args=Limited 3 children=5 + Check Keyword Data ${kw[0]} BuiltIn.Log args=Limited 2 (by start_keyword) children=4 + Check Keyword Data ${kw[0, 0]} BuiltIn.Log args=Limited 1 (by start_keyword) children=1 + Check Log Message ${kw[0, 0, 0]} Limited 1 (by start_keyword) + Check Log Message ${kw[0, 1]} Limited 1 (by log_message) + Check Log Message ${kw[0, 2]} Limited 2 (by start_keyword) + Check Keyword Data ${kw[0, 3]} BuiltIn.Log args=Limited 1 (by end_keyword) children=1 + Check Log Message ${kw[0, 3, 0]} Limited 1 (by end_keyword) + Check Log Message ${kw[1]} Limited 1 (by log_message) + Check Log Message ${kw[2]} Limited 2 (by log_message) + Check Log Message ${kw[3]} Limited 3 + Check Keyword Data ${kw[4]} BuiltIn.Log args=Limited 2 (by end_keyword) children=4 + Check Keyword Data ${kw[4, 0]} BuiltIn.Log args=Limited 1 (by start_keyword) children=1 + Check Log Message ${kw[4, 0, 0]} Limited 1 (by start_keyword) + Check Log Message ${kw[4, 1]} Limited 1 (by log_message) + Check Log Message ${kw[4, 2]} Limited 2 (by end_keyword) + Check Keyword Data ${kw[4, 3]} BuiltIn.Log args=Limited 1 (by end_keyword) children=1 + Check Log Message ${kw[4, 3, 0]} Limited 1 (by end_keyword) + +Unlimited recursion in start_keyword, end_keyword and log_message + Check Test Case Unlimited recursion + Check Recursion Error ${ERRORS[0]} start_keyword Recursive execution stopped. + Check Recursion Error ${ERRORS[1]} end_keyword Recursive execution stopped. + Check Recursion Error ${ERRORS[2]} log_message RecursionError: * + +*** Keywords *** +Check Recursion Error + [Arguments] ${msg} ${method} ${error} + ${listener} = Normalize Path ${LISTENER DIR}/Recursion.py + Check Log Message ${msg} + ... Calling method '${method}' of listener '${listener}' failed: ${error} + ... ERROR pattern=True diff --git a/atest/robot/output/listener_interface/result_files.robot b/atest/robot/output/listener_interface/result_files.robot new file mode 100644 index 00000000000..792163727c8 --- /dev/null +++ b/atest/robot/output/listener_interface/result_files.robot @@ -0,0 +1,97 @@ +*** Settings *** +Suite Teardown Remove Listener Files +Resource listener_resource.robot + +*** Variables *** +${PATH} %{TEMPDIR}/listen_result_files.txt +${V2} ${LISTENERS}/ListenAll.py +${V3} ${DATADIR}/output/listener_interface/ResultFiles.py + +*** Test Cases *** +Result files with v2 listener + VAR ${options} + ... --listener "${V2}${:}${PATH}" + ... --output myout.xml + ... --report myrep.html + ... --log mylog.html + ... --xunit myxun.xml + ... --debugfile mydeb.txt + Run Tests ${options} misc/pass_and_fail.robot output=${OUTDIR}/myout.xml + Validate result files + ... Debug: mydeb.txt + ... Output: myout.xml + ... Xunit: myxun.xml + ... Log: mylog.html + ... Report: myrep.html + +Result files disables with v2 listener + VAR ${options} + ... --listener "${V2}${:}${PATH}${:}output_file_disabled=True" + ... --log NONE + ... --report NONE + ... --output NONE + Run Tests Without Processing Output ${options} misc/pass_and_fail.robot + Validate result files + ... Output: None + +Result files with v3 listener + VAR ${options} + ... --listener "${V3}${:}${PATH}" + ... --output myout.xml + ... --report myrep.html + ... --log mylog.html + ... --xunit myxun.xml + ... --debugfile mydeb.txt + Run Tests ${options} misc/pass_and_fail.robot output=${OUTDIR}/myout.xml + Validate result files + ... Debug: mydeb.txt + ... Output: myout.xml + ... Xunit: myxun.xml + ... Log: mylog.html + ... Report: myrep.html + +Result files disables with v3 listener + VAR ${options} + ... --listener "${V3}${:}${PATH}${:}output_file_disabled=True" + ... --log NONE + ... --report NONE + ... --output NONE + Run Tests Without Processing Output ${options} misc/pass_and_fail.robot + Validate result files + ... Output: None + +Only 'result_file' method with v3 listener + VAR ${options} + ... --listener "${V3}${:}${PATH}${:}only_result_file=True" + ... --output myout.xml + ... --report myrep.html + ... --log mylog.html + ... --xunit myxun.xml + ... --debugfile mydeb.txt + Run Tests ${options} misc/pass_and_fail.robot output=${OUTDIR}/myout.xml + Validate result files + ... DEBUG: mydeb.txt + ... OUTPUT: myout.xml + ... XUNIT: myxun.xml + ... LOG: mylog.html + ... REPORT: myrep.html + +Result files disables with v3 listener having only 'result_file' method + VAR ${options} + ... --listener "${V3}${:}${PATH}${:}output_file_disabled=True:only_result_file=True" + ... --log NONE + ... --report NONE + ... --output NONE + Run Tests Without Processing Output ${options} misc/pass_and_fail.robot + Validate result files + +*** Keywords *** +Validate result files + [Arguments] @{files} + VAR ${expected} + ... @{files} + ... Closing...\n + ... separator=\n + ${file} = Get File ${PATH} + Should End With ${file} ${expected} + Stderr Should Be Empty diff --git a/atest/robot/output/listener_interface/result_model.robot b/atest/robot/output/listener_interface/result_model.robot new file mode 100644 index 00000000000..3f56c5c0472 --- /dev/null +++ b/atest/robot/output/listener_interface/result_model.robot @@ -0,0 +1,29 @@ +*** Settings *** +Suite Setup Run Tests --listener "${LISTENER DIR}/ResultModel.py;${MODEL FILE}" --loglevel DEBUG ${LISTENER DIR}/result_model.robot +Resource listener_resource.robot + +*** Variables *** +${MODEL FILE} %{TEMPDIR}/listener_result_model.json + +*** Test Cases *** +Result model is consistent with information sent to listeners + Should Be Empty ${ERRORS} + +Result model build during execution is same as saved to output.xml + ${expected} = Check Test Case Test + ${actual} = Evaluate robot.result.TestCase.from_json($MODEL_FILE) + ${suite} = Evaluate robot.result.TestSuite.from_dict({'tests': [$actual]}) # Required to get correct id. + Dictionaries Should Be Equal ${actual.to_dict()} ${expected.to_dict()} + +Messages below log level and messages explicitly removed are not included + ${tc} = Check Test Case Test + Check Keyword Data ${tc[2, 1]} BuiltIn.Log args=User keyword, DEBUG children=3 + Check Log Message ${tc[2, 1, 0]} Starting KEYWORD + Check Log Message ${tc[2, 1, 1]} User keyword DEBUG + Check Log Message ${tc[2, 1, 2]} Ending KEYWORD + Check Keyword Data ${tc[2, 2]} BuiltIn.Log args=Not logged, TRACE children=2 + Check Log Message ${tc[2, 2, 0]} Starting KEYWORD + Check Log Message ${tc[2, 2, 1]} Ending KEYWORD + Check Keyword Data ${tc[2, 3]} BuiltIn.Log args=Remove me! children=2 + Check Log Message ${tc[2, 3, 0]} Starting KEYWORD + Check Log Message ${tc[2, 3, 1]} Ending KEYWORD diff --git a/atest/robot/output/listener_interface/unsupported_listener_version.robot b/atest/robot/output/listener_interface/unsupported_listener_version.robot deleted file mode 100644 index 52e5ab0efec..00000000000 --- a/atest/robot/output/listener_interface/unsupported_listener_version.robot +++ /dev/null @@ -1,34 +0,0 @@ -*** Settings *** -Suite Setup Run Tests With Listeners -Resource listener_resource.robot -Test Template Taking listener into use should have failed - -*** Test Cases *** -Unsupported version - 0 unsupported_listeners.V1ClassListener - ... Listener 'unsupported_listeners.V1ClassListener' uses unsupported API version '1'. - 1 unsupported_listeners.InvalidVersionClassListener - ... Listener 'unsupported_listeners.InvalidVersionClassListener' uses unsupported API version 'kekkonen'. - -No version information - 2 unsupported_listeners - ... Listener 'unsupported_listeners' does not have mandatory 'ROBOT_LISTENER_API_VERSION' attribute. - -Unsupported Java listener - [Tags] require-jython - 3 OldJavaListener - ... Listener 'OldJavaListener' does not have mandatory 'ROBOT_LISTENER_API_VERSION' attribute. - -*** Keywords *** -Run Tests With Listeners - ${listeners} = Catenate - ... --listener unsupported_listeners.V1ClassListener - ... --listener unsupported_listeners.InvalidVersionClassListener - ... --listener unsupported_listeners - ... --listener OldJavaListener - Run Tests ${listeners} misc/pass_and_fail.robot - -Taking listener into use should have failed - [Arguments] ${index} ${name} ${error} - Check Log Message @{ERRORS}[${index}] - ... Taking listener '${name}' into use failed: ${error} ERROR diff --git a/atest/robot/output/listener_interface/using_run_keyword.robot b/atest/robot/output/listener_interface/using_run_keyword.robot new file mode 100644 index 00000000000..be7635fe20a --- /dev/null +++ b/atest/robot/output/listener_interface/using_run_keyword.robot @@ -0,0 +1,292 @@ +*** Settings *** +Suite Setup Run Tests With Keyword Running Listener +Resource listener_resource.robot + +*** Test Cases *** +In start_suite when suite has no setup + Check Keyword Data ${SUITE.setup} Implicit setup type=SETUP children=1 + Validate Log ${SUITE.setup[0]} start_suite + +In end_suite when suite has no teardown + Check Keyword Data ${SUITE.teardown} Implicit teardown type=TEARDOWN children=1 + Validate Log ${SUITE.teardown[0]} end_suite + +In start_suite when suite has setup + VAR ${kw} ${SUITE.suites[1].setup} + Check Keyword Data ${kw} Suite Setup type=SETUP children=5 + Validate Log ${kw[0]} start_suite + Check Keyword Data ${kw[1]} BuiltIn.Log args=start_keyword children=1 + Check Log Message ${kw[1, 0]} start_keyword + Validate Log ${kw[2]} Keyword + Check Keyword Data ${kw[3]} Keyword children=3 + Check Keyword Data ${kw[3, 0]} BuiltIn.Log args=start_keyword children=1 + Check Log Message ${kw[3, 0, 0]} start_keyword + Check Keyword Data ${kw[3, 1]} BuiltIn.Log args=Keyword children=3 + Check Keyword Data ${kw[3, 2]} BuiltIn.Log args=end_keyword children=1 + Check Log Message ${kw[3, 2, 0]} end_keyword + Check Keyword Data ${kw[4]} BuiltIn.Log args=end_keyword children=1 + Check Log Message ${kw[4, 0]} end_keyword + +In end_suite when suite has teardown + VAR ${kw} ${SUITE.suites[1].teardown} + Check Keyword Data ${kw} Suite Teardown type=TEARDOWN children=5 + Check Keyword Data ${kw[0]} BuiltIn.Log args=start_keyword children=1 + Check Log Message ${kw[0, 0]} start_keyword + Validate Log ${kw[1]} Keyword + Check Keyword Data ${kw[2]} Keyword children=3 + Check Keyword Data ${kw[2, 0]} BuiltIn.Log args=start_keyword children=1 + Check Log Message ${kw[2, 0, 0]} start_keyword + Check Keyword Data ${kw[2, 1]} BuiltIn.Log args=Keyword children=3 + Check Keyword Data ${kw[2, 2]} BuiltIn.Log args=end_keyword children=1 + Check Log Message ${kw[2, 2, 0]} end_keyword + Check Keyword Data ${kw[3]} BuiltIn.Log args=end_keyword children=1 + Check Log Message ${kw[3, 0]} end_keyword + Validate Log ${kw[4]} end_suite + +In start_test and end_test when test has no setup or teardown + ${tc} = Check Test Case First One + Length Should Be ${tc.body} 5 + Should Not Be True ${tc.setup} + Should Not Be True ${tc.teardown} + Validate Log ${tc[0]} start_test + Validate Log ${tc[1]} Test 1 + Validate Log ${tc[2]} Logging with debug level DEBUG + Check Keyword Data ${tc[3]} logs on trace tags=kw, tags children=3 + Check Keyword Data ${tc[3, 0]} BuiltIn.Log args=start_keyword children=1 + Check Keyword Data ${tc[3, 1]} BuiltIn.Log args=Log on \${TEST NAME}, TRACE children=3 + Check Keyword Data ${tc[3, 2]} BuiltIn.Log args=end_keyword children=1 + Validate Log ${tc[4]} end_test + +In start_test and end_test when test has setup and teardown + ${tc} = Check Test Case Test with setup and teardown + Length Should Be ${tc.body} 3 + Check Keyword Data ${tc.setup} Test Setup type=SETUP children=4 + Check Keyword Data ${tc.teardown} Test Teardown type=TEARDOWN children=4 + Validate Log ${tc[0]} start_test + Check Keyword Data ${tc[1]} Keyword children=3 + Check Keyword Data ${tc[1, 0]} BuiltIn.Log args=start_keyword children=1 + Check Log Message ${tc[1, 0, 0]} start_keyword + Check Keyword Data ${tc[1, 1]} BuiltIn.Log args=Keyword children=3 + Check Keyword Data ${tc[1, 2]} BuiltIn.Log args=end_keyword children=1 + Check Log Message ${tc[1, 2, 0]} end_keyword + Validate Log ${tc[2]} end_test + +In start_keyword and end_keyword with library keyword + ${tc} = Check Test Case First One + Should Be Equal ${tc[1].full_name} BuiltIn.Log + Should Be Equal ${tc[1, 0].full_name} BuiltIn.Log + Check Log Message ${tc[1, 0, 0]} start_keyword + Check Log Message ${tc[1, 1]} Test 1 + Should Be Equal ${tc[1, 2].full_name} BuiltIn.Log + Check Log Message ${tc[1, 2, 0]} end_keyword + Length Should Be ${tc[1].body} 3 + +In start_keyword and end_keyword with user keyword + ${tc} = Check Test Case First One + Should Be Equal ${tc[3].full_name} logs on trace + Should Be Equal ${tc[3, 0].full_name} BuiltIn.Log + Check Log Message ${tc[3, 0, 0]} start_keyword + Should Be Equal ${tc[3, 1].full_name} BuiltIn.Log + Should Be Equal ${tc[3, 1, 0].full_name} BuiltIn.Log + Check Log Message ${tc[3, 1, 0, 1]} start_keyword + Should Be Equal ${tc[3, 1, 2].full_name} BuiltIn.Log + Check Log Message ${tc[3, 1, 2, 1]} end_keyword + Length Should Be ${tc[3, 1].body} 3 + Should Be Equal ${tc[3, 2].full_name} BuiltIn.Log + Check Log Message ${tc[3, 2, 0]} end_keyword + Length Should Be ${tc[3].body} 3 + +In start_keyword and end_keyword with FOR loop + ${tc} = Check Test Case FOR + ${for} = Set Variable ${tc[1]} + Should Be Equal ${for.type} FOR + Length Should Be ${for.body} 5 + Length Should Be ${for.keywords} 2 + Should Be Equal ${for[0].full_name} BuiltIn.Log + Check Log Message ${for[0, 0]} start_keyword + Should Be Equal ${for[-1].full_name} BuiltIn.Log + Check Log Message ${for[-1,0]} end_keyword + +In start_keyword and end_keyword with WHILE + ${tc} = Check Test Case While loop executed multiple times + ${while} = Set Variable ${tc[2]} + Should Be Equal ${while.type} WHILE + Length Should Be ${while.body} 7 + Length Should Be ${while.keywords} 2 + Should Be Equal ${while[0].full_name} BuiltIn.Log + Check Log Message ${while[0, 0]} start_keyword + Should Be Equal ${while[-1].full_name} BuiltIn.Log + Check Log Message ${while[-1,0]} end_keyword + +In start_keyword and end_keyword with IF/ELSE + ${tc} = Check Test Case IF structure + Should Be Equal ${tc[1].type} VAR + Should Be Equal ${tc[2].type} IF/ELSE ROOT + Length Should Be ${tc[2].body} 3 # Listener is not called with root + Validate IF branch ${tc[2, 0]} IF NOT RUN # but is called with unexecuted branches. + Validate IF branch ${tc[2, 1]} ELSE IF PASS + Validate IF branch ${tc[2, 2]} ELSE NOT RUN + +In start_keyword and end_keyword with TRY/EXCEPT + ${tc} = Check Test Case Everything + Should Be Equal ${tc[1].type} TRY/EXCEPT ROOT + Length Should Be ${tc[1].body} 5 # Listener is not called with root + Validate FOR branch ${tc[1, 0]} TRY FAIL + Validate FOR branch ${tc[1, 1]} EXCEPT NOT RUN # but is called with unexecuted branches. + Validate FOR branch ${tc[1, 2]} EXCEPT PASS + Validate FOR branch ${tc[1, 3]} ELSE NOT RUN + Validate FOR branch ${tc[1, 4]} FINALLY PASS + +In start_keyword and end_keyword with BREAK and CONTINUE + ${tc} = Check Test Case WHILE loop in keyword + FOR ${iter} IN @{tc[1, 2][1:-1]} + Should Be Equal ${iter[3, 0, 1].type} CONTINUE + Should Be Equal ${iter[3, 0, 1, 0].full_name} BuiltIn.Log + Check Log Message ${iter[3, 0, 1, 0, 0]} start_keyword + Should Be Equal ${iter[3, 0, 1, 1].full_name} BuiltIn.Log + Check Log Message ${iter[3, 0, 1, 1, 0]} end_keyword + Should Be Equal ${iter[4, 0, 1].type} BREAK + Should Be Equal ${iter[4, 0, 1, 0].full_name} BuiltIn.Log + Check Log Message ${iter[4, 0, 1, 0, 0]} start_keyword + Should Be Equal ${iter[4, 0, 1, 1].full_name} BuiltIn.Log + Check Log Message ${iter[4, 0, 1, 1, 0]} end_keyword + END + +In start_keyword and end_keyword with RETURN + ${tc} = Check Test Case Second One + Should Be Equal ${tc[3, 1, 1, 2].type} RETURN + Should Be Equal ${tc[3, 1, 1, 2, 0].full_name} BuiltIn.Log + Check Log Message ${tc[3, 1, 1, 2, 0, 1]} start_keyword + Should Be Equal ${tc[3, 1, 1, 2, 1].full_name} BuiltIn.Log + Check Log Message ${tc[3, 1, 1, 2, 1, 1]} end_keyword + +With JSON output + [Documentation] Mainly test that executed keywords don't cause problems. + ... + ... Some data, such as keywords and messages on suite level, + ... are discarded and thus the exact output isn't the same as + ... with XML. + ... + ... Cannot validate output, because it doesn't match the schema. + Run Tests With Keyword Running Listener format=json validate=False + Should Contain Tests ${SUITE} + ... First One + ... Second One + ... Test with setup and teardown + ... Test with failing setup + ... Test with failing teardown + ... Failing test with failing teardown + ... FOR + ... FOR IN RANGE + ... FOR IN ENUMERATE + ... FOR IN ZIP + ... WHILE loop executed multiple times + ... WHILE loop in keyword + ... IF structure + ... Everything + ... Library keyword + ... User keyword and RETURN + ... Test documentation, tags and timeout + ... Test setup and teardown + ... Keyword Keyword documentation, tags and timeout + ... Keyword setup and teardown + ... Failure + ... VAR + ... IF + ... TRY + ... FOR and CONTINUE + ... WHILE and BREAK + ... GROUP + ... Syntax error + +In dry-run + Run Tests With Keyword Running Listener --dry-run + Should Contain Tests ${SUITE} + ... First One + ... Test with setup and teardown + ... FOR + ... FOR IN ENUMERATE + ... FOR IN ZIP + ... WHILE loop executed multiple times + ... WHILE loop in keyword + ... IF structure + ... Everything + ... Library keyword + ... User keyword and RETURN + ... Test documentation, tags and timeout + ... Test setup and teardown + ... Keyword Keyword documentation, tags and timeout + ... Keyword setup and teardown + ... VAR + ... IF + ... TRY + ... FOR and CONTINUE + ... WHILE and BREAK + ... GROUP + ... Second One=FAIL:Several failures occurred:\n\n1) No keyword with name 'Not executed' found.\n\n2) No keyword with name 'Not executed' found. + ... Test with failing setup=PASS + ... Test with failing teardown=PASS + ... Failing test with failing teardown=PASS + ... FOR IN RANGE=FAIL:No keyword with name 'Not executed!' found. + ... Failure=PASS + ... Syntax error=FAIL:Several failures occurred:\n\n1) Non-existing setting 'Bad'.\n\n2) Non-existing setting 'Ooops'. + +*** Keywords *** +Run Tests With Keyword Running Listener + [Arguments] ${options}= ${format}=xml ${validate}=True + VAR ${listener} ${LISTENER DIR}/keyword_running_listener.py + VAR ${output} ${OUTDIR}/output.${format} + VAR ${files} + ... misc/normal.robot + ... misc/setups_and_teardowns.robot + ... misc/for_loops.robot + ... misc/while.robot + ... misc/if_else.robot + ... misc/try_except.robot + ... misc/everything.robot + Run Tests --listener ${listener} ${options} -L debug -o ${output} ${files} output=${output} validate output=${validate} + Length Should Be ${ERRORS} 1 + +Validate Log + [Arguments] ${kw} ${message} ${level}=INFO + IF $level == 'INFO' + VAR ${args} ${message} + ELSE + VAR ${args} ${message}, ${level} + END + Check Keyword Data ${kw} BuiltIn.Log args=${args} children=3 + Check Keyword Data ${kw[0]} BuiltIn.Log args=start_keyword children=1 + Check Log Message ${kw[0, 0]} start_keyword + Check Log Message ${kw[1]} ${message} ${level} + Check Keyword Data ${kw[2]} BuiltIn.Log args=end_keyword children=1 + Check Log Message ${kw[2, 0]} end_keyword + +Validate IF branch + [Arguments] ${branch} ${type} ${status} + Should Be Equal ${branch.type} ${type} + Should Be Equal ${branch.status} ${status} + Length Should Be ${branch.body} 3 + Should Be Equal ${branch[0].full_name} BuiltIn.Log + Check Log Message ${branch[0, 0]} start_keyword + IF $status == 'PASS' + Should Be Equal ${branch[1].full_name} BuiltIn.Log + Should Be Equal ${branch[1, 0].full_name} BuiltIn.Log + Check Log Message ${branch[1, 0, 0]} start_keyword + Check Log Message ${branch[1, 1]} else if branch + Should Be Equal ${branch[1, 2].full_name} BuiltIn.Log + Check Log Message ${branch[1, 2, 0]} end_keyword + ELSE + Should Be Equal ${branch[1].full_name} BuiltIn.Fail + Should Be Equal ${branch[1].status} NOT RUN + END + Should Be Equal ${branch[-1].full_name} BuiltIn.Log + Check Log Message ${branch[-1,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[0].full_name} BuiltIn.Log + Check Log Message ${branch[0, 0]} start_keyword + Should Be Equal ${branch[-1].full_name} BuiltIn.Log + Check Log Message ${branch[-1,0]} end_keyword diff --git a/atest/robot/output/listener_interface/variables_in_test_name.robot b/atest/robot/output/listener_interface/variables_in_test_name.robot new file mode 100644 index 00000000000..cb38f711843 --- /dev/null +++ b/atest/robot/output/listener_interface/variables_in_test_name.robot @@ -0,0 +1,32 @@ +*** Settings *** +Documentation Verify that listeners see both the original test name and the resolved name. +... Tests both using listener v2 and v3. +Suite Setup Run With Listeners +Test Template Original and resolved name should be available +Resource listener_resource.robot + +*** Test Cases *** +Test case name with variable + \${NAME} Name \${VARIABLE} Variable + \${2} 2 \@{EMPTY} [] + +Test case name with non-existing variable + \${NAME} Name \${UNKNOWN VARIABLE} \${UNKNOWN VARIABLE} + +*** Keywords *** +Run With Listeners + ${listeners} = Catenate + ... --listener ${LISTENER DIR}/original_and_resolved_name_v2.py + ... --listener ${LISTENER DIR}/original_and_resolved_name_v3.py + Run Tests ${listeners} output/listener_interface/variables_in_test_name.robot + +Original and resolved name should be available + [Arguments] ${var1} ${value1} ${var2} ${value2} + ${resolved} = Set Variable Test Case "${value1}" With "${value2}" + ${message} = Catenate SEPARATOR=\n + ... [START] [original] Test Case "${var1}" With "${var2}" [resolved] ${resolved} + ... [END] [original] Test Case "${var1}" With "${var2}" [resolved] ${resolved} + # v3 listener sets test's message + Check Test Case ${resolved} PASS ${message} + # v2 listener prints to stdout + Stdout Should Contain ${message} diff --git a/atest/robot/output/names_needing_escaping.robot b/atest/robot/output/names_needing_escaping.robot index b7942b154c8..50006670bf3 100644 --- a/atest/robot/output/names_needing_escaping.robot +++ b/atest/robot/output/names_needing_escaping.robot @@ -34,4 +34,4 @@ Check TC And UK Name [Arguments] ${name} ${tc} = Check Test Case ${name} Should Be Equal ${tc.name} ${name} - Should Be Equal ${tc.kws[0].name} ${name} + Should Be Equal ${tc[0].name} ${name} diff --git a/atest/robot/output/outputs_have_correct_line_separators.robot b/atest/robot/output/outputs_have_correct_line_separators.robot index edd166e7ec8..3dfc2feb6d4 100644 --- a/atest/robot/output/outputs_have_correct_line_separators.robot +++ b/atest/robot/output/outputs_have_correct_line_separators.robot @@ -27,6 +27,7 @@ Outputs generated with rebot should have correct line separators Outputs Should Have Correct Line Separators [Arguments] @{outputs} - :FOR ${name} IN @{outputs} ${SYSLOG FILE} - \ ${path} = Join Path ${OUTDIR} ${name} - \ File Should Have Correct Line Separators ${path} + FOR ${name} IN @{outputs} ${SYSLOG FILE} + ${path} = Join Path ${OUTDIR} ${name} + File Should Have Correct Line Separators ${path} + END diff --git a/atest/robot/output/processing_output.robot b/atest/robot/output/processing_output.robot index 04b02388dc4..e7265026383 100644 --- a/atest/robot/output/processing_output.robot +++ b/atest/robot/output/processing_output.robot @@ -11,8 +11,8 @@ Test Case File Suite Should Be Equal ${SUITE.metadata['Something']} My Value Should Be Equal as Strings ${SUITE.metadata} {Something: My Value} Check Normal Suite Defaults ${SUITE} - Should Be Equal ${SUITE.full_message} 2 critical tests, 2 passed, 0 failed\n 2 tests total, 2 passed, 0 failed - Should Be Equal ${SUITE.statistics.message} 2 critical tests, 2 passed, 0 failed\n 2 tests total, 2 passed, 0 failed + Should Be Equal ${SUITE.full_message} 2 tests, 2 passed, 0 failed + Should Be Equal ${SUITE.statistics.message} 2 tests, 2 passed, 0 failed Should Contain Tests ${SUITE} First One Second One Directory Suite @@ -45,61 +45,65 @@ Minimal hand-created output My Run Robot And Rebot [Arguments] ${params} ${paths} Run Tests Without Processing Output ${params} ${paths} + Validate Elapsed In Output Copy Previous Outfile Run Rebot ${EMPTY} ${OUTFILE COPY} + Validate Elapsed In Output + +Validate Elapsed In Output + ${statuses} = Get Elements ${OUTFILE} .//status + FOR ${elem} IN @{statuses} + Should Match Regexp ${elem.attrib}[elapsed] ^\\d+\\.\\d+$ + END Check Normal Suite Defaults - [Arguments] ${mysuite} ${message}= ${tests}=[] ${setup}=${None} ${teardown}=${None} - Log ${mysuite.name} - Check Suite Defaults ${mysuite} ${message} ${tests} ${setup} ${teardown} - Check Normal Suite Times ${mysuite} + [Arguments] ${suite} ${message}= ${setup}=${None} ${teardown}=${None} + Check Suite Defaults ${suite} ${message} ${setup} ${teardown} + Check Normal Suite Times ${suite} Check Minimal Suite Defaults - [Arguments] ${mysuite} ${message}= - Check Suite Defaults ${mysuite} ${message} - Check Minimal Suite Times ${mysuite} + [Arguments] ${suite} ${message}= + Check Suite Defaults ${suite} ${message} + Check Minimal Suite Times ${suite} Check Normal Suite Times - [Arguments] ${mysuite} - Timestamp Should Be Valid ${mysuite.starttime} - Timestamp Should Be Valid ${mysuite.endtime} - Elapsed Time Should Be Valid ${mysuite.elapsedtime} - Should Be True ${mysuite.elapsedtime} >= 1 + [Arguments] ${suite} + 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] ${mysuite} - Should Be Equal ${mysuite.starttime} ${NONE} - Should Be Equal ${mysuite.endtime} ${NONE} - Should Be Equal ${mysuite.elapsedtime} ${0} + [Arguments] ${suite} + 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] ${mysuite} ${message}= ${tests}=[] ${setup}=${None} ${teardown}=${None} - Should Be Equal ${mysuite.message} ${message} - Check Setup Or Teardown ${mysuite.setup} ${setup} - Check Setup Or Teardown ${mysuite.teardown} ${teardown} - -Check Setup Or Teardown - [Arguments] ${item} ${expected} - ${actual} = Set Variable If "${expected}" == "None" ${item} ${item.name} - Should Be Equal ${actual} ${expected} + [Arguments] ${suite} ${message}= ${setup}=${None} ${teardown}=${None} + 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 Should Be Equal ${SUITE.status} FAIL - Should Contain Suites ${SUITE} Fourth Subsuites Subsuites2 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[1]} Sub1 Sub2 - :FOR ${s} IN - ... ${SUITE.suites[0]} - ... ${SUITE.suites[1].suites[0]} - ... ${SUITE.suites[1].suites[1]} + Should Contain Suites ${SUITE.suites[2]} Sub1 Sub2 + FOR ${s} IN + ... ${SUITE.suites[1]} ... ${SUITE.suites[2].suites[0]} - ... ${SUITE.suites[3]} - ... ${SUITE.suites[4]} + ... ${SUITE.suites[2].suites[1]} + ... ${SUITE.suites[3].suites[0]} + ... ${SUITE.suites[5]} - \ Should Be Empty ${s.suites} + ... ${SUITE.suites[6]} + Should Be Empty ${s.suites} + END Should Contain Tests ${SUITE} + ... Test With Prefix ... SubSuite1 First ... SubSuite2 First ... SubSuite3 First @@ -108,11 +112,15 @@ Check Suite Got From Misc/suites/ Directory ... Suite1 Second Third In Suite1 Suite2 First ... Suite3 First ... Suite4 First + ... Test With Double Underscore ... Test From Sub Suite 4 - Check Normal Suite Defaults ${SUITE.suites[0]} ${EMPTY} [] teardown=BuiltIn.Log - Check Normal Suite Defaults ${SUITE.suites[1]} - Check Normal Suite Defaults ${SUITE.suites[1].suites[0]} setup=BuiltIn.Log teardown=BuiltIn.No Operation - Check Normal Suite Defaults ${SUITE.suites[1].suites[1]} - Check Normal Suite Defaults ${SUITE.suites[2].suites[0]} - Check Normal Suite Defaults ${SUITE.suites[3]} + Check Normal Suite Defaults ${SUITE.suites[0]} + Check Normal Suite Defaults ${SUITE.suites[1]} setup=BuiltIn.Log teardown=BuiltIn.Log + Check Normal Suite Defaults ${SUITE.suites[2]} + Check Normal Suite Defaults ${SUITE.suites[2].suites[0]} setup=Setup teardown=BuiltIn.No Operation + Check Normal Suite Defaults ${SUITE.suites[2].suites[1]} + Check Normal Suite Defaults ${SUITE.suites[3].suites[0]} Check Normal Suite Defaults ${SUITE.suites[4]} + Check Normal Suite Defaults ${SUITE.suites[4].suites[0]} + Check Normal Suite Defaults ${SUITE.suites[5]} + Check Normal Suite Defaults ${SUITE.suites[6]} diff --git a/atest/robot/output/source_and_lineno_output.robot b/atest/robot/output/source_and_lineno_output.robot new file mode 100644 index 00000000000..0b8837406d8 --- /dev/null +++ b/atest/robot/output/source_and_lineno_output.robot @@ -0,0 +1,24 @@ +*** Settings *** +Resource atest_resource.robot +Suite Setup Run Tests ${EMPTY} misc/suites/subsuites2 + +*** Variables *** +${SOURCE} ${{pathlib.Path(r'${DATADIR}/misc/suites/subsuites2')}} + +*** Test Cases *** +Suite source and test lineno in output after execution + Source info should be correct + +Suite source and test lineno in output after Rebot + Copy Previous Outfile + Run Rebot ${EMPTY} ${OUTFILE COPY} + Source info should be correct + +*** Keywords *** +Source info should be correct + 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].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 6ba43daaffa..434e25e9387 100644 --- a/atest/robot/output/statistics.robot +++ b/atest/robot/output/statistics.robot @@ -3,71 +3,71 @@ Suite Setup My Setup Resource atest_resource.robot *** Test Cases *** -Statistics Should Be Written to XML +Statistics should be written to XML ${output} = Get File ${OUTFILE} ${exp} = Catenate SEPARATOR=\\r?\\n ... (?s) .* ... .* .* Should Match Regexp ${output} ${exp} -Total statistics should be Correct +Total statistics should be correct ${stats} = Get Element ${OUTFILE} statistics/total - ${crit} ${total} = Call Method ${stats} findall stat - Node Should Be Correct ${crit} Critical Tests 5 1 - Node Should Be Correct ${total} All Tests 10 1 + ${total} = Call Method ${stats} find stat + Node Should Be Correct ${total} All Tests 12 1 -Tag statistics should be Correct +Tag statistics should be correct ${stats} = Get Element ${OUTFILE} statistics/tag - Tag Node Should Be Correct ${stats[0]} t1 - ... 5 1 info=critical links=my title:http://url.to:::title:url - Tag Node Should Be Correct ${stats[2]} D1 OR sub3 OR t2 OR or and not - ... 4 0 info=combined combined=D1 OR sub3 OR t2 OR or and not - Tag Node Should Be Correct ${stats[3]} f1 AND t1 + Tag Node Should Be Correct ${stats[1]} d1 OR sub3 OR t2 OR or_and_not + ... 4 0 info=combined combined=d1 OR sub3 OR t2 OR or_and_not + Tag Node Should Be Correct ${stats[2]} f1 AND t1 ... 5 1 info=combined combined=f1 AND t1 - Tag Node Should Be Correct ${stats[4]} F1 NOT T1 + Tag Node Should Be Correct ${stats[3]} F1 NOT T1 ... 4 0 info=combined combined=F1 NOT T1 - Tag Node Should Be Correct ${stats[5]} NOT t1 - ... 5 0 info=combined combined=NOT t1 - Tag Node Should Be Correct ${stats[6]} d1 + Tag Node Should Be Correct ${stats[4]} NOT t1 + ... 7 0 info=combined combined=NOT t1 + Tag Node Should Be Correct ${stats[5]} d1 ... 1 0 links=title:url - Tag Node Should Be Correct ${stats[7]} d2 + Tag Node Should Be Correct ${stats[6]} d2 ... 1 0 - Tag Node Should Be Correct ${stats[8]} f1 + Tag Node Should Be Correct ${stats[7]} f1 ... 9 1 doc=this is tagdoc links=title:url - Tag Node Should Be Correct ${stats[9]} sub3 + Tag Node Should Be Correct ${stats[8]} sub3 ... 2 0 + Tag Node Should Be Correct ${stats[9]} t1 + ... 5 1 links=my title:http://url.to:::title:url Tag Node Should Be Correct ${stats[10]} XXX - ... 10 1 + ... 12 1 -Combined Tag Statistics Name Can Be Given +Combined tag statistics name can be given ${stats} = Get Element ${OUTFILE} statistics/tag - Tag Node Should Be Correct ${stats[1]} Combined tag with new name AND-OR-NOT + 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 10 1 - Suite Node Should Be Correct ${stats[1]} Suites.Fourth 0 1 - Suite Node Should Be Correct ${stats[2]} Suites.Subsuites 2 0 - Suite Node Should Be Correct ${stats[3]} Suites.Subsuites2 3 0 - Suite Node Should Be Correct ${stats[4]} Suites.Tsuite1 3 0 - Suite Node Should Be Correct ${stats[5]} Suites.Tsuite2 1 0 - Suite Node Should Be Correct ${stats[6]} Suites.Tsuite3 1 0 + 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.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 + Suite Node Should Be Correct ${stats[8]} Suites.Tsuite3 1 0 *** Keywords *** My Setup ${options} = Catenate - ... --critical t1 ... --tagdoc "f1:this is tagdoc" ... --tagstatlink "t*:http://url.to:my title" ... --tagstatlink ?1:url:title ... --tagstatcombine f1ANDt1 ... --tagstatcombine NOTt1 - ... --tagstatcombine D1ORsub3ORt2_OR_or_and_not - ... --tagstatcombine "d1ANDd2:Combined tag with new name AND-OR-NOT" + ... --tagstatcombine d1ORsub3ORt2ORor_and_not + ... --tagstatcombine "d1 AND d2:Combined tag with new name AND-OR-NOT" ... --suitestatlevel 2 ... --tagstatexclude t2 - ... --TagStatComb F1NOT_T1 + ... --TagStatComb "F1 NOT T1" ... --SetTag XXX Run Tests ${options} misc/suites diff --git a/atest/robot/output/statistics_in_log_and_report.robot b/atest/robot/output/statistics_in_log_and_report.robot index 3bdb8eec5a7..d879fab6141 100644 --- a/atest/robot/output/statistics_in_log_and_report.robot +++ b/atest/robot/output/statistics_in_log_and_report.robot @@ -1,8 +1,8 @@ *** Settings *** -Documentation Verify that stat information is written correctly to log/report -Resource atest_resource.robot -Library html_output_stats.py -Suite Setup Run tests with stat related options +Documentation Verify that stat information is written correctly to log/report. +Suite Setup Run tests with stat related options +Library LogDataFinder.py +Resource atest_resource.robot *** Test Cases *** Log contains total stats @@ -27,8 +27,6 @@ Report contains suite stats *** Keywords *** Run tests with stat related options ${opts} = Catenate - ... --Critical t1 - ... --NonCritical t2 ... --SuiteStatLevel 2 ... --TagStatInclude t? ... --TagStatInclude d1 @@ -41,37 +39,40 @@ Run tests with stat related options Verify total stats [Arguments] ${file} - ${critical} ${all} = Get Total Stats ${OUTDIR}${/}${file} - Verify stat ${critical} label:Critical Tests pass:5 fail:1 - Verify stat ${all} label:All Tests pass:10 fail:1 + ${all} = Get Total Stats ${OUTDIR}${/}${file} + Verify stat ${all[0]} label:All Tests pass:12 fail:1 skip:0 Verify tag stats [Arguments] ${file} ${stats} = Get Tag Stats ${OUTDIR}${/}${file} Length Should Be ${stats} 4 - Verify stat ${stats[0]} label:t1 pass:5 fail:1 - ... info:critical links:T1:http://t/1 doc:the doc - Verify stat ${stats[1]} label:t2 pass:2 fail:0 - ... info:non-critical links:T2:http://t/2 - Verify stat ${stats[2]} label:f1 AND t1 pass:5 fail:1 + Verify stat ${stats[0]} label:f1 AND t1 pass:5 fail:1 skip:0 ... info:combined combined:f1 AND t1 - Verify stat ${stats[3]} label:d1 pass:1 fail:0 + Verify stat ${stats[1]} label:d1 pass:1 fail:0 skip:0 + Verify stat ${stats[2]} label:t1 pass:5 fail:1 skip:0 + ... links:T1:http://t/1 doc:the doc + Verify stat ${stats[3]} label:t2 pass:2 fail:0 skip:0 + ... links:T2:http://t/2 Verify suite stats [Arguments] ${file} ${stats} = Get Suite Stats ${OUTDIR}${/}${file} - Length Should Be ${stats} 7 + Length Should Be ${stats} 9 Verify stat ${stats[0]} label:Suites name:Suites - ... id:s1 pass:10 fail:1 - Verify stat ${stats[1]} label:Suites.Fourth name:Fourth - ... id:s1-s1 pass:0 fail:1 - Verify stat ${stats[2]} label:Suites.Subsuites name:Subsuites - ... id:s1-s2 pass:2 fail:0 - Verify stat ${stats[3]} label:Suites.Subsuites2 name:Subsuites2 - ... id:s1-s3 pass:3 fail:0 - Verify stat ${stats[4]} label:Suites.Tsuite1 name:Tsuite1 - ... id:s1-s4 pass:3 fail:0 - Verify stat ${stats[5]} label:Suites.Tsuite2 name:Tsuite2 - ... id:s1-s5 pass:1 fail:0 - Verify stat ${stats[6]} label:Suites.Tsuite3 name:Tsuite3 - ... id:s1-s6 pass:1 fail:0 + ... id:s1 pass:12 fail:1 skip:0 + Verify stat ${stats[1]} label:Suites.Suite With Prefix name:Suite With Prefix + ... id:s1-s1 pass:1 fail:0 skip:0 + Verify stat ${stats[2]} label:Suites.Fourth name:Fourth + ... 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.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 + Verify stat ${stats[6]} label:Suites.Tsuite1 name:Tsuite1 + ... id:s1-s6 pass:3 fail:0 skip:0 + Verify stat ${stats[7]} label:Suites.Tsuite2 name:Tsuite2 + ... id:s1-s7 pass:1 fail:0 skip:0 + Verify stat ${stats[8]} label:Suites.Tsuite3 name:Tsuite3 + ... id:s1-s8 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 00c25ff2884..5406e857eda 100644 --- a/atest/robot/output/statistics_with_rebot.robot +++ b/atest/robot/output/statistics_with_rebot.robot @@ -3,53 +3,53 @@ Suite Setup My Setup Resource atest_resource.robot *** Test Cases *** -Statistics Should Be Written to XML +Statistics should be written to XML ${output} = Get File ${OUTFILE} ${exp} = Catenate SEPARATOR=\\r?\\n (?s) .* ... .* .* Should Match Regexp ${output} ${exp} -Total statistics should be Correct +Total statistics should be correct ${stats} = Get Element ${OUTFILE} statistics/total - ${crit} ${total} = Call Method ${stats} findall stat - Node Should Be Correct ${crit} Critical Tests 5 1 - Node Should Be Correct ${total} All Tests 10 1 + ${total} = Call Method ${stats} find stat + Node Should Be Correct ${total} All Tests 12 1 -Tag statistics should be Correct +Tag statistics should be correct ${stats} = Get Element ${OUTFILE} statistics/tag - Tag Node Should Be Correct ${stats[0]} t1 - ... 5 1 critical - Tag Node Should Be Correct ${stats[1]} Custom title AND-OR-NOT + Tag Node Should Be Correct ${stats[0]} Custom title AND-OR-NOT ... 1 0 info=combined combined=d1 AND d2 - Tag Node Should Be Correct ${stats[2]} F1 NOT T 1 - ... 4 0 info=combined combined=F1 NOT T 1 - Tag Node Should Be Correct ${stats[3]} d1 + Tag Node Should Be Correct ${stats[1]} F1 NOT T_1 + ... 4 0 info=combined combined=F1 NOT T_1 + Tag Node Should Be Correct ${stats[2]} d1 ... 1 0 - Tag Node Should Be Correct ${stats[4]} d2 + Tag Node Should Be Correct ${stats[3]} d2 ... 1 0 - Tag Node Should Be Correct ${stats[5]} f1 + Tag Node Should Be Correct ${stats[4]} f1 ... 9 1 - Tag Node Should Be Correct ${stats[6]} sub3 + Tag Node Should Be Correct ${stats[5]} sub3 ... 2 0 + Tag Node Should Be Correct ${stats[6]} t1 + ... 5 1 Tag Node Should Be Correct ${stats[7]} XxX - ... 10 1 + ... 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 10 1 - Node Should Be Correct ${stats[1]} Suites.Fourth 0 1 - Node Should Be Correct ${stats[2]} Suites.Subsuites 2 0 - Node Should Be Correct ${stats[3]} Suites.Subsuites2 3 0 - Node Should Be Correct ${stats[4]} Suites.Tsuite1 3 0 - Node Should Be Correct ${stats[5]} Suites.Tsuite2 1 0 - Node Should Be Correct ${stats[6]} Suites.Tsuite3 1 0 + 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.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 + Node Should Be Correct ${stats[8]} Suites.Tsuite3 1 0 *** Keywords *** My Setup Run Tests ${EMPTY} misc/suites Copy Previous Outfile ${options} = Catenate - ... --critical t1 ... --tagstatcombine "d1 AND d2:Custom title AND-OR-NOT" ... --suitestatlevel 2 ... --tagstatexclude t2 diff --git a/atest/robot/output/suite_and_test_id_in_output.robot b/atest/robot/output/suite_and_test_id_in_output.robot index 038ebfedb02..92418f85b15 100644 --- a/atest/robot/output/suite_and_test_id_in_output.robot +++ b/atest/robot/output/suite_and_test_id_in_output.robot @@ -14,10 +14,10 @@ Ids in output after rebot *** Keywords *** Suite And Test Ids Should Be Correct Should Be Equal ${SUITE.id} s1 - Should Be Equal ${SUITE.suites[0].id} s1-s1 - Should Be Equal ${SUITE.suites[0].tests[-1].id} s1-s1-t1 - Should Be Equal ${SUITE.suites[1].suites[0].id} s1-s2-s1 - Should Be Equal ${SUITE.suites[1].suites[-1].id} s1-s2-s2 - Should Be Equal ${SUITE.suites[1].suites[-1].tests[-1].id} s1-s2-s2-t1 - Should Be Equal ${SUITE.suites[3].tests[-1].id} s1-s4-t3 - Should Be Equal ${SUITE.suites[-1].id} s1-s6 + Should Be Equal ${SUITE.suites[1].id} s1-s2 + Should Be Equal ${SUITE.suites[1].tests[-1].id} s1-s2-t1 + Should Be Equal ${SUITE.suites[2].suites[0].id} s1-s3-s1 + Should Be Equal ${SUITE.suites[2].suites[-1].id} s1-s3-s2 + Should Be Equal ${SUITE.suites[2].suites[-1].tests[-1].id} s1-s3-s2-t1 + Should Be Equal ${SUITE.suites[5].tests[-1].id} s1-s6-t3 + Should Be Equal ${SUITE.suites[-1].id} s1-s8 diff --git a/atest/robot/output/test_criticality.robot b/atest/robot/output/test_criticality.robot deleted file mode 100644 index 7d9b43adf12..00000000000 --- a/atest/robot/output/test_criticality.robot +++ /dev/null @@ -1,22 +0,0 @@ -*** Settings *** -Resource atest_resource.robot -Suite Setup Run some tests and parse XML - -*** Test Cases *** -Test Criticality Should Be Serialized Inside Status Tag - Check Test Criticality in Output 0 yes - Check Test Criticality in Output 1 no - -*** Keywords *** -Run some tests and parse XML - Run Tests --noncritical fail misc${/}pass_and_fail.robot - ${SUITE}= Get Element ${OUTFILE} suite - Set Suite Variable ${SUITE} - -Check Test Criticality in Output - [Arguments] ${testindex} ${expected criticality} - ${test}= Set Variable ${suite.findall('test')[${testindex}]} - Should Be Equal ${test.find('status').get('critical')} - ... ${expected criticality} - - diff --git a/atest/robot/output/timeouts.robot b/atest/robot/output/timeouts.robot index bd8cdbfa824..179cd8b3218 100644 --- a/atest/robot/output/timeouts.robot +++ b/atest/robot/output/timeouts.robot @@ -19,9 +19,9 @@ Empty keyword timeout should not be written to XML Element Should Have Timeout [Arguments] ${value} ${element}=. @{tests}= Get Elements ${OUTFILE} */test - Element Attribute Should Be @{tests}[0] value ${value} ${element}/timeout + Element Attribute Should Be ${tests}[0] value ${value} ${element}/timeout Element Should Not Have Timeout [Arguments] ${index}=0 ${element}=. @{tests}= Get Elements ${OUTFILE} */test - Element Should Not Exist @{tests}[${index}] ${element}/timeout + Element Should Not Exist ${tests}[${index}] ${element}/timeout diff --git a/atest/robot/output/xunit.robot b/atest/robot/output/xunit.robot index 27a4515060d..25843cde935 100644 --- a/atest/robot/output/xunit.robot +++ b/atest/robot/output/xunit.robot @@ -2,38 +2,46 @@ Documentation Tests for xunit-compatible xml-output. Resource atest_resource.robot Variables unicode_vars.py -Suite Setup Run Tests -x xunit.xml -l log.html ${TESTDATA} +Suite Setup Run Tests -x xunit.xml -l log.html --skiponfailure täg ${TESTDATA} *** Variables *** ${TESTDATA} misc/non_ascii.robot ${PASS AND FAIL} misc/pass_and_fail.robot ${INVALID} %{TEMPDIR}${/}ïnvälïd-xünït.xml +${NESTED} misc/suites +${METADATA SUITE} parsing/suite_metadata.robot +${NORMAL SUITE} misc/normal.robot *** Test Cases *** XUnit File Is Created - Stderr should be empty - Check Stdout Contains XUnit: - File Should Exist ${OUTDIR}/xunit.xml - File Should Exist ${OUTDIR}/log.html + Verify Outputs File Structure Is Correct - ${root} = Get XUnit Node - Should Be Equal ${root.tag} testsuite - Suite Stats Should Be ${root} 8 4 0 + ${root} = Get Root Node + Suite Stats Should Be ${root} 8 3 1 ${SUITE.start_time} ${tests} = Get XUnit Nodes testcase Length Should Be ${tests} 8 - ${failures} = Get XUnit Nodes testcase/failure - Length Should Be ${failures} 4 + ${fails} = Get XUnit Nodes testcase/failure + Length Should Be ${fails} 3 + Element Attribute Should Be ${fails}[0] message + ... Setup failed:\n${MESSAGES} + Element Attribute Should Be ${fails}[0] type AssertionError + ${skips} = Get XUnit Nodes testcase/skipped + Length Should Be ${skips} 1 + Element Attribute Should Be ${skips}[0] message + ... Failed test skipped using 'täg' tag.\n\nOriginal failure:\n${MESSAGES} + Element Attribute Should Be ${skips}[0] type SkipExecution + Element Should Not Exist ${root} testsuite/properties Non-ASCII Content ${tests} = Get XUnit Nodes testcase - Should Be Equal ${tests[-1].attrib['name']} Ñöñ-ÄŚÇÏÏ Tëśt äņd Këywörd Nämës, Спасибо + Element Attribute Should Be ${tests}[-1] name Ñöñ-ÄŚÇÏÏ Tëśt äņd Këywörd Nämës, Спасибо ${failures} = Get XUnit Nodes testcase/failure - Should Be Equal ${failures[0].attrib['message']} ${MESSAGES} + Element Attribute Should Be ${failures}[0] message Setup failed:\n${MESSAGES} Multiline failure ${failures} = Get XUnit Nodes testcase/failure - Should Be Equal ${failures[-1].attrib['message']} Just ASCII here\n\nAlso teardown failed:\n${MESSAGES} + Element Attribute Should Be ${failures}[-1] message Just ASCII here\n\nAlso teardown failed:\n${MESSAGES} Suite has execution time ${suite} = Get XUnit Node @@ -47,7 +55,7 @@ Test has execution time No XUnit Option Given Run Tests ${EMPTY} ${TESTDATA} - Check Stdout Does Not Contain XUnit + Stdout Should Not Contain XUnit Invalid XUnit File Create Directory ${INVALID} @@ -56,40 +64,107 @@ Invalid XUnit File File Should Exist ${OUTDIR}/log.html ${dir} ${base} = Split Path ${INVALID} ${path} = Regexp Escape ${INVALID} - Check Stderr Matches Regexp - ... \\[ ERROR \\] Writing xunit file '${path}' failed: .* - -Skipping non-critical tests - Run tests --xUnit xunit.xml --xUnitSkipNonCritical --NonCritical fail ${PASS AND FAIL} - ${root} = Get XUnit Node . - Suite Stats Should Be ${root} 2 0 1 - ${skipped} = Get XUnit Node testcase/skipped - Should be equal ${skipped.text} FAIL: Expected failure - -Skipping all tests - Run tests --xunit xunit.xml --noncritical force --xunitskip ${PASS AND FAIL} - ${root} = Get XUnit Node . - Suite Stats Should Be ${root} 2 0 2 - ${skipped} = Get XUnit Nodes testcase/skipped - Should be equal ${skipped[0].text} PASS - Should be equal ${skipped[1].text} FAIL: Expected failure - Length Should Be ${skipped} 2 + Stderr Should Match Regexp + ... \\[ ERROR \\] Opening xunit file '${path}' failed: .* + +XUnit File From Nested Suites + Run Tests -x xunit.xml -l log.html ${TESTDATA} ${NESTED} + Verify Outputs + ${root} = Get Root Node + ${suites} = Get Elements ${root} testsuite + Length Should Be ${suites} 2 + ${tests} = Get Elements ${suites}[0] testcase + Length Should Be ${tests} 8 + Element Attribute Should be ${tests}[7] name Ñöñ-ÄŚÇÏÏ Tëśt äņd Këywörd Nämës, Спасибо + ${failures} = Get Elements ${suites}[0] testcase/failure + Length Should Be ${failures} 4 + Element Attribute Should be ${failures}[0] message ${MESSAGES} + ${nested suite} = Get Element ${OUTDIR}/xunit.xml xpath=testsuite[2] + Element Attribute Should Be ${nested suite} tests 13 + Element Attribute Should Be ${nested suite} failures 1 + ${properties} = Get Elements ${nested suite} testsuite[6]/properties/property + Length Should Be ${properties} 2 + Element Attribute Should be ${properties}[0] name Documentation + Element Attribute Should be ${properties}[0] value Normal test cases + Element Attribute Should be ${properties}[1] name Something + Element Attribute Should be ${properties}[1] value My Value + +XUnit File Root Testsuite Properties From CLI + Run Tests -M METACLI:"meta CLI" -x xunit.xml -l log.html -v META_VALUE_FROM_CLI:"cli meta" ${NORMAL SUITE} ${METADATA SUITE} + Verify Outputs + ${root} = Get Root Node + ${root_properties_element} = Get Properties Node ${root} + ${property_elements} = Get Elements ${root_properties_element}[0] property + Length Should Be ${property_elements} 1 + Element Attribute Should be ${property_elements}[0] name METACLI + Element Attribute Should be ${property_elements}[0] value meta CLI + +XUnit File Testsuite Properties From Suite Documentation + ${root} = Get Root Node + ${suites} = Get Elements ${root} testsuite + Length Should Be ${suites} 2 + ${normal_properties_element} = Get Properties Node ${suites}[0] + ${property_elements} = Get Elements ${normal_properties_element}[0] property + Length Should Be ${property_elements} 2 + Element Attribute Should be ${property_elements}[0] name Documentation + Element Attribute Should be ${property_elements}[0] value Normal test cases + +XUnit File Testsuite Properties From Metadata + ${root} = Get Root Node + ${suites} = Get Elements ${root} testsuite + ${meta_properties_element} = Get Properties Node ${suites}[1] + ${property_elements} = Get Elements ${meta_properties_element}[0] property + Length Should Be ${property_elements} 8 + 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${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${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 + Element Attribute Should be ${property_elements}[4] value This overrides first value + Element Attribute Should be ${property_elements}[5] name Value from CLI + Element Attribute Should be ${property_elements}[5] value cli meta + Element Attribute Should be ${property_elements}[6] name Variable from resource + Element Attribute Should be ${property_elements}[6] value Variable from a resource file + Element Attribute Should be ${property_elements}[7] name variables + Element Attribute Should be ${property_elements}[7] value Version: 1.2 *** Keywords *** Get XUnit Node [Arguments] ${xpath}=. ${node} = Get Element ${OUTDIR}/xunit.xml ${xpath} - [Return] ${node} + RETURN ${node} Get XUnit Nodes [Arguments] ${xpath} ${nodes} = Get Elements ${OUTDIR}/xunit.xml ${xpath} - [Return] ${nodes} + RETURN ${nodes} Suite Stats Should Be - [Arguments] ${elem} ${tests} ${failures} ${skipped} + [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 ${start_time.isoformat()} + +Verify Outputs + Stderr should be empty + Stdout Should Contain XUnit: + File Should Exist ${OUTDIR}/xunit.xml + File Should Exist ${OUTDIR}/log.html + +Get Root Node + ${root} = Get XUnit Node + Should Be Equal ${root.tag} testsuite + RETURN ${root} + +Get Properties Node + [Arguments] ${source} + ${properties} = Get Elements ${source} properties + Length Should Be ${properties} 1 + RETURN ${properties} diff --git a/atest/robot/parsing/caching_libs_and_resources.robot b/atest/robot/parsing/caching_libs_and_resources.robot index 1bf009ec8c1..3276fc81d6c 100644 --- a/atest/robot/parsing/caching_libs_and_resources.robot +++ b/atest/robot/parsing/caching_libs_and_resources.robot @@ -4,40 +4,42 @@ Resource atest_resource.robot *** Test Cases *** Import Libraries Only Once [Setup] Run Tests And Set $SYSLOG parsing/library_caching - : FOR ${name} IN Test 1.1 Test 1.2 Test 2.1 Test 2.2 - \ Check Test Case ${name} - Should Contain X Times ${SYSLOG} Imported library 'BuiltIn' with arguments [ ] 1 - Should Contain X Times ${SYSLOG} Found test library 'BuiltIn' with arguments [ ] from cache 2 - Should Contain X Times ${SYSLOG} Imported library 'OperatingSystem' with arguments [ ] 1 - Should Contain X Times ${SYSLOG} Found test library 'OperatingSystem' with arguments [ ] from cache 3 - Check Syslog Contains | INFO \ | Test library 'OperatingSystem' already imported by suite 'Library Caching.File1' - Check Syslog Contains | INFO \ | Test library 'OperatingSystem' already imported by suite 'Library Caching.File2' + FOR ${name} IN Test 1.1 Test 1.2 Test 2.1 Test 2.2 + Check Test Case ${name} + END + Should Contain X Times ${SYSLOG} Imported library 'BuiltIn' with arguments [ ] (version 1 + Should Contain X Times ${SYSLOG} Found library 'BuiltIn' with arguments [ ] from cache. 2 + Should Contain X Times ${SYSLOG} Imported library 'OperatingSystem' with arguments [ ] (version 1 + Should Contain X Times ${SYSLOG} Found library 'OperatingSystem' with arguments [ ] from cache. 3 + Syslog Should Contain | INFO \ | Suite 'Library Caching.File1' has already imported library 'OperatingSystem' with same arguments. This import is ignored. + Syslog Should Contain | INFO \ | Suite 'Library Caching.File2' has already imported library 'OperatingSystem' with same arguments. This import is ignored. Process Resource Files Only Once [Setup] Run Tests And Set $SYSLOG parsing/resource_parsing # Check that tests are run ok ${tc} = Check Test Case Test 1.1 - Check Log Message ${tc.kws[0].kws[0].msgs[0]} variable value from 02 resource - Check Log Message ${tc.kws[1].msgs[0]} variable value from 02 resource + Check Log Message ${tc[0, 0, 0]} variable value from 02 resource + Check Log Message ${tc[1, 0]} variable value from 02 resource ${tc} = Check Test Case Test 4.1 - Check Log Message ${tc.kws[0].kws[0].msgs[0]} variable value from 02 resource - Check Log Message ${tc.kws[1].msgs[0]} variable value from 02 resource + Check Log Message ${tc[0, 0, 0]} variable value from 02 resource + Check Log Message ${tc[1, 0]} variable value from 02 resource ${tc} = Check Test Case Test 4.2 - Check Log Message ${tc.kws[0].kws[0].msgs[0]} variable value from 03 resource - Check Log Message ${tc.kws[0].kws[1].msgs[0]} variable value from 02 resource - Check Log Message ${tc.kws[0].kws[2].kws[0].msgs[0]} variable value from 02 resource - Check Log Message ${tc.kws[1].msgs[0]} variable value from 03 resource + Check Log Message ${tc[0, 0, 0]} variable value from 03 resource + Check Log Message ${tc[0, 1, 0]} variable value from 02 resource + Check Log Message ${tc[0, 2, 0, 0]} variable value from 02 resource + Check Log Message ${tc[1, 0]} variable value from 03 resource ${dir} = Normalize Path ${DATADIR}/parsing/resource_parsing - # These messages come twice. Once when they are parsed as test case files and a second time when parsed as resource files. - Should Contain X Times ${SYSLOG} Parsing file '${dir}${/}02_resource.robot' 2 - Should Contain X Times ${SYSLOG} Parsing file '${dir}${/}03_resource.robot' 2 + Should Contain X Times ${SYSLOG} Parsing file '${dir}${/}02_resource.robot' 1 + Should Contain X Times ${SYSLOG} Parsing resource file '${dir}${/}02_resource.robot' 1 + Should Contain X Times ${SYSLOG} Parsing file '${dir}${/}03_resource.robot' 1 + Should Contain X Times ${SYSLOG} Parsing resource file '${dir}${/}03_resource.robot' 1 # Check that resources were parsed only once Syslog File Should Contain In Order Parsing directory '${dir}'. Syslog File Should Contain In Order Parsing file '${dir}${/}01_tests.robot'. Syslog File Should Contain In Order Data source '${dir}${/}02_resource.robot' has no tests or tasks. Syslog File Should Contain In Order Data source '${dir}${/}03_resource.robot' has no tests or tasks. Syslog File Should Contain In Order Parsing file '${dir}${/}04_tests.robot'. - Syslog File Should Contain In Order Started test suite 'Resource Parsing' + Syslog File Should Contain In Order Started suite 'Resource Parsing' Syslog File Should Contain In Order Imported resource file '${dir}${/}02_resource.robot' Syslog File Should Contain In Order Imported resource file '${dir}${/}03_resource.robot' Syslog File Should Contain In Order Found resource file '${dir}${/}02_resource.robot' from cache diff --git a/atest/robot/parsing/custom_parsers.robot b/atest/robot/parsing/custom_parsers.robot new file mode 100644 index 00000000000..cd720142ee9 --- /dev/null +++ b/atest/robot/parsing/custom_parsers.robot @@ -0,0 +1,130 @@ +*** Settings *** +Resource atest_resource.robot + +*** Variables *** +${DIR} ${{pathlib.Path(r'${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 + +Directory with init + Run Tests --parser ${DIR}/CustomParser.py:init=True ${DIR} + Validate Directory Suite init=True + +Extension with multiple parts + [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 + 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 + ... 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 + +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: + ... '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}${/}__init__.init' 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}${/}__init__.init' 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] ${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. + ... 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=${custom robot} + ... Test in Robot file=PASS + FOR ${test} IN @{SUITE.all_tests} + IF ${init} + Should Have Tags ${test} tag from init + Should Be Equal ${test.timeout} 42 seconds + IF '${test.name}' != 'Empty' + Check Log Message ${test.setup[0]} setup from init + Check Log Message ${test.teardown[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} + ${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/robot/parsing/data_formats/formats_resource.robot b/atest/robot/parsing/data_formats/formats_resource.robot index 7b1fcd43864..b0b452ff46e 100644 --- a/atest/robot/parsing/data_formats/formats_resource.robot +++ b/atest/robot/parsing/data_formats/formats_resource.robot @@ -3,14 +3,14 @@ Resource atest_resource.robot *** Variables *** ${FORMATS DIR} ${DATA DIR}/parsing/data_formats -${HTML DIR} ${FORMATS DIR}/html ${TSV DIR} ${FORMATS DIR}/tsv ${TXT DIR} ${FORMATS DIR}/txt ${ROBOT DIR} ${FORMATS DIR}/robot ${REST DIR} ${FORMATS DIR}/rest -${REST DIR 2} ${FORMATS DIR}/rest_directives +${JSON DIR} ${FORMATS DIR}/json ${MIXED DIR} ${FORMATS DIR}/mixed_data ${RESOURCE DIR} ${FORMATS DIR}/resources +${MARKDOWN DIR} ${FORMATS DIR}/markdown @{SAMPLE TESTS} Passing Failing User Keyword Nön-äscïï Own Tags Default Tags Variable Table ... Resource File Variable File Library Import Test Timeout Keyword Timeout Empty Rows Document ... Default Fixture Overridden Fixture Quotes Escaping @@ -18,32 +18,26 @@ ${RESOURCE DIR} ${FORMATS DIR}/resources *** Keywords *** Previous Run Should Have Been Successful - Should Not Be Equal ${SUITE} ${None} Running tests failed. No Values - -Run Tests And Verify Status - [Arguments] ${options} ${paths} - Set Suite Variable $SUITE ${None} - Run Tests ${options} ${paths} - Previous Run Should Have Been Successful + Should Not Be Equal ${SUITE} ${None} Running tests failed. values=False Run Sample File And Check Tests [Arguments] ${options} ${path} - Run Tests And Verify Status ${options} ${path} + Run Tests ${options} ${path} ${ignore} ${type} = Split Extension ${path} Should Be Equal ${SUITE.name} Sample Should Be Equal ${SUITE.doc} A complex testdata file in ${type} format. Check Log Message ${SUITE.setup.messages[0]} Setup - Should Be Equal ${SUITE.teardown} ${None} + Teardown Should Not Be Defined ${SUITE} Should Contain Tests ${SUITE} @{sample_tests} Check Test Tags Own Tags force1 force2 own1 own2 Check Test Tags Default Tags default1 force1 force2 ${test} = Check Test Case Test Timeout Should Be Equal ${test.timeout} 10 milliseconds ${test} = Check Test Case Keyword Timeout - Should Be Equal ${test.kws[0].timeout} 2 milliseconds + Should Be Equal ${test[0].timeout} 2 milliseconds Check Test Doc Document Testing the metadata parsing. ${test} = Check Test Case Default Fixture - Should Be Equal ${test.setup} ${None} + Setup Should Not Be Defined ${test} Check Log Message ${test.teardown.messages[0]} Test Teardown ${test} = Check Test Case Overridden Fixture Check Log Message ${test.setup.messages[0]} Own Setup INFO @@ -51,7 +45,7 @@ Run Sample File And Check Tests Run Suite Dir And Check Results [Arguments] ${options} ${path} - Run Tests And Verify Status ${options} ${path} + Run Tests ${options} ${path} ${ignore} ${type} = Split Path ${path} Should Be Equal ${SUITE.name} ${type.capitalize()} Should Be Equal ${SUITE.doc} ${EMPTY} @@ -59,35 +53,17 @@ 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} - Check Syslog Contains | INFO \ | Data source '${path}${/}invalid.${type}' has no tests or tasks. - Check Syslog Contains | INFO \ | Data source '${path}${/}empty.${type}' has no tests or tasks. - Check Syslog Contains | INFO \ | Ignoring file or directory '${path}${/}not_a_picture.jpg'. - Run Keyword If "${type}" not in ("html", "rest") - ... Check Syslog Contains | ERROR | Parsing '${path}${/}invalid_encoding.${type}' failed: UnicodeDecodeError + 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 [Arguments] ${suite} Should Be Equal ${suite.name} With Init Should Be Equal ${suite.doc} Testing suite init file - Check Log Message ${suite.setup.kws[0].messages[0]} Running suite setup - Should Be Equal ${suite.teardown} ${None} + Check Log Message ${suite.setup[0].messages[0]} Running suite setup + Teardown Should Not Be Defined ${suite} Should Contain Suites ${suite} Sub Suite1 Sub Suite2 Should Contain Tests ${suite} @{SUBSUITE_TESTS} - -*** Keywords *** -Check HTML Deprecation Message - [Arguments] ${index} ${path} - ${path} = Normalize Path ${path} - ${msg} = Catenate - ... Using test data in HTML format is deprecated. - ... Convert '${path}' to plain text format. - Check Log Message @{ERRORS}[${index}] ${msg} WARN - -Check Automatic Parsing Deprecated Message - [Arguments] ${index} ${path} - ${path} = Normalize Path ${path} - ${msg} = Catenate - ... Automatically parsing other than '*.robot' files is deprecated. - ... Convert '${path}' to '*.robot' format or use '--extension' to - ... explicitly configure which files to parse. - Check Log Message @{ERRORS}[${index}] ${msg} WARN diff --git a/atest/robot/parsing/data_formats/html.robot b/atest/robot/parsing/data_formats/html.robot deleted file mode 100644 index 9a5866d962a..00000000000 --- a/atest/robot/parsing/data_formats/html.robot +++ /dev/null @@ -1,26 +0,0 @@ -*** Settings *** -Resource formats_resource.robot - -*** Test Cases *** -One HTML - Run sample file and check tests ${EMPTY} ${HTMLDIR}/sample.html - -HTML With HTML Resource - Previous Run Should Have Been Successful - Check Test Case Resource File - -HTML Is Deprecated - Previous Run Should Have Been Successful - Check HTML Deprecation Message 1 ${HTMLDIR}/sample.html - Check HTML Deprecation Message 2 ${RESOURCEDIR}/html_resource.html - Check HTML Deprecation Message 3 ${RESOURCEDIR}/html_resource2.htm - Length Should Be ${ERRORS} 4 - -HTML Directory - Run Suite Dir And Check Results ${EMPTY} ${HTMLDIR} - ${malformed} = Normalize Path ${HTMLDIR}/malformed.html - Check Syslog Contains Data source '${malformed}' has no tests or tasks. - -Directory With HTML Init - Previous Run Should Have Been Successful - Check Suite With Init ${SUITE.suites[1]} 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/markdown.robot b/atest/robot/parsing/data_formats/markdown.robot new file mode 100644 index 00000000000..92e10318edb --- /dev/null +++ b/atest/robot/parsing/data_formats/markdown.robot @@ -0,0 +1,61 @@ +*** Settings *** +Resource formats_resource.robot + +*** Test Cases *** +One Markdown file + Run Sample File And Check Tests ${EMPTY} ${MARKDOWN DIR}/sample.md + +Markdown With Markdown 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 ${MARKDOWN DIR}/sample.md 10 + ... Non-existing setting 'Invalid'. + Error in file 1 ${MARKDOWN DIR}/../resources/markdown_resource.md 2 + ... Non-existing setting 'Invalid Resource'. + Length should be ${ERRORS} 2 + +Markdown Directory + Run Tests -F md:markdown ${MARKDOWN DIR} + Should Be Equal ${SUITE.name} Markdown + Should Contain Suites ${SUITE} Sample With Init + Should Contain Suites ${SUITE.suites[1]} Fence Blocks Sub Suite1 Sub Suite2 + Should Contain Tests ${SUITE} @{SAMPLE_TESTS} @{SUBSUITE_TESTS} Tilde Fence Longer Closure Fence Info String + ${path} = Normalize Path ${MARKDOWN DIR} + Syslog Should Contain | INFO \ | Data source '${path}${/}invalid.markdown' has no tests or tasks. + Syslog Should Contain | INFO \ | Data source '${path}${/}empty.markdown' has no tests or tasks. + Syslog Should Contain | INFO \ | Ignoring file or directory '${path}${/}not_a_picture.jpg'. + +Directory With Markdown Init + Previous Run Should Have Been Successful + ${suite} = Set Variable ${SUITE.suites[1]} + Should Be Equal ${suite.name} With Init + Should Be Equal ${suite.doc} Testing suite init file + Check Log Message ${suite.setup[0].messages[0]} Running suite setup + Teardown Should Not Be Defined ${suite} + Should Contain Suites ${suite} Fence Blocks Sub Suite1 Sub Suite2 + Should Contain Tests ${suite} @{SUBSUITE_TESTS} Tilde Fence Longer Closure Fence Info String + +Parsing errors in init file have correct source + Previous Run Should Have Been Successful + Error in file 0 ${MARKDOWN DIR}/sample.md 10 + ... Non-existing setting 'Invalid'. + Error in file 1 ${MARKDOWN DIR}/with_init/__init__.md 4 + ... Non-existing setting 'Invalid Init'. + Error in file 2 ${MARKDOWN DIR}/../resources/markdown_resource.md 2 + ... Non-existing setting 'Invalid Resource'. + Length should be ${ERRORS} 3 + +Fence Block Parsing In Markdown + Run Tests ${EMPTY} ${MARKDOWN DIR}/with_init/fence_blocks.md + Should Be Equal ${SUITE.name} Fence Blocks + Should Contain Tests ${SUITE} Tilde Fence Longer Closure Fence Info String + Length Should Be ${SUITE.tests} 3 + +'.robot.md' files are parsed automatically + Run Tests ${EMPTY} ${MARKDOWN DIR}/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/robot/parsing/data_formats/mixed.robot b/atest/robot/parsing/data_formats/mixed.robot index faccde8e492..d32822f33cd 100644 --- a/atest/robot/parsing/data_formats/mixed.robot +++ b/atest/robot/parsing/data_formats/mixed.robot @@ -1,99 +1,26 @@ *** Settings *** -Force Tags require-docutils Resource formats_resource.robot +Suite Setup Run tests -F txt:tsv ${MIXEDDIR} *** Variables *** -@{HTML TESTS} HTML Passing HTML Failing TSV Resource ReST Resource TXT Resource -@{TSV TESTS} TSV Passing TSV Failing HTML Resource ReST Resource TXT Resource -@{REST TESTS} ReST Passing ReST Failing HTML Resource TSV Resource TXT Resource -@{TXT TESTS} TXT Passing TXT Failing HTML Resource ReST Resource TSV Resource +@{TSV TESTS} TSV Passing TSV Failing TXT Resource +@{TXT TESTS} TXT Passing TXT Failing TSV Resource *** Test Cases *** -HTML Suite With TSV Resource - Run Tests And Verify Status ${EMPTY} ${MIXEDDIR}/HTML.html - Check Test Case TSV Resource - -HTML Suite With ReST Resource - Previous Run Should Have Been Successful - Check Test Case ReST Resource - -HTML Suite With TXT Resource - Previous Run Should Have Been Successful - Check Test Case TXT Resource - -TSV Suite With HTML Resource - Run Tests And Verify Status ${EMPTY} ${MIXEDDIR}/TSV.tsv - Check Test Case HTML Resource - -TSV Suite With ReST Resource - Previous Run Should Have Been Successful - Check Test Case ReST Resource - TSV Suite With TXT Resource - Previous Run Should Have Been Successful - Check Test Case TXT Resource - -ReST Suite With HTML Resource - Run Tests And Verify Status ${EMPTY} ${MIXEDDIR}/REST.rest - Check Test Case HTML Resource - -ReST Suite With TSV Resource - Previous Run Should Have Been Successful - Check Test Case TSV Resource - -ReST Suite With TXT Resource - Previous Run Should Have Been Successful Check Test Case TXT Resource -TXT Suite With HTML Resource - Run Tests And Verify Status ${EMPTY} ${MIXEDDIR}/TXT.txt - Check Test Case HTML Resource - -TXT Suite With ReST Resource - Previous Run Should Have Been Successful - Check Test Case ReST Resource - TXT Suite With TSV Resource - Previous Run Should Have Been Successful Check Test Case TSV Resource Directory With Mixed Data - Run Tests And Verify Status ${EMPTY} ${MIXEDDIR} - Verify Directory with Mixed Data With ReST - -Multiple Data Sources Without reST - Run Tests And Verify Status ${EMPTY} ${HTMLDIR}/sample.html ${TSVDIR}/sample.tsv ${TXTDIR}/sample.txt ${MIXEDDIR} - Verify Multiple Data Sources Without ReST - -Multiple Data Sources With reST - Run Tests And Verify Status ${EMPTY} ${HTMLDIR}/sample.html ${TSVDIR}/sample.tsv ${RESTDIR}/sample.rst ${TXTDIR}/sample.txt ${MIXEDDIR} - Verify Multiple Data Sources With ReST + Verify Directory with Mixed Data *** Keywords *** -Verify Directory With Mixed Data With ReST +Verify Directory With Mixed Data Should Be Equal ${SUITE.name} Mixed Data - Should Contain Suites ${SUITE} HTML REST TSV TXT - Should Be Equal ${SUITE.suites[0].doc} Test suite in HTML file - Should Be Equal ${SUITE.suites[1].doc} Test suite in ReST file - Should Be Equal ${SUITE.suites[2].doc} Test suite in TSV file - Should Be Equal ${SUITE.suites[3].doc} Test suite in TXT file - Should Contain Tests ${SUITE.suites[0]} @{HTML TESTS} - Should Contain Tests ${SUITE.suites[1]} @{REST TESTS} - Should Contain Tests ${SUITE.suites[2]} @{TSV TESTS} - Should Contain Tests ${SUITE.suites[3]} @{TXT TESTS} - -Verify Multiple Data Sources With ReST - Should Be Equal ${SUITE.name} Sample & Sample & Sample & Sample & Mixed Data - Should Contain Suites ${SUITE} Sample Sample Sample Sample Mixed Data - Should Contain Tests ${SUITE.suites[0]} @{SAMPLE_TESTS} - Should Contain Tests ${SUITE.suites[1]} @{SAMPLE_TESTS} - Should Contain Tests ${SUITE.suites[2]} @{SAMPLE_TESTS} - Should Contain Tests ${SUITE.suites[3]} @{SAMPLE_TESTS} - Should Contain Tests ${SUITE.suites[4]} @{HTML TESTS} @{REST TESTS} @{TSV TESTS} @{TXT TESTS} - -Verify Multiple Data Sources Without ReST - Should Be Equal ${SUITE.name} Sample & Sample & Sample & Mixed Data - Should Contain Suites ${SUITE} Sample Sample Sample Mixed Data - Should Contain Tests ${SUITE.suites[0]} @{SAMPLE_TESTS} - Should Contain Tests ${SUITE.suites[1]} @{SAMPLE_TESTS} - Should Contain Tests ${SUITE.suites[2]} @{SAMPLE_TESTS} + Should Contain Suites ${SUITE} TSV TXT + Should Be Equal ${SUITE.suites[0].doc} Test suite in TSV file + Should Be Equal ${SUITE.suites[1].doc} Test suite in TXT file + Should Contain Tests ${SUITE.suites[0]} @{TSV TESTS} + Should Contain Tests ${SUITE.suites[1]} @{TXT TESTS} diff --git a/atest/robot/parsing/data_formats/resource_extension.robot b/atest/robot/parsing/data_formats/resource_extension.robot deleted file mode 100644 index f84667e7cbe..00000000000 --- a/atest/robot/parsing/data_formats/resource_extension.robot +++ /dev/null @@ -1,18 +0,0 @@ -*** Settings *** -Suite Setup Run Tests ${EMPTY} parsing/data_formats/resource_extension -Resource atest_resource.robot - -*** Test Cases *** -Resource with '*.resource' extension - ${tc} = Check Test Case ${TESTNAME} - Check Log Message ${tc.kws[0].kws[0].kws[1].msgs[0]} nested.resource - Check Log Message ${tc.kws[0].kws[3].msgs[0]} resource.resource - Check Log Message ${tc.kws[1].kws[1].msgs[0]} nested.resource - Check Log Message ${tc.kws[4].msgs[0]} resource.resource - Check Log Message ${tc.kws[5].msgs[0]} nested.resource - -'*.resource' files are not parsed for tests - Should Contain Suites ${SUITE} Tests - Should Contain Tests ${SUITE} Resource with '*.resource' extension - ${path} = Normalize Path ${DATADIR}/parsing/data_formats/resource_extension/tests.resource - Check Syslog Contains | INFO \ | Ignoring file or directory '${path}'. diff --git a/atest/robot/parsing/data_formats/resource_extensions.robot b/atest/robot/parsing/data_formats/resource_extensions.robot new file mode 100644 index 00000000000..d40efacceef --- /dev/null +++ b/atest/robot/parsing/data_formats/resource_extensions.robot @@ -0,0 +1,53 @@ +*** Settings *** +Suite Setup Run Tests ${EMPTY} parsing/data_formats/resource_extensions +Resource atest_resource.robot + +*** Test Cases *** +Resource with '*.resource' extension + ${tc} = Check Test Case ${TESTNAME} + Check Log Message ${tc[0, 0, 1, 0]} nested.resource + Check Log Message ${tc[0, 3, 0]} resource.resource + Check Log Message ${tc[1, 1, 0]} nested.resource + Check Log Message ${tc[4, 0]} resource.resource + Check Log Message ${tc[5, 0]} nested.resource + +'*.resource' files are not parsed for tests + Should Contain Suites ${SUITE} Tests + ${path} = Normalize Path ${DATADIR}/parsing/data_formats/resource_extensions/tests.resource + Syslog Should Contain | INFO \ | Ignoring file or directory '${path}'. + +Resource with '*.robot' extension + Check Test Case ${TESTNAME} + +Resource with '*.txt' extension + Check Test Case ${TESTNAME} + +Resource with '*.tsv' extension + Check Test Case ${TESTNAME} + +Resource with '*.rst' extension + [Tags] require-docutils + Check Test Case ${TESTNAME} + +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 '*.md' extension + Check Test Case ${TESTNAME} + +Resource with '*.markdown' extension + Check Test Case ${TESTNAME} + +Resource with invalid extension + Check Test Case ${TESTNAME} + Error in file 0 parsing/data_formats/resource_extensions/tests.robot 12 + ... Invalid resource file extension '.invalid'. + ... Supported extensions are '.json', '.markdown', '.md', '.resource', '.rest', '.robot', '.rsrc', '.rst', '.tsv' and '.txt'. + Length should be ${ERRORS} 1 diff --git a/atest/robot/parsing/data_formats/rest.robot b/atest/robot/parsing/data_formats/rest.robot index 7ce173dd324..6b13fb1bb58 100644 --- a/atest/robot/parsing/data_formats/rest.robot +++ b/atest/robot/parsing/data_formats/rest.robot @@ -3,31 +3,40 @@ Force Tags require-docutils Resource formats_resource.robot *** Test Cases *** -One ReST +One reST using code-directive Run sample file and check tests ${EMPTY} ${RESTDIR}/sample.rst -ReST With ReST Resource +ReST With reST Resource Previous Run Should Have Been Successful Check Test Case Resource File -ReST Converted To HTML Is Deprecated +Parsing errors have correct source Previous Run Should Have Been Successful - Check HTML Deprecation Message 0 ${RESTDIR}/sample.rst - Check HTML Deprecation Message 2 ${RESOURCEDIR}/rest_resource.rst - Check HTML Deprecation Message 3 ${RESOURCEDIR}/rest_resource2.rest - -Parsing reST files automatically is deprecated - Previous Run Should Have Been Successful - Check Automatic Parsing Deprecated Message 1 ${RESTDIR}/sample.rst - Length should be ${ERRORS} 4 - -Using --extension avoids deprecation warning - Run sample file and check tests --extension rst ${RESTDIR}/sample.rst - Length should be ${ERRORS} 3 + 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} -Directory With ReST Init +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 + Should Be Equal ${SUITE.suites[0].name} Sub Suite2 + Should Contain Tests ${SUITE} Suite2 Test diff --git a/atest/robot/parsing/data_formats/rest_directives.robot b/atest/robot/parsing/data_formats/rest_directives.robot deleted file mode 100644 index a25164e79c7..00000000000 --- a/atest/robot/parsing/data_formats/rest_directives.robot +++ /dev/null @@ -1,20 +0,0 @@ -*** Settings *** -Force Tags require-docutils -Resource formats_resource.robot - -*** Test Cases *** -One ReST using code-directive - Run sample file and check tests ${EMPTY} ${RESTDIR2}/sample.rst - -ReST With ReST Resource - Previous Run Should Have Been Successful - Check Test Case Resource File - -Parsing reST files automatically is deprecated - Previous Run Should Have Been Successful - Check Automatic Parsing Deprecated Message 0 ${RESTDIR2}/sample.rst - Length should be ${ERRORS} 1 - -Using --extension avoids deprecation warning - Run sample file and check tests --extension rst ${RESTDIR2}/sample.rst - Length should be ${ERRORS} 0 diff --git a/atest/robot/parsing/data_formats/tsv.robot b/atest/robot/parsing/data_formats/tsv.robot index c88fa65cc90..0113cbd660c 100644 --- a/atest/robot/parsing/data_formats/tsv.robot +++ b/atest/robot/parsing/data_formats/tsv.robot @@ -9,51 +9,9 @@ TSV With TSV Resource Previous Run Should Have Been Successful Check Test Case Resource File -Un-escaping quoting and empty cells are deprecated - Previous Run Should Have Been Successful - Check quoting and empty cell deprecations - -Parsing TSV files automatically is deprecated - Previous Run Should Have Been Successful - Check Automatic Parsing Deprecated Message 6 ${TSVDIR}/sample.tsv - Length should be ${ERRORS} 7 - -Using --extension avoids deprecation warning - Run sample file and check tests --extension TsV ${TSVDIR}/sample.tsv - Check quoting and empty cell deprecations - Length should be ${ERRORS} 6 - TSV Directory Run Suite Dir And Check Results -F tsv ${TSVDIR} Directory With TSV Init Previous Run Should Have Been Successful Check Suite With Init ${SUITE.suites[1]} - -*** Keywords *** -Check quoting and empty cell deprecations - ${path} = Normalize Path ${TSVDIR}/sample.tsv - Check quoting deprecation ${ERRORS[0]} ${path} 1 - ... "This text should be ignored, even though it's not a comment." - Check quoting deprecation ${ERRORS[1]} ${path} 20 - ... """this has """"many "" quotes """"" - Check quoting deprecation ${ERRORS[2]} ${path} 51 - ... "Hello, world!!" - Check quoting deprecation ${ERRORS[3]} ${path} 93 - ... """this has """"many "" quotes """"" - Check empty cell deprecation ${ERRORS[4]} ${path} 97 - Check empty cell deprecation ${ERRORS[5]} ${path} 98 - -Check quoting deprecation - [Arguments] ${error} ${path} ${line} ${problem} - ${msg} = Catenate - ... TSV file '${path}' has quotes around cells which is deprecated and must be fixed. - ... Remove quotes from '${problem}' on line ${line}. - Check Log Message ${error} ${msg} WARN - -Check empty cell deprecation - [Arguments] ${error} ${path} ${line} - ${msg} = Catenate - ... TSV file '${path}' has empty data cells which is deprecated and must be fixed. - ... Escape empty cells on line ${line} with '\${EMPTY}'. - Check Log Message ${error} ${msg} WARN diff --git a/atest/robot/parsing/data_formats/txt.robot b/atest/robot/parsing/data_formats/txt.robot index f52c0c34bd1..f8b12617277 100644 --- a/atest/robot/parsing/data_formats/txt.robot +++ b/atest/robot/parsing/data_formats/txt.robot @@ -9,15 +9,6 @@ TXT With TXT Resource Previous Run Should Have Been Successful Check Test Case Resource File -Parsing TXT files automatically is deprecated - Previous Run Should Have Been Successful - Check Automatic Parsing Deprecated Message 0 ${TXTDIR}/sample.txt - Length should be ${ERRORS} 1 - -Using --extension avoids deprecation warning - Run sample file and check tests --extension txt ${TXTDIR}/sample.txt - Should be empty ${ERRORS} - TXT Directory Run Suite Dir And Check Results -FTXT ${TXTDIR} diff --git a/atest/robot/parsing/escaping.robot b/atest/robot/parsing/escaping.robot index b49f36c921b..11b812ade56 100644 --- a/atest/robot/parsing/escaping.robot +++ b/atest/robot/parsing/escaping.robot @@ -1,5 +1,5 @@ *** Settings *** -Suite Setup Run Tests ${EMPTY} parsing/escaping.robot +Suite Setup Run Tests ${EMPTY} parsing/escaping.robot Resource atest_resource.robot *** Test Cases *** @@ -39,7 +39,7 @@ Backslash New Line Check Test Case ${TEST NAME} -Space After Newline Escape Is Ignored +Space After Newline Is parsed Check Test Case ${TEST NAME} Carrriage Return @@ -48,25 +48,25 @@ Carrriage Return Tabulator Check Test Case ${TEST NAME} -Valid \x Escape +Valid \\x Escape Check Test Case ${TEST NAME} -Invalid \x Escape +Invalid \\x Escape Check Test Case ${TEST NAME} -Valid \u Escape +Valid \\u Escape Check Test Case ${TEST NAME} -Invalid \u Escape +Invalid \\u Escape Check Test Case ${TEST NAME} -Valid \U (32bit) Escape +Valid \\U (32bit) Escape Check Test Case ${TEST NAME} -Invalid \U (32bit) Escape +Invalid \\U (32bit) Escape Check Test Case ${TEST NAME} -\U (32bit) Escape Above Valid Range +\\U (32bit) Escape Above Valid Range Check Test Case ${TEST NAME} Hash @@ -82,8 +82,7 @@ Escaping Variables With User Keywords Check Test Case ${TEST NAME} No Errors Should Have Occurred - Should Be Empty ${ERRORS} - File Should Be Empty ${STDERR_FILE} + Length should be ${ERRORS} 0 Pipe Check Test Case ${TEST NAME} diff --git a/atest/robot/parsing/failing_init.robot b/atest/robot/parsing/failing_init.robot index 75918c95fa4..45d21db32f9 100644 --- a/atest/robot/parsing/failing_init.robot +++ b/atest/robot/parsing/failing_init.robot @@ -5,6 +5,6 @@ Resource atest_resource.robot *** Test Cases *** Failing Init Should Be Equal ${SUITE.doc} This should exist - ${path} = Normalize Path ${DATADIR}/parsing/failing_init/ - Check Log Message ${ERRORS[0]} Test suite initialization file in '${path}' cannot contain tests or tasks. ERROR + Error In File 0 parsing/failing_init/__init__.robot 1 + ... 'Test Cases' section is not allowed in suite initialization file. Check Test Case Fail Init diff --git a/atest/robot/parsing/html_entityrefs.robot b/atest/robot/parsing/html_entityrefs.robot deleted file mode 100644 index ff929bc842c..00000000000 --- a/atest/robot/parsing/html_entityrefs.robot +++ /dev/null @@ -1,22 +0,0 @@ -*** Settings *** -Documentation Tests for HTML entity and character references. Former are escapes like 'ä' and latter are in format 'R'. -Suite Setup Run Tests ${EMPTY} parsing/html_entityrefs.html -Resource atest_resource.robot - -*** Test Cases *** -Scandinavian Letters - Check Test Case ${TEST NAME} - -XML Escapes - Check Test Case ${TEST NAME} - -Other Escapes - Check Test Case ${TEST NAME} - -Numerical Escapes - [Documentation] These are character references - Check Test Case ${TEST NAME} - -Variables using escapes - Check Test Case ${TEST NAME} - diff --git a/atest/robot/parsing/ignore_bom.robot b/atest/robot/parsing/ignore_bom.robot index c8bb2b799a6..6897f32c885 100644 --- a/atest/robot/parsing/ignore_bom.robot +++ b/atest/robot/parsing/ignore_bom.robot @@ -7,12 +7,12 @@ Resource atest_resource.robot Byte order mark in plain text file [Setup] File Should Have Bom parsing/bom.robot ${tc} = Check test case ${TESTNAME} - Check log message ${tc.kws[0].msgs[0]} Hyvää päivää €åppa! + Check log message ${tc[0, 0]} Hyvää päivää €åppa! Byte order mark in TSV file [Setup] File Should Have Bom parsing/bom.robot ${tc} = Check test case ${TESTNAME} - Check log message ${tc.kws[0].msgs[0]} Hyvää päivää €åppa! + Check log message ${tc[0, 0]} Hyvää päivää €åppa! *** Keywords *** File Should Have Bom diff --git a/atest/robot/parsing/invalid.robot b/atest/robot/parsing/invalid.robot index 2f065c10ce5..90b4bccfbda 100644 --- a/atest/robot/parsing/invalid.robot +++ b/atest/robot/parsing/invalid.robot @@ -2,45 +2,97 @@ Resource data_formats/formats_resource.robot *** Variables *** -${PARSING} parsing -${NO TESTS} ${PARSING}${/}notests -${EMPTY TC TABLE} ${PARSING}${/}empty_testcase_table.robot -${NO TC TABLE MSG} File has no tests or tasks. +${PARSING} ${DATADIR}/parsing +${SUITE DIR} %{TEMPDIR}/tmp *** Test Cases *** -Invalid Input - Check Parsing Error unsupported.log Unsupported file format 'log'. ${PARSING}/unsupported.log +Directory Containing No Test Cases + Run tests and check error + ... ${PARSING}/notests + ... Suite 'Notests' contains no tests or tasks. -Malformed HTML - Check Parsing Error malformed.html .* ${HTMLDIR}/malformed.html +File Containing No Test Cases + Run tests and check error + ... ${PARSING}/empty_testcase_table.robot + ... Suite 'Empty Testcase Table' contains no tests or tasks. -HTML File Not Containing Tests - Check Parsing Error invalid.html ${NO TC TABLE MSG} ${HTMLDIR}/invalid.html +Empty File + Run tests and check error + ... ${ROBOTDIR}/empty.robot + ... Suite 'Empty' contains no tests or tasks. -Directory Containing No Test Cases - Run Tests Without Processing Output ${EMPTY} ${NO TESTS} - Stderr Should Be Equal To [ ERROR ] Suite 'Notests' contains no tests.${USAGE_TIP}\n +Multisource Containing Empty File + Run tests and check error + ... ${ROBOTDIR}/empty.robot ${ROBOTDIR}/sample.robot + ... Suite 'Empty' contains no tests or tasks. -File Containing No Test Cases - Run Tests Without Processing Output ${EMPTY} ${EMPTY TC TABLE} - Stderr Should Be Equal To [ ERROR ] Suite 'Empty Testcase Table' contains no tests.${USAGE_TIP}\n +Multisource With Empty Directory + Run tests and check error + ... ${ROBOTDIR}/sample.robot ${PARSING}/notests + ... Suite 'Notests' contains no tests or tasks. + +Multisource Containing Empty File With Non-standard Extension + Run tests and check error + ... ${PARSING}/unsupported.log ${ROBOTDIR}/sample.robot + ... Suite 'Unsupported' contains no tests or tasks. -Multisource Containing No Test Cases - Run Tests Without Processing Output ${EMPTY} ${ROBOTDIR}/empty.robot ${ROBOTDIR}/sample.robot - ${path} = Normalize Path ${ROBOTDIR}/empty.robot - Stderr Should Be Equal To [ ERROR ] Parsing '${path}' failed: ${NO TC TABLE MSG}${USAGE TIP}\n +File With Invalid Encoding + Run tests and check parsing error + ... ${PARSING}/invalid_encoding/invalid_encoding.robot + ... UnicodeDecodeError: .* + ... ${PARSING}/invalid_encoding/invalid_encoding.robot -Empty HTML File - Check Parsing Error empty.html ${NO TC TABLE MSG} ${HTMLDIR}/empty.html +Directory Containing File With Invalid Encoding + Run tests and check parsing error + ... ${PARSING}/invalid_encoding/ + ... UnicodeDecodeError: .* + ... ${PARSING}/invalid_encoding/invalid_encoding.robot -Empty TSV File - Check Parsing Error empty.tsv ${NO TC TABLE MSG} ${TSVDIR}/empty.tsv +Multisource Containing File With Invalid Encoding + Run tests and check parsing error + ... ${PARSING}/invalid_encoding/invalid_encoding.robot ${PARSING}/invalid_encoding/a_valid_file.robot + ... UnicodeDecodeError: .* + ... ${PARSING}/invalid_encoding/invalid_encoding.robot -Empty TXT File - Check Parsing Error empty.txt ${NO TC TABLE MSG} ${TXTDIR}/empty.txt +File without read permission + [Tags] no-windows + [Setup] Create test data without permissions ${SUITE DIR}/sample.robot + Run tests and check parsing error + ... ${SUITE DIR}/sample.robot + ... (IOError|PermissionError): .* + ... ${SUITE DIR}/sample.robot + [Teardown] Remove test data without permissions ${SUITE DIR}/sample.robot + +Directory without read permission + [Tags] no-windows + [Setup] Create test data without permissions ${SUITE DIR} + Run tests and check parsing error + ... ${SUITE DIR} + ... (OSError|PermissionError): .* + ... ${SUITE DIR} + ... Reading directory + [Teardown] Remove test data without permissions ${SUITE DIR} *** Keywords *** -Check Parsing Error - [Arguments] ${file} ${error} ${paths} - Run Tests Without Processing Output ${EMPTY} ${paths} - Check Stderr Matches Regexp \\[ ERROR \\] Parsing '.*[/\\\\]${file}' failed: ${error}${USAGE_TIP} +Run tests and check error + [Arguments] ${paths} ${error} + ${result}= Run Tests Without Processing Output ${EMPTY} ${paths} + Should be equal ${result.rc} ${252} + Stderr Should Match Regexp \\[ ERROR \\] ${error}${USAGE_TIP} + +Run tests and check parsing error + [Arguments] ${paths} ${error} ${path} ${prefix}=Parsing + ${path}= Normalize path ${path} + ${path}= Regexp escape ${path} + Run tests and check error ${paths} ${prefix} '${path}' failed: ${error} + +Create test data without permissions + [Arguments] ${remove permissions} + Create directory ${SUITE DIR} + Copy file ${ROBOTDIR}/sample.robot ${SUITE DIR} + Remove permissions ${remove permissions} + +Remove test data without permissions + [Arguments] ${remove permissions} + Set read write execute ${remove permissions} + Remove directory ${SUITE DIR} recursive=True diff --git a/atest/robot/parsing/line_continuation.robot b/atest/robot/parsing/line_continuation.robot index fb9935a802f..2a06b84067d 100644 --- a/atest/robot/parsing/line_continuation.robot +++ b/atest/robot/parsing/line_continuation.robot @@ -1,18 +1,20 @@ *** 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 *** Multiline suite documentation and metadata - Should Be Equal ${SUITE.doc} First row.\nSecond row.\n\nSecond paragraph\n!\n!\n! + Should Be Equal ${SUITE.doc} First row.\nSecond row.\n\nSecond paragraph\n! Should Be Equal ${SUITE.metadata['Name']} 1.1\n1.2\n\n2.1\n2.2\n2.3\n\n3.1 Multiline suite level settings - Should Contain Tags ${SUITE.tests[0]} + Should Have Tags ${SUITE.tests[0]} ... ... t1 t2 t3 t4 t5 t6 t7 t8 t9 - Check Log Message ${SUITE.tests[0].teardown.msgs[0]} 1st - Check Log Message ${SUITE.tests[0].teardown.msgs[1]} 2nd last - Length Should Be ${SUITE.tests[0].teardown.msgs} 2 + Check Log Message ${SUITE.tests[0].teardown[0]} 1st + Check Log Message ${SUITE.tests[0].teardown[1]} ${EMPTY} + Check Log Message ${SUITE.tests[0].teardown[2]} 2nd last + Check Log Message ${SUITE.tests[0].teardown[3]} ${EMPTY} + Length Should Be ${SUITE.tests[0].teardown.body} 4 Multiline import Check Test Case ${TEST NAME} @@ -22,19 +24,21 @@ Multiline variables Multiline arguments with library keyword ${tc} = Check Test Case ${TEST NAME} - Check Log Message ${tc.kws[0].msgs[0]} one - Check Log Message ${tc.kws[0].msgs[1]} two - Check Log Message ${tc.kws[0].msgs[2]} three - Check Log Message ${tc.kws[0].msgs[3]} four - Check Log Message ${tc.kws[0].msgs[4]} five + Check Log Message ${tc[0, 0]} one + Check Log Message ${tc[0, 1]} two + Check Log Message ${tc[0, 2]} three + Check Log Message ${tc[0, 3]} ${EMPTY} + Check Log Message ${tc[0, 4]} four + Check Log Message ${tc[0, 5]} five Multiline arguments with user keyword ${tc} = Check Test Case ${TEST NAME} - Check Log Message ${tc.kws[0].kws[0].msgs[0]} 1 - Check Log Message ${tc.kws[0].kws[0].msgs[1]} 2 - Check Log Message ${tc.kws[0].kws[0].msgs[2]} 3 - Check Log Message ${tc.kws[0].kws[0].msgs[3]} 4 - Check Log Message ${tc.kws[0].kws[0].msgs[4]} 5 + Check Log Message ${tc[0, 0, 0]} 1 + Check Log Message ${tc[0, 0, 1]} ${EMPTY} + Check Log Message ${tc[0, 0, 2]} 2 + Check Log Message ${tc[0, 0, 3]} 3 + Check Log Message ${tc[0, 0, 4]} 4 + Check Log Message ${tc[0, 0, 5]} 5 Multiline assignment Check Test Case ${TEST NAME} @@ -44,68 +48,31 @@ Multiline in user keyword 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. - Check Log Message ${tc.setup.msgs[0]} first - Check Log Message ${tc.setup.msgs[1]} last + Should Have Tags ${tc} @{{[f'my{i}' for i in range(1,6)]}} + Should Be Equal ${tc.doc} One.\nTwo.\nThree.\n\n${SPACE*32}Second paragraph. + Check Log Message ${tc.setup[0]} first + Check Log Message ${tc.setup[1]} ${EMPTY} + Check Log Message ${tc.setup[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[0]} Multiline user keyword settings and control structures + ... \${x} 1, 2 tags=keyword, tags + Check Log Message ${tc[0].teardown[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 are deprecated - [Template] Check Escaped Leading Empty Cell Deprecation - 1 - 10 - 12 - -Line continuation without value is deprecated - [Documentation] Except for Documentation and Metadata - [Template] Check Line Continuation Alone Deprecation - 2 In 'Default Tags' setting - 3 In 'Test Teardown' setting - 4 In 'Test Teardown' setting - 5 In 'Variables' section - 6 In 'Variables' section - 7 In 'Variables' section - 8 In 'Variables' section - 9 Invalid syntax in test case 'Multiline arguments with library keyword' - 11 Invalid syntax in test case 'Multiline arguments with user keyword' - 13 Invalid syntax in test case 'Multiline test settings': In '[Tags]' setting - 14 Invalid syntax in test case 'Multiline test settings': In '[Setup]' setting - 15 Invalid syntax in test case 'Invalid usage in test and user keyword' - 16 Invalid syntax in keyword 'Multiline user keyword settings': In '[Return]' setting - 17 Invalid syntax in keyword 'Invalid usage in UK' +Escaped empty cells before line continuation do not work + Error in file 0 parsing/line_continuation.robot 11 + ... Non-existing setting '\\'. + Error in file 1 parsing/line_continuation.robot 43 + ... Setting variable '\\' failed: Invalid variable name '\\'. + Check Test Case Invalid usage in keyword call Invalid multiline usage - [Setup] Check Test Case Invalid Usage In Test And User Keyword - [Template] Check Multiline Error - 0 Non-existing setting '...'. - -1 Setting variable '...' failed: Invalid variable name '...'. - -*** Keywords *** -Check Escaped Leading Empty Cell Deprecation - [Arguments] ${index} - ${message} = Catenate - ... Escaping empty cells with '\\' before line continuation marker '...' - ... is deprecated. Remove escaping before Robot Framework 3.2. - Check Multiline Error ${index} ${message} WARN - -Check Line Continuation Alone Deprecation - [Arguments] ${index} ${prefix} - ${message} = Catenate - ... ${prefix}: - ... Ignoring lines with only continuation marker '...' is deprecated. - Check Multiline Error ${index} ${message} WARN - -Check Multiline Error - [Arguments] ${index} ${message} ${level}=ERROR - ${path} = Normalize Path ${DATADIR}/parsing/line_continuation.robot - Check Log Message ${ERRORS}[${index}] - ... Error in file '${path}': ${message} ${level} + Check Test Case Invalid Usage In Test + Check Test Case Invalid Usage In User Keyword diff --git a/atest/robot/parsing/non_ascii_paths.robot b/atest/robot/parsing/non_ascii_paths.robot index d7b252bd643..5d8836bbef1 100644 --- a/atest/robot/parsing/non_ascii_paths.robot +++ b/atest/robot/parsing/non_ascii_paths.robot @@ -1,6 +1,5 @@ *** Settings *** Suite Setup Create input data and run tests -Suite Teardown Revert input data Resource atest_resource.robot *** Variables *** @@ -23,25 +22,18 @@ Creating logs and reports should succeed Stderr should be empty Failures processing files are handled gracefully - ${path} = Normalize Path ${BASEDIR}/Työ/tyhjä.robot - Check syslog contains Data source '${path}' has no tests or tasks. + ${path} = Normalize Path %{TEMPDIR}/Työ/tyhjä.robot + Syslog Should Contain Data source '${path}' has no tests or tasks. *** Keywords *** Create input data and run tests Create input data - Run Tests --log ulog.html --report ureport.html parsing/non_ascii_paths/testäö.robot parsing/non_ascii_paths/Työ + Run Tests --log ulog.html --report ureport.html %{TEMPDIR}/testäö.robot %{TEMPDIR}/Työ Create input data [Documentation] Mercurial doesn't seem to handle non-ASCII file names too well. ... Need to store files with ASCII names and rename them during execution. - Move Directory ${BASEDIR}/Ty-ouml ${BASEDIR}/Työ - Move File ${BASEDIR}/Työ/tyhj-auml.robot ${BASEDIR}/Työ/tyhjä.robot - Move File ${BASEDIR}/Työ/sect-test-sect.robot ${BASEDIR}/Työ/§test§.robot - Move File ${BASEDIR}/test-auml-ouml.robot ${BASEDIR}/testäö.robot - -Revert input data - [Documentation] Revert ASCII -> non-ASCII conversion done by `Create input data`. - Move File ${BASEDIR}/Työ/§test§.robot ${BASEDIR}/Työ/sect-test-sect.robot - Move File ${BASEDIR}/Työ/tyhjä.robot ${BASEDIR}/Työ/tyhj-auml.robot - Move Directory ${BASEDIR}/Työ ${BASEDIR}/Ty-ouml - Move File ${BASEDIR}/testäö.robot ${BASEDIR}/test-auml-ouml.robot + Copy Directory ${BASEDIR}/Ty-ouml %{TEMPDIR}/Työ + Move File %{TEMPDIR}/Työ/tyhj-auml.robot %{TEMPDIR}/Työ/tyhjä.robot + Move File %{TEMPDIR}/Työ/sect-test-sect.robot %{TEMPDIR}/Työ/§test§.robot + Copy File ${BASEDIR}/test-auml-ouml.robot %{TEMPDIR}/testäö.robot diff --git a/atest/robot/parsing/non_ascii_spaces.robot b/atest/robot/parsing/non_ascii_spaces.robot index f4baa22aa74..3a7743ec85d 100644 --- a/atest/robot/parsing/non_ascii_spaces.robot +++ b/atest/robot/parsing/non_ascii_spaces.robot @@ -5,58 +5,19 @@ Resource atest_resource.robot *** Test Cases *** In suite settings ${tc} = Check Test Case In test and keywords - Check Log Message ${tc.setup.kws[0].msgs[0]} ': :' - Check Log Message ${tc.teardown.kws[0].msgs[0]} ': :' - Normalization deprecated 0 Test\\xa0Setup 2 - Normalization deprecated 1 No-break\\xa0space 2 - Normalization deprecated 2 :\\xa0: 2 - Normalization deprecated 3 Test\\u1680Teardown 3 - Normalization deprecated 4 Ogham\\u1680space\\u1680mark 3 - Normalization deprecated 5 :\\u1680: 3 - -In variables - Normalization deprecated 6 \${NO-BREAK\\xa0SPACE}\\xa0= 6 - Normalization deprecated 7 :\\xa0: 6 - Normalization deprecated 8 \${OGHAM\\u1680SPACE\\u1680MARK}\\u1680= 7 - Normalization deprecated 9 :\\u1680: 7 - Normalization deprecated 10 \${IDEOGRAPHIC\\u3000SPACE}\\u3000= 8 - Normalization deprecated 11 :\\u3000: 8 + Check Log Message ${tc.setup[0, 0]} ':\\xa0:' + Check Log Message ${tc.setup[1, 0]} : : + Check Log Message ${tc.teardown[0, 0]} ':\\u1680:' + Check Log Message ${tc.teardown[1, 0]} : : In test and keywords ${tc} = Check Test Case ${TESTNAME} - Normalization deprecated 12 [\\xa0Tags\\u1680] 14 - Normalization deprecated 13 NBSP\\xa0and\\u1680Ogham 14 - Normalization deprecated 14 \${x}\\xa0= 15 - Normalization deprecated 15 No-break\\xa0space 15 - Normalization deprecated 16 :\\xa0: 15 - Normalization deprecated 17 \${x}\\u1680= 16 - Normalization deprecated 18 Ogham\\u1680space\\u1680mark 16 - Normalization deprecated 19 :\\u1680: 16 - Normalization deprecated 20 \${x}\\u3000= 17 - Normalization deprecated 21 Ideographic\\u3000space 17 - Normalization deprecated 22 :\\u3000: 17 - Normalization deprecated 23 No-break\\xa0space 21 - Normalization deprecated 24 :\\xa0: 21 - Normalization deprecated 25 No-break\\xa0space 25 - Normalization deprecated 26 :\\xa0: 25 - Normalization deprecated 27 No-break\\xa0space 28 - Normalization deprecated 28 [\\xa0Arguments\\xa0] 29 - Normalization deprecated 29 Should\\xa0be\\xa0equal 31 - Normalization deprecated 30 Should\\xa0be\\xa0equal 32 - Normalization deprecated 31 Should\\xa0be\\xa0equal 33 - Normalization deprecated 32 \${NO-BREAK\\xa0SPACE} 33 - Normalization deprecated 33 Ogham\\u1680space\\u1680mark 35 - Normalization deprecated 34 [\\u1680Arguments\\u1680] 36 - Normalization deprecated 35 Should\\u1680be\\u1680equal 38 - Normalization deprecated 36 Should\\u1680be\\u1680equal 39 - Normalization deprecated 37 Should\\u1680be\\u1680equal 40 - Normalization deprecated 38 \${OGHAM\\u1680SPACE\\u1680MARK} 40 - Normalization deprecated 39 Ideographic\\u3000space 42 - Normalization deprecated 40 [\\u3000Arguments\\u3000] 43 - Normalization deprecated 41 Should\\u3000be\\u3000equal 45 - Normalization deprecated 42 Should\\u3000be\\u3000equal 46 - Normalization deprecated 43 Should\\u3000be\\u3000equal 47 - Normalization deprecated 44 \${IDEOGRAPHIC\\u3000SPACE} 47 + Check Log Message ${tc[0, 0, 0]} ':\\xa0:' + Check Log Message ${tc[0, 1, 0]} : : + Check Log Message ${tc[1, 0, 0]} ':\\u1680:' + Check Log Message ${tc[1, 1, 0]} : : + Check Log Message ${tc[2, 0, 0]} ':\\u3000:' + Check Log Message ${tc[2, 1, 0]} : : As separator Check Test Case ${TESTNAME} @@ -66,13 +27,22 @@ With pipes In header Check Test Case ${TESTNAME} - Normalization deprecated 45 ***\\xa0Test\\u1680Cases\\u3000*** 49 -*** Keywords *** -Normalization deprecated - [Arguments] ${index} ${text} ${line} - ${path} = Normalize Path ${DATADIR}/parsing/non_ascii_spaces.robot - ${msg} = Catenate - ... Converting whitespace characters to ASCII spaces during parsing is deprecated. - ... Fix '${text}' in file '${path}' on line ${line}. - Check Log Message ${ERRORS}[${index}] ${msg} WARN +In test casename + Check Test Case ${TESTNAME} + +In WITH NAME + Check Test Case ${TESTNAME} + +In FOR separator + Check Test Case ${TESTNAME} + +In ELSE IF + ${tc} = Check Test Case ${TESTNAME} + Check Log Message ${tc[0, 3, 0, 0]} Should be executed + +In inline ELSE IF + Check Test Case ${TESTNAME} + +With embedded arguments and BDD prefixes + Check Test Case ${TESTNAME} diff --git a/atest/robot/parsing/non_breaking_space.robot b/atest/robot/parsing/non_breaking_space.robot deleted file mode 100644 index bd99fe0801e..00000000000 --- a/atest/robot/parsing/non_breaking_space.robot +++ /dev/null @@ -1,17 +0,0 @@ -*** Settings *** -Documentation Can be removed in RF 3.2 in favor of non_ascii_spaces.robot -Suite Setup Run Tests ${EMPTY} parsing/nbsp.* -Resource atest_resource.robot - -*** Test Cases *** -Non-breaking spaces in plain text file - Check test case ${TESTNAME} - -Non-breaking spaces in plain text file with pipes - Check test case ${TESTNAME} - -Non-breaking spaces in TSV file - Check test case ${TESTNAME} - -Non-breaking spaces in HTML file - Check test case ${TESTNAME} diff --git a/atest/robot/parsing/paths_are_not_case_normalized.robot b/atest/robot/parsing/paths_are_not_case_normalized.robot index f785f24b2d0..82a599a17ad 100644 --- a/atest/robot/parsing/paths_are_not_case_normalized.robot +++ b/atest/robot/parsing/paths_are_not_case_normalized.robot @@ -7,8 +7,8 @@ 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 - Check stdout contains ${/}LOG.html - Check stdout contains ${/}RaP.html + Stdout Should Contain ${/}LOG.html + Stdout Should Contain ${/}RaP.html diff --git a/atest/robot/parsing/pipes.robot b/atest/robot/parsing/pipes.robot index 01e5d19ad4f..b4735f8ebb2 100644 --- a/atest/robot/parsing/pipes.robot +++ b/atest/robot/parsing/pipes.robot @@ -10,7 +10,6 @@ Pipes All Around Check Test Case ${TEST NAME} Empty line with pipe - Should Be True not any(e.level == 'ERROR' for e in $ERRORS) Check Test Case ${TEST NAME} Pipes In Data @@ -22,32 +21,18 @@ Extra Pipes At The End Empty Cells In Middle Check Test Case ${TEST NAME} -Consequtive spaces +Consecutive spaces Check Test Case ${TEST NAME} - Collapsing deprecated 0 foo${SPACE * 12}bar 63 - Collapsing deprecated 1 non-ascii\\xa0\\u1680\\u3000spaces 64 Tabs Check Test Case ${TEST NAME} - Normalization deprecated 2 foo\\tbar 67 - Collapsing deprecated 3 foo\\t\\t\\tbar 68 Using FOR Loop With Pipes Check Test Case ${TEST NAME} -*** Keywords *** -Collapsing deprecated - [Arguments] ${index} ${text} ${line} - ${path} = Normalize Path ${DATADIR}/parsing/pipes.robot - ${msg} = Catenate - ... Collapsing consecutive whitespace during parsing is deprecated. - ... Fix '${text}' in file '${path}' on line ${line}. - Check Log Message ${ERRORS}[${index}] ${msg} WARN - -Normalization deprecated - [Arguments] ${index} ${text} ${line} - ${path} = Normalize Path ${DATADIR}/parsing/pipes.robot - ${msg} = Catenate - ... Converting whitespace characters to ASCII spaces during parsing is deprecated. - ... Fix '${text}' in file '${path}' on line ${line}. - Check Log Message ${ERRORS}[${index}] ${msg} WARN +Leading pipe without space after + Check Test Case |${TEST NAME} + Check Test Case || + Error In File 0 parsing/pipes.robot 6 Non-existing setting '||'. + Error In File 1 parsing/pipes.robot 7 Non-existing setting '|Documentation'. Did you mean:\n${SPACE*4}Documentation + Length Should Be ${ERRORS} 2 diff --git a/atest/robot/parsing/same_setting_multiple_times.robot b/atest/robot/parsing/same_setting_multiple_times.robot index 6cd771f5a83..82854d277c9 100644 --- a/atest/robot/parsing/same_setting_multiple_times.robot +++ b/atest/robot/parsing/same_setting_multiple_times.robot @@ -4,124 +4,77 @@ Resource atest_resource.robot *** Test Cases *** Suite Documentation - Should Be Equal ${SUITE.doc} ${EMPTY} - Setting multiple times 0 Documentation + Should Be Equal ${SUITE.doc} S1 Suite Metadata Should Be Equal ${SUITE.metadata['Foo']} M2 Suite Setup - Should Be Equal ${SUITE.setup} ${NONE} - Setting multiple times 1 Suite Setup + Should Be Equal ${SUITE.setup.full_name} BuiltIn.Log Many Suite Teardown - Should Be Equal ${SUITE.teardown} ${NONE} - Setting multiple times 2 Suite Teardown + Should Be Equal ${SUITE.teardown.full_name} BuiltIn.Comment Force and Default Tags - Check Test Tags Use Defaults - Setting multiple times 7 Force Tags - Setting multiple times 8 Force Tags - Setting multiple times 9 Default Tags - Setting multiple times 10 Default Tags + Check Test Tags Use Defaults D1 Test Setup ${tc} = Check Test Case Use Defaults - Should Be Equal ${tc.setup} ${NONE} - Setting multiple times 3 Test Setup + Should Be Equal ${tc.setup.full_name} BuiltIn.Log Many Test Teardown ${tc} = Check Test Case Use Defaults - Should Be Equal ${tc.teardown} ${NONE} - Setting multiple times 4 Test Teardown + Teardown Should Not Be Defined ${tc} Test Template ${tc} = Check Test Case Use Defaults - Check Keyword Data ${tc.kws[0]} BuiltIn.Sleep args=0.1s - Setting multiple times 6 Test Template + Check Keyword Data ${tc[0]} BuiltIn.Log Many args=Sleep, 0.1s Test Timeout ${tc} = Check Test Case Use Defaults - Should Be Equal ${tc.timeout} ${NONE} - Setting multiple times 11 Test Timeout + Should Be Equal ${tc.timeout} 1 second Test [Documentation] ${tc} = Check Test Case Test Settings - Should Be Equal ${tc.doc} ${EMPTY} - Setting multiple times in test case table 12 [Documentation] + Should Be Equal ${tc[0].type} ERROR + Should Be Equal ${tc[0].status} FAIL + Should Be Equal ${tc[0].values[0]} [Documentation] Test [Tags] Check Test Tags Test Settings - Setting multiple times in test case table 13 [Tags] - Setting multiple times in test case table 14 [Tags] Test [Setup] ${tc} = Check Test Case Test Settings - Should Be Equal ${tc.setup} ${NONE} - Setting multiple times in test case table 15 [Setup] + Should Be Equal ${tc.setup.full_name} BuiltIn.Log Many Test [Teardown] ${tc} = Check Test Case Test Settings - Should Be Equal ${tc.teardown} ${NONE} - Setting multiple times in test case table 16 [Teardown] - Setting multiple times in test case table 17 [Teardown] + Teardown Should Not Be Defined ${tc} Test [Template] ${tc} = Check Test Case Test Settings - Check Keyword Data ${tc.kws[0]} BuiltIn.No Operation - Setting multiple times in test case table 18 [Template] + Check Keyword Data ${tc[7]} BuiltIn.Log args=No Operation Test [Timeout] ${tc} = Check Test Case Test Settings - Should Be Equal ${tc.timeout} ${NONE} - Setting multiple times in test case table 19 [Timeout] + Should Be Equal ${tc.timeout} 2 seconds Keyword [Arguments] ${tc} = Check Test Case Keyword Settings - Check Keyword Data ${tc.kws[0]} Keyword Settings assign=\${ret} - Check Log Message ${tc.kws[0].msgs[0]} Arguments: [ \ ] TRACE - Setting multiple times in keyword table 20 [Arguments] + Check Keyword Data ${tc[0]} Keyword Settings assign=\${ret} args=1, 2, 3 tags=K1 status=FAIL + Check Log Message ${tc[0, 0]} Arguments: [ \${a1}='1' | \${a2}='2' | \${a3}='3' ] TRACE Keyword [Documentation] ${tc} = Check Test Case Keyword Settings - Should Be Equal ${tc.kws[0].doc} ${EMPTY} - Setting multiple times in keyword table 21 [Documentation] - Setting multiple times in keyword table 22 [Documentation] + Should Be Equal ${tc[0].doc} ${EMPTY} Keyword [Tags] ${tc} = Check Test Case Keyword Settings - Should Be Empty ${tc.kws[0].tags} - Setting multiple times in keyword table 23 [Tags] + Should Be True list($tc[0].tags) == ['K1'] Keyword [Timeout] ${tc} = Check Test Case Keyword Settings - Should Be Equal ${tc.kws[0].timeout} ${NONE} - Setting multiple times in keyword table 24 [Timeout] - Setting multiple times in keyword table 25 [Timeout] + Should Be Equal ${tc[0].timeout} ${NONE} Keyword [Return] - ${tc} = Check Test Case Keyword Settings - Check Log Message ${tc.kws[0].msgs[1]} Return: None TRACE - Check Log Message ${tc.kws[0].msgs[2]} \${ret} = None - Setting multiple times in keyword table 26 [Return] - Setting multiple times in keyword table 27 [Return] - -*** Keywords *** -Setting multiple times - [Arguments] ${index} ${setting} @{extra} - ${path} = Normalize Path ${DATADIR}/parsing/same_setting_multiple_times.robot - ${message} = Catenate - ... Error in file '${path}': - ... @{extra} - ... Setting '${setting}' used multiple times. - Check Log Message @{ERRORS}[${index}] ${message} ERROR - -Setting multiple times in test case table - [Arguments] ${index} ${setting} - Setting multiple times ${index} ${setting} - ... Invalid syntax in test case 'Test Settings': - -Setting multiple times in keyword table - [Arguments] ${index} ${setting} - Setting multiple times ${index} ${setting} - ... Invalid syntax in keyword 'Keyword Settings': + Check Test Case Keyword Settings diff --git a/atest/robot/parsing/spaces_and_tabs.robot b/atest/robot/parsing/spaces_and_tabs.robot index 1d514e1903a..f517f7239ae 100644 --- a/atest/robot/parsing/spaces_and_tabs.robot +++ b/atest/robot/parsing/spaces_and_tabs.robot @@ -14,16 +14,16 @@ Lot of spaces Trailing spaces ${tc} = Check Test Case ${TESTNAME} - Check Log Message ${tc.kws[0].msgs[0]} No spaces at end - Check Log Message ${tc.kws[1].msgs[0]} One space at end - Check Log Message ${tc.kws[2].msgs[0]} Two spaces at end - Check Log Message ${tc.kws[3].msgs[0]} Ten spaces at end - Check Log Message ${tc.kws[4].msgs[0]} Tab at end + Check Log Message ${tc[0, 0]} No spaces at end + Check Log Message ${tc[1, 0]} One space at end + Check Log Message ${tc[2, 0]} Two spaces at end + Check Log Message ${tc[3, 0]} Ten spaces at end + Check Log Message ${tc[4, 0]} Tab at end Tabs ${tc} = Check Test Case ${TESTNAME} - Check Log Message ${tc.kws[0].msgs[0]} I ignore tabs DEBUG + Check Log Message ${tc[0, 0]} I ignore tabs DEBUG Tabs and spaces ${tc} = Check Test Case ${TESTNAME} - Check Log Message ${tc.kws[0].msgs[0]} I ignore tabs (and spaces) DEBUG + Check Log Message ${tc[0, 0]} I ignore tabs (and spaces) DEBUG diff --git a/atest/robot/parsing/suite_metadata.robot b/atest/robot/parsing/suite_metadata.robot index 5703ee97ff0..b0e359f485e 100644 --- a/atest/robot/parsing/suite_metadata.robot +++ b/atest/robot/parsing/suite_metadata.robot @@ -12,10 +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\nis parsed using\nsame semantics as documentation. + Multiple lines Metadata in multiple lines + ... is parsed using + ... same semantics${SPACE*4}as${SPACE*4}documentation. + ... | table | + ... |${SPACE*3}!${SPACE*3}| Metadata With Variables Variables Version: 1.2 @@ -34,5 +38,6 @@ Unescaping Metadata In Setting Table *** Keywords *** Validate metadata - [Arguments] ${name} ${value} + [Arguments] ${name} @{lines} + ${value} = Catenate SEPARATOR=\n @{lines} Should be Equal ${SUITE.metadata['${name}']} ${value} diff --git a/atest/robot/parsing/suite_names.robot b/atest/robot/parsing/suite_names.robot new file mode 100644 index 00000000000..329ff59104a --- /dev/null +++ b/atest/robot/parsing/suite_names.robot @@ -0,0 +1,45 @@ +*** Settings *** +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 *** +Combined suite name + ${SUITE.name} Suites & Multiple Suites + +Directory suite name + ${SUITE.suites[0].name} Suites + ${SUITE.suites[1].name} Multiple Suites + +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[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[0].suites[3].name} Custom name for 📂 'subsuites2' + +Custom file suite name + ${SUITE.suites[0].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 96459ebc539..40a69865eee 100644 --- a/atest/robot/parsing/suite_settings.robot +++ b/atest/robot/parsing/suite_settings.robot @@ -7,26 +7,34 @@ 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 ... 1st logical line ... (i.e. paragraph) ... is shortdoc on console. - ... ${EMPTY} + ... ... 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". - ... ${EMPTY} + ... If a row ends with a newline + ... or backslash no automatic newline is added. + ... + ... | table | =header= | + ... | foo${SPACE*3}|${SPACE*4}bar${SPACE*3}| + ... | ragged | + ... ... Variables work since Robot 1.2 and doc_from_cli works too. ... Starting from RF 2.1 \${nonexisting} variables are left unchanged. ... Escaping (e.g. '\${non-existing}', 'c:\\temp', '\\n') works too. + ... Not \${closed Should Be Equal ${SUITE.doc} ${doc} Suite Name And Documentation On Console - Check Stdout Contains Suite Settings :: 1st logical line (i.e. paragraph) is shortdoc on console.${SPACE * 3}\n - Check Stdout Contains 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 @@ -45,13 +53,15 @@ Suite Setup Suite Teardown Verify Teardown ${SUITE} BuiltIn.Log Default suite teardown -Deprecated Setting Format - Verify Error 0 - ... Setting 'For CET ag S' is deprecated. Use 'Force Tags' instead. - ... level=WARN - Invalid Setting - Verify Error 1 Non-existing setting 'Invalid Setting'. + 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 33 + ... SEPARATOR=\n + ... Non-existing setting 'Megadata'. Did you mean: + ... ${SPACE*4}Metadata *** Keywords *** Verify Setup @@ -64,11 +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} - -Verify Error - [Arguments] ${index} @{message parts} ${level}=ERROR - ${path} = Normalize Path ${DATADIR}/parsing/suite_settings.robot - ${message} = Catenate Error in file '${path}': @{message parts} - Check Log Message ${ERRORS}[${index}] ${message} ${level} diff --git a/atest/robot/parsing/table_names.robot b/atest/robot/parsing/table_names.robot index dccade8be46..21d8109333e 100644 --- a/atest/robot/parsing/table_names.robot +++ b/atest/robot/parsing/table_names.robot @@ -3,57 +3,65 @@ 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 + Check Log Message ${tc[1, 0, 0, 0]} "Keywords" was executed -Deprecated Section Name Format +Comments section + Check Test Case Comment section exist + Length Should Be ${ERRORS} 6 + +Section names are space sensitive ${path} = Normalize Path ${DATADIR}/parsing/table_names.robot - ${message} = Catenate - ... Error in file '${path}': - ... Section name 'K e y w o r d' is deprecated. Use 'Keyword' instead. - Check Log Message ${ERRORS}[0] ${message} WARN + Invalid Section Error 0 table_names.robot 43 * * * K e y w o r d * * * -Comment Table - Check Test Case Comment tables exist - Length Should Be ${ERRORS} 1 +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 Tables - [Documentation] Unrecognized tables should cause error +Invalid sections [Setup] Run Tests ${EMPTY} parsing/invalid_table_names.robot ${tc} = Check Test Case Test in valid table - 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 - Validate Invalid Table Error ${ERRORS[0]} invalid_table_names.robot Error - Validate Invalid Table Error ${ERRORS[1]} invalid_table_names.robot ${EMPTY} - Validate Invalid Table Error ${ERRORS[2]} invalid_table_names.robot one more table cause an error - Validate Invalid Table Error ${ERRORS[3]} invalid_tables_resource.robot ${EMPTY} - Validate Invalid Table Error ${ERRORS[4]} invalid_tables_resource.robot Resource Error + ${path} = Normalize Path ${DATADIR}/parsing/invalid_tables_resource.robot + Check Log Message ${tc[0, 0, 0]} Keyword in valid table + 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 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} ${tc} = Check Test Case ${test case name} - Check Log Message ${tc.kws[0].msgs[0]} ${expected} - -Validate Invalid Table Error - [Arguments] ${error} ${file} ${header} - ${path} = Normalize Path ${DATADIR}/parsing/${file} - ${message} = Catenate - ... Error in file '${path}': Unrecognized table header '${header}'. - ... Available headers for data: 'Setting(s)', 'Variable(s)', 'Test Case(s)', - ... 'Task(s)' and 'Keyword(s)'. Use 'Comment(s)' to embedded additional data. - Check Log Message ${error} ${message} ERROR + Check Log Message ${tc[0, 0]} ${expected} + +Invalid Section Error + [Arguments] ${index} ${file} ${lineno} ${header} ${test and task}=, 'Test Cases', 'Tasks' + Error In File ${index} parsing/${file} ${lineno} + ... 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/robot/parsing/test_case_settings.robot b/atest/robot/parsing/test_case_settings.robot deleted file mode 100644 index dc73efd4af6..00000000000 --- a/atest/robot/parsing/test_case_settings.robot +++ /dev/null @@ -1,197 +0,0 @@ -*** Settings *** -Documentation Validating parsing of test case settings. Using tags, -... setups, teardowns and timeouts are tested elsewhere. -Suite Setup Run Tests ${EMPTY} parsing/test_case_settings.robot -Resource atest_resource.robot - -*** Test Cases *** -Name - ${tc} = Check Test Case Normal name - Should Be Equal ${tc.name} Normal name - -Names are not formatted - ${tc} = Check Test Case test_case names are NOT _forMatted_ - Should Be Equal ${tc.name} test_case names are NOT _forMatted_ - -'...' as name is deprecated - Check Test Case ... - Verify Error 0 - ... Invalid syntax in test case '...': - ... Using '...' as test case name is deprecated. - ... It will be considered line continuation in Robot Framework 3.2. - ... level=WARN - -Documentation - Verify Documentation Documentation in single line and column. - -Documentation in multiple columns - Verify Documentation Documentation for this test case in multiple columns - -Documentation in multiple rows - Verify Documentation 1st logical line - ... is shortdoc. - ... ${EMPTY} - ... This documentation has multiple rows - ... and also multiple columns. - -Documentation with variables - Verify Documentation Variables work in documentation since Robot 1.2. - -Documentation with non-existing variables - Verify Documentation - ... Starting from RF 2.1 \${NONEX} variables are just - ... left unchanged in all documentations. Existing ones - ... are replaced: "99999" - -Documentation with escaping - Verify Documentation \${VERSION}\nc:\\temp\n\n\\ - -Name and documentation on console - Check Stdout Contains Normal name${SPACE * 59}| PASS | - Check Stdout Contains test_case names are NOT _forMatted_${SPACE * 35}| PASS | - Check Stdout Contains Documentation :: Documentation in single line and column.${SPACE * 13}| PASS | - Check Stdout Contains Documentation in multiple rows :: 1st logical line is shortdoc.${SPACE * 7}| PASS | - Check Stdout Contains Documentation with non-existing variables :: Starting from RF ${2}.1 ... | PASS | - -Tags - Verify Tags force-1 test-1 test-2 - -Empty and NONE tags are ignored - Verify Tags force-1 test-1 test-2 test-3 - -Duplicate tags are ignored and first used format has precedence - [Documentation] Case, space and underscore insensitive - 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 - -No own tags - Verify Tags default-1 default-2 default-3 force-1 - -Override default tags using empty setting - Verify Tags force-1 - -Override default tags using NONE - Verify Tags force-1 - -Tags with variables - Verify Tags force-1 test-1 test-2 test-3 test-4 test-5 - -Tags with non-existing variables - Verify Tags \${non_existing} 42 \@{non_existing} force-1 test - -Setup - Verify Setup Test case setup - -Teardown - Verify Teardown Test case teardown - -Default setup and teardown - Verify Setup Default setup - Verify Teardown Default teardown - -Setup and teardown with variables - Verify Setup Logged using variables 1 - Verify Teardown Logged using variables 2 - -Override setup and teardown using empty settings - ${tc} = Check Test Case ${TEST NAME} - Should Be Equal ${tc.setup} ${NONE} - Should Be Equal ${tc.teardown} ${NONE} - -Override setup and teardown using NONE - ${tc} = Check Test Case ${TEST NAME} - Should Be Equal ${tc.setup} ${NONE} - Should Be Equal ${tc.teardown} ${NONE} - -Setup and teardown with escaping - Verify Setup One backslash \\ - Verify Teardown \${notvar} is not a variable - -Template - [Documentation] Mainly tested elsewhere - ${tc} = Check Test Case ${TEST NAME} - Check Log Message ${tc.kws[0].msgs[0]} Hello, world! - Check Log Message ${tc.kws[1].msgs[0]} Hi, tellus! - -Timeout - Verify Timeout 1 day - -Timeout with message - Verify Timeout 2 minutes 3 seconds 456 milliseconds - Verify Error 1 - ... Invalid syntax in test case 'Timeout with message': - ... Using custom timeout messages is deprecated since - ... Robot Framework 3.0.1 and will be removed in future versions. - ... Message that was used is 'Message'. - ... level=WARN - -Default timeout - Verify Timeout 1 minute 39 seconds 999 milliseconds - -Timeout with variables - Verify Timeout 1 day 3 hours 46 minutes 39 seconds - -Override timeout using empty setting - Verify Timeout ${NONE} - -Override timeout using NONE - Verify Timeout ${NONE} - -Invalid timeout - Verify Timeout invalid - -Multiple settings - Verify Documentation Documentation for this test case - Verify Tags force-1 test-1 test-2 - Verify Setup Test case setup - Verify Teardown Test case teardown - Verify Timeout 12 seconds 345 milliseconds - -Deprecated setting format - Check Test Case Invalid setting - Verify Error 2 - ... Invalid syntax in test case 'Invalid setting': - ... Setting 'Doc U Ment ation' is deprecated. Use 'Documentation' instead. - ... level=WARN - -Invalid setting - Check Test Case ${TEST NAME} - Verify Error 3 - ... Invalid syntax in test case '${TEST NAME}': - ... Non-existing setting 'Invalid'. - -*** Keywords *** -Verify Documentation - [Arguments] @{doc} - ${tc} = Check Test Case ${TEST NAME} - ${doc} = Catenate SEPARATOR=\n @{doc} - Should Be Equal ${tc.doc} ${doc} - -Verify Tags - [Arguments] @{tags} - Check Test Tags ${TEST NAME} @{tags} - -Verify Setup - [Arguments] ${message} - ${tc} = Check Test Case ${TEST NAME} - Should Be Equal ${tc.setup.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 - Check Log Message ${tc.teardown.msgs[0]} ${message} - -Verify Timeout - [Arguments] ${timeout} - ${tc} = Check Test Case ${TEST NAME} - Should Be Equal ${tc.timeout} ${timeout} - -Verify Error - [Arguments] ${index} @{message parts} ${level}=ERROR - ${path} = Normalize Path ${DATADIR}/parsing/test_case_settings.robot - ${message} = Catenate Error in file '${path}': @{message parts} - Check Log Message ${ERRORS}[${index}] ${message} ${level} diff --git a/atest/robot/parsing/test_metadata.robot b/atest/robot/parsing/test_metadata.robot new file mode 100644 index 00000000000..d5492e9ee03 --- /dev/null +++ b/atest/robot/parsing/test_metadata.robot @@ -0,0 +1,52 @@ +*** Settings *** +Resource atest_resource.robot +Suite Setup Run Tests --log log.html --report report.html parsing/test_metadata.robot validate output=True + +*** Test Cases *** +Metadata is available in modlel + Validate metadata in model + +Metadata is included in XML + Validate metadata in XML + +Metadata is included in log + Validate metadate in HTML ${OUTDIR}/log.html + +Metadata is included in report + Validate metadate in HTML ${OUTDIR}/report.html + +Metadata is preserved by Rebot + Copy Previous Outfile + Run Rebot --log rebot-log.html --report NONE ${OUTFILE COPY} + Validate metadata in model + +Metadata is included in JSON + Copy Previous Outfile + Run Tests -o output.json parsing/test_metadata.robot output=${OUTDIR}/output.json validate output=True + Validate metadata in model + Outputs Should Contain Same Data ${OUTFILE COPY} ${OUTDIR}/output.json ignore_timestamps=True + +*** Keywords *** +Validate metadata in model + ${tc} = Check Test Case Test With Metadata + Should Be Equal ${tc.metadata}[Owner] Team Robot + Should Be Equal ${tc.metadata}[Ticket] RF-4409 + Should Be Equal ${tc.metadata}[Escape] not bold & + Should Be Equal ${tc.metadata}[Format] *bold* & + ${tc} = Check Test Case Test Without Metadata + Should Be Empty ${tc.metadata} + +Validate metadata in XML + ${tc} = Get Element ${OUTFILE} xpath=.//test[@name="Test With Metadata"] + Element Text Should Be ${tc} Team Robot xpath=meta[@name="Owner"] + Element Text Should Be ${tc} RF-4409 xpath=meta[@name="Ticket"] + Element Text Should Be ${tc} not bold & xpath=meta[@name="Escape"] + Element Text Should Be ${tc} *bold* & xpath=meta[@name="Format"] + Element Should Not Exist ${OUTFILE} xpath=.//test[@name="Test Without Metadata"]/meta + +Validate metadate in HTML + [Arguments] ${path} + File Should Contain ${path} Team Robot + File Should Contain ${path} RF-4409 + File Should Contain ${path} not <b>bold</b> & <extra> + File Should Contain ${path} bold\\x3c/b> & <extra> diff --git a/atest/robot/parsing/test_settings.robot b/atest/robot/parsing/test_settings.robot new file mode 100644 index 00000000000..0a17b8c1483 --- /dev/null +++ b/atest/robot/parsing/test_settings.robot @@ -0,0 +1,212 @@ +*** Settings *** +Documentation Validating parsing of test case settings. Using tags, +... setups, teardowns and timeouts are tested elsewhere. +Suite Setup Run Tests ${EMPTY} parsing/test_settings.robot +Resource atest_resource.robot + +*** Test Cases *** +Name + ${tc} = Check Test Case Normal name + Should Be Equal ${tc.name} Normal name + +Names are not formatted + ${tc} = Check Test Case test_case names are NOT _forMatted_ + Should Be Equal ${tc.name} test_case names are NOT _forMatted_ + +Name with variable + ${tc} = Check Test Case Name with variables works since RF 3.2 + Should Be Equal ${tc.name} Name with variables works since RF 3.2 + +Name with non-existing variable + ${tc} = Check Test Case Name with \${NON-EXISTING VARIABLE} + Should Be Equal ${tc.name} Name with \${NON-EXISTING VARIABLE} + +Name with escaped variable + ${tc} = Check Test Case Name with \${ESCAPED} \${VARIABLE} + Should Be Equal ${tc.name} Name with \${ESCAPED} \${VARIABLE} + +Name with escapes + [Documentation] These names are not shown that nicely in log + ${tc} = Check Test Case Name with escapes like '', '\n' and 'c:\path\temp' + Should Be Equal ${tc.name} Name with escapes like '', '\n' and 'c:\path\temp' + +Name with invalid escapes + ${tc} = Check Test Case Name with invalid escapes like 'x' and 'uOOPS' + Should Be Equal ${tc.name} Name with invalid escapes like 'x' and 'uOOPS' + +Name with escaped escapes + ${tc} = Check Test Case Name with escaped escapes like '\\', '\\n' , '\\x' and 'c:\\path\\temp' + Should Be Equal ${tc.name} Name with escaped escapes like '\\', '\\n', '\\x' and 'c:\\path\\temp' + +Documentation + Verify Documentation Documentation in single line and column. + +Documentation 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${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${SPACE*3}|${SPACE*4}bar${SPACE*3}| + ... | ragged | + +Documentation with variables + Verify Documentation Variables work in documentation since RF 1.2. + +Documentation with non-existing variables + Verify Documentation + ... Starting from RF 2.1 \${NONEX} variables are just + ... left unchanged in all documentations. Existing ones + ... are replaced: "99999" + +Documentation with unclosed variables + Verify Documentation No closing curly at \${all test=${TEST NAME} 1 + Verify Documentation Not \${properly {closed} test=${TEST NAME} 2 + Verify Documentation 2nd not \${properly}[closed test=${TEST NAME} 3 + +Documentation with escaping + Verify Documentation \${VERSION}\nc:\\temp\n\n\\ + +Name and documentation on console + Stdout Should Contain Normal name${SPACE * 59}| PASS | + Stdout Should Contain test_case names are NOT _forMatted_${SPACE * 35}| PASS | + Stdout Should Contain Documentation :: Documentation in single line and column.${SPACE * 13}| PASS | + Stdout Should Contain Documentation in multiple rows :: 1st logical line is shortdoc.${SPACE * 7}| PASS | + Stdout Should Contain Documentation with non-existing variables :: Starting from RF ${2}.1 ... | PASS | + +Tags + Verify Tags force-1 test-1 test-2 + +Empty and NONE tags are ignored + Verify Tags force-1 test-1 test-2 test-3 + +Duplicate tags are ignored and first used format has precedence + [Documentation] Case, space and underscore insensitive + 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 + +No own tags + Verify Tags default-1 default-2 default-3 force-1 + +Override default tags using empty setting + Verify Tags force-1 + +Override default tags using NONE + Verify Tags force-1 + +Tags with variables + Verify Tags force-1 test-1 test-2 test-3 test-4 test-5 + +Tags with non-existing variables + Verify Tags \${non_existing} 42 \@{non_existing} force-1 test + +Setup + Verify Setup Test case setup + +Teardown + Verify Teardown Test case teardown + +Default setup and teardown + Verify Setup Default setup + Verify Teardown Default teardown + +Setup and teardown with variables + Verify Setup Logged using variables 1 + Verify Teardown Logged using variables 2 + +Setup and teardown with non-existing variables + Check Test Case ${TEST NAME} + +Override setup and teardown using empty settings + ${tc} = Check Test Case ${TEST NAME} + Setup Should Not Be Defined ${tc} + Teardown Should Not Be Defined ${tc} + +Override setup and teardown using NONE + ${tc} = Check Test Case ${TEST NAME} + Setup Should Not Be Defined ${tc} + Teardown Should Not Be Defined ${tc} + +Setup and teardown with escaping + Verify Setup One backslash \\ + Verify Teardown \${notvar} is not a variable + +Template + [Documentation] Mainly tested elsewhere + ${tc} = Check Test Case ${TEST NAME} + Check Log Message ${tc[0, 0]} Hello, world! + Check Log Message ${tc[1, 0]} Hi, tellus! + +Timeout + Verify Timeout 1 day + +Default timeout + Verify Timeout 1 minute 39 seconds 999 milliseconds + +Timeout with variables + Verify Timeout 1 day 3 hours 46 minutes 39 seconds + +Override timeout using empty setting + Verify Timeout ${NONE} + +Override timeout using NONE + Verify Timeout ${NONE} + +Invalid timeout + Verify Timeout invalid + +Multiple settings + Verify Documentation Documentation for this test case + Verify Tags force-1 test-1 test-2 + Verify Setup Test case setup + Verify Teardown Test case teardown + Verify Timeout 12 seconds 345 milliseconds + +Invalid setting + Check Test Case ${TEST NAME} + +Setting not valid with tests + Check Test Case ${TEST NAME} + +Small typo should provide recommendation + Check Test Case ${TEST NAME} + + +*** Keywords *** +Verify Documentation + [Arguments] @{doc} ${test}=${TEST NAME} + ${tc} = Check Test Case ${test} + ${doc} = Catenate SEPARATOR=\n @{doc} + Should Be Equal ${tc.doc} ${doc} + +Verify Tags + [Arguments] @{tags} + Check Test Tags ${TEST NAME} @{tags} + +Verify Setup + [Arguments] ${message} + ${tc} = Check Test Case ${TEST NAME} + Should Be Equal ${tc.setup.full_name} BuiltIn.Log + Check Log Message ${tc.setup[0]} ${message} + +Verify Teardown + [Arguments] ${message} + ${tc} = Check Test Case ${TEST NAME} + Should Be Equal ${tc.teardown.full_name} BuiltIn.Log + Check Log Message ${tc.teardown[0]} ${message} + +Verify Timeout + [Arguments] ${timeout} + ${tc} = Check Test Case ${TEST NAME} + Should Be Equal ${tc.timeout} ${timeout} 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/atest/robot/parsing/translations.robot b/atest/robot/parsing/translations.robot new file mode 100644 index 00000000000..ebf6386d6e0 --- /dev/null +++ b/atest/robot/parsing/translations.robot @@ -0,0 +1,108 @@ +*** Settings *** +Resource atest_resource.robot + +*** Test Cases *** +Finnish + Run Tests --language fi parsing/translations/finnish/tests.robot + Validate Translations + +Finnish task aliases + [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 + Run Tests --lang ${DATADIR}/parsing/translations/custom/custom.py parsing/translations/custom/tests.robot + Validate Translations + +Custom task aliases + 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} + 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} ^\\[ 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} приклад + +Invalid per file configuration + Error in file 0 parsing/translations/per_file_config/many.robot 4 + ... Invalid language configuration: + ... Language 'invalid' not found nor importable as a language module. + Error in file 1 parsing/translations/per_file_config/many.robot 5 + ... Invalid language configuration: + ... Language 'another invalid value' 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! + 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 + [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.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[0].full_name} Test Template + Should Be Equal ${tc[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[0].full_name} Keyword + Should Be Equal ${tc[0].doc} Keyword documentation. + Should Be Equal ${tc[0].tags} ${{['keyword', 'tags', 'own tag']}} + Should Be Equal ${tc[0].timeout} 1 hour + Should Be Equal ${tc[0].setup.full_name} BuiltIn.Log + Should Be Equal ${tc[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.full_name} Task Setup + Should Be Equal ${tc.teardown.full_name} Task Teardown + Should Be Equal ${tc[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[0].full_name} BuiltIn.Log diff --git a/atest/robot/parsing/user_keyword_settings.robot b/atest/robot/parsing/user_keyword_settings.robot index bc12862517b..8ab9eaec0ea 100644 --- a/atest/robot/parsing/user_keyword_settings.robot +++ b/atest/robot/parsing/user_keyword_settings.robot @@ -5,23 +5,14 @@ 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[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 + FOR ${kw} IN @{tc.body} + Should Be Equal ${kw.full_name} user_keyword nameS _are_not_ FORmatted END -'...' as name is deprecated - Check Test Case ${TEST NAME} - ${path} = Normalize Path ${DATADIR}/parsing/user_keyword_settings.robot - ${message} = Catenate - ... Error in file '${path}': Invalid syntax in keyword '...': - ... Using '...' as keyword name is deprecated. - ... It will be considered line continuation in Robot Framework 3.2. - Check Log Message ${ERRORS}[0] ${message} WARN - No documentation Verify Documentation ${EMPTY} test=Normal name @@ -29,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. @@ -43,25 +34,28 @@ Documentation with variables Documentation with non-existing variables Verify Documentation Starting from RF 2.1 \${NONEX} variables are left unchanged. +Documentation with unclosed variables + Verify Documentation Not \${closed + Documentation with escaping - Verify Documentation \${XXX} c:\\temp${SPACE*2}\\ + Verify Documentation \${XXX} - c:\\temp -${SPACE*2}- \\ Arguments [Documentation] Tested more thoroughly elsewhere. ${tc} = Check Test Case ${TEST NAME} - Check Log Message ${tc.kws[0].kws[0].msgs[0]} mandatory - Check Log Message ${tc.kws[0].kws[0].msgs[1]} default - Should Be True ${tc.kws[0].args} == ('mandatory',) - Check Log Message ${tc.kws[1].kws[0].msgs[0]} 1 - Check Log Message ${tc.kws[1].kws[0].msgs[1]} 2 - Should Be True ${tc.kws[1].args} == ('1', '2') - Check Log Message ${tc.kws[2].kws[0].msgs[0]} 1 - Check Log Message ${tc.kws[2].kws[0].msgs[1]} 2 - Check Log Message ${tc.kws[2].kws[0].msgs[2]} 3 - Check Log Message ${tc.kws[2].kws[0].msgs[3]} 4 - Check Log Message ${tc.kws[2].kws[0].msgs[4]} 5 - Check Log Message ${tc.kws[2].kws[0].msgs[5]} key=6 - Should Be True ${tc.kws[2].args} == ('\${1}', '\${2}', '\${3}', '\${4}', '\${5}', 'key=\${6}') + Check Log Message ${tc[0, 0, 0]} mandatory + Check Log Message ${tc[0, 0, 1]} default + Should Be True ${tc[0].args} == ('mandatory',) + Check Log Message ${tc[1, 0, 0]} 1 + Check Log Message ${tc[1, 0, 1]} 2 + Should Be True ${tc[1].args} == ('1', '2') + Check Log Message ${tc[2, 0, 0]} 1 + Check Log Message ${tc[2, 0, 1]} 2 + Check Log Message ${tc[2, 0, 2]} 3 + Check Log Message ${tc[2, 0, 3]} 4 + Check Log Message ${tc[2, 0, 4]} 5 + Check Log Message ${tc[2, 0, 5]} key=6 + Should Be True ${tc[2].args} == ('\${1}', '\${2}', '\${3}', '\${4}', '\${5}', 'key=\${6}') Teardown Verify Teardown Keyword teardown @@ -73,23 +67,37 @@ Teardown with escaping Verify Teardown \${notvar} is not a variable Return - Check Test Case ${TEST NAME} + [Documentation] [Return] is deprecated. In parsing it is transformed to RETURN. + ${tc} = Check Test Case ${TEST NAME} + Should Be Equal ${tc[0, 0].type} RETURN + Should Be Equal ${tc[0, 0].values} ${{('Return value',)}} + 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} + ${tc} = Check Test Case ${TEST NAME} + Should Be Equal ${tc[0, 1].type} RETURN + Should Be Equal ${tc[0, 1].values} ${{('\${ret}',)}} + 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} + ${tc} = Check Test Case ${TEST NAME} + Should Be Equal ${tc[0, 1].type} RETURN + Should Be Equal ${tc[0, 1].values} ${{('\${arg1}', '+', '\${arg2}', '=', '\${result}')}} + 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} + ${tc} = Check Test Case ${TEST NAME} + Should Be Equal ${tc[0, 0].type} RETURN + Should Be Equal ${tc[0, 0].values} ${{('\\\${XXX}', 'c:\\\\temp', '\\', '\\\\')}} + 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 -Timeout with message - Verify Timeout 2 minutes 3 seconds 456 milliseconds - Timeout with variables Verify Timeout 1 day 4 hours 48 minutes @@ -98,45 +106,36 @@ Invalid timeout Multiple settings Verify Documentation Documentation for a user keyword - Verify Teardown Teardown World - Verify Timeout 6 minutes - -Deprecatted setting format - Check Test Case Invalid setting - ${path} = Normalize Path ${DATADIR}/parsing/user_keyword_settings.robot - ${message} = Catenate - ... Error in file '${path}': - ... Invalid syntax in keyword 'Invalid passing': - ... Setting 'Doc U Ment ation' is deprecated. Use 'Documentation' instead. - Check Log Message ${ERRORS}[2] ${message} WARN + Verify Teardown Teardown World + Verify Timeout 6 minutes Invalid setting Check Test Case ${TEST NAME} - ${path} = Normalize Path ${DATADIR}/parsing/user_keyword_settings.robot - ${message} = Catenate - ... Error in file '${path}': - ... Invalid syntax in keyword 'Invalid passing': - ... Non-existing setting 'Invalid Setting'. - Check Log Message ${ERRORS}[3] ${message} ERROR - ${message} = Catenate - ... Error in file '${path}': - ... Invalid syntax in keyword 'Invalid failing': - ... Non-existing setting 'invalid'. - Check Log Message ${ERRORS}[4] ${message} ERROR + +Setting not valid with user keywords + Check Test Case ${TEST NAME} + +Small typo should provide recommendation + Check Test Case ${TEST NAME} + +Invalid empty line continuation in arguments should throw an error + 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 ''. *** Keywords *** Verify Documentation [Arguments] ${doc} ${test}=${TEST NAME} ${tc} = Check Test Case ${test} - Should Be Equal ${tc.kws[0].doc} ${doc} + Should Be Equal ${tc[0].doc} ${doc} Verify Teardown [Arguments] ${message} ${tc} = Check Test Case ${TEST NAME} - Should Be Equal ${tc.kws[0].kws[-1].name} BuiltIn.Log - Check Log Message ${tc.kws[0].kws[-1].msgs[0]} ${message} + Should Be Equal ${tc[0].teardown.full_name} BuiltIn.Log + Check Log Message ${tc[0].teardown[0]} ${message} Verify Timeout [Arguments] ${timeout} ${tc} = Check Test Case ${TEST NAME} - Should Be Equal ${tc.kws[0].timeout} ${timeout} + Should Be Equal ${tc[0].timeout} ${timeout} diff --git a/atest/robot/parsing/utf8_data/utf8_in_html.robot b/atest/robot/parsing/utf8_data/utf8_in_html.robot deleted file mode 100644 index f74a2653c05..00000000000 --- a/atest/robot/parsing/utf8_data/utf8_in_html.robot +++ /dev/null @@ -1,25 +0,0 @@ -*** Settings *** -Suite Setup Run Tests ${EMPTY} parsing/utf8_data.html -Resource atest_resource.robot - -*** Test Cases *** -UTF-8 In Metadata - Should Be Equal ${SUITE.doc} Testing that reading and writing of Unicode (äöå §½€ etc.) works properly. - Should Be Equal as Strings ${SUITE.metadata} {Ä: §} - Check Test Tags UTF-8 tag-§ tag-€ - Check Test Doc UTF-8 äöå §½€ - -UTF-8 In Keyword Arguments - ${tc} = Check Test Case UTF-8 - Check Log Message ${tc.setup.msgs[0]} äöå - Check Log Message ${tc.kws[0].msgs[0]} §½€ - Check Log Message ${tc.kws[1].msgs[0]} äöå §½€ - Check Log Message ${tc.kws[2].kws[0].msgs[0]} äöå - Check Log Message ${tc.kws[2].kws[1].msgs[0]} äöå §½€ - Check Log Message ${tc.kws[2].kws[2].msgs[0]} §½€ - -UTF-8 In Test Case And UK Names - ${tc} = Check Test Case UTF-8 Name Äöå §½€" - Check Keyword Data ${tc.kws[0]} Äöå §½€ \${ret} - Check Log Message ${tc.kws[1].msgs[0]} äöå §½€ - Check Log Message ${tc.kws[3].msgs[0]} value diff --git a/atest/robot/parsing/utf8_data/utf8_in_html5.robot b/atest/robot/parsing/utf8_data/utf8_in_html5.robot deleted file mode 100644 index a9fb31ea04e..00000000000 --- a/atest/robot/parsing/utf8_data/utf8_in_html5.robot +++ /dev/null @@ -1,25 +0,0 @@ -*** Settings *** -Suite Setup Run Tests ${EMPTY} parsing/utf8_data_in_html5.html -Resource atest_resource.robot - -*** Test Cases *** -UTF-8 In Metadata - Should Be Equal ${SUITE.doc} Testing that reading and writing of Unicode (äöå §½€ etc.) works properly. - Should Be Equal as Strings ${SUITE.metadata} {Ä: §} - Check Test Tags UTF-8 tag-§ tag-€ - Check Test Doc UTF-8 äöå §½€ - -UTF-8 In Keyword Arguments - ${tc} = Check Test Case UTF-8 - Check Log Message ${tc.setup.msgs[0]} äöå - Check Log Message ${tc.kws[0].msgs[0]} §½€ - Check Log Message ${tc.kws[1].msgs[0]} äöå §½€ - Check Log Message ${tc.kws[2].kws[0].msgs[0]} äöå - Check Log Message ${tc.kws[2].kws[1].msgs[0]} äöå §½€ - Check Log Message ${tc.kws[2].kws[2].msgs[0]} §½€ - -UTF-8 In Test Case And UK Names - ${tc} = Check Test Case UTF-8 Name Äöå §½€" - Check Keyword Data ${tc.kws[0]} Äöå §½€ \${ret} - Check Log Message ${tc.kws[1].msgs[0]} äöå §½€ - Check Log Message ${tc.kws[3].msgs[0]} value diff --git a/atest/robot/parsing/utf8_data/utf8_in_tsv.robot b/atest/robot/parsing/utf8_data/utf8_in_tsv.robot index 5cf83c7bd4a..8b63e3cd8cf 100644 --- a/atest/robot/parsing/utf8_data/utf8_in_tsv.robot +++ b/atest/robot/parsing/utf8_data/utf8_in_tsv.robot @@ -1,25 +1,25 @@ *** Settings *** -Suite Setup Run Tests ${EMPTY} parsing/utf8_data.tsv -Resource atest_resource.robot +Suite Setup Run Tests ${EMPTY} parsing/utf8_data.tsv +Resource atest_resource.robot *** Test Cases *** UTF-8 In Metadata - Should Be Equal ${SUITE.doc} Testing that reading and writing of Unicode (äöå §½€ etc.) works properly. - Should Be Equal as Strings ${SUITE.metadata} {Ä: §} - Check Test Tags UTF-8 tag-§ tag-€ - Check Test Doc UTF-8 äöå §½€ + Should Be Equal ${SUITE.doc} Testing that reading and writing of Unicode (äöå §½€ etc.) works properly. + Should Be Equal As Strings ${SUITE.metadata} {Ä: §} + Check Test Tags UTF-8 tag-§ tag-€ + Check Test Doc UTF-8 äöå §½€ UTF-8 In Keyword Arguments - ${tc} = Check Test Case UTF-8 - Check Log Message ${tc.setup.msgs[0]} äöå - Check Log Message ${tc.kws[0].msgs[0]} §½€ - Check Log Message ${tc.kws[1].msgs[0]} äöå §½€ - Check Log Message ${tc.kws[2].kws[0].msgs[0]} äöå - Check Log Message ${tc.kws[2].kws[1].msgs[0]} äöå §½€ - Check Log Message ${tc.kws[2].kws[2].msgs[0]} §½€ + ${tc} = Check Test Case UTF-8 + Check Log Message ${tc.setup[0]} äöå + Check Log Message ${tc[0, 0]} §½€ + Check Log Message ${tc[1, 0]} äöå §½€ + Check Log Message ${tc[2, 0, 0]} äöå + Check Log Message ${tc[2, 1, 0]} äöå §½€ + Check Log Message ${tc[2, 2, 0]} §½€ UTF-8 In Test Case And UK Names - ${tc} = Check Test Case UTF-8 Name Äöå §½€" - Check Keyword Data ${tc.kws[0]} Äöå §½€ \${ret} - Check Log Message ${tc.kws[1].msgs[0]} äöå §½€ - Check Log Message ${tc.kws[3].msgs[0]} value + ${tc} = Check Test Case UTF-8 Name Äöå §½€" + Check Keyword Data ${tc[0]} Äöå §½€ \${ret} + Check Log Message ${tc[1, 0]} äöå §½€ + Check Log Message ${tc[3, 0]} value diff --git a/atest/robot/parsing/utf8_data/utf8_in_txt.robot b/atest/robot/parsing/utf8_data/utf8_in_txt.robot index fca9e1856d3..c22049aa238 100644 --- a/atest/robot/parsing/utf8_data/utf8_in_txt.robot +++ b/atest/robot/parsing/utf8_data/utf8_in_txt.robot @@ -1,25 +1,25 @@ *** Settings *** -Suite Setup Run Tests ${EMPTY} parsing/utf8_data.robot -Resource atest_resource.robot +Suite Setup Run Tests ${EMPTY} parsing/utf8_data.robot +Resource atest_resource.robot *** Test Cases *** UTF-8 In Metadata - Should Be Equal ${SUITE.doc} Testing that reading and writing of Unicode (äöå §½€ etc.) works properly. - Should Be Equal as Strings ${SUITE.metadata} {Ä: §} - Check Test Tags UTF-8 tag-§ tag-€ - Check Test Doc UTF-8 äöå §½€ + Should Be Equal ${SUITE.doc} Testing that reading and writing of Unicode (äöå §½€ etc.) works properly. + Should Be Equal As Strings ${SUITE.metadata} {Ä: §} + Check Test Tags UTF-8 tag-§ tag-€ + Check Test Doc UTF-8 äöå §½€ UTF-8 In Keyword Arguments - ${tc} = Check Test Case UTF-8 - Check Log Message ${tc.setup.msgs[0]} äöå - Check Log Message ${tc.kws[0].msgs[0]} §½€ - Check Log Message ${tc.kws[1].msgs[0]} äöå §½€ - Check Log Message ${tc.kws[2].kws[0].msgs[0]} äöå - Check Log Message ${tc.kws[2].kws[1].msgs[0]} äöå §½€ - Check Log Message ${tc.kws[2].kws[2].msgs[0]} §½€ + ${tc} = Check Test Case UTF-8 + Check Log Message ${tc.setup[0]} äöå + Check Log Message ${tc[0, 0]} §½€ + Check Log Message ${tc[1, 0]} äöå §½€ + Check Log Message ${tc[2, 0, 0]} äöå + Check Log Message ${tc[2, 1, 0]} äöå §½€ + Check Log Message ${tc[2, 2, 0]} §½€ UTF-8 In Test Case And UK Names - ${tc} = Check Test Case UTF-8 Name Äöå §½€" - Check Keyword Data ${tc.kws[0]} Äöå §½€ \${ret} - Check Log Message ${tc.kws[1].msgs[0]} äöå §½€ - Check Log Message ${tc.kws[3].msgs[0]} value + ${tc} = Check Test Case UTF-8 Name Äöå §½€" + Check Keyword Data ${tc[0]} Äöå §½€ \${ret} + Check Log Message ${tc[1, 0]} äöå §½€ + Check Log Message ${tc[3, 0]} value diff --git a/atest/robot/rebot/combine.robot b/atest/robot/rebot/combine.robot index 2fb5f5917c2..f8aedbd16ad 100644 --- a/atest/robot/rebot/combine.robot +++ b/atest/robot/rebot/combine.robot @@ -80,66 +80,35 @@ Suite Metadata Should Be Equal ${SUITE2.metadata['Name']} value Should Be Equal ${SUITE2.metadata['Other Meta']} Another value -Earlier Criticality Should Be Ignored - ${tags} = Get Total Stat Nodes ${COMB OUT 1} - Tag Statistics Should Be ${tags[0]} 3 1 - Tag Statistics Should Be ${tags[1]} 3 1 - ${tags} = Get Total Stat Nodes ${COMB OUT 3} - Tag Statistics Should Be ${tags[0]} 9 1 - Tag Statistics Should Be ${tags[1]} 9 1 - -Critical/Non-Critical Tags Can Be Given - ${tags} = Get Total Stat Nodes ${COMB OUT 2} - Tag Statistics Should Be ${tags[0]} 1 1 - Tag Statistics Should Be ${tags[1]} 3 1 - ${tags} = Get Total Stat Nodes ${COMB OUT 4} - Tag Statistics Should Be ${tags[0]} 7 1 - Tag Statistics Should Be ${tags[1]} 9 1 - 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} - -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 + 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} @@ -167,12 +136,12 @@ Create inputs for Rebot Prevent accidental usage of ${SUITE} variable Create first input for Rebot - Create Output With Robot ${TEMP OUT 1} --critical pass misc/pass_and_fail.robot - Set Suite Variable $MILLIS1 ${ORIG ELAPSED} + Create Output With Robot ${TEMP OUT 1} ${EMPTY} misc/pass_and_fail.robot + Set Suite Variable $ELAPSED1 ${ORIG ELAPSED.total_seconds()} Create second input for Rebot - Create Output With Robot ${TEMP OUT 2} -c f1 -c nonex --noncritical notag misc/normal.robot - Set Suite Variable $MILLIS2 ${ORIG ELAPSED} + Create Output With Robot ${TEMP OUT 2} ${EMPTY} misc/normal.robot + Set Suite Variable $ELAPSED2 ${ORIG ELAPSED.total_seconds()} Combine without options Run Rebot ${EMPTY} ${TEMP OUT 1} ${TEMP OUT 2} @@ -185,7 +154,6 @@ Combine with options ... --doc "My fine doc" ... --metadata Name:value ... -M "Other Meta:Another value" - ... --critical force Run Rebot ${options} ${TEMP OUT 1} ${TEMP OUT 2} Set Suite Variable $SUITE2 ${SUITE} Copy File ${OUT FILE} ${COMB OUT 2} @@ -196,7 +164,7 @@ Combine with output with known times Set Suite Variable $SUITE3 ${SUITE} Recombine - Run Rebot --noncritical f1 rebot/times.xml ${COMB OUT 2} + Run Rebot ${EMPTY} rebot/times.xml ${COMB OUT 2} Set Suite Variable $SUITE4 ${SUITE} Copy File ${OUT FILE} ${COMB OUT 4} diff --git a/atest/robot/rebot/compatibility.robot b/atest/robot/rebot/compatibility.robot new file mode 100644 index 00000000000..4df330d0753 --- /dev/null +++ b/atest/robot/rebot/compatibility.robot @@ -0,0 +1,37 @@ +*** Settings *** +Documentation Test output.xml compatibility with old Robot versions and otherwise. +... +... RF version specific output.xml files have been generated by running +... ``./rundevel.py atest/testdata/misc/`` in appropriate version control tag. +Resource rebot_resource.robot + +*** Test Cases *** +RF 3.2 compatibility + Run Rebot And Validate Statistics rebot/output-3.2.2.xml 172 10 validate=False + +RF 4.0 compatibility + Run Rebot And Validate Statistics rebot/output-4.0.xml 172 10 + +RF 5.0 compatibility + Run Rebot And Validate Statistics rebot/output-5.0.xml 175 10 + +Suite only + Run Rebot And Validate Statistics rebot/suite_only.xml 179 10 3 + +Message directly under test + Run Rebot And Validate Statistics rebot/issue-3762.xml 1 0 + ${tc} = Check Test Case test A + Check Log Message ${tc[0]} Hi from test WARN + Check Log Message ${tc[1, 0]} Hi from keyword WARN + Check Log Message ${tc[2]} Hi from test again INFO + +*** Keywords *** +Run Rebot And Validate Statistics + [Arguments] ${path} ${passed} ${failed} ${skipped}=0 ${validate}=True + Run Rebot ${EMPTY} ${path} validate output=${validate} + ${total} ${passed} ${failed} ${skipped} = + ... Evaluate ${passed} + ${failed} + ${skipped}, ${passed}, ${failed}, ${skipped} + Should Be Equal ${SUITE.statistics.total} ${total} + Should Be Equal ${SUITE.statistics.passed} ${passed} + Should Be Equal ${SUITE.statistics.failed} ${failed} + Should Be Equal ${SUITE.statistics.skipped} ${skipped} diff --git a/atest/robot/rebot/filter_by_names.robot b/atest/robot/rebot/filter_by_names.robot index 744a06d4fda..971b8a36e7a 100644 --- a/atest/robot/rebot/filter_by_names.robot +++ b/atest/robot/rebot/filter_by_names.robot @@ -25,7 +25,7 @@ ${INPUT FILE} %{TEMPDIR}${/}robot-test-file.xml --test not matching Failing Rebot ... Suite 'Root' contains no tests matching name 'nonex'. - ... --test nonex ${INPUT FILE} + ... --test nonex --test not matching with multiple inputs Failing Rebot @@ -35,6 +35,18 @@ ${INPUT FILE} %{TEMPDIR}${/}robot-test-file.xml ... Suite 'My Name' contains no tests matching name 'nonex'. ... --test nonex -N "My Name" ${INPUT FILE} ${INPUT FILE} +--test and --include must both match + Run And Check Tests --test first --include t1 -i f1 First + Failing Rebot + ... Suite 'Root' contains no tests matching name 'fifth' and matching tag 't1'. + ... --test fifth --include t1 + +--exclude wins over --test + Run And Check Tests --test fi* --exclude t1 Fifth + Failing Rebot + ... Suite 'Root' contains no tests matching name 'first' and not matching tag 'f1'. + ... --test first --exclude f1 + --suite once Run And Check Suites --suite tsuite1 Tsuite1 @@ -51,10 +63,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 @@ -70,33 +83,50 @@ ${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 *2* 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 "subsuite3 second" -t *first -s nomatch -t nomatch --include f1 --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 - 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,22 +136,21 @@ 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 Should Contain Tests ${SUITE} @{tests} - Should Be True ${SUITE.statistics.all.passed} == len(@{tests}) + Should Be True ${SUITE.statistics.passed} == len(@{tests}) Check Stats Check Stats - Should Be True ${SUITE.statistics.all.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 True ${SUITE.statistics.failed} == 0 + 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} @@ -129,20 +158,12 @@ 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.all.passed} == len(@{tests}) - Check Stats - Run Suites [Arguments] ${options} Run Rebot ${options} ${INPUT FILE} Stderr Should Be Empty Failing Rebot - [Arguments] ${error} ${options} ${sources} + [Arguments] ${error} ${options} ${sources}=${INPUT FILE} Run Rebot Without Processing Output ${options} ${sources} Stderr Should Be Equal To [ ERROR ] ${error}${USAGE TIP}\n diff --git a/atest/robot/rebot/json_output_and_input.robot b/atest/robot/rebot/json_output_and_input.robot new file mode 100644 index 00000000000..e02631ce7f6 --- /dev/null +++ b/atest/robot/rebot/json_output_and_input.robot @@ -0,0 +1,66 @@ +*** Settings *** +Suite Setup Create XML and JSON outputs +Resource rebot_resource.robot + +*** Variables *** +${XML} %{TEMPDIR}/rebot.xml +${JSON} %{TEMPDIR}/rebot.json + +*** Test Cases *** +JSON output contains same suite information as XML output + Outputs Should Contain Same Data ${JSON} ${XML} + +JSON output structure + [Documentation] JSON schema validation would be good, but it's too slow with big output files. + ... The test after this one validates a smaller suite against a schema. + ${data} = Evaluate json.load(open($JSON, encoding='UTF-8')) + Lists Should Be Equal ${data} ${{['generator', 'generated', 'rpa', 'suite', 'statistics', 'errors']}} + Should Match ${data}[generator] Rebot ?.* (* on *) + Should Match ${data}[generated] 20??-??-??T??:??:??.?????? + Should Be Equal ${data}[rpa] ${False} + Should Be Equal ${data}[suite][name] Misc + Should Be Equal ${data}[suite][suites][2][name] Everything + Should Be Equal ${data}[statistics][total][skip] ${3} + Should Be Equal ${data}[statistics][tags][4][label] f1 + Should Be Equal ${data}[statistics][suites][-1][id] s1-s18 + Should Be Equal ${data}[errors][0][level] ERROR + +JSON output schema validation + [Tags] require-jsonschema + Run Rebot Without Processing Output --suite Everything --output %{TEMPDIR}/everything.json ${JSON} + Validate JSON Output %{TEMPDIR}/everything.json + +JSON input + Run Rebot ${EMPTY} ${JSON} + Outputs Should Contain Same Data ${JSON} ${OUTFILE} + +JSON input combined + Run Rebot ${EMPTY} ${XML} ${XML} + Copy Previous Outfile # Expected result + Run Rebot ${EMPTY} ${JSON} ${XML} + Outputs Should Contain Same Data ${OUTFILE} ${OUTFILE COPY} + Run Rebot ${EMPTY} ${JSON} ${JSON} + Outputs Should Contain Same Data ${OUTFILE} ${OUTFILE COPY} + +Invalid JSON input + Create File ${JSON} bad + Run Rebot Without Processing Output ${EMPTY} ${JSON} + ${json} = Normalize Path ${JSON} + VAR ${error} + ... Reading JSON source '${json}' failed: + ... Loading JSON data failed: + ... Invalid JSON data: * + Stderr Should Match [[] ERROR ] ${error}${USAGE TIP}\n + +Non-existing JSON input + Run Rebot Without Processing Output ${EMPTY} non_existing.json + ${json} = Normalize Path ${DATADIR}/non_existing.json + VAR ${error} + ... Reading JSON source '${json}' failed: + ... No such file or directory + Stderr Should Match [[] ERROR ] ${error}${USAGE TIP}\n + +*** Keywords *** +Create XML and JSON outputs + Create Output With Robot ${XML} ${EMPTY} misc + Run Rebot Without Processing Output --output ${JSON} ${XML} diff --git a/atest/robot/rebot/merge.robot b/atest/robot/rebot/merge.robot index 8a44eacd705..b2539d6214a 100644 --- a/atest/robot/rebot/merge.robot +++ b/atest/robot/rebot/merge.robot @@ -12,12 +12,14 @@ ${MERGE 1} %{TEMPDIR}/merge-1.xml ${MERGE 2} %{TEMPDIR}/merge-2.xml @{ALL TESTS} Suite4 First SubSuite1 First SubSuite2 First ... Test From Sub Suite 4 SubSuite3 First SubSuite3 Second -... Suite1 First Suite1 Second Third In Suite1 +... 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 @@ -27,11 +29,23 @@ Merge re-executed tests Run merge Test merge should have been successful +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 + +Suite elapsed time should be updated + [Setup] Should Be Equal ${PREV_TEST_STATUS} PASS + Should Be True $SUITE.elapsed_time > $ORIGINAL_ELAPSED + Merge re-executed and re-re-executed tests Re-run tests Re-re-run tests Run multi-merge - ${message} = Create expected multi-merge message html marker=${EMPTY} + ${message} = Create expected multi-merge message Test merge should have been successful status 2=FAIL message 2=${message} Add new tests @@ -60,29 +74,58 @@ 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 + Create Output With Robot ${MERGE1} --skip NOTskip rebot/merge_statuses.robot + Run Merge + ${prefix} = Catenate + ... *HTML* Test has been re-executed and results merged. + ... Latter result had SKIP status and was ignored. Message: + Should Contain Tests ${SUITE} + ... Pass=PASS:${prefix}\nTest skipped using 'NOT skip' tag pattern. + ... Fail=FAIL:${prefix}\nTest skipped using 'NOT skip' tag pattern.
Original message:\nNot <b>HTML</b> fail + ... Skip=SKIP:${prefix}\nHTML skip
Original message:\nHTML 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 + VAR ${ORIGINAL ELAPSED} ${SUITE.elapsed_time} scope=SUITE Verify original tests Should Be Equal ${SUITE.name} Suites Should Contain Suites ${SUITE} @{ALL SUITES} - Should Contain Suites ${SUITE.suites[1]} @{SUB SUITES 1} - Should Contain Suites ${SUITE.suites[2]} @{SUB SUITES 2} + Should Contain Suites ${SUITE.suites[2]} @{SUB SUITES 1} + Should Contain Suites ${SUITE.suites[3]} @{SUB SUITES 2} Should Contain Tests ${SUITE} @{ALL TESTS} ... SubSuite1 First=FAIL:This test was doomed to fail: YES != NO Re-run tests [Arguments] ${options}= - Create Output With Robot ${MERGE 1} --rerunfailed ${ORIGINAL} ${options} ${SUITES} + ${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 + ... --variable TEARDOWN_MSG:New! # -- ;; -- + ... --variable SETUP:NONE # Affects misc/suites/subsuites/sub1.robot + ... --variable TEARDOWN:NONE # -- ;; -- + ... --variable SLEEP:0.5 # -- ;; -- + ... --rerunfailed ${ORIGINAL} ${options} + Create Output With Robot ${MERGE 1} ${options} ${SUITES} Should Be Equal ${SUITE.name} Suites Should Contain Suites ${SUITE} @{RERUN SUITES} - Should Contain Suites ${SUITE.suites[1]} @{SUB SUITES 1}[0] + Should Contain Suites ${SUITE.suites[1]} ${SUB SUITES 1}[0] Should Contain Tests ${SUITE} @{RERUN TESTS} Re-re-run tests @@ -114,8 +157,8 @@ Test merge should have been successful ... ${status 2}=PASS ${message 2}= Should Be Equal ${SUITE.name} ${suite name} Should Contain Suites ${SUITE} @{ALL SUITES} - Should Contain Suites ${SUITE.suites[1]} @{SUB SUITES 1} - Should Contain Suites ${SUITE.suites[2]} @{SUB SUITES 2} + Should Contain Suites ${SUITE.suites[2]} @{SUB SUITES 1} + Should Contain Suites ${SUITE.suites[3]} @{SUB SUITES 2} ${message 1} = Create expected merge message ${message 1} ... FAIL Expected FAIL Expected ${message 2} = Create expected merge message ${message 2} @@ -125,23 +168,38 @@ Test merge should have been successful ... SubSuite1 First=${status 2}:${message 2} Timestamps should be cleared ... ${SUITE} - ... ${SUITE.suites[0]} ... ${SUITE.suites[1]} - ... ${SUITE.suites[1].suites[0]} - Timestamps should be set - ... ${SUITE.suites[1].suites[1]} ... ${SUITE.suites[2]} ... ${SUITE.suites[2].suites[0]} + Timestamps should be set ... ${SUITE.suites[2].suites[1]} ... ${SUITE.suites[3]} + ... ${SUITE.suites[3].suites[0]} + ... ${SUITE.suites[3].suites[1]} ... ${SUITE.suites[4]} - ... ${SUITE.suites[5]} + ... ${SUITE.suites[6]} + ... ${SUITE.suites[7]} + +Suite setup and teardown should have been merged + 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[0]} Rerun! + Check Log Message ${SUITE.suites[1].teardown[0]} New! + Should Be Equal ${SUITE.suites[2].suites[0].name} Sub1 + 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} - Should Contain Suites ${SUITE.suites[1]} @{SUB SUITES 1} - Should Contain Suites ${SUITE.suites[2]} @{SUB SUITES 2} + Should Contain Suites ${SUITE.suites[2]} @{SUB SUITES 1} + Should Contain Suites ${SUITE.suites[3]} @{SUB SUITES 2} Should Contain Tests ${SUITE} @{ALL TESTS} ... SubSuite1 First=FAIL:This test was doomed to fail: YES != NO ... Pass=PASS:*HTML* Test added from merged output. @@ -149,49 +207,49 @@ Test add should have been successful Timestamps should be cleared ... ${SUITE} Timestamps should be set - ... ${SUITE.suites[0]} ... ${SUITE.suites[1]} - ... ${SUITE.suites[1].suites[0]} - ... ${SUITE.suites[1].suites[1]} ... ${SUITE.suites[2]} ... ${SUITE.suites[2].suites[0]} ... ${SUITE.suites[2].suites[1]} ... ${SUITE.suites[3]} + ... ${SUITE.suites[3].suites[0]} + ... ${SUITE.suites[3].suites[1]} ... ${SUITE.suites[4]} - ... ${SUITE.suites[5]} + ... ${SUITE.suites[6]} + ... ${SUITE.suites[7]} Suite add should have been successful Should Be Equal ${SUITE.name} Suites Should Contain Suites ${SUITE} @{ALL SUITES} Pass And Fail - Should Contain Suites ${SUITE.suites[1]} @{SUB SUITES 1} - Should Contain Suites ${SUITE.suites[2]} @{SUB SUITES 2} + Should Contain Suites ${SUITE.suites[2]} @{SUB SUITES 1} + Should Contain Suites ${SUITE.suites[3]} @{SUB SUITES 2} Should Contain Tests ${SUITE} @{ALL TESTS} ... Pass Fail ... SubSuite1 First=FAIL:This test was doomed to fail: YES != NO - Should Be Equal ${SUITE.suites[6].name} Pass And Fail - Should Contain Tests ${SUITE.suites[6]} Pass Fail - Should Be Equal ${SUITE.suites[6].message} *HTML* Suite added from merged output. + Should Be Equal ${SUITE.suites[8].name} Pass And Fail + Should Contain Tests ${SUITE.suites[8]} Pass Fail + Should Be Equal ${SUITE.suites[8].message} *HTML* Suite added from merged output. Timestamps should be cleared ... ${SUITE} Timestamps should be set - ... ${SUITE.suites[0]} ... ${SUITE.suites[1]} - ... ${SUITE.suites[1].suites[0]} - ... ${SUITE.suites[1].suites[1]} ... ${SUITE.suites[2]} ... ${SUITE.suites[2].suites[0]} ... ${SUITE.suites[2].suites[1]} ... ${SUITE.suites[3]} + ... ${SUITE.suites[3].suites[0]} + ... ${SUITE.suites[3].suites[1]} ... ${SUITE.suites[4]} - ... ${SUITE.suites[5]} ... ${SUITE.suites[6]} + ... ${SUITE.suites[7]} + ... ${SUITE.suites[8]} Warnings should have been merged Length Should Be ${ERRORS} 2 Check Log Message ${ERRORS[0]} Original message WARN Check Log Message ${ERRORS[1]} Override WARN ${tc} = Check Test Case SubSuite1 First - Check Log Message ${tc.kws[0].msgs[0]} Override WARN + Check Log Message ${tc[0, 0]} Override WARN Merge should have failed Stderr Should Be Equal To @@ -200,39 +258,70 @@ 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} + FOR ${suite} IN @{suites} + 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} + FOR ${suite} IN @{suites} + Timestamp Should Be Valid ${suite.start_time} + Timestamp Should Be Valid ${suite.end_time} + END + +Create expected merge message header + [Arguments] ${html marker}=*HTML*${SPACE} + Run Keyword And Return Catenate SEPARATOR= + ... ${html marker}Test has been re-executed and results merged.
+ +Create expected merge old message body + [Arguments] ${old status} ${old message} + ${old status} = Set Variable If '${old status}' == 'PASS' + ... PASS FAIL + ${old message} = Set Variable If '${old message}' != '' + ... ${old message}
${EMPTY} + ${old message html achor} = Set Variable If '${old message}' != '' + ... Old message:${SPACE} ${EMPTY} + Run Keyword And Return Catenate SEPARATOR= + ... Old status: ${old status}
+ ... ${old message html achor}${old message} + +Create expected merge message body + [Arguments] ${new status} ${new message} ${old status} ${old message} + ${new status} = Set Variable If '${new status}' == 'PASS' + ... PASS FAIL + ${new message html achor} = Set Variable If '${new message}' != '' + ... New message:${SPACE} ${EMPTY} + ${new message} = Set Variable If '${new message}' != '' + ... ${new message}
${EMPTY} + ${old message} = Create expected merge old message body ${old status} ${old message} + Run Keyword And Return Catenate SEPARATOR= + ... New status: ${new status}
+ ... ${new message html achor}${new message} + ...
${old message} Create expected merge message [Arguments] ${message} ${new status} ${new message} ${old status} ${old message} ${html marker}=*HTML*${SPACE} Return From Keyword If """${message}""" ${message} - ${new status} = Set Variable If '${new status}' == 'PASS' - ... PASS FAIL - ${old status} = Set Variable If '${old status}' == 'PASS' - ... PASS FAIL + ${merge header} = Create expected merge message header html marker=${html marker} + ${merge body} = Create expected merge message body ${new status} ${new message} ${old status} ${old message} Run Keyword And Return Catenate SEPARATOR= - ... ${html marker}Re-executed test has been merged. - ...
New status: ${new status} - ...
New message: ${new message} - ...
Old status: ${old status} - ...
Old message: ${old message} + ... ${merge header} + ... ${merge body} Create expected multi-merge message [Arguments] ${html marker}=*HTML*${SPACE} - ${message} = Create expected merge message ${EMPTY} - ... PASS ${EMPTY} FAIL This test was doomed to fail: YES != NO html marker=${html marker} - ${message} = Create expected merge message ${EMPTY} - ... FAIL This test was doomed to fail: again != NO PASS ${message} - [Return] ${message} + ${header} = Create expected merge message header html marker=${html marker} + ${message 1} = Create expected merge message body + ... FAIL This test was doomed to fail: again != NO PASS ${EMPTY} + ${message 2} = Create expected merge old message body + ... FAIL This test was doomed to fail: YES != NO + Run Keyword And Return Catenate SEPARATOR= + ... ${header} + ... ${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

Keyword content flattened.\\x3c/b>\\x3c/i>\\x3c/p>" + Should Contain ${log} "*Content flattened." diff --git a/atest/robot/rebot/merge_html.robot b/atest/robot/rebot/merge_html.robot index cb0d7d7d6cb..2ca989bec82 100644 --- a/atest/robot/rebot/merge_html.robot +++ b/atest/robot/rebot/merge_html.robot @@ -3,6 +3,9 @@ Test Teardown Remove Files ${MERGE} Suite Teardown Remove Files ${ORIGINAL} Resource rebot_resource.robot +*** Variables *** +${MERGE HEADER} Test has been re-executed and results merged. + *** Variables *** ${DATA} ${DATADIR}/rebot/merge_html.robot ${ORIGINAL} %{TEMPDIR}/merge-original.xml @@ -11,45 +14,53 @@ ${MERGE} %{TEMPDIR}/merge.xml ${RUN_MSG_TEXT} Test message ${RUN_MSG_HTML} *HTML* Test message ${MERGE_MSG_TEXT_TEXT} SEPARATOR= -... *HTML* Re-executed test has been merged. +... *HTML* ${MERGE HEADER} ...


-... New status: FAIL +... New status: FAIL +...
+... New message: Test message ...
-... New message: Test message ...
-... Old status: FAIL +... Old status: FAIL +...
+... Old message: Test message ...
-... Old message: Test message ${MERGE_MSG_HTML_HTML} SEPARATOR= -... *HTML* Re-executed test has been merged. +... *HTML* ${MERGE HEADER} ...
-... New status: FAIL +... New status: FAIL +...
+... New message: Test message ...
-... New message: Test message ...
-... Old status: FAIL +... Old status: FAIL +...
+... Old message: Test message ...
-... Old message: Test message ${MERGE_MSG_TEXT_HTML} SEPARATOR= -... *HTML* Re-executed test has been merged. +... *HTML* ${MERGE HEADER} ...
-... New status: FAIL +... New status: FAIL +...
+... New message: Test message ...
-... New message: Test message ...
-... Old status: FAIL +... Old status: FAIL +...
+... Old message: Test message ...
-... Old message: Test message ${MERGE_MSG_HTML_TEXT} SEPARATOR= -... *HTML* Re-executed test has been merged. +... *HTML* ${MERGE HEADER} ...
-... New status: FAIL +... New status: FAIL +...
+... New message: Test message ...
-... New message: Test message ...
-... Old status: FAIL +... Old status: FAIL +...
+... Old message: Test message ...
-... Old message: Test message *** Test Cases *** Merge re-executed tests diff --git a/atest/robot/rebot/output_file.robot b/atest/robot/rebot/output_file.robot index 98a26dc60fa..700578fc31e 100644 --- a/atest/robot/rebot/output_file.robot +++ b/atest/robot/rebot/output_file.robot @@ -9,15 +9,23 @@ Output file content should be same with Robot and Rebot *** Keywords *** Generate output with Robot - Run tests -L TRACE -c fail misc/pass_and_fail.robot + ${inputs} = Catenate + ... misc/pass_and_fail.robot + ... misc/for_loops.robot + ... misc/if_else.robot + ... misc/try_except.robot + ... misc/while.robot + ... misc/warnings_and_errors.robot + ... keywords/embedded_arguments.robot + Run tests -L TRACE ${inputs} Run keyword and return Parse output file Generate output with Rebot Copy Previous Outfile - Run rebot -c fail ${OUTFILE COPY} + Run rebot ${EMPTY} ${OUTFILE COPY} Run keyword and return Parse output file Parse output file ${root} = Parse XML ${OUTFILE} Remove element attributes ${root} - [Return] ${root} + RETURN ${root} diff --git a/atest/robot/rebot/start_and_endtime_from_cli.robot b/atest/robot/rebot/start_and_endtime_from_cli.robot index 6f2f424f7a3..b22cdc84d2b 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 + 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)} -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/atest/robot/rebot/xunit.robot b/atest/robot/rebot/xunit.robot index 0dbebb34678..81567c6960e 100644 --- a/atest/robot/rebot/xunit.robot +++ b/atest/robot/rebot/xunit.robot @@ -14,40 +14,68 @@ ${INVALID} %{TEMPDIR}${/}ïnvälïd-xünït.xml No XUnit Option Given Run Rebot ${EMPTY} ${INPUT FILE} Stderr Should Be Empty - Check Stdout Does Not Contain XUnit + Stdout Should Not Contain XUnit XUnit Option Given Run Rebot --xunit xunit.xml --log log.html ${INPUT FILE} Stderr Should Be Empty - Check Stdout Contains XUnit: + Stdout Should Contain XUnit: File Should Exist ${OUTDIR}/xunit.xml File Should Exist ${OUTDIR}/log.html ${root} = Parse XML ${OUTDIR}/xunit.xml Should Be Equal ${root.tag} testsuite - ${tests} = Get Elements ${root} testcase - Length Should Be ${tests} 19 - Should Be Equal ${tests[7].attrib['name']} Ñöñ-ÄŚÇÏÏ Tëśt äņd Këywörd Nämës, Спасибо - ${failures} = Get Elements ${root} testcase/failure - Length Should Be ${failures} 5 - Should Be Equal ${failures[0].attrib['message']} ${MESSAGES} + ${suites} = Get Elements ${root} testsuite + Length Should Be ${suites} 2 + ${tests} = Get Elements ${suites}[0] testcase + Length Should Be ${tests} 8 + Element Attribute Should be ${tests}[7] name Ñöñ-ÄŚÇÏÏ Tëśt äņd Këywörd Nämës, Спасибо + ${failures} = Get Elements ${suites}[0] testcase/failure + Length Should Be ${failures} 4 + Element Attribute Should be ${failures}[0] message ${MESSAGES} + ${properties} = Get Elements ${suites}[1] testsuite[6]/properties/property + Length Should Be ${properties} 2 + Element Attribute Should be ${properties}[0] name Documentation + Element Attribute Should be ${properties}[0] value Normal test cases + +Suite Stats + [Template] Suite Stats Should Be + 21 5 + 8 4 xpath=testsuite[1] + 13 1 xpath=testsuite[2] + 1 1 xpath=testsuite[2]/testsuite[2] + 2 0 xpath=testsuite[2]/testsuite[3] + 1 0 xpath=testsuite[2]/testsuite[3]/testsuite[1] + 1 0 xpath=testsuite[2]/testsuite[3]/testsuite[2] + 3 0 xpath=testsuite[2]/testsuite[4] + 1 0 xpath=testsuite[2]/testsuite[4]/testsuite[1] + 2 0 xpath=testsuite[2]/testsuite[4]/testsuite[2] + 3 0 xpath=testsuite[2]/testsuite[6] + 1 0 xpath=testsuite[2]/testsuite[7] + 1 0 xpath=testsuite[2]/testsuite[8] Times in xUnit output - Previous Test Should Have Passed XUnit Option Given + Previous Test Should Have Passed Suite Stats ${suite} = Parse XML ${OUTDIR}/xunit.xml Element Attribute Should Match ${suite} time ?.??? - Element Attribute Should Match ${suite} time ?.??? xpath=.//testcase[1] + Element Attribute Should Match ${suite} time ?.??? xpath=testsuite[1] + Element Attribute Should Match ${suite} time ?.??? xpath=testsuite[1]/testcase[2] + Element Attribute Should Match ${suite} time ?.??? xpath=testsuite[2]/testsuite[2]/testcase[1] -XUnit skip non-criticals - Run Rebot --xUnit xunit.xml --xUnitSkipNonCritical --NonCritical f1 ${INPUT FILE} - Stderr Should Be Empty - ${root} = Parse XML ${OUTDIR}/xunit.xml - Element Attribute Should Be ${root} tests 19 - Element Attribute Should Be ${root} failures 4 - Element Attribute Should Be ${root} skipped 10 - ${skipped} = Get Elements ${root} xpath=testcase/skipped - Should Be Equal ${skipped[0].text} FAIL: Expected - Should Be Equal ${skipped[1].text} PASS - Length Should Be ${skipped} 10 +Suite Properties + [Template] Suite Properties Should Be + 0 + 0 xpath=testsuite[1] + 0 xpath=testsuite[2] + 2 xpath=testsuite[2]/testsuite[2] + 0 xpath=testsuite[2]/testsuite[3] + 2 xpath=testsuite[2]/testsuite[3]/testsuite[1] + 2 xpath=testsuite[2]/testsuite[3]/testsuite[2] + 0 xpath=testsuite[2]/testsuite[4] + 0 xpath=testsuite[2]/testsuite[4]/testsuite[1] + 2 xpath=testsuite[2]/testsuite[4]/testsuite[2] + 2 xpath=testsuite[2]/testsuite[6] + 2 xpath=testsuite[2]/testsuite[7] + 2 xpath=testsuite[2]/testsuite[8] Invalid XUnit File Create Directory ${INVALID} @@ -55,7 +83,46 @@ Invalid XUnit File File Should Not Exist ${INVALID} File Should Exist ${OUTDIR}/log.html ${path} = Regexp Escape ${INVALID} - Check Stderr Matches Regexp \\[ ERROR \\] Writing xunit file '${path}' failed: .* + Stderr Should Match Regexp + ... \\[ ERROR \\] Opening xunit file '${path}' failed: .* + +Merge outputs + Run Rebot -x xunit.xml ${INPUT FILE} ${INPUT FILE} + Suite Stats Should Be 42 10 0 timestamp=${EMPTY} + +Merged Suite properties + [Template] Suite Properties Should Be + 0 + 0 xpath=testsuite[1] + 0 xpath=testsuite[1]/testsuite[1] + 0 xpath=testsuite[1]/testsuite[2] + 2 xpath=testsuite[1]/testsuite[2]/testsuite[2] + 0 xpath=testsuite[1]/testsuite[2]/testsuite[3] + 2 xpath=testsuite[1]/testsuite[2]/testsuite[3]/testsuite[1] + 2 xpath=testsuite[1]/testsuite[2]/testsuite[3]/testsuite[2] + 0 xpath=testsuite[1]/testsuite[2]/testsuite[4] + 0 xpath=testsuite[1]/testsuite[2]/testsuite[4]/testsuite[1] + 2 xpath=testsuite[1]/testsuite[2]/testsuite[4]/testsuite[2] + 2 xpath=testsuite[1]/testsuite[2]/testsuite[6] + 2 xpath=testsuite[1]/testsuite[2]/testsuite[7] + 2 xpath=testsuite[1]/testsuite[2]/testsuite[8] + 0 xpath=testsuite[2] + 0 xpath=testsuite[2]/testsuite[1] + 0 xpath=testsuite[2]/testsuite[2] + 2 xpath=testsuite[2]/testsuite[2]/testsuite[2] + 0 xpath=testsuite[2]/testsuite[2]/testsuite[3] + 2 xpath=testsuite[2]/testsuite[2]/testsuite[3]/testsuite[1] + 2 xpath=testsuite[2]/testsuite[2]/testsuite[3]/testsuite[2] + 0 xpath=testsuite[2]/testsuite[2]/testsuite[4] + 0 xpath=testsuite[2]/testsuite[2]/testsuite[4]/testsuite[1] + 2 xpath=testsuite[2]/testsuite[2]/testsuite[4]/testsuite[2] + 2 xpath=testsuite[2]/testsuite[2]/testsuite[6] + 2 xpath=testsuite[2]/testsuite[2]/testsuite[7] + 2 xpath=testsuite[2]/testsuite[2]/testsuite[8] + +Start and end time + Run Rebot -x xunit.xml --starttime 20211215-12:11:10.456 --endtime 20211215-12:13:10.556 ${INPUT FILE} + Suite Stats Should Be 21 5 0 120.100 2021-12-15T12:11:10.456000 *** Keywords *** Create Input File @@ -65,3 +132,27 @@ Create Input File Remove Temps Remove Directory ${MYOUTDIR} recursive Remove File ${INPUT FILE} + +Suite Stats Should Be + [Arguments] ${tests} ${failures} ${skipped}=0 + ... ${time}=?.??? ${timestamp}=20??-??-??T??:??:??.?????? + ... ${xpath}=. + ${suite} = Get Element ${OUTDIR}/xunit.xml xpath=${xpath} + Element Attribute Should Be ${suite} tests ${tests} + Element Attribute Should Be ${suite} failures ${failures} + Element Attribute Should Be ${suite} skipped ${skipped} + Element Attribute Should Be ${suite} errors 0 + Element Attribute Should Match ${suite} time ${time} + Element Attribute Should Match ${suite} timestamp ${timestamp} + +Suite Properties Should Be + [Arguments] ${property_count} ${xpath}=. + ${suite} = Get Element ${OUTDIR}/xunit.xml xpath=${xpath} + ${properties_element} = Get Elements ${suite} properties + IF ${property_count} + Length Should Be ${properties_element} 1 + ${property_elements} = Get Elements ${properties_element}[0] property + Length Should Be ${property_elements} ${property_count} + ELSE + Length Should Be ${properties_element} 0 + END diff --git a/atest/robot/rpa/rebot_rpa_tasks.robot b/atest/robot/rpa/rebot_rpa_tasks.robot index 07c865e10c2..59d67679eac 100644 --- a/atest/robot/rpa/rebot_rpa_tasks.robot +++ b/atest/robot/rpa/rebot_rpa_tasks.robot @@ -15,13 +15,15 @@ Rebot tasks Combine tasks ${EMPTY} ${TASKS 1} ${TASKS 2} Task Failing Passing -Merge tasks - --merge ${TASKS 2} ${TASKS 1} Failing Passing Task=PASS:*HTML* Test added from merged output. - Rebot tests with --rpa --rpa ${TESTS} Test --RPA ${TESTS} ${TESTS} Test Test - --rpa --merge ${TESTS} ${TESTS} Test=PASS:*HTML* Re-executed test has been merged.
New status: PASS
New message:
Old status: PASS
Old message:${SPACE} + +Merge tasks + --merge ${TASKS 2} ${TASKS 1} Failing Passing Task=PASS:*HTML* Task added from merged output. + +Merge tasks so that results are merged + --rpa --merge ${TESTS} ${TESTS} Test=PASS:*HTML* Task has been re-executed and results merged.
New status: PASS

Old status: PASS
Rebot tasks with --norpa [Template] Rebot and validate test cases @@ -36,7 +38,7 @@ Conflicting output files cause error Conflicking output files with --rpa are fine --rpa ${TESTS} ${TASKS 1} Test Task - --RPA --merge ${TESTS} ${TASKS 1} Test Task=PASS:*HTML* Test added from merged output. + --RPA --merge ${TESTS} ${TASKS 1} Test Task=PASS:*HTML* Task added from merged output. Conflicting output files with --norpa are fine [Template] Rebot and validate test cases @@ -89,11 +91,8 @@ Outputs should contain correct mode information [Arguments] ${rpa} ${title} = Set variable if "${rpa}" == "false" Test Task Element attribute should be ${OUTDIR}/output.xml rpa ${rpa} - Element text should be ${OUTDIR}/output.xml Critical ${title}s xpath=statistics/total/stat[1] - Element text should be ${OUTDIR}/output.xml All ${title}s xpath=statistics/total/stat[2] + Element text should be ${OUTDIR}/output.xml All ${title}s xpath=statistics/total/stat[1] File should contain regexp ${OUTDIR}/log.html window\\.settings = \\{.*"rpa":${rpa},.*\\}; File should contain regexp ${OUTDIR}/report.html window\\.settings = \\{.*"rpa":${rpa},.*\\}; - File should contain regexp ${OUTDIR}/log.html window\\.output\\["stats"\\] = \\[\\[\\{.*"label":"Critical ${title}s",.*\\}\\]\\]; - File should contain regexp ${OUTDIR}/report.html window\\.output\\["stats"\\] = \\[\\[\\{.*"label":"Critical ${title}s",.*\\}\\]\\]; File should contain regexp ${OUTDIR}/log.html window\\.output\\["stats"\\] = \\[\\[\\{.*"label":"All ${title}s",.*\\}\\]\\]; File should contain regexp ${OUTDIR}/report.html window\\.output\\["stats"\\] = \\[\\[\\{.*"label":"All ${title}s",.*\\}\\]\\]; diff --git a/atest/robot/rpa/run_rpa_tasks.robot b/atest/robot/rpa/run_rpa_tasks.robot index 739a5462ac4..b2d9b8a762e 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,31 +16,36 @@ 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.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 + ... ${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 -v RPA:False rpa/ @{ALL TASKS} Conflicting headers in same file cause error [Documentation] Using --rpa or --norpa doesn't affect the behavior. [Template] NONE - Run tests without processing output --rpa %{TEMPDIR}/rpa/tasks_and_tests.robot + ${result}= Run tests without processing output --rpa %{TEMPDIR}/rpa/tasks_and_tests.robot + Should be equal ${result.rc} ${252} ${path} = Normalize path %{TEMPDIR}/rpa/tasks_and_tests.robot ${message} = Catenate ... [ ERROR ] Parsing '${path}' failed: @@ -49,20 +54,33 @@ Conflicting headers in same file cause error Conflicting headers in same file cause error when executing directory [Template] NONE - Run tests ${EMPTY} %{TEMPDIR}/rpa/ - Should contain tests ${SUITE} Task + ${result}= Run tests without processing output ${EMPTY} %{TEMPDIR}/rpa/ + Should be equal ${result.rc} ${252} ${path} = Normalize path %{TEMPDIR}/rpa/tasks_and_tests.robot - Check log message ${ERRORS[0]} Parsing '${path}' failed: One file cannot have both tests and tasks. ERROR + ${message} = Catenate + ... [ ERROR ] Parsing '${path}' failed: + ... One file cannot have both tests and tasks. + Stderr Should Be Equal To ${message}${USAGE TIP}\n --task as alias for --test --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 + Run Tests --rpa --variable TEST_OR_TASK:Task core/empty_testcase_and_uk.robot + Check Test Case ${EMPTY} + Check Test Case Empty Test Case *** Keywords *** Run and validate RPA tasks @@ -78,34 +96,31 @@ Run and validate test cases Should contain tests ${SUITE} @{tasks} Run and validate conflict - [Arguments] ${paths} ${conflicting} ${this} ${that} - Run tests without processing output ${EMPTY} ${paths} - ${conflicting} = Normalize path ${DATADIR}/${conflicting} - ${message} = Catenate - ... [ ERROR ] Conflicting execution modes. - ... File '${conflicting}' has ${this} but files parsed earlier have ${that}. - ... Fix headers or use '--rpa' or '--norpa' options to set the execution mode explicitly. - Stderr Should Be Equal To ${message}${USAGE TIP}\n + [Arguments] ${paths} ${suite1} ${suite2} ${mode1} ${mode2} @{extra errors} + Run tests without processing output --name Rpa ${paths} + ${extra} = Catenate @{extra errors} + ${error} = Catenate + ... [[] 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} ${title} = Set variable if "${rpa}" == "false" Test Task ${lower} = Set variable if "${rpa}" == "false" test task Element attribute should be ${OUTDIR}/output.xml rpa ${rpa} - Element text should be ${OUTDIR}/output.xml Critical ${title}s xpath=statistics/total/stat[1] - Element text should be ${OUTDIR}/output.xml All ${title}s xpath=statistics/total/stat[2] + Element text should be ${OUTDIR}/output.xml All ${title}s xpath=statistics/total/stat[1] File should contain regexp ${OUTDIR}/log.html window\\.settings = \\{.*"rpa":${rpa},.*\\}; File should contain regexp ${OUTDIR}/report.html window\\.settings = \\{.*"rpa":${rpa},.*\\}; - File should contain regexp ${OUTDIR}/log.html window\\.output\\["stats"\\] = \\[\\[\\{.*"label":"Critical ${title}s",.*\\}\\]\\]; - File should contain regexp ${OUTDIR}/report.html window\\.output\\["stats"\\] = \\[\\[\\{.*"label":"Critical ${title}s",.*\\}\\]\\]; File should contain regexp ${OUTDIR}/log.html window\\.output\\["stats"\\] = \\[\\[\\{.*"label":"All ${title}s",.*\\}\\]\\]; File should contain regexp ${OUTDIR}/report.html window\\.output\\["stats"\\] = \\[\\[\\{.*"label":"All ${title}s",.*\\}\\]\\]; - Check Stdout Contains Regexp \\d+ critical ${lower}s?, \\d+ passed, \\d+ failed\n\\d+ ${lower}s? total, \\d+ passed, \\d+ failed\n + Stdout Should Contain Regexp \\d+ ${lower}s?, \\d+ passed, \\d+ failed\n Initialize tests and tasks data Create directory ${TEMPDIR}/rpa diff --git a/atest/robot/rpa/task_aliases.robot b/atest/robot/rpa/task_aliases.robot new file mode 100644 index 00000000000..533eab1baa1 --- /dev/null +++ b/atest/robot/rpa/task_aliases.robot @@ -0,0 +1,68 @@ +*** Settings *** +Suite Setup Run Tests --loglevel DEBUG rpa/task_aliases.robot +Resource atest_resource.robot + +*** Test Cases *** +Defaults + ${tc} = Check Test Tags ${TESTNAME} task tags + Check timeout message ${tc.setup[0]} 1 minute 10 seconds + Check log message ${tc.setup[1]} Setup has an alias! + Check timeout message ${tc[0, 0]} 1 minute 10 seconds + Check log message ${tc[0, 1]} Using default settings + Check log message ${tc.teardown[0]} Also teardown has an alias!! + Should be equal ${tc.timeout} 1 minute 10 seconds + +Override + ${tc} = Check Test Tags ${TESTNAME} task tags own + Check log message ${tc.setup[0]} Overriding setup + Check log message ${tc[0, 0]} Overriding settings + Check log message ${tc.teardown[0]} Overriding teardown as well + Should be equal ${tc.timeout} ${NONE} + +Task timeout exceeded + ${tc} = Check Test Case ${TESTNAME} + Check timeout message ${tc[0, 0]} 99 milliseconds + Check log message ${tc[0, 1]} Task timeout 99 milliseconds exceeded. FAIL + +Invalid task timeout + Check Test Case ${TESTNAME} + +Task aliases are included in setting recommendations + Error In File + ... 0 rpa/task_aliases.robot 7 + ... SEPARATOR=\n + ... Non-existing setting 'Tesk Setup'. Did you mean: + ... ${SPACE*4}Test Setup + ... ${SPACE*4}Task Setup + +Task settings are not allowed in resource file + [Template] Validate invalid setting error + 1 2 Task Setup + 2 3 Task Teardown + 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[0]} 1 minute 10 seconds + Check log message ${tc.setup[1]} Setup has an alias! + Check timeout message ${tc[0, 0]} 1 minute 10 seconds + Check log message ${tc.teardown[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[0]} Overriding setup + Check log message ${tc.teardown[0]} Overriding teardown as well + Should be equal ${tc.timeout} ${NONE} + Should be empty ${ERRORS} + +*** Keywords *** +Check timeout message + [Arguments] ${msg} ${timeout} + Check log message ${msg} Task timeout ${timeout} active. * seconds left. DEBUG pattern=True + +Validate invalid setting error + [Arguments] ${index} ${lineno} ${setting} + Error In File + ... ${index} rpa/resource_with_invalid_task_settings.robot ${lineno} + ... Setting '${setting}' is not allowed in resource file. diff --git a/atest/robot/rpa/task_setup_teardown_template_timeout.robot b/atest/robot/rpa/task_setup_teardown_template_timeout.robot deleted file mode 100644 index 4dee45fae0a..00000000000 --- a/atest/robot/rpa/task_setup_teardown_template_timeout.robot +++ /dev/null @@ -1,46 +0,0 @@ -*** Settings *** -Suite Setup Run Tests --loglevel DEBUG rpa/task_setup_teardown_template_timeout.robot -Resource atest_resource.robot - -*** Test Cases *** -Defaults - ${tc} = Check Test Case ${TESTNAME} - 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 - Check log message ${tc.kws[0].msgs[1]} Using default settings - Check log message ${tc.teardown.msgs[0]} Also teardown has an alias!! - Should be equal ${tc.timeout} 1 minute 10 seconds - -Override - ${tc} = Check Test Case ${TESTNAME} - 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 - Should be equal ${tc.timeout} ${NONE} - -Task timeout exceeded - ${tc} = Check Test Case ${TESTNAME} - Check timeout message ${tc.kws[0].msgs[0]} 99 milliseconds - Check log message ${tc.kws[0].msgs[1]} Task timeout 99 milliseconds exceeded. FAIL - -Invalid task timeout - Check Test Case ${TESTNAME} - -Task settings are not allowed in resource file - [Template] Validate invalid setting error - ${ERRORS[0]} Task Setup - ${ERRORS[1]} Task Teardown - ${ERRORS[2]} Task Template - ${ERRORS[3]} Task Timeout - -*** Keywords *** -Check timeout message - [Arguments] ${msg} ${timeout} - Check log message ${msg} Task timeout ${timeout} active. * seconds left. DEBUG pattern=True - - -Validate invalid setting error - [Arguments] ${error} ${setting} - ${path} = Normalize Path ${DATADIR}/rpa/resource_with_invalid_task_settings.robot - Check log message ${error} Error in file '${path}': Non-existing setting '${setting}'. ERROR diff --git a/atest/robot/running/GetNestingLevel.py b/atest/robot/running/GetNestingLevel.py new file mode 100644 index 00000000000..1f091c2a720 --- /dev/null +++ b/atest/robot/running/GetNestingLevel.py @@ -0,0 +1,21 @@ +from robot.api import SuiteVisitor + + +class Nesting(SuiteVisitor): + + def __init__(self): + self.level = 0 + self.max = 0 + + def start_keyword(self, kw): + self.level += 1 + self.max = max(self.level, self.max) + + def end_keyword(self, kw): + self.level -= 1 + + +def get_nesting_level(test): + nesting = Nesting() + test.visit(nesting) + return nesting.max diff --git a/atest/robot/running/ProcessManager.py b/atest/robot/running/ProcessManager.py deleted file mode 100644 index 5db90e6cb1a..00000000000 --- a/atest/robot/running/ProcessManager.py +++ /dev/null @@ -1,74 +0,0 @@ -import subprocess -import os -import signal -import ctypes - - -class ProcessManager(object): - - def __init__(self): - self._process = None - self._stdout = None - self._stderr = None - - def start_process(self, *args): - self._process = subprocess.Popen(args, stderr=subprocess.PIPE, - stdout=subprocess.PIPE, - universal_newlines=True) - self._stdout = None - self._stderr = None - - def send_terminate(self, signal_name): - if os.name != 'nt': - os.kill(self._process.pid, getattr(signal, signal_name)) - else: - self._set_handler_to_ignore_one_sigint() - ctypes.windll.kernel32.GenerateConsoleCtrlEvent(0, 0) - - def _set_handler_to_ignore_one_sigint(self): - orig_handler = signal.getsignal(signal.SIGINT) - signal.signal(signal.SIGINT, lambda signum, frame: - signal.signal(signal.SIGINT, orig_handler)) - - def get_stdout(self): - self.wait_until_finished() - return self._stdout - - def get_stderr(self): - self.wait_until_finished() - return self._stderr - - def log_stdout_and_stderr(self): - self.wait_until_finished() - print('STDOUT:') - print(self._stdout) - print('STDERR:') - print(self._stderr) - - def wait_until_finished(self): - if self._stdout is None: - self._stdout, self._stderr = self._process.communicate() - - def get_runner(self, interpreter, robot_path): - run = os.path.join(robot_path, 'run.py') - if 'jython' not in interpreter: - return [interpreter, run] - jython_home = os.getenv('JYTHON_HOME') - if not jython_home: - raise RuntimeError('This test requires JYTHON_HOME environment variable to be set.') - return [self._get_java(), '-Dpython.home=%s' % jython_home, - '-classpath', self._get_classpath(jython_home), - 'org.python.util.jython', run] - - def _get_java(self): - java_home = os.getenv('JAVA_HOME') - if not java_home: - return 'java' - if java_home.startswith('"') and java_home.endswith('"'): - java_home = java_home[1:-1] - return os.path.join(java_home, 'bin', 'java') - - def _get_classpath(self, jython_home): - jython_jar = os.path.join(jython_home, 'jython.jar') - cp = jython_jar + os.pathsep + os.getenv('CLASSPATH', '') - return cp.strip(':;') diff --git a/atest/robot/running/continue_for_loop.robot b/atest/robot/running/continue_for_loop.robot deleted file mode 100644 index eebb8401372..00000000000 --- a/atest/robot/running/continue_for_loop.robot +++ /dev/null @@ -1,54 +0,0 @@ -*** Settings *** -Suite Setup Run Tests ${EMPTY} running/continue_for_loop.robot -Resource atest_resource.robot - -*** Test Cases *** -Simple Continue For Loop - Test And All Keywords Should Have Passed - -Continue For Loop In `Run Keyword` - Test And All Keywords Should Have Passed - -Continue For Loop In User Keyword - Test And All Keywords Should Have Passed - -Continue For Loop Should Terminate Immediate Loop Only - Test And All Keywords Should Have Passed - -Continue For Loop In User Keyword Should Terminate Immediate Loop Only - Test And All Keywords Should Have Passed - -Continue For Loop Without For Loop Should Fail - Check Test Case ${TESTNAME} - -Continue For Loop In User Keyword Without For Loop Should Fail - Check Test Case ${TESTNAME} - -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 - 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 - Test And All Keywords Should Have Passed - -Continue For Loop In Keyword Teardown - Test And All Keywords Should Have Passed - -Invalid Continue For Loop In User Keyword Teardown - Check Test Case ${TESTNAME} - -Continue For Loop If True - Check Test Case ${TESTNAME} - -Continue For Loop If False - Check Test Case ${TESTNAME} - -With Continuable Failure After - Check Test Case ${TESTNAME} - -With Continuable Failure Before - Check Test Case ${TESTNAME} - -With Continuable Failure In User Keyword - Check Test Case ${TESTNAME} diff --git a/atest/robot/running/continue_on_failure.robot b/atest/robot/running/continue_on_failure.robot index a13b1106c8f..784d162727f 100644 --- a/atest/robot/running/continue_on_failure.robot +++ b/atest/robot/running/continue_on_failure.robot @@ -6,101 +6,105 @@ Resource atest_resource.robot Continue in test ${tc}= Check Test Case ${TESTNAME} - Check Log Message ${tc.kws[1].msgs[0]} This should be executed + Check Log Message ${tc[1, 0]} This should be executed Continue in user keyword ${tc}= Check Test Case ${TESTNAME} - Check Log Message ${tc.kws[0].kws[1].msgs[0]} This should be executed in Test Case + Check Log Message ${tc[0, 1, 0]} This should be executed in Test Case Continue in test with several continuable failures ${tc}= Check Test Case ${TESTNAME} - Check Log Message ${tc.kws[1].msgs[0]} This should be executed - Check Log Message ${tc.kws[3].msgs[0]} This should also be executed - Check Log Message ${tc.kws[5].msgs[0]} This too should also be executed + Check Log Message ${tc[1, 0]} This should be executed + Check Log Message ${tc[3, 0]} This should also be executed + Check Log Message ${tc[5, 0]} This too should also be executed Continue in user keyword with several continuable failures ${tc}= Check Test Case ${TESTNAME} - Verify all failures in user keyword ${tc.kws[0]} Test Case - Verify all failures in user keyword ${tc.kws[1]} Test Case, Again + Verify all failures in user keyword ${tc[0]} Test Case + Verify all failures in user keyword ${tc[1]} Test Case, Again Continuable and regular failure ${tc}= Check Test Case ${TESTNAME} - Length Should Be ${tc.kws} 3 + Length Should Be ${tc.body} 4 + Should Be Equal ${tc[-1].status} NOT RUN Continue in nested user keyword ${tc}= Check Test Case ${TESTNAME} - Check Log Message ${tc.kws[0].kws[1].msgs[0]} This should be executed in Top Level UK (with ∏ön ÄßÇïï €§) - Verify all failures in user keyword ${tc.kws[0].kws[2]} Nested UK + Check Log Message ${tc[0, 1, 0]} This should be executed in Top Level UK (with ∏ön ÄßÇïï €§) + Verify all failures in user keyword ${tc[0, 2]} Nested UK Continuable and regular failure in UK Check Test Case ${TESTNAME} Several continuable failures and regular failure in nested UK ${tc}= Check Test Case ${TESTNAME} - Verify all failures in user keyword ${tc.kws[0].kws[2]} Nested UK - Verify all failures in user keyword ${tc.kws[1].kws[1].kws[2]} Nested UK + Verify all failures in user keyword ${tc[0, 2]} Nested UK + Verify all failures in user keyword ${tc[1, 1, 2]} Nested UK Continue when setting variables ${tc}= Check Test Case ${TESTNAME} - Check Log Message ${tc.kws[0].msgs[0]} \${ret} = None - Check Log Message ${tc.kws[0].msgs[1]} ContinuableApocalypseException: Can be continued FAIL - Check Log Message ${tc.kws[2].msgs[0]} \${r1} = None - Check Log Message ${tc.kws[2].msgs[1]} \${r2} = None - Check Log Message ${tc.kws[2].msgs[2]} \${r3} = None - Check Log Message ${tc.kws[2].msgs[3]} ContinuableApocalypseException: Can be continued FAIL - Check Log Message ${tc.kws[4].msgs[0]} \@{list} = [ ] - Check Log Message ${tc.kws[4].msgs[1]} ContinuableApocalypseException: Can be continued FAIL - Check Log Message ${tc.kws[6].msgs[0]} No jokes FAIL - Length Should Be ${tc.kws[6].msgs} 1 + Check Log Message ${tc[0, 0]} \${ret} = None + Check Log Message ${tc[0, 1]} ContinuableApocalypseException: Can be continued FAIL + Check Log Message ${tc[2, 0]} \${r1} = None + Check Log Message ${tc[2, 1]} \${r2} = None + Check Log Message ${tc[2, 2]} \${r3} = None + Check Log Message ${tc[2, 3]} ContinuableApocalypseException: Can be continued FAIL + Check Log Message ${tc[4, 0]} \@{list} = [ ] + Check Log Message ${tc[4, 1]} ContinuableApocalypseException: Can be continued FAIL + Check Log Message ${tc[6, 0]} No jokes FAIL + Length Should Be ${tc[6].body} 1 Continuable failure in user keyword returning value Check Test Case ${TESTNAME} Continue in test setup ${tc}= Check Test Case ${TESTNAME} - Check Log Message ${tc.setup.kws[1].msgs[0]} This should be executed in Test Setup - Should Be Empty ${tc.kws} + Check Log Message ${tc.setup[1, 0]} This should be executed in Test Setup + Should Be Empty ${tc.body} Continue in test teardown ${tc}= Check Test Case ${TESTNAME} - Check Log Message ${tc.teardown.kws[1].msgs[0]} This should be executed in Test Teardown + Check Log Message ${tc.teardown[1, 0]} This should be executed in Test Teardown Continue many times in test setup and teardown ${tc}= Check Test Case ${TESTNAME} - Verify all failures in user keyword ${tc.setup} Test Setup - Should Be Empty ${tc.kws} + Verify all failures in user keyword ${tc.setup} Test Setup + Should Be Empty ${tc.body} Verify all failures in user keyword ${tc.teardown} Test Teardown Continue in suite teardown ${suite}= Get Test Suite Continue On Failure - Check Log Message ${suite.teardown.kws[1].msgs[0]} This should be executed in Suite Teardown + Check Log Message ${suite.teardown[1, 0]} This should be executed in Suite Teardown Continue in suite setup ${suite}= Get Test Suite Continue On Failure In Suite Setup - Check Log Message ${suite.setup.kws[1].msgs[0]} This should be executed in Suite Setup (with ∏ön ÄßÇïï €§) + Check Log Message ${suite.setup[1, 0]} This should be executed in Suite Setup (with ∏ön ÄßÇïï €§) Continue in for loop ${tc}= Check Test Case ${TESTNAME} - Check Log Message ${tc.kws[0].kws[0].kws[0].msgs[0]} ContinuableApocalypseException: 0 FAIL - Check Log Message ${tc.kws[0].kws[0].kws[1].msgs[0]} This should be executed inside for loop - Check Log Message ${tc.kws[0].kws[1].kws[0].msgs[0]} ContinuableApocalypseException: 1 FAIL - Check Log Message ${tc.kws[0].kws[1].kws[1].msgs[0]} This should be executed inside for loop - Check Log Message ${tc.kws[0].kws[2].kws[0].msgs[0]} ContinuableApocalypseException: 2 FAIL - Check Log Message ${tc.kws[0].kws[2].kws[1].msgs[0]} This should be executed inside for loop - Check Log Message ${tc.kws[0].kws[3].kws[0].msgs[0]} ContinuableApocalypseException: 3 FAIL - Check Log Message ${tc.kws[0].kws[3].kws[1].msgs[0]} This should be executed inside for loop - Check Log Message ${tc.kws[0].kws[4].kws[0].msgs[0]} ContinuableApocalypseException: 4 FAIL - Check Log Message ${tc.kws[0].kws[4].kws[1].msgs[0]} This should be executed inside for loop - Check Log Message ${tc.kws[1].msgs[0]} This should be executed after for loop + Check Log Message ${tc[0, 0, 0, 0]} ContinuableApocalypseException: 0 FAIL + Check Log Message ${tc[0, 0, 1, 0]} This should be executed inside for loop + Check Log Message ${tc[0, 1, 0, 0]} ContinuableApocalypseException: 1 FAIL + Check Log Message ${tc[0, 1, 1, 0]} This should be executed inside for loop + Check Log Message ${tc[0, 2, 0, 0]} ContinuableApocalypseException: 2 FAIL + Check Log Message ${tc[0, 2, 1, 0]} This should be executed inside for loop + Check Log Message ${tc[0, 3, 0, 0]} ContinuableApocalypseException: 3 FAIL + Check Log Message ${tc[0, 3, 1, 0]} This should be executed inside for loop + Check Log Message ${tc[0, 4, 0, 0]} ContinuableApocalypseException: 4 FAIL + Check Log Message ${tc[0, 4, 1, 0]} This should be executed inside for loop + Check Log Message ${tc[1, 0]} This should be executed after for loop Continuable and regular failure in for loop Check Test Case ${TESTNAME} +robot.api.ContinuableFailure + Check Test Case ${TESTNAME} + *** Keywords *** Verify all failures in user keyword [Arguments] ${kw} ${where} - Check Log Message ${kw.kws[0].msgs[0]} ContinuableApocalypseException: 1 FAIL - Check Log Message ${kw.kws[1].msgs[0]} This should be executed in ${where} (with ∏ön ÄßÇïï €§) - Check Log Message ${kw.kws[2].msgs[0]} ContinuableApocalypseException: 2 FAIL - Check Log Message ${kw.kws[3].msgs[0]} This should also be executed in ${where} - Check Log Message ${kw.kws[4].msgs[0]} ContinuableApocalypseException: 3 FAIL - Check Log Message ${kw.kws[5].msgs[0]} This too should also be executed in ${where} + Check Log Message ${kw[0, 0]} ContinuableApocalypseException: 1 FAIL + Check Log Message ${kw[1, 0]} This should be executed in ${where} (with ∏ön ÄßÇïï €§) + Check Log Message ${kw[2, 0]} ContinuableApocalypseException: 2 FAIL + Check Log Message ${kw[3, 0]} This should also be executed in ${where} + Check Log Message ${kw[4, 0]} ContinuableApocalypseException: 3 FAIL + Check Log Message ${kw[5, 0]} This too should also be executed in ${where} diff --git a/atest/robot/running/continue_on_failure_tag.robot b/atest/robot/running/continue_on_failure_tag.robot new file mode 100644 index 00000000000..17443e98f2d --- /dev/null +++ b/atest/robot/running/continue_on_failure_tag.robot @@ -0,0 +1,142 @@ +*** Settings *** +Suite Setup Run Tests ${EMPTY} running/continue_on_failure_tag.robot +Resource atest_resource.robot + +*** Test Cases *** +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 continue tag + Check Test Case ${TESTNAME} + +Continue in test with continue tag and UK without tag + Check Test Case ${TESTNAME} + +Continue in test with continue tag and nested UK with and without tag + Check Test Case ${TESTNAME} + +Continue in test with continue tag and two nested UK with continue tag + Check Test Case ${TESTNAME} + +Continue in FOR loop with continue tag + Check Test Case ${TESTNAME} + +Continue in FOR loop with Set Tags + Check Test Case ${TESTNAME} + +No continue in FOR loop without tag + Check Test Case ${TESTNAME} + +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 continue tag + Check Test Case ${TESTNAME} + +Continue in IF with set and remove tag + Check Test Case ${TESTNAME} + +No continue in IF without tag + Check Test Case ${TESTNAME} + +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 continue tag + Check Test Case ${TESTNAME} + +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 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 + Check Test Case ${TESTNAME} + +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} + +Test test setup with continue-on-failure + Check Test Case ${TESTNAME} + +Test test setup with recursive-continue-on-failure + Check Test Case ${TESTNAME} + +recursive-stop-on-failure with continue-on-failure + Check Test Case ${TESTNAME} + +recursive-continue-on-failure with stop-on-failure + Check Test Case ${TESTNAME} diff --git a/atest/robot/running/detect_recursion.robot b/atest/robot/running/detect_recursion.robot new file mode 100644 index 00000000000..cd50dc5d443 --- /dev/null +++ b/atest/robot/running/detect_recursion.robot @@ -0,0 +1,41 @@ +*** Settings *** +Suite Setup Run Tests ${EMPTY} running/detect_recursion.robot +Library GetNestingLevel.py +Resource atest_resource.robot + +*** Test Cases *** +Infinite recursion + Check Test Case ${TESTNAME} + +Infinite cyclic recursion + Check Test Case ${TESTNAME} + +Infinite recursion with Run Keyword + Check Test Case ${TESTNAME} + +Infinitely recursive for loop + Check Test Case ${TESTNAME} + +Recursion below the recursion limit is ok + [Documentation] Also verifies that recursion limit blown earlier doesn't affect subsequent tests + Check Test Case ${TESTNAME} + +Recursion limit is over 140 started keywords + ${tc} = Check Test Case Infinite recursion + ${level} = Get Nesting Level ${tc} + Should Be True 140 < ${level} < 160 + +Recursion limit can be raised with `sys.setrecursionlimit` + [Setup] Should Be True sys.getrecursionlimit() == 1000 + # Raise limit with executed tests using sitecustomize.py. + Create File %{TEMPDIR}/sitecustomize.py import sys; sys.setrecursionlimit(1500) + Set Environment Variable PYTHONPATH %{TEMPDIR} + # Also raise limit here to be able to process created outputs. + Evaluate sys.setrecursionlimit(1500) + Run Tests -t "Infinite recursion" running/detect_recursion.robot + ${tc} = Check Test Case Infinite recursion + ${level} = Get Nesting Level ${tc} + Should Be True 220 < ${level} < 240 + [Teardown] Run Keywords + ... Remove File %{TEMPDIR}/sitecustomize.py AND + ... Evaluate sys.setrecursionlimit(1000) diff --git a/atest/robot/running/duplicate_suite_name.robot b/atest/robot/running/duplicate_suite_name.robot new file mode 100644 index 00000000000..6386a65e05e --- /dev/null +++ b/atest/robot/running/duplicate_suite_name.robot @@ -0,0 +1,32 @@ +*** Settings *** +Suite Setup Run Tests --exclude exclude running/duplicate_suite_name +Resource atest_resource.robot + +*** Test Cases *** +Suites with same name shoul be executed + Should Contain Suites ${SUITE} + ... Test + ... Test + +There should be warning when multiple suites with aame name are executed + Check Multiple Suites Log Message ${ERRORS[0]} Test + +There should be no warning if suites with same name are run explicitly + ${sources} = Catenate + ... running/duplicate_suite_name/test.robot + ... running/duplicate_suite_name/test_.robot + ... running/duplicate_suite_name/test.robot + Run Tests ${EMPTY} ${sources} + Should Contain Suites ${SUITE} + ... Test + ... Test + ... Test + Should Be Empty ${ERRORS} + +*** Keywords *** +Check Multiple Suites Log Message + [Arguments] ${error} ${name} + Check Log Message + ... ${error} + ... Multiple suites with name '${name}' executed in suite 'Duplicate Suite Name'. + ... WARN diff --git a/atest/robot/running/duplicate_test_name.robot b/atest/robot/running/duplicate_test_name.robot new file mode 100644 index 00000000000..68133471a24 --- /dev/null +++ b/atest/robot/running/duplicate_test_name.robot @@ -0,0 +1,42 @@ +*** Settings *** +Suite Setup Run Tests --exclude exclude running/duplicate_test_name.robot +Resource atest_resource.robot + +*** Test Cases *** +Tests with same name are executed + Should Contain Tests ${SUITE} + ... Duplicates + ... Duplicates + ... Duplicates + ... Duplicates with different case and spaces + ... Duplicates with different CASE ands p a c e s + ... Duplicates but only one executed + ... Test 1 Test 2 Test 3 + ... Duplicates after resolving variables + ... Duplicates after resolving variables + +There is warning when multiple tests with same name are executed + Check Multiple Tests Log Message ${ERRORS[0]} Duplicates + Check Multiple Tests Log Message ${ERRORS[1]} Duplicates + Check Multiple Tests Log Message ${ERRORS[2]} Duplicates with different CASE ands p a c e s + +There is warning if names are same after resolving variables + Check Multiple Tests Log Message ${ERRORS[3]} Duplicates after resolving variables + +There is no warning when there are multiple tests with same name but only one is executed + Check Test Case Duplicates but only one executed + Length Should Be ${ERRORS} 4 + +Original name can be same if there is variable and its value changes + Check Test Case Test 1 + Check Test Case Test 2 + Check Test Case Test 3 + Length Should Be ${ERRORS} 4 + +*** Keywords *** +Check Multiple Tests Log Message + [Arguments] ${error} ${test} + Check Log Message + ... ${error} + ... Multiple tests with name '${test}' executed in suite 'Duplicate Test Name'. + ... WARN diff --git a/atest/robot/running/empty_and_none_setup_and_teardown_are_ignored.robot b/atest/robot/running/empty_and_none_setup_and_teardown_are_ignored.robot index 8e03fefe9c8..f645242be00 100644 --- a/atest/robot/running/empty_and_none_setup_and_teardown_are_ignored.robot +++ b/atest/robot/running/empty_and_none_setup_and_teardown_are_ignored.robot @@ -5,27 +5,29 @@ Resource atest_resource.robot *** Test Cases *** Empty suite setup and teardown are ignored - Should Not Have Setup Or Teardown ${SUITE.suites[0]} + Suite Should Not Have Setup Or Teardown ${SUITE.suites[0]} Empty test setup and teardown are ignored Test Should Not Have Setup Or Teardown Empty default setup and teardown Test Should Not Have Setup Or Teardown Empty custom setup and teardown None suite setup and teardown are ignored - Should Not Have Setup Or Teardown ${SUITE.suites[1]} + Suite Should Not Have Setup Or Teardown ${SUITE.suites[1]} None test setup and teardown are ignored Test Should Not Have Setup Or Teardown None default setup and teardown Test Should Not Have Setup Or Teardown None custom setup and teardown *** Keywords *** -Should Not Have Setup Or Teardown - [Arguments] ${item} - Should Be Equal ${item.status} PASS - Should Be Equal ${item.keywords.setup} ${NONE} - Should Be Equal ${item.keywords.teardown} ${NONE} +Suite Should Not Have Setup Or Teardown + [Arguments] ${suite} + Should Be Equal ${suite.status} PASS + Setup Should Not Be Defined ${suite} + Teardown Should Not Be Defined ${suite} Test Should Not Have Setup Or Teardown [Arguments] ${name} ${tc} = Check Test Case ${name} - Should Not Have Setup Or Teardown ${tc} + Should Be Equal ${tc.status} PASS + Setup Should Not Be Defined ${tc} + Teardown Should Not Be Defined ${tc} diff --git a/atest/robot/running/enable_ctrl_c_event.py b/atest/robot/running/enable_ctrl_c_event.py new file mode 100644 index 00000000000..3165b190e8f --- /dev/null +++ b/atest/robot/running/enable_ctrl_c_event.py @@ -0,0 +1,7 @@ +try: + from ctypes import windll + + windll.kernel32.SetConsoleCtrlHandler(None, False) + +except ImportError: + pass diff --git a/atest/robot/running/exit_for_loop.robot b/atest/robot/running/exit_for_loop.robot deleted file mode 100644 index 11f9bac2877..00000000000 --- a/atest/robot/running/exit_for_loop.robot +++ /dev/null @@ -1,57 +0,0 @@ -*** Settings *** -Suite Setup Run Tests ${EMPTY} running/exit_for_loop.robot -Resource atest_resource.robot - -*** Test Cases *** -Simple Exit For Loop - Test And All Keywords Should Have Passed - -Exit For Loop In `Run Keyword` - Test And All Keywords Should Have Passed - -Exit For Loop In User Keyword - Test And All Keywords Should Have Passed - -Exit For Loop In User Keyword With Loop - Test And All Keywords Should Have Passed - -Exit For Loop In User Keyword With Loop Within Loop - Test And All Keywords Should Have Passed - -Exit For Loop In User Keyword Calling User Keyword With Exit For Loop - Test And All Keywords Should Have Passed - -Exit For Loop Without For Loop Should Fail - Check Test Case ${TESTNAME} - -Exit For Loop In User Keyword Without For Loop Should Fail - Check Test Case ${TESTNAME} - -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 - Check Log Message ${tc.kws[0].kws[0].kws[0].msgs[0]} Exiting for loop altogether. - -Exit For Loop In Test Teardown - Test And All Keywords Should Have Passed - -Exit For Loop In Keyword Teardown - Test And All Keywords Should Have Passed - -Invalid Exit For Loop In User Keyword Teardown - Check Test Case ${TESTNAME} - -Exit For Loop If True - Check Test Case ${TESTNAME} - -Exit For Loop If False - Check Test Case ${TESTNAME} - -With Continuable Failure After - Check Test Case ${TESTNAME} - -With Continuable Failure Before - Check Test Case ${TESTNAME} - -With Continuable Failure In User Keyword - Check Test Case ${TESTNAME} diff --git a/atest/robot/running/exit_on_failure_tag.robot b/atest/robot/running/exit_on_failure_tag.robot new file mode 100644 index 00000000000..f95649083ca --- /dev/null +++ b/atest/robot/running/exit_on_failure_tag.robot @@ -0,0 +1,17 @@ +*** Settings *** +Suite Setup Run Tests ${EMPTY} running/exit_on_failure_tag.robot +Resource atest_resource.robot + +*** Test Cases *** +Passing test with the tag has not special effect + Check Test Case ${TESTNAME} + +Failing test without the tag has no special effect + Check Test Case ${TESTNAME} + +Failing test with the tag initiates exit-on-failure + Check Test Case ${TESTNAME} + +Subsequent tests are not run + Check Test Case ${TESTNAME} 1 + Check Test Case ${TESTNAME} 2 diff --git a/atest/robot/running/failures_in_teardown.robot b/atest/robot/running/failures_in_teardown.robot index ed96811ff7b..175af344269 100644 --- a/atest/robot/running/failures_in_teardown.robot +++ b/atest/robot/running/failures_in_teardown.robot @@ -6,51 +6,83 @@ Resource atest_resource.robot *** Test Cases *** One Failure ${tc} = Check Test Case ${TESTNAME} - Check Log Message ${tc.teardown.kws[1].msgs[0]} This should be executed + Check Log Message ${tc.teardown[1, 0]} This should be executed Multiple Failures ${tc} = Check Test Case ${TESTNAME} - Check Log Message ${tc.teardown.kws[2].msgs[0]} This should also be executed + Check Log Message ${tc.teardown[2, 0]} This should also be executed Failure When Setting Variables ${tc} = Check Test Case ${TESTNAME} - Check Log Message ${tc.teardown.kws[0].msgs[0]} \${ret} = None - Check Log Message ${tc.teardown.kws[0].msgs[1]} Return values is None FAIL + Check Log Message ${tc.teardown[0, 0]} \${ret} = None + Check Log Message ${tc.teardown[0, 1]} Return values is None FAIL Failure In For Loop Check Test Case ${TESTNAME} 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} - Length Should Be ${tc.teardown.kws} 1 + Length Should Be ${tc.teardown.body} 2 + Should Be Equal ${tc.teardown[-1].status} NOT RUN + +Execution continues if executed keyword fails for keyword timeout + ${tc} = Check Test Case ${TESTNAME} + Length Should Be ${tc.teardown.body} 2 + Should Be Equal ${tc.teardown.body[0].status} FAIL + Should Be Equal ${tc.teardown.body[1].status} FAIL + Length Should Be ${tc.teardown.body[0].body} 2 + Should Be Equal ${tc.teardown[0, 0].status} FAIL + Check Log Message ${tc.teardown}[0, 0, 0] Keyword timeout 42 milliseconds exceeded. FAIL + Should Be Equal ${tc.teardown[0, 1].status} NOT RUN + Length Should Be ${tc.teardown.body[1].body} 1 + Check Log Message ${tc.teardown}[1, 0] This should be executed FAIL + +Execution stops after keyword timeout if keyword uses WUKS + ${tc} = Check Test Case ${TESTNAME} + Length Should Be ${tc.teardown.body} 2 + Should Be Equal ${tc.teardown.body[0].status} FAIL + Should Be Equal ${tc.teardown.body[1].status} NOT RUN + Length Should Be ${tc.teardown.body[0].body} 2 + Should Be Equal ${tc.teardown[0, 0].status} FAIL + Should Be Equal ${tc.teardown[0, 1].status} FAIL + Length Should Be ${tc.teardown[0, 0].body} 2 + Should Be Equal ${tc.teardown[0, 0, 0].status} PASS + Should Be Equal ${tc.teardown[0, 0, 1].status} FAIL + Check Log Message ${tc.teardown}[0, 0, 1, 0] Failing! FAIL + Length Should Be ${tc.teardown[0, 1].body} 2 + Should Be Equal ${tc.teardown[0, 1, 0].status} FAIL + Check Log Message ${tc.teardown}[0, 1, 0, 0] Keyword timeout 100 milliseconds exceeded. FAIL + Should Be Equal ${tc.teardown[0, 1, 1].status} NOT RUN Execution Continues If Variable Does Not Exist ${tc} = Check Test Case ${TESTNAME} - Length Should Be ${tc.teardown.kws} 3 + Length Should Be ${tc.teardown.body} 3 Execution Continues After Keyword Errors ${tc} = Check Test Case ${TESTNAME} - Length Should Be ${tc.teardown.kws} 3 + Length Should Be ${tc.teardown.body} 3 Execution Stops After Syntax Error ${tc} = Check Test Case ${TESTNAME} - Length Should Be ${tc.teardown.kws} 1 + Length Should Be ${tc.teardown.body} 2 + Should Be Equal ${tc.teardown[-1].status} NOT RUN Fatal Error ${tc} = Check Test Case ${TESTNAME} 1 - Length Should Be ${tc.teardown.kws} 1 - Check Test Case ${TESTNAME} 2 + Length Should Be ${tc.teardown.body} 2 + Should Be Equal ${tc.teardown[-1].status} NOT RUN + Check Test Case ${TESTNAME} 2 Suite Teardown Is Executed Fully ${td} = Set Variable ${SUITE.teardown} - Check Log Message ${td.kws[0].msgs[0]} Suite Message 1 FAIL - Check Log Message ${td.kws[1].msgs[0]} Suite Message 2 (with ∏ön ÄßÇïï €§) FAIL - Check Log Message ${td.kws[2].msgs[0]} Variable '\${it is ok not to exist}' not found. FAIL - Check Log Message ${td.kws[3].msgs[0]} This should be executed + Check Log Message ${td[0, 0]} Suite Message 1 FAIL + Check Log Message ${td[1, 0]} Suite Message 2 (with ∏ön ÄßÇïï €§) FAIL + Check Log Message ${td[2, 0]} Variable '\${it is ok not to exist}' not found. FAIL + Check Log Message ${td[3, 0]} This should be executed ${msg} = Catenate SEPARATOR=\n\n ... Suite teardown failed:\nSeveral failures occurred: ... 1) Suite Message 1 @@ -61,4 +93,5 @@ Suite Teardown Is Executed Fully Suite Teardown Should Stop At Fatal Error Run Tests ${EMPTY} running/fatal_error_in_suite_teardown.robot ${ts} = Get Test Suite fatal error in suite teardown - Length Should Be ${ts.teardown.kws} 1 + Length Should Be ${ts.teardown.body} 2 + Should Be Equal ${ts.teardown[-1].status} NOT RUN diff --git a/atest/robot/running/fatal_exception.robot b/atest/robot/running/fatal_exception.robot index ba0e9713701..6c3898b42df 100644 --- a/atest/robot/running/fatal_exception.robot +++ b/atest/robot/running/fatal_exception.robot @@ -5,14 +5,13 @@ Resource atest_resource.robot Exit From Python Keyword Run Tests ${EMPTY} running/fatal_exception/01__python_library_kw.robot ${tc}= Check Test Case ${TESTNAME} - Check Log Message ${tc.teardown.msgs[0]} This should be executed + Check Log Message ${tc.teardown[0]} This should be executed Check Test Case Test That Should Not Be Run 1 -Exit From Java Keyword - [Tags] require-jython - Run Tests ${EMPTY} running/fatal_exception/03__java_library_kw.robot +robot.api.FatalError + Run Tests ${EMPTY} running/fatal_exception/standard_error.robot Check Test Case ${TESTNAME} - Check Test Case Test That Should Not Be Run 3 + Check Test Case Test That Should Not Be Run Multiple Suite Aware Exiting Run Tests ${EMPTY} running/fatal_exception/ @@ -29,9 +28,8 @@ Skipped tests get robot:exit tag Previous test should have passed Skip Imports On Exit Check Test Tags Exit From Python Keyword some tag Check Test Tags Test That Should Not Be Run 1 robot:exit - Check Test Tags Test That Should Not Be Run 2.1 robot:exit + Check Test Tags Test That Should Not Be Run 2.1 robot:exit owntag Check Test Tags Test That Should Not Be Run 2.2 robot:exit - Check Test Tags Test That Should Not Be Run 3 robot:exit foo Skipping creates 'NOT robot:exit' combined tag statistics Previous test should have passed Skipped tests get robot:exit tag @@ -44,29 +42,29 @@ Multiple Suite Aware Exiting From Suite Setup Run Tests ${EMPTY} running/fatal_exception_suite_setup/ Check Test Case Test That Should Not Be Run 1 ${ts1} = Get Test Suite Suite Setup - Should End With ${ts1.teardown.msgs[0].message} Tearing down 1 + Should End With ${ts1.teardown[0].message} Tearing down 1 Check Test Case Test That Should Not Be Run 2.1 Check Test Case Test That Should Not Be Run 2.2 ${ts2} = Get Test Suite Irrelevant - Should Be Equal ${ts2.teardown} ${None} + Should Not Be True ${ts2.teardown} Multiple Suite Aware Exiting From Suite Setup With Skip Teardowns Run Tests --SkipTeardownOnExit running/fatal_exception_suite_setup/ Check Test Case Test That Should Not Be Run 1 ${ts1} = Get Test Suite Suite Setup - Should Be Equal ${ts1.teardown} ${None} + Should Not Be True ${ts1.teardown} Check Test Case Test That Should Not Be Run 2.1 Check Test Case Test That Should Not Be Run 2.2 ${ts2} = Get Test Suite Irrelevant - Should Be Equal ${ts2.teardown} ${None} + Should Not Be True ${ts2.teardown} Fatal Exception and Exit on Failure Run Tests --exitonfailure running/fatal_exception/01__python_library_kw.robot - Check Test Case Test That Should Not Be Run 1 FAIL Critical failure occurred and exit-on-failure mode is in use. + Check Test Case Test That Should Not Be Run 1 FAIL Failure occurred and exit-on-failure mode is in use. Fatal Exception And Skip Teardown On Exit Run Tests --SkipTeardownOnExit running/fatal_exception ${tc} = Check Test Case Exit From Python Keyword - Should Be Equal ${tc.teardown} ${None} + Should Not Be True ${tc.teardown} ${ts} = Get Test Suite Python Library Kw - Should Be Equal ${ts.teardown} ${None} + Should Not Be True ${ts.teardown} diff --git a/atest/robot/running/flatten.robot b/atest/robot/running/flatten.robot new file mode 100644 index 00000000000..dd3ab863fd9 --- /dev/null +++ b/atest/robot/running/flatten.robot @@ -0,0 +1,51 @@ +*** 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[0, 0]} From the main kw + +Nested UK + ${tc}= User keyword content should be flattened 2 + Check Log Message ${tc[0, 0]} arg + Check Log Message ${tc[0, 1]} from nested kw + +Loops and stuff + ${tc}= User keyword content should be flattened 13 + Check Log Message ${tc[0, 0]} inside for 0 + Check Log Message ${tc[0, 1]} inside for 1 + Check Log Message ${tc[0, 2]} inside for 2 + Check Log Message ${tc[0, 3]} inside while 0 + Check Log Message ${tc[0, 4]} \${LIMIT} = 1 + Check Log Message ${tc[0, 5]} inside while 1 + Check Log Message ${tc[0, 6]} \${LIMIT} = 2 + Check Log Message ${tc[0, 7]} inside while 2 + Check Log Message ${tc[0, 8]} \${LIMIT} = 3 + Check Log Message ${tc[0, 9]} inside if + Check Log Message ${tc[0, 10]} fail inside try FAIL + Check Log Message ${tc[0, 11]} Traceback (most recent call last):* DEBUG pattern=True + Check Log Message ${tc[0, 12]} inside except + +Recursion + User keyword content should be flattened 8 + +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[0, 0]} INFO 1 + Check Log Message ${tc[0, 1]} Log level changed from INFO to DEBUG. DEBUG + Check Log Message ${tc[0, 2]} INFO 2 + Check Log Message ${tc[0, 3]} DEBUG 2 level=DEBUG + +*** Keywords *** +User keyword content should be flattened + [Arguments] ${expected_message_count}=0 + ${tc}= Check Test Case ${TESTNAME} + Length Should Be ${tc[0].body} ${expected_message_count} + Length Should Be ${tc[0].messages} ${expected_message_count} + RETURN ${tc} diff --git a/atest/robot/running/for.robot b/atest/robot/running/for.robot deleted file mode 100644 index 4ff419260d6..00000000000 --- a/atest/robot/running/for.robot +++ /dev/null @@ -1,513 +0,0 @@ -*** Settings *** -Suite Setup Run Tests ${EMPTY} running/for.robot -Resource atest_resource.robot - -*** Test Cases *** -Simple For - ${tc} = Check Test Case ${TEST NAME} - Check Log Message ${tc.kws[0].msgs[0]} Not yet in For - Should Be For Keyword ${tc.kws[1]} 2 - Should Be For Item ${tc.kws[1].kws[0]} \${var} = one - Check Log Message ${tc.kws[1].kws[0].kws[0].msgs[0]} var: one - Should Be For Item ${tc.kws[1].kws[1]} \${var} = two - Check Log Message ${tc.kws[1].kws[1].kws[0].msgs[0]} var: two - Check Log Message ${tc.kws[2].msgs[0]} Not in For anymore - ${tc2} = Check Test Case ${TEST NAME} 2 - Should Be For Keyword ${tc2.kws[0]} 6 - Test "Simple For 2" Helper ${tc2.kws[0].kws[0]} 1 - Test "Simple For 2" Helper ${tc2.kws[0].kws[1]} 2 - Test "Simple For 2" Helper ${tc2.kws[0].kws[2]} 3 - Test "Simple For 2" Helper ${tc2.kws[0].kws[3]} 4 - Test "Simple For 2" Helper ${tc2.kws[0].kws[4]} 5 - Test "Simple For 2" Helper ${tc2.kws[0].kws[5]} 6 - -Indentation is not required - Check Test Case ${TEST NAME} - -Invalid END usage - Check Test Case ${TEST NAME} 1 - Check Test Case ${TEST NAME} 2 - Check Test Case ${TEST NAME} 3 - -Empty For Body Fails - ${tc} = Check Test Case ${TEST NAME} 1 - Should Be For Keyword ${tc.kws[0]} 0 - ${tc} = Check Test Case ${TEST NAME} 2 - Should Be For Keyword ${tc.kws[0]} 0 - -For Without Value Fails - ${tc} = Check Test Case ${TEST NAME} - Should Be For Keyword ${tc.kws[0]} 0 - -For Loop Over Empty List Variable Is Ok - Check Test Case ${TEST NAME} - -For Loop Over Generator - Check Test Case ${TEST NAME} - -For Failing - ${tc} = Check Test Case ${TEST NAME} 1 - Should Be For Keyword ${tc.kws[0]} 1 - Should Be Equal ${tc.kws[0].kws[0].status} FAIL - Check Log Message ${tc.kws[0].kws[0].kws[0].msgs[0]} Hello before failing kw - Should Be Equal As Integers ${tc.kws[0].kws[0].keyword_count} 2 - ${tc2} = Check Test Case ${TEST NAME} 2 - Should Be For Keyword ${tc2.kws[0]} 4 - Check Log Message ${tc2.kws[0].kws[2].kws[0].msgs[0]} Before Check - Check Log Message ${tc2.kws[0].kws[2].kws[2].msgs[0]} After Check - Check Log Message ${tc2.kws[0].kws[3].kws[0].msgs[0]} Before Check - Should Be Equal As Integers ${tc2.kws[0].kws[3].keyword_count} 2 - -For With Values On Multiple Rows - ${tc} = Check Test Case ${TEST NAME} - Should Be For Keyword ${tc.kws[0]} 10 - Check Log Message ${tc.kws[0].kws[0].kws[0].msgs[0]} 1 - : FOR ${i} IN RANGE 10 - \ ${exp} = Evaluate str(${i} + 1) - \ Check Log Message ${tc.kws[0].kws[${i}].kws[0].msgs[0]} ${exp} - # Sanity check - Check Log Message ${tc.kws[0].kws[0].kws[0].msgs[0]} 1 - Check Log Message ${tc.kws[0].kws[4].kws[0].msgs[0]} 5 - Check Log Message ${tc.kws[0].kws[9].kws[0].msgs[0]} 10 - -For With Keyword Args On Multiple Rows - ${tc} = Check Test Case ${TEST NAME} - Should Be For Keyword ${tc.kws[0]} 2 - Check Log Message ${tc.kws[0].kws[0].kws[1].msgs[0]} 1 2 3 4 5 6 7 one - Check Log Message ${tc.kws[0].kws[1].kws[1].msgs[0]} 1 2 3 4 5 6 7 two - -Many Fors In One Test - ${tc} = Check Test Case ${TEST NAME} - Should Be For Keyword ${tc.kws[0]} 2 - Check Log Message ${tc.kws[0].kws[0].kws[0].msgs[0]} In first for with var "foo" - Check Log Message ${tc.kws[0].kws[1].kws[0].msgs[0]} In first for with var "bar" - Should Be For Keyword ${tc.kws[1]} 1 - Check KW "My UK 2" ${tc.kws[1].kws[0].kws[0]} Hello, world! - Check Log Message ${tc.kws[2].msgs[0]} Outside for loop - Should Be For Keyword ${tc.kws[3]} 2 - Check Log Message ${tc.kws[3].kws[0].kws[0].msgs[0]} Third for loop - Check Log Message ${tc.kws[3].kws[0].kws[2].msgs[0]} Value: a - Check Log Message ${tc.kws[3].kws[1].kws[0].msgs[0]} Third for loop - Check Log Message ${tc.kws[3].kws[1].kws[2].msgs[0]} Value: b - Check Log Message ${tc.kws[4].msgs[0]} End of the test - -For With User Keywords - ${tc} = Check Test Case ${TEST NAME} - Should Be For Keyword ${tc.kws[0]} 2 - Check KW "My UK" ${tc.kws[0].kws[0].kws[0]} - Check KW "My UK 2" ${tc.kws[0].kws[0].kws[1]} foo - Check KW "My UK" ${tc.kws[0].kws[1].kws[0]} - Check KW "My UK 2" ${tc.kws[0].kws[1].kws[1]} bar - -For With Failures In User Keywords - ${tc} = Check Test Case ${TEST NAME} - Should Be For Keyword ${tc.kws[0]} 2 - -For In User Keywords - ${tc} = Check Test Case ${TEST NAME} - Check KW "For In UK" ${tc.kws[0]} - Check KW "For In UK with Args" ${tc.kws[1]} 4 one - -Nested For In User Keywords - ${tc} = Check Test Case ${TEST NAME} - Check KW "Nested For In UK" ${tc.kws[0]} foo - -For In Test And User Keywords - ${tc} = Check Test Case ${TEST NAME} - Should Be For Keyword ${tc.kws[1]} 1 - Check KW "For In UK" ${tc.kws[1].kws[0].kws[0]} - Check KW "For In UK with Args" ${tc.kws[1].kws[0].kws[1]} 2 one - Check KW "Nested For In UK" ${tc.kws[1].kws[0].kws[2]} one - -For Variable Scope - Check Test Case ${TEST NAME} - -For With Assign - ${tc} = Check Test Case ${TEST NAME} - Should Be For Keyword ${tc.kws[0]} 2 - Check Log Message ${tc.kws[0].kws[0].kws[0].msgs[0]} \${v1} = value 1 - Check Log Message ${tc.kws[0].kws[0].kws[1].msgs[0]} \${v2} = value 2 - Check Log Message ${tc.kws[0].kws[0].kws[1].msgs[1]} \${v3} = value 3 - Check Log Message ${tc.kws[0].kws[0].kws[2].msgs[0]} \@{list} = [ 1 | 2 | 3 | y ] - Check Log Message ${tc.kws[0].kws[1].kws[0].msgs[0]} \${v1} = value 1 - Check Log Message ${tc.kws[0].kws[1].kws[1].msgs[0]} \${v2} = value 2 - Check Log Message ${tc.kws[0].kws[1].kws[1].msgs[1]} \${v3} = value 3 - Check Log Message ${tc.kws[0].kws[1].kws[2].msgs[0]} \@{list} = [ 1 | 2 | 3 | z ] - -For With Invalid Assign - Check Test Case ${TEST NAME} - -For Without In - Check Test Case ${TEST NAME} 1 - Check Test Case ${TEST NAME} 2 - Check Test Case ${TEST NAME} 3 - -For Without Parameters - Check Test Case ${TEST NAME} - -For Without Variable - Check Test Case ${TEST NAME} - -Variable Format - Check Test Case ${TEST NAME} 1 - Check Test Case ${TEST NAME} 2 - Check Test Case ${TEST NAME} 3 - Check Test Case ${TEST NAME} 4 - -For With Non Existing Keyword - Check Test Case ${TEST NAME} - -For With Non Existing Variable - Check Test Case ${TEST NAME} - -For With Multiple Variables - ${tc} = Check Test Case ${TEST NAME} - Should Be For Keyword ${tc.kws[0]} 4 - Should Be For Item ${tc.kws[0].kws[0]} \${x} = 1, \${y} = a - Check Log Message ${tc.kws[0].kws[0].kws[0].msgs[0]} 1a - Should Be For Item ${tc.kws[0].kws[1]} \${x} = 2, \${y} = b - Check Log Message ${tc.kws[0].kws[1].kws[0].msgs[0]} 2b - Should Be For Item ${tc.kws[0].kws[2]} \${x} = 3, \${y} = c - Check Log Message ${tc.kws[0].kws[2].kws[0].msgs[0]} 3c - Should Be For Item ${tc.kws[0].kws[3]} \${x} = 4, \${y} = d - Check Log Message ${tc.kws[0].kws[3].kws[0].msgs[0]} 4d - Should Be For Keyword ${tc.kws[2]} 2 - Should Be For Item ${tc.kws[2].kws[0]} \${a} = 1, \${b} = 2, \${c} = 3, \${d} = 4, \${e} = 5 - Should Be For Item ${tc.kws[2].kws[1]} \${a} = 1, \${b} = 2, \${c} = 3, \${d} = 4, \${e} = 5 - -For With Non-Matching Number Of Parameters And Variables - ${tc} = Check Test Case ${TEST NAME} 1 - Should Be For Keyword ${tc.kws[1]} 0 - ${tc} = Check Test Case ${TEST NAME} 2 - Should Be For Keyword ${tc.kws[1]} 0 - -Cut Long Variable Value In For Item Name - ${tc} = Check Test Case ${TEST NAME} - ${exp10} = Set Variable 0123456789 - ${exp100} = Evaluate "${exp10}" * 10 - ${exp200} = Evaluate "${exp10}" * 20 - ${exp200+} = Set Variable ${exp200}... - Should Be For Keyword ${tc.kws[6]} 6 - Should Be For Item ${tc.kws[6].kws[0]} \${var} = ${exp10} - Should Be For Item ${tc.kws[6].kws[1]} \${var} = ${exp100} - Should Be For Item ${tc.kws[6].kws[2]} \${var} = ${exp200} - Should Be For Item ${tc.kws[6].kws[3]} \${var} = ${exp200+} - Should Be For Item ${tc.kws[6].kws[4]} \${var} = ${exp200+} - Should Be For Item ${tc.kws[6].kws[5]} \${var} = ${exp200+} - Should Be For Keyword ${tc.kws[7]} 2 - Should Be For Item ${tc.kws[7].kws[0]} \${var1} = ${exp10}, \${var2} = ${exp100}, \${var3} = ${exp200} - Should Be For Item ${tc.kws[7].kws[1]} \${var1} = ${exp200+}, \${var2} = ${exp200+}, \${var3} = ${exp200+} - -For with illegal xml characters - ${tc} = Check Test Case ${TEST NAME} - Should be equal ${tc.kws[0].kws[0].name} \${var} = illegal: - Should be equal ${tc.kws[0].kws[1].name} \${var} = more: - -For In Range - ${tc} = Check Test Case ${TEST NAME} - Should Be For In Range Keyword ${tc.kws[0]} 100 - Should Be For Item ${tc.kws[0].kws[0]} \${i} = 0 - Check Log Message ${tc.kws[0].kws[0].kws[1].msgs[0]} i: 0 - Should Be For Item ${tc.kws[0].kws[1]} \${i} = 1 - Check Log Message ${tc.kws[0].kws[1].kws[1].msgs[0]} i: 1 - Should Be For Item ${tc.kws[0].kws[42]} \${i} = 42 - Check Log Message ${tc.kws[0].kws[42].kws[1].msgs[0]} i: 42 - Should Be For Item ${tc.kws[0].kws[-1]} \${i} = 99 - Check Log Message ${tc.kws[0].kws[-1].kws[1].msgs[0]} i: 99 - -For In Range With Start And Stop - Check Test Case ${TEST NAME} - -For In Range With Start, Stop And Step - ${tc} = Check Test Case ${TEST NAME} - Should Be For In Range Keyword ${tc.kws[0]} 3 - Should Be For Item ${tc.kws[0].kws[0]} \${item} = 10 - Should Be For Item ${tc.kws[0].kws[1]} \${item} = 7 - Should Be For Item ${tc.kws[0].kws[2]} \${item} = 4 - -For In Range With Float Stop - ${tc} = Check Test Case ${TEST NAME} 1 - Should Be For In Range Keyword ${tc.kws[0]} 4 - Should Be For Item ${tc.kws[0].kws[0]} \${item} = 0.0 - Should Be For Item ${tc.kws[0].kws[1]} \${item} = 1.0 - Should Be For Item ${tc.kws[0].kws[2]} \${item} = 2.0 - Should Be For Item ${tc.kws[0].kws[3]} \${item} = 3.0 - ${tc} = Check Test Case ${TEST NAME} 2 - Should Be For In Range Keyword ${tc.kws[0]} 3 - Should Be For Item ${tc.kws[0].kws[0]} \${item} = 0.0 - Should Be For Item ${tc.kws[0].kws[1]} \${item} = 1.0 - Should Be For Item ${tc.kws[0].kws[2]} \${item} = 2.0 - -For In Range With Float Start And Stop - Check Test Case ${TEST NAME} 1 - Check Test Case ${TEST NAME} 2 - -For In Range With Float Start, Stop And Step - ${tc} = Check Test Case ${TEST NAME} - Should Be For In Range Keyword ${tc.kws[0]} 3 - Should Be For Item ${tc.kws[0].kws[0]} \${item} = 10.99 - Should Be For Item ${tc.kws[0].kws[1]} \${item} = 7.95 - Should Be For Item ${tc.kws[0].kws[2]} \${item} = 4.91 - -For In Range With Variables In Arguments - Check Test Case ${TEST NAME} - -For In Range With Expressions - Check Test Case ${TEST NAME} - -For In Range With Expressions Containing Floats - Check Test Case ${TEST NAME} - -For In Range With Multiple Variables - ${tc} = Check Test Case ${TEST NAME} - Should Be For In Range Keyword ${tc.kws[0]} 4 - Should Be For Item ${tc.kws[0].kws[0]} \${i} = -1, \${j} = 0, \${k} = 1 - Should Be For Item ${tc.kws[0].kws[1]} \${i} = 2, \${j} = 3, \${k} = 4 - Should Be For Item ${tc.kws[0].kws[2]} \${i} = 5, \${j} = 6, \${k} = 7 - Should Be For Item ${tc.kws[0].kws[3]} \${i} = 8, \${j} = 9, \${k} = 10 - -For In Zip - ${tc} = Check Test Case ${TEST NAME} - ${for_loop}= Set Variable ${tc.kws[2]} - Should Be For In Zip Keyword ${for_loop} 4 - Should Be For Item ${for_loop.kws[0]} \${item} = a, \${thing} = e - Should Be For Item ${for_loop.kws[1]} \${item} = b, \${thing} = f - Should Be For Item ${for_loop.kws[2]} \${item} = c, \${thing} = g - Should Be For Item ${for_loop.kws[3]} \${item} = d, \${thing} = h - -For In Zip With Uneven Lists - ${tc} = Check Test Case ${TEST NAME} - ${for_loop}= Set Variable ${tc.kws[2]} - Should Be For In Zip Keyword ${for_loop} 3 - Should Be For Item ${for_loop.kws[0]} \${item} = a, \${thing} = d - Should Be For Item ${for_loop.kws[1]} \${item} = b, \${thing} = e - Should Be For Item ${for_loop.kws[2]} \${item} = c, \${thing} = f - -For In Zip With 3 Lists - ${tc} = Check Test Case ${TEST NAME} - ${for_loop}= Set Variable ${tc.kws[3]} - Should Be For In Zip Keyword ${for_loop} 4 - Should Be For Item ${for_loop.kws[0]} \${item} = a, \${thing} = e, \${stuff} = 1 - Should Be For Item ${for_loop.kws[1]} \${item} = b, \${thing} = f, \${stuff} = 2 - Should Be For Item ${for_loop.kws[2]} \${item} = c, \${thing} = g, \${stuff} = 3 - Should Be For Item ${for_loop.kws[3]} \${item} = d, \${thing} = h, \${stuff} = 4 - -For In Zip With Other Iterables - ${tc} = Check Test Case ${TEST NAME} - -For In Zip Rejects Strings as iterable - ${tc} = Check Test Case ${TEST NAME} - ${for_loop}= Set Variable ${tc.kws[1]} - Should Be For In Zip Keyword ${for_loop} 0 - Should Be Equal ${for_loop.status} FAIL - -For In Zip With Non-list - ${tc} = Check Test Case ${TEST NAME} - ${for_loop}= Set Variable ${tc.kws[2]} - Should Be For In Zip Keyword ${for_loop} 0 - Should Be Equal ${for_loop.status} FAIL - -For In Zip With Too Few Variables - Check Test Case ${TEST NAME} - -For In Zip With Too Many Variables - Check Test Case ${TEST NAME} - -For In Enumerate - Check Test Case ${TEST NAME} (with 4 items) - Check Test Case ${TEST NAME} (with 5 items) - -For In Enumerate With 3 Variables - Check Test Case ${TEST NAME} - -For In Enumerate With 4 Variables - Check Test Case ${TEST NAME} - -For In Enumerate With not the right number of variables - Check Test Case ${TEST NAME} - -For In Enumerate With Too Few Variables - Check Test Case ${TEST NAME} - -For In Enumerate With Other Iterables - Check Test Case ${TEST NAME} - -For Loop Of Unexpected Name - Check Test Case ${TEST NAME} - -For In Range With Too Many Arguments - ${tc} = Check Test Case ${TEST NAME} - Should Be For In Range Keyword ${tc.kws[0]} 0 - -For In Range With No Arguments - Check Test Case ${TEST NAME} - -For In Range With Non-Number Arguments - Check Test Case ${TEST NAME} 1 - Check Test Case ${TEST NAME} 2 - -For In Range With Wrong Number Of Variables - Check Test Case ${TEST NAME} - -For In Range With Non-Existing Variables In Arguments - Check Test Case ${TEST NAME} - -For loop marker with colon still works - Check Test Case ${TEST NAME} - -For loop marker with colon is case and space insensitive - Check Test Case ${TEST NAME} - -For loop marker can have many colons - Check Test Case ${TEST NAME} - -Case and space insensitive for loop separator is deprecated - Check Test Case ${TEST NAME} - ${path} = Normalize Path ${DATADIR}/running/for.robot - ${message} = Catenate - ... Error in file '${path}': - ... Invalid syntax in test case '${TEST NAME}': - ... Using 'in' as a FOR loop separator is deprecated. Use 'IN' instead. - Check log message ${ERRORS}[0] ${message} WARN - ${message} = Catenate - ... Error in file '${path}': - ... Invalid syntax in test case '${TEST NAME}': - ... Using 'INRANGE' as a FOR loop separator is deprecated. Use 'IN RANGE' instead. - Check log message ${ERRORS}[1] ${message} WARN - ${message} = Catenate - ... Error in file '${path}': - ... Invalid syntax in test case '${TEST NAME}': - ... Using 'I ne numer ate' as a FOR loop separator is deprecated. Use 'IN ENUMERATE' instead. - Check log message ${ERRORS}[2] ${message} WARN - -Escaping with backslash still works - ${tc} = Check Test Case ${TEST NAME} - Should Be For Keyword ${tc.kws[0]} 2 - Should Be For Item ${tc.kws[0].kws[0]} \${var} = one - Check log message ${tc.kws[0].kws[0].kws[0].msgs[0]} var: one - Check KW "For in UK with backslashes" ${tc.kws[0].kws[0].kws[1]} one - Should Be For Item ${tc.kws[0].kws[1]} \${var} = two - Check log message ${tc.kws[0].kws[1].kws[0].msgs[0]} var: two - Check KW "For in UK with backslashes" ${tc.kws[0].kws[1].kws[1]} two - Check log message ${tc.kws[1].msgs[0]} Between for loops - Should Be For Keyword ${tc.kws[2]} 2 - Should Be For Item ${tc.kws[2].kws[0]} \${var} = one - Check log message ${tc.kws[2].kws[0].kws[0].msgs[0]} var: one - Check KW "For in UK with backslashes" ${tc.kws[0].kws[0].kws[1]} one - Should Be For Item ${tc.kws[2].kws[1]} \${var} = two - Check log message ${tc.kws[2].kws[1].kws[0].msgs[0]} var: two - Check KW "For in UK with backslashes" ${tc.kws[0].kws[1].kws[1]} two - -END is not required when escaping with backslash - ${tc} = Check Test Case ${TEST NAME} - Should Be For Keyword ${tc.kws[0]} 2 - Should Be For Item ${tc.kws[0].kws[0]} \${var} = one - Check log message ${tc.kws[0].kws[0].kws[0].msgs[0]} var: one - Check KW "For in UK with backslashes" ${tc.kws[0].kws[0].kws[1]} one - Should Be For Item ${tc.kws[0].kws[1]} \${var} = two - Check log message ${tc.kws[0].kws[1].kws[0].msgs[0]} var: two - Check KW "For in UK with backslashes" ${tc.kws[0].kws[1].kws[1]} two - Check log message ${tc.kws[1].msgs[0]} Between for loops - Should Be For Keyword ${tc.kws[2]} 2 - Should Be For Item ${tc.kws[2].kws[0]} \${var} = one - Check log message ${tc.kws[2].kws[0].kws[0].msgs[0]} var: one - Check KW "For in UK with backslashes" ${tc.kws[0].kws[0].kws[1]} one - Should Be For Item ${tc.kws[2].kws[1]} \${var} = two - Check log message ${tc.kws[2].kws[1].kws[0].msgs[0]} var: two - Check KW "For in UK with backslashes" ${tc.kws[0].kws[1].kws[1]} two - -*** Keywords *** -Should Be For Keyword - [Arguments] ${kw} ${subcount} - Should Be Equal ${kw.type} for Not FOR keyword - Should Contain ${kw.name} IN Not FOR keyword - Should Be Equal As Integers ${kw.keyword_count} ${subcount} Wrong number of sub keywords - -Should Be For In Range Keyword - [Arguments] ${kw} ${subcount} - Should Be For Keyword ${kw} ${subcount} - Should Contain ${kw.name} IN RANGE Not FOR IN RANGE keyword - -Should Be For In Zip Keyword - [Arguments] ${kw} ${subcount} - Should Be For Keyword ${kw} ${subcount} - Should Contain ${kw.name} IN ZIP Not FOR IN ZIP keyword - -Should Be For Item - [Arguments] ${kw} ${name} - Should Be Equal ${kw.type} foritem Not FOR item - Should Be Equal ${kw.name} ${name} - -Test "Simple For 2" 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 - -Test "For With Repeat" Helper - [Arguments] ${kw} ${num} - Should Be Equal ${kw.kws[0].name} 0x BuiltIn.Fail - Check Log Message ${kw.kws[1].msgs[1]} Executed once - Should Be Equal ${kw.kws[2].name} 2x My UK - Should Be Equal ${kw.kws[2].kws[0].name} BuiltIn.No Operation - Check Log Message ${kw.kws[2].kws[1].msgs[0]} We are in My UK - Should Be Equal ${kw.kws[2].kws[2].name} BuiltIn.No Operation - Check Log Message ${kw.kws[2].kws[3].msgs[0]} We are in My UK - Should Be Equal ${kw.kws[3].name} ${num}x BuiltIn.Log - Check Log Message ${kw.kws[3].msgs[1]} Executed ${num} times - Check Log Message ${kw.kws[4].msgs[1]} Executed 5 times - Check Log Message ${kw.kws[4].msgs[1]} Executed 5 times - Check Log Message ${kw.kws[4].msgs[2]} Executed 5 times - Check Log Message ${kw.kws[4].msgs[3]} Executed 5 times - Check Log Message ${kw.kws[4].msgs[4]} Executed 5 times - -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 - -Check KW "My UK 2" - [Arguments] ${kw} ${arg} - Should Be Equal ${kw.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 KW "My UK" ${kw.kws[2]} - -Check KW "For In UK" - [Arguments] ${kw} - Should Be Equal ${kw.name} For In UK - Check Log Message ${kw.kws[0].msgs[0]} Not for yet - Should Be For Keyword ${kw.kws[1]} 2 - Check Log Message ${kw.kws[1].kws[0].kws[0].msgs[0]} This is for with 1 - Check KW "My UK" ${kw.kws[1].kws[0].kws[1]} - Check Log Message ${kw.kws[1].kws[1].kws[0].msgs[0]} This is for with 2 - Check KW "My UK" ${kw.kws[1].kws[1].kws[1]} - Check Log Message ${kw.kws[2].msgs[0]} Not for anymore - -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 For Keyword ${kw.kws[0]} ${arg_count} - Check KW "My UK 2" ${kw.kws[0].kws[0].kws[0]} ${first_arg} - Should Be For Keyword ${kw.kws[2]} 1 - Check Log Message ${kw.kws[2].kws[0].kws[0].msgs[0]} This for loop is executed only once - -Check KW "Nested For In UK" - [Arguments] ${kw} ${first_arg} - Should Be For Keyword ${kw.kws[0]} 1 - 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 For Keyword ${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} - Check Log Message ${nested2.kws[1].msgs[0]} This ought to be enough FAIL - -Check KW "For in UK with backslashes" - [Arguments] ${kw} ${arg} - Should Be For Keyword ${kw.kws[0]} 2 - Should Be For Item ${kw.kws[0].kws[0]} \${x} = 1 - Check log message ${kw.kws[0].kws[0].kws[1].msgs[0]} ${arg}-1 - Should Be For Item ${kw.kws[0].kws[1]} \${x} = 2 - Check log message ${kw.kws[0].kws[1].kws[1].msgs[0]} ${arg}-2 diff --git a/atest/robot/running/for/break_and_continue.robot b/atest/robot/running/for/break_and_continue.robot new file mode 100644 index 00000000000..e79998042f0 --- /dev/null +++ b/atest/robot/running/for/break_and_continue.robot @@ -0,0 +1,59 @@ +*** Settings *** +Suite Setup Run Tests ${EMPTY} running/for/break_and_continue.robot +Resource for.resource +Test Template Test and all keywords should have passed + +*** Test Cases *** +CONTINUE + allow not run=True + +CONTINUE inside IF + allow not run=True allowed failure=Oh no, got 4 + +CONTINUE inside TRY + allow not run=True + +CONTINUE inside EXCEPT and TRY-ELSE + allow not run=True allowed failure=4 == 4 + +BREAK + allow not run=True + +BREAK inside IF + allow not run=True + +BREAK inside TRY + allow not run=True + +BREAK inside EXCEPT + allow not run=True allowed failure=This is excepted! + +BREAK inside TRY-ELSE + allow not run=True + +CONTINUE in UK + allow not run=True + +CONTINUE inside IF in UK + allow not run=True allowed failure=Oh no, got 4 + +CONTINUE inside TRY in UK + allow not run=True + +CONTINUE inside EXCEPT and TRY-ELSE in UK + allow not run=True allowed failure=4 == 4 + +BREAK in UK + allow not run=True + +BREAK inside IF in UK + allow not run=True + +BREAK inside TRY in UK + allow not run=True + +BREAK inside EXCEPT in UK + allow not run=True allowed failure=This is excepted! + +BREAK inside TRY-ELSE in UK + allow not run=True diff --git a/atest/robot/running/for/continue_for_loop.robot b/atest/robot/running/for/continue_for_loop.robot new file mode 100644 index 00000000000..59ab08e67b4 --- /dev/null +++ b/atest/robot/running/for/continue_for_loop.robot @@ -0,0 +1,54 @@ +*** Settings *** +Suite Setup Run Tests ${EMPTY} running/for/continue_for_loop.robot +Resource atest_resource.robot + +*** Test Cases *** +Simple Continue For Loop + Test And All Keywords Should Have Passed allow not run=True + +Continue For Loop In `Run Keyword` + Test And All Keywords Should Have Passed allow not run=True + +Continue For Loop is not supported in user keyword + Check Test Case ${TESTNAME} + +Continue For Loop Should Terminate Immediate Loop Only + Test And All Keywords Should Have Passed allow not run=True + +Continue For Loop In User Keyword Should Terminate Immediate Loop Only + Test And All Keywords Should Have Passed allow not run=True + +Continue For Loop Without For Loop Should Fail + Check Test Case ${TESTNAME} + +Continue For Loop In User Keyword Without For Loop Should Fail + Check Test Case ${TESTNAME} + +Continue For Loop Keyword Should Log Info + ${tc} = Check Test Case Simple Continue For Loop + Should Be Equal ${tc[0, 0, 0].full_name} BuiltIn.Continue For Loop + Check Log Message ${tc[0, 0, 0, 0]} Continuing for loop from the next iteration. + +Continue For Loop In Test Teardown + Test And All Keywords Should Have Passed + +Continue For Loop In Keyword Teardown + Test And All Keywords Should Have Passed + +Invalid Continue For Loop In User Keyword Teardown + Check Test Case ${TESTNAME} + +Continue For Loop If True + Check Test Case ${TESTNAME} + +Continue For Loop If False + Check Test Case ${TESTNAME} + +With Continuable Failure After + Check Test Case ${TESTNAME} + +With Continuable Failure Before + Check Test Case ${TESTNAME} + +With Continuable Failure In User Keyword + Check Test Case ${TESTNAME} diff --git a/atest/robot/running/for/exit_for_loop.robot b/atest/robot/running/for/exit_for_loop.robot new file mode 100644 index 00000000000..aff02d26484 --- /dev/null +++ b/atest/robot/running/for/exit_for_loop.robot @@ -0,0 +1,57 @@ +*** Settings *** +Suite Setup Run Tests ${EMPTY} running/for/exit_for_loop.robot +Resource atest_resource.robot + +*** Test Cases *** +Simple Exit For Loop + Test And All Keywords Should Have Passed allow not run=True + +Exit For Loop In `Run Keyword` + Test And All Keywords Should Have Passed allow not run=True + +Exit For Loop is not supported in user keyword + Check Test Case ${TESTNAME} + +Exit For Loop In User Keyword With Loop + Test And All Keywords Should Have Passed allow not run=True + +Exit For Loop In User Keyword With Loop Within Loop + Test And All Keywords Should Have Passed allow not run=True + +Exit For Loop In User Keyword Calling User Keyword With Exit For Loop + Check Test Case ${TESTNAME} + +Exit For Loop Without For Loop Should Fail + Check Test Case ${TESTNAME} + +Exit For Loop In User Keyword Without For Loop Should Fail + Check Test Case ${TESTNAME} + +Exit For Loop Keyword Should Log Info + ${tc} = Check Test Case Simple Exit For Loop + Should Be Equal ${tc[0, 0, 0].full_name} BuiltIn.Exit For Loop + Check Log Message ${tc[0, 0, 0, 0]} Exiting for loop altogether. + +Exit For Loop In Test Teardown + Test And All Keywords Should Have Passed + +Exit For Loop In Keyword Teardown + Test And All Keywords Should Have Passed + +Invalid Exit For Loop In User Keyword Teardown + Check Test Case ${TESTNAME} + +Exit For Loop If True + Check Test Case ${TESTNAME} + +Exit For Loop If False + Check Test Case ${TESTNAME} + +With Continuable Failure After + Check Test Case ${TESTNAME} + +With Continuable Failure Before + Check Test Case ${TESTNAME} + +With Continuable Failure In User Keyword + Check Test Case ${TESTNAME} diff --git a/atest/robot/running/for/for.resource b/atest/robot/running/for/for.resource new file mode 100644 index 00000000000..9b29093eda6 --- /dev/null +++ b/atest/robot/running/for/for.resource @@ -0,0 +1,44 @@ +*** Settings *** +Resource atest_resource.robot + +*** Keywords *** +Check test and get loop + [Arguments] ${test name} ${loop index}=0 + ${tc} = Check Test Case ${test name} + RETURN ${tc.body}[${loop index}] + +Check test and failed loop + [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[0].type} ITERATION + Should Be Equal ${loop[1].type} MESSAGE + Run Keyword Should Be ${type} loop ${loop} 1 FAIL &{config} + +Should be FOR loop + [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.non_messages} ${iterations} + Should Be Equal ${loop.status} ${status} + +Should be IN RANGE loop + [Arguments] ${loop} ${iterations} ${status}=PASS + Should Be FOR Loop ${loop} ${iterations} ${status} IN RANGE + +Should be IN ZIP loop + [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=${start} + +Should be FOR iteration + [Arguments] ${iteration} &{assign} + Should Be Equal ${iteration.type} ITERATION + Should Be Equal ${iteration.assign} ${assign} diff --git a/atest/robot/running/for/for.robot b/atest/robot/running/for/for.robot new file mode 100644 index 00000000000..93e7769b44b --- /dev/null +++ b/atest/robot/running/for/for.robot @@ -0,0 +1,342 @@ +*** Settings *** +Suite Setup Run Tests --log log-tests-also-string-reprs.html running/for/for.robot +Suite Teardown File Should Exist ${OUTDIR}/log-tests-also-string-reprs.html +Resource for.resource + +*** Test Cases *** +Simple loop + ${tc} = Check Test Case ${TEST NAME} + Check Log Message ${tc[0, 0]} Not yet in FOR + Should be FOR loop ${tc[1]} 2 + Should be FOR iteration ${tc[1, 0]} \${var}=one + Check Log Message ${tc[1, 0, 0, 0]} var: one + Should be FOR iteration ${tc[1, 1]} \${var}=two + Check Log Message ${tc[1, 1, 0, 0]} var: two + Check Log Message ${tc[2, 0]} Not in FOR anymore + +Variables in values + ${loop} = Check test and get loop ${TEST NAME} + Should be FOR loop ${loop} 6 + "Variables in values" helper ${loop[0]} 1 + "Variables in values" helper ${loop[1]} 2 + "Variables in values" helper ${loop[2]} 3 + "Variables in values" helper ${loop[3]} 4 + "Variables in values" helper ${loop[4]} 5 + "Variables in values" helper ${loop[5]} 6 + +Indentation is not required + ${loop} = Check test and get loop ${TEST NAME} 1 + Should be FOR loop ${loop} 2 + +Values on multiple rows + ${loop} = Check test and get loop ${TEST NAME} + Should be FOR loop ${loop} 10 + Check Log Message ${loop[0, 0, 0]} 1 + FOR ${i} IN RANGE 10 + Check Log Message ${loop[${i}, 0, 0]} ${{str($i + 1)}} + END + # Sanity check + Check Log Message ${loop[0, 0, 0]} 1 + Check Log Message ${loop[4, 0, 0]} 5 + Check Log Message ${loop[9, 0, 0]} 10 + +Keyword arguments on multiple rows + ${loop} = Check test and get loop ${TEST NAME} + Should be FOR loop ${loop} 2 + Check Log Message ${loop[0, 1, 0]} 1 2 3 4 5 6 7 one + Check Log Message ${loop[1, 1, 0]} 1 2 3 4 5 6 7 two + +Multiple loops in a test + ${tc} = Check Test Case ${TEST NAME} + Should be FOR loop ${tc[0]} 2 + Check Log Message ${tc[0, 0, 0, 0]} In first loop with "foo" + Check Log Message ${tc[0, 1, 0, 0]} In first loop with "bar" + Should be FOR loop ${tc[1]} 1 + Check kw "My UK 2" ${tc[1, 0, 0]} Hello, world! + Check Log Message ${tc[2, 0]} Outside loop + Should be FOR loop ${tc[3]} 2 + Check Log Message ${tc[3, 0, 0, 0]} Third loop + Check Log Message ${tc[3, 0, 2, 0]} Value: a + Check Log Message ${tc[3, 1, 0, 0]} Third loop + Check Log Message ${tc[3, 1, 2, 0]} Value: b + Check Log Message ${tc[4, 0]} The End + +Nested loop syntax + ${tc} = Check Test Case ${TEST NAME} + Should be FOR loop ${tc[0]} 3 + Should be FOR loop ${tc[0, 0, 1]} 3 + Check Log Message ${tc[0, 0, 0, 0]} 1 in + Check Log Message ${tc[0, 0, 1, 0, 0, 0]} values 1 a + Check Log Message ${tc[0, 0, 1, 1, 0, 0]} values 1 b + Check Log Message ${tc[0, 0, 1, 2, 0, 0]} values 1 c + Check Log Message ${tc[0, 0, 2, 0]} 1 out + Check Log Message ${tc[0, 1, 0, 0]} 2 in + Check Log Message ${tc[0, 1, 1, 0, 0, 0]} values 2 a + Check Log Message ${tc[0, 1, 1, 1, 0, 0]} values 2 b + Check Log Message ${tc[0, 1, 1, 2, 0, 0]} values 2 c + Check Log Message ${tc[0, 1, 2, 0]} 2 out + Check Log Message ${tc[0, 2, 0, 0]} 3 in + Check Log Message ${tc[0, 2, 1, 0, 0, 0]} values 3 a + Check Log Message ${tc[0, 2, 1, 1, 0, 0]} values 3 b + Check Log Message ${tc[0, 2, 1, 2, 0, 0]} values 3 c + Check Log Message ${tc[0, 2, 2, 0]} 3 out + Check Log Message ${tc[1, 0]} The End + +Multiple loops in a loop + Check Test Case ${TEST NAME} + +Deeply nested loops + Check Test Case ${TEST NAME} + +Settings after FOR + ${tc} = Check Test Case ${TEST NAME} + Should be FOR loop ${tc[0]} 1 + Check Log Message ${tc.teardown[0]} Teardown was found and eXecuted. + +Looping over empty list variable is OK + ${tc} = Check Test Case ${TEST NAME} + Should be FOR loop ${tc[0]} 1 NOT RUN + Should be FOR iteration ${tc[0, 0]} \${var}= + Check keyword data ${tc[0, 0, 0]} BuiltIn.Fail args=Not executed status=NOT RUN + +Other iterables + ${tc} = Check Test Case ${TEST NAME} + Should be FOR loop ${tc[2]} 10 + +Failure inside FOR + ${loop} = Check test and get loop ${TEST NAME} 1 + Should be FOR loop ${loop} 1 FAIL + Check Log Message ${loop[0, 0, 0]} Hello before failing kw + Should Be Equal ${loop[0, 0].status} PASS + Check Log Message ${loop[0, 1, 0]} Here we fail! FAIL + Should Be Equal ${loop[0, 1].status} FAIL + Should Be Equal ${loop[0, 2].status} NOT RUN + Should Be Equal ${loop[0].status} FAIL + Length Should Be ${loop[0].body} 3 + ${loop} = Check test and get loop ${TEST NAME} 2 + Should be FOR loop ${loop} 4 FAIL + Check Log Message ${loop[0, 0, 0]} Before Check + Check Log Message ${loop[0, 2, 0]} After Check + Length Should Be ${loop[0].body} 3 + Should Be Equal ${loop[0].status} PASS + Should Be Equal ${loop[1].status} PASS + Should Be Equal ${loop[2].status} PASS + Check Log Message ${loop[3, 0, 0]} Before Check + Check Log Message ${loop[3, 1, 0]} Failure with <4> FAIL + Should Be Equal ${loop[3, 2].status} NOT RUN + Length Should Be ${loop[3].body} 3 + Should Be Equal ${loop[3].status} FAIL + +Loop with user keywords + ${loop} = Check test and get loop ${TEST NAME} + Should be FOR loop ${loop} 2 + Check kw "My UK" ${loop[0, 0]} + Check kw "My UK 2" ${loop[0, 1]} foo + Check kw "My UK" ${loop[1, 0]} + Check kw "My UK 2" ${loop[1, 1]} bar + +Loop with failures in user keywords + ${tc} = Check Test Case ${TEST NAME} + Should be FOR loop ${tc[0]} 2 FAIL + +Loop in user keyword + ${tc} = Check Test Case ${TEST NAME} + Check kw "For In UK" ${tc[0]} + Check kw "For In UK with Args" ${tc[1]} 4 one + +Keyword with loop calling other keywords with loops + ${tc} = Check Test Case ${TEST NAME} + Check kw "Nested For In UK" ${tc[0]} foo + +Test with loop calling keywords with loops + ${loop} = Check test and get loop ${TEST NAME} 1 + Should be FOR loop ${loop} 1 FAIL + Check kw "For In UK" ${loop[0, 0]} + Check kw "For In UK with Args" ${loop[0, 1]} 2 one + Check kw "Nested For In UK" ${loop[0, 2]} one + +Loop variables is available after loop + Check Test Case ${TEST NAME} + +Assign inside loop + ${loop} = Check test and get loop ${TEST NAME} + Should be FOR loop ${loop} 2 + Check Log Message ${loop[0, 0, 0]} \${v1} = v1 + Check Log Message ${loop[0, 1, 0]} \${v2} = v2 + Check Log Message ${loop[0, 1, 1]} \${v3} = vY + Check Log Message ${loop[0, 2, 0]} \@{list} = [ v1 | v2 | vY | Y ] + Check Log Message ${loop[1, 0, 0]} \${v1} = v1 + Check Log Message ${loop[1, 1, 0]} \${v2} = v2 + Check Log Message ${loop[1, 1, 1]} \${v3} = vZ + Check Log Message ${loop[1, 2, 0]} \@{list} = [ v1 | v2 | vZ | Z ] + +Invalid assign inside loop + ${tc} = Check Test Case ${TEST NAME} + Should be FOR loop ${tc[0]} 1 FAIL + +Loop with non-existing keyword + Check Test Case ${TEST NAME} + +Loop with non-existing variable + Check Test Case ${TEST NAME} + +Loop value with non-existing variable + Check Test Case ${TEST NAME} + +Multiple loop variables + ${tc} = Check Test Case ${TEST NAME} + ${loop} = Set Variable ${tc[0]} + Should be FOR loop ${loop} 4 + Should be FOR iteration ${loop[0]} \${x}=1 \${y}=a + Check Log Message ${loop[0, 0, 0]} 1a + Should be FOR iteration ${loop[1]} \${x}=2 \${y}=b + Check Log Message ${loop[1, 0, 0]} 2b + Should be FOR iteration ${loop[2]} \${x}=3 \${y}=c + Check Log Message ${loop[2, 0, 0]} 3c + Should be FOR iteration ${loop[3]} \${x}=4 \${y}=d + Check Log Message ${loop[3, 0, 0]} 4d + ${loop} = Set Variable ${tc[2]} + Should be FOR loop ${loop} 2 + Should be FOR iteration ${loop[0]} \${a}=1 \${b}=2 \${c}=3 \${d}=4 \${e}=5 + Should be FOR iteration ${loop[1]} \${a}=1 \${b}=2 \${c}=3 \${d}=4 \${e}=5 + +Wrong number of loop variables + Check test and failed loop ${TEST NAME} 1 + Check test and failed loop ${TEST NAME} 2 + +Cut long iteration variable values + ${tc} = Check Test Case ${TEST NAME} + ${loop} = Set Variable ${tc[6]} + ${exp10} = Set Variable 0123456789 + ${exp100} = Evaluate "${exp10}" * 10 + ${exp200} = Evaluate "${exp10}" * 20 + ${exp200+} = Set Variable ${exp200}... + Should be FOR loop ${loop} 6 + Should be FOR iteration ${loop[0]} \${var}=${exp10} + Should be FOR iteration ${loop[1]} \${var}=${exp100} + Should be FOR iteration ${loop[2]} \${var}=${exp200} + Should be FOR iteration ${loop[3]} \${var}=${exp200+} + Should be FOR iteration ${loop[4]} \${var}=${exp200+} + Should be FOR iteration ${loop[5]} \${var}=${exp200+} + ${loop} = Set Variable ${tc[7]} + Should be FOR loop ${loop} 2 + Should be FOR iteration ${loop[0]} \${var1}=${exp10} \${var2}=${exp100} \${var3}=${exp200} + Should be FOR iteration ${loop[1]} \${var1}=${exp200+} \${var2}=${exp200+} \${var3}=${exp200+} + +Characters that are illegal in XML + ${tc} = Check Test Case ${TEST NAME} + Should be FOR iteration ${tc[0, 0]} \${var}=illegal: + Should be FOR iteration ${tc[0, 1]} \${var}=more: + +Old :FOR syntax is not supported + Check Test Case ${TESTNAME} + +Escaping with backslash is not supported + Check Test Case ${TESTNAME} + +FOR is case and space sensitive + Check Test Case ${TEST NAME} 1 + Check Test Case ${TEST NAME} 2 + +Invalid END usage + Check Test Case ${TEST NAME} 1 + Check Test Case ${TEST NAME} 2 + +Empty body + Check test and failed loop ${TEST NAME} + +No END + Check test and failed loop ${TEST NAME} + +Invalid END + Check test and failed loop ${TEST NAME} + +No loop values + Check test and failed loop ${TEST NAME} + +No loop variables + Check test and failed loop ${TEST NAME} + +Invalid loop variable + Check test and failed loop ${TEST NAME} 1 + Check test and failed loop ${TEST NAME} 2 + Check test and failed loop ${TEST NAME} 3 + Check test and failed loop ${TEST NAME} 4 + Check test and failed loop ${TEST NAME} 5 + Check test and failed loop ${TEST NAME} 6 + +Invalid separator + Check Test Case ${TEST NAME} + +Separator is case- and space-sensitive + Check Test Case ${TEST NAME} 1 + Check Test Case ${TEST NAME} 2 + Check Test Case ${TEST NAME} 3 + Check Test Case ${TEST NAME} 4 + +FOR without any paramenters + Check Test Case ${TESTNAME} + +Syntax error in nested loop + Check Test Case ${TESTNAME} 1 + Check Test Case ${TESTNAME} 2 + +Unexecuted + ${tc} = Check Test Case ${TESTNAME} + Should be FOR loop ${tc[1, 0, 0]} 1 NOT RUN + Should be FOR iteration ${tc[1, 0, 0, 0]} \${x}= \${y}= + Should be FOR loop ${tc[5]} 1 NOT RUN + Should be FOR iteration ${tc[5, 0]} \${x}= \${y}= + +Header at the end of file + Check Test Case ${TESTNAME} + +*** Keywords *** +"Variables in values" helper + [Arguments] ${kw} ${num} + Check Log Message ${kw[0, 0]} ${num} + Check Log Message ${kw[1, 0]} Hello from for loop + Should Be Equal ${kw[2].full_name} BuiltIn.No Operation + +Check kw "My UK" + [Arguments] ${kw} + Should Be Equal ${kw.full_name} My UK + Should Be Equal ${kw[0].full_name} BuiltIn.No Operation + Check Log Message ${kw[1, 0]} We are in My UK + +Check kw "My UK 2" + [Arguments] ${kw} ${arg} + Should Be Equal ${kw.full_name} My UK 2 + Check kw "My UK" ${kw[0]} + Check Log Message ${kw[1, 0]} My UK 2 got argument "${arg}" + Check kw "My UK" ${kw[2]} + +Check kw "For In UK" + [Arguments] ${kw} + Should Be Equal ${kw.full_name} For In UK + Check Log Message ${kw[0, 0]} Not for yet + Should be FOR loop ${kw[1]} 2 + Check Log Message ${kw[1, 0, 0, 0]} This is for with 1 + Check kw "My UK" ${kw[1, 0, 1]} + Check Log Message ${kw[1, 1, 0, 0]} This is for with 2 + Check kw "My UK" ${kw[1, 1, 1]} + Check Log Message ${kw[2, 0]} Not for anymore + +Check kw "For In UK With Args" + [Arguments] ${kw} ${arg_count} ${first_arg} + Should Be Equal ${kw.full_name} For In UK With Args + Should be FOR loop ${kw[0]} ${arg_count} + Check kw "My UK 2" ${kw[0, 0, 0]} ${first_arg} + Should be FOR loop ${kw[2]} 1 + Check Log Message ${kw[2, 0, 0, 0]} This for loop is executed only once + +Check kw "Nested For In UK" + [Arguments] ${kw} ${first_arg} + Should be FOR loop ${kw[0]} 1 FAIL + Check kw "For In UK" ${kw[0, 0, 0]} + ${nested2} = Set Variable ${kw[0, 0, 1]} + Should Be Equal ${nested2.full_name} Nested For In UK 2 + Should be FOR loop ${nested2[0]} 2 + Check kw "For In UK" ${nested2[0, 0, 0]} + Check Log Message ${nested2[0, 0, 1, 0]} Got arg: ${first_arg} + Check Log Message ${nested2[1, 0]} This ought to be enough FAIL diff --git a/atest/robot/running/for/for_dict_iteration.robot b/atest/robot/running/for/for_dict_iteration.robot new file mode 100644 index 00000000000..a8b840726fc --- /dev/null +++ b/atest/robot/running/for/for_dict_iteration.robot @@ -0,0 +1,119 @@ +*** Settings *** +Suite Setup Run Tests ${EMPTY} running/for/for_dict_iteration.robot +Resource for.resource + +*** Test Cases *** +FOR loop with one variable + ${loop} = Check test and get loop ${TESTNAME} + Should be FOR loop ${loop} 3 + Should be FOR iteration ${loop.body[0]} \${item}=('a', '1') + Should be FOR iteration ${loop.body[1]} \${item}=('b', '2') + Should be FOR iteration ${loop.body[2]} \${item}=('c', '3') + +FOR loop with two variables + ${loop} = Check test and get loop ${TESTNAME} + Should be FOR loop ${loop} 3 + Should be FOR iteration ${loop.body[0]} \${key}=a \${value}=1 + Should be FOR iteration ${loop.body[1]} \${key}=b \${value}=2 + Should be FOR iteration ${loop.body[2]} \${key}=c \${value}=3 + +FOR loop with more than two variables is invalid + Check test and failed loop ${TESTNAME} + +FOR IN ENUMERATE loop with one variable + ${loop} = Check test and get loop ${TESTNAME} + Should be IN ENUMERATE loop ${loop} 3 + Should be FOR iteration ${loop.body[0]} \${var}=(0, 'a', '1') + Should be FOR iteration ${loop.body[1]} \${var}=(1, 'b', '2') + Should be FOR iteration ${loop.body[2]} \${var}=(2, 'c', '3') + +FOR IN ENUMERATE loop with two variables + ${loop} = Check test and get loop ${TESTNAME} + Should be IN ENUMERATE loop ${loop} 3 + Should be FOR iteration ${loop.body[0]} \${index}=0 \${item}=('a', '1') + Should be FOR iteration ${loop.body[1]} \${index}=1 \${item}=('b', '2') + Should be FOR iteration ${loop.body[2]} \${index}=2 \${item}=('c', '3') + +FOR IN ENUMERATE loop with three variables + ${loop} = Check test and get loop ${TESTNAME} + Should be IN ENUMERATE loop ${loop} 3 + Should be FOR iteration ${loop.body[0]} \${i}=0 \${k}=a \${v}=1 + Should be FOR iteration ${loop.body[1]} \${i}=1 \${k}=b \${v}=2 + Should be FOR iteration ${loop.body[2]} \${i}=2 \${k}=c \${v}=3 + +FOR IN ENUMERATE loop with start + ${loop} = Check test and get loop ${TESTNAME} + 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 + +FOR IN RANGE loop doesn't support dict iteration + Check test and failed loop ${TESTNAME} IN RANGE + +FOR IN ZIP loop doesn't support dict iteration + Check test and failed loop ${TESTNAME} IN ZIP + +Multiple dict variables + Check Test Case ${TESTNAME} + +Dict variable with 'key=value' syntax + Check Test Case ${TESTNAME} + +Last value wins + Check Test Case ${TESTNAME} + +Equal sign in variable + Check Test Case ${TESTNAME} + +'key=value' alone is still considered "normal" iteration + ${tc} = Check Test Case ${TESTNAME} + ${message} = Catenate + ... FOR loop iteration over values that are all in 'name=value' format like 'a=1' 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 'a\\=1' to use normal FOR loop iteration and to disable this warning. + Check Log Message ${tc[0, 0]} ${message} WARN + Check Log Message ${ERRORS}[0] ${message} WARN + ${message} = Catenate + ... FOR loop iteration over values that are all in 'name=value' format like 'x==1' 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 'x\\==1' to use normal FOR loop iteration and to disable this warning. + Check Log Message ${tc[2, 0]} ${message} WARN + Check Log Message ${ERRORS}[1] ${message} WARN + +Non-string keys + Check Test Case ${TESTNAME} + +Invalid key + Check Test Case ${TESTNAME} + +Invalid dict + Check Test Case ${TESTNAME} 1 + Check Test Case ${TESTNAME} 2 + +Non-existing variable + Check Test Case ${TESTNAME} 1 + Check Test Case ${TESTNAME} 2 + +Dict variables and invalid values + Check test and failed loop ${TESTNAME} 1 + Check test and failed loop ${TESTNAME} 2 + Check test and failed loop ${TESTNAME} 3 + +Equal sign in variable doesn't initiate dict iteration + ${loop} = Check test and get loop ${TESTNAME} + Should be FOR loop ${loop} 2 + Should be FOR iteration ${loop.body[0]} \${item}== + Should be FOR iteration ${loop.body[1]} \${item}== + +'key=value' syntax with normal values doesn't initiate dict iteration + ${loop} = Check test and get loop ${TESTNAME} 1 + Should be FOR loop ${loop} 3 + Should be FOR iteration ${loop.body[0]} \${item}=a=1 + Should be FOR iteration ${loop.body[1]} \${item}=normal + Should be FOR iteration ${loop.body[2]} \${item}=c=3 + ${loop} = Check test and get loop ${TESTNAME} 2 + Should be FOR loop ${loop} 3 + Should be FOR iteration ${loop.body[0]} \${item}=a=1 + Should be FOR iteration ${loop.body[1]} \${item}=b=2 + Should be FOR iteration ${loop.body[2]} \${item}=c=3 diff --git a/atest/robot/running/for/for_in_enumerate.robot b/atest/robot/running/for/for_in_enumerate.robot new file mode 100644 index 00000000000..67ddc3c3134 --- /dev/null +++ b/atest/robot/running/for/for_in_enumerate.robot @@ -0,0 +1,71 @@ +*** Settings *** +Suite Setup Run Tests ${EMPTY} running/for/for_in_enumerate.robot +Resource for.resource + +*** Test Cases *** +Index and item + ${loop} = Check test and get loop ${TEST NAME} + Should be IN ENUMERATE loop ${loop} 4 + Should be FOR iteration ${loop.body[0]} \${index}=0 \${item}=a + Should be FOR iteration ${loop.body[1]} \${index}=1 \${item}=b + Should be FOR iteration ${loop.body[2]} \${index}=2 \${item}=c + Should be FOR iteration ${loop.body[3]} \${index}=3 \${item}=d + +Values from list variable + ${loop} = Check test and get loop ${TEST NAME} + Should be IN ENUMERATE loop ${loop} 4 + Should be FOR iteration ${loop.body[0]} \${index}=0 \${item}=a + Should be FOR iteration ${loop.body[1]} \${index}=1 \${item}=b + Should be FOR iteration ${loop.body[2]} \${index}=2 \${item}=c + Should be FOR iteration ${loop.body[3]} \${index}=3 \${item}=d + +Start + ${loop} = Check test and get loop ${TEST NAME} + 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 + Should be FOR iteration ${loop.body[3]} \${index}=4 \${item}=4 + Should be FOR iteration ${loop.body[4]} \${index}=5 \${item}=5 + +Escape start + ${loop} = Check test and get loop ${TEST NAME} + Should be IN ENUMERATE loop ${loop} 2 + +Invalid start + Check test and failed loop ${TEST NAME} IN ENUMERATE start=invalid + +Invalid variable in start + Check test and failed loop ${TEST NAME} IN ENUMERATE start=\${invalid} + +Start multiple times + ${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 + Should be IN ENUMERATE loop ${loop} 3 + Should be FOR iteration ${loop.body[0]} \${i}=0 \${item1}=a \${item2}=b + Should be FOR iteration ${loop.body[1]} \${i}=1 \${item1}=c \${item2}=d + Should be FOR iteration ${loop.body[2]} \${i}=2 \${item1}=e \${item2}=f + +Index and five items + ${loop} = Check test and get loop ${TEST NAME} 1 + Should be IN ENUMERATE loop ${loop} 2 + Should be FOR iteration ${loop.body[0]} \${x}=0 \${i1}=a \${i2}=b \${i3}=c \${i4}=d \${i5}=e + Should be FOR iteration ${loop.body[1]} \${x}=1 \${i1}=f \${i2}=g \${i3}=h \${i4}=i \${i5}=j + +One variable only + ${loop} = Check test and get loop ${TEST NAME} + Should be IN ENUMERATE loop ${loop} 3 + Should be FOR iteration ${loop.body[0]} \${item}=(0, 'a') + Should be FOR iteration ${loop.body[1]} \${item}=(1, 'b') + +Wrong number of variables + Check test and failed loop ${TEST NAME} IN ENUMERATE + +No values + Check test and failed loop ${TEST NAME} IN ENUMERATE + +No values with start + Check test and failed loop ${TEST NAME} IN ENUMERATE start=0 diff --git a/atest/robot/running/for/for_in_range.robot b/atest/robot/running/for/for_in_range.robot new file mode 100644 index 00000000000..0defe65d78d --- /dev/null +++ b/atest/robot/running/for/for_in_range.robot @@ -0,0 +1,99 @@ +*** Settings *** +Suite Setup Run Tests ${EMPTY} running/for/for_in_range.robot +Resource for.resource + +*** Test Cases *** +Only stop + ${loop} = Check test and get loop ${TEST NAME} + Should be IN RANGE loop ${loop} 100 + Should be FOR iteration ${loop[0]} \${i}=0 + Check log message ${loop[0, 1, 0]} i: 0 + Should be FOR iteration ${loop[1]} \${i}=1 + Check log message ${loop[1, 1, 0]} i: 1 + Should be FOR iteration ${loop[42]} \${i}=42 + Check log message ${loop[42, 1, 0]} i: 42 + Should be FOR iteration ${loop[-1]} \${i}=99 + Check log message ${loop[-1, 1, 0]} i: 99 + +Start and stop + ${loop} = Check test and get loop ${TEST NAME} + Should be IN RANGE loop ${loop} 4 + +Start, stop and step + ${loop} = Check test and get loop ${TEST NAME} + Should be IN RANGE loop ${loop} 3 + Should be FOR iteration ${loop[0]} \${item}=10 + Should be FOR iteration ${loop[1]} \${item}=7 + Should be FOR iteration ${loop[2]} \${item}=4 + +Float stop + ${loop} = Check test and get loop ${TEST NAME} 1 + Should be IN RANGE loop ${loop} 4 + Should be FOR iteration ${loop[0]} \${item}=0.0 + Should be FOR iteration ${loop[1]} \${item}=1.0 + Should be FOR iteration ${loop[2]} \${item}=2.0 + Should be FOR iteration ${loop[3]} \${item}=3.0 + ${loop} = Check test and get loop ${TEST NAME} 2 + Should be IN RANGE loop ${loop} 3 + Should be FOR iteration ${loop[0]} \${item}=0.0 + Should be FOR iteration ${loop[1]} \${item}=1.0 + Should be FOR iteration ${loop[2]} \${item}=2.0 + +Float start and stop + ${loop} = Check test and get loop ${TEST NAME} 1 + Should be IN RANGE loop ${loop} 3 + Should be FOR iteration ${loop[0]} \${item}=-1.5 + Should be FOR iteration ${loop[1]} \${item}=-0.5 + Should be FOR iteration ${loop[2]} \${item}=0.5 + ${loop} = Check test and get loop ${TEST NAME} 2 0 + Should be IN RANGE loop ${loop} 4 + Should be FOR iteration ${loop[0]} \${item}=-1.5 + Should be FOR iteration ${loop[1]} \${item}=-0.5 + Should be FOR iteration ${loop[2]} \${item}=0.5 + Should be FOR iteration ${loop[3]} \${item}=1.5 + +Float start, stop and step + ${loop} = Check test and get loop ${TEST NAME} + Should be IN RANGE loop ${loop} 3 + Should be FOR iteration ${loop[0]} \${item}=10.99 + Should be FOR iteration ${loop[1]} \${item}=7.95 + Should be FOR iteration ${loop[2]} \${item}=4.91 + +Variables in arguments + ${loop} = Check test and get loop ${TEST NAME} 0 + Should be IN RANGE loop ${loop} 2 + ${loop} = Check test and get loop ${TEST NAME} 2 + Should be IN RANGE loop ${loop} 1 + +Calculations + Check test case ${TEST NAME} + +Calculations with floats + Check test case ${TEST NAME} + +Multiple variables + ${loop} = Check test and get loop ${TEST NAME} 0 + Should be IN RANGE loop ${loop} 1 + Should be FOR iteration ${loop[0]} \${a}=0 \${b}=1 \${c}=2 \${d}=3 \${e}=4 + ${loop} = Check test and get loop ${TEST NAME} 2 + Should be IN RANGE loop ${loop} 4 + Should be FOR iteration ${loop[0]} \${i}=-1 \${j}=0 \${k}=1 + Should be FOR iteration ${loop[1]} \${i}=2 \${j}=3 \${k}=4 + Should be FOR iteration ${loop[2]} \${i}=5 \${j}=6 \${k}=7 + Should be FOR iteration ${loop[3]} \${i}=8 \${j}=9 \${k}=10 + +Too many arguments + Check test and failed loop ${TEST NAME} IN RANGE + +No arguments + Check test and failed loop ${TEST NAME} IN RANGE + +Non-number arguments + Check test and failed loop ${TEST NAME} 1 IN RANGE + Check test and failed loop ${TEST NAME} 2 IN RANGE + +Wrong number of variables + Check test and failed loop ${TEST NAME} IN RANGE + +Non-existing variables in arguments + Check test and failed loop ${TEST NAME} IN RANGE diff --git a/atest/robot/running/for/for_in_zip.robot b/atest/robot/running/for/for_in_zip.robot new file mode 100644 index 00000000000..987e080ff40 --- /dev/null +++ b/atest/robot/running/for/for_in_zip.robot @@ -0,0 +1,137 @@ +*** Settings *** +Suite Setup Run Tests ${EMPTY} running/for/for_in_zip.robot +Resource for.resource + +*** Test Cases *** +Two variables and lists + ${loop} = Check test and get loop ${TEST NAME} + Should be IN ZIP loop ${loop} 3 + Should be FOR iteration ${loop[0]} \${x}=a \${y}=x + Should be FOR iteration ${loop[1]} \${x}=b \${y}=y + Should be FOR iteration ${loop[2]} \${x}=c \${y}=z + +Uneven lists cause deprecation warning by default + ${loop} = Check test and get loop ${TEST NAME} + Should be IN ZIP loop ${loop} 3 + Check Log Message ${loop[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[1]} \${x}=a \${y}=1 + Should be FOR iteration ${loop[2]} \${x}=b \${y}=2 + Should be FOR iteration ${loop[3]} \${x}=c \${y}=3 + +Three variables and lists + ${loop} = Check test and get loop ${TEST NAME} + Should be IN ZIP loop ${loop} 3 + Should be FOR iteration ${loop[0]} \${x}=a \${y}=x \${z}=1 + Should be FOR iteration ${loop[1]} \${x}=b \${y}=y \${z}=2 + Should be FOR iteration ${loop[2]} \${x}=c \${y}=z \${z}=3 + +Six variables and lists + ${loop} = Check test and get loop ${TEST NAME} + Should be IN ZIP loop ${loop} 3 + Should be FOR iteration ${loop[0]} \${x}=a \${y}=x \${z}=1 \${å}=1 \${ä}=x \${ö}=a + Should be FOR iteration ${loop[1]} \${x}=b \${y}=y \${z}=2 \${å}=2 \${ä}=y \${ö}=b + Should be FOR iteration ${loop[2]} \${x}=c \${y}=z \${z}=3 \${å}=3 \${ä}=z \${ö}=c + +One variable and list + ${loop} = Check test and get loop ${TEST NAME} + Should be IN ZIP loop ${loop} 3 + Should be FOR iteration ${loop[0]} \${x}=a + Should be FOR iteration ${loop[1]} \${x}=b + Should be FOR iteration ${loop[2]} \${x}=c + +One variable and two lists + ${loop} = Check test and get loop ${TEST NAME} + Should be IN ZIP loop ${loop} 3 + Should be FOR iteration ${loop[0]} \${x}=('a', 'x') + Should be FOR iteration ${loop[1]} \${x}=('b', 'y') + Should be FOR iteration ${loop[2]} \${x}=('c', 'z') + +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[0]} \${x}=('a', 'x', 1, 1, 'x', 'a') + Should be FOR iteration ${loop[1]} \${x}=('b', 'y', 2, 2, 'y', 'b') + Should be FOR iteration ${loop[2]} \${x}=('c', 'z', 3, 3, 'z', 'c') + +Other iterables + Check Test Case ${TEST NAME} + +List variable containing iterables + ${loop} = Check test and get loop ${TEST NAME} 1 + Should be IN ZIP loop ${loop} 3 + Should be FOR iteration ${loop[0]} \${x}=a \${y}=x \${z}=f + Should be FOR iteration ${loop[1]} \${x}=b \${y}=y \${z}=o + Should be FOR iteration ${loop[2]} \${x}=c \${y}=z \${z}=o + +List variable with iterables can be empty + ${tc} = Check Test Case ${TEST NAME} + Should be IN ZIP loop ${tc[0]} 1 NOT RUN + Should be FOR iteration ${tc[0, 0]} \${x}= + Should be IN ZIP loop ${tc[1]} 1 NOT RUN + Should be FOR iteration ${tc[1, 0]} \${x}= \${y}= \${z}= + Check Log Message ${tc[2, 0]} Executed! + +Strict mode + ${tc} = Check Test Case ${TEST NAME} + Should be IN ZIP loop ${tc[0]} 3 PASS mode=STRICT + Should be IN ZIP loop ${tc[2]} 1 FAIL mode=strict + +Strict mode requires items to have length + ${tc} = Check Test Case ${TEST NAME} + Should be IN ZIP loop ${tc[0]} 1 FAIL mode=STRICT + +Shortest mode + ${tc} = Check Test Case ${TEST NAME} + Should be IN ZIP loop ${tc[0]} 3 PASS mode=SHORTEST fill=ignored + Should be IN ZIP loop ${tc[3]} 3 PASS mode=\${{'shortest'}} + +Shortest mode supports infinite iterators + ${tc} = Check Test Case ${TEST NAME} + Should be IN ZIP loop ${tc[0]} 3 PASS mode=SHORTEST + +Longest mode + ${tc} = Check Test Case ${TEST NAME} + Should be IN ZIP loop ${tc[0]} 3 PASS mode=LONGEST + Should be IN ZIP loop ${tc[3]} 5 PASS mode=LoNgEsT + +Longest mode with custom fill value + ${tc} = Check Test Case ${TEST NAME} + Should be IN ZIP loop ${tc[0]} 5 PASS mode=longest fill=? + Should be IN ZIP loop ${tc[3]} 3 PASS mode=longest fill=\${0} + +Invalid mode + ${tc} = Check Test Case ${TEST NAME} + Should be IN ZIP loop ${tc[0]} 1 FAIL mode=bad + +Invalid mode from variable + ${tc} = Check Test Case ${TEST NAME} + Should be IN ZIP loop ${tc[0]} 1 FAIL mode=\${{'bad'}} + +Config more than once + ${tc} = Check Test Case ${TEST NAME} 1 + Should be IN ZIP loop ${tc[0]} 1 FAIL mode=shortest + ${tc} = Check Test Case ${TEST NAME} 2 + Should be IN ZIP loop ${tc[0]} 1 FAIL fill=z + +Non-existing variable in mode + ${tc} = Check Test Case ${TEST NAME} + Should be IN ZIP loop ${tc[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[0]} 1 FAIL mode=longest fill=\${bad} + +Not iterable value + Check test and failed loop ${TEST NAME} IN ZIP + +Strings are not considered iterables + Check test and failed loop ${TEST NAME} IN ZIP + +Too few variables + Check test and failed loop ${TEST NAME} 1 IN ZIP 0 + Check test and failed loop ${TEST NAME} 2 IN ZIP 1 + +Too many variables + Check test and failed loop ${TEST NAME} 1 IN ZIP 0 + Check test and failed loop ${TEST NAME} 2 IN ZIP 1 diff --git a/atest/robot/running/group/group.robot b/atest/robot/running/group/group.robot new file mode 100644 index 00000000000..f579f090cf5 --- /dev/null +++ b/atest/robot/running/group/group.robot @@ -0,0 +1,42 @@ +*** Settings *** +Suite Setup Run Tests ${EMPTY} running/group/group.robot +Resource atest_resource.robot + +*** Test Cases *** +Basics + ${tc}= Check Test Case ${TESTNAME} + Check Body Item Data ${tc[0]} type=GROUP name=1st group children=2 + Check Body Item Data ${tc[0, 0]} type=KEYWORD name=Log args=Inside group + Check Body Item Data ${tc[0, 1]} type=KEYWORD name=Log args=Still inside + Check Body Item Data ${tc[1]} type=GROUP name=second children=1 + Check Body Item Data ${tc[1, 0]} type=KEYWORD name=Log args=Inside second group + Check Body Item Data ${tc[2]} type=KEYWORD name=Log args=After + +Failing + ${tc}= Check Test Case ${TESTNAME} + Check Body Item Data ${tc[0]} type=GROUP name=Fails children=2 status=FAIL message=Failing inside GROUP! + Check Body Item Data ${tc[0, 0]} type=KEYWORD name=Fail children=1 status=FAIL message=Failing inside GROUP! + Check Body Item Data ${tc[0, 1]} type=KEYWORD name=Fail children=0 status=NOT RUN + Check Body Item Data ${tc[1]} type=GROUP name=Not run children=1 status=NOT RUN + Check Body Item Data ${tc[1, 0]} type=KEYWORD name=Fail children=0 status=NOT RUN + +Anonymous + ${tc}= Check Test Case ${TESTNAME} + Check Body Item Data ${tc[0]} type=GROUP name=${EMPTY} children=1 + Check Body Item Data ${tc[0, 0]} type=KEYWORD name=Log args=Inside unnamed group + +Variable in name + ${tc}= Check Test Case ${TESTNAME} + Check Body Item Data ${tc[0]} type=GROUP name=Test is named: ${TEST NAME} children=1 + Check Body Item Data ${tc[0, 0]} type=KEYWORD name=Log args=\${TEST_NAME} + Check Log Message ${tc[0, 0, 0]} ${TEST NAME} + Check Body Item Data ${tc[1]} type=GROUP name=42 children=1 + Check Body Item Data ${tc[1, 0]} type=KEYWORD name=Log args=Should be 42 + +In user keyword + ${tc}= Check Test Case ${TESTNAME} + Check Body Item Data ${tc[0]} type=KEYWORD name=Keyword children=4 + Check Body Item Data ${tc[0, 0]} type=KEYWORD name=Log args=Before + Check Body Item Data ${tc[0, 1]} type=GROUP name=First children=2 + Check Body Item Data ${tc[0, 2]} type=GROUP name=Second children=1 + Check Body Item Data ${tc[0, 3]} type=KEYWORD name=Log args=After diff --git a/atest/robot/running/group/invalid_group.robot b/atest/robot/running/group/invalid_group.robot new file mode 100644 index 00000000000..f6d415cdaf9 --- /dev/null +++ b/atest/robot/running/group/invalid_group.robot @@ -0,0 +1,44 @@ +*** Settings *** +Suite Setup Run Tests ${EMPTY} running/group/invalid_group.robot +Resource atest_resource.robot + +*** Test Cases *** +END missing + ${tc} = Check Test Case ${TESTNAME} + Length Should Be ${tc.body} 1 + Check Body Item Data ${tc[0]} GROUP status=FAIL children=2 message=GROUP must have closing END. + Check Body Item Data ${tc[0, 0]} KEYWORD status=NOT RUN children=0 name=Fail args=Not run + Check Body Item Data ${tc[0, 1]} MESSAGE level=FAIL message=GROUP must have closing END. + +Empty + ${tc} Check Test Case ${TESTNAME} + Length Should Be ${tc.body} 2 + Check Body Item Data ${tc[0]} GROUP status=FAIL children=1 message=GROUP cannot be empty. + Check Body Item Data ${tc[0, 0]} MESSAGE level=FAIL message=GROUP cannot be empty. + Check Body Item Data ${tc[1]} KEYWORD status=NOT RUN children=0 name=Log args=Outside + +Multiple parameters + ${tc} Check Test Case ${TESTNAME} + Length Should Be ${tc.body} 2 + Check Body Item Data ${tc[0]} GROUP status=FAIL children=2 message=GROUP accepts only one argument as name, got 3 arguments 'Too', 'many' and 'values'. + Check Body Item Data ${tc[0, 0]} KEYWORD status=NOT RUN children=0 name=Fail args=Not run + Check Body Item Data ${tc[0, 1]} MESSAGE level=FAIL message=GROUP accepts only one argument as name, got 3 arguments 'Too', 'many' and 'values'. + Check Body Item Data ${tc[1]} KEYWORD status=NOT RUN children=0 name=Log args=Last Keyword + +Non-existing variable in name + ${tc} Check Test Case ${TESTNAME} + Length Should Be ${tc.body} 2 + Check Body Item Data ${tc[0]} GROUP status=FAIL children=2 message=Variable '\${non_existing_var}' not found. name=\${non_existing_var} in name + Check Body Item Data ${tc[0, 0]} KEYWORD status=NOT RUN children=0 name=Fail args=Not run + Check Body Item Data ${tc[0, 1]} MESSAGE level=FAIL message=Variable '\${non_existing_var}' not found. + Check Body Item Data ${tc[1]} KEYWORD status=NOT RUN children=0 name=Log args=Last Keyword + +Invalid data is not reported after failures + ${tc} Check Test Case ${TESTNAME} + Length Should Be ${tc.body} 4 + Check Body Item Data ${tc[0]} KEYWORD status=FAIL children=1 name=Fail args=Something bad happened! message=Something bad happened! + Check Body Item Data ${tc[1]} GROUP status=NOT RUN children=1 name=\${non_existing_non_executed_variable_is_ok} + Check Body Item Data ${tc[1, 0]} KEYWORD status=NOT RUN children=0 name=Fail args=Not run + Check Body Item Data ${tc[2]} GROUP status=NOT RUN children=0 name=Empty non-executed GROUP is ok + Check Body Item Data ${tc[3]} GROUP status=NOT RUN children=1 name=Even missing END is ok + Check Body Item Data ${tc[3, 0]} KEYWORD status=NOT RUN children=0 name=Fail args=Not run diff --git a/atest/robot/running/group/nesting_group.robot b/atest/robot/running/group/nesting_group.robot new file mode 100644 index 00000000000..1d612e0c189 --- /dev/null +++ b/atest/robot/running/group/nesting_group.robot @@ -0,0 +1,51 @@ +*** Settings *** +Suite Setup Run Tests ${EMPTY} running/group/nesting_group.robot +Resource atest_resource.robot + +*** Test Cases *** +Nested + ${tc} Check Test Case ${TESTNAME} + Check Body Item Data ${tc[0]} type=GROUP name= + Check Body Item Data ${tc[0, 0]} type=KEYWORD name=Set Variable + Check Body Item Data ${tc[0, 1]} type=GROUP name=This Is A Named Group + Check Body Item Data ${tc[0, 1, 0]} type=KEYWORD name=Should Be Equal + +With other control structures + ${tc} Check Test Case ${TESTNAME} + Check Body Item Data ${tc[0]} type=IF/ELSE ROOT + Check Body Item Data ${tc[0, 0]} type=IF condition=True children=2 + Check Body Item Data ${tc[0, 0, 0]} type=GROUP name=Hello children=1 + Check Body Item Data ${tc[0, 0, 0, 0]} type=VAR name=\${i} + Check Body Item Data ${tc[0, 0, 1]} type=GROUP name=With WHILE children=2 + Check Body Item Data ${tc[0, 0, 1, 0]} type=WHILE condition=$i < 2 children=2 + Check Body Item Data ${tc[0, 0, 1, 0, 0]} type=ITERATION + Check Body Item Data ${tc[0, 0, 1, 0, 0, 0]} type=GROUP name=Group1 Inside WHILE (0) children=1 + Check Body Item Data ${tc[0, 0, 1, 0, 0, 0, 0]} type=KEYWORD name=Log args=\${i} + Check Body Item Data ${tc[0, 0, 1, 0, 0, 1]} type=GROUP name=Group2 Inside WHILE children=1 + Check Body Item Data ${tc[0, 0, 1, 0, 0, 1, 0]} type=VAR name=\${i} value=\${i + 1} + Check Body Item Data ${tc[0, 0, 1, 0, 1]} type=ITERATION + Check Body Item Data ${tc[0, 0, 1, 0, 1, 0]} type=GROUP name=Group1 Inside WHILE (1) children=1 + Check Body Item Data ${tc[0, 0, 1, 0, 1, 0, 0]} type=KEYWORD name=Log args=\${i} + Check Body Item Data ${tc[0, 0, 1, 0, 1, 1]} type=GROUP name=Group2 Inside WHILE children=1 + Check Body Item Data ${tc[0, 0, 1, 0, 1, 1, 0]} type=VAR name=\${i} value=\${i + 1} + Check Body Item Data ${tc[0, 0, 1, 1]} type=IF/ELSE ROOT + Check Body Item Data ${tc[0, 0, 1, 1, 0]} type=IF status=NOT RUN condition=$i != 2 children=1 + Check Body Item Data ${tc[0, 0, 1, 1, 0, 0]} type=KEYWORD status=NOT RUN name=Fail args=Shall be logged but NOT RUN + +In non-executed branch + ${tc} Check Test Case ${TESTNAME} + Check Body Item Data ${tc[0]} type=VAR name=\${var} value=value + Check Body Item Data ${tc[1]} type=IF/ELSE ROOT + Check Body Item Data ${tc[1, 0]} type=IF condition=True children=1 + Check Body Item Data ${tc[1, 0, 0]} type=GROUP name=GROUP in IF children=2 + Check Body Item Data ${tc[1, 0, 0, 0]} type=KEYWORD name=Should Be Equal + Check Body Item Data ${tc[1, 0, 0, 1]} type=IF/ELSE ROOT + Check Body Item Data ${tc[1, 0, 0, 1, 0]} type=IF status=PASS children=1 condition=True + Check Body Item Data ${tc[1, 0, 0, 1, 0, 0]} type=KEYWORD status=PASS name=Log args=IF in GROUP + Check Body Item Data ${tc[1, 0, 0, 1, 1]} type=ELSE status=NOT RUN + Check Body Item Data ${tc[1, 0, 0, 1, 1, 0]} type=GROUP status=NOT RUN children=1 name=GROUP in ELSE + Check Body Item Data ${tc[1, 0, 0, 1, 1, 0, 0]} type=KEYWORD status=NOT RUN name=Fail args=Shall be logged but NOT RUN + Check Body Item Data ${tc[1, 1]} type=ELSE IF status=NOT RUN + Check Body Item Data ${tc[1, 1, 0]} type=GROUP status=NOT RUN children=1 name=\${non_existing_variable_is_fine_here} + Check Body Item Data ${tc[1, 2]} type=ELSE status=NOT RUN + Check Body Item Data ${tc[1, 2, 0]} type=GROUP status=NOT RUN children=0 name=Even empty GROUP is allowed diff --git a/atest/robot/running/group/templates.robot b/atest/robot/running/group/templates.robot new file mode 100644 index 00000000000..b42966b2524 --- /dev/null +++ b/atest/robot/running/group/templates.robot @@ -0,0 +1,69 @@ +*** Settings *** +Suite Setup Run Tests ${EMPTY} running/group/templates.robot +Resource atest_resource.robot + +*** Test Cases *** +Pass + ${tc} = Check Test Case ${TESTNAME} + Check Body Item Data ${tc[0]} type=GROUP status=PASS children=1 name=1 + Check Body Item Data ${tc[0, 0]} type=KEYWORD status=PASS children=1 name=Run Keyword args=Log, 1.1 + Check Body Item Data ${tc[1]} type=GROUP status=PASS children=2 name=2 + Check Body Item Data ${tc[1, 0]} type=KEYWORD status=PASS children=1 name=Run Keyword args=Log, 2.1 + Check Body Item Data ${tc[1, 1]} type=KEYWORD status=PASS children=1 name=Run Keyword args=Log, 2.2 + +Pass and fail + ${tc} = Check Test Case ${TESTNAME} + Check Body Item Data ${tc[0]} type=GROUP status=PASS children=1 name=1 + Check Body Item Data ${tc[0, 0]} type=KEYWORD status=PASS children=1 name=Run Keyword args=Log, 1.1 + Check Body Item Data ${tc[1]} type=GROUP status=FAIL children=2 name=2 message=2.1 + Check Body Item Data ${tc[1, 0]} type=KEYWORD status=FAIL children=1 name=Run Keyword args=Fail, 2.1 message=2.1 + Check Body Item Data ${tc[1, 1]} type=KEYWORD status=PASS children=1 name=Run Keyword args=Log, 2.2 + Check Body Item Data ${tc[2]} type=GROUP status=PASS children=1 name=3 + Check Body Item Data ${tc[2, 0]} type=KEYWORD status=PASS children=1 name=Run Keyword args=Log, 3.1 + +Fail multiple times + ${tc} = Check Test Case ${TESTNAME} + Check Body Item Data ${tc[0]} type=GROUP status=FAIL children=1 name=1 message=1.1 + Check Body Item Data ${tc[0, 0]} type=KEYWORD status=FAIL children=1 name=Run Keyword args=Fail, 1.1 message=1.1 + Check Body Item Data ${tc[1]} type=GROUP status=FAIL children=3 name=2 message=Several failures occurred:\n\n1) 2.1\n\n2) 2.3 + Check Body Item Data ${tc[1, 0]} type=KEYWORD status=FAIL children=1 name=Run Keyword args=Fail, 2.1 message=2.1 + Check Body Item Data ${tc[1, 1]} type=KEYWORD status=PASS children=1 name=Run Keyword args=Log, 2.2 + Check Body Item Data ${tc[1, 2]} type=KEYWORD status=FAIL children=1 name=Run Keyword args=Fail, 2.3 message=2.3 + Check Body Item Data ${tc[2]} type=GROUP status=PASS children=1 name=3 + Check Body Item Data ${tc[2, 0]} type=KEYWORD status=PASS children=1 name=Run Keyword args=Log, 3.1 + Check Body Item Data ${tc[3]} type=GROUP status=FAIL children=1 name=4 message=4.1 + Check Body Item Data ${tc[3, 0]} type=KEYWORD status=FAIL children=1 name=Run Keyword args=Fail, 4.1 message=4.1 + +Pass and skip + ${tc} = Check Test Case ${TESTNAME} + Check Body Item Data ${tc[0]} type=GROUP status=SKIP children=1 name=1 message=1.1 + Check Body Item Data ${tc[0, 0]} type=KEYWORD status=SKIP children=1 name=Run Keyword args=Skip, 1.1 message=1.1 + Check Body Item Data ${tc[1]} type=GROUP status=PASS children=1 name=2 + Check Body Item Data ${tc[1, 0]} type=KEYWORD status=PASS children=1 name=Run Keyword args=Log, 2.1 + Check Body Item Data ${tc[2]} type=GROUP status=PASS children=2 name=3 + Check Body Item Data ${tc[2, 0]} type=KEYWORD status=SKIP children=1 name=Run Keyword args=Skip, 3.1 message=3.1 + Check Body Item Data ${tc[2, 1]} type=KEYWORD status=PASS children=1 name=Run Keyword args=Log, 3.2 + +Pass, fail and skip + ${tc} = Check Test Case ${TESTNAME} + Check Body Item Data ${tc[0]} type=GROUP status=FAIL children=3 name=1 message=1.1 + Check Body Item Data ${tc[0, 0]} type=KEYWORD status=FAIL children=1 name=Run Keyword args=Fail, 1.1 message=1.1 + Check Body Item Data ${tc[0, 1]} type=KEYWORD status=SKIP children=1 name=Run Keyword args=Skip, 1.2 message=1.2 + Check Body Item Data ${tc[0, 2]} type=KEYWORD status=PASS children=1 name=Run Keyword args=Log, 1.3 + Check Body Item Data ${tc[1]} type=GROUP status=SKIP children=1 name=2 message=2.1 + Check Body Item Data ${tc[1, 0]} type=KEYWORD status=SKIP children=1 name=Run Keyword args=Skip, 2.1 message=2.1 + Check Body Item Data ${tc[2]} type=GROUP status=PASS children=1 name=3 + Check Body Item Data ${tc[2, 0]} type=KEYWORD status=PASS children=1 name=Run Keyword args=Log, 3.1 + +Skip all + ${tc} = Check Test Case ${TESTNAME} + Check Body Item Data ${tc[0]} type=GROUP status=SKIP children=2 name=1 message=All iterations skipped. + Check Body Item Data ${tc[0, 0]} type=KEYWORD status=SKIP children=1 name=Run Keyword args=Skip, 1.1 message=1.1 + Check Body Item Data ${tc[0, 1]} type=KEYWORD status=SKIP children=1 name=Run Keyword args=Skip, 1.2 message=1.2 + Check Body Item Data ${tc[1]} type=GROUP status=SKIP children=1 name=2 message=2.1 + Check Body Item Data ${tc[1, 0]} type=KEYWORD status=SKIP children=1 name=Run Keyword args=Skip, 2.1 message=2.1 + +Just one that is skipped + ${tc} = Check Test Case ${TESTNAME} + Check Body Item Data ${tc[0]} type=GROUP status=SKIP children=1 name=1 message=1.1 + Check Body Item Data ${tc[0, 0]} type=KEYWORD status=SKIP children=1 name=Run Keyword args=Skip, 1.1 message=1.1 diff --git a/atest/robot/running/html_error_message.robot b/atest/robot/running/html_error_message.robot index 894e2eff0b0..9ff087a0d82 100644 --- a/atest/robot/running/html_error_message.robot +++ b/atest/robot/running/html_error_message.robot @@ -9,11 +9,15 @@ ${FAILURE} Robot Framework *** Test Cases *** Set Test Message ${tc} = Check Test Case ${TESTNAME} - Check Log Message ${tc.kws[0].msgs[0]} Set test message to:\n${MESSAGE} html=True + Check Log Message ${tc[0, 0]} Set test message to:\n${MESSAGE} html=True HTML failure ${tc} = Check Test Case ${TESTNAME} - Check Log Message ${tc.kws[0].msgs[0]} ${FAILURE} FAIL html=True + Check Log Message ${tc[0, 0]} ${FAILURE} FAIL html=True + +HTML failure with non-generic exception + ${tc} = Check Test Case ${TESTNAME} + Check Log Message ${tc[0, 0]} ValueError: Invalid value FAIL html=True HTML failure in setup Check Test Case ${TESTNAME} @@ -26,8 +30,8 @@ Normal failure in body and HTML failure in teardown HTML failure in body and normal failure teardown ${tc} = Check Test Case ${TESTNAME} - Check Log Message ${tc.kws[0].msgs[0]} Should be HTML FAIL html=True - Check Log Message ${tc.teardown.msgs[0]} Should NOT be HTML FAIL html=False + Check Log Message ${tc[0, 0]} Should be HTML FAIL html=True + Check Log Message ${tc.teardown[0]} Should NOT be HTML FAIL html=False HTML failure in body and in teardown Check Test Case ${TESTNAME} diff --git a/atest/robot/running/if/complex_if.robot b/atest/robot/running/if/complex_if.robot new file mode 100644 index 00000000000..5ceb8de5c32 --- /dev/null +++ b/atest/robot/running/if/complex_if.robot @@ -0,0 +1,58 @@ +*** Settings *** +Suite Setup Run Tests ${EMPTY} running/if/complex_if.robot +Resource atest_resource.robot + +*** Test Cases *** +Multiple keywords in if + Check Test Case ${TESTNAME} + +Nested ifs + Check Test Case ${TESTNAME} + +If inside for loop + Check Test Case ${TESTNAME} + +Setting after if + ${tc} = Check Test Case ${TESTNAME} + Check Log Message ${tc.teardown[0]} Teardown was found and executed. + +For loop inside if + Check Test Case ${TESTNAME} + +Direct Boolean condition + ${tc} = Check Test Case ${TESTNAME} + Should Be Equal ${tc[0].status} PASS + Should Be Equal ${tc[0, 0].status} PASS + Should Be Equal ${tc[0, 0, 0].status} PASS + +Direct Boolean condition false + ${tc} = Check Test Case ${TESTNAME} + Should Be Equal ${tc[0].status} PASS + Should Be Equal ${tc[0, 0].status} NOT RUN + Should Be Equal ${tc[0, 0, 0].status} NOT RUN + +Nesting insanity + Check Test Case ${TESTNAME} + +Recursive If + ${tc} = Check Test Case ${TESTNAME} + Should Be Equal ${tc[0, 0].status} PASS + Should Be Equal ${tc[0, 0, 0, 0].status} PASS + +If creating variable + Check Test Case ${TESTNAME} + +For loop if else early exit + Check Test Case ${TESTNAME} + +For loop if else if early exit + Check Test Case ${TESTNAME} + +If with comments + Check Test Case ${TESTNAME} + +If with invalid condition after valid is ok + Check Test Case ${TESTNAME} + +If with dollar var from variables table + Check Test Case ${TESTNAME} diff --git a/atest/robot/running/if/else_if.robot b/atest/robot/running/if/else_if.robot new file mode 100644 index 00000000000..924230d48b2 --- /dev/null +++ b/atest/robot/running/if/else_if.robot @@ -0,0 +1,29 @@ +*** Settings *** +Suite Setup Run Tests ${EMPTY} running/if/else_if.robot +Test Template Check IF/ELSE Status +Resource if.resource + +*** Test Cases *** +Else if condition 1 passes + PASS NOT RUN NOT RUN + +Else if condition 2 passes + NOT RUN PASS NOT RUN + +Else if else passes + NOT RUN NOT RUN PASS + +Else if condition 1 failing + FAIL NOT RUN NOT RUN + +Else if condition 2 failing + NOT RUN FAIL NOT RUN + +Else if else failing + NOT RUN NOT RUN FAIL + +Invalid + FAIL NOT RUN + +After failure + NOT RUN NOT RUN NOT RUN index=1 run=False diff --git a/atest/robot/running/if/if.resource b/atest/robot/running/if/if.resource new file mode 100644 index 00000000000..d80528a8a67 --- /dev/null +++ b/atest/robot/running/if/if.resource @@ -0,0 +1,37 @@ +*** Settings *** +Resource atest_resource.robot + +*** Keywords *** +Check IF/ELSE Status + [Arguments] @{statuses} ${types}=${None} ${root}=${None} ${test}=${TEST NAME} ${index}=0 ${else}=${True} ${run}=${True} + IF not $root + ${tc} = Check Test Case ${test} + ${root} = Set Variable ${tc.body}[${index}] + END + Should Be Equal ${root.type} IF/ELSE ROOT + IF 'FAIL' in ${statuses} + Should Be Equal ${root.status} FAIL + ELSE IF ${run} + Should Be Equal ${root.status} PASS + ELSE + Should Be Equal ${root.status} NOT RUN + END + Check Branch Statuses ${root.body} ${statuses} ${types} ${else} + +Check Branch Statuses + [Arguments] ${branches} ${statuses} ${types}=${None} ${else}=${True} + IF $types + ${types} = Evaluate ${types} + ELSE + IF ${else} and len($branches) > 1 + ${types} = Evaluate ['IF'] + ['ELSE IF'] * (len($branches) - 2) + ['ELSE'] + ELSE + ${types} = Evaluate ['IF'] + ['ELSE IF'] * (len($branches) - 1) + END + END + Should Be Equal ${{len($branches)}} ${{len($statuses)}} + Should Be Equal ${{len($branches)}} ${{len($types)}} + FOR ${branch} ${type} ${status} IN ZIP ${branches} ${types} ${statuses} + Should Be Equal ${branch.type} ${type} + Should Be Equal ${branch.status} ${status} + END diff --git a/atest/robot/running/if/if_else.robot b/atest/robot/running/if/if_else.robot new file mode 100644 index 00000000000..8764bc0d103 --- /dev/null +++ b/atest/robot/running/if/if_else.robot @@ -0,0 +1,51 @@ +*** Settings *** +Suite Setup Run Tests ${EMPTY} running/if/if_else.robot +Resource atest_resource.robot + +*** Test Cases *** +If passing + Check Test Case ${TESTNAME} + +If failing + Check Test Case ${TESTNAME} + +If not executed + Check Test Case ${TESTNAME} + +If not executed failing + Check Test Case ${TESTNAME} + +If else - if executed + Check Test Case ${TESTNAME} + +If else - else executed + Check Test Case ${TESTNAME} + +If else - if executed - failing + Check Test Case ${TESTNAME} + +If else - else executed - failing + Check Test Case ${TESTNAME} + +If passing in keyword + Check Test Case ${TESTNAME} + +If passing in else keyword + Check Test Case ${TESTNAME} + +If failing in keyword + Check Test Case ${TESTNAME} + +If failing in else keyword + Check Test Case ${TESTNAME} + +Mutable condition + ${tc} = Check Test Case ${TESTNAME} + Check Log Message ${tc[1, 0, 1, 0]} Should be executed! + +Expression evaluation time is included in elapsed time + ${tc} = Check Test Case ${TESTNAME} + Elapsed Time Should Be Valid ${tc[0].elapsed_time} minimum=0.2 + Elapsed Time Should Be Valid ${tc[0, 0].elapsed_time} minimum=0.1 + Elapsed Time Should Be Valid ${tc[0, 1].elapsed_time} minimum=0.1 + Elapsed Time Should Be Valid ${tc[0, 2].elapsed_time} maximum=1.0 diff --git a/atest/robot/running/if/inline_if_else.robot b/atest/robot/running/if/inline_if_else.robot new file mode 100644 index 00000000000..d3a5a346db5 --- /dev/null +++ b/atest/robot/running/if/inline_if_else.robot @@ -0,0 +1,98 @@ +*** Settings *** +Suite Setup Run Tests ${EMPTY} running/if/inline_if_else.robot +Test Template Check IF/ELSE Status +Resource if.resource + +*** Test Cases *** +IF passing + PASS + +IF failing + FAIL + +IF erroring + FAIL + +Not executed + NOT RUN + +Not executed after failure + NOT RUN NOT RUN NOT RUN index=1 run=False + +Not executed after failure with assignment + [Template] NONE + ${tc} = Check Test Case ${TEST NAME} + Check IF/ELSE Status NOT RUN NOT RUN root=${tc[1]} run=False + Check IF/ELSE Status NOT RUN NOT RUN root=${tc[2]} run=False + Check Keyword Data ${tc[1, 0, 0]} Not run assign=\${x} status=NOT RUN + Check Keyword Data ${tc[2, 0, 0]} Not run assign=\${x}, \@{y} status=NOT RUN + +ELSE IF not executed + NOT RUN NOT RUN PASS index=0 + FAIL NOT RUN NOT RUN index=1 else=False + +ELSE IF executed + NOT RUN PASS NOT RUN index=0 + NOT RUN NOT RUN FAIL NOT RUN NOT RUN index=1 + +ELSE not executed + PASS NOT RUN index=0 + FAIL NOT RUN index=1 + +ELSE executed + NOT RUN PASS index=0 + NOT RUN FAIL index=1 + +Assign + PASS NOT RUN NOT RUN index=0 + 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 + +List assign + PASS NOT RUN index=0 + NOT RUN PASS index=2 + +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 + +Assign when no branch is run + NOT RUN PASS index=0 + NOT RUN NOT RUN PASS index=2 + NOT RUN PASS index=4 + +Inside FOR + [Template] NONE + ${tc} = Check Test Case ${TEST NAME} + Check IF/ELSE Status NOT RUN PASS root=${tc[0, 0, 0]} + Check IF/ELSE Status NOT RUN PASS root=${tc[0, 1, 0]} + Check IF/ELSE Status FAIL NOT RUN root=${tc[0, 2, 0]} + +Inside normal IF + [Template] NONE + ${tc} = Check Test Case ${TEST NAME} + Check IF/ELSE Status NOT RUN PASS root=${tc[0, 0, 1]} + Check IF/ELSE Status NOT RUN NOT RUN root=${tc[0, 1, 0]} run=False + +In keyword + [Template] NONE + ${tc} = Check Test Case ${TEST NAME} + Check IF/ELSE Status PASS NOT RUN root=${tc[0, 0]} + Check IF/ELSE Status NOT RUN PASS NOT RUN root=${tc[0, 1]} + Check IF/ELSE Status NOT RUN NOT RUN NOT RUN FAIL + ... NOT RUN NOT RUN NOT RUN root=${tc[0, 2]} diff --git a/atest/robot/running/if/invalid_if.robot b/atest/robot/running/if/invalid_if.robot new file mode 100644 index 00000000000..714a7b4eb80 --- /dev/null +++ b/atest/robot/running/if/invalid_if.robot @@ -0,0 +1,128 @@ +*** Settings *** +Suite Setup Run Tests ${EMPTY} running/if/invalid_if.robot +Test Template Branch statuses should be +Resource atest_resource.robot + +*** Test Cases *** +IF without condition + FAIL + +IF without condition with ELSE + FAIL NOT RUN + +IF with invalid condition + FAIL + +IF with invalid condition with ELSE + FAIL NOT RUN + +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 + +$var recommendation with multiple variables + FAIL index=1 + +Remove quotes around variable in $var recommendation + FAIL index=2 + +IF without END + FAIL + +Invalid END + FAIL + +IF with wrong case + [Template] NONE + Check Test Case ${TEST NAME} + +ELSE IF without condition + FAIL NOT RUN NOT RUN + +ELSE IF with multiple conditions + [Template] NONE + ${tc} = Branch statuses should be FAIL NOT RUN NOT RUN + Should Be Equal ${tc[0, 1].condition} \${False}, ooops, \${True} + +ELSE with condition + FAIL NOT RUN + +IF with empty body + FAIL + +ELSE with empty body + FAIL NOT RUN + +ELSE IF with empty body + FAIL NOT RUN NOT RUN + +ELSE after ELSE + FAIL NOT RUN NOT RUN + +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 + +Multiple errors + FAIL NOT RUN NOT RUN NOT RUN NOT RUN + +Invalid data causes syntax error + [Template] Check Test Case + ${TEST NAME} + +Invalid condition causes normal error + [Template] Check Test Case + ${TEST NAME} + +Non-existing variable in condition causes normal error + [Template] Check Test Case + ${TEST NAME} + +*** Keywords *** +Branch statuses should be + [Arguments] @{statuses} ${index}=0 + ${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} mode=STRICT + Should Be Equal ${branch.status} ${status} + END + RETURN ${tc} diff --git a/atest/robot/running/if/invalid_inline_if.robot b/atest/robot/running/if/invalid_inline_if.robot new file mode 100644 index 00000000000..5a7ba2c0ac0 --- /dev/null +++ b/atest/robot/running/if/invalid_inline_if.robot @@ -0,0 +1,115 @@ +*** Settings *** +Suite Setup Run Tests ${EMPTY} running/if/invalid_inline_if.robot +Test Template Check IF/ELSE Status +Resource if.resource + +*** Test Cases *** +Invalid condition + FAIL NOT RUN + +Condition with non-existing variable + FAIL + +Invalid condition with other error + FAIL NOT RUN + +Empty IF + FAIL + +IF without branch + FAIL + +IF without branch with ELSE IF + FAIL NOT RUN else=False + +IF without branch with ELSE + FAIL NOT RUN + +IF followed by ELSE IF + FAIL + +IF followed by ELSE + FAIL + +Empty ELSE IF + FAIL NOT RUN test=${TESTNAME} 1 else=False + NOT RUN FAIL test=${TESTNAME} 2 else=False + +ELSE IF without branch + FAIL NOT RUN test=${TESTNAME} 1 else=False + FAIL NOT RUN NOT RUN test=${TESTNAME} 2 + +Empty ELSE + FAIL NOT RUN NOT RUN + +ELSE IF after ELSE + FAIL NOT RUN NOT RUN types=['IF', 'ELSE', 'ELSE IF'] test=${TESTNAME} 1 + FAIL NOT RUN NOT RUN NOT RUN types=['IF', 'ELSE', 'ELSE IF', 'ELSE IF'] test=${TESTNAME} 2 + +Multiple ELSEs + FAIL NOT RUN NOT RUN types=['IF', 'ELSE', 'ELSE'] test=${TESTNAME} 1 + FAIL NOT RUN NOT RUN NOT RUN types=['IF', 'ELSE', 'ELSE', 'ELSE'] test=${TESTNAME} 2 + +Nested IF + FAIL test=${TESTNAME} 1 + FAIL NOT RUN test=${TESTNAME} 2 + FAIL test=${TESTNAME} 3 + +Nested FOR + FAIL + +Unnecessary END + PASS NOT RUN index=0 + NOT RUN FAIL index=1 + +Invalid END after inline header + [Template] NONE + ${tc} = Check Test Case ${TEST NAME} + Check IF/ELSE Status PASS root=${tc[0]} + Check Log Message ${tc[0, 0, 0, 0]} Executed inside inline IF + Check Log Message ${tc[1, 0]} Executed outside IF + Should Be Equal ${tc[2].type} ERROR + Should Be Equal ${tc[2].status} FAIL + +Assign in IF branch + FAIL + +Assign in ELSE IF branch + FAIL NOT RUN else=False + +Assign in ELSE branch + FAIL NOT RUN + +Invalid assign mark usage + FAIL NOT RUN + +Too many list variables in assign + FAIL NOT RUN + +Invalid number of variables in assign + NOT RUN FAIL + +Invalid value for list assign + FAIL NOT RUN + +Invalid value for dict assign + NOT RUN FAIL + +Assign when IF branch is empty + FAIL NOT RUN + +Assign when ELSE IF branch is empty + FAIL NOT RUN NOT RUN + +Assign when ELSE branch is empty + FAIL NOT RUN + +Control structures are allowed + [Template] NONE + ${tc} = Check Test Case ${TESTNAME} + Check IF/ELSE Status NOT RUN PASS root=${tc[0, 0]} + +Control structures are not allowed with assignment + [Template] NONE + ${tc} = Check Test Case ${TESTNAME} + Check IF/ELSE Status FAIL NOT RUN root=${tc[0, 0]} diff --git a/atest/robot/running/invalid_break_and_continue.robot b/atest/robot/running/invalid_break_and_continue.robot new file mode 100644 index 00000000000..6730a116b6a --- /dev/null +++ b/atest/robot/running/invalid_break_and_continue.robot @@ -0,0 +1,62 @@ +*** Settings *** +Suite Setup Run Tests ${EMPTY} running/invalid_break_and_continue.robot +Resource atest_resource.robot + +*** Test Cases *** +CONTINUE in test case + Check Test Case ${TESTNAME} + +CONTINUE in keyword + Check Test Case ${TESTNAME} + +CONTINUE in IF + Check Test Case ${TESTNAME} + +CONTINUE in ELSE + Check Test Case ${TESTNAME} + +CONTINUE in TRY + Check Test Case ${TESTNAME} + +CONTINUE in EXCEPT + Check Test Case ${TESTNAME} + +CONTINUE in TRY-ELSE + Check Test Case ${TESTNAME} + +CONTINUE with argument in FOR + ${tc} = Check Test Case ${TESTNAME} + Check Log Message ${tc[0, 0, 1, 0]} CONTINUE does not accept arguments, got 'should not work'. FAIL + +CONTINUE with argument in WHILE + ${tc} = Check Test Case ${TESTNAME} + Check Log Message ${tc[0, 0, 1, 0]} CONTINUE does not accept arguments, got 'should', 'not' and 'work'. FAIL + +BREAK in test case + Check Test Case ${TESTNAME} + +BREAK in keyword + Check Test Case ${TESTNAME} + +BREAK in IF + Check Test Case ${TESTNAME} + +BREAK in ELSE + Check Test Case ${TESTNAME} + +BREAK in TRY + Check Test Case ${TESTNAME} + +BREAK in EXCEPT + Check Test Case ${TESTNAME} + +BREAK in TRY-ELSE + Check Test Case ${TESTNAME} + +BREAK with argument in FOR + ${tc} = Check Test Case ${TESTNAME} + Check Log Message ${tc[0, 0, 1, 0]} BREAK does not accept arguments, got 'should not work'. FAIL + +BREAK with argument in WHILE + ${tc} = Check Test Case ${TESTNAME} + Check Log Message ${tc[0, 0, 1, 0]} BREAK does not accept arguments, got 'should', 'not' and 'work'. FAIL diff --git a/atest/robot/running/long_error_messages.robot b/atest/robot/running/long_error_messages.robot index 7e4c6208e95..abef7c57b16 100644 --- a/atest/robot/running/long_error_messages.robot +++ b/atest/robot/running/long_error_messages.robot @@ -1,65 +1,65 @@ *** Settings *** -Documentation Testing that possible long error messages are truncated -Suite Setup Run Tests ${EMPTY} running/long_error_messages.robot -Resource atest_resource.robot +Documentation Testing that possible long error messages are truncated +Suite Setup Run Tests ${EMPTY} running/long_error_messages.robot +Resource atest_resource.robot *** Variables *** -${EXPLANATION} \n${SPACE*4}\[ Message content over the limit has been removed. ]\n -${3DOTS} \\.\\.\\. +${EXPLANATION} \n${SPACE*4}\[ Message content over the limit has been removed. ]\n +${3DOTS} \\.\\.\\. *** Test Cases *** Under The Limit - Has Not Been Cut 50 x 1 Message Under The Limit - Has Not Been Cut 100 x 1 Message Under The Limit - Has Not Been Cut 50 x 10 Message Under The Limit - Has Not Been Cut 150 X 19 Message Under The Limit - Has Not Been Cut 77 x 39 Message Under The Limit + Has Not Been Cut 50 x 1 Message Under The Limit + Has Not Been Cut 100 x 1 Message Under The Limit + Has Not Been Cut 50 x 10 Message Under The Limit + Has Not Been Cut 150 X 19 Message Under The Limit + Has Not Been Cut 77 x 39 Message Under The Limit On The Limit - Has Not Been Cut 20 x 40 Message On The Limit - Has Not Been Cut 150 x 20 Message On The Limit - Has Not Been Cut 3120 x 1 Message On The Limit + Has Not Been Cut 20 x 40 Message On The Limit + Has Not Been Cut 150 x 20 Message On The Limit + Has Not Been Cut 3120 x 1 Message On The Limit Over The Limit - Has Been Cut 8 x 41 Message Over The Limit - Has Been Cut 400 x 7 Message Over The Limit 1201.*${3DOTS} ${3DOTS}.*END - Has Been Cut 3121 x 1 Message Over The Limit .*${3DOTS}\\n ${3DOTS}.*\\n + Has Been Cut 8 x 41 Message Over The Limit + Has Been Cut 400 x 7 Message Over The Limit 1201.*${3DOTS} ${3DOTS}.*END + Has Been Cut 3121 x 1 Message Over The Limit .*${3DOTS}\\n ${3DOTS}.*\\n Multiple Short Errors - ${tc} = Has Been Cut ${TESTNAME} - Should Contain X Times ${tc.message} \n 40 + ${tc} = Has Been Cut ${TESTNAME} + Should Contain X Times ${tc.message} \n 40 Two long errors - ${tc} = Has Been Cut ${TESTNAME} + ${tc} = Has Been Cut ${TESTNAME} Should Contain X Times ${tc.message} \n 40 Should Start With ${tc.message} Several failures occurred:\n\n1) ContinuableApocalypseException: 1 Should End With ${tc.message} 1961~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~2000 - *** Keywords *** Has Been Cut - [Arguments] ${test} ${eol_dots}= ${bol_dots}= - ${test} = Get Test Case ${test} - Should Contain ${test.message} ${EXPLANATION} - 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} + [Arguments] ${test} ${eol_dots}= ${bol_dots}= + ${test} = Get Test Case ${test} + Should Contain ${test.message} ${EXPLANATION} + 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} + RETURN ${test} Error Message In Log Should Not Have Been Cut - [Arguments] ${kws} - @{keywords} = Set Variable ${kws} - :FOR ${kw} IN @{keywords} - \ Run Keyword If ${kw.msgs} - ... Should Not Contain ${kw.msgs[-1].message} ${EXPLANATION} - \ Error Message In Log Should Not Have Been Cut ${kw.kws} + [Arguments] ${item} + FOR ${child} IN @{item.non_messages} + FOR ${msg} IN @{child.messages} + Should Not Contain ${msg.message} ${EXPLANATION} + END + Error Message In Log Should Not Have Been Cut ${child} + END Should Match Non Empty Regexp - [Arguments] ${message} ${pattern} - Run Keyword If '${pattern}' Should Match Regexp ${message} ${pattern} + [Arguments] ${message} ${pattern} + IF $pattern + ... Should Match Regexp ${message} ${pattern} Has Not Been Cut - [Arguments] ${testname} - ${test} = Get Test Case ${testname} - Should Not Contain ${test.message} Message content over - + [Arguments] ${testname} + ${test} = Get Test Case ${testname} + Should Not Contain ${test.message} Message content over diff --git a/atest/robot/running/non_ascii_bytes.robot b/atest/robot/running/non_ascii_bytes.robot index dd8e73ae4fc..b84792d1a6e 100644 --- a/atest/robot/running/non_ascii_bytes.robot +++ b/atest/robot/running/non_ascii_bytes.robot @@ -3,31 +3,31 @@ Documentation These tests log, raise, and return messages containing non-ASC ... When these messages are logged, the bytes are escaped. Suite Setup Run Tests ${EMPTY} running/non_ascii_bytes.robot Resource atest_resource.robot -Variables ${DATADIR}/running/expbytevalues.py ${INTERPRETER} +Variables ${DATADIR}/running/expbytevalues.py *** Test Cases *** In Message ${tc}= Check Test Case ${TESTNAME} - Check Log Message ${tc.kws[0].msgs[0]} ${exp_log_msg} + Check Log Message ${tc[0, 0]} ${exp_log_msg} In Multiline Message ${tc}= Check Test Case ${TESTNAME} - Check Log Message ${tc.kws[0].msgs[0]} ${exp_log_multiline_msg} + Check Log Message ${tc[0, 0]} ${exp_log_multiline_msg} In Return Value [Documentation] Return value is not altered by the framework and thus it ... contains the exact same bytes that the keyword returned. ${tc} = Check Test Case ${TESTNAME} - Check Log Message ${tc.kws[0].msgs[0]} \${retval} = ${exp_return_msg} + Check Log Message ${tc[0, 0]} \${retval} = ${exp_return_msg} In Exception ${tc} = Check Test Case ${TESTNAME} - Check Log Message ${tc.kws[0].msgs[0]} ${exp_error_msg} FAIL + Check Log Message ${tc[0, 0]} ${exp_error_msg} FAIL In Exception In Setup ${tc} = Check Test Case ${TESTNAME} - Check Log Message ${tc.setup.msgs[0]} ${exp_error_msg} FAIL + Check Log Message ${tc.setup[0]} ${exp_error_msg} FAIL In Exception In Teardown ${tc} = Check Test Case ${TESTNAME} - Check Log Message ${tc.teardown.msgs[0]} ${exp_error_msg} FAIL + Check Log Message ${tc.teardown[0]} ${exp_error_msg} FAIL diff --git a/atest/robot/running/pass_execution.robot b/atest/robot/running/pass_execution.robot index ef624be55a4..4bea02d7167 100644 --- a/atest/robot/running/pass_execution.robot +++ b/atest/robot/running/pass_execution.robot @@ -1,9 +1,9 @@ *** Settings *** -Suite Setup Run Tests ${EMPTY} running/pass_execution.robot -Resource atest_resource.robot +Suite Setup Run Tests ${EMPTY} running/pass_execution.robot +Resource atest_resource.robot *** Variables *** -${PREFIX}= Execution passed with message:\n +${PREFIX}= Execution passed with message:\n *** Test Cases *** Message is required @@ -11,11 +11,11 @@ Message is required With message ${tc}= Check Test Tags ${TESTNAME} force1 force2 - Check Log Message ${tc.kws[0].msgs[0]} ${PREFIX}My message + Check Log Message ${tc[0, 0]} ${PREFIX}My message With HTML message ${tc}= Check Test Tags ${TESTNAME} force1 force2 - Check Log Message ${tc.kws[0].msgs[0]} ${PREFIX}Message HTML + Check Log Message ${tc[0, 0]} ${PREFIX}Message HTML Empty message is not allowed Check Test Case ${TESTNAME} @@ -40,17 +40,17 @@ Used in template keyword Used in for loop ${tc}= Check Test Case ${TESTNAME} - Check Log Message ${tc.kws[0].kws[0].kws[0].msgs[0]} ${PREFIX}Message with 'foo' + Check Log Message ${tc[0, 0, 0, 0]} ${PREFIX}Message with 'foo' Used in setup ${tc} = Check Test Case ${TESTNAME} - Keyword Should Have Been Executed ${tc.kws[0]} + Keyword Should Have Been Executed ${tc[0]} Keyword Should Have Been Executed ${tc.teardown} Used in teardown ${tc}= Check Test Case ${TESTNAME} Should Be Equal ${tc.teardown.status} PASS - Check Log Message ${tc.teardown.kws[0].msgs[0]} ${PREFIX}This message is used. + Check Log Message ${tc.teardown[0, 0]} ${PREFIX}This message is used. Before failing teardown Check Test Case ${TESTNAME} @@ -60,14 +60,14 @@ After continuable failure After continuable failure in user keyword ${tc}= Check Test Case ${TESTNAME} - Should Be Equal ${tc.kws[0].status} FAIL + Should Be Equal ${tc[0].status} FAIL After continuable failure in FOR loop ${tc}= Check Test Case ${TESTNAME} - Should Be Equal ${tc.kws[0].status} FAIL - Should Be Equal ${tc.kws[0].kws[0].status} FAIL - Should Be Equal ${tc.kws[0].kws[0].kws[0].status} FAIL - Should Be Equal ${tc.kws[0].kws[0].kws[1].status} PASS + Should Be Equal ${tc[0].status} FAIL + Should Be Equal ${tc[0, 0].status} FAIL + Should Be Equal ${tc[0, 0, 0].status} FAIL + Should Be Equal ${tc[0, 0, 1].status} PASS After continuable failure and before failing teardown Check Test Case ${TESTNAME} @@ -86,65 +86,64 @@ After continuable failure in keyword teardown Remove one tag ${tc}= Check Test Tags ${TESTNAME} force2 - Check Log Message ${tc.kws[0].msgs[0]} Removed tag 'force1'. - Check Log Message ${tc.kws[0].msgs[1]} ${PREFIX}Message + Check Log Message ${tc[0, 0]} Removed tag 'force1'. + Check Log Message ${tc[0, 1]} ${PREFIX}Message Remove multiple tags ${tc}= Check Test Tags ${TESTNAME} - Check Log Message ${tc.kws[0].msgs[0]} Removed tags 'force1' and 'force2'. - Check Log Message ${tc.kws[0].msgs[1]} ${PREFIX}Message + Check Log Message ${tc[0, 0]} Removed tags 'force1' and 'force2'. + Check Log Message ${tc[0, 1]} ${PREFIX}Message Remove tags with pattern ${tc}= Check Test Tags ${TESTNAME} - Check Log Message ${tc.kws[0].msgs[0]} Removed tag 'force?'. - Check Log Message ${tc.kws[0].msgs[1]} ${PREFIX}Message + Check Log Message ${tc[0, 0]} Removed tag 'force?'. + Check Log Message ${tc[0, 1]} ${PREFIX}Message Set one tag ${tc}= Check Test Tags ${TESTNAME} force1 force2 tag - Check Log Message ${tc.kws[0].msgs[0]} Set tag 'tag'. - Check Log Message ${tc.kws[0].msgs[1]} ${PREFIX}Message + Check Log Message ${tc[0, 0]} Set tag 'tag'. + Check Log Message ${tc[0, 1]} ${PREFIX}Message Set multiple tags ${tc}= Check Test Tags ${TESTNAME} force1 force2 tag1 tag2 - Check Log Message ${tc.kws[0].msgs[0]} Set tags 'tag1' and 'tag2'. - Check Log Message ${tc.kws[0].msgs[1]} ${PREFIX}Message + Check Log Message ${tc[0, 0]} Set tags 'tag1' and 'tag2'. + Check Log Message ${tc[0, 1]} ${PREFIX}Message Set and remove tags ${tc}= Check Test Tags ${TESTNAME} tag1 tag2 - Check Log Message ${tc.kws[0].msgs[0]} Removed tag 'force?'. - Check Log Message ${tc.kws[0].msgs[1]} Set tags 'tag1' and 'tag2'. - Check Log Message ${tc.kws[0].msgs[2]} ${PREFIX}Message + Check Log Message ${tc[0, 0]} Removed tag 'force?'. + Check Log Message ${tc[0, 1]} Set tags 'tag1' and 'tag2'. + Check Log Message ${tc[0, 2]} ${PREFIX}Message Set tags are not removed ${tc}= Check Test Tags ${TESTNAME} force1 force2 tag1 tag2 - Check Log Message ${tc.kws[0].msgs[0]} Removed tag 'tag?'. - Check Log Message ${tc.kws[0].msgs[1]} Set tags 'tag1' and 'tag2'. - Check Log Message ${tc.kws[0].msgs[2]} ${PREFIX}Message + Check Log Message ${tc[0, 0]} Removed tag 'tag?'. + Check Log Message ${tc[0, 1]} Set tags 'tag1' and 'tag2'. + Check Log Message ${tc[0, 2]} ${PREFIX}Message Set tags in teardown ${tc}= Check Test Tags ${TESTNAME} tag1 tag2 - Check Log Message ${tc.teardown.msgs[0]} Removed tag 'force?'. - Check Log Message ${tc.teardown.msgs[1]} Set tags 'tag1' and 'tag2'. - Check Log Message ${tc.teardown.msgs[2]} ${PREFIX}Message + Check Log Message ${tc.teardown[0]} Removed tag 'force?'. + Check Log Message ${tc.teardown[1]} Set tags 'tag1' and 'tag2'. + Check Log Message ${tc.teardown[2]} ${PREFIX}Message Pass Execution If when condition is true Check Test Case ${TESTNAME} Pass Execution If when condition is false ${tc} = Check Test Case ${TESTNAME} - Keyword Should Have Been Executed ${tc.kws[1]} + Keyword Should Have Been Executed ${tc[1]} Pass Execution If resolves variables only condition is true ${tc} = Check Test Case ${TESTNAME} - Keyword Should Have Been Executed ${tc.kws[1]} + Keyword Should Have Been Executed ${tc[1]} Pass Execution If with multiple variables Check Test Tags ${TESTNAME} force1 force2 my tags Statuses should be correct when running tests - Check Stdout Contains SEPARATOR=\n - ... 34 critical tests, 20 passed, 14 failed - ... 34 tests total, 20 passed, 14 failed + Stdout Should Contain SEPARATOR=\n + ... 34 tests, 20 passed, 14 failed Passes suite setup and teardown and can modify tags in former Run Tests ${EMPTY} running/pass_execution_in_suite_setup_and_teardown.robot diff --git a/atest/robot/running/prevent_recursion.robot b/atest/robot/running/prevent_recursion.robot deleted file mode 100644 index 6a44fdae593..00000000000 --- a/atest/robot/running/prevent_recursion.robot +++ /dev/null @@ -1,22 +0,0 @@ -*** Settings *** -Suite Setup Run Tests ${EMPTY} running/prevent_recursion.robot -Resource atest_resource.robot - -*** Test Cases *** - -Infinite recursion - Check Test Case ${TESTNAME} - -Infinite cyclic recursion - Check Test Case ${TESTNAME} - -Infinite recursion with Run Keyword - Check Test Case ${TESTNAME} - -Infinitely recursive for loop - Check Test Case ${TESTNAME} - -Recursion below the recursion limit is ok - [Documentation] Also verifies that recursion limit blown earlier doesn't affect subsequent tests - Check Test Case ${TESTNAME} - diff --git a/atest/robot/running/return.robot b/atest/robot/running/return.robot new file mode 100644 index 00000000000..a94de10b833 --- /dev/null +++ b/atest/robot/running/return.robot @@ -0,0 +1,73 @@ +*** Settings *** +Suite Setup Run Tests ${EMPTY} running/return.robot +Resource atest_resource.robot + +*** Test Cases *** +Simple + ${tc} = Check Test Case ${TESTNAME} + Should Be Equal ${tc[0, 1].type} RETURN + Should Be Equal ${tc[0, 1].values} ${{()}} + Should Be Equal ${tc[0, 1].status} PASS + Should Be Equal ${tc[0, 1].message} ${EMPTY} + Should Be Equal ${tc[0, 2].status} NOT RUN + Should Be Equal ${tc[0].message} ${EMPTY} + +Return value + ${tc} = Check Test Case ${TESTNAME} + Should Be Equal ${tc[0, 0].type} RETURN + Should Be Equal ${tc[0, 0].values} ${{('value',)}} + +Return value as variable + ${tc} = Check Test Case ${TESTNAME} + Should Be Equal ${tc[0, 0].type} RETURN + Should Be Equal ${tc[0, 0].values} ${{('\${42}',)}} + +Return multiple values + ${tc} = Check Test Case ${TESTNAME} + Should Be Equal ${tc[0, 0].type} RETURN + Should Be Equal ${tc[0, 0].values} ${{('first', '\${2}', 'third')}} + +In nested keyword + Check Test Case ${TESTNAME} + +In IF + ${tc} = Check Test Case ${TESTNAME} + Should Be Equal ${tc[0, 0, 0, 0].type} RETURN + Should Be Equal ${tc[0, 0, 0, 0].status} PASS + Should Be Equal ${tc[0, 0, 0, 1].status} NOT RUN + Should Be Equal ${tc[0, 1].status} NOT RUN + Should Be Equal ${tc[2, 0, 1, 0].type} RETURN + Should Be Equal ${tc[2, 0, 1, 0].status} PASS + Should Be Equal ${tc[2, 0, 1, 1].status} NOT RUN + Should Be Equal ${tc[2, 1].status} NOT RUN + +In inline IF + Check Test Case ${TESTNAME} + +In FOR + ${tc} = Check Test Case ${TESTNAME} + Should Be Equal ${tc[0, 0, 0, 0].type} RETURN + Should Be Equal ${tc[0, 0, 0, 0].status} PASS + Should Be Equal ${tc[0, 0, 0, 1].status} NOT RUN + Should Be Equal ${tc[0, 1].status} NOT RUN + +In nested FOR/IF structure + Check Test Case ${TESTNAME} + +In test + Check Test Case ${TESTNAME} + +In test with values + Check Test Case ${TESTNAME} + +In test inside IF + Check Test Case ${TESTNAME} + +In test inside FOR + Check Test Case ${TESTNAME} + +In test inside WHILE + Check Test Case ${TESTNAME} + +In test inside TRY + Check Test Case ${TESTNAME} diff --git a/atest/robot/running/return_from_keyword.robot b/atest/robot/running/return_from_keyword.robot index dccd81ab529..34b905cfa3c 100644 --- a/atest/robot/running/return_from_keyword.robot +++ b/atest/robot/running/return_from_keyword.robot @@ -4,34 +4,34 @@ Resource atest_resource.robot *** Test Cases *** Without return value - Test And All Keywords Should Have Passed + Test And All Keywords Should Have Passed allow not run=True With single return value - Test And All Keywords Should Have Passed + Test And All Keywords Should Have Passed allow not run=True With multiple return values - Test And All Keywords Should Have Passed + Test And All Keywords Should Have Passed allow not run=True With variable - Test And All Keywords Should Have Passed + Test And All Keywords Should Have Passed allow not run=True With list variable - Test And All Keywords Should Have Passed + Test And All Keywords Should Have Passed allow not run=True Escaping - Test And All Keywords Should Have Passed + Test And All Keywords Should Have Passed allow not run=True In nested keyword - Test And All Keywords Should Have Passed + Test And All Keywords Should Have Passed allow not run=True Inside for loop in keyword - Test And All Keywords Should Have Passed + Test And All Keywords Should Have Passed allow not run=True Keyword teardown is run - Test And All Keywords Should Have Passed + Test And All Keywords Should Have Passed allow not run=True In a keyword inside keyword teardown - Test And All Keywords Should Have Passed + Test And All Keywords Should Have Passed allow not run=True Fails if used directly in keyword teardown Check Test Case ${TESTNAME} @@ -49,12 +49,12 @@ With continuable failure in for loop Check Test Case ${TESTNAME} Return From Keyword If - Test And All Keywords Should Have Passed + Test And All Keywords Should Have Passed allow not run=True Return From Keyword If does not evaluate bogus arguments if condition is untrue Check Test Case ${TESTNAME} Logs Info ${tc} = Check Test Case Without Return Value - Check Log Message ${tc.kws[0].kws[0].msgs[0]} + Check Log Message ${tc[0, 0, 0]} ... Returning from the enclosing user keyword. diff --git a/atest/robot/running/setup_and_teardown_using_embedded_arguments.robot b/atest/robot/running/setup_and_teardown_using_embedded_arguments.robot new file mode 100644 index 00000000000..04fa1b5d44e --- /dev/null +++ b/atest/robot/running/setup_and_teardown_using_embedded_arguments.robot @@ -0,0 +1,46 @@ +*** Settings *** +Suite Setup Run Tests ${EMPTY} running/setup_and_teardown_using_embedded_arguments.robot +Resource atest_resource.robot + +*** Test Cases *** +Suite setup and teardown + Should Be Equal ${SUITE.setup.name} Embedded "arg" + Should Be Equal ${SUITE.teardown.name} Object \${LIST} + +Test setup and teardown + ${tc} = Check Test Case ${TESTNAME} + Should Be Equal ${tc.setup.name} Embedded "arg" + Should Be Equal ${tc.teardown.name} Embedded "arg" + +Keyword setup and teardown + ${tc} = Check Test Case ${TESTNAME} + Should Be Equal ${tc[0].setup.name} Embedded "arg" + Should Be Equal ${tc[0].teardown.name} Embedded "arg" + +Argument as variable + ${tc} = Check Test Case ${TESTNAME} + Should Be Equal ${tc.setup.name} Embedded "\${ARG}" + Should Be Equal ${tc[0].setup.name} Embedded "\${ARG}" + Should Be Equal ${tc[0].teardown.name} Embedded "\${ARG}" + Should Be Equal ${tc.teardown.name} Embedded "\${ARG}" + +Argument as non-string variable + ${tc} = Check Test Case ${TESTNAME} + Should Be Equal ${tc.setup.name} Object \${LIST} + Should Be Equal ${tc[0].setup.name} Object \${LIST} + Should Be Equal ${tc[0].teardown.name} Object \${LIST} + Should Be Equal ${tc.teardown.name} Object \${LIST} + +Argument matching only after replacing variables + ${tc} = Check Test Case ${TESTNAME} + Should Be Equal ${tc.setup.name} Embedded "arg" + Should Be Equal ${tc[0].setup.name} Embedded "arg" + Should Be Equal ${tc[0].teardown.name} Embedded "arg" + Should Be Equal ${tc.teardown.name} Embedded "arg" + +Exact match after replacing variables has higher precedence + ${tc} = Check Test Case ${TESTNAME} + Should Be Equal ${tc.setup.name} Embedded not, exact match instead + Should Be Equal ${tc[0].setup.name} Embedded not, exact match instead + Should Be Equal ${tc[0].teardown.name} Embedded not, exact match instead + Should Be Equal ${tc.teardown.name} Embedded not, exact match instead diff --git a/atest/robot/running/skip.robot b/atest/robot/running/skip.robot new file mode 100644 index 00000000000..1af592e235f --- /dev/null +++ b/atest/robot/running/skip.robot @@ -0,0 +1,167 @@ +*** Settings *** +Suite Setup Run Tests --skip skip-this --SkipOnFailure skip-on-failure running/skip/ +Resource atest_resource.robot + +*** Test Cases *** +Skip keyword + Check Test Case ${TEST NAME} + +Skip with SkipExecution exception in library + Check Test Case ${TEST NAME} + +Skip with SkipExecution exception in library using HTML + Check Test Case ${TEST NAME} + +Skip with custom exception + Check Test Case ${TEST NAME} + +Skip If Keyword with True Condition + Check Test Case ${TEST NAME} + +Skip If Keyword with True Condition And Custom Message + Check Test Case ${TEST NAME} + +Skip If Keyword with False Condition + Check Test Case ${TEST NAME} + +Skip Keyword with Custom Message + Check Test Case ${TEST NAME} + +Skip in Setup + Check Test Case ${TEST NAME} + +Remaining setup keywords aren't run after skip + Check Test Case ${TEST NAME} + +Skip in Teardown + Check Test Case ${TEST NAME} + +Remaining teardown keywords aren't run after skip + Check Test Case ${TEST NAME} + +Skip in Teardown After Failure In Body + Check Test Case ${TEST NAME} + +Teardown is executed after skip + ${tc} = Check Test Case ${TEST NAME} + Check log message ${tc.teardown[0]} Teardown is executed! + +Fail in Teardown After Skip In Body + Check Test Case ${TEST NAME} + +Skip in Teardown After Skip In Body + Check Test Case ${TEST NAME} + +Skip After Continuable Failure + Check Test Case ${TEST NAME} + +Skip After Multiple Continuable Failures + Check Test Case ${TEST NAME} + +Skip After Continuable Failure with HTML Message + Check Test Case ${TEST NAME} + +Skip After Multiple Continuable Failure with HTML Messages + Check Test Case ${TEST NAME} + +Skip with Pass Execution in Teardown + Check Test Case ${TEST NAME} + +Skip in Teardown with Pass Execution in Body + Check Test Case ${TEST NAME} + +Skip in Teardown After Continuable Failures + Check Test Case ${TEST NAME} + +Skip in Suite Setup + Check Test Case ${TEST NAME} + +Skip in Directory Suite Setup + Check Test Case ${TEST NAME} + +Skip In Suite Teardown + Check Test Case ${TEST NAME} + +Skip In Suite Setup And Teardown + Check Test Case ${TEST NAME} + +Skip In Suite Teardown After Fail In Setup + Check Test Case ${TEST NAME} + +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} + +Skip with Run Keyword and Expect Error + Check Test Case ${TEST NAME} + +Skip with Run Keyword and Return Status + Check Test Case ${TEST NAME} + +Skip with Wait Until Keyword Succeeds + Check Test Case ${TEST NAME} + +Skipped with --skip + Check Test Case ${TEST NAME} + +Skipped with --skip when tag uses variable + Check Test Case ${TEST NAME} + +Skipped with robot:skip + Check Test Case ${TEST NAME} + +Skipped with robot:skip when tag uses variable + Check Test Case ${TEST NAME} + +Skipped with --SkipOnFailure + Check Test Case ${TEST NAME} + +Skipped with --SkipOnFailure when tag uses variable + Check Test Case ${TEST NAME} + +Skipped with --SkipOnFailure when failure in setup + Check Test Case ${TEST NAME} + +Skipped with --SkipOnFailure when failure in teardown + Check Test Case ${TEST NAME} + +Skipped with --SkipOnFailure when Set Tags used in teardown + Check Test Case ${TEST NAME} + +Skipped with robot:skip-on-failure + Check Test Case ${TEST NAME} + +Skipped with robot:skip-on-failure when tag uses variable + Check Test Case ${TEST NAME} + +Skipping does not affect passing and failing tests + Check Test Case Passing + Check Test Case Failing + +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 + +--skip and --skip-on-failure used multiple times + Run Tests --skip skip-this --skip no-match --SkipOnFailure skip-on-failure --skip-on-failure xxx running/skip/skip.robot + Check Test Case Skipped with --skip + ... message=Test skipped using 'no-match' and 'skip-this' tags. + Check Test Case Skipped with --SkipOnFailure + ... message=Failed test skipped using 'skip-on-failure' and 'xxx' tags.\n\nOriginal failure:\nOoops, we fail! + +--skip and --skip-on-failure with patterns + Run Tests --skip skip-t*s --skip no-match --SkipOnFailure xxxORskip-on-failure running/skip/skip.robot + Check Test Case Skipped with --skip + ... message=Test skipped using 'no-match' and 'skip-t*s' tag patterns. + Check Test Case Skipped with --SkipOnFailure + ... message=Failed test skipped using 'xxx OR skip-on-failure' tag pattern.\n\nOriginal failure:\nOoops, we fail! diff --git a/atest/robot/running/skip_in_rpa_mode.robot b/atest/robot/running/skip_in_rpa_mode.robot new file mode 100644 index 00000000000..6da7ef27bfd --- /dev/null +++ b/atest/robot/running/skip_in_rpa_mode.robot @@ -0,0 +1,10 @@ +*** Settings *** +Suite Setup Run Tests --rpa --skip skip-this --SkipOnFailure skip-on-failure --variable test_or_task:task running/skip/ +Resource atest_resource.robot + +*** Test Cases *** +Skipped with --skip + Check Test Case ${TEST NAME} + +Skipped with --SkipOnFailure + Check Test Case ${TEST NAME} diff --git a/atest/robot/running/skip_with_template.robot b/atest/robot/running/skip_with_template.robot new file mode 100644 index 00000000000..a642c665146 --- /dev/null +++ b/atest/robot/running/skip_with_template.robot @@ -0,0 +1,71 @@ +*** Settings *** +Suite Setup Run Tests ${EMPTY} running/skip_with_template.robot +Resource atest_resource.robot + +*** Test Cases *** +SKIP + PASS -> PASS + ${tc} = Check Test Case ${TEST NAME} + Status Should Be ${tc[0]} SKIP Skipped + Status Should Be ${tc[1]} PASS + +FAIL + ANY -> FAIL + ${tc} = Check Test Case ${TEST NAME} + Status Should Be ${tc[0]} PASS + Status Should Be ${tc[1]} SKIP Skipped + Status Should Be ${tc[2]} PASS + Status Should Be ${tc[3]} FAIL Failed + Status Should Be ${tc[4]} SKIP Skipped + +Only SKIP -> SKIP + ${tc} = Check Test Case ${TEST NAME} + Status Should Be ${tc[0]} SKIP Skipped + Status Should Be ${tc[1]} SKIP Skipped + +IF w/ SKIP + PASS -> PASS + ${tc} = Check Test Case ${TEST NAME} + Status Should Be ${tc[0]} PASS + Status Should Be ${tc[1]} SKIP Skipped + Status Should Be ${tc[2]} PASS + +IF w/ FAIL + ANY -> FAIL + ${tc} = Check Test Case ${TEST NAME} + Status Should Be ${tc[0]} FAIL Failed + Status Should Be ${tc[1]} SKIP Skipped + Status Should Be ${tc[2]} PASS + +IF w/ only SKIP -> SKIP + ${tc} = Check Test Case ${TEST NAME} + Status Should Be ${tc[0]} SKIP All iterations skipped. + Status Should Be ${tc[1]} SKIP Skip 3 + Status Should Be ${tc[2]} SKIP Skip 4 + +FOR w/ SKIP + PASS -> PASS + ${tc} = Check Test Case ${TEST NAME} + Status Should Be ${tc[0]} PASS + Status Should Be ${tc[1]} SKIP just once + Status Should Be ${tc[2]} PASS + +FOR w/ FAIL + ANY -> FAIL + ${tc} = Check Test Case ${TEST NAME} + Status Should Be ${tc[0]} FAIL Several failures occurred:\n\n1) a\n\n2) b + Status Should Be ${tc[1]} SKIP just once + Status Should Be ${tc[2]} PASS + +FOR w/ only SKIP -> SKIP + ${tc} = Check Test Case ${TEST NAME} + Status Should Be ${tc[0]} SKIP All iterations skipped. + Status Should Be ${tc[1]} SKIP just once + +Messages in test body are ignored + ${tc} = Check Test Case ${TEST NAME} + Check Log Message ${tc[0]} Hello 'Messages in test body are ignored', says listener! + Check Log Message ${tc[1, 0, 0]} Library listener adds messages to body of this test. + Check Log Message ${tc[2, 0, 0]} This iteration is skipped! SKIP + Check Log Message ${tc[3, 0, 0]} This iteration passes! + Check Log Message ${tc[4]} Bye 'Messages in test body are ignored', says listener! + +*** Keywords *** +Status Should Be + [Arguments] ${item} ${status} ${message}= + Should Be Equal ${item.status} ${status} + Should Be Equal ${item.message} ${message} diff --git a/atest/robot/running/steps_after_failure.robot b/atest/robot/running/steps_after_failure.robot new file mode 100644 index 00000000000..602f40d3001 --- /dev/null +++ b/atest/robot/running/steps_after_failure.robot @@ -0,0 +1,168 @@ +*** Settings *** +Suite Setup Run Tests ${EMPTY} running/steps_after_failure.robot +Resource atest_resource.robot + +*** Test Cases *** +Library keyword after failure + ${tc} = Check Test Case ${TESTNAME} + Should Not Be Run ${tc[2:]} 5 + Check Log Message ${tc.teardown[0]} This is run + +User keyword after failure + ${tc} = Check Test Case ${TESTNAME} + Should Not Be Run ${tc[1:]} + +Non-existing keyword after failure + ${tc} = Check Test Case ${TESTNAME} + Should Not Be Run ${tc[1:]} + +Invalid keyword usage after failure + ${tc} = Check Test Case ${TESTNAME} + Should Not Be Run ${tc[1:]} + +Assignment after failure + ${tc} = Check Test Case ${TESTNAME} + Should Not Be Run ${tc[1:]} 4 + Check Keyword Data ${tc[1]} Not run assign=\${x} status=NOT RUN + Check Keyword Data ${tc[2]} Not run assign=\${x} status=NOT RUN + Check Keyword Data ${tc[3]} Not run assign=\${x}, \${y} status=NOT RUN + Check Keyword Data ${tc[4]} Not run assign=\${x}, \${y} status=NOT RUN + +IF after failure + ${tc} = Check Test Case ${TESTNAME} + Should Not Be Run ${tc[1:]} + Should Not Be Run ${tc[1, 0].body} + Should Not Be Run ${tc[1, 1].body} + Check Keyword Data ${tc[1, 1, 0]} + ... BuiltIn.Fail assign=\${x} args=This should not be run status=NOT RUN + +GROUP after failure + ${tc} = Check Test Case ${TESTNAME} + Should Not Be Run ${tc[1:]} + Should Not Be Run ${tc[1].body} 2 + Check Keyword Data ${tc[1, 1]} + ... BuiltIn.Fail assign=\${x} args=This should not be run status=NOT RUN + +FOR after failure + ${tc} = Check Test Case ${TESTNAME} + Should Not Be Run ${tc[1:]} + Should Not Be Run ${tc[1].body} + Should Not Be Run ${tc[1, 0].body} 2 + Check Keyword Data ${tc[1, 0, 1]} + ... BuiltIn.Fail assign=\${x} args=This should not be run either status=NOT RUN + +TRY after failure + ${tc} = Check Test Case ${TESTNAME} + Should Not Be Run ${tc[1:]} + Should Not Be Run ${tc[1].body} 4 + FOR ${step} IN @{tc[1].body} + Should Not Be Run ${step.body} + END + +WHILE after failure + ${tc} = Check Test Case ${TESTNAME} + Should Not Be Run ${tc[1:]} 3 + Should Not Be Run ${tc[1].body} + Should Not Be Run ${tc[1, 0].body} 3 + Should Not Be Run ${tc[2].body} + Should Not Be Run ${tc[2, 0].body} 2 + Should Not Be Run ${tc[3].body} + Should Not Be Run ${tc[3, 0].body} 1 + +RETURN after failure + ${tc} = Check Test Case ${TESTNAME} + Should Not Be Run ${tc[1:]} + Should Not Be Run ${tc[0][1:]} 2 + Should Be Equal ${tc[0, 1].type} RETURN + +BREAK and CONTINUE after failure + ${tc} = Check Test Case ${TESTNAME} + Should Not Be Run ${tc[1:]} 1 + Should Not Be Run ${tc[0, 0][1:]} 2 + Should Not Be Run ${tc[1].body} + Should Not Be Run ${tc[1, 0].body} 2 + +Nested control structure after failure + ${tc} = Check Test Case ${TESTNAME} + Should Not Be Run ${tc[1:]} 2 + Should Be Equal ${tc[1].type} FOR + Should Not Be Run ${tc[1].body} 1 + Should Be Equal ${tc[1, 0].type} ITERATION + Should Not Be Run ${tc[1, 0].body} 2 + Should Be Equal ${tc[1, 0, 0].type} IF/ELSE ROOT + Should Not Be Run ${tc[1, 0, 0].body} 2 + Should Be Equal ${tc[1, 0, 0, 0].type} IF + Should Not Be Run ${tc[1, 0, 0, 0].body} 2 + Should Be Equal ${tc[1, 0, 0, 0, 0].type} FOR + Should Not Be Run ${tc[1, 0, 0, 0, 0].body} 1 + Should Be Equal ${tc[1, 0, 0, 0, 0, 0].type} ITERATION + Should Not Be Run ${tc[1, 0, 0, 0, 0, 0].body} 2 + Should Be Equal ${tc[1, 0, 0, 0, 0, 0, 0].type} KEYWORD + Should Be Equal ${tc[1, 0, 0, 0, 0, 0, 1].type} GROUP + Should Not Be Run ${tc[1, 0, 0, 0, 0, 0, 1].body} 2 + Should Be Equal ${tc[1, 0, 0, 0, 0, 0, 1, 0].type} KEYWORD + Should Be Equal ${tc[1, 0, 0, 0, 0, 0, 1, 1].type} KEYWORD + Should Be Equal ${tc[1, 0, 0, 0, 1].type} KEYWORD + Should Be Equal ${tc[1, 0, 0, 1].type} ELSE + Should Not Be Run ${tc[1, 0, 0, 1].body} 2 + Should Be Equal ${tc[1, 0, 0, 1, 0].type} WHILE + Should Not Be Run ${tc[1, 0, 0, 1, 0].body} 1 + Should Be Equal ${tc[1, 0, 0, 1, 0, 0].type} ITERATION + Should Not Be Run ${tc[1, 0, 0, 1, 0, 0].body} 2 + Should Be Equal ${tc[1, 0, 0, 1, 0, 0, 0].type} KEYWORD + Should Be Equal ${tc[1, 0, 0, 1, 0, 0, 1].type} KEYWORD + Should Be Equal ${tc[1, 0, 0, 1, 1].type} TRY/EXCEPT ROOT + Should Not Be Run ${tc[1, 0, 0, 1, 1].body} 2 + Should Be Equal ${tc[1, 0, 0, 1, 1, 0].type} TRY + Should Not Be Run ${tc[1, 0, 0, 1, 1, 0].body} 1 + Should Be Equal ${tc[1, 0, 0, 1, 1, 0, 0].type} KEYWORD + Should Be Equal ${tc[1, 0, 0, 1, 1, 1].type} EXCEPT + Should Not Be Run ${tc[1, 0, 0, 1, 1, 1].body} 1 + Should Be Equal ${tc[1, 0, 0, 1, 1, 1, 0].type} BREAK + Should Be Equal ${tc[1, 0, 1].type} KEYWORD + Should Be Equal ${tc[2].type} KEYWORD + +Failure in user keyword + ${tc} = Check Test Case ${TESTNAME} + Should Not Be Run ${tc[1:]} + Should Not Be Run ${tc[0][1:]} 2 + +Failure in IF branch + ${tc} = Check Test Case ${TESTNAME} + Should Not Be Run ${tc[0, 0][1:]} + Should Not Be Run ${tc[0, 1].body} + Should Not Be Run ${tc[1:]} + +Failure in ELSE IF branch + ${tc} = Check Test Case ${TESTNAME} + Should Not Be Run ${tc[0, 0].body} + Should Not Be Run ${tc[0, 1][1:]} + Should Not Be Run ${tc[0, 2].body} + Should Not Be Run ${tc[1:]} + +Failure in ELSE branch + ${tc} = Check Test Case ${TESTNAME} + Should Not Be Run ${tc[0, 0].body} + Should Not Be Run ${tc[0, 1][1:]} + Should Not Be Run ${tc[1:]} + +Failure in GROUP + ${tc} = Check Test Case ${TESTNAME} + Should Not Be Run ${tc[0, 0][1:]} + Should Not Be Run ${tc[0][1:]} 2 + Should Not Be Run ${tc[0, 2].body} + Should Not Be Run ${tc[1:]} + +Failure in FOR iteration + ${tc} = Check Test Case ${TESTNAME} + Should Not Be Run ${tc[1:]} + Length Should Be ${tc[0].body} 1 + Should Not Be Run ${tc[0, 0][1:]} + +*** Keywords *** +Should Not Be Run + [Arguments] ${steps} ${expected count}=1 + FOR ${step} IN @{steps} + Should Be Equal ${step.status} NOT RUN + END + Length Should Be ${steps} ${expected count} diff --git a/atest/robot/running/stopping_with_signal.robot b/atest/robot/running/stopping_with_signal.robot index 581ba8bd0df..fe79715a963 100644 --- a/atest/robot/running/stopping_with_signal.robot +++ b/atest/robot/running/stopping_with_signal.robot @@ -1,82 +1,110 @@ *** Settings *** -Resource atest_resource.robot -Library ProcessManager.py +Documentation Test that SIGINT and SIGTERM can stop execution gracefully +... (one signal) and forcefully (two signals). Windows does not +... support these signals so we use CTRL_C_EVENT instead SIGINT +... and do not test with SIGTERM. +Resource atest_resource.robot *** Variables *** -${TEST FILE} %{TEMPDIR}${/}signal-tests.txt +${TEST FILE} %{TEMPDIR}${/}signal-tests.txt *** Test Cases *** SIGINT Signal Should Stop Test Execution Gracefully - Start And Send Signal without_any_timeout.robot One SIGINT - Process Output For Graceful Shutdown + Start And Send Signal without_any_timeout.robot One SIGINT Check Test Cases Have Failed Correctly SIGTERM Signal Should Stop Test Execution Gracefully - [Tags] no-windows - Start And Send Signal without_any_timeout.robot One SIGTERM - Process Output For Graceful Shutdown + [Tags] no-windows + Start And Send Signal without_any_timeout.robot One SIGTERM Check Test Cases Have Failed Correctly Execution Is Stopped Even If Keyword Swallows Exception - [Tags] no-ipy no-jython - Start And Send Signal swallow_exception.robot One SIGTERM - Process Output For Graceful Shutdown + Start And Send Signal swallow_exception.robot One SIGINT Check Test Cases Have Failed Correctly One Signal Should Stop Test Execution Gracefully When Run Keyword Is Used - Start And Send Signal run_keyword.robot One SIGTERM - Process Output For Graceful Shutdown + Start And Send Signal run_keyword.robot One SIGINT Check Test Cases Have Failed Correctly One Signal Should Stop Test Execution Gracefully When Test Timeout Is Used - Start And Send Signal test_timeout.robot One SIGTERM - Process Output For Graceful Shutdown + Start And Send Signal test_timeout.robot One SIGINT Check Test Cases Have Failed Correctly One Signal Should Stop Test Execution Gracefully When Keyword Timeout Is Used - Start And Send Signal keyword_timeout.robot One SIGTERM - Process Output For Graceful Shutdown + Start And Send Signal keyword_timeout.robot One SIGINT Check Test Cases Have Failed Correctly Two SIGINT Signals Should Stop Test Execution Forcefully - Start And Send Signal without_any_timeout.robot Two SIGINTs 2s + Start And Send Signal without_any_timeout.robot Two SIGINTs 2s Check Tests Have Been Forced To Shutdown Two SIGTERM Signals Should Stop Test Execution Forcefully - [Tags] no-windows - Start And Send Signal without_any_timeout.robot Two SIGTERMs 2s + [Tags] no-windows + Start And Send Signal without_any_timeout.robot Two SIGTERMs 2s Check Tests Have Been Forced To Shutdown Two Signals Should Stop Test Execution Forcefully When Run Keyword Is Used - Start And Send Signal run_keyword.robot Two SIGINTs 2s + Start And Send Signal run_keyword.robot Two SIGINTs 2s Check Tests Have Been Forced To Shutdown Two Signals Should Stop Test Execution Forcefully When Test Timeout Is Used - Start And Send Signal test_timeout.robot Two SIGINTs 2s + Start And Send Signal test_timeout.robot Two SIGINTs 2s Check Tests Have Been Forced To Shutdown Two Signals Should Stop Test Execution Forcefully When Keyword Timeout Is Used - Start And Send Signal keyword_timeout.robot Two SIGINTs 2s + Start And Send Signal keyword_timeout.robot Two SIGINTs 2s Check Tests Have Been Forced To Shutdown One Signal Should Stop Test Execution Gracefully And Test Case And Suite Teardowns Should Be Run - Start And Send Signal with_teardown.robot One SIGINT - Process Output For Graceful Shutdown + Start And Send Signal with_teardown.robot One SIGINT Check Test Cases Have Failed Correctly - ${tc} = Get Test Case Test - Check Log Message ${tc.teardown.msgs[0]} Logging Test Case Teardown - ${ts} = Get Test Suite With Teardown - Check Log Message ${ts.teardown.kws[0].msgs[0]} Logging Suite Teardown + ${tc} = Get Test Case Test + Check Log Message ${tc.teardown[0]} Logging Test Case Teardown + Check Log Message ${SUITE.teardown[0, 0]} Logging Suite Teardown Skip Teardowns After Stopping Gracefully - Start And Send Signal with_teardown.robot One SIGINT 0s --SkipTeardownOnExit - Process Output For Graceful Shutdown + Start And Send Signal with_teardown.robot One SIGINT 0s --SkipTeardownOnExit Check Test Cases Have Failed Correctly - ${tc} = Get Test Case Test - Should Be Equal ${tc.teardown} ${None} - ${ts} = Get Test Suite With Teardown - Should Be Equal ${ts.teardown} ${None} + ${tc} = Get Test Case Test + Teardown Should Not Be Defined ${tc} + Teardown Should Not Be Defined ${SUITE} +SIGINT Signal Should Stop Async Test Execution Gracefully + Start And Send Signal async_stop.robot One SIGINT 5 + Check Test Cases Have Failed Correctly + ${tc} = Get Test Case Test + Length Should Be ${tc[1].body} 1 + Check Log Message ${tc[1, 0]} Start Sleep + Length Should Be ${SUITE.teardown.body} 0 + +Two SIGINT Signals Should Stop Async Test Execution Forcefully + Start And Send Signal async_stop.robot Two SIGINTs 5 + Check Tests Have Been Forced To Shutdown + +SIGTERM Signal Should Stop Async Test Execution Gracefully + [Tags] no-windows + Start And Send Signal async_stop.robot One SIGTERM 5 + Check Test Cases Have Failed Correctly + ${tc} = Get Test Case Test + Length Should Be ${tc[1].body} 1 + Check Log Message ${tc[1, 0]} Start Sleep + Length Should Be ${SUITE.teardown.body} 0 + +Two SIGTERM Signals Should Stop Async Test Execution Forcefully + [Tags] no-windows + Start And Send Signal async_stop.robot Two SIGTERMs 5 + Check Tests Have Been Forced To Shutdown + +Signal handler is reset after execution + [Tags] no-windows + ${result} = Run Process + ... @{INTERPRETER.interpreter} + ... ${DATADIR}/running/stopping_with_signal/test_signalhandler_is_reset.py + ... stderr=STDOUT + ... env:PYTHONPATH=${INTERPRETER.src_dir} + Log ${result.stdout} + Should Contain X Times ${result.stdout} Execution terminated by signal count=1 + Should Be Equal ${result.rc} ${0} *** Keywords *** Start And Send Signal @@ -85,7 +113,9 @@ Start And Send Signal Start Run ${datasource} ${sleep} @{extra options} Wait Until Created ${TESTFILE} timeout=45s Run Keyword ${signals} - Wait Until Finished + ${result} = Wait For Process timeout=45s on_timeout=terminate + Log Many ${result.rc} ${result.stdout} ${result.stderr} + Set Test Variable $STDERR ${result.stderr} Start Run [Arguments] ${datasource} ${sleep} @{extra options} @@ -94,28 +124,29 @@ Start Run ... --output ${OUTFILE} --report NONE --log NONE ... --variable TESTSIGNALFILE:${TEST FILE} ... --variable TEARDOWNSLEEP:${sleep} + ... --variablefile ${CURDIR}${/}enable_ctrl_c_event.py ... @{extra options} ... ${DATADIR}${/}running${/}stopping_with_signal${/}${datasource} Log Many @{command} - ProcessManager.start process @{command} + Start Process @{command} Check Test Cases Have Failed Correctly + Process Output ${OUTFILE} Check Test Tags Test Check Test Tags Test2 robot:exit Check Tests Have Been Forced To Shutdown - ${stderr} = ProcessManager.Get Stderr - Should Contain ${stderr} Execution forcefully stopped - -Process Output For Graceful Shutdown - Wait Until Created ${OUTFILE} timeout=45s - Process Output ${OUTFILE} + Should Contain ${STDERR} Execution forcefully stopped One SIGINT - Send Terminate SIGINT + # Process library doesn't support sending signals on Windows so need to + # use Call Method instead. Also use CTRL_C_EVENT, not SIGINT, on Windows. + ${process} = Get Process Object + ${signal} = Evaluate signal.CTRL_C_EVENT if $INTERPRETER.is_windows else signal.SIGINT + Call Method ${process} send_signal ${signal} One SIGTERM - Send Terminate SIGTERM + Send Signal To Process SIGTERM Two SIGINTs One SIGINT diff --git a/atest/robot/running/test_case_status.robot b/atest/robot/running/test_case_status.robot new file mode 100644 index 00000000000..3336db55058 --- /dev/null +++ b/atest/robot/running/test_case_status.robot @@ -0,0 +1,62 @@ +*** 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 Cases *** +Test Passes + Check Test Case ${TEST NAME} + +Test Fails + Check Test Case ${TEST NAME} + +Non-Existing Keyword Error + Check Test Case ${TEST NAME} + +Test Setup Passes + Check Test Case ${TEST NAME} + +Test Setup Fails + [Documentation] Checks also that test is not run if setup fails + Check Test Case ${TEST NAME} + +Test Setup Error + Check Test Case ${TEST NAME} + +Test Teardown Passes + Check Test Case ${TEST NAME} + +Test Teardown Fails + Check Test Case ${TEST NAME} + +Test Teardown Error + Check Test Case ${TEST NAME} + +Test And Teardown Fails + Check Test Case ${TEST NAME} + +Test Setup And Teardown Pass + Check Test Case ${TEST NAME} + +Test Teardown is Run When Setup Fails + ${tc} = Check Test Case ${TEST NAME} + Should Not Be Equal ${tc.teardown} ${None} Teardown not run values=False + Length Should Be ${tc.teardown.body} 1 + Check Log Message ${tc.teardown[0]} Hello from teardown! + +Test Setup And Teardown Fails + Check Test Case ${TEST NAME} + +robot.api.Failure + Check Test Case ${TEST NAME} + +robot.api.Failure with HTML message + Check Test Case ${TEST NAME} + +robot.api.Error + Check Test Case ${TEST NAME} + +robot.api.Error with HTML message + Check Test Case ${TEST NAME} diff --git a/atest/robot/running/test_template.robot b/atest/robot/running/test_template.robot index d40e2b22471..ba2a2e22941 100644 --- a/atest/robot/running/test_template.robot +++ b/atest/robot/running/test_template.robot @@ -24,7 +24,7 @@ Overriding Default Template In Test With NONE Value Template With Variables Check Test Case ${TESTNAME} -Template With @{EMPTY} Variable +Template With \@{EMPTY} Variable Check Test Case ${TESTNAME} Template With Variables And Keyword Name @@ -51,6 +51,44 @@ Template With FOR Loop Containing Variables Template With FOR IN RANGE Loop Check Test Case ${TESTNAME} +Nested FOR + Check Test Case ${TESTNAME} + +Invalid FOR + Check Test Case ${TESTNAME} + +Template With IF + ${tc} = Check Test Case ${TESTNAME} + Should Be Equal ${tc[0].status} PASS + Should Be Equal ${tc[0, 0].type} IF + Should Be Equal ${tc[0, 0].status} NOT RUN + Should Be Equal ${tc[0, 1].type} ELSE IF + Should Be Equal ${tc[0, 1].status} NOT RUN + Should Be Equal ${tc[0, 2].type} ELSE + Should Be Equal ${tc[0, 2].status} PASS + +Template With IF Failing + ${tc} = Check Test Case ${TESTNAME} + Should Be Equal ${tc[0].status} FAIL + Should Be Equal ${tc[0, 0].type} IF + Should Be Equal ${tc[0, 0].status} FAIL + Should Be Equal ${tc[1].status} FAIL + Should Be Equal ${tc[1, 0].type} IF + Should Be Equal ${tc[1, 0].status} NOT RUN + Should Be Equal ${tc[1, 1].type} ELSE IF + Should Be Equal ${tc[1, 1].status} FAIL + Should Be Equal ${tc[1, 2].type} ELSE + Should Be Equal ${tc[1, 2].status} NOT RUN + +Invalid IF + ${tc} = Check Test Case ${TESTNAME} + Should Be Equal ${tc[0].status} FAIL + Should Be Equal ${tc[0, 0].type} IF + Should Be Equal ${tc[0, 0].status} FAIL + +FOR and IF + Check Test Case ${TESTNAME} + User Keywords Should Not Be Continued On Failure Check Test Case ${TESTNAME} @@ -78,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/test_template_with_embeded_args.robot b/atest/robot/running/test_template_with_embeded_args.robot index 680406bce21..65b3d427abe 100644 --- a/atest/robot/running/test_template_with_embeded_args.robot +++ b/atest/robot/running/test_template_with_embeded_args.robot @@ -5,39 +5,39 @@ Resource atest_resource.robot *** Test Cases *** Matching arguments ${tc} = Check Test Case ${TESTNAME} - Keyword should be ${tc.kws[0]} The result of 1 + 1 should be 2 - Keyword should be ${tc.kws[1]} The result of 1 + 2 should be 3 - Keyword should be ${tc.kws[2]} The result of 1 + 3 should be 5 + Keyword should be ${tc[0]} The result of 1 + 1 should be 2 + Keyword should be ${tc[1]} The result of 1 + 2 should be 3 + Keyword should be ${tc[2]} The result of 1 + 3 should be 5 Argument names do not need to be same as in definition ${tc} = Check Test Case ${TESTNAME} - Keyword should be ${tc.kws[0]} The result of 1 + 1 should be 2 - Keyword should be ${tc.kws[1]} The result of 1 + 2 should be 3 - Keyword should be ${tc.kws[2]} The result of 1 + 3 should be 5 + Keyword should be ${tc[0]} The result of 1 + 1 should be 2 + Keyword should be ${tc[1]} The result of 1 + 2 should be 3 + Keyword should be ${tc[2]} The result of 1 + 3 should be 5 Some arguments can be hard-coded ${tc} = Check Test Case ${TESTNAME} - Keyword should be ${tc.kws[0]} The result of 1 + 1 should be 3 - Keyword should be ${tc.kws[1]} The result of 1 + 2 should be 3 - Keyword should be ${tc.kws[2]} The result of 1 + 3 should be 3 + Keyword should be ${tc[0]} The result of 1 + 1 should be 3 + Keyword should be ${tc[1]} The result of 1 + 2 should be 3 + Keyword should be ${tc[2]} The result of 1 + 3 should be 3 Can have different arguments than definition ${tc} = Check Test Case ${TESTNAME} - Keyword should be ${tc.kws[0]} The result of 38 + 3 + 1 should be 42 - Keyword should be ${tc.kws[1]} The non-existing of 666 should be 42 + Keyword should be ${tc[0]} The result of 38 + 3 + 1 should be 42 + Keyword should be ${tc[1]} The non-existing of 666 should be 42 Can use variables ${tc} = Check Test Case ${TESTNAME} - Keyword should be ${tc.kws[0]} The result of \${1} + \${2} should be \${3} + Keyword should be ${tc[0]} The result of \${1} + \${2} should be \${3} Cannot have more arguments than variables ${tc} = Check Test Case ${TESTNAME} - Keyword should be ${tc.kws[0]} The result of \${calc} should be 3 + Keyword should be ${tc[0]} The result of \${calc} should be 3 ... 1 + 2 extra Cannot have less arguments than variables ${tc} = Check Test Case ${TESTNAME} - Keyword should be ${tc.kws[0]} The result of \${calc} should be \${extra} + Keyword should be ${tc[0]} The result of \${calc} should be \${extra} ... 1 + 2 *** Keywords *** diff --git a/atest/robot/running/timeouts.robot b/atest/robot/running/timeouts.robot index fc817ad8b68..28089a9d1cb 100644 --- a/atest/robot/running/timeouts.robot +++ b/atest/robot/running/timeouts.robot @@ -16,19 +16,14 @@ Timeouted Test Passes Timeouted Test Fails Before Timeout Check Test Case Failing Before Timeout -Show Correct Trace Back When Failing Before Timeout - [Tags] no-ipy # For some reason IronPython loses the traceback in this case. +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) - Check Log Message ${tc.kws[0].msgs[-1]} ${expected} pattern=yes level=DEBUG - -Show Correct Trace Back When Failing In Java Before Timeout - [Tags] require-jython - ${tc} = Check Test Case ${TEST NAME} - Should Contain ${tc.kws[0].msgs[-1].message} at ExampleJavaLibrary.exception( + ... RuntimeError: Failure before timeout + Check Log Message ${tc[0, -1]} ${expected} DEBUG pattern=True traceback=True Timeouted Test Timeouts Check Test Case Sleeping And Timeouting @@ -38,7 +33,6 @@ Total Time Too Long Check Test Case ${TEST NAME} 1 Check Test Case ${TEST NAME} 2 Check Test Case ${TEST NAME} 3 - Check Test Case ${TEST NAME} 4 Timout Defined For One Test Check Test Case ${TEST NAME} @@ -68,17 +62,17 @@ Test Timeouts When Also Keywords Are Timeouted Keyword Timeout From Variable ${tc} = Check Test Case ${TEST NAME} - Should Be Equal ${tc.kws[0].timeout} 1 millisecond + Should Be Equal ${tc[0].timeout} 10 milliseconds Keyword Timeout From Argument ${tc} = Check Test Case ${TEST NAME} - Should Be Equal ${tc.kws[0].timeout} 1 second - Should Be Equal ${tc.kws[1].timeout} 2 milliseconds + Should Be Equal ${tc[0].timeout} 1 second + Should Be Equal ${tc[1].timeout} 11 milliseconds Embedded Arguments Timeout From Argument ${tc} = Check Test Case ${TEST NAME} - Should Be Equal ${tc.kws[0].timeout} 1 second - Should Be Equal ${tc.kws[1].timeout} 3 milliseconds + Should Be Equal ${tc[0].timeout} 1 second + Should Be Equal ${tc[1].timeout} 12 milliseconds Local Variables Are Not Visible In Child Keyword Timeout Check Test Case ${TEST NAME} @@ -93,9 +87,9 @@ Test Timeout During Setup Teardown After Test Timeout [Documentation] Test that teardown is executed after a test has timed out ${tc} = Check Test Case ${TEST NAME} - Check Log Message ${tc.teardown.msgs[0]} Teardown executed + Check Log Message ${tc.teardown[0]} Teardown executed ${tc} = Check Test Case Teardown With Sleep After Test Timeout - Check Log Message ${tc.teardown.kws[1].msgs[0]} Teardown executed + Check Log Message ${tc.teardown[1, 0]} Teardown executed Failing Teardown After Test Timeout Check Test Case ${TEST NAME} @@ -103,7 +97,7 @@ Failing Teardown After Test Timeout Test Timeout During Teardown [Documentation] Test timeout should not interrupt teardown but test should be failed afterwards ${tc} = Check Test Case ${TEST NAME} - Check Log Message ${tc.teardown.kws[1].msgs[0]} Teardown executed + Check Log Message ${tc.teardown[1, 0]} Teardown executed Timeouted Setup Passes Check Test Case ${TEST NAME} @@ -117,6 +111,30 @@ Timeouted Teardown Passes Timeouted Teardown Timeouts Check Test Case ${TEST NAME} +Keyword teardown after test timeout + ${tc} = Check Test Case ${TEST NAME} + Should Be Equal ${tc[0].teardown.status} PASS + Check Log Message ${tc[0].teardown[0, 0]} I'm a teardown keyword + +Keyword teardown after keyword timeout + ${tc} = Check Test Case ${TEST NAME} + Should Be Equal ${tc[0].teardown.status} PASS + Check Log Message ${tc[0].teardown[0, 0]} I'm a teardown keyword + +Keyword teardown fails due to total time + ${tc} = Check Test Case ${TEST NAME} + Should Be Equal ${tc[0].teardown.status} PASS + Check Log Message ${tc[0].teardown[0, 0]} I'm a teardown keyword + +Keyword teardown fails for own timeout + ${tc} = Check Test Case ${TEST NAME} + Should Be Equal ${tc[0].teardown.status} FAIL + Check Log Message ${tc[0].teardown[0, 0]} Keyword timeout 14 milliseconds active. 0.??? seconds left. level=DEBUG pattern=True + Check Log Message ${tc[0].teardown[0, 1]} I'm a teardown keyword + +Keyword in teardown fails for timeout + Check Test Case ${TEST NAME} + Timeouted UK Using Non Timeouted UK Check Test Case ${TEST NAME} @@ -138,14 +156,8 @@ Keyword Timeout Should Not Be Active For Run Keyword Variants But To Keywords Th Logging With Timeouts [Documentation] Testing that logging works with timeouts ${tc} = Check Test Case Timeouted Keyword Passes - Check Log Message ${tc.kws[0].msgs[1]} Testing logging in timeouted test - Check Log Message ${tc.kws[1].kws[0].msgs[1]} Testing logging in timeouted keyword - -It Should Be Possible To Print From Java Libraries When Test Timeout Has Been Set - [Tags] require-jython - ${tc} = Check Test Case ${TEST NAME} - Timeout should have been active ${tc.kws[0]} 1 second 2 - Check Log message ${tc.kws[0].msgs[1]} My message from java lib + Check Log Message ${tc[0, 1]} Testing logging in timeouted test + Check Log Message ${tc[1, 0, 1]} Testing logging in timeouted keyword Timeouted Keyword Called With Wrong Number of Arguments Check Test Case ${TEST NAME} @@ -155,27 +167,44 @@ Timeouted Keyword Called With Wrong Number of Arguments with Run Keyword Test Timeout Logging ${tc} = Check Test Case Passing - Timeout should have been active ${tc.kws[0]} 1 second 1 + Timeout should have been active ${tc[0]} 10 seconds 1 ${tc} = Check Test Case Failing Before Timeout - Timeout should have been active ${tc.kws[0]} 2 seconds 3 + Timeout should have been active ${tc[0]} 2 seconds 3 ${tc} = Check Test Case Sleeping And Timeouting - Timeout should have been active ${tc.kws[0]} 1 second 2 exceeded=True + Timeout should have been active ${tc[0]} 5 milliseconds 2 exceeded=True Keyword Timeout Logging ${tc} = Check Test Case Timeouted Keyword Passes - Keyword timeout should have been active ${tc.kws[1].kws[0]} 5 seconds 2 + Keyword timeout should have been active ${tc[1, 0]} 5 seconds 2 ${tc} = Check Test Case Timeouted Keyword Fails Before Timeout - Keyword timeout should have been active ${tc.kws[0].kws[0]} 2 hours 30 minutes 3 + Keyword timeout should have been active ${tc[0, 0]} 2 hours 30 minutes 3 ${tc} = Check Test Case Timeouted Keyword Timeouts - Keyword timeout should have been active ${tc.kws[0].kws[0]} 99 milliseconds 2 exceeded=True + Keyword timeout should have been active ${tc[0, 0]} 11 milliseconds 2 exceeded=True + +Zero timeout is ignored + ${tc} = Check Test Case ${TEST NAME} + Should Be Equal ${tc.timeout} ${None} + Should Be Equal ${tc[0].timeout} ${None} + Elapsed Time Should Be Valid ${tc[0].elapsed_time} minimum=0.01 + +Negative timeout is ignored + ${tc} = Check Test Case ${TEST NAME} + Should Be Equal ${tc.timeout} ${None} + Should Be Equal ${tc[0].timeout} ${None} + Elapsed Time Should Be Valid ${tc[0].elapsed_time} minimum=0.01 + +Invalid test timeout + Check Test Case ${TEST NAME} + +Invalid keyword timeout + Check Test Case ${TEST NAME} *** Keywords *** 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} + Check Log Message ${kw[0]} ${type} timeout ${timeout} active. *.??? seconds left. DEBUG pattern=True + Length Should Be ${kw.body} ${msg count} + IF ${exceeded} Timeout should have exceeded ${kw} ${timeout} ${type} Keyword timeout should have been active [Arguments] ${kw} ${timeout} ${msg count} ${exceeded}=False @@ -183,4 +212,4 @@ Keyword timeout should have been active Timeout should have exceeded [Arguments] ${kw} ${timeout} ${type}=Test - Check Log Message ${kw.msgs[1]} ${type} timeout ${timeout} exceeded. FAIL + Check Log Message ${kw[1]} ${type} timeout ${timeout} exceeded. FAIL diff --git a/atest/robot/running/timeouts_with_custom_messages.robot b/atest/robot/running/timeouts_with_custom_messages.robot index eafe1a1bff7..4f21b34fb55 100644 --- a/atest/robot/running/timeouts_with_custom_messages.robot +++ b/atest/robot/running/timeouts_with_custom_messages.robot @@ -2,68 +2,26 @@ Suite Setup Run Tests ${EMPTY} running/timeouts_with_custom_messages.robot Resource atest_resource.robot -*** Variables *** -${DEPRECATED} Using custom timeout messages is deprecated since Robot Framework 3.0.1 and will be removed in future versions. - *** Test Cases *** Default Test Timeout Message - ${tc} = Check Test Case ${TEST NAME} - Check Log Message ${tc.kws[0].msgs[0]} My customized default test timeout FAIL - Custom message should be deprecated with Test Timeout 0 My customized \${DEFAULT END} + Check Test Case ${TEST NAME} + Using more than one value with timeout should error 0 2 2 Test Timeout Test Timeout Message - ${tc} = Check Test Case ${TEST NAME} - Check Log Message ${tc.kws[0].msgs[0]} My test timeout message FAIL - Custom message should be deprecated with [Timeout] in tests 1 My test timeout message + Check Test Case ${TEST NAME} Test Timeout Message In Multiple Columns - ${tc} = Check Test Case ${TEST NAME} - Check Log Message ${tc.kws[0].msgs[0]} My test timeout message in multiple columns FAIL - Custom message should be deprecated with [Timeout] in tests 2 My test timeout message in multiple columns - -Test Timeout Message With Variables - ${tc} = Check Test Case ${TEST NAME} - Check Log Message ${tc.kws[0].msgs[0]} Test message from variable FAIL - Custom message should be deprecated with [Timeout] in tests 3 Test \${MESSAGE} + Check Test Case ${TEST NAME} Keyword Timeout Message - ${tc} = Check Test Case ${TEST NAME} - Check Log Message ${tc.kws[1].kws[0].msgs[0]} My keyword timeout message FAIL - Custom message should be deprecated with [Timeout] in keywords 4 My keyword timeout message + Check Test Case ${TEST NAME} Keyword Timeout Message In Multiple Columns - ${tc} = Check Test Case ${TEST NAME} - Check Log Message ${tc.kws[0].kws[0].msgs[0]} My keyword timeout message in multiple columns FAIL - Custom message should be deprecated with [Timeout] in keywords 5 My keyword timeout message in multiple columns - -Keyword Timeout Message With Variables - ${tc} = Check Test Case ${TEST NAME} - Check Log Message ${tc.kws[0].kws[0].msgs[0]} Keyword message from variable FAIL - Custom message should be deprecated with [Timeout] in keywords 6 Keyword \${MESSAGE} + Check Test Case ${TEST NAME} *** Keywords *** -Custom message should be deprecated with Test Timeout - [Arguments] ${index} ${message} - ${path} = Normalize Path ${DATADIR}/running/timeouts_with_custom_messages.robot - ${warning} = Catenate Error in file '${path}': - ... ${DEPRECATED} - ... Message that was used is '${message}'. - Check Log Message @{ERRORS}[${index}] ${warning} WARN - -Custom message should be deprecated with [Timeout] in tests - [Arguments] ${index} ${message} - ${path} = Normalize Path ${DATADIR}/running/timeouts_with_custom_messages.robot - ${warning} = Catenate Error in file '${path}': - ... Invalid syntax in test case '${TEST NAME}': - ... ${DEPRECATED} - ... Message that was used is '${message}'. - Check Log Message @{ERRORS}[${index}] ${warning} WARN - -Custom message should be deprecated with [Timeout] in keywords - [Arguments] ${index} ${message} - ${path} = Normalize Path ${DATADIR}/running/timeouts_with_custom_messages.robot - ${warning} = Catenate Error in file '${path}': - ... Invalid syntax in keyword '${TEST NAME}': - ... ${DEPRECATED} - ... Message that was used is '${message}'. - Check Log Message @{ERRORS}[${index}] ${warning} WARN +Using more than one value with timeout should error + [Arguments] ${index} ${lineno} ${count} ${setting}=Timeout + Error In File + ... ${index} running/timeouts_with_custom_messages.robot ${lineno} + ... Setting '${setting}' accepts only one value, got ${count}. diff --git a/atest/robot/running/timeouts_with_logging.robot b/atest/robot/running/timeouts_with_logging.robot index 725db6abc4d..aec35dcc247 100644 --- a/atest/robot/running/timeouts_with_logging.robot +++ b/atest/robot/running/timeouts_with_logging.robot @@ -1,9 +1,16 @@ *** Settings *** -Suite Setup Run Tests ${EMPTY} running/timeouts_with_logging.robot -Resource atest_resource.robot +Suite Setup Run Tests ${EMPTY} running/timeouts_with_logging.robot +Resource atest_resource.robot *** Test Cases *** -Timeouts with programmatic logging - Should Contain Tests ${SUITE} - ... RF logger 1 RF logger 2 RF logger 3 RF logger 4 RF logger 5 - ... Python logging 1 Python logging 2 Python logging 3 Python logging 4 Python logging 5 +Test timeout when logging using RF logger + Check Test Case ${TESTNAME} + +Keyword timeout when logging using RF logger + Check Test Case ${TESTNAME} + +Test timeout when logging using Python logger + Check Test Case ${TESTNAME} + +Keyword timeout when logging using Python logger + Check Test Case ${TESTNAME} diff --git a/atest/robot/running/try_except/except_behaviour.robot b/atest/robot/running/try_except/except_behaviour.robot new file mode 100644 index 00000000000..1ede85342a5 --- /dev/null +++ b/atest/robot/running/try_except/except_behaviour.robot @@ -0,0 +1,77 @@ +*** Settings *** +Resource try_except_resource.robot +Suite Setup Run Tests ${EMPTY} running/try_except/except_behaviour.robot +Test Template Verify try except and block statuses + +*** Test Cases *** +Equals is the default matcher + FAIL PASS pattern_types=[None] + +Equals with whitespace + FAIL PASS + +Glob matcher + FAIL NOT RUN PASS pattern_types=['GloB', 'gloB'] + +Startswith matcher + FAIL PASS pattern_types=['start'] + +Regexp matcher + FAIL NOT RUN PASS pattern_types=['REGEXP', 'REGEXP'] + +Regexp escapes + FAIL PASS + +Regexp flags + FAIL NOT RUN PASS + +Variable in pattern + FAIL PASS + +Invalid variable in pattern + FAIL FAIL PASS + +Non-string pattern + FAIL NOT RUN NOT RUN NOT RUN NOT RUN + +Variable in pattern type + FAIL PASS pattern_types=['\${regexp}'] + +Invalid variable in pattern type + FAIL FAIL PASS pattern_types=['\${does not exist}'] + +Invalid pattern type + 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}'] + +Pattern type multiple times + FAIL PASS NOT RUN pattern_types=['start'] + +Pattern type without patterns + FAIL PASS + +Skip cannot be caught + SKIP NOT RUN PASS tc_status=SKIP + +Return cannot be caught + PASS NOT RUN PASS path=body[0].body[0] + +AS gets the message + FAIL PASS + +AS with multiple pattern + FAIL PASS + +AS with many failures + FAIL PASS + +AS with default except + FAIL PASS + +AS as the error message + FAIL PASS diff --git a/atest/robot/running/try_except/invalid_try_except.robot b/atest/robot/running/try_except/invalid_try_except.robot new file mode 100644 index 00000000000..45ad4c198fb --- /dev/null +++ b/atest/robot/running/try_except/invalid_try_except.robot @@ -0,0 +1,90 @@ +*** Settings *** +Resource try_except_resource.robot +Suite Setup Run Tests ${EMPTY} running/try_except/invalid_try_except.robot +Test Template Verify try except and block statuses + +*** Test Cases *** +TRY without END + TRY:FAIL EXCEPT:NOT RUN FINALLY:NOT RUN + +TRY without body + TRY:FAIL EXCEPT:NOT RUN FINALLY:NOT RUN + +TRY without EXCEPT or FINALLY + TRY:FAIL + +TRY with ELSE without EXCEPT or FINALLY + TRY:FAIL ELSE:NOT RUN + +TRY with argument + TRY:FAIL EXCEPT:NOT RUN FINALLY:NOT RUN + +EXCEPT without body + TRY:FAIL EXCEPT:NOT RUN EXCEPT:NOT RUN FINALLY:NOT RUN + +Default EXCEPT not last + TRY:FAIL EXCEPT:NOT RUN EXCEPT:NOT RUN FINALLY:NOT RUN + +Multiple default EXCEPTs + TRY:FAIL EXCEPT:NOT RUN EXCEPT:NOT RUN ELSE:NOT RUN + +AS requires variable + TRY:FAIL EXCEPT:NOT RUN + +AS accepts only one variable + TRY:FAIL EXCEPT:NOT RUN + +Invalid AS variable + TRY:FAIL EXCEPT:NOT RUN + +ELSE with argument + TRY:FAIL EXCEPT:NOT RUN ELSE:NOT RUN FINALLY:NOT RUN + +ELSE without body + TRY:FAIL EXCEPT:NOT RUN ELSE:NOT RUN FINALLY:NOT RUN + +Multiple ELSE blocks + TRY:FAIL EXCEPT:NOT RUN ELSE:NOT RUN ELSE:NOT RUN FINALLY:NOT RUN + +FINALLY with argument + TRY:FAIL EXCEPT:NOT RUN FINALLY:NOT RUN + +FINALLY without body + TRY:FAIL FINALLY:NOT RUN + +Multiple FINALLY blocks + TRY:FAIL EXCEPT:NOT RUN FINALLY:NOT RUN FINALLY:NOT RUN + +ELSE before EXCEPT + TRY:FAIL EXCEPT:NOT RUN ELSE:NOT RUN EXCEPT:NOT RUN FINALLY:NOT RUN + +FINALLY before EXCEPT + TRY:FAIL EXCEPT:NOT RUN FINALLY:NOT RUN EXCEPT:NOT RUN + +FINALLY before ELSE + TRY:FAIL EXCEPT:NOT RUN FINALLY:NOT RUN ELSE:NOT RUN + +Template with TRY + TRY:FAIL EXCEPT:NOT RUN + +Template with TRY inside IF + TRY:FAIL EXCEPT:NOT RUN path=body[0].body[0].body[0] + +Template with IF inside TRY + TRY:FAIL FINALLY:NOT RUN + +BREAK in FINALLY + TRY:PASS FINALLY:FAIL path=body[0].body[0].body[0] + +CONTINUE in FINALLY + TRY:PASS FINALLY:FAIL path=body[0].body[0].body[0] + +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 + +Dangling FINALLY + [Template] Check Test Case + ${TEST NAME} diff --git a/atest/robot/running/try_except/nested_try_except.robot b/atest/robot/running/try_except/nested_try_except.robot new file mode 100644 index 00000000000..1465b0032b1 --- /dev/null +++ b/atest/robot/running/try_except/nested_try_except.robot @@ -0,0 +1,116 @@ +*** Settings *** +Resource try_except_resource.robot +Suite Setup Run Tests ${EMPTY} running/try_except/nested_try_except.robot +Test Template Verify try except and block statuses + +*** Test cases *** +Try except inside try + FAIL PASS + FAIL NOT RUN NOT RUN PASS path=body[0].body[0].body[0] + +Try except inside except + FAIL PASS NOT RUN + FAIL PASS PASS path=body[0].body[1].body[0] + +Try except inside try else + PASS NOT RUN PASS + FAIL PASS PASS path=body[0].body[2].body[0] + +Try except inside finally + FAIL PASS PASS + FAIL PASS PASS path=body[0].body[-1].body[0] + +Try except inside if + FAIL PASS path=body[0].body[0].body[0] + +Try except inside else if + PASS NOT RUN PASS path=body[0].body[1].body[0] + +Try except inside else + FAIL PASS path=body[0].body[1].body[0] + +Try except inside for loop + PASS NOT RUN PASS path=body[0].body[0].body[0] + FAIL PASS NOT RUN path=body[0].body[1].body[0] + +Try except inside while loop + PASS NOT RUN PASS path=body[1].body[0].body[0] + FAIL PASS NOT RUN path=body[1].body[1].body[0] + +If inside try failing + FAIL PASS NOT RUN + +If inside except handler + FAIL PASS NOT RUN + +If inside except handler failing + FAIL FAIL NOT RUN + +If inside else block + PASS NOT RUN PASS + +If inside else block failing + PASS NOT RUN FAIL + +If inside finally block + FAIL NOT RUN PASS tc_status=FAIL + +If inside finally block failing + PASS NOT RUN FAIL + +For loop inside try failing + FAIL PASS NOT RUN + +For loop inside except handler + FAIL PASS NOT RUN + +For loop inside except handler failing + FAIL FAIL NOT RUN + +For loop inside else block + PASS NOT RUN PASS + +For loop inside else block failing + PASS NOT RUN FAIL + +For loop inside finally block + FAIL NOT RUN PASS tc_status=FAIL + +For loop inside finally block failing + PASS NOT RUN FAIL + +While loop inside try failing + FAIL PASS NOT RUN + +While loop inside except handler + FAIL PASS NOT RUN + +While loop inside except handler failing + FAIL FAIL NOT RUN + +While loop inside else block + PASS NOT RUN PASS + +While loop inside else block failing + PASS NOT RUN FAIL + +While loop inside finally block + FAIL NOT RUN PASS tc_status=FAIL + +While loop inside finally block failing + PASS NOT RUN FAIL + +Try Except in test setup + FAIL PASS path=setup.body[0] + +Try Except in test teardown + FAIL PASS path=teardown.body[0] + +Failing Try Except in test setup + FAIL NOT RUN path=setup.body[0] + +Failing Try Except in test teardown + FAIL NOT RUN path=teardown.body[0] + +Failing Try Except in test teardown and other failures + FAIL NOT RUN path=teardown.body[0] diff --git a/atest/robot/running/try_except/try_except.robot b/atest/robot/running/try_except/try_except.robot new file mode 100644 index 00000000000..8d074cd6446 --- /dev/null +++ b/atest/robot/running/try_except/try_except.robot @@ -0,0 +1,67 @@ +*** Settings *** +Resource try_except_resource.robot +Suite Setup Run Tests ${EMPTY} running/try_except/try_except.robot +Test Template Verify try except and block statuses + +*** Test Cases *** +Try with no failures + PASS NOT RUN + +First except executed + FAIL PASS + +Second except executed + FAIL NOT RUN PASS NOT RUN + +Second matching except ignored + FAIL PASS NOT RUN + +Except handler failing + FAIL FAIL NOT RUN + +Else branch executed + PASS NOT RUN PASS + +Else branch not executed + FAIL PASS NOT RUN + +Else branch failing + PASS NOT RUN FAIL + +Multiple except patterns + FAIL PASS + +Default except pattern + FAIL PASS + +Syntax errors cannot be caught + FAIL NOT RUN NOT RUN + +Finally block executed when no failures + [Template] None + ${tc}= Verify try except and block statuses PASS NOT RUN PASS PASS + Check Log Message ${tc[0, 0, 0, 0]} all good + Check Log Message ${tc[0, 2, 0, 0]} in the else + Check Log Message ${tc[0, 3, 0, 0]} Hello from finally! + +Finally block executed after catch + [Template] None + ${tc}= Verify try except and block statuses FAIL PASS PASS + Check Log Message ${tc[0, 0, 0, 0]} all not good FAIL + Check Log Message ${tc[0, 1, 0, 0]} we are safe now + Check Log Message ${tc[0, 2, 0, 0]} Hello from finally! + +Finally block executed after failure in except + FAIL FAIL NOT RUN PASS + +Finally block executed after failure in else + PASS NOT RUN FAIL PASS + +Try finally with no errors + PASS PASS + +Try finally with failing try + FAIL PASS tc_status=FAIL + +Finally block failing + FAIL PASS FAIL diff --git a/atest/robot/running/try_except/try_except_in_uk.robot b/atest/robot/running/try_except/try_except_in_uk.robot new file mode 100644 index 00000000000..f3d8c29dd35 --- /dev/null +++ b/atest/robot/running/try_except/try_except_in_uk.robot @@ -0,0 +1,70 @@ +*** Settings *** +Resource try_except_resource.robot +Suite Setup Run Tests ${EMPTY} running/try_except/try_except_in_uk.robot +Test Template Verify try except and block statuses in uk + +*** Test Cases *** +Try with no failures + PASS NOT RUN + +First except executed + FAIL PASS + +Second except executed + FAIL NOT RUN PASS NOT RUN + +Second matching except ignored + FAIL PASS NOT RUN + +Except handler failing + FAIL FAIL NOT RUN + +Else branch executed + PASS NOT RUN PASS + +Else branch not executed + FAIL PASS NOT RUN + +Else branch failing + PASS NOT RUN FAIL + +Multiple except patterns + FAIL PASS + +Default except pattern + FAIL PASS + +Finally block executed when no failures + PASS NOT RUN PASS PASS + +Finally block executed after catch + FAIL PASS PASS + +Finally block executed after failure in except + FAIL FAIL NOT RUN PASS + +Finally block executed after failure in else + PASS NOT RUN FAIL PASS + +Try finally with no errors + PASS PASS + +Try finally with failing try + FAIL PASS tc_status=FAIL + +Finally block failing + FAIL PASS FAIL + +Return in try + PASS NOT RUN NOT RUN PASS + +Return in except handler + FAIL PASS NOT RUN PASS + +Return in else + PASS NOT RUN PASS PASS + +*** Keywords *** +Verify try except and block statuses in uk + [Arguments] @{types_and_statuses} ${tc_status}= ${path}=body[0].body[0] + Verify try except and block statuses @{types_and_statuses} tc_status=${tc_status} path=${path} diff --git a/atest/robot/running/try_except/try_except_resource.robot b/atest/robot/running/try_except/try_except_resource.robot new file mode 100644 index 00000000000..af65fc06c0d --- /dev/null +++ b/atest/robot/running/try_except/try_except_resource.robot @@ -0,0 +1,44 @@ +*** Settings *** +Resource atest_resource.robot + +*** Keywords *** +Verify try except and block statuses + [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 + [Arguments] @{statuses} ${tc_status}=${None} + ${tc} = Check Test Case ${TESTNAME} + IF $tc_status + Should Be Equal ${tc.status} ${tc_status} + ELSE IF 'FAIL' in $statuses[1:] or ($statuses[0] == 'FAIL' and 'PASS' not in $statuses[1:]) + Should Be Equal ${tc.status} FAIL + ELSE + Should Be Equal ${tc.status} PASS + END + RETURN ${tc} + +Block statuses should be + [Arguments] ${try_except} @{types_and_statuses} + @{blocks}= Set Variable ${try_except.body} + ${expected_block_count}= Get Length ${types_and_statuses} + Length Should Be ${blocks} ${expected_block_count} + FOR ${block} ${type_and_status} IN ZIP ${blocks} ${types_and_statuses} + IF ':' in $type_and_status + Should Be Equal ${block.type} ${type_and_status.split(':')[0]} + Should Be Equal ${block.status} ${type_and_status.split(':')[1]} + ELSE + 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/break_and_continue.robot b/atest/robot/running/while/break_and_continue.robot new file mode 100644 index 00000000000..bc932d39d12 --- /dev/null +++ b/atest/robot/running/while/break_and_continue.robot @@ -0,0 +1,77 @@ +*** Settings *** +Resource while.resource +Suite Setup Run Tests ${EMPTY} running/while/break_and_continue.robot +Test Template Check WHILE loop + +*** Test Cases *** +With CONTINUE + PASS 5 + +With CONTINUE inside IF + FAIL 3 + +With CONTINUE inside TRY + PASS 5 + +With CONTINUE inside EXCEPT and TRY-ELSE + PASS 5 + +With BREAK + PASS 1 + +With BREAK inside IF + PASS 2 + +With BREAK inside TRY + PASS 1 + +With BREAK inside EXCEPT + PASS 1 + +With BREAK inside TRY-ELSE + PASS 1 + +BREAK with continuable failures + FAIL 1 + +CONTINUE with continuable failures + FAIL 2 + +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] + +With CONTINUE inside IF in UK + FAIL 3 body[0].body[0] + +With CONTINUE inside TRY in UK + PASS 5 body[0].body[0] + +With CONTINUE inside EXCEPT and TRY-ELSE in UK + PASS 5 body[0].body[0] + +With BREAK in UK + PASS 1 body[0].body[0] + +With BREAK inside IF in UK + PASS 2 body[0].body[0] + +With BREAK inside TRY in UK + PASS 1 body[0].body[0] + +With BREAK inside EXCEPT in UK + PASS 1 body[0].body[0] + +With BREAK inside TRY-ELSE in UK + PASS 1 body[0].body[0] diff --git a/atest/robot/running/while/invalid_while.robot b/atest/robot/running/while/invalid_while.robot new file mode 100644 index 00000000000..e2526405943 --- /dev/null +++ b/atest/robot/running/while/invalid_while.robot @@ -0,0 +1,61 @@ +*** Settings *** +Resource while.resource +Suite Setup Run Tests --log test_result_model_as_well running/while/invalid_while.robot + +*** Test Cases *** +Multiple conditions + ${tc} = Check Invalid WHILE Test Case + Should Be Equal ${tc[0].condition} Too, many, conditions, ! + +Invalid condition + Check Invalid WHILE Test Case + +Non-existing ${variable} in condition + Check Invalid WHILE Test Case + +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 + +No END + Check Invalid WHILE Test Case + +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} + +Templatest are not supported + ${tc} = Check Test Case ${TEST NAME} + Should Be Equal ${tc[0].type} WHILE + Should Be Equal ${tc[0].status} FAIL + Should Be Equal ${tc[0, 0].type} ITERATION + Should Be Equal ${tc[0, 0].status} NOT RUN + Check Keyword Data ${tc[0, 0, 0]} ${EMPTY} args=1 status=NOT RUN + Check Keyword Data ${tc[0, 0, 1]} ${EMPTY} args=2 status=NOT RUN + +*** Keywords *** +Check Invalid WHILE Test Case + [Arguments] ${body}=True + ${tc} = Check Test Case ${TESTNAME} + Should Be Equal ${tc[0].type} WHILE + Should Be Equal ${tc[0].status} FAIL + Should Be Equal ${tc[0, 0].type} ITERATION + Should Be Equal ${tc[0, 0].status} NOT RUN + IF ${body} + Should Be Equal ${tc[0, 0, 0].full_name} BuiltIn.Fail + Should Be Equal ${tc[0, 0, 0].status} NOT RUN + END + RETURN ${tc} diff --git a/atest/robot/running/while/nested_while.robot b/atest/robot/running/while/nested_while.robot new file mode 100644 index 00000000000..e745c002619 --- /dev/null +++ b/atest/robot/running/while/nested_while.robot @@ -0,0 +1,28 @@ +*** Settings *** +Resource while.resource +Suite Setup Run Tests ${EMPTY} running/while/nested_while.robot + +*** Test Cases *** +Inside FOR + ${tc}= Check test case ${TEST NAME} + Check loop attributes ${tc[0, 0, 0]} PASS 4 + Check loop attributes ${tc[0, 1, 0]} PASS 3 + Check loop attributes ${tc[0, 2, 0]} PASS 2 + Length should be ${tc[0].body} 3 + +Failing inside FOR + ${tc}= Check test case ${TEST NAME} + Check loop attributes ${tc[0, 0, 0]} FAIL 2 + Length should be ${tc[0].body} 1 + +Inside IF + ${tc}= Check test case ${TEST NAME} + Check loop attributes ${tc[0, 0, 1]} PASS 4 + +In suite setup + ${suite}= Get Test Suite Nested While + Check loop attributes ${suite.setup[1]} PASS 4 + +In suite teardown + ${suite}= Get Test Suite Nested While + Check loop attributes ${suite.teardown[1]} PASS 4 diff --git a/atest/robot/running/while/on_limit.robot b/atest/robot/running/while/on_limit.robot new file mode 100644 index 00000000000..91415d27cb9 --- /dev/null +++ b/atest/robot/running/while/on_limit.robot @@ -0,0 +1,59 @@ +*** Settings *** +Suite Setup Run Tests ${EMPTY} running/while/on_limit.robot +Resource while.resource + +*** Test Cases *** +On limit pass with time limit defined + Check WHILE Loop PASS not known + +On limit pass with iteration limit defined + Check WHILE loop PASS 5 + +On limit fail + Check WHILE Loop FAIL 5 + +On limit pass with failures in loop + Check WHILE Loop FAIL 1 + +On limit pass with continuable failure + Check WHILE Loop FAIL 2 + +On limit fail with continuable failure + Check WHILE Loop FAIL 2 + +Invalid on_limit + Check WHILE Loop FAIL 1 not_run=True + +Invalid on_limit from variable + Check WHILE Loop FAIL 1 not_run=True + +On limit without limit + Check WHILE Loop FAIL 1 not_run=True + +On limit with invalid variable + Check WHILE Loop FAIL 1 not_run=True + +On limit message + Check WHILE Loop FAIL 11 + +On limit message without limit + Check WHILE Loop FAIL 10000 + +On limit message from variable + Check WHILE Loop FAIL 5 + +Part of on limit message from variable + Check WHILE Loop FAIL 5 + +On limit message is not used if limit is not hit + Check WHILE Loop PASS 2 + +Nested while on limit message + Check WHILE Loop FAIL 1 path=body[0] + Check WHILE Loop FAIL 5 path=body[0].body[0].body[0] + +On limit message before limit + Check WHILE Loop FAIL 5 + +On limit message with invalid variable + Check WHILE Loop FAIL 1 not_run=True diff --git a/atest/robot/running/while/while.resource b/atest/robot/running/while/while.resource new file mode 100644 index 00000000000..392399f1bb6 --- /dev/null +++ b/atest/robot/running/while/while.resource @@ -0,0 +1,21 @@ +*** Settings *** +Resource atest_resource.robot + +*** Keywords *** +Check WHILE loop + [Arguments] ${status} ${iterations} ${path}=body[0] ${not_run}=False + ${tc}= Check Test Case ${TEST NAME} + ${loop}= Check Loop Attributes ${tc.${path}} ${status} ${iterations} + IF ${not_run} + Should Be Equal ${loop.body[0].status} NOT RUN + END + RETURN ${loop} + +Check Loop Attributes + [Arguments] ${loop} ${status} ${iterations} + Should Be Equal ${loop.type} WHILE + Should Be Equal ${loop.status} ${status} + IF '${iterations}' != 'not known' + Length Should Be ${loop.non_messages} ${iterations} + END + RETURN ${loop} diff --git a/atest/robot/running/while/while.robot b/atest/robot/running/while/while.robot new file mode 100644 index 00000000000..76580a70e04 --- /dev/null +++ b/atest/robot/running/while/while.robot @@ -0,0 +1,54 @@ +*** Settings *** +Resource while.resource +Suite Setup Run Tests ${EMPTY} running/while/while.robot + +*** Test Cases *** +Loop executed once + ${loop}= Check While Loop PASS 1 + Check Log Message ${loop[0, 0, 0]} 1 + +Loop executed multiple times + Check While Loop PASS 5 + +Loop not executed + ${loop} = Check While Loop NOT RUN 1 + Length Should Be ${loop.body[0].body} 2 + FOR ${item} IN ${loop.body[0]} @{loop.body[0].body} + 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 + +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 + +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] + +In keyword + Check While Loop PASS 3 path=body[0].body[0] + +Loop fails in keyword + Check While Loop FAIL 2 path=body[0].body[0] + +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 + 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/running/while/while_limit.robot b/atest/robot/running/while/while_limit.robot new file mode 100644 index 00000000000..22185673eee --- /dev/null +++ b/atest/robot/running/while/while_limit.robot @@ -0,0 +1,66 @@ +*** Settings *** +Suite Setup Run Tests ${EMPTY} running/while/while_limit.robot +Resource while.resource + +*** Test Cases *** +Default limit is 10000 iterations + Check WHILE Loop FAIL 10000 + +Limit with iteration count + Check WHILE Loop FAIL 5 + +Iteration count with 'times' suffix + Check WHILE Loop FAIL 3 + +Iteration count with 'x' suffix + Check WHILE Loop FAIL 4 + +Iteration count normalization + ${loop}= Check WHILE Loop PASS 1 body[0] + Should Be Equal ${loop.limit} 1_000 + ${loop}= Check WHILE Loop FAIL 30 body[1] + Should Be Equal ${loop.limit} 3 0 T i m e S + +Limit as timestr + Check WHILE Loop FAIL not known + +Limit from variable + Check WHILE Loop FAIL 11 + +Part of limit from variable + Check WHILE Loop FAIL not known + +Limit can be disabled + Check WHILE Loop PASS 10041 + +No condition with limit + Check WHILE Loop FAIL 2 + +Limit exceeds in teardown + Check WHILE Loop FAIL not known teardown.body[0] + +Limit exceeds after failures in teardown + Check WHILE Loop FAIL 2 teardown.body[0] + +Continue after limit in teardown + Check WHILE Loop PASS not known teardown.body[0] + +Invalid limit invalid suffix + Check WHILE Loop FAIL 1 not_run=True + +Invalid limit invalid value + Check WHILE Loop FAIL 1 not_run=True + +Invalid limit mistyped prefix + Check WHILE Loop FAIL 1 not_run=True + +Limit with non-existing variable + Check WHILE Loop FAIL 1 not_run=True + +Limit used multiple times + ${loop}= Check WHILE Loop FAIL 1 not_run=True + Should Be Equal ${loop.limit} 2 + +Invalid values after limit + ${loop}= Check WHILE Loop FAIL 1 not_run=True + Should Be Equal ${loop.condition} $variable < 2, limit=2, invalid 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/robot/standard_libraries/builtin/builtin_resource.robot b/atest/robot/standard_libraries/builtin/builtin_resource.robot index b8099a8e844..217f72c70f1 100644 --- a/atest/robot/standard_libraries/builtin/builtin_resource.robot +++ b/atest/robot/standard_libraries/builtin/builtin_resource.robot @@ -3,14 +3,6 @@ Resource atest_resource.robot *** Keywords *** Verify argument type message - [Arguments] ${msg} ${type1} ${type2} - ${type1} = Map String Types ${type1} - ${type2} = Map String Types ${type2} + [Arguments] ${msg} ${type1}=str ${type2}=str ${level} = Evaluate 'DEBUG' if $type1 == $type2 else 'INFO' - Check log message ${msg} Argument types are:\n<* '${type1}'>\n<* '${type2}'> ${level} pattern=True - -Map String Types - [Arguments] ${type} - Return From Keyword If ($INTERPRETER.is_py2 and not $INTERPRETER.is_ironpython) and $type == "bytes" str - Return From Keyword If ($INTERPRETER.is_py3 or $INTERPRETER.is_ironpython) and $type == "str" unicode - Return From Keyword ${type} + Check log message ${msg} Argument types are:\n\n ${level} diff --git a/atest/robot/standard_libraries/builtin/call_method.robot b/atest/robot/standard_libraries/builtin/call_method.robot index 8cfb85f1323..8e34ce4d981 100644 --- a/atest/robot/standard_libraries/builtin/call_method.robot +++ b/atest/robot/standard_libraries/builtin/call_method.robot @@ -1,34 +1,35 @@ *** 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 *** - Call Method - Check Test Case ${TEST NAME} + Check Test Case ${TEST NAME} Call Method Returns - Check Test Case ${TEST NAME} + Check Test Case ${TEST NAME} Called Method Fails - Check Test Case ${TEST NAME} + ${tc} = Check Test Case ${TEST NAME} + Check Log Message ${tc[0, 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[0, 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} + Check Test Case ${TEST NAME} Equals in non-kwargs must be escaped - Check Test Case ${TEST NAME} + Check Test Case ${TEST NAME} Call Method From Module - Check Test Case ${TEST NAME} + Check Test Case ${TEST NAME} Call Non Existing Method - Check Test Case ${TEST NAME} - -Call Java Method - [Tags] require-jython - Check Test Case ${TEST NAME} - -Call Non Existing Java Method - [Tags] require-jython - Check Test Case ${TEST NAME} + Check Test Case ${TEST NAME} diff --git a/atest/robot/standard_libraries/builtin/converter.robot b/atest/robot/standard_libraries/builtin/converter.robot index 42cdfaaf655..074da92b23f 100644 --- a/atest/robot/standard_libraries/builtin/converter.robot +++ b/atest/robot/standard_libraries/builtin/converter.robot @@ -2,18 +2,10 @@ Suite Setup Run Tests --loglevel DEBUG standard_libraries/builtin/converter.robot Resource atest_resource.robot -*** Variables *** -${ARG TYPES MSG} Argument types are:\n - *** Test Cases *** Convert To Integer ${tc}= Check Test Case ${TEST NAME} - Verify argument type message ${tc.kws[0].kws[0].msgs[0]} unicode - -Convert To Integer With Java Objects - [Tags] require-jython - ${tc}= Check Test Case ${TEST NAME} - Verify argument type message ${tc.kws[0].kws[0].msgs[0]} java.lang.String + Verify argument type message ${tc[0, 0, 0]} Convert To Integer With Base Check Test Case ${TEST NAME} @@ -24,30 +16,21 @@ Convert To Integer With Invalid Base Convert To Integer With Embedded Base Check Test Case ${TEST NAME} -Convert To Integer With Base And Java Objects - [Tags] require-jython - Check Test Case ${TEST NAME} - Convert To Binary ${tc}= Check Test Case ${TEST NAME} - Verify argument type message ${tc.kws[0].kws[0].msgs[0]} unicode + Verify argument type message ${tc[0, 0, 0]} Convert To Octal ${tc}= Check Test Case ${TEST NAME} - Verify argument type message ${tc.kws[0].kws[0].msgs[0]} unicode + Verify argument type message ${tc[0, 0, 0]} Convert To Hex ${tc}= Check Test Case ${TEST NAME} - Verify argument type message ${tc.kws[0].kws[0].msgs[0]} unicode + Verify argument type message ${tc[0, 0, 0]} Convert To Number ${tc}= Check Test Case ${TEST NAME} - Verify argument type message ${tc.kws[0].kws[0].msgs[0]} unicode - -Convert To Number With Java Objects - [Tags] require-jython - ${tc}= Check Test Case ${TEST NAME} - Verify argument type message ${tc.kws[0].kws[0].msgs[0]} java.lang.String + Verify argument type message ${tc[0, 0, 0]} Convert To Number With Precision Check Test Case ${TEST NAME} @@ -57,16 +40,22 @@ Numeric conversions with long types Convert To String ${tc}= Check Test Case ${TEST NAME} - Verify argument type message ${tc.kws[0].msgs[0]} unicode + Verify argument type message ${tc[0, 0, 0, 0]} str + Verify argument type message ${tc[0, 2, 0, 0]} int + +Convert To String NFC normalizes + ${tc}= Check Test Case ${TEST NAME} + Verify argument type message ${tc[0, 0]} str Convert To Boolean ${tc}= Check Test Case ${TEST NAME} - Verify argument type message ${tc.kws[0].msgs[0]} unicode + Verify argument type message ${tc[0, 0, 0, 0]} str + Verify argument type message ${tc[0, 6, 0, 0]} int Create List Check Test Case ${TEST NAME} *** Keywords *** Verify argument type message - [Arguments] ${msg} ${type1} - Check log message ${msg} Argument types are:\n DEBUG + [Arguments] ${msg} ${type}=str + Check log message ${msg} Argument types are:\n DEBUG diff --git a/atest/robot/standard_libraries/builtin/count.robot b/atest/robot/standard_libraries/builtin/count.robot index a1534f15e6a..7667f1951d8 100644 --- a/atest/robot/standard_libraries/builtin/count.robot +++ b/atest/robot/standard_libraries/builtin/count.robot @@ -6,28 +6,24 @@ Resource builtin_resource.robot Get Count [Documentation] Tested also by Should Contain X Times keyword that uses this intenally. ${tc} = Check test case ${TESTNAME} - Check Log Message ${tc.kws[0].kws[0].msgs[0]} Item found from the first item 2 times - Check Log Message ${tc.kws[1].kws[0].msgs[0]} Item found from the first item 2 times - Check Log Message ${tc.kws[2].kws[0].msgs[0]} Item found from the first item 1 time - Check Log Message ${tc.kws[3].kws[0].msgs[0]} Item found from the first item 1 time - Check Log Message ${tc.kws[4].kws[0].msgs[0]} Item found from the first item 50 times - Check Log Message ${tc.kws[5].kws[0].msgs[0]} Item found from the first item 0 times + Check Log Message ${tc[0, 0, 0]} Item found from container 2 times. + Check Log Message ${tc[1, 0, 0]} Item found from container 2 times. + Check Log Message ${tc[2, 0, 0]} Item found from container 1 time. + Check Log Message ${tc[3, 0, 0]} Item found from container 1 time. + Check Log Message ${tc[4, 0, 0]} Item found from container 50 times. + Check Log Message ${tc[5, 0, 0]} Item found from container 0 times. Should Contain X Times with strings ${tc} = Check test case ${TESTNAME} - Check Log Message ${tc.kws[0].msgs[0]} Item found from the first item 2 times - Check Log Message ${tc.kws[1].msgs[0]} Item found from the first item 1 time - Check Log Message ${tc.kws[3].msgs[0]} Item found from the first item 0 times + Check Log Message ${tc[0, 0]} Item found from container 2 times. + Check Log Message ${tc[1, 0]} Item found from container 1 time. + Check Log Message ${tc[3, 0]} Item found from container 0 times. Should Contain X Times with containers ${tc} = Check test case ${TESTNAME} - Check Log Message ${tc.kws[0].msgs[0]} Item found from the first item 1 time - Check Log Message ${tc.kws[1].msgs[0]} Item found from the first item 2 times - Check Log Message ${tc.kws[3].msgs[0]} Item found from the first item 0 times - -Should Contain X Times with Java types - [Tags] require-jython - Check test case ${TESTNAME} + Check Log Message ${tc[0, 0]} Item found from container 1 time. + Check Log Message ${tc[1, 0]} Item found from container 2 times. + Check Log Message ${tc[3, 0]} Item found from container 0 times. Should Contain X Times failing Check test case ${TESTNAME} @@ -35,6 +31,27 @@ Should Contain X Times failing Should Contain X Times case-insensitive Check test case ${TESTNAME} +Should Contain X Times without leading spaces + Check test case ${TESTNAME} + +Should Contain X Times without trailing spaces + Check test case ${TESTNAME} + +Should Contain X Times without leading and trailing spaces + Check test case ${TESTNAME} + +Should Contain X Times and do not collapse spaces + Check test case ${TESTNAME} + +Should Contain X Times and collapse spaces + Check test case ${TESTNAME} + +Should Contain X Times with recursive normalization + Check test case ${TESTNAME} + +Should Contain X Times with bytes auto conversion + Check test case ${TESTNAME} + Should Contain X Times with invalid item Check test case ${TESTNAME} diff --git a/atest/robot/standard_libraries/builtin/create_dictionary.robot b/atest/robot/standard_libraries/builtin/create_dictionary.robot index 773a02d573d..796b561c8fe 100644 --- a/atest/robot/standard_libraries/builtin/create_dictionary.robot +++ b/atest/robot/standard_libraries/builtin/create_dictionary.robot @@ -57,14 +57,14 @@ Separate keys and values with invalid key Separate keys and values and 'key=value' syntax Check Test Case ${TESTNAME} -`&{dict}` variable +`\&{dict}` variable Check Test Case ${TESTNAME} -`&{dict}` variable with internal variables +`\&{dict}` variable with internal variables Check Test Case ${TESTNAME} -Non-existing `&{dict}` variable +Non-existing `\&{dict}` variable Check Test Case ${TESTNAME} -Non-dictionary `&{dict}` variable +Non-dictionary `\&{dict}` variable Check Test Case ${TESTNAME} diff --git a/atest/robot/standard_libraries/builtin/evaluate.robot b/atest/robot/standard_libraries/builtin/evaluate.robot index 4bc872834ff..4bf13404ed3 100644 --- a/atest/robot/standard_libraries/builtin/evaluate.robot +++ b/atest/robot/standard_libraries/builtin/evaluate.robot @@ -1,24 +1,63 @@ *** Settings *** -Suite Setup Run Tests ${EMPTY} standard_libraries/builtin/evaluate.robot -Resource atest_resource.robot +Suite Setup Run Keywords +... Remove Environment Variable PYTHONCASEOK AND +... Run Tests ${EMPTY} standard_libraries/builtin/evaluate.robot +Suite Teardown Set Environment Variable PYTHONCASEOK True +Resource atest_resource.robot *** Test Cases *** Evaluate Check Test Case ${TESTNAME} -Evaluate With Modules +Custom additions to builtins are supported Check Test Case ${TESTNAME} -Evaluate With Namespace +Modules are imported automatically Check Test Case ${TESTNAME} -Evaluate with Get Variables Namespace +Importing non-existing module fails with NameError Check Test Case ${TESTNAME} -Evaluate with Non-dict Namespace +Importing invalid module fails with original error Check Test Case ${TESTNAME} -Evaluate gets variables automatically +Automatic module imports are case-sensitive + Check Test Case ${TESTNAME} + +Automatic modules don't override builtins + Check Test Case ${TESTNAME} + +Explicit modules + Check Test Case ${TESTNAME} + +Explicit modules are needed with nested modules + Check Test Case ${TESTNAME} + +Explicit modules can override builtins + Check Test Case ${TESTNAME} + +Explicit modules used in lambda + Check Test Case ${TESTNAME} + +Evaluation namespace is mutable + Check Test Case ${TESTNAME} + +Custom namespace + Check Test Case ${TESTNAME} + +Custom namespace is case-sensitive + Check Test Case ${TESTNAME} + +Custon namespace used in lambda + Check Test Case ${TESTNAME} + +Namespace from Get Variables + Check Test Case ${TESTNAME} + +Non-dict namespace + Check Test Case ${TESTNAME} + +Variables are available automatically Check Test Case ${TESTNAME} Automatic variables don't work in strings @@ -46,6 +85,16 @@ Non-existing automatic variable with recommendation Check Test Case ${TESTNAME} 1 Check Test Case ${TESTNAME} 2 +Invalid expression + Check Test Case ${TESTNAME} 1 + Check Test Case ${TESTNAME} 2 + Check Test Case ${TESTNAME} 3 + Check Test Case ${TESTNAME} 4 + Check Test Case ${TESTNAME} 5 + Check Test Case ${TESTNAME} 6 + Check Test Case ${TESTNAME} 7 + Check Test Case ${TESTNAME} 8 + Invalid $ usage Check Test Case ${TESTNAME} 1 Check Test Case ${TESTNAME} 2 @@ -59,8 +108,17 @@ Invalid $ usage Evaluate Empty Check Test Case ${TESTNAME} -Evaluate Nonstring +Evaluate Non-string Check Test Case ${TESTNAME} Evaluate doesn't see module globals Check Test Case ${TESTNAME} + +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 + Check Test Case ${TESTNAME} + +Evaluation errors can be caught + Check Test Case ${TESTNAME} diff --git a/atest/robot/standard_libraries/builtin/fail.robot b/atest/robot/standard_libraries/builtin/fail.robot index 5076fb2fe28..c78ede1bbe6 100644 --- a/atest/robot/standard_libraries/builtin/fail.robot +++ b/atest/robot/standard_libraries/builtin/fail.robot @@ -5,45 +5,51 @@ Resource atest_resource.robot *** Test Cases *** Fail ${tc}= Check Test Tags ${TESTNAME} force1 force2 - Length Should Be ${tc.kws[0].msgs} 1 + Length Should Be ${tc[0].body} 1 -Fail With Message +Fail with message ${tc}= Check Test Tags ${TESTNAME} force1 force2 - Length Should Be ${tc.kws[0].msgs} 1 + Length Should Be ${tc[0].body} 1 + +Fail with non-string message + Check Test Case ${TESTNAME} + +Fail with non-true message having non-empty string representation + Check Test Case ${TESTNAME} Set one tag ${tc}= Check Test Tags ${TESTNAME} force1 force2 tag - Length Should Be ${tc.kws[0].msgs} 2 - Check Log Message ${tc.kws[0].msgs[0]} Set tag 'tag'. + Length Should Be ${tc[0].body} 2 + Check Log Message ${tc[0, 0]} Set tag 'tag'. Set multiple tags ${tc}= Check Test Tags ${TESTNAME} force1 force2 tag1 tag2 - Length Should Be ${tc.kws[0].msgs} 2 - Check Log Message ${tc.kws[0].msgs[0]} Set tags 'tag1' and 'tag2'. + Length Should Be ${tc[0].body} 2 + Check Log Message ${tc[0, 0]} Set tags 'tag1' and 'tag2'. Remove one tag ${tc}= Check Test Tags ${TESTNAME} force2 - Length Should Be ${tc.kws[0].msgs} 2 - Check Log Message ${tc.kws[0].msgs[0]} Removed tag 'force1'. + Length Should Be ${tc[0].body} 2 + Check Log Message ${tc[0, 0]} Removed tag 'force1'. Remove multiple tags ${tc}= Check Test Tags ${TESTNAME} - Length Should Be ${tc.kws[0].msgs} 2 - Check Log Message ${tc.kws[0].msgs[0]} Removed tags 'force1' and 'force2'. + Length Should Be ${tc[0].body} 2 + Check Log Message ${tc[0, 0]} Removed tags 'force1' and 'force2'. Remove multiple tags with pattern ${tc}= Check Test Tags ${TESTNAME} - Length Should Be ${tc.kws[0].msgs} 2 - Check Log Message ${tc.kws[0].msgs[0]} Removed tag 'force?'. + Length Should Be ${tc[0].body} 2 + Check Log Message ${tc[0, 0]} Removed tag 'force?'. Set and remove tags ${tc}= Check Test Tags ${TESTNAME} force2 tag1 tag2 - Length Should Be ${tc.kws[0].msgs} 3 - Check Log Message ${tc.kws[0].msgs[0]} Removed tags 'force1' and 'nonEx'. - Check Log Message ${tc.kws[0].msgs[1]} Set tags 'tag1' and 'tag2'. + Length Should Be ${tc[0].body} 3 + Check Log Message ${tc[0, 0]} Removed tags 'force1' and 'nonEx'. + Check Log Message ${tc[0, 1]} Set tags 'tag1' and 'tag2'. Set tags should not be removed ${tc}= Check Test Tags ${TESTNAME} fii foo - Length Should Be ${tc.kws[0].msgs} 3 - Check Log Message ${tc.kws[0].msgs[0]} Removed tag 'f*'. - Check Log Message ${tc.kws[0].msgs[1]} Set tags 'foo' and 'fii'. + Length Should Be ${tc[0].body} 3 + Check Log Message ${tc[0, 0]} Removed tag 'f*'. + Check Log Message ${tc[0, 1]} Set tags 'foo' and 'fii'. diff --git a/atest/robot/standard_libraries/builtin/fatal_error.robot b/atest/robot/standard_libraries/builtin/fatal_error.robot index 61d9f5773db..009dcd947f3 100644 --- a/atest/robot/standard_libraries/builtin/fatal_error.robot +++ b/atest/robot/standard_libraries/builtin/fatal_error.robot @@ -1,15 +1,13 @@ *** Settings *** Resource atest_resource.robot -Suite Setup Run Tests ${EMPTY} standard_libraries/builtin/fatal_error.robot - -***Test Cases*** +Suite Setup Run Tests ${EMPTY} standard_libraries/builtin/fatal_error.robot +*** Test Cases *** Test is stopped when `Fatal Error` keyword is used - Check Test Case ${TESTNAME} + Check Test Case ${TESTNAME} Subsequent tests are not executed after `Fatal Error` keyword has been used - Check Test Case ${TESTNAME} + Check Test Case ${TESTNAME} Suite teardown is executed after `Fatal Error` keyword - [Documentation] Tests also Fata Error without a message - Check Log Message ${SUITE.teardown.msgs[0]} AssertionError FAIL + Check Log Message ${SUITE.teardown[0]} AssertionError FAIL diff --git a/atest/robot/standard_libraries/builtin/get_library_instance.robot b/atest/robot/standard_libraries/builtin/get_library_instance.robot index 0116cc86e2d..a1f949fe17a 100644 --- a/atest/robot/standard_libraries/builtin/get_library_instance.robot +++ b/atest/robot/standard_libraries/builtin/get_library_instance.robot @@ -9,10 +9,6 @@ Library imported normally Module library Check Test Case ${TESTNAME} -Java library - [Tags] require-jython - Check Test Case ${TESTNAME} - Library with alias Check Test Case ${TESTNAME} diff --git a/atest/robot/standard_libraries/builtin/get_time.robot b/atest/robot/standard_libraries/builtin/get_time.robot index 71ab3139235..3702004020c 100644 --- a/atest/robot/standard_libraries/builtin/get_time.robot +++ b/atest/robot/standard_libraries/builtin/get_time.robot @@ -36,3 +36,6 @@ When Time Is UTC When Time Is UTC +- something Check Test Case ${TEST NAME} + +DST is handled correctly when adding or substracting time + Check Test Case ${TEST NAME} diff --git a/atest/robot/standard_libraries/builtin/get_variable_value.robot b/atest/robot/standard_libraries/builtin/get_variable_value.robot index b7f47283237..71436e457bf 100644 --- a/atest/robot/standard_libraries/builtin/get_variable_value.robot +++ b/atest/robot/standard_libraries/builtin/get_variable_value.robot @@ -9,6 +9,9 @@ Get value when variable exists Get value when variable doesn't exist Check Test Case ${TESTNAME} +Get value when default value is none + Check Test Case ${TESTNAME} + Default value contains variables Check Test Case ${TESTNAME} @@ -21,6 +24,13 @@ List variables Extended variable syntax Check Test Case ${TESTNAME} +Nested variable + Check Test Case ${TESTNAME} + +List and dict variable items + Check Test Case ${TESTNAME} + Invalid variable syntax Check Test Case ${TESTNAME} 1 Check Test Case ${TESTNAME} 2 + Check Test Case ${TESTNAME} 3 diff --git a/atest/robot/standard_libraries/builtin/import_library.robot b/atest/robot/standard_libraries/builtin/import_library.robot index 60a093a9b0f..f6675fb8e44 100644 --- a/atest/robot/standard_libraries/builtin/import_library.robot +++ b/atest/robot/standard_libraries/builtin/import_library.robot @@ -14,6 +14,9 @@ Import Library With Arguments Import Library With Variables And WITH NAME Check Test Case ${TEST NAME} +Import Library With WITH NAME containing non-ASCII spaces + Check Test Case ${TEST NAME} + Import Library Using Physical Path Check Test Case ${TEST NAME} diff --git a/atest/robot/standard_libraries/builtin/keyword_should_exist.robot b/atest/robot/standard_libraries/builtin/keyword_should_exist.robot index 481c5491150..e751828b6fe 100644 --- a/atest/robot/standard_libraries/builtin/keyword_should_exist.robot +++ b/atest/robot/standard_libraries/builtin/keyword_should_exist.robot @@ -3,7 +3,6 @@ Suite Setup Run Tests ${EMPTY} standard_libraries/builtin/keyword_should_e Resource atest_resource.robot *** Test Cases *** - Library keyword exists with short name Check Test Case ${TESTNAME} @@ -31,6 +30,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} @@ -45,4 +47,3 @@ Empty keyword name Non-string keyword name Check Test Case ${TESTNAME} - diff --git a/atest/robot/standard_libraries/builtin/length.robot b/atest/robot/standard_libraries/builtin/length.robot index 9f8e36b1272..dd140f810b7 100644 --- a/atest/robot/standard_libraries/builtin/length.robot +++ b/atest/robot/standard_libraries/builtin/length.robot @@ -5,19 +5,19 @@ 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[0, 0, 0]} Length is 0. + Check Log Message ${tc[1, 0, 0]} Length is 1. + Check Log Message ${tc[2, 0, 0]} Length is 2. + Check Log Message ${tc[3, 0, 0]} Length is 3. + Check Log Message ${tc[4, 0, 0]} Length is 11. + Check Log Message ${tc[5, 0, 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[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 + Check Log Message ${tc[-1, 0]} Length is 2. + Check Log Message ${tc[-1, 1]} Length of '*' should be 3 but is 2. FAIL pattern=yep + Check Log Message ${tc[-1, 2]} Traceback* DEBUG pattern=yep + Length Should Be ${tc[-1].body} 3 Length Should Be with custom message Check Test Case ${TESTNAME} @@ -26,27 +26,43 @@ Length Should Be with invalid length Check Test Case ${TESTNAME} Should Be Empty - Check test case ${TESTNAME} + Check Test Case ${TESTNAME} 1 + Check Test Case ${TESTNAME} 2 + Check Test Case ${TESTNAME} 3 Should Be Empty with custom message - Check test case ${TESTNAME} + Check Test Case ${TESTNAME} Should Not Be Empty - Check test case ${TESTNAME} + Check Test Case ${TESTNAME} 1 + Check Test Case ${TESTNAME} 2 Should Not Be Empty with custom message - Check test case ${TESTNAME} + Check Test Case ${TESTNAME} -Getting length with `length` method - Check test case ${TESTNAME} +Getting length with `length` method is deprecated + ${tc} = Check Test Case ${TESTNAME} + Check custom length deprecation ${tc[0, 0]} item.length() 40 + Check custom length deprecation ${tc[1]} item.length() 40 + Check custom length deprecation ${tc[2]} item.length() 40 + Check custom length deprecation ${tc[3]} item.length() 40 -Getting length with `size` method - Check test case ${TESTNAME} +Getting length with `size` method is deprecated + ${tc} = Check Test Case ${TESTNAME} + Check custom length deprecation ${tc[0, 0]} item.size() 41 + Check custom length deprecation ${tc[1]} item.size() 41 + Check custom length deprecation ${tc[2]} item.size() 41 + Check custom length deprecation ${tc[3]} item.size() 41 -Getting length with `length` attribute - Check test case ${TESTNAME} +Getting length with `length` attribute is deprecated + ${tc} = Check Test Case ${TESTNAME} + Check custom length deprecation ${tc[0, 0]} item.length 42 + Check custom length deprecation ${tc[1]} item.length 42 + Check custom length deprecation ${tc[2]} item.length 42 + Check custom length deprecation ${tc[3]} item.length 42 -Getting length from Java types - [Documentation] Tests that it's possible to get the lenght of String, Vector, Hashtable and array - [Tags] require-jython - Check test case ${TESTNAME} +*** Keywords *** +Check custom length deprecation + [Arguments] ${kw} ${deprecated} ${length} + Check Log Message ${kw[0]} Using '${deprecated}' for getting object length is deprecated. Only 'len(obj)' will be supported in the future. WARN + Check Log Message ${kw[1]} Length is ${length}. diff --git a/atest/robot/standard_libraries/builtin/listener_printing_start_end_kw.py b/atest/robot/standard_libraries/builtin/listener_printing_start_end_kw.py index 9a91451a5bc..a4431f0123e 100644 --- a/atest/robot/standard_libraries/builtin/listener_printing_start_end_kw.py +++ b/atest/robot/standard_libraries/builtin/listener_printing_start_end_kw.py @@ -1,14 +1,13 @@ import sys - ROBOT_LISTENER_API_VERSION = 2 def start_keyword(name, attrs): - sys.stdout.write('start keyword %s\n' % name) - sys.stderr.write('start keyword %s\n' % name) + sys.stdout.write(f"start keyword {name}\n") + sys.stderr.write(f"start keyword {name}\n") def end_keyword(name, attrs): - sys.stdout.write('end keyword %s\n' % name) - sys.stderr.write('end keyword %s\n' % name) + sys.stdout.write(f"end keyword {name}\n") + sys.stderr.write(f"end keyword {name}\n") diff --git a/atest/robot/standard_libraries/builtin/listener_using_builtin.py b/atest/robot/standard_libraries/builtin/listener_using_builtin.py index 07b83c0001c..a432df8e6f3 100644 --- a/atest/robot/standard_libraries/builtin/listener_using_builtin.py +++ b/atest/robot/standard_libraries/builtin/listener_using_builtin.py @@ -1,9 +1,6 @@ from robot.libraries.BuiltIn import BuiltIn -BIN = BuiltIn() -ROBOT_LISTENER_API_VERSION = 2 - -def start_keyword(*args): - if BIN.get_variables()['${TESTNAME}'] == 'Listener Using BuiltIn': - BIN.set_test_variable('${SET BY LISTENER}', 'quux') +def start_keyword(data, result): + if BuiltIn().get_variables()["${TESTNAME}"] == "Listener Using BuiltIn": + BuiltIn().set_test_variable("${SET BY LISTENER}", "quux") diff --git a/atest/robot/standard_libraries/builtin/log.robot b/atest/robot/standard_libraries/builtin/log.robot index b70e045c9e1..8b0207dde7f 100644 --- a/atest/robot/standard_libraries/builtin/log.robot +++ b/atest/robot/standard_libraries/builtin/log.robot @@ -8,188 +8,199 @@ ${HTML} Robot Framework *** Test Cases *** Log ${tc} = Check Test Case ${TEST NAME} - Check Log Message ${tc.kws[0].msgs[0]} Hello, world! - Check Log Message ${tc.kws[1].msgs[0]} 42 - Check Log Message ${tc.kws[2].msgs[0]} None - Check Log Message ${tc.kws[3].msgs[0]} String presentation of MyObject + Check Log Message ${tc[0, 0]} Hello, world! + Check Log Message ${tc[1, 0]} 42 + Check Log Message ${tc[2, 0]} None + Check Log Message ${tc[3, 0]} String presentation of MyObject Log with different levels ${tc} = Check Test Case ${TEST NAME} - Check Log Message ${tc.kws[0].msgs[1]} Log says: Hello from tests! INFO - Check Log Message ${tc.kws[1].msgs[1]} Trace level TRACE - Check Log Message ${tc.kws[2].msgs[1]} Debug level DEBUG - Check Log Message ${tc.kws[3].msgs[1]} Info level INFO - Check Log Message ${tc.kws[4].msgs[1]} Warn level WARN - Check Log Message ${tc.kws[5].msgs[1]} Error level ERROR - Check Log Message ${ERRORS[0]} Warn level WARN - Check Log Message ${ERRORS[1]} Error level ERROR - Length Should Be ${ERRORS} 2 + Check Log Message ${tc[0, 1]} Log says: Hello from tests! INFO + Check Log Message ${tc[1, 1]} Trace level TRACE + Check Log Message ${tc[2, 1]} Debug level DEBUG + Check Log Message ${tc[3, 1]} Info level INFO + Check Log Message ${tc[4, 1]} Warn level WARN + Check Log Message ${tc[5, 1]} Error level ERROR + Check Log Message ${ERRORS[0]} Warn level WARN + Check Log Message ${ERRORS[1]} Error level ERROR + +Invalid log level failure is catchable + Check Test Case ${TEST NAME} HTML is escaped by default ${tc} = Check Test Case ${TEST NAME} - Check Log Message ${tc.kws[0].msgs[0]} not bold - Check Log Message ${tc.kws[1].msgs[0]} ${HTML} + Check Log Message ${tc[0, 0]} not bold + Check Log Message ${tc[1, 0]} ${HTML} HTML pseudo level ${tc} = Check Test Case ${TEST NAME} - Check Log Message ${tc.kws[0].msgs[0]} bold html=True - Check Log Message ${tc.kws[1].msgs[0]} ${HTML} html=True + Check Log Message ${tc[0, 0]} bold html=True + Check Log Message ${tc[1, 0]} ${HTML} html=True Explicit HTML ${tc} = Check Test Case ${TEST NAME} - Check Log Message ${tc.kws[0].msgs[0]} bold html=True - Check Log Message ${tc.kws[1].msgs[0]} ${HTML} DEBUG html=True - Check Log Message ${tc.kws[2].msgs[0]} ${HTML} DEBUG + Check Log Message ${tc[0, 0]} bold html=True + Check Log Message ${tc[1, 0]} ${HTML} DEBUG html=True + Check Log Message ${tc[2, 0]} ${HTML} DEBUG FAIL is not valid log level Check Test Case ${TEST NAME} Log also to console ${tc} = Check Test Case ${TEST NAME} - Check Log Message ${tc.kws[0].msgs[0]} Hello, console! - Check Log Message ${tc.kws[1].msgs[0]} ${HTML} DEBUG html=True - Check Stdout Contains Hello, console!\n - Check Stdout Contains ${HTML}\n + Check Log Message ${tc[0, 0]} Hello, console! + Check Log Message ${tc[1, 0]} ${HTML} DEBUG html=True + Stdout Should Contain Hello, console!\n + Stdout Should Contain ${HTML}\n + +Disable logging to console + ${tc} = Check Test Case ${TEST NAME} + Check Log Message ${tc[0, 0]} WARN but no console WARN + Check Log Message ${tc[1, 0]} ERROR but no console ERROR + Check Log Message ${ERRORS[2]} WARN but no console WARN + Check Log Message ${ERRORS[3]} ERROR but no console ERROR + Stdout Should Not Contain no console + Stderr Should Not Contain no console + +CONSOLE pseudo level + ${tc} = Check Test Case ${TEST NAME} + Check Log Message ${tc[0, 0]} Hello, info and console! + Stdout Should Contain Hello, info and console!\n repr=True - [Documentation] In RF 3.1.2 `formatter=repr` and `repr=True` yield same - ... results and thus these tests are identical. - ${tc} = Check Test Case ${TEST NAME} - Check Log Message ${tc.kws[0].msgs[0]} 'Nothing special here' - ${expected} = Set Variable If ${INTERPRETER.is_py2} - ... 'Hyv\\xe4\\xe4 y\\xf6t\\xe4 \\u2603!' - ... 'Hyvää yötä ☃!' - Check Log Message ${tc.kws[1].msgs[0]} ${expected} - Check Log Message ${tc.kws[2].msgs[0]} 42 DEBUG - Check Log Message ${tc.kws[4].msgs[0]} b'\\x00abc\\xff (repr=True)' - ${expected} = Set Variable If ${INTERPRETER.is_py2} - ... 'hyva\\u0308' - ... 'hyvä' - Check Log Message ${tc.kws[6].msgs[0]} ${expected} - Check Stdout Contains b'\\x00abc\\xff (repr=True)' + ${tc} = Check Test Case ${TEST NAME} + Check Log Message ${tc[0, 0]} The 'repr' argument of 'BuiltIn.Log' is deprecated. Use 'formatter=repr' instead. WARN + Check Log Message ${tc[0, 1]} Nothing special here + Check Log Message ${tc[1, 0]} The 'repr' argument of 'BuiltIn.Log' is deprecated. Use 'formatter=repr' instead. WARN + Check Log Message ${tc[1, 1]} 'Hyvää yötä ☃!' formatter=repr - [Documentation] In RF 3.1.2 `formatter=repr` and `repr=True` yield same - ... results and thus these tests are identical. - ${tc} = Check Test Case ${TEST NAME} - Check Log Message ${tc.kws[0].msgs[0]} 'Nothing special here' - ${expected} = Set Variable If ${INTERPRETER.is_py2} - ... 'Hyv\\xe4\\xe4 y\\xf6t\\xe4 \\u2603!' - ... 'Hyvää yötä ☃!' - Check Log Message ${tc.kws[1].msgs[0]} ${expected} - Check Log Message ${tc.kws[2].msgs[0]} 42 DEBUG - Check Log Message ${tc.kws[4].msgs[0]} b'\\x00abc\\xff (formatter=repr)' - ${expected} = Set Variable If ${INTERPRETER.is_py2} - ... 'hyva\\u0308' - ... 'hyvä' - Check Log Message ${tc.kws[6].msgs[0]} ${expected} - Check Stdout Contains b'\\x00abc\\xff (formatter=repr)' + ${tc} = Check Test Case ${TEST NAME} + Check Log Message ${tc[0, 0]} 'Nothing special here' + Check Log Message ${tc[1, 0]} 'Hyvää yötä ☃!' + Check Log Message ${tc[2, 0]} 42 DEBUG + Check Log Message ${tc[4, 0]} b'\\x00abc\\xff (formatter=repr)' + Check Log Message ${tc[6, 0]} 'hyvä' + Stdout Should Contain b'\\x00abc\\xff (formatter=repr)' formatter=ascii ${tc} = Check Test Case ${TEST NAME} - ${u} = Set Variable If ${INTERPRETER.is_py2} u ${EMPTY} - ${u2} = Set Variable If ${INTERPRETER.is_py2} and not ${INTERPRETER.is_ironpython} u ${EMPTY} - ${b} = Set Variable If ${INTERPRETER.is_py2} and not ${INTERPRETER.is_ironpython} ${EMPTY} b - Check Log Message ${tc.kws[0].msgs[0]} ${u2}'Nothing special here' - Check Log Message ${tc.kws[1].msgs[0]} ${u}'Hyv\\xe4\\xe4 y\\xf6t\\xe4 \\u2603!' - Check Log Message ${tc.kws[2].msgs[0]} 42 DEBUG - Check Log Message ${tc.kws[4].msgs[0]} ${b}'\\x00abc\\xff (formatter=ascii)' - Check Log Message ${tc.kws[6].msgs[0]} ${u}'hyva\\u0308' - Check Stdout Contains ${b}'\\x00abc\\xff (formatter=ascii)' + Check Log Message ${tc[0, 0]} 'Nothing special here' + Check Log Message ${tc[1, 0]} 'Hyv\\xe4\\xe4 y\\xf6t\\xe4 \\u2603!' + Check Log Message ${tc[2, 0]} 42 DEBUG + Check Log Message ${tc[4, 0]} b'\\x00abc\\xff (formatter=ascii)' + Check Log Message ${tc[6, 0]} 'hyva\\u0308' + Stdout Should Contain b'\\x00abc\\xff (formatter=ascii)' formatter=str ${tc} = Check Test Case ${TEST NAME} - Check Log Message ${tc.kws[0].msgs[0]} Nothing special here - Check Log Message ${tc.kws[1].msgs[0]} Hyvää yötä ☃! - Check Log Message ${tc.kws[2].msgs[0]} 42 DEBUG - Check Log Message ${tc.kws[4].msgs[0]} abc\\xff (formatter=str) - Check Log Message ${tc.kws[6].msgs[0]} hyvä - Check Stdout Contains abc\\xff (formatter=str) + Check Log Message ${tc[0, 0]} Nothing special here + Check Log Message ${tc[1, 0]} Hyvää yötä ☃! + Check Log Message ${tc[2, 0]} 42 DEBUG + Check Log Message ${tc[4, 0]} abc\xff (formatter=str) + Check Log Message ${tc[6, 0]} hyvä + Stdout Should Contain abc\xff (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[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}] - ${expected} = Set Variable If ${INTERPRETER.is_py2} - ... ['hyv\\xe4', b'hyv\\xe4', {'\\u2603': b'\\x00\\xff'}] - ... ['hyvä', b'hyv\\xe4', {'☃': b'\\x00\\xff'}] - Check Log Message ${tc.kws[11].msgs[0]} ${expected} - Check Stdout Contains ${small dict} - Check Stdout Contains ${small list} + Check Log Message ${tc[1, 0]} '${long string}' + Check Log Message ${tc[3, 0]} ${small dict} + Check Log Message ${tc[5, 0]} {'big': 'dict',\n 'long': '${long string}',\n 'nested': ${small dict},\n 'list': [1, 2, 3],\n 'sorted': False} + Check Log Message ${tc[7, 0]} ${small list} + Check Log Message ${tc[9, 0]} ['big',\n 'list',\n '${long string}',\n b'${long string}',\n ['nested', ('tuple', 2)],\n ${small dict}] + Check Log Message ${tc[11, 0]} ['hyvä', b'hyv\\xe4', {'☃': b'\\x00\\xff'}] + Stdout Should Contain ${small dict} + Stdout Should Contain ${small list} + +formatter=len + ${tc} = Check Test Case ${TEST NAME} + Check Log Message ${tc[0, 0]} 20 + Check Log Message ${tc[1, 0]} 13 DEBUG + Check Log Message ${tc[3, 0]} 21 + Check Log Message ${tc[5, 0]} 5 + +formatter=type + ${tc} = Check Test Case ${TEST NAME} + Check Log Message ${tc[0, 0]} str + Check Log Message ${tc[1, 0]} str + Check Log Message ${tc[2, 0]} int DEBUG + Check Log Message ${tc[4, 0]} bytes + Check Log Message ${tc[6, 0]} datetime formatter=invalid Check Test Case ${TEST NAME} Log callable ${tc} = Check Test Case ${TEST NAME} - Check Log Message ${tc.kws[0].msgs[0]} *objects_for_call_method.MyObject* pattern=yes - Check Log Message ${tc.kws[2].msgs[0]} at *> pattern=yes + Check Log Message ${tc[0, 0]} *objects_for_call_method.MyObject* pattern=yes + Check Log Message ${tc[2, 0]} at *> pattern=yes Log Many ${tc} = Check Test Case ${TEST NAME} - Check Log Message ${tc.kws[0].msgs[0]} Log Many says: - Check Log Message ${tc.kws[0].msgs[1]} 1 - Check Log Message ${tc.kws[0].msgs[2]} 2 - Check Log Message ${tc.kws[0].msgs[3]} 3 - Check Log Message ${tc.kws[0].msgs[4]} String presentation of MyObject - Check Log Message ${tc.kws[1].msgs[0]} Log Many says: Hi!! - Check Log Message ${tc.kws[2].msgs[0]} 1 - Check Log Message ${tc.kws[2].msgs[1]} 2 - Check Log Message ${tc.kws[2].msgs[2]} 3 - Check Log Message ${tc.kws[2].msgs[3]} String presentation of MyObject - Should Be Empty ${tc.kws[3].msgs} - Should Be Empty ${tc.kws[4].msgs} - Check Log Message ${tc.kws[5].msgs[0]} -- - Check Log Message ${tc.kws[5].msgs[1]} -[]- - Check Log Message ${tc.kws[5].msgs[2]} -{}- - Check Log Message ${tc.kws[6].msgs[0]} 1 - Check Log Message ${tc.kws[6].msgs[1]} 2 + Check Log Message ${tc[0, 0]} Log Many says: + Check Log Message ${tc[0, 1]} 1 + Check Log Message ${tc[0, 2]} 2 + Check Log Message ${tc[0, 3]} 3 + Check Log Message ${tc[0, 4]} String presentation of MyObject + Check Log Message ${tc[1, 0]} Log Many says: Hi!! + Check Log Message ${tc[2, 0]} 1 + Check Log Message ${tc[2, 1]} 2 + Check Log Message ${tc[2, 2]} 3 + Check Log Message ${tc[2, 3]} String presentation of MyObject + Should Be Empty ${tc[3].body} + Should Be Empty ${tc[4].body} + Check Log Message ${tc[5, 0]} preserve + Check Log Message ${tc[5, 1]} ${EMPTY} + Check Log Message ${tc[5, 2]} empty + Check Log Message ${tc[5, 3]} ${EMPTY} + Check Log Message ${tc[6, 0]} -- + Check Log Message ${tc[6, 1]} -[]- + Check Log Message ${tc[6, 2]} -{}- + Check Log Message ${tc[7, 0]} 1 + Check Log Message ${tc[7, 1]} 2 Log Many with named and dict arguments ${tc} = Check Test Case ${TEST NAME} - Check Log Message ${tc.kws[0].msgs[0]} a=1 - Check Log Message ${tc.kws[0].msgs[1]} b=2 - Check Log Message ${tc.kws[0].msgs[2]} 3=c - Check Log Message ${tc.kws[0].msgs[3]} obj=String presentation of MyObject - Check Log Message ${tc.kws[1].msgs[0]} a=1 - Check Log Message ${tc.kws[1].msgs[1]} b=2 - Check Log Message ${tc.kws[1].msgs[2]} 3=c - Check Log Message ${tc.kws[1].msgs[3]} obj=String presentation of MyObject - Check Log Message ${tc.kws[2].msgs[0]} a=1 - Check Log Message ${tc.kws[2].msgs[1]} b=2 - Check Log Message ${tc.kws[2].msgs[2]} 3=c - Check Log Message ${tc.kws[2].msgs[3]} obj=String presentation of MyObject - Check Log Message ${tc.kws[2].msgs[4]} b=no override - Check Log Message ${tc.kws[2].msgs[5]} 3=three + Check Log Message ${tc[0, 0]} a=1 + Check Log Message ${tc[0, 1]} b=2 + Check Log Message ${tc[0, 2]} 3=c + Check Log Message ${tc[0, 3]} obj=String presentation of MyObject + Check Log Message ${tc[1, 0]} a=1 + Check Log Message ${tc[1, 1]} b=2 + Check Log Message ${tc[1, 2]} 3=c + Check Log Message ${tc[1, 3]} obj=String presentation of MyObject + Check Log Message ${tc[2, 0]} a=1 + Check Log Message ${tc[2, 1]} b=2 + Check Log Message ${tc[2, 2]} 3=c + Check Log Message ${tc[2, 3]} obj=String presentation of MyObject + Check Log Message ${tc[2, 4]} b=no override + Check Log Message ${tc[2, 5]} 3=three Log Many with positional, named and dict arguments ${tc} = Check Test Case ${TEST NAME} - Check Log Message ${tc.kws[0].msgs[0]} 1 - Check Log Message ${tc.kws[0].msgs[1]} 2 - Check Log Message ${tc.kws[0].msgs[2]} three=3 - Check Log Message ${tc.kws[0].msgs[3]} 4=four - Check Log Message ${tc.kws[1].msgs[0]} 1 - Check Log Message ${tc.kws[1].msgs[1]} 2 - Check Log Message ${tc.kws[1].msgs[2]} 3 - Check Log Message ${tc.kws[1].msgs[3]} String presentation of MyObject - Check Log Message ${tc.kws[1].msgs[4]} a=1 - Check Log Message ${tc.kws[1].msgs[5]} b=2 - Check Log Message ${tc.kws[1].msgs[6]} 3=c - Check Log Message ${tc.kws[1].msgs[7]} obj=String presentation of MyObject - Check Log Message ${tc.kws[1].msgs[8]} 1 - Check Log Message ${tc.kws[1].msgs[9]} 2 - Check Log Message ${tc.kws[1].msgs[10]} 3 - Check Log Message ${tc.kws[1].msgs[11]} String presentation of MyObject - Check Log Message ${tc.kws[1].msgs[12]} a=1 - Check Log Message ${tc.kws[1].msgs[13]} b=2 - Check Log Message ${tc.kws[1].msgs[14]} 3=c - Check Log Message ${tc.kws[1].msgs[15]} obj=String presentation of MyObject + Check Log Message ${tc[0, 0]} 1 + Check Log Message ${tc[0, 1]} 2 + Check Log Message ${tc[0, 2]} three=3 + Check Log Message ${tc[0, 3]} 4=four + Check Log Message ${tc[1, 0]} 1 + Check Log Message ${tc[1, 1]} 2 + Check Log Message ${tc[1, 2]} 3 + Check Log Message ${tc[1, 3]} String presentation of MyObject + Check Log Message ${tc[1, 4]} a=1 + Check Log Message ${tc[1, 5]} b=2 + Check Log Message ${tc[1, 6]} 3=c + Check Log Message ${tc[1, 7]} obj=String presentation of MyObject + Check Log Message ${tc[1, 8]} 1 + Check Log Message ${tc[1, 9]} 2 + Check Log Message ${tc[1, 10]} 3 + Check Log Message ${tc[1, 11]} String presentation of MyObject + Check Log Message ${tc[1, 12]} a=1 + Check Log Message ${tc[1, 13]} b=2 + Check Log Message ${tc[1, 14]} 3=c + Check Log Message ${tc[1, 15]} obj=String presentation of MyObject Log Many with non-existing variable Check Test Case ${TEST NAME} @@ -202,9 +213,17 @@ Log Many with dict variable containing non-dict Log To Console ${tc} = Check Test Case ${TEST NAME} - :FOR ${i} IN RANGE 4 - \ Should Be Empty ${tc.kws[${i}].msgs} - Check Stdout Contains stdout äö w/ newline\n - Check Stdout Contains stdout äö w/o new......line äö - Check Stderr Contains stderr äö w/ newline\n - Check Stdout Contains 42 + FOR ${i} IN RANGE 4 + Should Be Empty ${tc[${i}].body} + END + Stdout Should Contain stdout äö w/ newline\n + Stdout Should Contain stdout äö w/o new......line äö + Stderr Should Contain stderr äö w/ newline\n + Stdout Should Contain 42 + +Log To Console With Formatting + Stdout Should Contain ************test middle align with star padding************* + Stdout Should Contain ####################test right align with hash padding + Stdout Should Contain ${SPACE * 6}test-with-spacepad-and-weird-characters+%?,_\>~}./asdf + Stdout Should Contain ${SPACE * 24}message starts here,this sentence should be on the same sentence as "message starts here" + Stderr Should Contain ${SPACE * 26}test log to stderr diff --git a/atest/robot/standard_libraries/builtin/log_variables.robot b/atest/robot/standard_libraries/builtin/log_variables.robot index 7d7e82cb1cd..a9b4fed59bc 100644 --- a/atest/robot/standard_libraries/builtin/log_variables.robot +++ b/atest/robot/standard_libraries/builtin/log_variables.robot @@ -5,7 +5,7 @@ Resource atest_resource.robot *** Test Cases *** Log Variables In Suite Setup - Set Test Variable ${KW} ${SUITE.setup.keywords[7]} + Set Test Variable ${KW} ${SUITE.setup[7]} Set Test Variable ${INDEX} ${0} Check Variable Message \${/} = * pattern=yes Check Variable Message \${:} = ${:} @@ -15,13 +15,16 @@ Log Variables In Suite Setup Check Variable Message \${cli_var_3} = CLI3 Check Variable Message \${DEBUG_FILE} = NONE Check Variable Message \&{DICT} = { key=value | two=2 } + Check Variable Message \${ENDLESS} = repeat('RF') Check Variable Message \${EXECDIR} = * pattern=yes Check Variable Message \${False} = * pattern=yes + Check Variable Message \${ITERABLE} = at 0x*> pattern=yes Check Variable Message \@{LIST} = [ Hello | world ] Check Variable Message \${LOG_FILE} = NONE Check Variable Message \${LOG_LEVEL} = INFO Check Variable Message \${None} = None Check Variable Message \${null} = None + Check Variable Message \&{OPTIONS} = { rpa=False | include=[] | exclude=[] | skip=[] | skip_on_failure=[] | console_width=78 } Check Variable Message \${OUTPUT_DIR} = * pattern=yes Check Variable Message \${OUTPUT_FILE} = * pattern=yes Check Variable Message \${PREV_TEST_MESSAGE} = @@ -41,11 +44,11 @@ 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} 34 Wrong total message count + Length Should Be ${kw.messages} 37 Log Variables In Test ${test} = Check Test Case Log Variables - Set Test Variable ${KW} ${test.keywords[0]} + Set Test Variable ${KW} ${test[0]} Set Test Variable ${INDEX} ${1} Check Variable Message \${/} = * pattern=yes Check Variable Message \${:} = ${:} @@ -55,13 +58,16 @@ Log Variables In Test Check Variable Message \${cli_var_3} = CLI3 Check Variable Message \${DEBUG_FILE} = NONE Check Variable Message \&{DICT} = { key=value | two=2 } + Check Variable Message \${ENDLESS} = repeat('RF') Check Variable Message \${EXECDIR} = * pattern=yes Check Variable Message \${False} = * pattern=yes + Check Variable Message \${ITERABLE} = at 0x*> pattern=yes Check Variable Message \@{LIST} = [ Hello | world ] Check Variable Message \${LOG_FILE} = NONE Check Variable Message \${LOG_LEVEL} = TRACE Check Variable Message \${None} = None Check Variable Message \${null} = None + Check Variable Message \&{OPTIONS} = { rpa=False | include=[] | exclude=[] | skip=[] | skip_on_failure=[] | console_width=78 } Check Variable Message \${OUTPUT_DIR} = * pattern=yes Check Variable Message \${OUTPUT_FILE} = * pattern=yes Check Variable Message \${PREV_TEST_MESSAGE} = @@ -80,14 +86,15 @@ Log Variables In Test Check Variable Message \${SUITE_SOURCE} = * pattern=yes Check Variable Message \${TEMPDIR} = * pattern=yes Check Variable Message \${TEST_DOCUMENTATION} = + Check Variable Message \&{TEST_METADATA} = { } 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} 38 Wrong total message count + Length Should Be ${kw.messages} 42 Log Variables After Setting New Variables ${test} = Check Test Case Log Variables - Set Test Variable ${KW} ${test.keywords[4]} + Set Test Variable ${KW} ${test[4]} Set Test Variable ${INDEX} ${1} Check Variable Message \${/} = * DEBUG pattern=yes Check Variable Message \${:} = ${:} DEBUG @@ -97,15 +104,18 @@ Log Variables After Setting New Variables Check Variable Message \${cli_var_3} = CLI3 DEBUG Check Variable Message \${DEBUG_FILE} = NONE DEBUG Check Variable Message \&{DICT} = { key=value | two=2 } DEBUG + Check Variable Message \${ENDLESS} = repeat('RF') DEBUG Check Variable Message \${EXECDIR} = * DEBUG pattern=yes Check Variable Message \${False} = * DEBUG pattern=yes Check Variable Message \@{int_list_1} = [ 0 | 1 | 2 | 3 ] DEBUG Check Variable Message \@{int_list_2} = [ 0 | 1 | 2 | 3 ] DEBUG + Check Variable Message \${ITERABLE} = at 0x*> DEBUG pattern=yes Check Variable Message \@{LIST} = [ Hello | world ] DEBUG Check Variable Message \${LOG_FILE} = NONE DEBUG Check Variable Message \${LOG_LEVEL} = TRACE DEBUG Check Variable Message \${None} = None DEBUG Check Variable Message \${null} = None DEBUG + Check Variable Message \&{OPTIONS} = { rpa=False | include=[] | exclude=[] | skip=[] | skip_on_failure=[] | console_width=78 } DEBUG Check Variable Message \${OUTPUT_DIR} = * DEBUG pattern=yes Check Variable Message \${OUTPUT_FILE} = * DEBUG pattern=yes Check Variable Message \${PREV_TEST_MESSAGE} = DEBUG @@ -124,15 +134,16 @@ Log Variables After Setting New Variables Check Variable Message \${SUITE_SOURCE} = * DEBUG pattern=yes Check Variable Message \${TEMPDIR} = * DEBUG pattern=yes Check Variable Message \${TEST_DOCUMENTATION} = DEBUG + Check Variable Message \&{TEST_METADATA} = { } DEBUG Check Variable Message \${TEST_NAME} = Log Variables DEBUG 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} 41 Wrong total message count + Length Should Be ${kw.messages} 45 Log Variables In User Keyword ${test} = Check Test Case Log Variables - Set Test Variable ${KW} ${test.keywords[5].keywords[1]} + Set Test Variable ${KW} ${test[5, 2]} Set Test Variable ${INDEX} ${1} Check Variable Message \${/} = * pattern=yes Check Variable Message \${:} = ${:} @@ -142,13 +153,16 @@ Log Variables In User Keyword Check Variable Message \${cli_var_3} = CLI3 Check Variable Message \${DEBUG_FILE} = NONE Check Variable Message \&{DICT} = { key=value | two=2 } + Check Variable Message \${ENDLESS} = repeat('RF') Check Variable Message \${EXECDIR} = * pattern=yes Check Variable Message \${False} = * pattern=yes + Check Variable Message \${ITERABLE} = at 0x*> pattern=yes Check Variable Message \@{LIST} = [ Hello | world ] Check Variable Message \${LOG_FILE} = NONE Check Variable Message \${LOG_LEVEL} = TRACE Check Variable Message \${None} = None Check Variable Message \${null} = None + Check Variable Message \&{OPTIONS} = { rpa=False | include=[] | exclude=[] | skip=[] | skip_on_failure=[] | console_width=78 } Check Variable Message \${OUTPUT_DIR} = * pattern=yes Check Variable Message \${OUTPUT_FILE} = * pattern=yes Check Variable Message \${PREV_TEST_MESSAGE} = @@ -167,14 +181,18 @@ Log Variables In User Keyword Check Variable Message \${SUITE_SOURCE} = * pattern=yes Check Variable Message \${TEMPDIR} = * pattern=yes Check Variable Message \${TEST_DOCUMENTATION} = + Check Variable Message \&{TEST_METADATA} = { } Check Variable Message \${TEST_NAME} = Log Variables 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} 39 Wrong total message count + Length Should Be ${kw.messages} 43 + +List and dict variables failing during iteration + Check Test Case ${TEST NAME} *** Keywords *** Check Variable Message [Arguments] ${expected} ${level}=INFO ${pattern}= - Check Log Message ${KW.msgs[${INDEX}]} ${expected} ${level} pattern=${pattern} + Check Log Message ${KW[${INDEX}]} ${expected} ${level} pattern=${pattern} Set Test Variable ${INDEX} ${INDEX + 1} diff --git a/atest/robot/standard_libraries/builtin/misc.robot b/atest/robot/standard_libraries/builtin/misc.robot index 38994c9afac..a7af9cba20c 100644 --- a/atest/robot/standard_libraries/builtin/misc.robot +++ b/atest/robot/standard_libraries/builtin/misc.robot @@ -11,9 +11,9 @@ Catenate Comment ${tc} = Check Test Case ${TEST NAME} - Should Be Empty ${tc.kws[0].msgs} - Should Be Empty ${tc.kws[1].msgs} - Should Be Empty ${tc.kws[2].msgs} + Should Be Empty ${tc[0].body} + Should Be Empty ${tc[1].body} + Should Be Empty ${tc[2].body} Regexp Escape Check Test Case ${TEST NAME} diff --git a/atest/robot/standard_libraries/builtin/reload_library.robot b/atest/robot/standard_libraries/builtin/reload_library.robot index 25cb84aad6a..97bf6e4d58e 100644 --- a/atest/robot/standard_libraries/builtin/reload_library.robot +++ b/atest/robot/standard_libraries/builtin/reload_library.robot @@ -5,12 +5,12 @@ Resource atest_resource.robot *** Test Cases *** Reload and add keyword ${tc}= Check Test Case ${TESTNAME} - Check log message ${tc.kws[2].msgs[0]} Reloaded library Reloadable with 7 keywords. + Check log message ${tc[2, 0]} Reloaded library Reloadable with 7 keywords. Reloading changes args ${tc}= Check Test Case ${TESTNAME} - Should be equal ${tc.kws[0].doc} Doc for original 1 with args arg - Should be equal ${tc.kws[3].doc} Doc for original 1 with args arg1, arg2 + Should be equal ${tc[0].doc} Doc for original 1 with args arg + Should be equal ${tc[3].doc} Doc for original 1 with args arg1, arg2 Reloading can remove a keyword Check Test Case ${TESTNAME} @@ -32,8 +32,8 @@ Reloading None fails Static library ${tc}= Check Test Case ${TESTNAME} - Should be equal ${tc.kws[2].doc} This doc for static + Should be equal ${tc[2].doc} This doc for static Module library ${tc}= Check Test Case ${TESTNAME} - Should be equal ${tc.kws[3].doc} This doc for module + Should be equal ${tc[3].doc} This doc for module diff --git a/atest/robot/standard_libraries/builtin/reload_with_name.robot b/atest/robot/standard_libraries/builtin/reload_with_name.robot index 7b04683ff74..dcabc6ea7f6 100644 --- a/atest/robot/standard_libraries/builtin/reload_with_name.robot +++ b/atest/robot/standard_libraries/builtin/reload_with_name.robot @@ -5,7 +5,7 @@ Resource atest_resource.robot *** Test Cases *** Reload with name ${tc}= Check Test Case ${TESTNAME} - Check log message ${tc.kws[1].msgs[0]} Reloaded library foo with 7 keywords. + Check log message ${tc[1, 0]} Reloaded library foo with 7 keywords. Reload with instance Check Test Case ${TESTNAME} diff --git a/atest/robot/standard_libraries/builtin/repeat_keyword.robot b/atest/robot/standard_libraries/builtin/repeat_keyword.robot index c745e9c1b9d..b5076ebd262 100644 --- a/atest/robot/standard_libraries/builtin/repeat_keyword.robot +++ b/atest/robot/standard_libraries/builtin/repeat_keyword.robot @@ -5,27 +5,27 @@ Resource atest_resource.robot *** Test Cases *** Times As String ${tc} = Check Test Case ${TEST NAME} - Check Repeated Messages ${tc.kws[0]} 2 Hello, repeating world! + Check Repeated Messages ${tc[0]} 2 Hello, repeating world! Times As Integer ${tc} = Check Test Case ${TEST NAME} - Check Repeated Messages ${tc.kws[0]} 42 This works too!! + Check Repeated Messages ${tc[0]} 42 This works too!! Times With 'times' Postfix ${tc} = Check Test Case ${TEST NAME} - Check Repeated Messages ${tc.kws[0]} 3 This is done 3 times - Check Repeated Messages ${tc.kws[1]} 2 Case and space insensitive + Check Repeated Messages ${tc[0]} 3 This is done 3 times + Check Repeated Messages ${tc[1]} 2 Case and space insensitive Times With 'x' Postfix ${tc} = Check Test Case ${TEST NAME} - Check Repeated Messages ${tc.kws[0]} 10 Close to old repeating syntax - Check Repeated Messages ${tc.kws[1]} 1 Case and space + Check Repeated Messages ${tc[0]} 10 Close to old repeating syntax + Check Repeated Messages ${tc[1]} 1 Case and space 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[0]} 0 name=This is not executed + Check Repeated Messages ${tc[1]} 0 name=\${nonex} + Check Repeated Messages ${tc[2]} 0 name=This is not executed Invalid Times Check Test Case Invalid Times 1 @@ -33,16 +33,20 @@ Invalid Times Repeat Keyword With Time String ${tc} = Check Test Case ${TEST NAME} - Check Repeated Messages With Time ${tc.kws[0]} This is done for 00:00:00.003 - Check Repeated Messages With Time ${tc.kws[1]} This is done for 3 milliseconds - Check Repeated Messages With Time ${tc.kws[2]} This is done for 3ms + Check Repeated Messages With Time ${tc[0]} This is done for 00:00:00.003 + Check Repeated Messages With Time ${tc[1]} This is done for 3 milliseconds + Check Repeated Messages With Time ${tc[2]} This is done for 3ms + +Repeat Keyword With `timedelta` + ${tc} = Check Test Case ${TEST NAME} + Check Repeated Messages With Time ${tc[0]} This is done for `timedelta(milliseconds=3)` Repeat Keyword Arguments As Variables ${tc} = Check Test Case ${TEST_NAME} - Check Repeated Keyword Name ${tc.kws[1]} 2 BuiltIn.Should Be Equal - Check Repeated Keyword Name ${tc.kws[3]} 42 BuiltIn.Should Be Equal - Check Repeated Keyword Name ${tc.kws[5]} 10 BuiltIn.No Operation - Check Repeated Keyword Name ${tc.kws[7]} 1 BuiltIn.Should Be Equal + Check Repeated Keyword Name ${tc[1]} 2 BuiltIn.Should Be Equal + Check Repeated Keyword Name ${tc[3]} 42 BuiltIn.Should Be Equal + Check Repeated Keyword Name ${tc[5]} 10 BuiltIn.No Operation + Check Repeated Keyword Name ${tc[7]} 1 BuiltIn.Should Be Equal Repeated Keyword As Non-existing Variable Check Test Case ${TEST_NAME} @@ -56,44 +60,52 @@ Repeated Keyword Failing Repeat Keyword With Continuable Failure ${tc} = Check Test Case ${TEST_NAME} - Length Should Be ${tc.kws[0].kws} 3 + Length Should Be ${tc[0].body} 6 + Length Should Be ${tc[0].messages} 3 Repeat Keyword With Failure After Continuable Failure ${tc} = Check Test Case ${TEST_NAME} - Length Should Be ${tc.kws[0].kws} 2 + Length Should Be ${tc[0].body} 4 + Length Should Be ${tc[0].messages} 2 Repeat Keyword With Pass Execution ${tc} = Check Test Case ${TEST_NAME} - Length Should Be ${tc.kws[0].kws} 1 + Length Should Be ${tc[0].body} 2 + Length Should Be ${tc[0].messages} 1 Repeat Keyword With Pass Execution After Continuable Failure ${tc} = Check Test Case ${TEST_NAME} - Length Should Be ${tc.kws[0].kws} 2 + Length Should Be ${tc[0].body} 4 + Length Should Be ${tc[0].messages} 2 *** Keywords *** Check Repeated Messages - [Arguments] ${kw} ${count} ${msg}=${None} - 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} + [Arguments] ${kw} ${rounds} ${msg}= ${name}= + IF ${rounds} == 0 + Length Should Be ${kw.body} 1 + Check Log Message ${kw[0]} Keyword '${name}' repeated zero times. + ELSE + Length Should Be ${kw.body} ${{int($rounds) * 2}} + Length Should Be ${kw.messages} ${rounds} + END + FOR ${i} IN RANGE ${rounds} + Check Log Message ${kw[${i * 2}]} Repeating keyword, round ${i+1}/${rounds}. + Check Log Message ${kw[${i * 2 + 1}, 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} Check Repeated Messages With Time [Arguments] ${kw} ${msg}=${None} - Should Be True ${kw.kw_count} > 0 - FOR ${i} IN RANGE ${kw.kw_count} - Check Log Message ${kw.msgs[${i}]} + Should Be True len($kw.body) / 2 == len($kw.messages) and len($kw.body) > 0 + FOR ${i} IN RANGE ${{len($kw.messages)}} + Check Log Message ${kw[${i * 2}]} ... Repeating keyword, round ${i+1}, *remaining. pattern=yes - Check Log Message ${kw.kws[${i}].msgs[0]} ${msg} + Check Log Message ${kw[${i * 2 + 1}, 0]} ${msg} END - Should Be Equal As Integers ${kw.msg_count} ${kw.kw_count} + Should Be Equal ${{len($kw.messages) * 2}} ${{len($kw.body)}} 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 True len($kw.body) / 2 == len($kw.messages) == ${count} + FOR ${i} IN RANGE 1 ${count} * 2 2 + Should Be Equal ${kw[${i}].full_name} ${name} END 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.robot b/atest/robot/standard_libraries/builtin/run_keyword.robot index 9834e44a0e8..bbd5a64a10c 100644 --- a/atest/robot/standard_libraries/builtin/run_keyword.robot +++ b/atest/robot/standard_libraries/builtin/run_keyword.robot @@ -4,52 +4,94 @@ 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 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 + ${tc} = Check Test Case ${TEST NAME} + Check Run Keyword ${tc[0]} BuiltIn.Log This is logged with Run Keyword + Check Keyword Data ${tc[1, 0]} BuiltIn.No Operation + Check Run Keyword ${tc[2]} BuiltIn.Log Many 1 2 3 4 5 + Check Run Keyword ${tc[4]} BuiltIn.Log Run keyword with variable: Log + Check Run Keyword ${tc[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].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].kws[0]} BuiltIn.Evaluate args=1+2 + ${tc} = Check Test Case ${TEST NAME} + Check Keyword Data ${tc[0]} BuiltIn.Run Keyword \${ret} Set Variable, hello world + Check Keyword Data ${tc[0, 0]} BuiltIn.Set Variable args=hello world + Check Keyword Data ${tc[2]} BuiltIn.Run Keyword \${ret} Evaluate, 1+2 + Check Keyword Data ${tc[2, 0]} BuiltIn.Evaluate args=1+2 Run Keyword With Arguments That Needs To Be Escaped - ${tc} = Check test Case ${TEST NAME} - Check Log Message ${tc.kws[1].kws[0].msgs[0]} c:\\temp\\foo - Check Log Message ${tc.kws[1].kws[0].msgs[1]} \${notvar} + ${tc} = Check Test Case ${TEST NAME} + Check Log Message ${tc[1, 0, 0]} c:\\temp\\foo + Check Log Message ${tc[1, 0, 1]} \${notvar} Escaping Arguments From Opened List Variable - ${tc} = Check test Case ${TEST NAME} - Check Log Message ${tc.kws[1].kws[0].msgs[0]} message=foo - Check Log Message ${tc.kws[3].kws[0].msgs[0]} 42 + ${tc} = Check Test Case ${TEST NAME} + Check Log Message ${tc[1, 0, 0]} message=foo + Check Log Message ${tc[3, 0, 0]} 42 Run Keyword With UK - ${tc} = Check test Case ${TEST NAME} - Check Run Keyword In UK ${tc.kws[0]} BuiltIn.Log Using UK - Check Run Keyword In UK ${tc.kws[1]} BuiltIn.Log Many yksi kaksi + ${tc} = Check Test Case ${TEST NAME} + Check Run Keyword In UK ${tc[0]} BuiltIn.Log Using UK + Check Run Keyword In UK ${tc[1]} BuiltIn.Log Many yksi kaksi Run Keyword In Multiple Levels And With UK - Check test Case ${TEST NAME} + Check Test Case ${TEST NAME} + +With keyword accepting embedded arguments + ${tc} = Check Test Case ${TEST NAME} + Check Run Keyword With Embedded Args ${tc[0]} Embedded "arg" arg + +With library keyword accepting embedded arguments + ${tc} = Check Test Case ${TEST NAME} + Check Run Keyword With Embedded Args ${tc[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[0]} Embedded "\${VARIABLE}" value + Check Run Keyword With Embedded Args ${tc[1]} Embedded "\${1}" 1 + Check Run Keyword With Embedded Args ${tc[2]} Embedded "\${VAR_MULTILINE}" foo\nbar + +With library keyword accepting embedded arguments as variables + ${tc} = Check Test Case ${TEST NAME} + Check Run Keyword With Embedded Args ${tc[0]} Embedded "\${VARIABLE}" in library value + Check Run Keyword With Embedded Args ${tc[1]} Embedded "\${1}" in library 1 + Check Run Keyword With Embedded Args ${tc[2]} Embedded "\${VAR_MULTILINE}" in library foo\nbar + +With keyword accepting embedded arguments as variables containing objects + ${tc} = Check Test Case ${TEST NAME} + Check Run Keyword With Embedded Args ${tc[0]} Embedded "\${OBJECT}" Robot + Check Run Keyword With Embedded Args ${tc[1]} Embedded object "\${OBJECT}" 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 - 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 +With library keyword accepting embedded arguments as variables containing objects + ${tc} = Check Test Case ${TEST NAME} + Check Run Keyword With Embedded Args ${tc[0]} Embedded "\${OBJECT}" in library Robot + Check Run Keyword With Embedded Args ${tc[1]} Embedded object "\${OBJECT}" in library Robot + +Embedded arguments matching only after replacing variables + ${tc} = Check Test Case ${TEST NAME} + Check Run Keyword With Embedded Args ${tc[1]} Embedded "arg" arg + Check Run Keyword With Embedded Args ${tc[2]} Embedded "arg" in library arg + +Exact match after replacing variables has higher precedence than embedded arguments + ${tc} = Check Test Case ${TEST NAME} + Check Run Keyword ${tc[1]} Embedded "not" + Check Log Message ${tc[1][0][0][0]} Nothing embedded in this user keyword! + Check Run Keyword ${tc[2]} embedded_args.Embedded "not" in library + Check Log Message ${tc[2][0][0]} Nothing embedded in this library keyword! + +Run Keyword In FOR Loop + ${tc} = Check Test Case ${TEST NAME} + Check Run Keyword ${tc[0, 0, 0]} BuiltIn.Log hello from for loop + Check Run Keyword In UK ${tc[0, 2, 0]} BuiltIn.Log hei maailma + Check Run Keyword ${tc[1, 0, 0]} BuiltIn.Log hello from second for loop Run Keyword With Test Timeout Check Test Case ${TEST NAME} Passing ${tc} = Check Test Case ${TEST NAME} Exceeded - Check Run Keyword ${tc.kws[0]} BuiltIn.Log Before Timeout + Check Run Keyword ${tc[0]} BuiltIn.Log Before Timeout Run Keyword With KW Timeout - Check test Case ${TEST NAME} Passing - Check test Case ${TEST NAME} Exceeded + Check Test Case ${TEST NAME} Passing + Check Test Case ${TEST NAME} Exceeded Run Keyword With Invalid Keyword Name Check Test Case ${TEST NAME} @@ -64,21 +106,32 @@ Stdout and stderr are not captured when running Run Keyword ... end keyword BuiltIn.Run Keyword ... end keyword My UK ... end keyword BuiltIn.Run Keyword - Check Stdout Contains ${expected} - Check Stderr Contains ${expected} + Stdout Should Contain ${expected} + Stderr Should Contain ${expected} *** 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} - ${index} = Set Variable ${0} - :FOR ${msg} IN @{msgs} - \ Check Log Message ${kw.kws[0].msgs[${index}]} ${msg} - \ ${index} = evaluate ${index} +1 + [Arguments] ${kw} ${name} @{msgs} + Should Be Equal ${kw.full_name} BuiltIn.Run Keyword + Should Be Equal ${kw[0].full_name} ${name} + FOR ${index} ${msg} IN ENUMERATE @{msgs} + Check Log Message ${kw[0, ${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[0].full_name} My UK + Check Run Keyword ${kw[0, 0]} ${subkw_name} @{msgs} + +Check Run Keyword With Embedded Args + [Arguments] ${kw} ${subkw_name} ${msg} + Should Be Equal ${kw.full_name} BuiltIn.Run Keyword + IF ${subkw_name.endswith('library')} + Should Be Equal ${kw[0].full_name} embedded_args.${subkw_name} + Check Log Message ${kw[0, 0]} ${msg} + ELSE + Should Be Equal ${kw[0].full_name} ${subkw_name} + Should Be Equal ${kw[0, 0].full_name} BuiltIn.Log + Check Log Message ${kw[0, 0, 0]} ${msg} + END diff --git a/atest/robot/standard_libraries/builtin/run_keyword_and_continue_on_failure.robot b/atest/robot/standard_libraries/builtin/run_keyword_and_continue_on_failure.robot index 3f83802c905..2af70af225d 100644 --- a/atest/robot/standard_libraries/builtin/run_keyword_and_continue_on_failure.robot +++ b/atest/robot/standard_libraries/builtin/run_keyword_and_continue_on_failure.robot @@ -5,16 +5,16 @@ Resource atest_resource.robot *** Test Cases *** Run Keyword And Continue On Failure ${tc}= Check Test Case ${TESTNAME} - Check Log Message ${tc.kws[0].kws[0].msgs[0]} Expected Failure FAIL - Check Log Message ${tc.kws[1].kws[0].msgs[0]} Expected Failure 2 FAIL - Check Log Message ${tc.kws[2].msgs[0]} This should be executed + Check Log Message ${tc[0, 0, 0]} Expected Failure FAIL + Check Log Message ${tc[1, 0, 0]} Expected Failure 2 FAIL + Check Log Message ${tc[2, 0]} This should be executed Run Keyword And Continue On Failure In For Loop Check Test Case ${TESTNAME} Run User keyword And Continue On Failure ${tc}= Check Test Case ${TESTNAME} - Check Log Message ${tc.kws[1].msgs[0]} This should be executed + Check Log Message ${tc[1, 0]} This should be executed Run Keyword And Continue On Failure With For Loops Check Test Case ${TESTNAME} diff --git a/atest/robot/standard_libraries/builtin/run_keyword_and_return.robot b/atest/robot/standard_libraries/builtin/run_keyword_and_return.robot index b0fbeccc939..9e345446a7f 100644 --- a/atest/robot/standard_libraries/builtin/run_keyword_and_return.robot +++ b/atest/robot/standard_libraries/builtin/run_keyword_and_return.robot @@ -5,15 +5,15 @@ Resource atest_resource.robot *** Test Cases *** Return one value ${tc} = Check Test Case ${TESTNAME} - Check log message ${tc.kws[0].kws[0].msgs[0]} Returning from the enclosing user keyword. + Check Log Message ${tc[0, 0, 1]} Returning from the enclosing user keyword. Return multiple values Check Test Case ${TESTNAME} Return nothing ${tc} = Check Test Case ${TESTNAME} - Check log message ${tc.kws[0].kws[0].kws[0].msgs[0]} No return value - Check log message ${tc.kws[0].kws[0].msgs[0]} Returning from the enclosing user keyword. + Check Log Message ${tc[0, 0, 0, 0]} No return value + Check Log Message ${tc[0, 0, 1]} Returning from the enclosing user keyword. Nested usage Check Test Case ${TESTNAME} @@ -23,13 +23,13 @@ Keyword fails Inside Run Keyword variants ${tc} = Check Test Case ${TESTNAME} - Check log message ${tc.kws[2].kws[0].kws[0].msgs[0]} First keyword - Check log message ${tc.kws[2].kws[0].kws[2].msgs[0]} Returning from the enclosing user keyword. + Check Log Message ${tc[2, 0, 0, 0]} First keyword + Check Log Message ${tc[2, 0, 2, 1]} Returning from the enclosing user keyword. Using Run Keyword variants ${tc} = Check Test Case ${TESTNAME} - Check log message ${tc.kws[2].kws[0].kws[0].kws[1].msgs[0]} Second keyword - Check log message ${tc.kws[2].kws[0].kws[0].kws[2].msgs[0]} Returning from the enclosing user keyword. + Check Log Message ${tc[2, 0, 0, 1, 0]} Second keyword + Check Log Message ${tc[2, 0, 0, 2, 1]} Returning from the enclosing user keyword. Outside user keyword Check Test Case ${TESTNAME} @@ -42,9 +42,9 @@ Return strings that needs to be escaped Run Keyword And Return If ${tc} = Check Test Case ${TESTNAME} - Check log message ${tc.kws[0].kws[1].msgs[0]} Returning from the enclosing user keyword. - Check log message ${tc.kws[2].kws[1].msgs[0]} Returning from the enclosing user keyword. - Check log message ${tc.kws[4].kws[0].kws[2].kws[0].msgs[0]} Returning from the enclosing user keyword. + Check Log Message ${tc[0, 1, 1]} Returning from the enclosing user keyword. + Check Log Message ${tc[2, 1, 1]} Returning from the enclosing user keyword. + Check Log Message ${tc[4, 0, 2, 0, 1]} Returning from the enclosing user keyword. Run Keyword And Return If can have non-existing keywords and variables if condition is not true Check Test Case ${TESTNAME} diff --git a/atest/robot/standard_libraries/builtin/run_keyword_and_warn_on_failure.robot b/atest/robot/standard_libraries/builtin/run_keyword_and_warn_on_failure.robot new file mode 100644 index 00000000000..442c10ac1c5 --- /dev/null +++ b/atest/robot/standard_libraries/builtin/run_keyword_and_warn_on_failure.robot @@ -0,0 +1,34 @@ +*** Settings *** +Suite Setup Run Tests ${EMPTY} standard_libraries/builtin/run_keyword_and_warn_on_failure.robot +Resource atest_resource.robot + +*** Test Cases *** +Run Keyword And Warn On Failure + ${tc}= Check Test Case ${TESTNAME} + Check Log Message ${tc[0, 1]} Executing keyword 'FAIL' failed:\nExpected Warn WARN + +Run Keyword And Warn On Failure For Keyword Teardown + ${tc}= Check Test Case ${TESTNAME} + Check Log Message ${tc[0, 1]} + ... Executing keyword 'Failing Keyword Teardown' failed:\nKeyword teardown failed:\nExpected WARN + +Run User keyword And Warn On Failure + ${tc}= Check Test Case ${TESTNAME} + Check Log Message ${tc[0, 1]} + ... Executing keyword 'Exception In User Defined Keyword' failed:\nExpected Warn In User Keyword WARN + +Run Keyword And Warn On Failure With Syntax Error + Check Test Case ${TESTNAME} + +Run Keyword And Warn On Failure With Failure On Test Teardown + ${tc}= Check Test Case ${TESTNAME} + Check Log Message ${tc.teardown[1]} + ... Executing keyword 'Should Be Equal' failed:\nx != y WARN + +Run Keyword And Warn On Failure With Timeout + Check Test Case ${TESTNAME} + +Run Keyword And Warn On Failure On Suite Teardown + ${suite} = Get Test Suite Run Keyword And Warn On Failure + Check Log Message ${suite.teardown[1]} + ... Executing keyword 'Fail' failed:\nExpected Warn From Suite Teardown WARN 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 80fd7525a8b..0ac6326e777 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,36 +1,13 @@ -*** Setting *** -Suite Setup Run Tests --critical critical standard_libraries/builtin/run_keyword_based_on_suite_stats +*** Settings *** +Suite Setup Run Tests ${EMPTY} standard_libraries/builtin/run_keyword_based_on_suite_stats Resource atest_resource.robot -*** Test Case *** -Run Keyword If All Critical Tests Passed - ${suite} = Get Test Suite Run Keyword If All Critical Tests Passed When Criticals Pass - Should Be Equal ${suite.teardown.kws[0].name} My Teardown - Check Log Message ${suite.teardown.kws[0].kws[0].msgs[0]} Suite teardown message - -Run Keyword If All Critical Tests Passed Can't be Used In Test - Check test Case Run Keyword If All Critical Tests Passed Can't be Used In Test - -Run Keyword If All Critical Tests Passed Is not executed when Critcal Test Fails - Check test Case Run Keyword If All Critical Tests Passed Is not executed when Critcal Test Fails - -Run Keyword If Any Critical Tests Failed - ${suite} = Get Test Suite Run Keyword If Any Critical Tests Failed When Critical Fails - Should Be Equal As Integers ${suite.statistics.critical.failed} 1 - Should Be Equal ${suite.teardown.kws[0].name} My Teardown - Check Log Message ${suite.teardown.kws[0].kws[0].msgs[0]} Suite teardown message - -Run Keyword If Any Critical Tests Failed Can't be Used In Test - Check test Case Run Keyword If Any Critical Tests Failed Can't be Used In Test - -Run Keyword If Any Critical Tests failed Is not executed when All Critical Tests Pass - Check Test Case Run Keyword If Any Critical Tests failed Is not executed when All Critcal Tests Pass - +*** 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.all.failed} 0 - Should Be Equal ${suite.teardown.kws[0].name} My Teardown - Check Log Message ${suite.teardown.kws[0].kws[0].msgs[0]} Suite teardown message + Should Be Equal As Integers ${suite.statistics.failed} 0 + Should Be Equal ${suite.teardown[0].name} My Teardown + Check Log Message ${suite.teardown[0, 0, 0]} Suite teardown message Run Keyword If All Tests Passed Can't be Used In Test Check Test Case Run Keyword If All Tests Passed Can't be Used In Test @@ -40,9 +17,9 @@ Run Keyword If All tests Passed Is not Executed When Any Test Fails Run Keyword If Any Tests Failed ${suite} = Get Test Suite Run Keyword If Any Tests Failed When Test Fails - Should Be Equal As Integers ${suite.statistics.all.failed} 1 - Should Be Equal ${suite.teardown.kws[0].name} My Teardown - Check Log Message ${suite.teardown.kws[0].kws[0].msgs[0]} Suite teardown message + Should Be Equal As Integers ${suite.statistics.failed} 1 + Should Be Equal ${suite.teardown[0].name} My Teardown + Check Log Message ${suite.teardown[0, 0, 0]} Suite teardown message Run Keyword If Any Tests Failed Can't be Used In Test Check Test Case Run Keyword If Any Tests Failed Can't be Used In Test 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 1df13a758b0..b635c2444c6 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,42 +1,76 @@ -*** Setting *** +*** Settings *** Suite Setup Run Tests ${EMPTY} standard_libraries/builtin/run_keyword_if_test_passed_failed Resource atest_resource.robot -*** Test Case *** -Run Keyword If test Failed When Test Fails +*** Test Cases *** +Run Keyword If Test Failed when test fails ${tc} = Check Test Case ${TEST NAME} - Should Be Equal ${tc.teardown.kws[0].name} BuiltIn.Log - Check Log Message ${tc.teardown.kws[0].msgs[0]} Hello from teardown! + Should Be Equal ${tc.teardown[0].full_name} BuiltIn.Log + Check Log Message ${tc.teardown[0, 0]} Hello from teardown! -Run Keyword If test Failed When Test Does Not Fail +Run Keyword If Test Failed in user keyword when test fails ${tc} = Check Test Case ${TEST NAME} - Should Be Empty ${tc.teardown.keywords} + Check Log Message ${tc.teardown[1, 0, 0]} Apparently test failed! FAIL + +Run Keyword If Test Failed when test passes + ${tc} = Check Test Case ${TEST NAME} + Should Be Empty ${tc.teardown.body} + +Run Keyword If Test Failed in user keyword when test passes + ${tc} = Check Test Case ${TEST NAME} + Should Be Empty ${tc.teardown[1].body} + +Run Keyword If Test Failed when test is skipped + ${tc} = Check Test Case ${TEST NAME} + Should Be Empty ${tc.teardown.body} + +Run Keyword If Test Failed in user keyword when test is skipped + ${tc} = Check Test Case ${TEST NAME} + Should Be Empty ${tc.teardown[1].body} Run Keyword If Test Failed Can't Be Used In Setup ${tc} = Check Test Case ${TEST NAME} - Should Be Empty ${tc.setup.keywords} + Length Should Be ${tc.setup.body} 1 + Check Log Message ${tc.setup[0]} Keyword 'Run Keyword If Test Failed' can only be used in test teardown. FAIL Run Keyword If Test Failed Can't Be Used in Test Check Test Case ${TEST NAME} Run Keyword If Test Failed Uses User Keyword ${tc} = Check Test Case ${TEST NAME} - Check Log Message ${tc.teardown.kws[0].kws[0].msgs[0]} Teardown message + Check Log Message ${tc.teardown[0, 0, 0]} Teardown message Run Keyword If Test Failed Fails Check Test Case ${TEST NAME} Run Keyword If test Failed Can't Be Used In Suite Setup or Teardown ${tc} = Check Test Case ${TEST NAME} - Check Log Message ${SUITE.suites[0].setup.msgs[0]} Keyword 'Run Keyword If Test Failed' can only be used in test teardown. FAIL - Check Log Message ${SUITE.suites[0].teardown.msgs[0]} Keyword 'Run Keyword If Test Failed' can only be used in test teardown. FAIL + Check Log Message ${SUITE.suites[0].setup[0]} Keyword 'Run Keyword If Test Failed' can only be used in test teardown. FAIL + Check Log Message ${SUITE.suites[0].teardown[0]} Keyword 'Run Keyword If Test Failed' can only be used in test teardown. FAIL -Run Keyword If Test Passed When Test Passes +Run Keyword If Test Passed when test passes ${tc} = Check Test Case ${TEST NAME} - Check Log Message ${tc.teardown.kws[0].msgs[0]} Teardown of passing test + Check Log Message ${tc.teardown[0, 0]} Teardown of passing test -Run Keyword If Test Passed When Test Fails - Check Test Case ${TEST NAME} +Run Keyword If Test Passed in user keyword when test passes + ${tc} = Check Test Case ${TEST NAME} + Check Log Message ${tc.teardown[1, 0, 0]} Apparently test passed! FAIL + +Run Keyword If Test Passed when test fails + ${tc} = Check Test Case ${TEST NAME} + Should Be Empty ${tc.teardown.body} + +Run Keyword If Test Passed in user keyword when test fails + ${tc} = Check Test Case ${TEST NAME} + Should Be Empty ${tc.teardown[1].body} + +Run Keyword If Test Passed when test is skipped + ${tc} = Check Test Case ${TEST NAME} + Should Be Empty ${tc.teardown.body} + +Run Keyword If Test Passed in user keyword when test is skipped + ${tc} = Check Test Case ${TEST NAME} + Should Be Empty ${tc.teardown[1].body} Run Keyword If Test Passed Can't Be used In Setup Check Test Case ${TEST NAME} @@ -46,8 +80,8 @@ Run Keyword If Test Passed Can't Be used In Test Run Keyword If Test Passes Uses User Keyword ${tc} = Check Test Case ${TEST NAME} - Check Log Message ${tc.teardown.kws[0].kws[0].msgs[0]} Teardown message - Check Keyword Data ${tc.teardown.kws[0].kws[0]} BuiltIn.Log args=\${message} + Check Log Message ${tc.teardown[0, 0, 0]} Teardown message + Check Keyword Data ${tc.teardown[0, 0]} BuiltIn.Log args=\${message} Run Keyword If Test Passed Fails Check Test Case ${TEST NAME} @@ -60,22 +94,27 @@ Run Keyword If Test Failed When Teardown Fails Run Keyword If Test Passed/Failed With Earlier Ignored Failures ${tc} = Check Test Case ${TEST NAME} - Should Be Equal ${tc.teardown.kws[0].kws[0].status} FAIL - Should Be Equal ${tc.teardown.kws[0].status} PASS - Should Be Equal ${tc.teardown.kws[1].kws[0].status} FAIL - Should Be Equal ${tc.teardown.kws[1].status} PASS - Should Be Equal ${tc.teardown.status} PASS + Should Be Equal ${tc.teardown[0, 0].status} FAIL + Should Be Equal ${tc.teardown[0].status} PASS + Should Be Equal ${tc.teardown[1, 0].status} FAIL + Should Be Equal ${tc.teardown[1].status} PASS + Should Be Equal ${tc.teardown.status} PASS + +Run Keyword If Test Passed/Failed after skip in teardown + ${tc} = Check Test Case ${TEST NAME} + Should Be Empty ${tc.teardown[1].body} + Should Be Empty ${tc.teardown[2].body} Continuable Failure In Teardown Check Test Case ${TEST NAME} Run Keyword If test Passed Can't Be Used In Suite Setup or Teardown ${tc} = Check Test Case ${TEST NAME} - Check Log Message ${SUITE.suites[2].setup.msgs[0]} Keyword 'Run Keyword If Test Passed' can only be used in test teardown. FAIL - Check Log Message ${SUITE.suites[2].teardown.msgs[0]} Keyword 'Run Keyword If Test Passed' can only be used in test teardown. FAIL + Check Log Message ${SUITE.suites[2].setup[0]} Keyword 'Run Keyword If Test Passed' can only be used in test teardown. FAIL + Check Log Message ${SUITE.suites[2].teardown[0]} Keyword 'Run Keyword If Test Passed' can only be used in test teardown. FAIL Variable Values Should Not Be Visible As Keyword's Arguments ${tc} = Check Test Case Run Keyword If Test Failed Uses User Keyword - Check Keyword Data ${tc.teardown} BuiltIn.Run Keyword If Test Failed args=Teardown UK, \${TEARDOWN MESSAGE} - Check Keyword Data ${tc.teardown.kws[0]} Teardown UK args=\${TEARDOWN MESSAGE} - Check Keyword Data ${tc.teardown.kws[0].kws[0]} BuiltIn.Log args=\${message} + Check Keyword Data ${tc.teardown} BuiltIn.Run Keyword If Test Failed args=Teardown UK, \${TEARDOWN MESSAGE} type=TEARDOWN + Check Keyword Data ${tc.teardown[0]} Teardown UK args=\${TEARDOWN MESSAGE} + Check Keyword Data ${tc.teardown[0, 0]} BuiltIn.Log args=\${message} 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 d2a02edf423..5141a284b7e 100644 --- a/atest/robot/standard_libraries/builtin/run_keyword_if_unless.robot +++ b/atest/robot/standard_libraries/builtin/run_keyword_if_unless.robot @@ -1,28 +1,28 @@ -*** 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.kws[0].kws[0].msgs[0]} ${EXECUTED} + Check Log Message ${tc[0, 0, 0]} ${EXECUTED} Run Keyword If With False Expression ${tc} = Check Test Case ${TEST NAME} - Should Be Equal As Integers ${tc.kws[0].keyword_count} 0 + Should Be Empty ${tc[0].body} Run Keyword In User Keyword ${tc} = Check Test Case ${TEST NAME} - Check Log Message ${tc.kws[0].kws[0].kws[0].msgs[0]} ${EXECUTED} - Should Be Equal As Integers ${tc.kws[1].kws[0].keyword_count} 0 + Check Log Message ${tc[0, 0, 0, 0]} ${EXECUTED} + Should Be Empty ${tc[1, 0].body} Run Keyword With ELSE ${tc} = Check Test Case ${TEST NAME} - Check Log Message ${tc.kws[1].kws[0].msgs[0]} ${EXECUTED} - Check Log Message ${tc.kws[3].kws[0].msgs[0]} ${EXECUTED} + Check Log Message ${tc[1, 0, 0]} ${EXECUTED} + Check Log Message ${tc[3, 0, 0]} ${EXECUTED} Keyword Name in ELSE as variable Check Test Case ${TEST NAME} @@ -45,18 +45,18 @@ Only first ELSE is significant Run Keyword With ELSE IF ${tc} = Check Test Case ${TEST NAME} - Check Log Message ${tc.kws[1].kws[0].msgs[0]} ${EXECUTED} + Check Log Message ${tc[1, 0, 0]} ${EXECUTED} Run Keyword with ELSE IF and ELSE ${tc} = Check Test Case ${TEST NAME} - Check Log Message ${tc.kws[0].kws[0].msgs[0]} ${EXECUTED} - Check Log Message ${tc.kws[1].kws[0].msgs[0]} ${EXECUTED} + Check Log Message ${tc[0, 0, 0]} ${EXECUTED} + Check Log Message ${tc[1, 0, 0]} ${EXECUTED} Run Keyword with multiple ELSE IF ${tc} = Check Test Case ${TEST NAME} - Check Log Message ${tc.kws[0].kws[0].msgs[0]} ${EXECUTED} - Check Log Message ${tc.kws[1].kws[0].msgs[0]} ${EXECUTED} - Check Log Message ${tc.kws[2].kws[0].msgs[0]} ${EXECUTED} + Check Log Message ${tc[0, 0, 0]} ${EXECUTED} + Check Log Message ${tc[1, 0, 0]} ${EXECUTED} + Check Log Message ${tc[2, 0, 0]} ${EXECUTED} Keyword Name in ELSE IF as variable Check Test Case ${TEST NAME} @@ -79,49 +79,53 @@ ELSE IF without keyword is invalid ELSE before ELSE IF is ignored ${tc} = Check Test Case ${TEST NAME} - Check Log Message ${tc.kws[0].kws[0].msgs[0]} ${EXECUTED} + Check Log Message ${tc[0, 0, 0]} ${EXECUTED} ELSE and ELSE IF inside list arguments should be escaped Check Test Case ${TEST NAME} ELSE and ELSE IF must be upper case ${tc} = Check Test Case ${TEST NAME} - Test ELSE (IF) Escaping ${tc.kws[0].kws[0]} else - Test ELSE (IF) Escaping ${tc.kws[1].kws[0]} ELSE iF + Test ELSE (IF) Escaping ${tc[0, 0]} else + Test ELSE (IF) Escaping ${tc[1, 0]} ELSE iF ELSE and ELSE IF must be whitespace sensitive ${tc} = Check Test Case ${TEST NAME} - Test ELSE (IF) Escaping ${tc.kws[0].kws[0]} EL SE - Test ELSE (IF) Escaping ${tc.kws[1].kws[0]} ELSEIF + Test ELSE (IF) Escaping ${tc[0, 0]} EL SE + Test ELSE (IF) Escaping ${tc[1, 0]} ELSEIF Run Keyword With Escaped ELSE and ELSE IF ${tc} = Check Test Case ${TEST NAME} - Test ELSE (IF) Escaping ${tc.kws[0].kws[0]} ELSE - Test ELSE (IF) Escaping ${tc.kws[1].kws[0]} ELSE IF + Test ELSE (IF) Escaping ${tc[0, 0]} ELSE + Test ELSE (IF) Escaping ${tc[1, 0]} ELSE IF Run Keyword With ELSE and ELSE IF from Variable ${tc} = Check Test Case ${TEST NAME} - Test ELSE (IF) Escaping ${tc.kws[0].kws[0]} ELSE - Test ELSE (IF) Escaping ${tc.kws[1].kws[0]} ELSE - Test ELSE (IF) Escaping ${tc.kws[2].kws[0]} ELSE IF - Test ELSE (IF) Escaping ${tc.kws[3].kws[0]} ELSE IF + Test ELSE (IF) Escaping ${tc[0, 0]} ELSE + Test ELSE (IF) Escaping ${tc[1, 0]} ELSE + Test ELSE (IF) Escaping ${tc[2, 0]} ELSE IF + Test ELSE (IF) Escaping ${tc[3, 0]} ELSE IF Run Keyword Unless With False Expression ${tc} = Check Test Case ${TEST NAME} - Check Log Message ${tc.kws[1].kws[0].msgs[0]} ${EXECUTED} + Check Log Message ${ERRORS[0]} Keyword 'BuiltIn.Run Keyword Unless' is deprecated. WARN + Check Log Message ${tc[1, 0]} Keyword 'BuiltIn.Run Keyword Unless' is deprecated. WARN + Check Log Message ${tc[1, 1, 0]} ${EXECUTED} Run Keyword Unless With True Expression ${tc} = Check Test Case ${TEST NAME} - Should Be Equal As Integers ${tc.kws[0].keyword_count} 0 + Check Log Message ${ERRORS[1]} Keyword 'BuiltIn.Run Keyword Unless' is deprecated. WARN + Check Log Message ${tc[0, 0]} Keyword 'BuiltIn.Run Keyword Unless' is deprecated. WARN + Length Should Be ${tc[0].body} 1 Variable Values Should Not Be Visible As Keyword's Arguments ${tc} = Check Test Case Run Keyword In User Keyword - Check Keyword Data ${tc.kws[0].kws[0]} BuiltIn.Run Keyword If args='\${status}' == 'PASS', Log, \${message} - Check Keyword Data ${tc.kws[0].kws[0].kws[0]} BuiltIn.Log args=\${message} + Check Keyword Data ${tc[0, 0]} BuiltIn.Run Keyword If args='\${status}' == 'PASS', Log, \${message} + Check Keyword Data ${tc[0, 0, 0]} BuiltIn.Log args=\${message} *** Keywords *** Test ELSE (IF) Escaping [Arguments] ${kw} ${else (if)} - Length Should Be ${kw.msgs} 2 - Check Log Message ${kw.msgs[0]} ${else (if)} - Check Log Message ${kw.msgs[1]} ${EXECUTED} + Length Should Be ${kw.body} 2 + Check Log Message ${kw[0]} ${else (if)} + Check Log Message ${kw[1]} ${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 dd277e90c6b..632d23d74a1 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,19 +1,28 @@ -*** 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} - 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[0]} BuiltIn.Run Keyword args=My UK, Log, \${OBJECT} + Check Keyword Data ${tc[0, 0]} My UK args=Log, \${OBJECT} + Check Keyword Data ${tc[0, 0, 0]} BuiltIn.Run Keyword args=\${name}, \@{args} + Check Keyword Data ${tc[0, 0, 0, 0]} BuiltIn.Log args=\@{args} + Check Log Message ${tc[0, 0, 0, 0, 0]} Robot + Check Keyword Data ${tc[0, 0, 1, 0]} BuiltIn.Log args=\${args}[0] + Check Log Message ${tc[0, 0, 1, 0, 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} + Check Keyword Data ${tc[0, 0]} \\Log Many args=c:\\\\temp\\\\foo, \\\${notvar} + Check Keyword Data ${tc[1, 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} @@ -45,10 +54,10 @@ 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} - Check Keyword Data ${tc.kws[0].kws[0].kws[0]} BuiltIn.Log Many args=\@{args} - Check Log Message ${tc.kws[0].kws[0].kws[0].msgs[0]} c:\\temp\\foo - Check Log Message ${tc.kws[0].kws[0].kws[0].msgs[1]} \${notvar} + Check Keyword Data ${tc[0, 0]} \\Log Many args=\@{ARGS} + Check Keyword Data ${tc[0, 0, 0]} BuiltIn.Log Many args=\@{args} + Check Log Message ${tc[0, 0, 0, 0]} c:\\temp\\foo + Check Log Message ${tc[0, 0, 0, 1]} \${notvar} diff --git a/atest/robot/standard_libraries/builtin/run_keyword_variants_with_escaping_control_arguments.robot b/atest/robot/standard_libraries/builtin/run_keyword_variants_with_escaping_control_arguments.robot index bf6a2754738..6c55e2821a3 100644 --- a/atest/robot/standard_libraries/builtin/run_keyword_variants_with_escaping_control_arguments.robot +++ b/atest/robot/standard_libraries/builtin/run_keyword_variants_with_escaping_control_arguments.robot @@ -5,32 +5,32 @@ Resource atest_resource.robot *** Test Cases *** Run Keyword with Run Keywords with Arguments Inside List variable should escape AND ${tc}= Test Should Have Correct Keywords BuiltIn.Run Keywords - Check Log Message ${tc.kws[0].kws[0].kws[0].kws[0].msgs[0]} log message + Check Log Message ${tc[0, 0, 0, 0, 0]} log message Run Keyword with Run Keywords and Arguments Inside List variable should escape AND ${tc}= Test Should Have Correct Keywords BuiltIn.Run Keywords - Check Log Message ${tc.kws[0].kws[0].kws[0].kws[0].msgs[0]} log message + Check Log Message ${tc[0, 0, 0, 0, 0]} log message Run Keyword If with Run Keywords With Arguments Inside List variable should escape AND ${tc}= Test Should Have Correct Keywords BuiltIn.Run Keywords - Check Log Message ${tc.kws[0].kws[0].kws[0].kws[0].msgs[0]} log message + Check Log Message ${tc[0, 0, 0, 0, 0]} log message Run Keyword If with Run Keywords And Arguments Inside List variable should escape AND ${tc}= Test Should Have Correct Keywords BuiltIn.Run Keyword - Check Log Message ${tc.kws[0].kws[0].kws[0].kws[0].kws[0].msgs[0]} log message + Check Log Message ${tc[0, 0, 0, 0, 0, 0]} log message Run Keywords With Run Keyword If should not escape ELSE and ELSE IF ${tc}= Test Should Have Correct Keywords ... BuiltIn.Run Keyword If BuiltIn.Log BuiltIn.Run Keyword If - Check Log Message ${tc.kws[0].kws[0].kws[0].msgs[0]} log message - Check Log Message ${tc.kws[0].kws[1].msgs[0]} that + Check Log Message ${tc[0, 0, 0, 0]} log message + Check Log Message ${tc[0, 1, 0]} that Run Keywords With Run Keyword If In List Variable Should Escape ELSE and ELSE IF From List Variable ${tc}= Test Should Have Correct Keywords ... BuiltIn.Run Keyword If BuiltIn.Log BuiltIn.Run Keyword If - Check Log Message ${tc.kws[0].kws[1].msgs[0]} that + Check Log Message ${tc[0, 1, 0]} that Run Keywords With Run Keyword If With Arguments From List Variable should escape ELSE and ELSE IF From List Variable ${tc}= Test Should Have Correct Keywords ... BuiltIn.Run Keyword If BuiltIn.Log BuiltIn.Run Keyword If - Check Log Message ${tc.kws[0].kws[1].msgs[0]} that + Check Log Message ${tc[0, 1, 0]} that 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 4f2464d8a7b..987f3a69384 100644 --- a/atest/robot/standard_libraries/builtin/run_keyword_with_errors.robot +++ b/atest/robot/standard_libraries/builtin/run_keyword_with_errors.robot @@ -5,13 +5,13 @@ Resource atest_resource.robot *** Test Cases *** Ignore Error When Keyword Passes ${tc} = Check Test Case ${TEST NAME} - Check Log Message ${tc.kws[0].kws[0].msgs[0]} My message + Check Log Message ${tc[0, 0, 0]} My message Ignore Error When Keyword Fails ${tc} = Check Test Case ${TEST NAME} - Check Log Message ${tc.kws[0].kws[0].msgs[0]} My error message FAIL - Should Be Equal ${tc.kws[0].kws[0].status} FAIL - Should Be Equal ${tc.kws[0].status} PASS + Should Be Equal ${tc[0].status} PASS + Should Be Equal ${tc[0, 0].status} FAIL + Check Log Message ${tc[0, 0, 0]} My error message FAIL Ignore Error Returns When Keyword Passes Check Test Case ${TEST NAME} @@ -21,29 +21,32 @@ Ignore Error Returns When Keyword Fails Ignore Error With User Keyword When Keywords Pass ${tc} = Check Test Case ${TEST NAME} - Check Log Message ${tc.kws[0].kws[0].kws[0].msgs[0]} Hello world - Check Keyword Data ${tc.kws[0].kws[0].kws[2]} BuiltIn.Evaluate \${ret} 1+2 + Check Log Message ${tc[0, 0, 0, 0]} Hello world + Check Keyword Data ${tc[0, 0, 2]} BuiltIn.Evaluate \${ret} 1+2 Ignore Error With User Keyword When Keyword Fails ${tc} = Check Test Case ${TEST NAME} - Check Log Message ${tc.kws[0].kws[0].kws[0].kws[0].msgs[0]} Hello world - Check Log Message ${tc.kws[0].kws[0].kws[1].msgs[0]} Expected failure in UK FAIL - Should Be Equal As Integers ${tc.kws[0].kws[0].keyword_count} 2 + Check Log Message ${tc[0, 0, 0, 0, 0]} Hello world + Check Log Message ${tc[0, 0, 1, 0]} Expected failure in UK FAIL + Length Should Be ${tc[0, 0].body} 3 + Should Be Equal ${tc[0, 0, -1].status} NOT RUN Ignore Error With Arguments That Needs To Be Escaped Check Test Case ${TEST NAME} Ignore Error When Timeout Occurs ${tc} = Check Test Case ${TEST NAME} - Should Be Equal ${tc.kws[0].status} FAIL Run Keyword And Ignore Error captured timeout even though it should not no values + Should Be Equal ${tc[0].status} FAIL + ... Run Keyword And Ignore Error captured timeout even though it should not + ... values=False 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 @@ -74,9 +77,9 @@ Ignore Error With "Passing" Exceptions Expect Error When Error Occurs ${tc} = Check Test Case ${TEST NAME} - Check Log Message ${tc.kws[0].kws[0].msgs[0]} My error message FAIL - Should Be Equal ${tc.kws[0].kws[0].status} FAIL - Should Be Equal ${tc.kws[0].status} PASS + Should Be Equal ${tc[0].status} PASS + Should Be Equal ${tc[0, 0].status} FAIL + Check Log Message ${tc[0, 0, 0]} My error message FAIL Expect Error When Different Error Occurs Check Test Case ${TEST NAME} @@ -96,31 +99,34 @@ Expected Error Should Be Returned Expect Error With User Keyword When Keywords Pass ${tc} = Check Test Case ${TEST NAME} - Check Log Message ${tc.kws[0].kws[0].kws[0].msgs[0]} Hello world - Check Keyword Data ${tc.kws[0].kws[0].kws[2]} BuiltIn.Evaluate \${ret} 1+2 + Check Log Message ${tc[0, 0, 0, 0]} Hello world + Check Keyword Data ${tc[0, 0, 2]} BuiltIn.Evaluate \${ret} 1+2 Expect Error With User Keyword When Keyword Fails ${tc} = Check Test Case ${TEST NAME} - Check Log Message ${tc.kws[0].kws[0].kws[0].kws[0].msgs[0]} Hello world - Check Log Message ${tc.kws[0].kws[0].kws[1].msgs[0]} Expected failure in UK FAIL - Should Be Equal As Integers ${tc.kws[0].kws[0].keyword_count} 2 + Check Log Message ${tc[0, 0, 0, 0, 0]} Hello world + Check Log Message ${tc[0, 0, 1, 0]} Expected failure in UK FAIL + Length Should Be ${tc[0, 0].body} 3 + Should Be Equal ${tc[0, 0, -1].status} NOT RUN Expect Error With Arguments That Needs To Be Escaped ${tc} = Check Test Case ${TEST NAME} - Check Log Message ${tc.kws[1].kws[0].msgs[0]} c:\\temp\\foo\\not_new_line - Check Log Message ${tc.kws[1].kws[0].msgs[1]} \${notvar} + Check Log Message ${tc[1, 0, 0]} c:\\temp\\foo\\not_new_line + Check Log Message ${tc[1, 0, 1]} \${notvar} Expect Error When Timeout Occurs ${tc} = Check Test Case ${TEST NAME} - Should Be Equal ${tc.kws[0].status} FAIL Run Keyword And Expect Error captured timeout even though it should not no values + Should Be Equal ${tc[0].status} FAIL + ... Run Keyword And Expect Error captured timeout even though it should not + ... values=False 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 @@ -158,6 +164,9 @@ Expect Error With STARTS Expect Error With REGEXP Check Test Case ${TEST NAME} +Expect Error With REGEXP requires full match + Check Test Case ${TEST NAME} + Expect Error With Unrecognized Prefix Check Test Case ${TEST NAME} @@ -166,4 +175,4 @@ Expect Error With "Passing" Exceptions Variable Values Should Not Be Visible As Keyword's Arguments ${tc} = Check Test Case Ignore Error With Arguments That Needs To be Escaped - Check Keyword Data ${tc.kws[3].kws[0]} BuiltIn.Create List args=\@{NEEDS ESCAPING} + Check Keyword Data ${tc[3, 0]} BuiltIn.Create List args=\@{NEEDS ESCAPING} diff --git a/atest/robot/standard_libraries/builtin/run_keywords.robot b/atest/robot/standard_libraries/builtin/run_keywords.robot index 39049cea76d..e245b9146e7 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 @@ -6,12 +8,32 @@ Resource atest_resource.robot Passing keywords ${tc} = Test Should Have Correct Keywords ... BuiltIn.No Operation Passing BuiltIn.Log Variables - Check Log Message ${tc.kws[0].kws[1].kws[0].msgs[0]} Hello, world! + Check Log Message ${tc[0, 1, 0, 0]} Hello, world! 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[0, 0, 0, 0]} arg + Check Log Message ${tc[0, 1, 0, 0]} 1 + Check Log Message ${tc[0, 2, 0, 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[0, 0, 0]} arg + Check Log Message ${tc[0, 1, 0]} 1 + Check Log Message ${tc[0, 2, 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,6 +70,9 @@ In test setup In test teardown Check Test Case ${TESTNAME} +In test teardown with non-existing variable in keyword name + Check Test Case ${TESTNAME} + In test teardown with ExecutionPassed exception Check Test Case ${TESTNAME} @@ -50,7 +80,7 @@ In test teardown with ExecutionPassed exception after continuable failure Check Test Case ${TESTNAME} In suite setup - Check Log Message ${SUITE.setup.kws[0].kws[0].msgs[0]} Hello, world! + Check Log Message ${SUITE.setup[0, 0, 0]} Hello, world! Should Contain Keywords ${SUITE.setup} Passing BuiltIn.No Operation In suite teardown 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..4e792da98bf 100644 --- a/atest/robot/standard_libraries/builtin/run_keywords_with_arguments.robot +++ b/atest/robot/standard_libraries/builtin/run_keywords_with_arguments.robot @@ -1,50 +1,52 @@ *** 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 *** Test Cases *** With arguments ${tc}= Test Should Have Correct Keywords BuiltIn.Should Be Equal BuiltIn.No Operation BuiltIn.Log Many BuiltIn.Should Be Equal - Check Log Message ${tc.kws[0].kws[2].msgs[1]} 1 + Check Log Message ${tc[0, 2, 1]} 1 Should fail with failing keyword Test Should Have Correct Keywords BuiltIn.No Operation BuiltIn.Should Be Equal Should support keywords and arguments from variables ${tc}= Test Should Have Correct Keywords BuiltIn.Should Be Equal BuiltIn.No Operation BuiltIn.Log Many BuiltIn.Should Be Equal As Integers - Check Log Message ${tc.kws[0].kws[2].msgs[0]} hello - Check Log Message ${tc.kws[0].kws[2].msgs[1]} 1 - Check Log Message ${tc.kws[0].kws[2].msgs[2]} 2 - Check Log Message ${tc.kws[0].kws[2].msgs[3]} 3 + Check Log Message ${tc[0, 2, 0]} hello + Check Log Message ${tc[0, 2, 1]} 1 + Check Log Message ${tc[0, 2, 2]} 2 + Check Log Message ${tc[0, 2, 3]} 3 AND must be upper case ${tc}= Test Should Have Correct Keywords BuiltIn.Log Many no kw - Check Log Message ${tc.kws[0].kws[0].msgs[1]} and + Check Log Message ${tc[0, 0, 1]} and AND must be whitespace sensitive ${tc}= Test Should Have Correct Keywords BuiltIn.Log Many no kw - Check Log Message ${tc.kws[0].kws[0].msgs[1]} A ND + Check Log Message ${tc[0, 0, 1]} A ND Escaped AND ${tc}= Test Should Have Correct Keywords BuiltIn.Log Many no kw - Check Log Message ${tc.kws[0].kws[0].msgs[1]} AND + Check Log Message ${tc[0, 0, 1]} AND AND from Variable ${tc}= Test Should Have Correct Keywords BuiltIn.Log Many no kw - Check Log Message ${tc.kws[0].kws[0].msgs[1]} AND + Check Log Message ${tc[0, 0, 1]} AND AND in List Variable ${tc}= Test Should Have Correct Keywords BuiltIn.Log Many no kw - Check Log Message ${tc.kws[0].kws[0].msgs[1]} AND + Check Log Message ${tc[0, 0, 1]} AND Escapes in List Variable should be handled correctly ${tc}= Test Should Have Correct Keywords BuiltIn.Log Many no kw - Check Log Message ${tc.kws[0].kws[0].msgs[0]} 1 - Check Log Message ${tc.kws[0].kws[0].msgs[1]} AND - Check Log Message ${tc.kws[0].kws[0].msgs[2]} 2 - Check Log Message ${tc.kws[0].kws[0].msgs[3]} Log Many - Check Log Message ${tc.kws[0].kws[0].msgs[4]} x\${escaped} - Check Log Message ${tc.kws[0].kws[0].msgs[5]} c:\\temp + Check Log Message ${tc[0, 0, 0]} 1 + Check Log Message ${tc[0, 0, 1]} AND + Check Log Message ${tc[0, 0, 2]} 2 + Check Log Message ${tc[0, 0, 3]} Log Many + Check Log Message ${tc[0, 0, 4]} x\${escaped} + Check Log Message ${tc[0, 0, 5]} c:\\temp AND as last argument should raise an error Test Should Have Correct Keywords BuiltIn.Log Many BuiltIn.No Operation @@ -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/robot/standard_libraries/builtin/set_documentation.robot b/atest/robot/standard_libraries/builtin/set_documentation.robot index d155f09b210..bbe36cbe526 100644 --- a/atest/robot/standard_libraries/builtin/set_documentation.robot +++ b/atest/robot/standard_libraries/builtin/set_documentation.robot @@ -5,35 +5,40 @@ Resource atest_resource.robot *** Test Cases *** Set test documentation ${tc} = Check Test Doc ${TESTNAME} This has been set!\nTo several lines. - Check Log Message ${tc.kws[0].msgs[0]} Set test documentation to:\nThis has been set!\nTo several lines. + Check Log Message ${tc[0, 0]} Set test documentation to:\nThis has been set!\nTo several lines. Replace test documentation ${tc} = Check Test Doc ${TESTNAME} New doc - Check Log Message ${tc.kws[0].msgs[0]} Set test documentation to:\nNew doc + Check Log Message ${tc[0, 0]} Set test documentation to:\nNew doc Append to test documentation - ${tc} = Check Test Doc ${TESTNAME} Original doc is continued \n\ntwice! - Check Log Message ${tc.kws[0].msgs[0]} Set test documentation to:\nOriginal doc is continued - Check Log Message ${tc.kws[2].msgs[0]} Set test documentation to:\nOriginal doc is continued \n\ntwice! + ${tc} = Check Test Doc ${TESTNAME} Original doc is continued \n\ntwice! thrice!! + Check Log Message ${tc[0, 0]} Set test documentation to:\nOriginal doc is continued + Check Log Message ${tc[2, 0]} Set test documentation to:\nOriginal doc is continued \n\ntwice! + Check Log Message ${tc[4, 0]} Set test documentation to:\nOriginal doc is continued \n\ntwice! thrice + Check Log Message ${tc[6, 0]} Set test documentation to:\nOriginal doc is continued \n\ntwice! thrice! + Check Log Message ${tc[8, 0]} Set test documentation to:\nOriginal doc is continued \n\ntwice! thrice!! Set suite documentation ${tc} = Check Test Case ${TESTNAME} - Check Log Message ${tc.kws[0].msgs[0]} Set suite documentation to:\nNew suite doc + Check Log Message ${tc[0, 0]} Set suite documentation to:\nNew suite doc Check Test Case ${TESTNAME} 2 Should Start With ${SUITE.suites[0].doc} New suite doc Append to suite documentation ${tc} = Check Test Case ${TESTNAME} - Check Log Message ${tc.kws[0].msgs[0]} Set suite documentation to:\nNew suite doc is continued + Check Log Message ${tc[0, 0]} Set suite documentation to:\nNew suite doc is continued ${tc} = Check Test Case ${TESTNAME} 2 - Check Log Message ${tc.kws[1].msgs[0]} Set suite documentation to:\nNew suite doc is continued \n\ntwice! - Should Be Equal ${SUITE.suites[0].doc} New suite doc is continued \n\ntwice! + Check Log Message ${tc[1, 0]} Set suite documentation to:\nNew suite doc is continued \n\ntwice! + Check Log Message ${tc[3, 0]} Set suite documentation to:\nNew suite doc is continued \n\ntwice!,thrice + Check Log Message ${tc[5, 0]} Set suite documentation to:\nNew suite doc is continued \n\ntwice!,thrice?1 + Should Be Equal ${SUITE.suites[0].doc} New suite doc is continued \n\ntwice!,thrice?1 Set init file suite docs Should Be Equal ${SUITE.doc} Init file doc. Concatenated in setup. Appended in test. - Check Log Message ${SUITE.setup.msgs[0]} Set suite documentation to:\nInit file doc. Concatenated in setup. + Check Log Message ${SUITE.setup[0]} Set suite documentation to:\nInit file doc. Concatenated in setup. Set top level suite documentation ${tc} = Check Test Case ${TESTNAME} - Check Log Message ${tc.kws[0].msgs[0]} Set suite documentation to:\nInit file doc. Concatenated in setup. Appended in test. + Check Log Message ${tc[0, 0]} Set suite documentation to:\nInit file doc. Concatenated in setup. Appended in test. 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..f7b692bbc7c 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,8 @@ Library Search Order Is Space Insensitive Library Search Order Is Case Insensitive Check Test Case ${TEST NAME} +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_log_level.robot b/atest/robot/standard_libraries/builtin/set_log_level.robot index 709679cc2c3..621e617125c 100644 --- a/atest/robot/standard_libraries/builtin/set_log_level.robot +++ b/atest/robot/standard_libraries/builtin/set_log_level.robot @@ -5,24 +5,36 @@ 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[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[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} - Check Log Message ${tc.kws[12].msgs[0]} This is logged INFO - 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} + Check Log Message ${tc[0, 0]} Log level changed from INFO to TRACE. DEBUG + Check Log Message ${tc[1, 1]} This is logged TRACE + Check Log Message ${tc[2, 1]} This is logged DEBUG + Check Log Message ${tc[3, 1]} This is logged INFO + Check Log Message ${tc[4, 1]} Log level changed from TRACE to DEBUG. DEBUG + Should Be Empty ${tc[6].body} + Check Log Message ${tc[7, 0]} This is logged DEBUG + Check Log Message ${tc[8, 0]} This is logged INFO + Should Be Empty ${tc[9].body} + Should Be Empty ${tc[10].body} + Should Be Empty ${tc[11].body} + Check Log Message ${tc[12, 0]} This is logged INFO + Should Be Empty ${tc[15].body} + Check Log Message ${tc[16, 0]} This is logged ERROR + Should Be Empty ${tc[17].body} + Should Be Empty ${tc[18].body} + Should Be Empty ${tc[19].body} Invalid Log Level Failure Is Catchable Check Test Case ${TESTNAME} +Reset Log Level + ${tc} = Check Test Case ${TESTNAME} + Check Log Message ${tc[0, 0]} Log level changed from INFO to DEBUG. DEBUG + Check Log Message ${tc[1, 0]} This is logged INFO + Check Log Message ${tc[2, 0]} This is logged DEBUG + Should Be Empty ${tc[3].body} + Check Log Message ${tc[4, 0]} This is logged INFO + Should Be Empty ${tc[5].body} + Log Level Goes To HTML File Should Contain ${OUTDIR}${/}set_log_level_log.html KW Info to log File Should Contain ${OUTDIR}${/}set_log_level_log.html KW Trace to log 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..1d0a1bbd403 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,9 @@ Resource Search Order Is Case Insensitive Default Resource Order Should Be Suite Specific Check Test Case ${TEST NAME} + +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/robot/standard_libraries/builtin/set_suite_metadata.robot b/atest/robot/standard_libraries/builtin/set_suite_metadata.robot index 3c7ea124e35..36ca3731efc 100644 --- a/atest/robot/standard_libraries/builtin/set_suite_metadata.robot +++ b/atest/robot/standard_libraries/builtin/set_suite_metadata.robot @@ -5,49 +5,62 @@ Resource atest_resource.robot *** Test Cases *** Set new value Metadata should have value New metadata Set in test - ${tc} = Check test case ${TESTNAME} - Check log message ${tc.kws[0].msgs[0]} + ${tc} = Check Test Case ${TESTNAME} + Check Log Message ${tc[0, 0]} ... Set suite metadata 'New metadata' to value 'Set in test'. Override existing value Metadata should have value Initial New value - ${tc} = Check test case ${TESTNAME} - Check log message ${tc.kws[0].msgs[0]} + ${tc} = Check Test Case ${TESTNAME} + Check Log Message ${tc[0, 0]} ... Set suite metadata 'Initial' to value 'New value'. Names are case and space insensitive Metadata should have value My Name final value - ${tc} = Check test case ${TESTNAME} - Check log message ${tc.kws[1].msgs[0]} + ${tc} = Check Test Case ${TESTNAME} + Check Log Message ${tc[1, 0]} ... Set suite metadata 'MYname' to value 'final value'. Append to value Metadata should have value To Append Original is continued \n\ntwice! - ${tc} = Check test case ${TESTNAME} - Check log message ${tc.kws[0].msgs[0]} + ${tc} = Check Test Case ${TESTNAME} + Check Log Message ${tc[0, 0]} ... Set suite metadata 'To Append' to value 'Original'. - Check log message ${tc.kws[2].msgs[0]} + Check Log Message ${tc[2, 0]} ... Set suite metadata 'toappend' to value 'Original is continued'. - Check log message ${tc.kws[4].msgs[0]} + Check Log Message ${tc[4, 0]} ... Set suite metadata 'TOAPPEND' to value 'Original is continued \n\ntwice!'. + Check Log Message ${tc[6, 0]} + ... Set suite metadata 'Version' to value '1.0'. + Check Log Message ${tc[8, 0]} + ... Set suite metadata 'version' to value '1.0/2.0'. + Check Log Message ${tc[10, 0]} + ... Set suite metadata 'ver sion' to value '1.0/2.0/3.0'. Set top-level suite metadata Metadata should have value New metadata Metadata for top level suite top=yes - ${tc} = Check test case ${TESTNAME} - Check log message ${tc.kws[0].msgs[0]} + ${tc} = Check Test Case ${TESTNAME} + Check Log Message ${tc[0, 0]} ... Set suite metadata 'New metadata' to value 'Metadata for'. - Check log message ${tc.kws[1].msgs[0]} + Check Log Message ${tc[1, 0]} ... Set suite metadata 'newmetadata' to value 'Metadata for top level suite'. + Metadata should have value Separator 2top**level top=yes + Check Log Message ${tc[3, 0]} + ... Set suite metadata 'Separator' to value '2'. + Check Log Message ${tc[4, 0]} + ... Set suite metadata 'Separator' to value '2top'. + Check Log Message ${tc[5, 0]} + ... Set suite metadata 'Separator' to value '2top**level'. Non-ASCII and non-string names and values - ${tc} = Check test case ${TESTNAME} - Check log message ${tc.kws[0].msgs[0]} + ${tc} = Check Test Case ${TESTNAME} + Check Log Message ${tc[0, 0]} ... Set suite metadata '42' to value '1'. - Check log message ${tc.kws[2].msgs[0]} + Check Log Message ${tc[2, 0]} ... Set suite metadata '42' to value '1 päivä'. -Modifying ${SUITE METADATA} has no effect also after setting metadata - Check test case ${TESTNAME} +Modifying \${SUITE METADATA} has no effect also after setting metadata + Check Test Case ${TESTNAME} Metadata should have value Cannot be set otherwise Set in suite setup diff --git a/atest/robot/standard_libraries/builtin/set_test_message.robot b/atest/robot/standard_libraries/builtin/set_test_message.robot index 28882ff74d7..bb79583aa1e 100644 --- a/atest/robot/standard_libraries/builtin/set_test_message.robot +++ b/atest/robot/standard_libraries/builtin/set_test_message.robot @@ -6,42 +6,44 @@ Resource atest_resource.robot *** Test Cases *** Set Message To Successful Test ${tc} = Check Test Case ${TEST NAME} - Check Log Message ${tc.kws[0].msgs[0]} Set test message to:\nMy Test + Check Log Message ${tc[0, 0]} Set test message to:\nMy Test Reset Message Check Test Case ${TEST NAME} Append To Message ${tc} = Check Test Case ${TEST NAME} - Check Log Message ${tc.kws[0].msgs[0]} Set test message to:\nMy - Check Log Message ${tc.kws[1].msgs[0]} Set test message to:\nMy & its continuation <> + Check Log Message ${tc[0, 0]} Set test message to:\nMy + Check Log Message ${tc[1, 0]} Set test message to:\nMy & its continuation <> + Check Log Message ${tc[2, 0]} Set test message to:\nMy & its continuation <>1 + Check Log Message ${tc[3, 0]} Set test message to:\nMy & its continuation <>1,\n2 Set Non-ASCII Message ${tc} = Check Test Case ${TEST NAME} - Check Log Message ${tc.kws[0].msgs[0]} Set test message to:\nHyvää yötä + Check Log Message ${tc[0, 0]} Set test message to:\nHyvää yötä Set Multiline Message ${tc} = Check Test Case ${TEST NAME} - Check Log Message ${tc.kws[0].msgs[0]} Set test message to:\n1\n2\n3 + Check Log Message ${tc[0, 0]} Set test message to:\n1\n2\n3 Set HTML Message ${tc} = Check Test Case ${TEST NAME} - Check Log Message ${tc.kws[0].msgs[0]} Set test message to:\nMy HTML message html=True + Check Log Message ${tc[0, 0]} Set test message to:\nMy HTML message html=True Append HTML to non-HTML ${tc} = Check Test Case ${TEST NAME} - Check Log Message ${tc.kws[0].msgs[0]} Set test message to:\nMy non-HTML & html=False - Check Log Message ${tc.kws[1].msgs[0]} Set test message to:\nMy non-HTML <message> & its HTML continuation html=True + Check Log Message ${tc[0, 0]} Set test message to:\nMy non-HTML & html=False + Check Log Message ${tc[1, 0]} Set test message to:\nMy non-HTML <message> & its HTML continuation html=True Append non-HTML to HTML ${tc} = Check Test Case ${TEST NAME} - Check Log Message ${tc.kws[0].msgs[0]} Set test message to:\nMy HTML message html=True - Check Log Message ${tc.kws[1].msgs[0]} Set test message to:\nMy HTML message & its non-HTML <continuation> html=True + Check Log Message ${tc[0, 0]} Set test message to:\nMy HTML message html=True + Check Log Message ${tc[1, 0]} Set test message to:\nMy HTML message & its non-HTML <continuation> html=True Append HTML to HTML ${tc} = Check Test Case ${TEST NAME} - Check Log Message ${tc.kws[0].msgs[0]} Set test message to:\nMy HTML message html=True - Check Log Message ${tc.kws[1].msgs[0]} Set test message to:\nMy HTML message & its HTML continuation html=True + Check Log Message ${tc[0, 0]} Set test message to:\nMy HTML message html=True + Check Log Message ${tc[1, 0]} Set test message to:\nMy HTML message & its HTML continuation html=True Set Non-String Message Check Test Case ${TEST NAME} @@ -87,3 +89,18 @@ Not Allowed In Suite Setup or Teardown ... Also suite teardown failed: ... 'Set Test Message' keyword cannot be used in suite setup or teardown. Should Be Equal ${SUITE.suites[1].message} ${error} + +Append HTML to non-HTML with separator + ${tc} = Check Test Case ${TEST NAME} + Check Log Message ${tc[0, 0]} Set test message to:\nA non HTML html=False + Check Log Message ${tc[1, 0]} Set test message to:\nA non HTML <message>&its HTML continuation html=True + +Append non-HTML to HTML with separator + ${tc} = Check Test Case ${TEST NAME} + Check Log Message ${tc[0, 0]} Set test message to:\nA HTML message html=True + Check Log Message ${tc[1, 0]} Set test message to:\nA HTML message<\br>its non-HTML <continuation> html=True + +Append HTML to HTML with separator + ${tc} = Check Test Case ${TEST NAME} + Check Log Message ${tc[0, 0]} Set test message to:\nA HTML message html=True + Check Log Message ${tc[1, 0]} Set test message to:\nA HTML message && its HTML continuation html=True diff --git a/atest/robot/standard_libraries/builtin/set_test_metadata.robot b/atest/robot/standard_libraries/builtin/set_test_metadata.robot new file mode 100644 index 00000000000..713b6d30f16 --- /dev/null +++ b/atest/robot/standard_libraries/builtin/set_test_metadata.robot @@ -0,0 +1,67 @@ +*** Settings *** +Suite Setup Run Tests ${EMPTY} standard_libraries/builtin/set_test_metadata.robot +Resource atest_resource.robot + +*** Test Cases *** +Set new value + Metadata should have value New metadata Set in test + ${tc} = Check Test Case ${TESTNAME} + Check Log Message ${tc[0, 0]} + ... Set test metadata 'New metadata' to value 'Set in test'. + +Override existing value + Metadata should have value Initial New value + ${tc} = Check Test Case ${TESTNAME} + Check Log Message ${tc[0, 0]} + ... Set test metadata 'Initial' to value 'New value'. + +Names are case and space insensitive + Metadata should have value My Name final value + ${tc} = Check Test Case ${TESTNAME} + Check Log Message ${tc[1, 0]} + ... Set test metadata 'MYname' to value 'final value'. + +Append to value + Metadata should have value To Append Original is continued \n\ntwice! + Metadata should have value Version 1.0/2.0/3.0 + ${tc} = Check Test Case ${TESTNAME} + Check Log Message ${tc[0, 0]} + ... Set test metadata 'To Append' to value 'Original'. + Check Log Message ${tc[4, 0]} + ... Set test metadata 'TOAPPEND' to value 'Original is continued \n\ntwice!'. + Check Log Message ${tc[10, 0]} + ... Set test metadata 'ver sion' to value '1.0/2.0/3.0'. + +Non-ASCII and non-string names and values + Metadata should have value 42 1 päivä + ${tc} = Check Test Case ${TESTNAME} + Check Log Message ${tc[0, 0]} + ... Set test metadata '42' to value '1'. + Check Log Message ${tc[2, 0]} + ... Set test metadata '42' to value '1 päivä'. + +Modifying \${TEST METADATA} has no effect also after setting metadata + Check Test Case ${TESTNAME} + Metadata should have value Cannot be set otherwise + +Set Task Metadata as alias for Set Test Metadata + Metadata should have value Task Value is continued + ${tc} = Check Test Case ${TESTNAME} + Check Log Message ${tc[0, 0]} + ... Set test metadata 'Task' to value 'Value'. + +Set in test setup + Metadata should have value Setup Value + +Set in test teardown + Metadata should have value Teardown Another value + +Metadata is test specific + ${tc} = Check Test Case ${TESTNAME} + Should Be Empty ${tc.metadata} + +*** Keywords *** +Metadata should have value + [Arguments] ${name} ${value} + ${tc} = Check Test Case ${TESTNAME} + Should Be Equal ${tc.metadata['${name}']} ${value} 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/robot/standard_libraries/builtin/setting_variables.robot b/atest/robot/standard_libraries/builtin/setting_variables.robot index 4938dce19cd..22d47f80251 100644 --- a/atest/robot/standard_libraries/builtin/setting_variables.robot +++ b/atest/robot/standard_libraries/builtin/setting_variables.robot @@ -1,17 +1,43 @@ -*** 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 +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 + Check Log Message ${tc[0, 0]} \${var} = Hello Set Variable With More Or Less Than One Value Check Test Case ${TESTNAME} +Set Local Variable - Scalars + ${tc} = Check Test Case ${TESTNAME} + Check Log Message ${tc[1, 0]} \${scalar} = Hello world + +Set Local Variable - Lists + ${tc} = Check Test Case ${TESTNAME} + Check Log Message ${tc[3, 0]} \@{list} = [ One | Two | Three ] + Check Log Message ${tc[6, 0]} \@{list} = [ 1 | 2 | 3 ] + +Set Local Variable - Dicts + ${tc} = Check Test Case ${TESTNAME} + Check Log Message ${tc[4, 0]} \&{DICT} = { a=1 | 2=b } + +Set Local Variables Overrides Test Variables + Check Test Case ${TESTNAME} + +Set Local Variable In Keyword Not Available In Test + Check Test Case ${TESTNAME} + +Set Local Variable In Keyword Not Available In Another Keyword + Check Test Case ${TESTNAME} + +Setting Local Variable In Test Not Available In Keyword + Check Test Case ${TESTNAME} + Set Test Variable - Scalars Check Test Case ${TESTNAME} @@ -30,7 +56,7 @@ Set Test Variable Needing Escaping Set Test Variable Affect Subsequent Keywords ${tc} = Check Test Case ${TESTNAME} - Should Be Equal ${tc.kws[0].doc} Makes a variable available everywhere within the scope of the current test. + Should Be Equal ${tc[0].doc} Makes the variable available everywhere within the scope of the current test. Set Test Variable In User Keyword Check Test Case ${TESTNAME} @@ -41,9 +67,18 @@ Set Test Variable Not Affecting Other Tests Test Variables Set In One Suite Are Not Available In Another Check Test Case ${TESTNAME} +Test variables set on suite level is not seen in tests + Check Test Case ${TESTNAME} + +Test variable set on suite level does not hide existing suite variable + Check Test Case ${TESTNAME} + +Test variable set on suite level can be overridden as suite variable + Check Test Case ${TESTNAME} + Set Task Variable as alias for Set Test Variable ${tc} = Check Test Case ${TESTNAME} - Should Be Equal ${tc.kws[0].doc} Makes a variable available everywhere within the scope of the current task. + Should Be Equal ${tc[0].doc} Makes the variable available everywhere within the scope of the current task. Set Suite Variable Check Test Case ${TESTNAME} 1 @@ -118,7 +153,7 @@ Mutating dict variable set using `Set Test/Suite/Global Variable` keywords Check Test Case ${TEST NAME} 2 Check Test Case ${TEST NAME} 3 -Using @{EMPTY} with `Set Test/Suite/Global Variable` keywords +Using \@{EMPTY} with `Set Test/Suite/Global Variable` keywords Check Test Case ${TEST NAME} Check Test Case ${TEST NAME} 2 @@ -145,7 +180,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/builtin/should_be_equal.robot b/atest/robot/standard_libraries/builtin/should_be_equal.robot index 7a802e241c6..e7b43a6fd3f 100644 --- a/atest/robot/standard_libraries/builtin/should_be_equal.robot +++ b/atest/robot/standard_libraries/builtin/should_be_equal.robot @@ -5,24 +5,54 @@ Resource builtin_resource.robot *** Test Cases *** Basics ${tc}= Check test case ${TESTNAME} - Verify argument type message ${tc.kws[0].msgs[0]} unicode unicode - Verify argument type message ${tc.kws[1].msgs[0]} unicode unicode - Verify argument type message ${tc.kws[2].msgs[0]} float int - Verify argument type message ${tc.kws[3].msgs[0]} bytes bytes - Verify argument type message ${tc.kws[4].msgs[0]} unicode unicode + Verify argument type message ${tc[0, 0]} + Verify argument type message ${tc[1, 0]} + Verify argument type message ${tc[2, 0]} float int + Verify argument type message ${tc[3, 0]} bytes bytes + Verify argument type message ${tc[4, 0]} Case-insensitive Check Test Case ${TESTNAME} +Without leading spaces + Check Test Case ${TESTNAME} + +Without trailing spaces + Check Test Case ${TESTNAME} + +Without leading and trailing spaces + Check Test Case ${TESTNAME} + +Do not collapse spaces + Check Test Case ${TESTNAME} + +Collapse spaces + Check Test Case ${TESTNAME} + +Normalization with bytes + Check Test Case ${TESTNAME} + +Auto conversion with bytes + Check Test Case ${TESTNAME} + Fails with values Check test case ${TESTNAME} Fails without values Check test case ${TESTNAME} +NO VALUES is deprecated + ${tc} = Check test case ${TESTNAME} + Check Log Message ${tc[0, 1]} Using 'NO VALUES' for disabling the 'values' argument is deprecated. Use 'values=False' instead. WARN + Check Log Message ${tc[1, 1]} Using 'no values' for disabling the 'values' argument is deprecated. Use 'values=False' instead. WARN + Multiline comparison uses diff ${tc} = Check test case ${TESTNAME} - Check Log Message ${tc.kws[0].msgs[1]} foo\nbar\ndar\n\n!=\n\nfoo\nbar\ngar\n\ndar + Check Log Message ${tc[0, 1]} foo\nbar\ndar\n\n!=\n\nfoo\nbar\ngar\n\ndar + +Multiline comparison with custom message + ${tc} = Check test case ${TESTNAME} + Check Log Message ${tc[0, 1]} foo\nbar\ndar\n\n!=\n\nfoo\nbar\ngar\n\ndar Multiline comparison requires both multiline Check test case ${TESTNAME} @@ -33,34 +63,23 @@ Multiline comparison without including values formatter=repr Check test case ${TESTNAME} -formatter=repr/ascii with non-ASCII characters on Python 2 - [Tags] require-py2 - Check test case ${TESTNAME} - -formatter=repr/ascii with non-ASCII characters on Python 3 - [Tags] require-py3 +formatter=repr/ascii with non-ASCII characters Check test case ${TESTNAME} formatter=repr with multiline ${tc} = Check test case ${TESTNAME} - Check Log Message ${tc.kws[0].msgs[1]} foo\nbar\ndar\n\n!=\n\nfoo\nbar\ngar\n\ndar + Check Log Message ${tc[0, 1]} foo\nbar\ndar\n\n!=\n\nfoo\nbar\ngar\n\ndar formatter=repr with multiline and different line endings ${tc} = Check test case ${TESTNAME} - Check Log Message ${tc.kws[0].msgs[1]} 1\n2\n3\n\n!=\n\n1\n2\n3 - Check Log Message ${tc.kws[1].msgs[1]} 1\n2\n3\n\n!=\n\n1\n2\n3 - -formatter=repr/ascii with multiline and non-ASCII characters on Python 2 - [Tags] require-py2 - ${tc} = Check test case ${TESTNAME} - Check Log Message ${tc.kws[0].msgs[1]} Å\nÄ\n\Ö\n\n!=\n\nÅ\nÄ\n\Ö - Check Log Message ${tc.kws[1].msgs[1]} Å\nÄ\n\Ö\n\n!=\n\nÅ\nÄ\n\Ö + Check Log Message ${tc[0, 1]} 1\n2\n3\n\n!=\n\n1\n2\n3 + Check Log Message ${tc[1, 1]} 1\n2\n3\n\n!=\n\n1\n2\n3 -formatter=repr/ascii with multiline and non-ASCII characters on Python 3 - [Tags] require-py3 +formatter=repr/ascii with multiline and non-ASCII characters ${tc} = Check test case ${TESTNAME} - Check Log Message ${tc.kws[0].msgs[1]} Å\nÄ\n\Ö\n\n!=\n\nÅ\nÄ\n\Ö - Check Log Message ${tc.kws[1].msgs[1]} Å\nÄ\n\Ö\n\n!=\n\nÅ\nÄ\n\Ö + Check Log Message ${tc[0, 1]} Å\nÄ\n\Ö\n\n!=\n\nÅ\nA\u0308\n\Ö + Check Log Message ${tc[1, 1]} Å\nÄ\n\Ö\n\n!=\n\nÅ\nA\u0308\n\Ö + Check Log Message ${tc[2, 1]} Å\nÄ\n\Ö\n\n!=\n\nÅ\nA\u0308\n\Ö Invalid formatter Check test case ${TESTNAME} @@ -73,28 +92,60 @@ Dictionaries of different type with same items pass Bytes containing non-ascii characters ${tc}= Check test case ${TESTNAME} - Verify argument type message ${tc.kws[0].msgs[0]} bytes bytes - Verify argument type message ${tc.kws[1].msgs[0]} bytes bytes + Verify argument type message ${tc[0, 0]} bytes bytes + Verify argument type message ${tc[1, 0]} bytes bytes Unicode and bytes with non-ascii characters ${tc}= Check test case ${TESTNAME} - Verify argument type message ${tc.kws[0].msgs[0]} bytes unicode + Verify argument type message ${tc[0, 0]} bytes str Types info is added if string representations are same ${tc}= Check test case ${TESTNAME} - Verify argument type message ${tc.kws[0].msgs[0]} unicode int + Verify argument type message ${tc[0, 0]} str int Should Not Be Equal ${tc}= Check test case ${TESTNAME} - Verify argument type message ${tc.kws[0].msgs[0]} unicode unicode - Verify argument type message ${tc.kws[1].msgs[0]} unicode int - Verify argument type message ${tc.kws[2].msgs[0]} unicode unicode + Verify argument type message ${tc[0, 0]} str str + Verify argument type message ${tc[1, 0]} str int + Verify argument type message ${tc[2, 0]} str str + +Should Not Be Equal with custom message + Check Test Case ${TESTNAME} + +Should Not Be Equal with custom message without values + Check Test Case ${TESTNAME} + +Should Not Be Equal with deprecated NO VALUES + ${tc} = Check test case ${TESTNAME} + Check Log Message ${tc[0, 1]} Using 'no values' for disabling the 'values' argument is deprecated. Use 'values=False' instead. WARN + Check Log Message ${tc[1, 1]} Using 'NO VALUES' for disabling the 'values' argument is deprecated. Use 'values=False' instead. WARN Should Not Be Equal case-insensitive Check Test Case ${TESTNAME} +Should Not Be Equal without leading spaces + Check Test Case ${TESTNAME} + +Should Not Be Equal without trailing spaces + Check Test Case ${TESTNAME} + +Should Not Be Equal without leading and trailing spaces + Check Test Case ${TESTNAME} + +Should Not Be Equal and do not collapse spaces + Check Test Case ${TESTNAME} + +Should Not Be Equal and collapse spaces + Check Test Case ${TESTNAME} + +Should Not Be Equal with bytes normalization + Check Test Case ${TESTNAME} + +Should Not Be Equal with bytes auto conversion + Check Test Case ${TESTNAME} + Should Not Be Equal with bytes containing non-ascii characters ${tc}= Check test case ${TESTNAME} - Verify argument type message ${tc.kws[0].msgs[0]} bytes bytes - Verify argument type message ${tc.kws[1].msgs[0]} bytes unicode - Verify argument type message ${tc.kws[2].msgs[0]} bytes bytes + Verify argument type message ${tc[0, 0]} bytes bytes + Verify argument type message ${tc[1, 0]} bytes str + Verify argument type message ${tc[2, 0]} bytes bytes diff --git a/atest/robot/standard_libraries/builtin/should_be_equal_as_xxx.robot b/atest/robot/standard_libraries/builtin/should_be_equal_as_xxx.robot index 87575e9eee8..c571788cffc 100644 --- a/atest/robot/standard_libraries/builtin/should_be_equal_as_xxx.robot +++ b/atest/robot/standard_libraries/builtin/should_be_equal_as_xxx.robot @@ -5,35 +5,35 @@ Resource builtin_resource.robot *** Test Cases *** Should Be Equal As Integers ${tc}= Check test case ${TESTNAME} - Verify argument type message ${tc.kws[0].msgs[0]} unicode unicode + Verify argument type message ${tc[0, 0]} Should Be Equal As Integers with base Check test case ${TESTNAME} Should Not Be Equal As Integers ${tc}= Check test case ${TESTNAME} - Verify argument type message ${tc.kws[0].msgs[0]} unicode unicode + Verify argument type message ${tc[0, 0]} Should Not Be Equal As Integers with base Check test case ${TESTNAME} Should Be Equal As Numbers ${tc}= Check test case ${TESTNAME} - Verify argument type message ${tc.kws[0].msgs[0]} unicode unicode + Verify argument type message ${tc[0, 0]} Should Be Equal As Numbers with precision Check test case ${TESTNAME} Should Not Be Equal As Numbers ${tc}= Check test case ${TESTNAME} - Verify argument type message ${tc.kws[0].msgs[0]} unicode unicode + Verify argument type message ${tc[0, 0]} Should Not Be Equal As Numbers with precision Check test case ${TESTNAME} Should Be Equal As Strings ${tc}= Check test case ${TESTNAME} - Verify argument type message ${tc.kws[0].msgs[0]} int unicode + Verify argument type message ${tc[0, 0]} int Should Be Equal As Strings does NFC normalization Check test case ${TESTNAME} @@ -41,18 +41,51 @@ Should Be Equal As Strings does NFC normalization Should Be Equal As Strings case-insensitive Check test case ${TESTNAME} +Should Be Equal As Strings without leading spaces + Check test case ${TESTNAME} + +Should Be Equal As Strings without trailing spaces + Check test case ${TESTNAME} + +Should Be Equal As Strings without leading and trailing spaces + Check test case ${TESTNAME} + +Should Be Equal As Strings and do not collapse spaces + Check test case ${TESTNAME} + +Should Be Equal As Strings and collapse spaces + Check test case ${TESTNAME} + Should Be Equal As Strings repr Check test case ${TESTNAME} Should Be Equal As Strings multiline Check test case ${TESTNAME} +Should Be Equal As Strings multiline with custom message + Check test case ${TESTNAME} + Should Be Equal As Strings repr multiline Check test case ${TESTNAME} Should Not Be Equal As Strings ${tc}= Check test case ${TESTNAME} - Verify argument type message ${tc.kws[0].msgs[0]} unicode float + Verify argument type message ${tc[0, 0]} str float Should Not Be Equal As Strings case-insensitive Check test case ${TESTNAME} + +Should Not Be Equal As Strings without leading spaces + Check test case ${TESTNAME} + +Should Not Be Equal As Strings without trailing spaces + Check test case ${TESTNAME} + +Should Not Be Equal As Strings without leading and trailing spaces + Check test case ${TESTNAME} + +Should Not Be Equal As Strings and do not collapse spaces + Check test case ${TESTNAME} + +Should Not Be Equal As Strings and collapse spaces + Check test case ${TESTNAME} diff --git a/atest/robot/standard_libraries/builtin/should_be_equal_type_conversion.robot b/atest/robot/standard_libraries/builtin/should_be_equal_type_conversion.robot new file mode 100644 index 00000000000..752d7ad340b --- /dev/null +++ b/atest/robot/standard_libraries/builtin/should_be_equal_type_conversion.robot @@ -0,0 +1,37 @@ +*** Settings *** +Suite Setup Run Tests ${EMPTY} standard_libraries/builtin/should_be_equal_type_conversion.robot +Resource atest_resource.robot + +*** Test Cases *** +Convert second argument using `type` + Check Test Case ${TESTNAME} + +Automatic `type` + Check Test Case ${TESTNAME} + +Automatic `type` doesn't handle nested types + Check Test Case ${TESTNAME} + +First argument must match `type` + Check Test Case ${TESTNAME} + +Conversion fails with `type` + Check Test Case ${TESTNAME} + +Invalid type with `type` + Check Test Case ${TESTNAME} + +Convert both arguments using `types` + Check Test Case ${TESTNAME} + +Conversion fails with `types` + Check Test Case ${TESTNAME} + +Invalid type with `types` + Check Test Case ${TESTNAME} + +Cannot use both `type` and `types` + Check Test Case ${TESTNAME} + +Automatic type doesn't work with `types` + Check Test Case ${TESTNAME} diff --git a/atest/robot/standard_libraries/builtin/should_be_true.robot b/atest/robot/standard_libraries/builtin/should_be_true.robot index aac73ea3566..e870ab99437 100644 --- a/atest/robot/standard_libraries/builtin/should_be_true.robot +++ b/atest/robot/standard_libraries/builtin/should_be_true.robot @@ -21,8 +21,11 @@ Should Not Be True with message Should Not Be True with invalid expression Check test case ${TESTNAME} -Should (Not) Be True is evaluated with os- and sys-modules +Should (Not) Be True automatically imports modules Check test case ${TESTNAME} Should (Not) Be True is evaluated with robot's variables Check test case ${TESTNAME} + +Should (Not) Be True uses non-strings directly + Check test case ${TESTNAME} diff --git a/atest/robot/standard_libraries/builtin/should_contain.robot b/atest/robot/standard_libraries/builtin/should_contain.robot index 07e34a5a474..e5f5264e77d 100644 --- a/atest/robot/standard_libraries/builtin/should_contain.robot +++ b/atest/robot/standard_libraries/builtin/should_contain.robot @@ -12,6 +12,27 @@ Should Contain with non-strings Should Contain case-insensitive Check Test Case ${TESTNAME} +Should Contain without leading spaces + Check Test Case ${TESTNAME} + +Should Contain without trailing spaces + Check Test Case ${TESTNAME} + +Should Contain without leading and trailing spaces + Check Test Case ${TESTNAME} + +Should Contain and do not collapse spaces + Check Test Case ${TESTNAME} + +Should Contain and collapse spaces + Check Test Case ${TESTNAME} + +Should Contain with recursive normalization + Check Test Case ${TESTNAME} + +Should Contain with bytes auto conversion + Check Test Case ${TESTNAME} + Should Not Contain Check test case ${TESTNAME} @@ -20,3 +41,21 @@ Should Not Contain with non-strings Should Not Contain case-insensitive Check Test Case ${TESTNAME} + +Should Not Contain without leading spaces + Check Test Case ${TESTNAME} + +Should Not Contain without trailing spaces + Check Test Case ${TESTNAME} + +Should Not Contain without leading and trailing spaces + Check Test Case ${TESTNAME} + +Should Not Contain and do not collapse spaces + Check Test Case ${TESTNAME} + +Should Not Contain and collapse spaces + Check Test Case ${TESTNAME} + +Should Not Contain with bytes auto conversion + Check Test Case ${TESTNAME} diff --git a/atest/robot/standard_libraries/builtin/should_contain_any.robot b/atest/robot/standard_libraries/builtin/should_contain_any.robot index 7e53fca6324..4eeabe64426 100644 --- a/atest/robot/standard_libraries/builtin/should_contain_any.robot +++ b/atest/robot/standard_libraries/builtin/should_contain_any.robot @@ -9,10 +9,31 @@ Should Contain Any Should Contain Any failing Check test case ${TESTNAME} -Should Contain Any without items fails +Should Contain Any case-insensitive Check test case ${TESTNAME} -Should Contain Any case-insensitive +Should Contain Any without leading spaces + Check test case ${TESTNAME} + +Should Contain Any without trailing spaces + Check test case ${TESTNAME} + +Should Contain Any without leading and trailing spaces + Check test case ${TESTNAME} + +Should Contain Any and do not collapse spaces + Check test case ${TESTNAME} + +Should Contain Any and collapse spaces + Check test case ${TESTNAME} + +Should Contain Any with recursive normalization + Check test case ${TESTNAME} + +Should Contain Any with bytes auto conversion + Check test case ${TESTNAME} + +Should Contain Any without items fails Check test case ${TESTNAME} Should Contain Any with invalid configuration @@ -24,10 +45,28 @@ Should Not Contain Any Should Not Contain Any failing Check test case ${TESTNAME} -Should Not Contain Any without items fails +Should Not Contain Any case-insensitive Check test case ${TESTNAME} -Should Not Contain Any case-insensitive +Should Not Contain Any without leading spaces + Check test case ${TESTNAME} + +Should Not Contain Any without trailing spaces + Check test case ${TESTNAME} + +Should Not Contain Any without leading and trailing spaces + Check test case ${TESTNAME} + +Should Not Contain Any and do not collapse spaces + Check test case ${TESTNAME} + +Should Not Contain Any and collapse spaces + Check test case ${TESTNAME} + +Should Not Contain Any with bytes auto conversion + Check test case ${TESTNAME} + +Should Not Contain Any without items fails Check test case ${TESTNAME} Should Not Contain Any with invalid configuration diff --git a/atest/robot/standard_libraries/builtin/should_match.robot b/atest/robot/standard_libraries/builtin/should_match.robot index fe42f3a12e1..d0a668ad9ee 100644 --- a/atest/robot/standard_libraries/builtin/should_match.robot +++ b/atest/robot/standard_libraries/builtin/should_match.robot @@ -12,12 +12,7 @@ Should Match with extra trailing newline Should Match case-insensitive Check test case ${TESTNAME} -Should Match with bytes containing non-ascii characters - [Tags] require-py2 no-ipy - Check test case ${TESTNAME} - -Should Match does not work with bytes on Python 3 - [Tags] require-py3 + Should Match with bytes Check test case ${TESTNAME} Should Not Match @@ -26,8 +21,7 @@ Should Not Match Should Not Match case-insensitive Check test case ${TESTNAME} -Should Not Match with bytes containing non-ascii characters - [Tags] require-py2 no-ipy + Should Not Match with bytes Check test case ${TESTNAME} Should Match Regexp @@ -36,8 +30,17 @@ Should Match Regexp Should Match Regexp returns match and groups Check test case ${TESTNAME} -Should Match Regexp with bytes containing non-ascii characters +Should Match Regexp with bytes + Check test case ${TESTNAME} + +Should Match Regexp with bytes autoconversion Check test case ${TESTNAME} Should Not Match Regexp Check test case ${TESTNAME} + +Should Not Match Regexp with bytes + Check test case ${TESTNAME} + +Should Not Match Regexp with bytes auto conversion + Check test case ${TESTNAME} diff --git a/atest/robot/standard_libraries/builtin/should_xxx_with.robot b/atest/robot/standard_libraries/builtin/should_xxx_with.robot index 704cf5739c3..5d4e022a208 100644 --- a/atest/robot/standard_libraries/builtin/should_xxx_with.robot +++ b/atest/robot/standard_libraries/builtin/should_xxx_with.robot @@ -6,25 +6,91 @@ Resource builtin_resource.robot Should Start With Check test case ${TESTNAME} +Should Start With without values + Check test case ${TESTNAME} + Should Start With case-insensitive Check test case ${TESTNAME} -Should Start With without values +Should Start With without leading spaces + Check test case ${TESTNAME} + +Should Start With without trailing spaces + Check test case ${TESTNAME} + +Should Start With without leading and trailing spaces + Check test case ${TESTNAME} + +Should Start With and do not collapse spaces + Check test case ${TESTNAME} + +Should Start With and collapse spaces + Check test case ${TESTNAME} + +Should Start With with bytes normalization + Check test case ${TESTNAME} + +Should Start With with bytes auto conversion Check test case ${TESTNAME} Should Not Start With Check test case ${TESTNAME} +Should Not Start With without values + Check test case ${TESTNAME} + Should Not Start With case-insensitive Check test case ${TESTNAME} +Should Not Start With without leading spaces + Check test case ${TESTNAME} + +Should Not Start With without trailing spaces + Check test case ${TESTNAME} + +Should Not Start With without leading and trailing spaces + Check test case ${TESTNAME} + +Should Not Start With and do not collapse spaces + Check test case ${TESTNAME} + +Should Not Start With and collapse spaces + Check test case ${TESTNAME} + +Should Not Start With with bytes normalization + Check test case ${TESTNAME} + +Should Not Start With with bytes conversion + Check test case ${TESTNAME} + Should End With Check test case ${TESTNAME} +Should End With without values + Check test case ${TESTNAME} + Should End With case-insensitive Check test case ${TESTNAME} -Should End With without values +Should End With without leading spaces + Check test case ${TESTNAME} + +Should End With without trailing spaces + Check test case ${TESTNAME} + +Should End With without leading and trailing spaces + Check test case ${TESTNAME} + +Should End With and do not collapse spaces + Check test case ${TESTNAME} + +Should End With and collapse spaces + Check test case ${TESTNAME} + +Should End With with bytes normalization + Check test case ${TESTNAME} + +Should End With with bytes auto conversion Check test case ${TESTNAME} Should Not End With @@ -32,3 +98,31 @@ Should Not End With Should Not End With case-insensitive Check test case ${TESTNAME} + +Should Not End With without leading spaces + Check test case ${TESTNAME} + +Should Not End With without trailing spaces + Check test case ${TESTNAME} + +Should Not End With without leading and trailing spaces + Check test case ${TESTNAME} + +Should Not End With and do not collapse spaces + Check test case ${TESTNAME} + +Should Not End With and collapse spaces + Check test case ${TESTNAME} + +Should Not End With with bytes normalization + Check test case ${TESTNAME} + +Should Not End With with bytes conversion + Check test case ${TESTNAME} + +NO VALUES is deprecated + ${tc} = Check test case ${TESTNAME} + Check Log Message ${tc[0, 0]} Using 'NO VALUES' for disabling the 'values' argument is deprecated. Use 'values=False' instead. WARN + Check Log Message ${tc[1, 0]} Using 'no values' for disabling the 'values' argument is deprecated. Use 'values=False' instead. WARN + Check Log Message ${tc[2, 0]} Using 'No values' for disabling the 'values' argument is deprecated. Use 'values=False' instead. WARN + Check Log Message ${tc[3, 0]} Using 'No Values' for disabling the 'values' argument is deprecated. Use 'values=False' instead. WARN diff --git a/atest/robot/standard_libraries/builtin/sleep.robot b/atest/robot/standard_libraries/builtin/sleep.robot index fbfc2b8a254..454392bdafa 100644 --- a/atest/robot/standard_libraries/builtin/sleep.robot +++ b/atest/robot/standard_libraries/builtin/sleep.robot @@ -5,24 +5,23 @@ 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[1, 0]} Slept 1 second 111 milliseconds. + Check Log Message ${tc[3, 0]} Slept 1 second 234 milliseconds. + Check Log Message ${tc[5, 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[1, 0]} Slept 0 seconds. + Check Log Message ${tc[2, 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[1]} No good reason + Check Log Message ${tc[0, 0]} Slept 42 milliseconds. + Check Log Message ${tc[0, 1]} No good reason Invalid Time Does Not Cause Uncatchable Error Check Test Case ${TESTNAME} 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/tags.robot b/atest/robot/standard_libraries/builtin/tags.robot index 2bdbc966eec..0a4bf8eca0e 100644 --- a/atest/robot/standard_libraries/builtin/tags.robot +++ b/atest/robot/standard_libraries/builtin/tags.robot @@ -1,66 +1,61 @@ *** Settings *** -Suite Setup Run Tests --critical *remove* standard_libraries/builtin/tags -Resource atest_resource.robot +Suite Setup Run Tests ${EMPTY} standard_libraries/builtin/tags +Resource atest_resource.robot *** Variables *** -@{SUITE_TAGS} default force force-init set set-init +@{SUITE_TAGS} default force force-init set set-init *** Test Cases *** Set And Remove Tags In Suite Level - Should Have Only Suite Tags Set And Remove Tags In Suite Level + Should Have Only Suite Tags Set And Remove Tags In Suite Level Set No Tags - Should Have Only Suite Tags Set No Tags + Should Have Only Suite Tags Set No Tags Set One Tag - ${tc} = Tags Should Have Been Added Set One Tag one - Check Log Message ${tc.kws[0].msgs[0]} Set tag 'one'. + ${tc} = Tags Should Have Been Added Set One Tag one + Check Log Message ${tc[0, 0]} Set tag 'one'. Set Multiple Tags - ${tc} = Tags Should Have Been Added Set Multiple Tags 1 2 3 HELLO Some spaces here - Check Log Message ${tc.kws[0].msgs[0]} Set tags '1', '2' and '3'. - Check Log Message ${tc.kws[1].msgs[0]} Set tags 'HELLO', '' and 'Some spaces here'. + ${tc} = Tags Should Have Been Added Set Multiple Tags 1 2 3 HELLO Some spaces here + Check Log Message ${tc[0, 0]} Set tags '1', '2' and '3'. + Check Log Message ${tc[1, 0]} Set tags 'HELLO', '' and 'Some spaces here'. Tags Set In One Test Are Not Visible To Others - Should Have Only Suite Tags Tags Set In One Test Are Not Visible To Others + Should Have Only Suite Tags Tags Set In One Test Are Not Visible To Others Remove No Tags - Should Have Only Suite Tags Remove No Tags + Should Have Only Suite Tags Remove No Tags Remove One Tag - ${tc} = Tags Should Have Been Removed Remove One Tag force - Check Log Message ${tc.kws[0].msgs[0]} Removed tag 'force'. + ${tc} = Tags Should Have Been Removed Remove One Tag force + Check Log Message ${tc[0, 0]} Removed tag 'force'. Remove Non-Existing Tag - Should Have Only Suite Tags Remove Non-Existing Tag + Should Have Only Suite Tags Remove Non-Existing Tag Remove Multiple Tags - ${tc} = Tags Should Have Been Removed Remove Multiple Tags default set set-init - Check Log Message ${tc.kws[0].msgs[0]} Removed tags 'default', 'SET' and 'non-existing'. - Check Log Message ${tc.kws[1].msgs[0]} Removed tags '' and 'set-init'. + ${tc} = Tags Should Have Been Removed Remove Multiple Tags default set set-init + Check Log Message ${tc[0, 0]} Removed tags 'default', 'SET' and 'non-existing'. + Check Log Message ${tc[1, 0]} Removed tags '' and 'set-init'. Remove Tags With Pattern - Check Test Tags Remove Tags With Pattern + Check Test Tags Remove Tags With Pattern Tags Removed In One Test Are Not Removed From Others - Should Have Only Suite Tags Tags Removed In One Test Are Not Removed From Others + Should Have Only Suite Tags Tags Removed In One Test Are Not Removed From Others Set And Remove Tags In A User Keyword - Check Test Tags Set And Remove Tags In A User Keyword tc uk uk2 + Check Test Tags Set And Remove Tags In A User Keyword tc uk uk2 Set Tags In Test Setup - Check Test Tags Set Tags In Test Setup set-init setup tag + Check Test Tags Set Tags In Test Setup set-init setup tag Set Tags In Test Teardown - Check Test Tags Set Tags In Test Teardown set-init teardown + Check Test Tags Set Tags In Test Teardown set-init teardown Using Set And Remove Tags In Suite Teardown Fails - Should Be Equal ${SUITE.suites[1].message} Suite teardown failed:\n'Set Tags' cannot be used in suite teardown. - -Set and Removed Tags Should Affect Criticality - ${tags} = Get Total Stat Nodes - Tag Statistics Should Be ${tags[0]} 0 0 - Tag Statistics Should Be ${tags[1]} 12 4 + Should Be Equal ${SUITE.suites[1].message} Suite teardown failed:\n'Set Tags' cannot be used in suite teardown. Modifying ${TEST TAGS} after setting them has no affect on tags test has Check Test Tags ${TEST NAME} force-init set-init new @@ -70,20 +65,19 @@ Modifying ${TEST TAGS} after removing them has no affect on tags test has *** Keywords *** Should Have Only Suite Tags - [Arguments] ${testname} - Check Test Tags ${testname} @{SUITE_TAGS} + [Arguments] ${testname} + Check Test Tags ${testname} @{SUITE_TAGS} Tags Should Have Been Added - [Arguments] ${testname} @{added} - @{tags} = Create List @{SUITE_TAGS} @{added} - Sort List ${tags} - ${tc} = Check Test Tags ${testname} @{tags} - [Return] ${tc} + [Arguments] ${testname} @{added} + @{tags} = Create List @{SUITE_TAGS} @{added} + Sort List ${tags} + ${tc} = Check Test Tags ${testname} @{tags} + 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} - + [Arguments] ${testname} @{removed} + @{tags} = Copy List ${SUITE_TAGS} + Remove Values From List ${tags} @{removed} + ${tc} = Check Test Tags ${testname} @{tags} + RETURN ${tc} 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..3eaeb68e554 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 @@ -1,38 +1,71 @@ *** Settings *** -Documentation These tests mainly verify that using BuiltIn externally does not cause importing problems as in -... https://github.com/robotframework/robotframework/issues/654. -... There are separate tests for creating and registering Run Keyword variants. -Suite Setup Run Tests --listener ${CURDIR}/listener_using_builtin.py standard_libraries/builtin/used_in_custom_libs_and_listeners.robot +Suite Setup Run Tests +... --listener ${CURDIR}/listener_using_builtin.py +... standard_libraries/builtin/used_in_custom_libs_and_listeners.robot 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[1]} Hello, debug world! DEBUG + Check Log Message ${tc[0, 0]} Log level changed from NONE to DEBUG. DEBUG + Check Log Message ${tc[0, 1]} Hello, debug world! DEBUG + Length should be ${tc[0].messages} 2 + +Named argument syntax + Check Test Case ${TESTNAME} Listener Using BuiltIn Check Test Case ${TESTNAME} -Use 'Run Keyword' with non-Unicode values +Use 'Run Keyword' with non-string values ${tc} = Check Test Case ${TESTNAME} - Check Log Message ${tc.kws[0].kws[0].msgs[0]} 42 - Check Log Message ${tc.kws[0].kws[1].msgs[0]} \\xff + Check Log Message ${tc[0, 0, 0]} 42 + Check Log Message ${tc[0, 1, 0]} \xff + +Use dictionary returned by 'Get Variables' with non-string keys + Check Test Case ${TESTNAME} 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[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 - Check Log Message ${tc.kws[3].kws[1].msgs[0]} Test timeout 1 day active. * seconds left. level=DEBUG pattern=True - Check Log Message ${tc.kws[3].kws[1].msgs[1]} \\xff + Check Log Message ${tc[0, 0]} Log level changed from NONE to DEBUG. DEBUG + Check Log Message ${tc[0, 1]} Hello, debug world! DEBUG + Length should be ${tc[0].messages} 2 + Check Log Message ${tc[3, 0]} Test timeout 1 day active. * seconds left. level=DEBUG pattern=True + Check Log Message ${tc[3, 1, 0]} Test timeout 1 day active. * seconds left. level=DEBUG pattern=True + Check Log Message ${tc[3, 1, 1]} 42 + Check Log Message ${tc[3, 2, 0]} Test timeout 1 day active. * seconds left. level=DEBUG pattern=True + Check Log Message ${tc[3, 2, 1]} \xff User keyword used via 'Run Keyword' ${tc} = Check Test Case ${TESTNAME} - Check Log Message ${tc.kws[0].kws[0].kws[0].msgs[0]} This is x-911-zzz + Check Log Message ${tc[0, 0]} Before + Check Log Message ${tc[0, 1, 0, 0]} This is x-911-zzz + Check Log Message ${tc[0, 2]} After User keyword used via 'Run Keyword' with timeout and trace level ${tc} = Check Test Case ${TESTNAME} - Check Log Message ${tc.kws[0].kws[0].kws[0].msgs[1]} This is x-911-zzz + Check Log Message ${tc[0, 0]} Arguments: [ \ ] level=TRACE + Check Log Message ${tc[0, 1]} Test timeout 1 day active. * seconds left. level=DEBUG pattern=True + Check Log Message ${tc[0, 2]} Before + Check Log Message ${tc[0, 3, 0]} Arguments: [ \${x}='This is x' | \${y}=911 | \${z}='zzz' ] level=TRACE + Check Log Message ${tc[0, 3, 1, 0]} Arguments: [ 'This is x-911-zzz' ] level=TRACE + Check Log Message ${tc[0, 3, 1, 1]} Keyword timeout 1 hour active. * seconds left. level=DEBUG pattern=True + Check Log Message ${tc[0, 3, 1, 2]} This is x-911-zzz + Check Log Message ${tc[0, 3, 1, 3]} Return: None level=TRACE + Check Log Message ${tc[0, 3, 2]} Return: None level=TRACE + Check Log Message ${tc[0, 4]} After + Check Log Message ${tc[0, 5]} Return: None level=TRACE + +Recursive 'Run Keyword' usage + ${tc} = Check Test Case ${TESTNAME} + Check Log Message ${tc[0, 0, 0]} 1 + Check Log Message ${tc[0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0]} 10 + +Recursive 'Run Keyword' usage with timeout + Check Test Case ${TESTNAME} + +Timeout when running keyword that logs huge message + Check Test Case ${TESTNAME} + +Timeout in parent keyword after running keyword + Check Test Case ${TESTNAME} 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 4593b353593..da2e5d8b791 100644 --- a/atest/robot/standard_libraries/builtin/wait_until_keyword_succeeds.robot +++ b/atest/robot/standard_libraries/builtin/wait_until_keyword_succeeds.robot @@ -6,20 +6,20 @@ Resource atest_resource.robot Fail Because Timeout exceeded ${tc} = Check Test Case ${TESTNAME} # Cannot test exactly how many times kw is run because it depends on interpreter speed. - Check Log Message ${tc.kws[0].kws[0].msgs[0]} Still 2 times to fail! FAIL - Should Be True len($tc.kws[0].kws) < 4 + Check Log Message ${tc[0, 0, 0]} Still 2 times to fail! FAIL + Should Be True len($tc[0].non_messages) < 4 Pass with first Try ${tc} = Check Test Case ${TESTNAME} - Check Log Message ${tc.kws[0].kws[0].msgs[0]} Used to test that variable name, not value, is shown in arguments - Length Should Be ${tc.kws[0].kws} 1 + Check Log Message ${tc[0, 0, 0]} Used to test that variable name, not value, is shown in arguments + Length Should Be ${tc[0].body} 1 Pass With Some Medium Try ${tc} = Check Test Case ${TESTNAME} - Check Log Message ${tc.kws[0].kws[0].msgs[0]} Still 2 times to fail! FAIL - Check Log Message ${tc.kws[0].kws[1].msgs[0]} Still 1 times to fail! FAIL - Check Log Message ${tc.kws[0].kws[2].msgs[0]} Still 0 times to fail! FAIL - Length Should Be ${tc.kws[0].kws} 4 + Check Log Message ${tc[0, 0, 0]} Still 2 times to fail! FAIL + Check Log Message ${tc[0, 1, 0]} Still 1 times to fail! FAIL + Check Log Message ${tc[0, 2, 0]} Still 0 times to fail! FAIL + Length Should Be ${tc[0].body} 4 Pass With Last Possible Try Check Test Case ${TESTNAME} @@ -69,28 +69,57 @@ 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 - Check Log Message ${tc.kws[0].kws[2].kws[0].msgs[0]} Variable '\${nonexisting}' not found. FAIL - Length Should Be ${tc.kws[0].kws} 3 + Check Log Message ${tc[0, 0, 0, 0]} Variable '\${nonexisting}' not found. FAIL + Check Log Message ${tc[0, 1, 0, 0]} Variable '\${nonexisting}' not found. FAIL + Check Log Message ${tc[0, 2, 0, 0]} Variable '\${nonexisting}' not found. FAIL + Length Should Be ${tc[0].non_messages} 3 Pass With Initially Nonexisting Variable Inside Wait Until Keyword Succeeds ${tc} = Check Test Case ${TESTNAME} - Check Log Message ${tc.kws[0].kws[0].kws[0].msgs[0]} Variable '\${created after accessing first time}' not found. FAIL - Check Log Message ${tc.kws[0].kws[1].kws[0].msgs[0]} created in keyword teardown - Length Should Be ${tc.kws[0].kws} 2 + Check Log Message ${tc[0, 0, 0, 0]} Variable '\${created after accessing first time}' not found. FAIL + Check Log Message ${tc[0, 1, 0, 0]} created in keyword teardown + Length Should Be ${tc[0].body} 2 Variable Values Should Not Be Visible In Keyword Arguments ${tc} = Check Test Case Pass With First Try - Check Keyword Data ${tc.kws[0].kws[0]} BuiltIn.Log args=\${HELLO} + Check Keyword Data ${tc[0, 0]} BuiltIn.Log args=\${HELLO} + +Strict retry interval + ${tc} = Check Test Case ${TESTNAME} + Length Should Be ${tc[0].body} 4 + Elapsed Time Should Be Valid ${tc[0].elapsed_time} minimum=0.3 maximum=0.9 + +Fail with strict retry interval + ${tc} = Check Test Case ${TESTNAME} + Length Should Be ${tc[0].non_messages} 3 + Elapsed Time Should Be Valid ${tc[0].elapsed_time} minimum=0.2 maximum=0.6 + +Strict retry interval violation + ${tc} = Check Test Case ${TESTNAME} + Length Should Be ${tc[0].non_messages} 4 + Elapsed Time Should Be Valid ${tc[0].elapsed_time} minimum=0.4 maximum=1.2 + FOR ${index} IN 1 3 5 7 + Check Log Message ${tc[0, ${index}]} + ... Keyword execution time ??? milliseconds is longer than retry interval 100 milliseconds. + ... WARN pattern=True + END + +Strict and invalid retry interval + Check Test Case ${TESTNAME} + +Keyword name as variable + Check Test Case ${TESTNAME} diff --git a/atest/robot/standard_libraries/collections/dictionaries_should_be_equal.robot b/atest/robot/standard_libraries/collections/dictionaries_should_be_equal.robot new file mode 100644 index 00000000000..6ac9f3cbbb9 --- /dev/null +++ b/atest/robot/standard_libraries/collections/dictionaries_should_be_equal.robot @@ -0,0 +1,77 @@ +*** Settings *** +Suite Setup Run Tests ${EMPTY} standard_libraries/collections/dictionaries_should_be_equal.robot +Resource atest_resource.robot + +*** Test Cases *** +Comparison with itself + Check Test Case ${TESTNAME} + +Keys in different order + Check Test Case ${TESTNAME} + +Different dictionary types + Check Test Case ${TESTNAME} + +First dictionary missing keys + Check Test Case ${TESTNAME} + +Second dictionary missing keys + Check Test Case ${TESTNAME} + +Both dictionaries missing keys + Check Test Case ${TESTNAME} + +Missing keys and custom error message + Check Test Case ${TESTNAME} + +Missing keys and custom error message with values + Check Test Case ${TESTNAME} + +Different values + Check Test Case ${TESTNAME} + +Different values and custom error message + Check Test Case ${TESTNAME} + +Different values and custom error message with values + Check Test Case ${TESTNAME} + +NO VALUES is deprecated + ${tc} = Check Test Case ${TEST NAME} + Check Log Message ${tc[0, 0]} Using 'No values' for disabling the 'values' argument is deprecated. Use 'values=False' instead. WARN + +`ignore_keys` + Check Test Case ${TESTNAME} + +`ignore_keys` with non-string keys + Check Test Case ${TESTNAME} + +`ignore_keys` recursive + Check Test Case ${TESTNAME} + +`ignore_keys` with missing keys + Check Test Case ${TESTNAME} + +`ignore_keys` with wrong values + Check Test Case ${TESTNAME} + +`ignore_keys` as string must be valid expression + Check Test Case ${TESTNAME} + +`ignore_keys` must be list + Check Test Case ${TESTNAME} + +`ignore_case` + Check Test Case ${TESTNAME} + +`ignore_case` with ´ignore_keys` + Check Test Case ${TESTNAME} + +`ignore_case` when normalized keys have conflict + Check Test Case ${TESTNAME} + +`ignore_value_order` set to True + Check Test Case ${TESTNAME} + +`ignore_value_order` set to False and dictionaries have lists in different order + Check Test Case ${TESTNAME} diff --git a/atest/robot/standard_libraries/collections/dictionary.robot b/atest/robot/standard_libraries/collections/dictionary.robot index f1c78d56523..4ada7baea98 100644 --- a/atest/robot/standard_libraries/collections/dictionary.robot +++ b/atest/robot/standard_libraries/collections/dictionary.robot @@ -4,7 +4,10 @@ Suite Setup Run Tests --loglevel debug standard_libraries/collection Resource atest_resource.robot *** Test Cases *** -Convert To Dictionary +Convert To Dictionary with mappings + Check Test Case ${TEST NAME} + +Convert To Dictionary with list of tuples Check Test Case ${TEST NAME} Set To Dictionary @@ -16,15 +19,24 @@ Set To Dictionary With wrong number of arguments Set To Dictionary With **kwargs Check Test Case ${TEST NAME} +Set To Dictionary with immutable + Check Test Case ${TEST NAME} + Remove From Dictionary ${tc} = Check Test Case ${TEST NAME} - Check Log Message ${tc.kws[0].msgs[0]} Removed item with key 'b' and value '2'. - Check Log Message ${tc.kws[0].msgs[1]} Key 'x' not found. - Check Log Message ${tc.kws[0].msgs[2]} Key '2' not found. + Check Log Message ${tc[0, 0]} Removed item with key 'b' and value '2'. + Check Log Message ${tc[0, 1]} Key 'x' not found. + Check Log Message ${tc[0, 2]} Key '2' not found. + +Remove From Dictionary with immutable + Check Test Case ${TEST NAME} Keep In Dictionary Check Test Case ${TEST NAME} +Keep In Dictionary with immutable + Check Test Case ${TEST NAME} + Copy Dictionary Check Test Case ${TEST NAME} @@ -59,93 +71,10 @@ Get From Dictionary Check Test Case ${TEST NAME} Get From Dictionary With Invalid Key - Check Test Case ${TEST NAME} - -Dictionary Should Contain Key - Check Test Case ${TEST NAME} - -Dictionary Should Contain Key With Missing Key - Check Test Case ${TEST NAME} - -Dictionary Should Contain Item - Check Test Case ${TEST NAME} - -Dictionary Should Contain Item With Missing Key - Check Test Case ${TEST NAME} - -Dictionary Should Contain Item With Wrong Value - Check Test Case ${TEST NAME} - -Dictionary Should Not Contain Key - Check Test Case ${TEST NAME} - -Dictionary Should Not Contain Key With Existing Key - Check Test Case ${TEST NAME} - -Dictionary Should (Not) Contain Key Does Not Require `has_key` - Check Test Case ${TEST NAME} - -Dictionary Should Contain Value - Check Test Case ${TEST NAME} - -Dictionary Should Contain Value With Missing Value - Check Test Case ${TEST NAME} - -Dictionary Should Not Contain Value - Check Test Case ${TEST NAME} - -Dictionary Should Not Contain Value With Existing Value - Check Test Case ${TEST NAME} - -Dictionaries Should Be Equal - Check Test Case ${TEST NAME} + Check Test Case ${TEST NAME} 1 + Check Test Case ${TEST NAME} 2 -Dictionaries Of Different Type Should Be Equal - Check Test Case ${TEST NAME} - -Dictionaries Should Equal With First Dictionary Missing Keys - Check Test Case ${TEST NAME} - -Dictionaries Should Equal With Second Dictionary Missing Keys - Check Test Case ${TEST NAME} - -Dictionaries Should Equal With Both Dictionaries Missing Keys - Check Test Case ${TEST NAME} - -Dictionaries Should Be Equal With Different Keys And Own Error Message - Check Test Case ${TEST NAME} - -Dictionaries Should Be Equal With Different Keys And Own And Default Error Messages - Check Test Case ${TEST NAME} - -Dictionaries Should Be Equal With Different Values - Check Test Case ${TEST NAME} - -Dictionaries Should Be Equal With Different Values And Own Error Message - Check Test Case ${TEST NAME} - -Dictionaries Should Be Equal With Different Values And Own And Default Error Messages - Check Test Case ${TEST NAME} - -Dictionary Should Contain Sub Dictionary - Check Test Case ${TEST NAME} - -Dictionary Should Contain Sub Dictionary With Missing Keys - Check Test Case ${TEST NAME} - -Dictionary Should Contain Sub Dictionary With Missing Keys And Own Error Message - Check Test Case ${TEST NAME} - -Dictionary Should Contain Sub Dictionary With Missing Keys And Own And Default Error Message - Check Test Case ${TEST NAME} - -Dictionary Should Contain Sub Dictionary With Different Value - Check Test Case ${TEST NAME} - -Dictionary Should Contain Sub Dictionary With Different Value And Own Error Message - Check Test Case ${TEST NAME} - -Dictionary Should Contain Sub Dictionary With Different Value And Own And Default Error Message +Get From Dictionary With Default Check Test Case ${TEST NAME} Log Dictionary With Different Log Levels @@ -155,17 +84,18 @@ Log Dictionary With Different Log Levels ... a: 1 ... b: 2 ... c: - Check Log Message ${tc.kws[0].msgs[0]} ${expected} INFO - Should Be Empty ${tc.kws[1].msgs} - Check Log Message ${tc.kws[2].msgs[0]} ${expected} WARN - Check Log Message ${tc.kws[3].msgs[0]} ${expected} DEBUG - Check Log Message ${tc.kws[4].msgs[0]} ${expected} INFO + Check Log Message ${tc[0, 0]} ${expected} INFO + Should Be Empty ${tc[1].body} + Check Log Message ${tc[2, 0]} ${expected} WARN + Check Log Message ${tc[3, 0]} ${expected} DEBUG + Check Log Message ${tc[4, 0]} ${expected} INFO Log Dictionary With Different Dictionaries ${tc} = Check Test Case ${TEST NAME} - Check Log Message ${tc.kws[0].msgs[0]} Dictionary is empty. - Check Log Message ${tc.kws[1].msgs[0]} Dictionary has one item:\na: 1 - Check Log Message ${tc.kws[3].msgs[0]} Dictionary size is 3 and it contains following items:\nTrue: xxx\nfoo: []\n(1, 2, 3): 3.14 + Check Log Message ${tc[0, 0]} Dictionary is empty. + Check Log Message ${tc[1, 0]} Dictionary has one item:\na: 1 + Check Log Message ${tc[3, 0]} Dictionary size is 3 and it contains following items:\nTrue: xxx\nfoo: []\n(1, 2, 3): 3.14 + Check Log Message ${tc[4, 0]} Dictionary size is 2 and it contains following items:\na: 1\nb: 2 Pop From Dictionary Without Default Check Test Case ${TEST NAME} @@ -173,5 +103,11 @@ Pop From Dictionary Without Default Pop From Dictionary With Default Check Test Case ${TEST NAME} +Pop From Dictionary with immutable + Check Test Case ${TEST NAME} + Check invalid dictionary argument errors Check Test Case ${TEST NAME} + +Bytes normalization + Check Test Case ${TEST NAME} diff --git a/atest/robot/standard_libraries/collections/dictionary_should_contain.robot b/atest/robot/standard_libraries/collections/dictionary_should_contain.robot new file mode 100644 index 00000000000..cab543351be --- /dev/null +++ b/atest/robot/standard_libraries/collections/dictionary_should_contain.robot @@ -0,0 +1,107 @@ +*** Settings *** +Suite Setup Run Tests ${EMPTY} standard_libraries/collections/dictionary_should_contain.robot +Resource atest_resource.robot + +*** Test Cases *** +Should contain key + Check Test Case ${TESTNAME} + +Should contain key with custom message + Check Test Case ${TESTNAME} + +Should contain key with `ignore_case` + Check Test Case ${TESTNAME} + +Should not contain key + Check Test Case ${TESTNAME} + +Should not contain key with custom message + Check Test Case ${TESTNAME} + +Should not contain key with `ignore_case` + Check Test Case ${TESTNAME} + +Should contain value + Check Test Case ${TESTNAME} + +Should contain value with custom message + Check Test Case ${TESTNAME} + +Should contain value with `ignore_case` + Check Test Case ${TESTNAME} + +Should not contain value + Check Test Case ${TESTNAME} + +Should not contain value with custom message + Check Test Case ${TESTNAME} + +Should not contain value with `ignore_case` + Check Test Case ${TESTNAME} + +Should contain item + Check Test Case ${TESTNAME} + +Should contain item with missing key + Check Test Case ${TESTNAME} + +Should contain item with missing key and custom message + Check Test Case ${TESTNAME} + +Should contain item with wrong value + Check Test Case ${TESTNAME} + +Should contain item with wrong value and custom message + Check Test Case ${TESTNAME} + +Should contain item with values looking same but having different types + Check Test Case ${TESTNAME} + +Should contain item with `ignore_case` + Check Test Case ${TESTNAME} + +Should contain item with `ignore_case=key` + Check Test Case ${TESTNAME} + +Should contain item with `ignore_case=value` + Check Test Case ${TESTNAME} + +Should contain sub dictionary + Check Test Case ${TESTNAME} + +Should contain sub dictionary with missing keys + Check Test Case ${TESTNAME} + +Should contain sub dictionary with missing keys and custom error message + Check Test Case ${TESTNAME} + +Should contain sub dictionary with missing keys and custom error message containig values + Check Test Case ${TESTNAME} + +Should contain sub dictionary with wrong value + Check Test Case ${TESTNAME} + +Should contain sub dictionary with wrong value and custom error message + Check Test Case ${TESTNAME} + +Should contain sub dictionary with wrong value and custom error message containing values + Check Test Case ${TESTNAME} + +Should contain sub dictionary with deprecated NO VALUES + ${tc} = Check Test Case ${TEST NAME} + Check Log Message ${tc[0, 0]} Using 'NO VALUES' for disabling the 'values' argument is deprecated. Use 'values=False' instead. WARN + +Should contain sub dictionary with `ignore_case` + Check Test Case ${TESTNAME} + +`ignore_case` when normalized keys have conflict + Check Test Case ${TESTNAME} + +`has_key` is not required + Check Test Case ${TESTNAME} + +Should contain sub dictionary with `ignore_value_order` + Check Test Case ${TESTNAME} + +Should contain sub dictionary with `ignore_value_order` set to False when dictionaries have lists in different order + Check Test Case ${TESTNAME} diff --git a/atest/robot/standard_libraries/collections/list.robot b/atest/robot/standard_libraries/collections/list.robot index 6582c2832a5..1f3aa755a5a 100644 --- a/atest/robot/standard_libraries/collections/list.robot +++ b/atest/robot/standard_libraries/collections/list.robot @@ -13,22 +13,16 @@ Convert To List With Invalid Type Append To List Check Test Case ${TEST NAME} -Insert Into List With String Index +Append To List with immutable Check Test Case ${TEST NAME} -Insert Into List With Int Index +Insert Into List Check Test Case ${TEST NAME} -Insert Into List With Index Over Lists Size +Insert Into List with immutable Check Test Case ${TEST NAME} -Insert Into List With Index Negative Index - Check Test Case ${TEST NAME} - -Insert Into List With Index Under Lists Size - Check Test Case ${TEST NAME} - -Insert Into List With Invalid Index +Insert Into List with invalid index Check Test Case ${TEST NAME} Combine Lists @@ -37,51 +31,54 @@ Combine Lists Set List Value Check Test Case ${TEST NAME} -Set List Value Index Out Of List +Set List Value with immutable Check Test Case ${TEST NAME} -Set List Value With Invalid Index +Set List Value with invalid index Check Test Case ${TEST NAME} Remove Values From List Check Test Case ${TEST NAME} -Remove Non Existing Values From List +Remove Values From List with immutable Check Test Case ${TEST NAME} Remove From List Check Test Case ${TEST NAME} -Remove From List Index Out Of List +Remove From List with immutable Check Test Case ${TEST NAME} -Remove From List With Invalid Index +Remove From List with invalid index Check Test Case ${TEST NAME} Remove Duplicates ${tc} = Check Test Case ${TEST NAME} - Check Log Message ${tc.kws[0].msgs[0]} 0 duplicates removed. - Check Log Message ${tc.kws[2].msgs[0]} 3 duplicates removed. + Check Log Message ${tc[0, 0, 0, 0]} 0 duplicates removed. + Check Log Message ${tc[1, 0, 0, 0]} 3 duplicates removed. Count Values In List Check Test Case ${TEST NAME} -Count Values In List With Invalid Start Index - Check Test Case ${TEST NAME} - -Count Values In List With Invalid Stop Index +Count Values In List with invalid index Check Test Case ${TEST NAME} Get Index From List Check Test Case ${TEST NAME} -Get Index From List With Non Existing Value +Get Index From List with empty string as start index is deprecated + ${tc} = Check Test Case ${TEST NAME} + Check Log Message ${tc[0, 0, 0, 0]} + ... Using an empty string as a start index with the 'Get Index From List' keyword is deprecated. Use '0' instead. + ... WARN + +Get Index From List with negative indices Check Test Case ${TEST NAME} -Get Index From List With Invalid Start Index +Get Index From List with indices out-of-bounds Check Test Case ${TEST NAME} -Get Index From List With Invalid Stop Index +Get Index From List with invalid index Check Test Case ${TEST NAME} Copy List @@ -93,34 +90,37 @@ Shallow Copy List Deep Copy List Check Test Case ${TEST NAME} -Reserve List +Reverse List Check Test Case ${TEST NAME} -Sort List +Reverse List with immutable Check Test Case ${TEST NAME} -Sorting Unsortable List Fails +Sort List Check Test Case ${TEST NAME} -Get From List +Sort List with immutable Check Test Case ${TEST NAME} -Get From List With Invalid Index +Sorting Unsortable List Fails Check Test Case ${TEST NAME} -Get From List Out Of List Index +Get From List Check Test Case ${TEST NAME} -Get Slice From List +Get From List with invalid index Check Test Case ${TEST NAME} -Get Slice From List With Invalid Start Index +Get Slice From List Check Test Case ${TEST NAME} -Get Slice From List With Invalid Stop Index - Check Test Case ${TEST NAME} +Get Slice From List with empty string as start index is deprecated + ${tc} = Check Test Case ${TEST NAME} + Check Log Message ${tc[0, 0, 0, 0]} + ... Using an empty string as a start index with the 'Get Slice From List' keyword is deprecated. Use '0' instead. + ... WARN -Get Slice From List With Out Of List Index +Get Slice From List with invalid index Check Test Case ${TEST NAME} List Should Contain Value @@ -149,26 +149,23 @@ List Should Not Contain Duplicates Is Case And Space Sensitive List Should Not Contain Duplicates With One Duplicate ${tc} = Check Test Case ${TEST NAME} - Check Log Message ${tc.kws[1].msgs[0]} 'item' found 2 times. + Check Log Message ${tc[1, 0]} 'item' found 2 times. List Should Not Contain Duplicates With Multiple Duplicates ${tc} = Check Test Case ${TEST NAME} - Check Log Message ${tc.kws[1].msgs[0]} '2' found 2 times. - Check Log Message ${tc.kws[1].msgs[1]} 'None' found 2 times. - Check Log Message ${tc.kws[1].msgs[2]} '4' found 4 times. - Check Log Message ${tc.kws[1].msgs[3]} '[1, 2, 3]' found 2 times. - Check Log Message ${tc.kws[1].msgs[4]} '[]' found 10 times. + Check Log Message ${tc[1, 0]} '2' found 2 times. + Check Log Message ${tc[1, 1]} 'None' found 2 times. + Check Log Message ${tc[1, 2]} '4' found 4 times. + Check Log Message ${tc[1, 3]} '[1, 2, 3]' found 2 times. + Check Log Message ${tc[1, 4]} '[]' found 10 times. List Should Not Contain Duplicates With Custom Error Message ${tc} = Check Test Case ${TEST NAME} - Check Log Message ${tc.kws[2].msgs[0]} '42' found 42 times. + Check Log Message ${tc[1, 0]} '6' found 7 times. Lists Should Be Equal Check Test Case ${TEST NAME} -List and Tuple should be equal - Check Test Case ${TEST NAME} - Lists Should Be equal With Different Lengths Check Test Case ${TEST NAME} @@ -199,36 +196,46 @@ Lists Should Be Equal With Named Indices As Dictionary Lists Should Be Equal With Named Indices As Dictionary With Too Few Values Check Test Case ${TEST NAME} +Lists Should Be Equal Ignore Order + Check Test Case ${TEST NAME} + +Ignore Order Is Recursive + Check Test Case ${TEST NAME} + List Should Contain Sub List Check Test Case ${TEST NAME} List Should Contain Sub List With Missing Values Check Test Case ${TEST NAME} +List Should Contain Sub List When The Only Missing Value Is Empty String + Check Test Case ${TEST NAME} + List Should Contain Sub List With Missing Values And Own Error Message Check Test Case ${TEST NAME} List Should Contain Sub List With Missing Values And Own and Default Error Messages Check Test Case ${TEST NAME} -Log List With Different Log Levels +'NO VALUES' is deprecated ${tc} = Check Test Case ${TEST NAME} - ${expected} = Catenate SEPARATOR=\n + Check Log Message ${tc[0, 0]} Using 'NO VALUES' for disabling the 'values' argument is deprecated. Use 'values=False' instead. WARN + Check Log Message ${tc[1, 0]} Using 'no values' for disabling the 'values' argument is deprecated. Use 'values=False' instead. WARN + +Log List + ${tc} = Check Test Case ${TEST NAME} + VAR ${three items} ... List length is 3 and it contains following items: ... 0: 11 ... 1: 12 ... 2: 13 - Check Log Message ${tc.kws[0].msgs[0]} ${expected} INFO - Variable Should Not Exist ${tc.kws[1].msgs[0]} - Check Log Message ${tc.kws[2].msgs[0]} ${expected} WARN - Check Log Message ${tc.kws[3].msgs[0]} ${expected} DEBUG - Check Log Message ${tc.kws[4].msgs[0]} ${expected} INFO - -Log List With Different Lists - ${tc} = Check Test Case ${TEST NAME} - Check Log Message ${tc.kws[0].msgs[0]} List is empty. INFO - Check Log Message ${tc.kws[1].msgs[0]} List has one item:\n1 - Check Log Message ${tc.kws[4].msgs[0]} List length is 2 and it contains following items:\n 0: (1, 2, 3)\n 1: 3.12 + ... separator=\n + Check Log Message ${tc[0, 0]} List is empty. INFO + Check Log Message ${tc[1, 0]} ${three items} INFO + Check Log Message ${tc[2, 0]} ${three items} INFO + Should Be Empty ${tc[3].body} + Check Log Message ${tc[4, 0]} ${three items} WARN + Check Log Message ${tc[5, 0]} ${three items} DEBUG Count Matches In List Case Insensitive Check Test Case ${TEST NAME} @@ -323,5 +330,29 @@ List Should Not Contain Value, Value Found and Own Error Message Regexp List Should Not Contain Value, Value Found and Own Error Message Glob Check Test Case ${TEST NAME} -Check List Error +Lists Should Be Equal With Ignore Case + Check Test Case ${TEST NAME} + +List Should Contain Value With Ignore Case + Check Test Case ${TEST NAME} + +List Should Not Contain Value With Ignore Case Does Contain Value + Check Test Case ${TEST NAME} + +List Should Contain Sub List With Ignore Case + Check Test Case ${TEST NAME} + +List Should Not Contain Duplicates With Ignore Case + Check Test Case ${TEST NAME} + +List Should Contain Value With Ignore Case And Nested List and Dictionary + Check Test Case ${TEST NAME} + +Lists Should be equal with Ignore Case and Order + Check Test Case ${TEST NAME} + +Validate argument conversion errors + Check Test Case ${TEST NAME} + +Bytes normalization Check Test Case ${TEST NAME} diff --git a/atest/robot/standard_libraries/datetime/convert_date_input_format.robot b/atest/robot/standard_libraries/datetime/convert_date_input_format.robot index 95639489caf..87ea52687b3 100644 --- a/atest/robot/standard_libraries/datetime/convert_date_input_format.robot +++ b/atest/robot/standard_libraries/datetime/convert_date_input_format.robot @@ -1,15 +1,15 @@ *** Settings *** -Suite Setup Run Tests ${EMPTY} standard_libraries/datetime/convert_date_input_format.robot -Resource atest_resource.robot +Suite Setup Run Tests ${EMPTY} standard_libraries/datetime/convert_date_input_format.robot +Resource atest_resource.robot *** Test Cases *** -String inputs +Timestamp Check Test Case ${TESTNAME} -Formatted strings +Custom timestamp Check Test Case ${TESTNAME} -Formatted with %f in middle +TODAY and NOW Check Test Case ${TESTNAME} Epoch @@ -18,6 +18,9 @@ Epoch Datetime object Check Test Case ${TESTNAME} +Date object + Check Test Case ${TESTNAME} + Pad zeroes to missing values Check Test Case ${TESTNAME} diff --git a/atest/robot/standard_libraries/datetime/convert_date_result_format.robot b/atest/robot/standard_libraries/datetime/convert_date_result_format.robot index e3d5cc8f706..48c40bcd43a 100644 --- a/atest/robot/standard_libraries/datetime/convert_date_result_format.robot +++ b/atest/robot/standard_libraries/datetime/convert_date_result_format.robot @@ -1,28 +1,28 @@ *** Settings *** -Suite Setup Run Tests ${EMPTY} standard_libraries/datetime/convert_date_result_format.robot -Resource atest_resource.robot +Suite Setup Run Tests ${EMPTY} standard_libraries/datetime/convert_date_result_format.robot +Resource atest_resource.robot *** Test Cases *** -Should convert to timestamp +Timestamp Check Test Case ${TESTNAME} -Timestamp should contain millis rounded to three digits +Milliseconds handling with timestamps Check Test Case ${TESTNAME} -Should convert to timestamp with format +Custom timestamp Check Test Case ${TESTNAME} -Should convert to epoch +Datetime Check Test Case ${TESTNAME} -Should convert to datetime +Date Check Test Case ${TESTNAME} -Should exclude milliseconds +Epoch Check Test Case ${TESTNAME} -Epoch time is float regardless are millis included or not +Excluding milliseconds Check Test Case ${TESTNAME} -Formatted with %f in middle +Epoch is float regardless are millis included or not Check Test Case ${TESTNAME} diff --git a/atest/robot/standard_libraries/datetime/get_current_date.robot b/atest/robot/standard_libraries/datetime/get_current_date.robot index a26cc4fd07f..f09d951db00 100644 --- a/atest/robot/standard_libraries/datetime/get_current_date.robot +++ b/atest/robot/standard_libraries/datetime/get_current_date.robot @@ -3,7 +3,7 @@ Suite Setup Run Tests ${EMPTY} standard_libraries/datetime/get_curren Resource atest_resource.robot *** Test Cases *** -Local time increases +Local time Check Test Case ${TESTNAME} UTC Time @@ -18,11 +18,20 @@ Increment Negative Increment Check Test Case ${TESTNAME} +Default result format + Check Test Case ${TESTNAME} + Result format timestamp Check Test Case ${TESTNAME} +Result format custom timestamp + Check Test Case ${TESTNAME} + Result format epoch Check Test Case ${TESTNAME} +Local and UTC epoch times are same + Check Test Case ${TESTNAME} + Result format datetime Check Test Case ${TESTNAME} diff --git a/atest/robot/standard_libraries/dialogs/dialogs.robot b/atest/robot/standard_libraries/dialogs/dialogs.robot index bfa9f999616..bb049c007e6 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 --exclude jybot_only standard_libraries/dialogs/dialogs.robot -Force Tags manual no-ci +Suite Setup Run Tests ${EMPTY} standard_libraries/dialogs/dialogs.robot +Test Tags manual no-ci Resource atest_resource.robot *** Test Cases *** @@ -40,9 +40,27 @@ 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} +Get Selection From User When Default Value Provided by Index + Check Test Case ${TESTNAME} + +Get Selection From User When Default Value Provided by String + Check Test Case ${TESTNAME} + +Get Selection From User When Default Value Is Integer + Check Test Case ${TESTNAME} + +Get Selection From User When Default Value Index Is Out of Bounds + Check Test Case ${TESTNAME} + +Get Selection From User When Default Value Cannot Be Found + Check Test Case ${TESTNAME} + Get Selection From User Cancelled Check Test Case ${TESTNAME} @@ -64,7 +82,8 @@ Get Selections From User Exited Multiple dialogs in a row Check Test Case ${TESTNAME} -Dialog and timeout - [Tags] require-jython - Run Tests --include jybot_only standard_libraries/dialogs/dialogs.robot - Check Test Case ${TESTNAME} FAIL Test timeout 1 second exceeded. +Garbage Collection In Thread Should Not Cause Problems + Check Test Case ${TESTNAME} + +Timeout can close dialog + Check Test Case ${TESTNAME} diff --git a/atest/robot/standard_libraries/easter.robot b/atest/robot/standard_libraries/easter.robot index 7876de7afb4..cf8097982c5 100644 --- a/atest/robot/standard_libraries/easter.robot +++ b/atest/robot/standard_libraries/easter.robot @@ -8,6 +8,6 @@ Not None Shall Not Pass None Shall Pass ${tc} = Check Test Case ${TESTNAME} - Check Log Message ${tc.kws[0].msgs[0]} + Check Log Message ${tc[0, 0]} ... ', - html=True + "allowfullscreen>" + "", + html=True, ) diff --git a/src/robot/libraries/OperatingSystem.py b/src/robot/libraries/OperatingSystem.py index d7e5f418f26..ca599322361 100644 --- a/src/robot/libraries/OperatingSystem.py +++ b/src/robot/libraries/OperatingSystem.py @@ -15,28 +15,30 @@ import fnmatch import glob -import io import os +import re import shutil -import sys +import subprocess import tempfile import time +from datetime import datetime, timedelta +from typing import NoReturn, Sequence -from robot.version import get_version from robot.api import logger -from robot.utils import (abspath, ConnectionCache, console_decode, del_env_var, - get_env_var, get_env_vars, get_time, is_truthy, - is_unicode, normpath, parse_time, plural_or_not, - secs_to_timestamp, secs_to_timestr, seq2str, - set_env_var, timestr_to_secs, unic, CONSOLE_ENCODING, - IRONPYTHON, JYTHON, PY2, PY3, SYSTEM_ENCODING, WINDOWS) +from robot.api.types import Secret +from robot.utils import ( + abspath, ConnectionCache, console_decode, CONSOLE_ENCODING, del_env_var, + get_env_var, get_env_vars, get_time, parse_time, plural_or_not as s, PY_VERSION, + safe_str, secs_to_timestr, seq2str, set_env_var +) +from robot.version import get_version __version__ = get_version() -PROCESSES = ConnectionCache('No active processes.') +PROCESSES = ConnectionCache("No active processes.") -class OperatingSystem(object): - """A test library providing keywords for OS related tasks. +class OperatingSystem: + r"""A library providing keywords for operating system related tasks. ``OperatingSystem`` is Robot Framework's standard library that enables various operating system related tasks to be performed in @@ -49,32 +51,30 @@ class OperatingSystem(object): == Table of contents == - - `Path separators` - - `Pattern matching` - - `Tilde expansion` - - `Boolean arguments` - - `Example` - - `Shortcuts` - - `Keywords` + %TOC% = Path separators = - Because Robot Framework uses the backslash (``\\``) as an escape character - in the test data, using a literal backslash requires duplicating it like - in ``c:\\\\path\\\\file.txt``. That can be inconvenient especially with + Because Robot Framework uses the backslash (``\``) as an escape character + in its data, using a literal backslash requires duplicating it like + in ``c:\\path\\file.txt``. That can be inconvenient especially with longer Windows paths, and thus all keywords expecting paths as arguments convert forward slashes to backslashes automatically on Windows. This also means that paths like ``${CURDIR}/path/file.txt`` are operating system independent. Notice that the automatic path separator conversion does not work if - the path is only a part of an argument like with `Run` and `Start Process` - keywords. In these cases the built-in variable ``${/}`` that contains - ``\\`` or ``/``, depending on the operating system, can be used instead. + the path is only a part of an argument like with the `Run` keyword. + In these cases the built-in variable ``${/}`` that contains ``\`` or ``/``, + depending on the operating system, can be used instead. = Pattern matching = - Some keywords allow their arguments to be specified as + Many keywords accept arguments as either _glob_ or _regular expression_ patterns. + + == Glob patterns == + + Some keywords, for example `List Directory`, support so called [http://en.wikipedia.org/wiki/Glob_(programming)|glob patterns] where: | ``*`` | matches any string, even an empty string | @@ -84,67 +84,67 @@ class OperatingSystem(object): | ``[a-z]`` | matches one character from the range in the bracket | | ``[!a-z]`` | matches one character not from the range in the bracket | - Unless otherwise noted, matching is case-insensitive on - case-insensitive operating systems such as Windows. + Unless otherwise noted, matching is case-insensitive on case-insensitive + operating systems such as Windows. + + == Regular expressions == - Starting from Robot Framework 2.9.1, globbing is not done if the given path - matches an existing file even if it would contain a glob pattern. + Some keywords, for example `Grep File`, support + [http://en.wikipedia.org/wiki/Regular_expression|regular expressions] + that are more powerful but also more complicated that glob patterns. + The regular expression support is implemented using Python's + [http://docs.python.org/library/re.html|re module] and its documentation + should be consulted for more information about the syntax. + + Because the backslash character (``\``) is an escape character in + Robot Framework data, possible backslash characters in regular + expressions need to be escaped with another backslash like ``\\d\\w+``. + Strings that may contain special characters but should be handled + as literal strings, can be escaped with the `Regexp Escape` keyword + from the BuiltIn library. = Tilde expansion = Paths beginning with ``~`` or ``~username`` are expanded to the current or specified user's home directory, respectively. The resulting path is operating system dependent, but typically e.g. ``~/robot`` is expanded to - ``C:\\Users\\\\robot`` on Windows and ``/home//robot`` on - Unixes. - - The ``~username`` form does not work on Jython. - - = Boolean arguments = + ``C:\Users\\robot`` on Windows and ``/home//robot`` on Unixes. - Some keywords accept arguments that are handled as Boolean values true or - false. If such an argument is given as a string, it is considered false if - it is an empty string or equal to ``FALSE``, ``NONE``, ``NO``, ``OFF`` or - ``0``, case-insensitively. Other strings are considered true regardless - their value, and other argument types are tested using the same - [http://docs.python.org/library/stdtypes.html#truth|rules as in Python]. + = pathlib.Path support = - True examples: - | `Remove Directory` | ${path} | recursive=True | # Strings are generally true. | - | `Remove Directory` | ${path} | recursive=yes | # Same as the above. | - | `Remove Directory` | ${path} | recursive=${TRUE} | # Python ``True`` is true. | - | `Remove Directory` | ${path} | recursive=${42} | # Numbers other than 0 are true. | + 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. - False examples: - | `Remove Directory` | ${path} | recursive=False | # String ``false`` is false. | - | `Remove Directory` | ${path} | recursive=no | # Also string ``no`` is false. | - | `Remove Directory` | ${path} | recursive=${EMPTY} | # Empty string is false. | - | `Remove Directory` | ${path} | recursive=${FALSE} | # Python ``False`` is false. | - - Considering string ``NONE`` false is new in Robot Framework 3.0.3 and - considering also ``OFF`` and ``0`` false is new in Robot Framework 3.1. + For backwards compatibility reasons, all keywords returning paths return them + as strings. = Example = - | =Setting= | =Value= | - | Library | OperatingSystem | - - | =Variable= | =Value= | - | ${PATH} | ${CURDIR}/example.txt | - - | =Test Case= | =Action= | =Argument= | =Argument= | - | Example | Create File | ${PATH} | Some text | - | | File Should Exist | ${PATH} | | - | | Copy File | ${PATH} | ~/file.txt | - | | ${output} = | Run | ${TEMPDIR}${/}script.py arg | + | ***** Settings ***** + | Library OperatingSystem + | + | ***** Variables ***** + | ${PATH} ${CURDIR}/example.txt + | + | ***** Test Cases ***** + | Example + | `Create File` ${PATH} Some text + | `File Should Exist` ${PATH} + | `Copy File` ${PATH} ~/file.txt """ - ROBOT_LIBRARY_SCOPE = 'GLOBAL' + + ROBOT_LIBRARY_SCOPE = "GLOBAL" ROBOT_LIBRARY_VERSION = __version__ - def run(self, command): - """Runs the given command in the system and returns the output. + def run(self, command: str) -> str: + """_This keyword is considered deprecated. Use the + [http://robotframework.org/robotframework/latest/libraries/Process.html| + Process] library instead._ - The execution status of the command *is not checked* by this + Runs the given command in the system and returns the output. + + The execution status of the command _is not checked_ by this keyword, and it must be done separately based on the returned output. If the execution return code is needed, either `Run And Return RC` or `Run And Return RC And Output` can be used. @@ -171,22 +171,21 @@ def run(self, command): | ${stdout} = | Run | /opt/script.sh 2>/tmp/stderr.txt | | Should Be Equal | ${stdout} | TEST PASSED | | File Should Be Empty | /tmp/stderr.txt | - - *TIP:* `Run Process` keyword provided by the - [http://robotframework.org/robotframework/latest/libraries/Process.html| - Process library] supports better process configuration and is generally - recommended as a replacement for this keyword. """ return self._run(command)[1] - def run_and_return_rc(self, command): - """Runs the given command in the system and returns the return code. + def run_and_return_rc(self, command: str) -> int: + """_This keyword is considered deprecated. Use the + [http://robotframework.org/robotframework/latest/libraries/Process.html| + Process] library instead._ + + Runs the given command in the system and returns the return code (RC). - The return code (RC) is returned as a positive integer in + The return code is returned as a positive integer in range from 0 to 255 as returned by the executed command. On some operating systems (notable Windows) original return codes can be something else, but this keyword always maps them to - the 0-255 range. Since the RC is an integer, it must be + the 0-255 range. Since the return code is an integer, it must be checked e.g. with the keyword `Should Be Equal As Integers` instead of `Should Be Equal` (both are built-in keywords). @@ -198,18 +197,16 @@ def run_and_return_rc(self, command): See `Run` and `Run And Return RC And Output` if you need to get the output of the executed command. - - *TIP:* `Run Process` keyword provided by the - [http://robotframework.org/robotframework/latest/libraries/Process.html| - Process library] supports better process configuration and is generally - recommended as a replacement for this keyword. """ return self._run(command)[0] - def run_and_return_rc_and_output(self, command): - """Runs the given command in the system and returns the RC and output. + def run_and_return_rc_and_output(self, command: str) -> "tuple[int, str]": + """_This keyword is considered deprecated. Use the + [http://robotframework.org/robotframework/latest/libraries/Process.html| + Process] library instead._ - The return code (RC) is returned similarly as with `Run And Return RC` + Runs the given command in the system and returns the return code (RC) + and output. The return code is returned similarly as with `Run And Return RC` and the output similarly as with `Run`. Examples: @@ -220,22 +217,27 @@ def run_and_return_rc_and_output(self, command): | Should Be True | ${rc} > 42 | | Should Be Equal | ${stdout} | TEST PASSED | | File Should Be Empty | /tmp/stderr.txt | - - *TIP:* `Run Process` keyword provided by the - [http://robotframework.org/robotframework/latest/libraries/Process.html| - Process library] supports better process configuration and is generally - recommended as a replacement for this keyword. """ return self._run(command) - def _run(self, command): - process = _Process(command) - self._info("Running command '%s'." % process) - stdout = process.read() - rc = process.close() - return rc, stdout - - def get_file(self, path, encoding='UTF-8', encoding_errors='strict'): + def _run(self, command: str) -> "tuple[int, str]": + self._info(f"Running command '{command}'.") + process = subprocess.Popen( + command, shell=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT + ) + output, _ = process.communicate() + output = console_decode(output, "SYSTEM").replace("\r\n", "\n") + if output.endswith("\n"): + output = output[:-1] + rc = process.returncode % 256 + return rc, output + + def get_file( + self, + path: str, + encoding: str = "UTF-8", + encoding_errors: str = "strict", + ) -> str: """Returns the contents of a specified file. This keyword reads the specified file and returns the contents. @@ -260,30 +262,25 @@ def get_file(self, path, encoding='UTF-8', encoding_errors='strict'): - ``ignore``: Ignore characters that cannot be decoded. - ``replace``: Replace characters that cannot be decoded with a replacement character. - - Support for ``SYSTEM`` and ``CONSOLE`` encodings in Robot Framework 3.0. """ path = self._absnorm(path) self._link("Getting file '%s'.", path) encoding = self._map_encoding(encoding) - if IRONPYTHON: - # https://github.com/IronLanguages/main/issues/1233 - with open(path) as f: - content = f.read().decode(encoding, encoding_errors) - else: - with io.open(path, encoding=encoding, errors=encoding_errors, - newline='') as f: - content = f.read() - return content.replace('\r\n', '\n') - - def _map_encoding(self, encoding): - # Python 3 opens files in native system encoding by default. - if PY3 and encoding.upper() == 'SYSTEM': - return None - return {'SYSTEM': SYSTEM_ENCODING, - 'CONSOLE': CONSOLE_ENCODING}.get(encoding.upper(), encoding) - - def get_binary_file(self, path): + # Using `newline=None` (default) and not converting `\r\n` -> `\n` + # ourselves would be better but some of our own acceptance tests + # depend on these semantics. Best solution would probably be making + # `newline` configurable. + # FIXME: Make `newline` configurable or at least submit an issue about that. + with open(path, encoding=encoding, errors=encoding_errors, newline="") as f: + return f.read().replace("\r\n", "\n") + + def _map_encoding(self, encoding: str) -> "str | None": + return { + "SYSTEM": "locale" if PY_VERSION > (3, 10) else None, + "CONSOLE": CONSOLE_ENCODING, + }.get(encoding.upper(), encoding) + + def get_binary_file(self, path: str) -> bytes: """Returns the contents of a specified file. This keyword reads the specified file and returns the contents as is. @@ -291,47 +288,74 @@ def get_binary_file(self, path): """ path = self._absnorm(path) self._link("Getting file '%s'.", path) - with open(path, 'rb') as f: - return bytes(f.read()) - - def grep_file(self, path, pattern, encoding='UTF-8', encoding_errors='strict'): - """Returns the lines of the specified file that match the ``pattern``. + with open(path, "rb") as f: + return f.read() + + def grep_file( + self, + path: str, + pattern: str, + encoding: str = "UTF-8", + encoding_errors: str = "strict", + regexp: bool = False, + ) -> str: + r"""Returns the lines of the specified file that match the ``pattern``. This keyword reads a file from the file system using the defined ``path``, ``encoding`` and ``encoding_errors`` similarly as `Get File`. A difference is that only the lines that match the given ``pattern`` are - returned. Lines are returned as a single string catenated back together + returned. Lines are returned as a single string concatenated back together with newlines and the number of matched lines is automatically logged. Possible trailing newline is never returned. - A line matches if it contains the ``pattern`` anywhere in it and - it *does not need to match the pattern fully*. The pattern - matching syntax is explained in `introduction`, and in this - case matching is case-sensitive. + A line matches if it contains the ``pattern`` anywhere in it i.e. it does + not need to match the pattern fully. There are two supported pattern types: + + - By default the pattern is considered a _glob_ pattern where, for example, + ``*`` and ``?`` can be used as wildcards. + - If the ``regexp`` argument is given a true value, the pattern is + considered to be a _regular expression_. These patterns are more + powerful but also more complicated than glob patterns. They often use + the backslash character and it needs to be escaped in Robot Framework + date like `\\`. + + For more information about glob and regular expression syntax, see + the `Pattern matching` section. With this keyword matching is always + case-sensitive. Examples: | ${errors} = | Grep File | /var/log/myapp.log | ERROR | | ${ret} = | Grep File | ${CURDIR}/file.txt | [Ww]ildc??d ex*ple | + | ${ret} = | Grep File | ${CURDIR}/file.txt | [Ww]ildc\\w+d ex.*ple | regexp=True | - If more complex pattern matching is needed, it is possible to use - `Get File` in combination with String library keywords like `Get - Lines Matching Regexp`. + Special encoding values ``SYSTEM`` and ``CONSOLE`` that `Get File` supports + are supported by this keyword only with Robot Framework 4.0 and newer. + + Support for regular expressions is new in Robot Framework 5.0. """ - pattern = '*%s*' % pattern path = self._absnorm(path) + if not regexp: + pattern = fnmatch.translate(f"{pattern}*") + reobj = re.compile(pattern) + encoding = self._map_encoding(encoding) lines = [] total_lines = 0 self._link("Reading file '%s'.", path) - with io.open(path, encoding=encoding, errors=encoding_errors) as f: - for line in f.readlines(): + with open(path, encoding=encoding, errors=encoding_errors) as file: + for line in file: total_lines += 1 - line = line.rstrip('\r\n') - if fnmatch.fnmatchcase(line, pattern): + line = line.rstrip("\r\n") + if reobj.search(line): lines.append(line) - self._info('%d out of %d lines matched' % (len(lines), total_lines)) - return '\n'.join(lines) - - def log_file(self, path, encoding='UTF-8', encoding_errors='strict'): + self._info(f"{len(lines)} out of {total_lines} lines matched.") + return "\n".join(lines) + + def log_file( + self, + path: str, + encoding: str = "UTF-8", + encoding_errors: str = "strict", + ) -> str: """Wrapper for `Get File` that also logs the returned file. The file is logged with the INFO level. If you want something else, @@ -347,151 +371,168 @@ def log_file(self, path, encoding='UTF-8', encoding_errors='strict'): # File and directory existence - def should_exist(self, path, msg=None): + def should_exist(self, path: str, msg: "str | None" = None): """Fails unless the given path (file or directory) exists. The path can be given as an exact path or as a glob pattern. - The pattern matching syntax is explained in `introduction`. + See the `Glob patterns` section for details about the supported syntax. + The default error message can be overridden with the ``msg`` argument. """ path = self._absnorm(path) if not self._glob(path): - self._fail(msg, "Path '%s' does not exist." % path) + self._fail(msg, f"Path '{path}' does not exist.") self._link("Path '%s' exists.", path) - def should_not_exist(self, path, msg=None): + def should_not_exist(self, path: str, msg: "str | None" = None): """Fails if the given path (file or directory) exists. The path can be given as an exact path or as a glob pattern. - The pattern matching syntax is explained in `introduction`. + See the `Glob patterns` section for details about the supported syntax. + The default error message can be overridden with the ``msg`` argument. """ path = self._absnorm(path) matches = self._glob(path) if matches: - self._fail(msg, self._get_matches_error('Path', path, matches)) + self._fail(msg, self._get_matches_error("Path", path, matches)) self._link("Path '%s' does not exist.", path) - def _glob(self, path): + def _glob(self, path: str) -> "list[str]": return glob.glob(path) if not os.path.exists(path) else [path] - def _get_matches_error(self, what, path, matches): + def _get_matches_error(self, kind: str, path: str, matches: "list[str]") -> str: if not self._is_glob_path(path): - return "%s '%s' exists." % (what, path) - return "%s '%s' matches %s." % (what, path, seq2str(sorted(matches))) + return f"{kind} '{path}' exists." + return f"{kind} '{path}' matches {seq2str(sorted(matches))}." - def _is_glob_path(self, path): - return '*' in path or '?' in path or ('[' in path and ']' in path) + def _is_glob_path(self, path: str) -> bool: + return "*" in path or "?" in path or ("[" in path and "]" in path) - def file_should_exist(self, path, msg=None): + def file_should_exist(self, path: str, msg: "str | None" = None): """Fails unless the given ``path`` points to an existing file. The path can be given as an exact path or as a glob pattern. - The pattern matching syntax is explained in `introduction`. + See the `Glob patterns` section for details about the supported syntax. + The default error message can be overridden with the ``msg`` argument. """ path = self._absnorm(path) matches = [p for p in self._glob(path) if os.path.isfile(p)] if not matches: - self._fail(msg, "File '%s' does not exist." % path) + self._fail(msg, f"File '{path}' does not exist.") self._link("File '%s' exists.", path) - def file_should_not_exist(self, path, msg=None): + def file_should_not_exist(self, path: str, msg: "str | None" = None): """Fails if the given path points to an existing file. The path can be given as an exact path or as a glob pattern. - The pattern matching syntax is explained in `introduction`. + See the `Glob patterns` section for details about the supported syntax. + The default error message can be overridden with the ``msg`` argument. """ path = self._absnorm(path) matches = [p for p in self._glob(path) if os.path.isfile(p)] if matches: - self._fail(msg, self._get_matches_error('File', path, matches)) + self._fail(msg, self._get_matches_error("File", path, matches)) self._link("File '%s' does not exist.", path) - def directory_should_exist(self, path, msg=None): + def directory_should_exist(self, path: str, msg: "str | None" = None): """Fails unless the given path points to an existing directory. The path can be given as an exact path or as a glob pattern. - The pattern matching syntax is explained in `introduction`. + See the `Glob patterns` section for details about the supported syntax. + The default error message can be overridden with the ``msg`` argument. """ path = self._absnorm(path) matches = [p for p in self._glob(path) if os.path.isdir(p)] if not matches: - self._fail(msg, "Directory '%s' does not exist." % path) + self._fail(msg, f"Directory '{path}' does not exist.") self._link("Directory '%s' exists.", path) - def directory_should_not_exist(self, path, msg=None): + def directory_should_not_exist(self, path: str, msg: "str | None" = None): """Fails if the given path points to an existing file. The path can be given as an exact path or as a glob pattern. - The pattern matching syntax is explained in `introduction`. + See the `Glob patterns` section for details about the supported syntax. + The default error message can be overridden with the ``msg`` argument. """ path = self._absnorm(path) matches = [p for p in self._glob(path) if os.path.isdir(p)] if matches: - self._fail(msg, self._get_matches_error('Directory', path, matches)) + self._fail(msg, self._get_matches_error("Directory", path, matches)) self._link("Directory '%s' does not exist.", path) # Waiting file/dir to appear/disappear - def wait_until_removed(self, path, timeout='1 minute'): + def wait_until_removed( + self, + path: str, + timeout: "timedelta | None" = timedelta(minutes=1), + ): """Waits until the given file or directory is removed. The path can be given as an exact path or as a glob pattern. - The pattern matching syntax is explained in `introduction`. + See the `Glob patterns` section for details about the supported syntax. If the path is a pattern, the keyword waits until all matching items are removed. - The optional ``timeout`` can be used to control the maximum time of - waiting. The timeout is given as a timeout string, e.g. in a format - ``15 seconds``, ``1min 10s`` or just ``10``. The time string format is - described in an appendix of Robot Framework User Guide. + Waits for 1 minute by default, but that can be changed by using the + ``timeout`` argument. Using a negative value or ``None`` disables the timeout. - If the timeout is negative, the keyword is never timed-out. The keyword - returns immediately, if the path does not exist in the first place. + Examples: + | Wait Until Removed | ${path} | + | Wait Until Removed | ${path} | 10 seconds | + | Wait Until Removed | ${path} | timeout=None | + + Disabling timeout using ``None`` is new in Robot Framework 7.4. """ path = self._absnorm(path) - timeout = timestr_to_secs(timeout) + timeout = timeout.total_seconds() if timeout else -1 maxtime = time.time() + timeout while self._glob(path): if timeout >= 0 and time.time() > maxtime: - self._fail("'%s' was not removed in %s." - % (path, secs_to_timestr(timeout))) + self._fail(f"'{path}' was not removed in {secs_to_timestr(timeout)}.") time.sleep(0.1) self._link("'%s' was removed.", path) - def wait_until_created(self, path, timeout='1 minute'): + def wait_until_created( + self, + path: str, + timeout: "timedelta | None" = timedelta(minutes=1), + ): """Waits until the given file or directory is created. The path can be given as an exact path or as a glob pattern. - The pattern matching syntax is explained in `introduction`. + See the `Glob patterns` section for details about the supported syntax. If the path is a pattern, the keyword returns when an item matching it is created. - The optional ``timeout`` can be used to control the maximum time of - waiting. The timeout is given as a timeout string, e.g. in a format - ``15 seconds``, ``1min 10s`` or just ``10``. The time string format is - described in an appendix of Robot Framework User Guide. + Waits for 1 minute by default, but that can be changed by using + the ``timeout`` argument. Using a negative value or ``None`` disables + the timeout. + + Examples: + | Wait Until Created | ${path} | + | Wait Until Created | ${path} | 10 seconds | + | Wait Until Created | ${path} | timeout=None | - If the timeout is negative, the keyword is never timed-out. The keyword - returns immediately, if the path already exists. + Disabling timeout using ``None`` is new in Robot Framework 7.4. """ path = self._absnorm(path) - timeout = timestr_to_secs(timeout) + timeout = timeout.total_seconds() if timeout else -1 maxtime = time.time() + timeout while not self._glob(path): if timeout >= 0 and time.time() > maxtime: - self._fail("'%s' was not created in %s." - % (path, secs_to_timestr(timeout))) + self._fail(f"'{path}' was not created in {secs_to_timestr(timeout)}.") time.sleep(0.1) self._link("'%s' was created.", path) # Dir/file empty - def directory_should_be_empty(self, path, msg=None): + def directory_should_be_empty(self, path: str, msg: "str | None" = None): """Fails unless the specified directory is empty. The default error message can be overridden with the ``msg`` argument. @@ -499,11 +540,11 @@ def directory_should_be_empty(self, path, msg=None): path = self._absnorm(path) items = self._list_dir(path) if items: - self._fail(msg, "Directory '%s' is not empty. Contents: %s." - % (path, seq2str(items, lastsep=', '))) + contents = seq2str(items, lastsep=", ") + self._fail(msg, f"Directory '{path}' is not empty. Contents: {contents}.") self._link("Directory '%s' is empty.", path) - def directory_should_not_be_empty(self, path, msg=None): + def directory_should_not_be_empty(self, path: str, msg: "str | None" = None): """Fails if the specified directory is empty. The default error message can be overridden with the ``msg`` argument. @@ -511,40 +552,43 @@ def directory_should_not_be_empty(self, path, msg=None): path = self._absnorm(path) items = self._list_dir(path) if not items: - self._fail(msg, "Directory '%s' is empty." % path) - self._link("Directory '%%s' contains %d item%s." - % (len(items), plural_or_not(items)), path) + self._fail(msg, f"Directory '{path}' is empty.") + self._link(f"Directory '%s' contains {len(items)} item{s(items)}.", path) - def file_should_be_empty(self, path, msg=None): + def file_should_be_empty(self, path: str, msg: "str | None" = None): """Fails unless the specified file is empty. The default error message can be overridden with the ``msg`` argument. """ path = self._absnorm(path) if not os.path.isfile(path): - self._error("File '%s' does not exist." % path) + self._error(f"File '{path}' does not exist.") size = os.stat(path).st_size if size > 0: - self._fail(msg, - "File '%s' is not empty. Size: %d bytes." % (path, size)) + self._fail(msg, f"File '{path}' is not empty. Size: {size} byte{s(size)}.") self._link("File '%s' is empty.", path) - def file_should_not_be_empty(self, path, msg=None): - """Fails if the specified directory is empty. + def file_should_not_be_empty(self, path: str, msg: "str | None" = None): + """Fails if the specified file is empty. The default error message can be overridden with the ``msg`` argument. """ path = self._absnorm(path) if not os.path.isfile(path): - self._error("File '%s' does not exist." % path) + self._error(f"File '{path}' does not exist.") size = os.stat(path).st_size if size == 0: - self._fail(msg, "File '%s' is empty." % path) - self._link("File '%%s' contains %d bytes." % size, path) + self._fail(msg, f"File '{path}' is empty.") + self._link(f"File '%s' contains {size} bytes.", path) # Creating and removing files and directory - def create_file(self, path, content='', encoding='UTF-8'): + def create_file( + self, + path: str, + content: "str | Secret" = "", + encoding: str = "UTF-8", + ): """Creates a file with the given content and encoding. If the directory where the file is created does not exist, it is @@ -566,31 +610,31 @@ def create_file(self, path, content='', encoding='UTF-8'): and `Create Binary File` if you need to write bytes without encoding. `File Should Not Exist` can be used to avoid overwriting existing files. - - The support for ``SYSTEM`` and ``CONSOLE`` encodings is new in Robot - Framework 3.0. Automatically converting ``\\n`` to ``\\r\\n`` on - Windows is new in Robot Framework 3.1. """ + if isinstance(content, Secret): + content = content.value path = self._write_to_file(path, content, encoding) self._link("Created file '%s'.", path) - def _write_to_file(self, path, content, encoding=None, mode='w'): + def _write_to_file( + self, + path: str, + content: "str | bytes", + encoding: "str | None" = None, + mode: str = "w", + ) -> str: path = self._absnorm(path) parent = os.path.dirname(path) if not os.path.exists(parent): os.makedirs(parent) - # io.open() only accepts Unicode, not byte-strings, in text mode. - # We expect possible byte-strings to be all ASCII. - if PY2 and isinstance(content, str) and 'b' not in mode: - content = unicode(content) if encoding: encoding = self._map_encoding(encoding) - with io.open(path, mode, encoding=encoding) as f: + with open(path, mode, encoding=encoding) as f: f.write(content) return path - def create_binary_file(self, path, content): - """Creates a binary file with the given content. + def create_binary_file(self, path: str, content: bytes): + r"""Creates a binary file with the given content. If content is given as a Unicode string, it is first converted to bytes character by character. All characters with ordinal below 256 can be @@ -603,19 +647,22 @@ def create_binary_file(self, path, content): with missing intermediate directories. Examples: - | Create Binary File | ${dir}/example.png | ${image content} | - | Create Binary File | ${path} | \\x01\\x00\\xe4\\x00 | + | Create Binary File | ${dir}/example.png | ${image content} | + | Create Binary File | ${path} | \x01\x00\xe4\x00 | Use `Create File` if you want to create a text file using a certain encoding. `File Should Not Exist` can be used to avoid overwriting existing files. """ - if is_unicode(content): - content = bytes(bytearray(ord(c) for c in content)) - path = self._write_to_file(path, content, mode='wb') + path = self._write_to_file(path, content, mode="wb") self._link("Created binary file '%s'.", path) - def append_to_file(self, path, content, encoding='UTF-8'): + def append_to_file( + self, + path: str, + content: "str | Secret" = "", + encoding: str = "UTF-8", + ): """Appends the given content to the specified file. If the file exists, the given text is written to its end. If the file @@ -624,21 +671,20 @@ def append_to_file(self, path, content, encoding='UTF-8'): Other than not overwriting possible existing files, this keyword works exactly like `Create File`. See its documentation for more details about the usage. - - Note that special encodings ``SYSTEM`` and ``CONSOLE`` only work - with this keyword starting from Robot Framework 3.1.2. """ - path = self._write_to_file(path, content, encoding, mode='a') + if isinstance(content, Secret): + content = content.value + path = self._write_to_file(path, content, encoding, mode="a") self._link("Appended to file '%s'.", path) - def remove_file(self, path): + def remove_file(self, path: str): """Removes a file with the given path. Passes if the file does not exist, but fails if the path does not point to a regular file (e.g. it points to a directory). The path can be given as an exact path or as a glob pattern. - The pattern matching syntax is explained in `introduction`. + See the `Glob patterns` section for details about the supported syntax. If the path is a pattern, all files matching it are removed. """ path = self._absnorm(path) @@ -647,11 +693,11 @@ def remove_file(self, path): self._link("File '%s' does not exist.", path) for match in matches: if not os.path.isfile(match): - self._error("Path '%s' is not a file." % match) + self._error(f"Path '{path}' is not a file.") os.remove(match) self._link("Removed file '%s'.", match) - def remove_files(self, *paths): + def remove_files(self, *paths: str): """Uses `Remove File` to remove multiple files one-by-one. Example: @@ -660,10 +706,10 @@ def remove_files(self, *paths): for path in paths: self.remove_file(path) - def empty_directory(self, path): + def empty_directory(self, path: str): """Deletes all the content from the given directory. - Deletes both files and sub-directories, but the specified directory + Deletes both files and subdirectories, but the specified directory itself if not removed. Use `Remove Directory` if you want to remove the whole directory. """ @@ -675,28 +721,28 @@ def empty_directory(self, path): os.remove(item) self._link("Emptied directory '%s'.", path) - def create_directory(self, path): + def create_directory(self, path: str): """Creates the specified directory. - Also possible intermediate directories are created. Passes if the + Possible intermediate directories are created as well. Passes if the directory already exists, but fails if the path exists and is not a directory. """ path = self._absnorm(path) if os.path.isdir(path): - self._link("Directory '%s' already exists.", path ) + self._link("Directory '%s' already exists.", path) elif os.path.exists(path): - self._error("Path '%s' is not a directory." % path) + self._error(f"Path '{path}' is not a directory.") else: os.makedirs(path) self._link("Created directory '%s'.", path) - def remove_directory(self, path, recursive=False): + def remove_directory(self, path: str, recursive: bool = False): """Removes the directory pointed to by the given ``path``. - If the second argument ``recursive`` is given a true value (see - `Boolean arguments`), the directory is removed recursively. Otherwise - removing fails if the directory is not empty. + If the second argument ``recursive`` is given a true value, the + directory is removed recursively. Otherwise, removing fails if + the directory is not empty. If the directory pointed to by the ``path`` does not exist, the keyword passes, but it fails, if the ``path`` points to a file. @@ -704,24 +750,23 @@ def remove_directory(self, path, recursive=False): path = self._absnorm(path) if not os.path.exists(path): self._link("Directory '%s' does not exist.", path) - elif not os.path.isdir(path): - self._error("Path '%s' is not a directory." % path) + return + if not os.path.isdir(path): + self._error(f"Path '{path}' is not a directory.") + if recursive: + shutil.rmtree(path) else: - if is_truthy(recursive): - shutil.rmtree(path) - else: - self.directory_should_be_empty( - path, "Directory '%s' is not empty." % path) - os.rmdir(path) - self._link("Removed directory '%s'.", path) + self.directory_should_be_empty(path, f"Directory '{path}' is not empty.") + os.rmdir(path) + self._link("Removed directory '%s'.", path) # Moving and copying files and directories - def copy_file(self, source, destination): - """Copies the source file into the destination. + def copy_file(self, source: str, destination: str) -> str: + r"""Copies the source file into the destination. Source must be a path to an existing file or a glob pattern (see - `Pattern matching`) that matches exactly one file. How the + `Glob patterns`) that matches exactly one file. How the destination is interpreted is explained below. 1) If the destination is an existing file, the source file is copied @@ -731,72 +776,73 @@ def copy_file(self, source, destination): copied into it. A possible file with the same name as the source is overwritten. - 3) If the destination does not exist and it ends with a path - separator (``/`` or ``\\``), it is considered a directory. That + 3) If the destination does not exist, and it ends with a path + separator (``/`` or ``\``), it is considered a directory. That directory is created and a source file copied into it. Possible missing intermediate directories are also created. - 4) If the destination does not exist and it does not end with a path + 4) If the destination does not exist, and it does not end with a path separator, it is considered a file. If the path to the file does not exist, it is created. - The resulting destination path is returned since Robot Framework 2.9.2. + The resulting destination path is returned. See also `Copy Files`, `Move File`, and `Move Files`. """ - source, destination = \ - self._prepare_copy_and_move_file(source, destination) + source, destination = self._prepare_copy_file(source, destination) if not self._are_source_and_destination_same_file(source, destination): - source, destination = self._atomic_copy(source, destination) + self._atomic_copy(source, destination) self._link("Copied file from '%s' to '%s'.", source, destination) return destination - def _prepare_copy_and_move_file(self, source, destination): - source = self._normalize_copy_and_move_source(source) - destination = self._normalize_copy_and_move_destination(destination) - if os.path.isdir(destination): - destination = os.path.join(destination, os.path.basename(source)) - return source, destination + def _prepare_copy_file(self, source: str, dest: str) -> "tuple[str, str]": + source = self._normalize_source(source) + dest = self._normalize_destination(dest) + if os.path.isdir(dest): + dest = os.path.join(dest, os.path.basename(source)) + return source, dest - def _normalize_copy_and_move_source(self, source): + def _normalize_source(self, source: str) -> str: source = self._absnorm(source) sources = self._glob(source) if len(sources) > 1: - self._error("Multiple matches with source pattern '%s'." % source) + self._error(f"Multiple matches with source pattern '{source}'.") if sources: source = sources[0] if not os.path.exists(source): - self._error("Source file '%s' does not exist." % source) + self._error(f"Source file '{source}' does not exist.") if not os.path.isfile(source): - self._error("Source file '%s' is not a regular file." % source) + self._error(f"Source file '{source}' is not a regular file.") return source - def _normalize_copy_and_move_destination(self, destination): - is_dir = os.path.isdir(destination) or destination.endswith(('/', '\\')) - destination = self._absnorm(destination) - directory = destination if is_dir else os.path.dirname(destination) + def _normalize_destination(self, dest: str) -> str: + is_dir = os.path.isdir(dest) or dest.endswith(("/", "\\")) + dest = self._absnorm(dest) + directory = dest if is_dir else os.path.dirname(dest) self._ensure_destination_directory_exists(directory) - return destination + return dest - def _ensure_destination_directory_exists(self, path): + def _ensure_destination_directory_exists(self, path: str): if not os.path.exists(path): os.makedirs(path) elif not os.path.isdir(path): - self._error("Destination '%s' exists and is not a directory." % path) - - def _are_source_and_destination_same_file(self, source, destination): - if self._force_normalize(source) == self._force_normalize(destination): - self._link("Source '%s' and destination '%s' point to the same " - "file.", source, destination) + self._error(f"Destination '{path}' exists and is not a directory.") + + def _are_source_and_destination_same_file(self, source: str, dest: str) -> bool: + if ( + os.path.exists(source) + and os.path.exists(dest) + and os.path.samefile(source, dest) + ): + self._link( + "Source '%s' and destination '%s' point to the same file.", + source, + dest, + ) return True return False - def _force_normalize(self, path): - # TODO: Should normalize_path also support link normalization? - # TODO: Should we handle dos paths like 'exampl~1.txt'? - return os.path.realpath(normpath(path, case_normalize=True)) - - def _atomic_copy(self, source, destination): + def _atomic_copy(self, source: str, dest: str): """Copy file atomically (or at least try to). This method tries to ensure that a file copy operation will not fail @@ -811,41 +857,39 @@ def _atomic_copy(self, source, destination): See also https://github.com/robotframework/robotframework/issues/1502 """ - temp_directory = tempfile.mkdtemp(dir=os.path.dirname(destination)) + temp_directory = tempfile.mkdtemp(dir=os.path.dirname(dest)) temp_file = os.path.join(temp_directory, os.path.basename(source)) try: shutil.copy(source, temp_file) - if os.path.exists(destination): - os.remove(destination) - shutil.move(temp_file, destination) + if os.path.exists(dest): + os.remove(dest) + shutil.move(temp_file, dest) finally: shutil.rmtree(temp_directory) - return source, destination - def move_file(self, source, destination): + def move_file(self, source: str, destination: str) -> str: """Moves the source file into the destination. Arguments have exactly same semantics as with `Copy File` keyword. - Destination file path is returned since Robot Framework 2.9.2. + Destination file path is returned. If the source and destination are on the same filesystem, rename - operation is used. Otherwise file is copied to the destination + operation is used. Otherwise, file is copied to the destination filesystem and then removed from the original filesystem. See also `Move Files`, `Copy File`, and `Copy Files`. """ - source, destination = \ - self._prepare_copy_and_move_file(source, destination) + source, destination = self._prepare_copy_file(source, destination) if not self._are_source_and_destination_same_file(destination, source): shutil.move(source, destination) self._link("Moved file from '%s' to '%s'.", source, destination) return destination - def copy_files(self, *sources_and_destination): + def copy_files(self, *sources_and_destination: str): """Copies specified files to the target directory. Source files can be given as exact paths and as glob patterns (see - `Pattern matching`). At least one source must be given, but it is + `Glob patterns`). At least one source must be given, but it is not an error if it is a pattern that does not match anything. Last argument must be the destination directory. If the destination @@ -857,63 +901,55 @@ def copy_files(self, *sources_and_destination): See also `Copy File`, `Move File`, and `Move Files`. """ - sources, destination \ - = self._prepare_copy_and_move_files(sources_and_destination) + sources, dest = self._prepare_copy_files(sources_and_destination) for source in sources: - self.copy_file(source, destination) + self.copy_file(source, dest) - def _prepare_copy_and_move_files(self, items): + def _prepare_copy_files(self, items: Sequence[str]) -> "tuple[list[str], str]": if len(items) < 2: - self._error('Must contain destination and at least one source.') + self._error("Must contain destination and at least one source.") sources = self._glob_files(items[:-1]) destination = self._absnorm(items[-1]) self._ensure_destination_directory_exists(destination) return sources, destination - def _glob_files(self, patterns): + def _glob_files(self, patterns: Sequence[str]) -> "list[str]": files = [] for pattern in patterns: files.extend(self._glob(self._absnorm(pattern))) return files - def move_files(self, *sources_and_destination): + def move_files(self, *sources_and_destination: str): """Moves specified files to the target directory. Arguments have exactly same semantics as with `Copy Files` keyword. See also `Move File`, `Copy File`, and `Copy Files`. """ - sources, destination \ - = self._prepare_copy_and_move_files(sources_and_destination) + sources, dest = self._prepare_copy_files(sources_and_destination) for source in sources: - self.move_file(source, destination) + self.move_file(source, dest) def copy_directory(self, source, destination): """Copies the source directory into the destination. - If the destination exists, the source is copied under it. Otherwise + If the destination exists, the source is copied under it. Otherwise, the destination directory and the possible missing intermediate directories are created. """ - source, destination \ - = self._prepare_copy_and_move_directory(source, destination) - try: - shutil.copytree(source, destination) - except shutil.Error: - # https://github.com/robotframework/robotframework/issues/2321 - if not (WINDOWS and JYTHON): - raise + source, destination = self._prepare_copy_dir(source, destination) + shutil.copytree(source, destination) self._link("Copied directory from '%s' to '%s'.", source, destination) - def _prepare_copy_and_move_directory(self, source, destination): + def _prepare_copy_dir(self, source: str, destination: str) -> "tuple[str, str]": source = self._absnorm(source) destination = self._absnorm(destination) if not os.path.exists(source): - self._error("Source '%s' does not exist." % source) + self._error(f"Source '{source}' does not exist.") if not os.path.isdir(source): - self._error("Source '%s' is not a directory." % source) + self._error(f"Source '{source}' is not a directory.") if os.path.exists(destination) and not os.path.isdir(destination): - self._error("Destination '%s' is not a directory." % destination) + self._error(f"Destination '{destination}' is not a directory.") if os.path.exists(destination): base = os.path.basename(source) destination = os.path.join(destination, base) @@ -923,25 +959,24 @@ def _prepare_copy_and_move_directory(self, source, destination): os.makedirs(parent) return source, destination - def move_directory(self, source, destination): + def move_directory(self, source: str, destination: str): """Moves the source directory into a destination. Uses `Copy Directory` keyword internally, and ``source`` and ``destination`` arguments have exactly same semantics as with that keyword. """ - source, destination \ - = self._prepare_copy_and_move_directory(source, destination) + source, destination = self._prepare_copy_dir(source, destination) shutil.move(source, destination) self._link("Moved directory from '%s' to '%s'.", source, destination) # Environment Variables - def get_environment_variable(self, name, default=None): + def get_environment_variable(self, name: str, default: "str | None" = None) -> str: """Returns the value of an environment variable with the given name. - If no such environment variable is set, returns the default value, if - given. Otherwise fails the test case. + If no environment variable is found, returns possible default value. + If no default value is given, the keyword fails. Returned variables are automatically decoded to Unicode using the system encoding. @@ -951,20 +986,33 @@ def get_environment_variable(self, name, default=None): """ value = get_env_var(name, default) if value is None: - self._error("Environment variable '%s' does not exist." % name) + self._error(f"Environment variable '{name}' does not exist.") return value - def set_environment_variable(self, name, value): + def set_environment_variable(self, name: str, value: "str | Secret"): """Sets an environment variable to a specified value. Values are converted to strings automatically. Set variables are automatically encoded using the system encoding. """ - set_env_var(name, value) - self._info("Environment variable '%s' set to value '%s'." - % (name, value)) + if isinstance(value, Secret): + value = value.value + secret = True + else: + secret = False + self._set_environment_variable(name, value, secret) - def append_to_environment_variable(self, name, *values, **config): + def _set_environment_variable(self, name: str, value: str, secret: bool = False): + set_env_var(name, value) + set_to = "a secret value" if secret else f"value '{value}'" + self._info(f"Environment variable '{name}' set to {set_to}.") + + def append_to_environment_variable( + self, + name: str, + *values: "str | Secret", + separator: str = os.pathsep, + ): """Appends given ``values`` to environment variable ``name``. If the environment variable already exists, values are added after it, @@ -972,8 +1020,7 @@ def append_to_environment_variable(self, name, *values, **config): Values are, by default, joined together using the operating system path separator (``;`` on Windows, ``:`` elsewhere). This can be changed - by giving a separator after the values like ``separator=value``. No - other configuration parameters are accepted. + by giving a separator after the values like ``separator=value``. Examples (assuming ``NAME`` and ``NAME2`` do not exist initially): | Append To Environment Variable | NAME | first | | @@ -985,18 +1032,17 @@ def append_to_environment_variable(self, name, *values, **config): | Append To Environment Variable | NAME2 | second | separator=- | | Should Be Equal | %{NAME2} | first-second | """ - sentinel = object() - initial = self.get_environment_variable(name, sentinel) - if initial is not sentinel: - values = (initial,) + values - separator = config.pop('separator', os.pathsep) - if config: - config = ['='.join(i) for i in sorted(config.items())] - self._error('Configuration %s not accepted.' - % seq2str(config, lastsep=' or ')) - self.set_environment_variable(name, separator.join(values)) - - def remove_environment_variable(self, *names): + initial = get_env_var(name) + if initial: + values = (initial, *values) + if any(isinstance(v, Secret) for v in values): + values = [v.value if isinstance(v, Secret) else v for v in values] + secret = True + else: + secret = False + self._set_environment_variable(name, separator.join(values), secret) + + def remove_environment_variable(self, *names: str): """Deletes the specified environment variable. Does nothing if the environment variable is not set. @@ -1007,32 +1053,41 @@ def remove_environment_variable(self, *names): for name in names: value = del_env_var(name) if value: - self._info("Environment variable '%s' deleted." % name) + self._info(f"Environment variable '{name}' deleted.") else: - self._info("Environment variable '%s' does not exist." % name) + self._info(f"Environment variable '{name}' does not exist.") - def environment_variable_should_be_set(self, name, msg=None): + def environment_variable_should_be_set(self, name: str, msg: "str | None" = None): """Fails if the specified environment variable is not set. + Environment variable is considered not to be set if it does not exist + or if its value is an empty string. + The default error message can be overridden with the ``msg`` argument. """ value = get_env_var(name) if not value: - self._fail(msg, "Environment variable '%s' is not set." % name) - self._info("Environment variable '%s' is set to '%s'." % (name, value)) - - def environment_variable_should_not_be_set(self, name, msg=None): + self._fail(msg, f"Environment variable '{name}' is not set.") + self._info(f"Environment variable '{name}' is set to '{value}'.") + + def environment_variable_should_not_be_set( + self, + name: str, + msg: "str | None" = None, + ): """Fails if the specified environment variable is set. + Environment variable is considered not to be set if it does not exist + or if its value is an empty string. + The default error message can be overridden with the ``msg`` argument. """ value = get_env_var(name) if value: - self._fail(msg, "Environment variable '%s' is set to '%s'." - % (name, value)) - self._info("Environment variable '%s' is not set." % name) + self._fail(msg, f"Environment variable '{name}' is set to '{value}'.") + self._info(f"Environment variable '{name}' is not set.") - def get_environment_variables(self): + def get_environment_variables(self) -> "dict[str, str]": """Returns currently available environment variables as a dictionary. Both keys and values are decoded to Unicode using the system encoding. @@ -1041,7 +1096,9 @@ def get_environment_variables(self): """ return get_env_vars() - def log_environment_variables(self, level='INFO'): + def log_environment_variables( + self, level: logger.LogLevel = "INFO" + ) -> "dict[str, str]": """Logs all environment variables using the given log level. Environment variables are also returned the same way as with @@ -1049,12 +1106,12 @@ def log_environment_variables(self, level='INFO'): """ variables = get_env_vars() for name in sorted(variables, key=lambda item: item.lower()): - self._log('%s = %s' % (name, variables[name]), level) + self._log(f"{name} = {variables[name]}", level) return variables # Path - def join_path(self, base, *parts): + def join_path(self, base: str, *parts: str) -> str: """Joins the given path part(s) to the given base path. The path separator (``/`` or ``\\``) is inserted when needed and @@ -1074,11 +1131,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)) - def join_paths(self, base, *paths): + def join_paths(self, base: str, *paths: str) -> "list[str]": """Joins given paths with base and returns resulted paths. See `Join Path` for more information. @@ -1094,16 +1149,15 @@ def join_paths(self, base, *paths): """ return [self.join_path(base, path) for path in paths] - def normalize_path(self, path, case_normalize=False): + def normalize_path(self, path: str, case_normalize=False) -> str: """Normalizes the given path. - Collapses redundant separators and up-level references. - Converts ``/`` to ``\\`` on Windows. - Replaces initial ``~`` or ``~user`` by that user's home directory. - The latter is not supported on Jython. - - If ``case_normalize`` is given a true value (see `Boolean arguments`) - on Windows, converts the path to all lowercase. New in Robot - Framework 3.1. + - If ``case_normalize`` is given a true value on Windows, converts + the path to all lowercase. + - Converts ``pathlib.Path`` instances to ``str``. Examples: | ${path1} = | Normalize Path | abc/ | @@ -1119,16 +1173,16 @@ 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))) + 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 # utility do, desirable. if case_normalize: path = os.path.normcase(path) - return path or '.' + return path or "." - def split_path(self, path): + def split_path(self, path: str) -> "tuple[str, str]": """Splits the given path from the last path separator (``/`` or ``\\``). The given path is first normalized (e.g. a possible trailing @@ -1147,7 +1201,7 @@ def split_path(self, path): """ return os.path.split(self.normalize_path(path)) - def split_extension(self, path): + def split_extension(self, path: str) -> "tuple[str, str]": """Splits the extension from the given path. The given path is first normalized (e.g. possible trailing @@ -1175,16 +1229,16 @@ def split_extension(self, path): """ path = self.normalize_path(path) basename = os.path.basename(path) - if basename.startswith('.' * basename.count('.')): - return path, '' - if path.endswith('.'): - path2 = path.rstrip('.') - trailing_dots = '.' * (len(path) - len(path2)) + if basename.startswith("." * basename.count(".")): + return path, "" + if path.endswith("."): + path2 = path.rstrip(".") + trailing_dots = "." * (len(path) - len(path2)) path = path2 else: - trailing_dots = '' + trailing_dots = "" basepath, extension = os.path.splitext(path) - if extension.startswith('.'): + if extension.startswith("."): extension = extension[1:] if extension: extension += trailing_dots @@ -1194,7 +1248,11 @@ def split_extension(self, path): # Misc - def get_modified_time(self, path, format='timestamp'): + def get_modified_time( + self, + path: str, + format: str = "timestamp", + ) -> "int | str | list[str]": """Returns the last modification time of a file or directory. How time is returned is determined based on the given ``format`` @@ -1231,12 +1289,12 @@ def get_modified_time(self, path, format='timestamp'): """ path = self._absnorm(path) if not os.path.exists(path): - self._error("Path '%s' does not exist." % path) + self._error(f"Path '{path}' does not exist.") mtime = get_time(format, os.stat(path).st_mtime) - self._link("Last modified time of '%%s' is %s." % mtime, path) + self._link(f"Last modified time of '%s' is {mtime}.", path) return mtime - def set_modified_time(self, path, mtime): + def set_modified_time(self, path: str, mtime: "int | str"): """Sets the file modification and access times. Changes the modification and access times of the given file to @@ -1275,25 +1333,29 @@ def set_modified_time(self, path, mtime): mtime = parse_time(mtime) path = self._absnorm(path) if not os.path.exists(path): - self._error("File '%s' does not exist." % path) + self._error(f"File '{path}' does not exist.") if not os.path.isfile(path): - self._error("Path '%s' is not a regular file." % path) + self._error(f"Path '{path}' is not a regular file.") os.utime(path, (mtime, mtime)) - time.sleep(0.1) # Give os some time to really set these times - tstamp = secs_to_timestamp(mtime, seps=('-', ' ', ':')) - self._link("Set modified time of '%%s' to %s." % tstamp, path) + time.sleep(0.1) # Give OS some time to really set these times. + tstamp = datetime.fromtimestamp(mtime).isoformat(" ", timespec="seconds") + self._link(f"Set modified time of '%s' to {tstamp}.", path) - def get_file_size(self, path): + def get_file_size(self, path: str) -> int: """Returns and logs file size as an integer in bytes.""" path = self._absnorm(path) if not os.path.isfile(path): - self._error("File '%s' does not exist." % path) + self._error(f"File '{path}' does not exist.") size = os.stat(path).st_size - plural = plural_or_not(size) - self._link("Size of file '%%s' is %d byte%s." % (size, plural), path) + self._link(f"Size of file '%s' is {size} byte{s(size)}.", path) return size - def list_directory(self, path, pattern=None, absolute=False): + def list_directory( + self, + path: str, + pattern: "str | None" = None, + absolute: bool = False, + ) -> "list[str]": """Returns and logs items in a directory, optionally filtered with ``pattern``. File and directory names are returned in case-sensitive alphabetical @@ -1304,11 +1366,12 @@ def list_directory(self, path, pattern=None, absolute=False): File and directory names are returned relative to the given path (e.g. ``'file.txt'``) by default. If you want them be returned in absolute format (e.g. ``'/home/robot/file.txt'``), give the ``absolute`` - argument a true value (see `Boolean arguments`). + argument a true value. If ``pattern`` is given, only items matching it are returned. The pattern - matching syntax is explained in `introduction`, and in this case - matching is case-sensitive. + is considered to be a _glob pattern_ and the full syntax is explained in + the `Glob patterns` section. With this keyword matching is always + case-sensitive. Examples (using also other `List Directory` variants): | @{items} = | List Directory | ${TEMPDIR} | @@ -1316,26 +1379,33 @@ def list_directory(self, path, pattern=None, absolute=False): | ${count} = | Count Files In Directory | ${CURDIR} | ??? | """ items = self._list_dir(path, pattern, absolute) - self._info('%d item%s:\n%s' % (len(items), plural_or_not(items), - '\n'.join(items))) + self._info(f"{len(items)} item{s(items)}:\n" + "\n".join(items)) return items - def list_files_in_directory(self, path, pattern=None, absolute=False): + def list_files_in_directory( + self, + path: str, + pattern: "str | None" = None, + absolute: bool = False, + ) -> "list[str]": """Wrapper for `List Directory` that returns only files.""" files = self._list_files_in_dir(path, pattern, absolute) - self._info('%d file%s:\n%s' % (len(files), plural_or_not(files), - '\n'.join(files))) + self._info(f"{len(files)} file{s(files)}:\n" + "\n".join(files)) return files - def list_directories_in_directory(self, path, pattern=None, absolute=False): + def list_directories_in_directory( + self, + path: str, + pattern: "str | None" = None, + absolute: bool = False, + ) -> "list[str]": """Wrapper for `List Directory` that returns only directories.""" dirs = self._list_dirs_in_dir(path, pattern, absolute) - self._info('%d director%s:\n%s' % (len(dirs), - 'y' if len(dirs) == 1 else 'ies', - '\n'.join(dirs))) + label = "directory" if len(dirs) == 1 else "directories" + self._info(f"{len(dirs)} {label}:\n" + "\n".join(dirs)) return dirs - def count_items_in_directory(self, path, pattern=None): + def count_items_in_directory(self, path: str, pattern: "str | None" = None) -> int: """Returns and logs the number of all items in the given directory. The argument ``pattern`` has the same semantics as with `List Directory` @@ -1343,47 +1413,73 @@ def count_items_in_directory(self, path, pattern=None): with the built-in keyword `Should Be Equal As Integers`. """ count = len(self._list_dir(path, pattern)) - self._info("%s item%s." % (count, plural_or_not(count))) + self._info(f"{count} item{s(count)}.") return count - def count_files_in_directory(self, path, pattern=None): + def count_files_in_directory(self, path: str, pattern: "str | None" = None) -> int: """Wrapper for `Count Items In Directory` returning only file count.""" count = len(self._list_files_in_dir(path, pattern)) - self._info("%s file%s." % (count, plural_or_not(count))) + self._info(f"{count} file{s(count)}.") return count - def count_directories_in_directory(self, path, pattern=None): + def count_directories_in_directory( + self, + path: str, + pattern: "str | None" = None, + ) -> int: """Wrapper for `Count Items In Directory` returning only directory count.""" count = len(self._list_dirs_in_dir(path, pattern)) - self._info("%s director%s." % (count, 'y' if count == 1 else 'ies')) + label = "directory" if count == 1 else "directories" + self._info(f"{count} {label}.") return count - def _list_dir(self, path, pattern=None, absolute=False): + def _list_dir( + self, + path: str, + pattern: "str | None" = None, + absolute: bool = False, + ) -> "list[str]": path = self._absnorm(path) self._link("Listing contents of directory '%s'.", path) if not os.path.isdir(path): - self._error("Directory '%s' does not exist." % path) - # result is already unicode but unic also handles NFC normalization - items = sorted(unic(item) for item in os.listdir(path)) + self._error(f"Directory '{path}' does not exist.") + # Result is already a string, but safe_str also handles NFC normalization. + items = sorted(safe_str(item) for item in os.listdir(path)) if pattern: items = [i for i in items if fnmatch.fnmatchcase(i, pattern)] - if is_truthy(absolute): + if absolute: path = os.path.normpath(path) items = [os.path.join(path, item) for item in items] return items - def _list_files_in_dir(self, path, pattern=None, absolute=False): - return [item for item in self._list_dir(path, pattern, absolute) - if os.path.isfile(os.path.join(path, item))] - - def _list_dirs_in_dir(self, path, pattern=None, absolute=False): - return [item for item in self._list_dir(path, pattern, absolute) - if os.path.isdir(os.path.join(path, item))] - - def touch(self, path): + def _list_files_in_dir( + self, + path: str, + pattern: "str | None" = None, + absolute: bool = False, + ) -> "list[str]": + return [ + item + for item in self._list_dir(path, pattern, absolute) + if os.path.isfile(os.path.join(path, item)) + ] + + def _list_dirs_in_dir( + self, + path: str, + pattern: "str | None" = None, + absolute: bool = False, + ) -> "list[str]": + return [ + item + for item in self._list_dir(path, pattern, absolute) + if os.path.isdir(os.path.join(path, item)) + ] + + def touch(self, path: str): """Emulates the UNIX touch command. - Creates a file, if it does not exist. Otherwise changes its access and + Creates a file, if it does not exist. Otherwise, changes its access and modification times to the current time. Fails if used with the directories or the parent directory of the given @@ -1391,89 +1487,37 @@ def touch(self, path): """ path = self._absnorm(path) if os.path.isdir(path): - self._error("Cannot touch '%s' because it is a directory." % path) + self._error(f"Cannot touch '{path}' because it is a directory.") if not os.path.exists(os.path.dirname(path)): - self._error("Cannot touch '%s' because its parent directory does " - "not exist." % path) + self._error( + f"Cannot touch '{path}' because its parent directory does not exist." + ) if os.path.exists(path): mtime = round(time.time()) os.utime(path, (mtime, mtime)) self._link("Touched existing file '%s'.", path) else: - open(path, 'w').close() + open(path, "w", encoding="ASCII").close() self._link("Touched new file '%s'.", path) - def _absnorm(self, path): - path = self.normalize_path(path) - try: - return abspath(path) - except ValueError: # http://ironpython.codeplex.com/workitem/29489 - return path + def _absnorm(self, path: str) -> str: + return abspath(self.normalize_path(path)) - def _fail(self, *messages): + def _fail(self, *messages: str) -> NoReturn: raise AssertionError(next(msg for msg in messages if msg)) - def _error(self, msg): + def _error(self, msg: str) -> NoReturn: raise RuntimeError(msg) - def _info(self, msg): - self._log(msg, 'INFO') + def _info(self, msg: str): + self._log(msg, "INFO") - def _link(self, msg, *paths): - paths = tuple('%s' % (p, p) for p in paths) - self._log(msg % paths, 'HTML') + def _link(self, msg: str, *paths: str): + paths = tuple(f'{p}' for p in paths) + self._log(msg % paths, "HTML") - def _warn(self, msg): - self._log(msg, 'WARN') + def _warn(self, msg: str): + self._log(msg, "WARN") - def _log(self, msg, level): + def _log(self, msg: str, level: logger.LogLevel): logger.write(msg, level) - - -class _Process: - - def __init__(self, command): - self._command = self._process_command(command) - self._process = os.popen(self._command) - - def __str__(self): - return self._command - - def read(self): - return self._process_output(self._process.read()) - - def close(self): - try: - rc = self._process.close() - except IOError: # Has occurred sometimes in Windows - return 255 - if rc is None: - return 0 - # In Windows (Python and Jython) return code is value returned by - # command (can be almost anything) - # In other OS: - # In Jython return code can be between '-255' - '255' - # In Python return code must be converted with 'rc >> 8' and it is - # between 0-255 after conversion - if WINDOWS or JYTHON: - return rc % 256 - return rc >> 8 - - def _process_command(self, command): - if '>' not in command: - if command.endswith('&'): - command = command[:-1] + ' 2>&1 &' - else: - command += ' 2>&1' - return self._encode_to_file_system(command) - - def _encode_to_file_system(self, string): - enc = sys.getfilesystemencoding() if PY2 else None - return string.encode(enc) if enc else string - - def _process_output(self, output): - if '\r\n' in output: - output = output.replace('\r\n', '\n') - if output.endswith('\n'): - output = output[:-1] - return console_decode(output, force=True) diff --git a/src/robot/libraries/Process.py b/src/robot/libraries/Process.py index ee8aab03afe..7a23553c4e7 100644 --- a/src/robot/libraries/Process.py +++ b/src/robot/libraries/Process.py @@ -13,922 +13,1426 @@ # See the License for the specific language governing permissions and # limitations under the License. -import ctypes import os +import signal as signal_module import subprocess +import sys import time -import signal as signal_module +from collections.abc import Sequence +from datetime import timedelta +from io import IOBase +from pathlib import Path +from tempfile import NamedTemporaryFile +from typing import Literal, Union -from robot.utils import (ConnectionCache, abspath, cmdline2list, console_decode, - is_list_like, is_truthy, NormalizedDict, py2to3, - secs_to_timestr, system_decode, system_encode, - timestr_to_secs, IRONPYTHON, JYTHON, WINDOWS) -from robot.version import get_version from robot.api import logger +from robot.api.deco import library +from robot.api.types import Secret +from robot.errors import TimeoutExceeded +from robot.utils import ( + cmdline2list, ConnectionCache, console_decode, console_encode, NormalizedDict, + secs_to_timestr, system_decode, system_encode, WINDOWS +) +from robot.version import get_version + +LOCALE_ENCODING = "locale" if sys.version_info >= (3, 10) else None +Handle = Union[subprocess.Popen, str, None] +Stdin = Union[str, Secret, Path, int, IOBase, None] +OnTimeout = Literal["continue", "terminate", "kill"] + + +class ProcessResult: + r"""An object containing process execution results in its attributes. + + | Attribute | Explanation | + |-------------|---------------------------------------------------------------| + | rc | Return code of the process as an integer. | + | stdout | Contents of the standard output stream. | + | stderr | Contents of the standard error stream. | + | stdout_path | Path where stdout was redirected or `None` if not redirected. | + | stderr_path | Path where stderr was redirected or `None` if not redirected. | + + Example: + + ```robotframework + *** Test Cases *** + Run process + ${result} = Run Process python -c print("Hello!") + Should Be Equal ${result.rc} 0 type=int + Should Be Equal ${result.stdout} Hello! + Should Be Empty ${result.stderr} + ``` + + Notice that in `stdout` and `stderr` content possible trailing newline + is removed and `\r\n` converted to `\n` automatically. If you need to see + the original process output, redirect it to a file using + [process configuration] and read it from there. + """ + + def __init__( + self, + process, + stdout, + stderr, + stdin=None, + rc=None, + output_encoding=None, + streams_to_close=(), + ): + self._process = process + self.stdout_path = self._get_path(stdout) + self.stderr_path = self._get_path(stderr) + self.rc = rc + self._output_encoding = output_encoding + self._stdout = None + self._stderr = None + self._custom_streams = [ + stream + for stream in (stdout, stderr, stdin) + if self._is_custom_stream(stream) + ] + self._streams_to_close = streams_to_close + + def _get_path(self, stream): + return stream.name if self._is_custom_stream(stream) else None + + def _is_custom_stream(self, stream): + return stream not in (subprocess.PIPE, subprocess.STDOUT, None) + + @property + def stdout(self): + if self._stdout is None: + self._read_stdout() + return self._stdout + + @stdout.setter + def stdout(self, stdout): + self._stdout = self._format_output(stdout) + + @property + def stderr(self): + if self._stderr is None: + self._read_stderr() + return self._stderr + + @stderr.setter + def stderr(self, stderr): + self._stderr = self._format_output(stderr) + + def _read_stdout(self): + self._stdout = self._read_stream(self.stdout_path, self._process.stdout) + + def _read_stderr(self): + self._stderr = self._read_stream(self.stderr_path, self._process.stderr) + + def _read_stream(self, stream_path, stream): + if stream_path: + stream = open(stream_path, "rb") + elif not self._is_open(stream): + return "" + try: + content = stream.read() + except IOError: + content = "" + finally: + if stream_path: + stream.close() + return self._format_output(content) + + def _is_open(self, stream): + return stream and not stream.closed + + def _format_output(self, output): + if output is None: + return None + output = console_decode(output, self._output_encoding).replace("\r\n", "\n") + if output.endswith("\n"): + output = output[:-1] + return output + + def close_streams(self): + if self._is_open(self._process.stdout): + self._read_stdout() + if self._is_open(self._process.stderr): + self._read_stderr() + for stream in self._streams_to_close: + stream.close() + self._streams_to_close = () + + def __str__(self): + return f"" + + +class ProcessConfiguration: + + def __init__( + self, + cwd=None, + shell=False, + stdout=None, + stderr=None, + stdin=None, + output_encoding="CONSOLE", + alias=None, + env=None, + **env_extra, + ): + self.cwd = os.path.normpath(cwd) if cwd else os.path.abspath(".") + self.shell = shell + self.alias = alias + self.output_encoding = output_encoding + self.streams_to_close = [] + self.stdout_stream = self._new_stream(stdout) + self.stderr_stream = self._get_stderr(stderr, stdout, self.stdout_stream) + self.stdin_stream = self._get_stdin(stdin) + self.secret_env_keys = [] + self.env = self._construct_env(env, env_extra) + + def _new_stream(self, name): + if not name: + return subprocess.PIPE + if name == "DEVNULL": + stream = open(os.devnull, "w", encoding=LOCALE_ENCODING) + else: + path = os.path.normpath(os.path.join(self.cwd, name)) + stream = open(path, "w", encoding=LOCALE_ENCODING) + self.streams_to_close.append(stream) + return stream + + def _get_stderr(self, stderr, stdout, stdout_stream): + if stderr and stderr in ["STDOUT", stdout]: + if stdout_stream == subprocess.PIPE: + return subprocess.STDOUT + return stdout_stream + return self._new_stream(stderr) + def _get_stdin(self, stdin): + if isinstance(stdin, Path): + stdin = str(stdin) + elif isinstance(stdin, Secret): + stdin = stdin.value + elif not isinstance(stdin, str): + return stdin + elif stdin.upper() == "NONE": + return None + elif stdin == "PIPE": + return subprocess.PIPE + path = os.path.normpath(os.path.join(self.cwd, stdin)) + if os.path.isfile(path): + stdin_file = open(path, encoding=LOCALE_ENCODING) + else: + stdin_file = NamedTemporaryFile(prefix="stdin-") + stdin_file.write(console_encode(stdin, self.output_encoding, force=True)) + stdin_file.seek(0) + self.streams_to_close.append(stdin_file) + return stdin_file -class Process(object): - """Robot Framework test library for running processes. + def _construct_env(self, env, extra): + env = self._get_initial_env(env, extra) + if env is None: + return None + if WINDOWS: + env = NormalizedDict(env, spaceless=False) + self._add_to_env(env, extra) + if WINDOWS: + env = {key.upper(): env[key] for key in env} + return env - This library utilizes Python's - [http://docs.python.org/library/subprocess.html|subprocess] - module and its - [http://docs.python.org/library/subprocess.html#popen-constructor|Popen] - class. + def _get_initial_env(self, env, extra): + if env: + result = {} + for name, value in env.items(): + name = system_encode(name) + if isinstance(value, Secret): + result[name] = system_encode(value.value) + self.secret_env_keys.append(name) + else: + result[name] = system_encode(value) + return result + if extra: + return os.environ.copy() + return None + + def _add_to_env(self, env, extra): + for name, value in extra.items(): + if not name.startswith("env:"): + raise RuntimeError( + f"Keyword argument '{name}' is not supported by this keyword." + ) + name = system_encode(name[4:]) + if isinstance(value, Secret): + env[name] = system_encode(value.value) + self.secret_env_keys.append(name) + else: + env[name] = system_encode(value) + + def get_command(self, command, arguments): + command = [system_encode(item) for item in (command, *arguments)] + if not self.shell: + return command + if arguments: + return subprocess.list2cmdline(command) + return command[0] + + @property + def popen_config(self): + config = { + "stdout": self.stdout_stream, + "stderr": self.stderr_stream, + "stdin": self.stdin_stream, + "shell": self.shell, + "cwd": self.cwd, + "env": self.env, + } + self._add_process_group_config(config) + return config + + def _add_process_group_config(self, config): + if hasattr(os, "setsid"): + config["start_new_session"] = True + if hasattr(subprocess, "CREATE_NEW_PROCESS_GROUP"): + config["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP + + @property + def result_config(self): + return { + "stdout": self.stdout_stream, + "stderr": self.stderr_stream, + "stdin": self.stdin_stream, + "output_encoding": self.output_encoding, + "streams_to_close": tuple(self.streams_to_close), + } + + def __str__(self): + if self.secret_env_keys: + secret_env = dict.fromkeys(self.secret_env_keys, str(Secret(""))) + env = {**self.env, **secret_env} + else: + env = 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: {env}""" + + def _stream_name(self, stream): + if hasattr(stream, "name"): + return stream.name + return { + subprocess.PIPE: "PIPE", + subprocess.STDOUT: "STDOUT", + None: "None", + }.get(stream, stream) + + +# Dummy argument converter for providing type documentation to Popen. +def _popen_documentation(value): + """[subprocess.Popen] object. + + Returned by [Start Process]. Can be used as a `handle` with keywords + like [Wait For Process] when working with multiple concurrent processes. + Attributes like `pid` and `stdin` can also be accessed directly. + + Examples: + + ```robotframework + *** Test Cases *** + Popen Object + ${process} = Start Process ${command} + Log PID: ${process.pid} + # Later + ${result} = Terminate Process ${process} + ``` + """ + return value + + +@library( + version=get_version(), + scope="GLOBAL", + doc_format="MARKDOWN", + converters={ + ProcessResult: lambda value: value, + subprocess.Popen: _popen_documentation, + }, + auto_keywords=True, +) +class Process: + r"""Robot Framework library for running processes. The library has following main usages: - - Running processes in system and waiting for their completion using - `Run Process` keyword. - - Starting processes on background using `Start Process`. - - Waiting started process to complete using `Wait For Process` or - stopping them with `Terminate Process` or `Terminate All Processes`. + - Running processes in the system and waiting for their completion using + the [Run Process] keyword. + - Starting processes on background using the [Start Process] keyword. + - Waiting started process to complete using [Wait For Process] or + stopping them with [Terminate Process] or [Terminate All Processes]. + + This library provides various benefits over using `Run` and other similar + keywords in the [OperatingSystem] library: + + - Better [process configuration]. + - Convenient [result object] with all result information (`rc`, `stdout`, + `stderr`). + - Support for background processes, process termination, sending signals + and so on. + + This library uses Python's standard [subprocess] module internally. - == Table of contents == + ### Table of contents - - `Specifying command and arguments` - - `Process configuration` - - `Active process` - - `Result object` - - `Boolean arguments` - - `Example` - - `Shortcuts` - - `Keywords` + %TOC% - = Specifying command and arguments = + # Specifying command and arguments - Both `Run Process` and `Start Process` accept the command to execute and + Both [Run Process] and [Start Process] accept the command to execute and all arguments passed to the command as separate arguments. This makes usage convenient and also allows these keywords to automatically escape possible spaces and other special characters in commands and arguments. Notice that if a command accepts options that themselves accept values, these options and their values must be given as separate arguments. - When `running processes in shell`, it is also possible to give the whole + When [running processes in shell], it is also possible to give the whole command to execute as a single string. The command can then contain multiple commands to be run together. When using this approach, the caller is responsible on escaping. Examples: - | `Run Process` | ${tools}${/}prog.py | argument | second arg with spaces | - | `Run Process` | java | -jar | ${jars}${/}example.jar | --option | value | - | `Run Process` | prog.py "one arg" && tool.sh | shell=yes | cwd=${tools} | + + ```robotframework + *** Test Cases *** + Command and arguments separately + Run Process python -c print("Hello!") + Run Process ${tools}/prog.py argument second arg with spaces + + Command and arguments as single string + Run Process prog.py "one arg" && tool.sh shell=True cwd=${tools} + ``` Possible non-string arguments are converted to strings automatically. - = Process configuration = - - `Run Process` and `Start Process` keywords can be configured using - 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. - - | = Name = | = Explanation = | - | shell | Specifies whether to run the command in shell or not. | - | cwd | Specifies the working directory. | - | env | Specifies environment variables given to the process. | - | env: | Overrides the named environment variable(s) only. | - | stdout | Path of a file where to write standard output. | - | stderr | Path of a file where to write standard error. | - | output_encoding | Encoding to use when reading command outputs. | - | alias | Alias given to the process. | - - Note that because ``**configuration`` is passed using ``name=value`` syntax, - possible equal signs in other arguments passed to `Run Process` and - `Start Process` must be escaped with a backslash like ``name\\=value``. - See `Run Process` for an example. - - == 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, - like ``copy`` and ``dir`` on Windows, are not available. You can, however, + # Process configuration + + [Run Process] and [Start Process] keywords can be configured using + optional configuration arguments. These arguments must be given + after other arguments passed to these keywords and must use the + `name=value` syntax. Available configuration arguments are + listed below and discussed further in the subsequent sections. + + | Name | Explanation | + |-----------------|----------------------------------------------------------| + | shell | Whether to run the command in a shell or not. | + | cwd | Directory where to run the command. | + | env | Environment variables as a dictionary. | + | **env_extra | Environment variables using `env:=` syntax. | + | stdout | Path to a file where to write standard output. | + | stderr | Path to a file where to write standard error. | + | stdin | Configure process standard input. New in RF 4.1.2. | + | output_encoding | Encoding to use when reading command outputs. | + | alias | A custom name given to the process. | + + Note that possible equal signs in other arguments passed to the [Run Process] + and [Start Process] keywords must be escaped with a backslash like `name\=value`. + See the [Run Process] keyword for an example. + + ## 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, + like `copy` and `dir` on Windows, are not available. You can, however, run shell scripts and batch files without using a shell. - Giving the ``shell`` argument any non-false value, such as ``shell=True``, - changes the program to be executed in a shell. It allows using the shell - capabilities, but can also make the process invocation operating system - dependent. Having a shell between the actually started process and this - library can also interfere communication with the process such as stopping - it and reading its outputs. Because of these problems, it is recommended - to use the shell only when absolutely necessary. + Using `shell=True` changes the program to be executed in a shell. It allows + using the shell capabilities, but can also make the process invocation + operating system dependent. Having a shell between the actually started + process and this library can also interfere communication with the process + such as stopping it and reading its outputs. Because of these problems, + it is recommended to use the shell only when absolutely necessary. When using a shell it is possible to give the whole command to execute - as a single string. See `Specifying command and arguments` section for + as a single string. See the [Specifying command and arguments] section for examples and more details in general. - == Current working directory == + ## Current working directory - By default the child process will be executed in the same directory - as the parent process, the process running tests, is executed. This - can be changed by giving an alternative location using the ``cwd`` argument. + By default, the child process will be executed in the same directory + as the parent process, the process running Robot Framework, is executed. This + can be changed by giving an alternative location using the `cwd` argument. Forward slashes in the given path are automatically converted to backslashes on Windows. - `Standard output and error streams`, when redirected to files, - are also relative to the current working directory possibly set using - the ``cwd`` argument. + When [standard output and error streams] are redirected to files, + file paths are relative to the current working directory as well. Example: - | `Run Process` | prog.exe | cwd=${ROOT}/directory | stdout=stdout.txt | - == Environment variables == + ```robotframework + *** Test Cases *** + Current working directory + Run Process prog.exe cwd=${ROOT}/directory stdout=stdout.txt + ``` - 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 + ## Environment variables + + 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:=`` format to set or override only that named variables. + `env:=` format to set or override only that named variables. It is also possible to use these two approaches together. Examples: - | `Run Process` | program | env=${environ} | - | `Run Process` | program | env:http_proxy=10.144.1.10:8080 | env:PATH=%{PATH}${:}${PROGDIR} | - | `Run Process` | program | env=${environ} | env:EXTRA=value | - == Standard output and error streams == + ```robotframework + *** Test Cases *** + Environment variables + Run Process program env=${environ} + Run Process program env:http_proxy=10.144.1.10:8080 env:PATH=%{PATH}${:}${PROGDIR} + Run Process program env=${environ} env:EXTRA=value + ``` - 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. Additionally on Jython, everything written to - these in-memory buffers can be lost if the process is terminated. + ## Standard output and error streams - 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. + By default, processes are run so that their standard output and standard + error streams are kept in the memory. This typically works fine, but there + can be problems if the amount of output is large or unlimited. Prior to + Robot Framework 7.3, the limit was smaller than nowadays and reaching it + caused a deadlock. - Given ``stdout`` and ``stderr`` paths are relative to the `current working - directory`. Forward slashes in the given paths are automatically converted - to backslashes on Windows. + 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 output. This can also be useful if other processes or + other keywords need to read or manipulate the output somehow. - As a special feature, it is possible to redirect the standard error to - the standard output by using ``stderr=STDOUT``. + Given `stdout` and `stderr` paths are relative to the [current working directory]. + Forward slashes in the given paths are automatically converted to backslashes + on Windows. Regardless are outputs redirected to files or not, they are accessible - through the `result object` returned when the process ends. Commands are - expected to write outputs using the console encoding, but `output encoding` - can be configured using the ``output_encoding`` argument if needed. + through the [result object] returned when the process ends. Commands are + expected to write outputs using the console encoding, but [output encoding] + can be configured using the `output_encoding` argument if needed. + + As a special feature, it is possible to redirect the standard error to + the standard output by using `stderr=STDOUT`. + + If you are not interested in outputs at all, you can explicitly ignore it by + using a special value `DEVNULL` both with `stdout` and `stderr`. For + example, `stdout=DEVNULL` is the same as redirecting output on console + with `> /dev/null` on UNIX-like operating systems or `> NUL` on Windows. + This way even a huge amount of output cannot cause problems, but naturally + the output is not available after execution either. Examples: - | ${result} = | `Run Process` | program | stdout=${TEMPDIR}/stdout.txt | stderr=${TEMPDIR}/stderr.txt | - | `Log Many` | stdout: ${result.stdout} | stderr: ${result.stderr} | - | ${result} = | `Run Process` | program | stderr=STDOUT | - | `Log` | all output: ${result.stdout} | + + ```robotframework + *** Test Cases *** + Redirect stdout and stderr + ${result} = Run Process program stdout=${TMP}/stdout.txt stderr=${TMP}/stderr.txt + + Merge stderr to stdout + ${result} = Run Process program stderr=STDOUT + + Discard output + ${result} = Run Process program stdout=DEVNULL stderr=DEVNULL + ``` Note that the created output files are not automatically removed after - the test run. The user is responsible to remove them if needed. + execution. The user is responsible to remove them if needed. + + ## Standard input stream + + The `stdin` argument makes it possible to pass information to the standard + input stream of the started process. How its value is interpreted is + explained in the table below. - == Output encoding == + | Value | Explanation | + |------------------|-------------------------------------------------------------| + | 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 | Write the text to a temporary file and use it as the stdin. | + | Any other value | Used as-is. Could be a file descriptor, stdout of another process, etc. | + + Values `PIPE` and `NONE` are case-insensitive and internally mapped to + `subprocess.PIPE` and `None`, respectively. + + Examples: + + ```robotframework + *** Test Cases *** + Stdin as file + Run Process command stdin=${CURDIR}/stdin.txt + + Stdin as text + Run Process command stdin=Stdin as text. + + Stdin from other process + ${process} = Start Process command1 + Run Process command2 stdin=${process.stdout} + ``` + + The [Examples] section has an executable example where output of a process + is passed to another using `stdin`. + + 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 Executed commands are, by default, expected to write outputs to the - `standard output and error streams` using the encoding used by the + [standard output and error streams] using the encoding used by the system console. If the command uses some other encoding, that can be - configured using the ``output_encoding`` argument. This is especially - useful on Windows where the console uses a different encoding than rest + configured using the `output_encoding` argument. This is especially + useful on Windows where the console can use a different encoding than rest of the system, and many commands use the general system encoding instead of the console encoding. - The value used with the ``output_encoding`` argument must be a valid + The value used with the `output_encoding` argument must be a valid encoding and must match the encoding actually used by the command. As a - convenience, it is possible to use strings ``CONSOLE`` and ``SYSTEM`` + convenience, it is possible to use strings `CONSOLE` and `SYSTEM` to specify that the console or system encoding is used, respectively. If produced outputs use different encoding then configured, values got - through the `result object` will be invalid. + through the [result object] will be invalid. Examples: - | `Start Process` | program | output_encoding=UTF-8 | - | `Run Process` | program | stdout=${path} | output_encoding=SYSTEM | - The support to set output encoding is new in Robot Framework 3.0. + ```robotframework + *** Test Cases *** + Output encoding + Start Process program output_encoding=UTF-8 + Run Process program stdout=${path} output_encoding=SYSTEM + ``` - == Alias == + ## Alias A custom name given to the process that can be used when selecting the - `active process`. + [active process]. Examples: - | `Start Process` | program | alias=example | - | `Run Process` | python | -c | print 'hello' | alias=hello | - = Active process = + ```robotframework + *** Test Cases *** + Alias + Start Process program alias=MyProgram + # Later + Wait For Process MyProgram + ``` - The test library keeps record which of the started processes is currently - active. By default it is latest process started with `Start Process`, - but `Switch Process` can be used to select a different one. Using - `Run Process` does not affect the active process. + # Active process + + The library keeps record which of the started processes is currently active. + By default, it is the latest process started with [Start Process], + but [Switch Process] can be used to activate a different process. Using + [Run Process] does not affect the active process. The keywords that operate on started processes will use the active process by default, but it is possible to explicitly select a different process - using the ``handle`` argument. The handle can be the identifier returned by - `Start Process` or an ``alias`` explicitly given to `Start Process` or - `Run Process`. + using the `handle` argument. The handle can be an [alias] explicitly + given to [Start Process] or the process object returned by it. - = Result object = + # Result object - `Run Process`, `Wait For Process` and `Terminate Process` keywords return a + [Run Process], [Wait For Process] and [Terminate Process] keywords return a result object that contains information about the process execution as its attributes. The same result object, or some of its attributes, can also - be get using `Get Process Result` keyword. Attributes available in the + be get using [Get Process Result] keyword. Attributes available in the object are documented in the table below. - | = Attribute = | = Explanation = | - | rc | Return code of the process as an integer. | - | stdout | Contents of the standard output stream. | - | stderr | Contents of the standard error stream. | - | stdout_path | Path where stdout was redirected or ``None`` if not redirected. | - | stderr_path | Path where stderr was redirected or ``None`` if not redirected. | + | Attribute | Explanation | + |-------------|---------------------------------------------------------------| + | rc | Return code of the process as an integer. | + | stdout | Contents of the standard output stream. | + | stderr | Contents of the standard error stream. | + | stdout_path | Path where stdout was redirected or `None` if not redirected. | + | stderr_path | Path where stderr was redirected or `None` if not redirected. | Example: - | ${result} = | `Run Process` | program | - | `Should Be Equal As Integers` | ${result.rc} | 0 | - | `Should Match` | ${result.stdout} | Some t?xt* | - | `Should Be Empty` | ${result.stderr} | | - | ${stdout} = | `Get File` | ${result.stdout_path} | - | `Should Be Equal` | ${stdout} | ${result.stdout} | - | `File Should Be Empty` | ${result.stderr_path} | | - - = Boolean arguments = - - Some keywords accept arguments that are handled as Boolean values true or - false. If such an argument is given as a string, it is considered false if - it is an empty string or equal to ``FALSE``, ``NONE``, ``NO``, ``OFF`` or - ``0``, case-insensitively. Other strings are considered true regardless - their value, and other argument types are tested using the same - [http://docs.python.org/library/stdtypes.html#truth|rules as in Python]. - - True examples: - | `Terminate Process` | kill=True | # Strings are generally true. | - | `Terminate Process` | kill=yes | # Same as the above. | - | `Terminate Process` | kill=${TRUE} | # Python ``True`` is true. | - | `Terminate Process` | kill=${42} | # Numbers other than 0 are true. | - - False examples: - | `Terminate Process` | kill=False | # String ``false`` is false. | - | `Terminate Process` | kill=no | # Also string ``no`` is false. | - | `Terminate Process` | kill=${EMPTY} | # Empty string is false. | - | `Terminate Process` | kill=${FALSE} | # Python ``False`` is false. | - - Considering string ``NONE`` false is new in Robot Framework 3.0.3 and - considering also ``OFF`` and ``0`` false is new in Robot Framework 3.1. - - = Example = - - | ***** Settings ***** - | Library Process - | Suite Teardown `Terminate All Processes` kill=True - | - | ***** Test Cases ***** - | Example - | `Start Process` program arg1 arg2 alias=First - | ${handle} = `Start Process` command.sh arg | command2.sh shell=True cwd=/path - | ${result} = `Run Process` ${CURDIR}/script.py - | `Should Not Contain` ${result.stdout} FAIL - | `Terminate Process` ${handle} - | ${result} = `Wait For Process` First - | `Should Be Equal As Integers` ${result.rc} 0 + + ```robotframework + *** Test Cases *** + Result object + ${result} = Run Process python -c print("Hello!") + Should Be Equal ${result.rc} 0 type=int + Should Be Equal ${result.stdout} Hello! + Should Be Empty ${result.stderr} + ``` + + Notice that in `stdout` and `stderr` content possible trailing newline + is removed and `\r\n` converted to `\n` automatically. If you need to see + the original process output, redirect it to a file using + [process configuration] and read it from there. + + # Examples + + These examples can be executed locally. + + ```robotframework + *** Settings *** + Library Process + Suite Teardown Terminate All Processes kill=True + + *** Test Cases *** + Simple execution + ${result} = Run Process python -c print("Hello, world!") + Should Be Equal ${result.stdout} Hello, world! + + Background processes and process configuration + ${process} = Start Process python -c print("Hello!") + Start Process + ... python -c "import time; time.sleep(3)" && python -c "print('Hi!')" + ... stdout=${CURDIR}/stdout.txt + ... stderr=STDOUT + ... shell=True + ... alias=Slow + ${result1} = Run Process python -c print("Moi!") + Should Be Equal ${result1.stdout} Moi! + ${result2} = Wait For Process ${process} + Should Be Equal ${result2.stdout} Hello! + ${result3} = Wait For Process Slow + Should Be Equal ${result3.stdout} Hi! + + Pipeline + ${process} = Start Process python -c print("Hello, world!") + ${result} = Run Process + ... python -c import sys; print(sys.stdin.read().upper().strip()) + ... stdin=${process.stdout} + Wait For Process ${process} + Should Be Equal ${result.stdout} HELLO, WORLD! + ``` + + [OperatingSystem]: https://robotframework.org/robotframework/latest/libraries/OperatingSystem.html + [subprocess]: https://docs.python.org/library/subprocess.html + [subprocess.Popen]: https://docs.python.org/3/library/subprocess.html#popen-objects """ - ROBOT_LIBRARY_SCOPE = 'GLOBAL' - ROBOT_LIBRARY_VERSION = get_version() + TERMINATE_TIMEOUT = 30 KILL_TIMEOUT = 10 def __init__(self): - self._processes = ConnectionCache('No active process.') - self._results = {} + self._processes = ConnectionCache[subprocess.Popen]("No active process.") + self._results: dict[subprocess.Popen, ProcessResult] = {} + + def run_process( + self, + command: str, + *arguments: "str | Secret", + cwd: "str | None" = None, + shell: bool = False, + stdout: "str | None" = None, + stderr: "str | None" = None, + stdin: Stdin = None, + output_encoding: str = "CONSOLE", + alias: "str | None" = None, + timeout: "timedelta | None" = None, + on_timeout: OnTimeout = "terminate", + env: "dict[str, str | Secret] | None" = None, + **env_extra: "str | Secret", + ) -> ProcessResult: + r"""Runs a process and waits for it to complete. + + Args: + command: Command to execute. + *arguments: Arguments passed to the command. + cwd: Working directory of the process. + shell: Whether to run the command in a shell. + stdout: Path to file for standard output. + stderr: Path to file for standard error. + stdin: Process standard input configuration. + output_encoding: Encoding for reading command outputs. + alias: Custom name for the process. + timeout: Maximum time to wait for the process. + on_timeout: Action when timeout occurs. + env: Environment variables as a Python dictionary. + **env_extra: Override named environment variables using + `env:=` syntax. + + Returns: + Result object containing execution results. + + `command` and `arguments` specify the command to execute and arguments + passed to it. See the [Specifying command and arguments] section for + more details. - def run_process(self, command, *arguments, **configuration): - """Runs a process and waits for it to complete. + The started process can be configured using the `cwd`, `shell`, `stdout`, + `stderr`, `stdin`, `output_encoding`, `env`, `env_extra` and `alias` + parameters that are documented in the [Process configuration] section. - ``command`` and ``*arguments`` specify the command to execute and - arguments passed to it. See `Specifying command and arguments` for - more details. + Configuration related to waiting for processes consists of `timeout` + and `on_timeout` parameters that have same semantics than with the + [Wait For Process] keyword. - ``**configuration`` contains additional configuration related to - starting processes and waiting for them to finish. See `Process - 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 - if timeout is defined the default action on timeout is ``terminate``. + Process outputs are, by default, written into in-memory buffers. + This typically works fine, but there can be problems if the amount of + output is large or unlimited. To avoid such problems, outputs can be + redirected to files using the `stdout` and `stderr` configuration + parameters. See the [Standard output and error streams] section for + more information. - Returns a `result object` containing information about the execution. + Note that possible equal signs in `command` and `arguments` must + be escaped with a backslash (e.g. `name\=value`). - Note that possible equal signs in ``*arguments`` must be escaped - with a backslash (e.g. ``name\\=value``) to avoid them to be passed in - as ``**configuration``. + This keyword does not change the [active process]. 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} | timeout=1min | on_timeout=continue | - | ${result} = | Run Process | java -Dname\\=value Example | shell=True | cwd=${EXAMPLE} | - This keyword does not change the `active process`. + ```robotframework + *** Test Cases *** + Run process + ${result} = Run Process python -c print("Hello, world!") + Should Be Equal ${result.stdout} Hello, world! + ${result} = Run Process ${command} stdout=${CURDIR}/stdout.txt stderr=STDOUT + ${result} = Run Process ${command} timeout=1 minute on_timeout=continue + ${result} = Run Process java -Dname\=value Example shell=True cwd=${EXAMPLE} + ``` """ current = self._processes.current - timeout = configuration.pop('timeout', None) - on_timeout = configuration.pop('on_timeout', 'terminate') try: - handle = self.start_process(command, *arguments, **configuration) + handle = self.start_process( + command, + *arguments, + cwd=cwd, + shell=shell, + stdout=stdout, + stderr=stderr, + stdin=stdin, + output_encoding=output_encoding, + alias=alias, + env=env, + **env_extra, + ) return self.wait_for_process(handle, timeout, on_timeout) finally: self._processes.current = current - def start_process(self, command, *arguments, **configuration): + def start_process( + self, + command: str, + *arguments: "str | Secret", + cwd: "str | None" = None, + shell: bool = False, + stdout: "str | None" = None, + stderr: "str | None" = None, + stdin: Stdin = None, + output_encoding: str = "CONSOLE", + alias: "str | None" = None, + env: "dict[str, str | Secret] | None" = None, + **env_extra: "str | Secret", + ) -> subprocess.Popen: """Starts a new process on background. - See `Specifying command and arguments` and `Process configuration` - for more information about the arguments, and `Run Process` keyword - for related examples. - - Makes the started process new `active process`. Returns an identifier - that can be used as a handle to activate the started process if needed. + Args: + command: Command to execute. + *arguments: Arguments passed to the command. + cwd: Working directory for the process. + shell: Whether to run the command in a shell. + stdout: Path to file for standard output. + stderr: Path to file for standard error. + stdin: Process standard input configuration. + output_encoding: Encoding for reading command outputs. + alias: Custom name for the process. + env: Environment variables as a Python dictionary. + **env_extra: Override named environment variables using + `env:=` syntax. + + Returns: + Created [subprocess.Popen] object which can be used later to activate + this process. `Popen` attributes like `pid` can also be accessed directly. + + See the [Specifying command and arguments] and [Process configuration] + sections for more information about the arguments, and the [Run Process] + keyword for related examples. This includes information about redirecting + process outputs to avoid process handing due to output buffers getting + full. Processes are started so that they create a new process group. This - allows sending signals to and terminating also possible child - processes. This is not supported on Jython. + allows terminating and sending signals to possible child processes. + + Makes the started process new [active process]. + + Examples: + + ```robotframework + *** Test Cases *** + Start process and wait using alias + Start Process ${command} alias=example + # Later + ${result} = Wait For Process example + + Start process and terminate using process object + ${process} = Start Process ${command} + Log PID: ${process.pid} + # Later + Terminate Process ${process} + ``` + + See the [Examples] section for examples starting multiple processes + and creating process pipelines. + + Returning a [subprocess.Popen] object is new in Robot Framework 5.0. + Earlier versions returned a generic handle and getting the process object + required using the [Get Process Object] keyword separately. """ - conf = ProcessConfiguration(**configuration) - command = conf.get_command(command, list(arguments)) - self._log_start(command, conf) - process = subprocess.Popen(command, **conf.popen_config) - self._results[process] = ExecutionResult(process, **conf.result_config) - return self._processes.register(process, alias=conf.alias) - - def _log_start(self, command, config): - if is_list_like(command): - command = self.join_command_line(command) - logger.info(u'Starting process:\n%s' % system_decode(command)) - logger.debug(u'Process configuration:\n%s' % config) - - def is_process_running(self, handle=None): + config = ProcessConfiguration( + cwd=cwd, + shell=shell, + stdout=stdout, + stderr=stderr, + stdin=stdin, + output_encoding=output_encoding, + alias=alias, + env=env, + **env_extra, + ) + actual_arguments = [] + logged_arguments = [] + for arg in arguments: + actual_arguments.append(arg.value if isinstance(arg, Secret) else arg) + logged_arguments.append(str(arg)) + self._log_start(config.get_command(command, logged_arguments), config) + process = subprocess.Popen( + config.get_command(command, actual_arguments), **config.popen_config + ) + self._results[process] = ProcessResult(process, **config.result_config) + self._processes.register(process, alias=config.alias) + return self._processes.current + + def _log_start(self, command: "str | list[str]", config: ProcessConfiguration): + if isinstance(command, list): + command = self.join_command_line(*command) + logger.info(f"Starting process:\n{system_decode(command)}") + logger.debug(f"Process configuration:\n{config}") + + def is_process_running(self, handle: Handle = None) -> bool: """Checks is the process running or not. - If ``handle`` is not given, uses the current `active process`. + Args: + handle: Process handle or alias. Uses the current [active process] + by default. - Returns ``True`` if the process is still running and ``False`` otherwise. + Returns: + `True` if the process is still running and `False` otherwise. """ return self._processes[handle].poll() is None - def process_should_be_running(self, handle=None, - error_message='Process is not running.'): + def process_should_be_running( + self, + handle: Handle = None, + error_message: str = "Process is not running.", + ): """Verifies that the process is running. - If ``handle`` is not given, uses the current `active process`. + Args: + handle: Process handle or alias. Uses the current [active process] + by default. + error_message: Message used if verification fails. - Fails if the process has stopped. + Raises: + AssertionError: If the process has stopped. """ if not self.is_process_running(handle): raise AssertionError(error_message) - def process_should_be_stopped(self, handle=None, - error_message='Process is running.'): + def process_should_be_stopped( + self, + handle: Handle = None, + error_message: str = "Process is running.", + ): """Verifies that the process is not running. - If ``handle`` is not given, uses the current `active process`. + Args: + handle: Process handle or alias. Uses the current [active process] + by default. + error_message: Message used if verification fails. - Fails if the process is still running. + Raises: + AssertionError: If the process is still running. """ if self.is_process_running(handle): raise AssertionError(error_message) - def wait_for_process(self, handle=None, timeout=None, on_timeout='continue'): + def wait_for_process( + self, + handle: Handle = None, + timeout: "timedelta | None" = None, + on_timeout: OnTimeout = "continue", + ) -> "ProcessResult | None": """Waits for the process to complete or to reach the given timeout. - The process to wait for must have been started earlier with - `Start Process`. If ``handle`` is not given, uses the current - `active process`. + Args: + handle: Process handle or alias. Uses the current [active process] + by default. + timeout: Maximum time to wait for the process. + on_timeout: What to do if the process does not end within the `timeout`. + + Returns: + Result object if the process ends, `None` if it is left running. + + The process to wait for must have been started earlier using the + [Start Process] keyword. - ``timeout`` defines the maximum time to wait for the process. It can be - given in - [http://robotframework.org/robotframework/latest/RobotFrameworkUserGuide.html#time-format| - various time formats] supported by Robot Framework, for example, ``42``, - ``42 s``, or ``1 minute 30 seconds``. + `timeout` defines the maximum time to wait for the process. It can be + given in formats like `42`, `42 s`, or `1 minute 30 seconds`. The timeout + is ignored if it is `None` (default), zero or negative. - ``on_timeout`` defines what to do if the timeout occurs. Possible values + `on_timeout` defines what to do if the timeout occurs. Possible values and corresponding actions are explained in the table below. Notice that reaching the timeout never fails the test. - | = Value = | = Action = | + | Value | Action | + |-----------|----------------------------------------| | continue | The process is left running (default). | | terminate | The process is gracefully terminated. | | kill | The process is forcefully stopped. | - See `Terminate Process` keyword for more details how processes are + See the [Terminate Process] keyword for more details how processes are terminated and killed. - If the process ends before the timeout or it is terminated or killed, - this keyword returns a `result object` containing information about - the execution. If the process is left running, Python ``None`` is + If the process ends before the timeout, or it is terminated or killed, + this keyword returns a [result object] containing information about + the execution. If the process is left running, Python `None` is returned instead. + Note: If Robot Framework's test or keyword timeout is exceeded while + this keyword is waiting for the process to end, the process is killed + to avoid leaving it running on the background. This is new in Robot + Framework 7.3. + Examples: - | # Process ends cleanly | | | - | ${result} = | Wait For Process | example | - | Process Should Be Stopped | example | | - | Should Be Equal As Integers | ${result.rc} | 0 | - | # Process does not end | | | - | ${result} = | Wait For Process | timeout=42 secs | - | Process Should Be Running | | | - | Should Be Equal | ${result} | ${NONE} | - | # Kill non-ending process | | | - | ${result} = | Wait For Process | timeout=1min 30s | on_timeout=kill | - | Process Should Be Stopped | | | - | Should Be Equal As Integers | ${result.rc} | -9 | + + ```robotframework + *** Test Cases *** + Process ends cleanly + ${result} = Wait For Process example + Process Should Be Stopped example + Should Be Equal ${result.rc} 0 type=int + + Process does not end + ${result} = Wait For Process timeout=42 secods + Process Should Be Running + Should Be Equal ${result} None type=None + + Kill non-ending process + ${result} = Wait For Process timeout=1min 30s on_timeout=kill + Should Be Equal ${result.rc} -9 type=int + ``` """ process = self._processes[handle] - logger.info('Waiting for process to complete.') - if timeout: - timeout = timestr_to_secs(timeout) - if not self._process_is_stopped(process, timeout): - logger.info('Process did not complete in %s.' - % secs_to_timestr(timeout)) - return self._manage_process_timeout(handle, on_timeout.lower()) + logger.info("Waiting for process to complete.") + timeout = timeout.total_seconds() if timeout else -1 + if timeout > 0 and not self._process_is_stopped(process, 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) - def _manage_process_timeout(self, handle, on_timeout): - if on_timeout == 'terminate': + def _manage_process_timeout( + self, + handle: subprocess.Popen, + on_timeout: str, + ) -> "ProcessResult | None": + if on_timeout == "terminate": return self.terminate_process(handle) - elif on_timeout == 'kill': + if on_timeout == "kill": return self.terminate_process(handle, kill=True) - else: - logger.info('Leaving process intact.') - return None + logger.info("Leaving process intact.") + return None - def _wait(self, process): + def _wait(self, process: subprocess.Popen) -> ProcessResult: result = self._results[process] - result.rc = process.wait() or 0 + # Popen.communicate() does not like closed stdin/stdout/stderr PIPEs. + # Due to us using a timeout, we only need to care about stdin. + # https://github.com/python/cpython/issues/131064 + if process.stdin and process.stdin.closed: + process.stdin = None + # Timeout is used with communicate() to support Robot's timeouts. + while True: + try: + result.stdout, result.stderr = process.communicate(timeout=0.1) + except subprocess.TimeoutExpired: + continue + except TimeoutExceeded: + logger.info("Timeout exceeded.") + self._kill(process) + raise + else: + break + result.rc = process.returncode result.close_streams() - logger.info('Process completed.') + logger.info("Process completed.") return result - def terminate_process(self, handle=None, kill=False): + def terminate_process( + self, + handle: Handle = None, + kill: bool = False, + ) -> ProcessResult: """Stops the process gracefully or forcefully. - If ``handle`` is not given, uses the current `active process`. + Args: + handle: Process handle or alias. Uses the current [active process] + by default. + kill: Whether to forcefully kill the process. + + Returns: + Result object containing information about the execution. - By default first tries to stop the process gracefully. If the process - does not stop in 30 seconds, or ``kill`` argument is given a true value, - (see `Boolean arguments`) kills the process forcefully. Stops also all - the child processes of the originally started process. + Raises: + RuntimeError: If terminating processes is not supported by the + used Python version, or if forceful kill fails. - Waits for the process to stop after terminating it. Returns a `result - object` containing information about the execution similarly as `Wait - For Process`. + By default, first tries to stop the process gracefully. If the process + does not stop in 30 seconds, or the `kill` argument is given a true + value, kills the process forcefully. Stops also all the child processes + of the originally started process. - On Unix-like machines graceful termination is done using ``TERM (15)`` - signal and killing using ``KILL (9)``. Use `Send Signal To Process` + Waits for the process to stop after terminating it. Returns a + [result object] containing information about the execution similarly + as [Wait For Process]. + + On Unix-like machines graceful termination is done using the `TERM (15)` + signal and forceful kill using `KILL (9)`. Use [Send Signal To Process] instead if you just want to send either of these signals without waiting for the process to stop. - On Windows graceful termination is done using ``CTRL_BREAK_EVENT`` - event and killing using Win32 API function ``TerminateProcess()``. - - Examples: - | ${result} = | Terminate Process | | - | Should Be Equal As Integers | ${result.rc} | -15 | # On Unixes | - | Terminate Process | myproc | kill=true | - - Limitations: - - Graceful termination is not supported on Windows when using Jython. - Process is killed instead. - - Stopping the whole process group is not supported when using Jython. - - On Windows forceful kill only stops the main process, not possible - child processes. + On Windows graceful termination is done using the `CTRL_BREAK_EVENT` + event and forceful kill using Win32 API function `TerminateProcess()`. + In this case forceful kill only stops the main process, not possible + child processes. """ process = self._processes[handle] - if not hasattr(process, 'terminate'): - raise RuntimeError('Terminating processes is not supported ' - 'by this Python version.') - terminator = self._kill if is_truthy(kill) else self._terminate + if not hasattr(process, "terminate"): + raise RuntimeError( + "Terminating processes is not supported by this Python version." + ) + terminator = self._kill if kill else self._terminate try: terminator(process) except OSError: if not self._process_is_stopped(process, self.KILL_TIMEOUT): raise - logger.debug('Ignored OSError because process was stopped.') + logger.debug("Ignored OSError because process was stopped.") return self._wait(process) - def _kill(self, process): - logger.info('Forcefully killing process.') - if hasattr(os, 'killpg'): + def _kill(self, process: subprocess.Popen): + logger.info("Forcefully killing process.") + if hasattr(os, "killpg"): os.killpg(process.pid, signal_module.SIGKILL) else: process.kill() if not self._process_is_stopped(process, self.KILL_TIMEOUT): - raise RuntimeError('Failed to kill process.') + raise RuntimeError("Failed to kill process.") - def _terminate(self, process): - logger.info('Gracefully terminating process.') + def _terminate(self, process: subprocess.Popen): + logger.info("Gracefully terminating process.") # Sends signal to the whole process group both on POSIX and on Windows # if supported by the interpreter. - if hasattr(os, 'killpg'): + if hasattr(os, "killpg"): os.killpg(process.pid, signal_module.SIGTERM) - elif hasattr(signal_module, 'CTRL_BREAK_EVENT'): - if IRONPYTHON: - # https://ironpython.codeplex.com/workitem/35020 - ctypes.windll.kernel32.GenerateConsoleCtrlEvent( - signal_module.CTRL_BREAK_EVENT, process.pid) - else: - process.send_signal(signal_module.CTRL_BREAK_EVENT) + elif hasattr(signal_module, "CTRL_BREAK_EVENT"): + process.send_signal(signal_module.CTRL_BREAK_EVENT) else: process.terminate() if not self._process_is_stopped(process, self.TERMINATE_TIMEOUT): - logger.info('Graceful termination failed.') + logger.info("Graceful termination failed.") self._kill(process) - def terminate_all_processes(self, kill=False): + def terminate_all_processes(self, kill: bool = False): """Terminates all still running processes started by this library. + Args: + kill: Whether to forcefully kill processes. + This keyword can be used in suite teardown or elsewhere to make - sure that all processes are stopped, + sure that all processes are stopped. - By default tries to terminate processes gracefully, but can be - configured to forcefully kill them immediately. See `Terminate Process` - that this keyword uses internally for more details. + Tries to terminate processes gracefully by default, but can be + configured to forcefully kill them immediately. See the + [Terminate Process] keyword that this keyword uses internally for + more details. """ - for handle in range(1, len(self._processes) + 1): - if self.is_process_running(handle): - self.terminate_process(handle, kill=kill) + for process in self._processes: + if self.is_process_running(process): + self.terminate_process(process, kill=kill) self.__init__() - def send_signal_to_process(self, signal, handle=None, group=False): - """Sends the given ``signal`` to the specified process. + def send_signal_to_process( + self, + signal: "int | str", + handle: Handle = None, + group: bool = False, + ): + """Sends the given `signal` to the specified process. - If ``handle`` is not given, uses the current `active process`. + Args: + signal: The signal to send, either as an integer or signal name. + handle: Process handle or alias. Uses the current [active process] + by default. + group: Whether to send the signal to the whole process group. Signal can be specified either as an integer as a signal name. In the - latter case it is possible to give the name both with or without ``SIG`` + latter case it is possible to give the name both with or without `SIG` prefix, but names are case-sensitive. For example, all the examples - below send signal ``INT (2)``: + below send signal `INT (2)`. - | Send Signal To Process | 2 | | # Send to active process | - | Send Signal To Process | INT | | | - | Send Signal To Process | SIGINT | myproc | # Send to named process | + ```robotframework + *** Test Cases *** + Send signal + Send Signal To Process 2 + Send Signal To Process INT + Send Signal To Process SIGINT myproc + ``` This keyword is only supported on Unix-like machines, not on Windows. What signals are supported depends on the system. For a list of existing signals on your system, see the Unix man pages related to - signal handling (typically ``man signal`` or ``man 7 signal``). + signal handling (typically `man signal` or `man 7 signal`). - By default sends the signal only to the parent process, not to possible - child processes started by it. Notice that when `running processes in - shell`, the shell is the parent process and it depends on the system + By default, sends the signal only to the parent process, not to possible + child processes started by it. Notice that when [running processes in shell], + the shell is the parent process, and it depends on the system does the shell propagate the signal to the actual started process. - - To send the signal to the whole process group, ``group`` argument can - be set to any true value (see `Boolean arguments`). This is not - supported by Jython, however. """ - if os.sep == '\\': - raise RuntimeError('This keyword does not work on Windows.') + if os.sep == "\\": + 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)) - if is_truthy(group) and hasattr(os, 'killpg'): + logger.info(f"Sending signal {signal} ({signum}).") + if group and hasattr(os, "killpg"): os.killpg(process.pid, signum) - elif hasattr(process, 'send_signal'): + elif hasattr(process, "send_signal"): process.send_signal(signum) else: - raise RuntimeError('Sending signals is not supported ' - 'by this Python version.') + raise RuntimeError( + "Sending signals is not supported by this Python version." + ) - def _get_signal_number(self, int_or_name): + def _get_signal_number(self, int_or_name: "int | str") -> int: try: return int(int_or_name) except ValueError: return self._convert_signal_name_to_number(int_or_name) - def _convert_signal_name_to_number(self, name): + def _convert_signal_name_to_number(self, name: str) -> int: try: - return getattr(signal_module, - name if name.startswith('SIG') else 'SIG' + 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): + def get_process_id(self, handle: Handle = None) -> int: """Returns the process ID (pid) of the process as an integer. - If ``handle`` is not given, uses the current `active process`. + Args: + handle: Process handle or alias. Uses the current [active process] + by default. - Notice that the pid is not the same as the handle returned by - `Start Process` that is used internally by this library. + Returns: + Process ID as an integer. + + Starting from Robot Framework 5.0, it is also possible to directly access + the `pid` attribute of the [subprocess.Popen] object returned by the + [Start Process] keyword like `${process.pid}`. """ return self._processes[handle].pid - def get_process_object(self, handle=None): - """Return the underlying ``subprocess.Popen`` object. + def get_process_object(self, handle: Handle = None) -> subprocess.Popen: + """Return the underlying `subprocess.Popen` object. - If ``handle`` is not given, uses the current `active process`. - """ - return self._processes[handle] + Args: + handle: Process handle or alias. Uses the current [active process] + by default. - def get_process_result(self, handle=None, rc=False, stdout=False, - stderr=False, stdout_path=False, stderr_path=False): - """Returns the specified `result object` or some of its attributes. + Returns: + The underlying [subprocess.Popen] object. - The given ``handle`` specifies the process whose results should be - returned. If no ``handle`` is given, results of the current `active - process` are returned. In either case, the process must have been - finishes before this keyword can be used. In practice this means - that processes started with `Start Process` must be finished either - with `Wait For Process` or `Terminate Process` before using this - keyword. + Starting from Robot Framework 5.0, the [Start Process] keyword returns + the created [subprocess.Popen] object directly. That makes this keyword + mostly redundant. + """ + return self._processes[handle] - If no other arguments than the optional ``handle`` are given, a whole - `result object` is returned. If one or more of the other arguments + def get_process_result( + self, + handle: Handle = None, + rc: bool = False, + stdout: bool = False, + stderr: bool = False, + stdout_path: bool = False, + stderr_path: bool = False, + ) -> "ProcessResult | int | str | tuple[int | str, ...]": + """Returns the specified [result object] or some of its attributes. + + Args: + handle: Process handle or alias. Uses the current [active process] + by default. + rc: If true, return the return code. + stdout: If true, return the standard output. + stderr: If true, return the standard error. + stdout_path: If true, return the stdout path. + stderr_path: If true, return the stderr path. + + Returns: + A result object, a single attribute, or a tuple of attributes + depending on which arguments are given. + + The given `handle` specifies the process whose results should be + returned. If no `handle` is given, results of the current [active process] + are returned. In either case, the process must have been finishes before + this keyword can be used. In practice this means that processes started + with [Start Process] must be finished either with [Wait For Process] or + [Terminate Process] before using this keyword. + + If no other arguments than the optional `handle` are given, a whole + [result object] is returned. If one or more of the other arguments are given any true value, only the specified attributes of the - `result object` are returned. These attributes are always returned + [result object] are returned. These attributes are always returned in the same order as arguments are specified in the keyword signature. - See `Boolean arguments` section for more details about true and false - values. - - Examples: - | Run Process | python | -c | print 'Hello, world!' | alias=myproc | - | # Get result object | | | - | ${result} = | Get Process Result | myproc | - | Should Be Equal | ${result.rc} | ${0} | - | Should Be Equal | ${result.stdout} | Hello, world! | - | Should Be Empty | ${result.stderr} | | - | # Get one attribute | | | - | ${stdout} = | Get Process Result | myproc | stdout=true | - | Should Be Equal | ${stdout} | Hello, world! | - | # Multiple attributes | | | - | ${stdout} | ${stderr} = | Get Process Result | myproc | stdout=yes | stderr=yes | - | Should Be Equal | ${stdout} | Hello, world! | - | Should Be Empty | ${stderr} | | Although getting results of a previously executed process can be handy in general, the main use case for this keyword is returning results over the remote library interface. The remote interface does not support returning the whole result object, but individual attributes can be returned without problems. + + Examples: + + ```robotframework + *** Test Cases *** + Get process result + Run Process python -c print('Hello, world!') alias=myproc + # Get result object + ${result} = Get Process Result myproc + Should Be Equal ${result.rc} 0 type=int + Should Be Equal ${result.stdout} Hello, world! + # Get one attribute + ${stdout} = Get Process Result myproc stdout=Trye + Should Be Equal ${stdout} Hello, world! + # Get multiple attributes + ${stdout} ${stderr} = Get Process Result myproc rc=True stderr=True + Should Be Equal ${rc} 0 type=int + Should Be Equal ${stdout} Hello, world! + ``` """ result = self._results[self._processes[handle]] if result.rc is None: - raise RuntimeError('Getting results of unfinished processes ' - 'is not supported.') - attributes = self._get_result_attributes(result, rc, stdout, stderr, - stdout_path, stderr_path) + raise RuntimeError( + "Getting results of unfinished processes is not supported." + ) + attributes = self._get_result_attributes( + result, rc, stdout, stderr, stdout_path, stderr_path + ) if not attributes: return result - elif len(attributes) == 1: + if len(attributes) == 1: return attributes[0] return attributes def _get_result_attributes(self, result, *includes): - attributes = (result.rc, result.stdout, result.stderr, - result.stdout_path, result.stderr_path) - includes = (is_truthy(incl) for incl in includes) + attributes = ( + result.rc, + result.stdout, + result.stderr, + result.stdout_path, + result.stderr_path, + ) return tuple(attr for attr, incl in zip(attributes, includes) if incl) - def switch_process(self, handle): - """Makes the specified process the current `active process`. + def switch_process(self, handle: Handle): + """Makes the specified process the current [active process]. - The handle can be an identifier returned by `Start Process` or - the ``alias`` given to it explicitly. + Args: + handle: Process handle or alias to activate. + + The handle can be the process object returned by the [Start Process] + keyword or the `alias` given to it explicitly. Example: - | Start Process | prog1 | alias=process1 | - | Start Process | prog2 | alias=process2 | - | # currently active process is process2 | - | Switch Process | process1 | - | # now active process is process1 | + + ```robotframework + *** Test Cases *** + Switch Active Process + Start Process prog1 alias=process1 + Start Process prog2 alias=process2 + # The active process is 'process2' at this point. + Switch Process process1 + # Now active process is 'process1'. + ``` """ self._processes.switch(handle) - def _process_is_stopped(self, process, timeout): + def _process_is_stopped(self, process: subprocess.Popen, timeout: float) -> bool: stopped = lambda: process.poll() is not None max_time = time.time() + timeout while time.time() <= max_time and not stopped(): time.sleep(min(0.1, timeout)) return stopped() - def split_command_line(self, args, escaping=False): + def split_command_line(self, command: str, escaping: bool = False) -> "list[str]": """Splits command line string into a list of arguments. + Args: + command: Command line string to split. + escaping: Whether to treat backslash as an escape character. + + Returns: + List of command line 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 doubling backslashes + in Windows paths and elsewhere. Examples: - | @{cmd} = | Split Command Line | --option "value with spaces" | - | Should Be True | $cmd == ['--option', 'value with spaces'] | - New in Robot Framework 2.9.2. + ```robotframework + *** Test Cases *** + Parse Command Line + @{cmd} = Split Command Line --option "value with spaces" + Should Be Equal ${cmd} ['--option', 'value with spaces'] type=list + ``` """ - return cmdline2list(args, escaping=escaping) + return cmdline2list(command, escaping=escaping) - def join_command_line(self, *args): + def join_command_line(self, *command: "Sequence[str] | str") -> str: """Joins arguments into one command line string. + Args: + *command: Arguments to join, either as individual strings or as a list. + + Returns: + Joined command line string. + In resulting command line string arguments are delimited with a space, 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 - object, then the values of that list are joined instead. + Command to join can be given as individual arguments or as a list. - Example: - | ${cmd} = | Join Command Line | --option | value with spaces | - | Should Be Equal | ${cmd} | --option "value with spaces" | + Examples: - New in Robot Framework 2.9.2. + ```robotframework + *** Test Cases *** + Command as individual arguments + ${cmd} = Join Command Line --option value with spaces + Should Be Equal ${cmd} --option "value with spaces" + + Command as list + VAR @{arguments} --option value with spaces + ${cmd} = Join Command Line ${arguments} + Should Be Equal ${cmd} --option "value with spaces" + ``` """ - if len(args) == 1 and is_list_like(args[0]): - args = args[0] - return subprocess.list2cmdline(args) - - -class ExecutionResult(object): - - def __init__(self, process, stdout, stderr, rc=None, output_encoding=None): - self._process = process - self.stdout_path = self._get_path(stdout) - self.stderr_path = self._get_path(stderr) - self.rc = rc - self._output_encoding = output_encoding - self._stdout = None - self._stderr = None - self._custom_streams = [stream for stream in (stdout, stderr) - if self._is_custom_stream(stream)] - - def _get_path(self, stream): - return stream.name if self._is_custom_stream(stream) else None - - def _is_custom_stream(self, stream): - return stream not in (subprocess.PIPE, subprocess.STDOUT) - - @property - def stdout(self): - if self._stdout is None: - self._read_stdout() - return self._stdout - - @property - def stderr(self): - if self._stderr is None: - self._read_stderr() - return self._stderr - - def _read_stdout(self): - self._stdout = self._read_stream(self.stdout_path, self._process.stdout) - - def _read_stderr(self): - self._stderr = self._read_stream(self.stderr_path, self._process.stderr) - - def _read_stream(self, stream_path, stream): - if stream_path: - stream = open(stream_path, 'rb') - elif not self._is_open(stream): - return '' - try: - content = stream.read() - except IOError: # http://bugs.jython.org/issue2218 - return '' - finally: - if stream_path: - stream.close() - return self._format_output(content) - - def _is_open(self, stream): - return stream and not stream.closed - - def _format_output(self, output): - output = console_decode(output, self._output_encoding, force=True) - output = output.replace('\r\n', '\n') - if output.endswith('\n'): - output = output[:-1] - return output - - def close_streams(self): - standard_streams = self._get_and_read_standard_streams(self._process) - for stream in standard_streams + self._custom_streams: - if self._is_open(stream): - stream.close() - - def _get_and_read_standard_streams(self, process): - stdin, stdout, stderr = process.stdin, process.stdout, process.stderr - if stdout: - self._read_stdout() - if stderr: - self._read_stderr() - return [stdin, stdout, stderr] - - def __str__(self): - return '' % self.rc - - -@py2to3 -class ProcessConfiguration(object): - - def __init__(self, cwd=None, shell=False, stdout=None, stderr=None, - output_encoding='CONSOLE', alias=None, env=None, **rest): - self.cwd = self._get_cwd(cwd) - self.stdout_stream = self._new_stream(stdout) - self.stderr_stream = self._get_stderr(stderr, stdout, self.stdout_stream) - self.shell = is_truthy(shell) - self.alias = alias - self.output_encoding = output_encoding - 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: - name = name.replace('/', os.sep) - return open(os.path.join(self.cwd, name), 'w') - return subprocess.PIPE - - def _get_stderr(self, stderr, stdout, stdout_stream): - if stderr and stderr in ['STDOUT', stdout]: - if stdout_stream != subprocess.PIPE: - return stdout_stream - return subprocess.STDOUT - return self._new_stream(stderr) - - def _construct_env(self, env, extra): - env = self._get_initial_env(env, extra) - if env is None: - return None - if WINDOWS: - env = NormalizedDict(env, spaceless=False) - self._add_to_env(env, extra) - if WINDOWS: - env = dict((key.upper(), env[key]) for key in env) - return env - - def _get_initial_env(self, env, extra): - if env: - return dict((system_encode(k), system_encode(env[k])) for k in env) - if extra: - return os.environ.copy() - 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]) - - def get_command(self, command, arguments): - command = [system_encode(item) for item in [command] + arguments] - if not self.shell: - return command - if arguments: - return subprocess.list2cmdline(command) - return command[0] - - @property - def popen_config(self): - config = {'stdout': self.stdout_stream, - 'stderr': self.stderr_stream, - 'stdin': subprocess.PIPE, - 'shell': self.shell, - 'cwd': self.cwd, - 'env': self.env} - # Close file descriptors regardless the Python version: - # https://github.com/robotframework/robotframework/issues/2794 - if not WINDOWS: - config['close_fds'] = True - if not JYTHON: - self._add_process_group_config(config) - return config - - def _add_process_group_config(self, config): - if hasattr(os, 'setsid'): - config['preexec_fn'] = os.setsid - if hasattr(subprocess, 'CREATE_NEW_PROCESS_GROUP'): - config['creationflags'] = subprocess.CREATE_NEW_PROCESS_GROUP - - @property - def result_config(self): - return {'stdout': self.stdout_stream, - 'stderr': self.stderr_stream, - 'output_encoding': self.output_encoding} - - def __unicode__(self): - return """\ -cwd: %s -shell: %s -stdout: %s -stderr: %s -alias: %s -env: %s""" % (self.cwd, self.shell, self._stream_name(self.stdout_stream), - self._stream_name(self.stderr_stream), self.alias, self.env) - - def _stream_name(self, stream): - if hasattr(stream, 'name'): - return stream.name - return {subprocess.PIPE: 'PIPE', - subprocess.STDOUT: 'STDOUT'}.get(stream, stream) + parts = [] + for part in command: + if isinstance(part, str): + parts.append(part) + else: + parts.extend(part) + return subprocess.list2cmdline(parts) diff --git a/src/robot/libraries/Remote.py b/src/robot/libraries/Remote.py index ab1e689ad7c..ea2bb4c7a7e 100644 --- a/src/robot/libraries/Remote.py +++ b/src/robot/libraries/Remote.py @@ -13,35 +13,23 @@ # See the License for the specific language governing permissions and # limitations under the License. -from __future__ import absolute_import - -try: - import httplib - import xmlrpclib -except ImportError: # Py3 - import http.client as httplib - import xmlrpc.client as xmlrpclib +import http.client import re import socket import sys -import time - -try: - from xml.parsers.expat import ExpatError -except ImportError: # No expat in IronPython 2.7 - class ExpatError(Exception): - pass +import xmlrpc.client +from contextlib import contextmanager +from datetime import date, datetime, timedelta +from xml.parsers.expat import ExpatError from robot.errors import RemoteError -from robot.utils import (is_bytes, is_dict_like, is_list_like, is_number, - is_string, timestr_to_secs, unic, DotDict, - IRONPYTHON, JYTHON, PY2) +from robot.utils import DotDict, is_dict_like, is_list_like, safe_str, timestr_to_secs -class Remote(object): - ROBOT_LIBRARY_SCOPE = 'TEST SUITE' +class Remote: + ROBOT_LIBRARY_SCOPE = "TEST SUITE" - def __init__(self, uri='http://127.0.0.1:8270', timeout=None): + def __init__(self, uri="http://127.0.0.1:8270", timeout=None): """Connects to a remote server at ``uri``. Optional ``timeout`` can be used to specify a timeout to wait when @@ -53,49 +41,76 @@ def __init__(self, uri='http://127.0.0.1:8270', timeout=None): the operating system and its configuration. Notice that setting a timeout that is shorter than keyword execution time will interrupt the keyword. - - Timeouts do not work with IronPython. """ - if '://' not in uri: - uri = 'http://' + uri + if "://" not in uri: + uri = "http://" + uri if timeout: timeout = timestr_to_secs(timeout) self._uri = uri self._client = XmlRpcRemoteClient(uri, timeout) + self._lib_info = None + self._lib_info_initialized = False - def get_keyword_names(self, attempts=2): - for i in range(attempts): - time.sleep(i) + def get_keyword_names(self): + if self._is_lib_info_available(): + return [ + name + for name in self._lib_info + if not (name[:2] == "__" and name[-2:] == "__") + ] + try: + return self._client.get_keyword_names() + except TypeError as error: + raise RuntimeError( + f"Connecting remote server at {self._uri} failed: {error}" + ) + + def _is_lib_info_available(self): + if not self._lib_info_initialized: try: - return self._client.get_keyword_names() - except TypeError as err: - error = err - raise RuntimeError('Connecting remote server at %s failed: %s' - % (self._uri, error)) + self._lib_info = self._client.get_library_information() + except TypeError: + pass + self._lib_info_initialized = True + return self._lib_info is not None def get_keyword_arguments(self, name): + return self._get_kw_info( + name, + "args", + self._client.get_keyword_arguments, + default=["*args"], + ) + + def _get_kw_info(self, kw, info, getter, default=None): + if self._is_lib_info_available(): + return self._lib_info[kw].get(info, default) try: - return self._client.get_keyword_arguments(name) + return getter(kw) except TypeError: - return ['*args'] + return default def get_keyword_types(self, name): - try: - return self._client.get_keyword_types(name) - except TypeError: - return None + return self._get_kw_info( + name, + "types", + self._client.get_keyword_types, + default=(), + ) def get_keyword_tags(self, name): - try: - return self._client.get_keyword_tags(name) - except TypeError: - return None + return self._get_kw_info( + name, + "tags", + self._client.get_keyword_tags, + ) def get_keyword_documentation(self, name): - try: - return self._client.get_keyword_documentation(name) - except TypeError: - return None + return self._get_kw_info( + name, + "doc", + self._client.get_keyword_documentation, + ) def run_keyword(self, name, args, kwargs): coercer = ArgumentCoercer() @@ -103,57 +118,60 @@ def run_keyword(self, name, args, kwargs): kwargs = coercer.coerce(kwargs) result = RemoteResult(self._client.run_keyword(name, args, kwargs)) sys.stdout.write(result.output) - if result.status != 'PASS': - raise RemoteError(result.error, result.traceback, result.fatal, - result.continuable) + if result.status != "PASS": + raise RemoteError( + result.error, + result.traceback, + result.fatal, + result.continuable, + ) return result.return_ -class ArgumentCoercer(object): - binary = re.compile('[\x00-\x08\x0B\x0C\x0E-\x1F]') - non_ascii = re.compile('[\x80-\xff]') +class ArgumentCoercer: + binary = re.compile("[\x00-\x08\x0b\x0c\x0e-\x1f]") def coerce(self, argument): - for handles, handler in [(is_string, self._handle_string), - (is_bytes, self._handle_bytes), - (is_number, self._pass_through), - (is_dict_like, self._coerce_dict), - (is_list_like, self._coerce_list), - (lambda arg: True, self._to_string)]: + for handles, handler in [ + ((str,), self._handle_string), + ((int, float, bytes, bytearray, datetime), self._pass_through), + ((date,), self._handle_date), + ((timedelta,), self._handle_timedelta), + (is_dict_like, self._coerce_dict), + (is_list_like, self._coerce_list), + ]: + if isinstance(handles, tuple): + handles = lambda arg, types=handles: isinstance(arg, types) if handles(argument): return handler(argument) + return self._to_string(argument) 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): - arg = arg.encode('ASCII') + # Map Unicode code points to bytes directly + return arg.encode("latin-1") except UnicodeError: - raise ValueError('Cannot represent %r as binary.' % arg) - return xmlrpclib.Binary(arg) - - def _handle_bytes(self, arg): - # http://bugs.jython.org/issue2429 - if IRONPYTHON or JYTHON: - arg = str(arg) - return xmlrpclib.Binary(arg) + raise ValueError(f"Cannot represent {arg!r} as binary.") def _pass_through(self, arg): return arg + def _handle_date(self, arg): + return datetime(arg.year, arg.month, arg.day) + + def _handle_timedelta(self, arg): + return arg.total_seconds() + 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) @@ -161,41 +179,32 @@ def _to_key(self, item): return item def _to_string(self, item): - item = unic(item) if item is not None else '' + item = safe_str(item) if item is not None else "" return self._handle_string(item) def _validate_key(self, key): - if isinstance(key, xmlrpclib.Binary): - raise ValueError('Dictionary keys cannot be binary. Got %s%r.' - % ('b' if PY2 else '', key.data)) - if IRONPYTHON: - try: - key.encode('ASCII') - except UnicodeError: - raise ValueError('Dictionary keys cannot contain non-ASCII ' - 'characters on IronPython. Got %r.' % key) + if isinstance(key, bytes): + raise ValueError(f"Dictionary keys cannot be binary. Got {key!r}.") -class RemoteResult(object): +class RemoteResult: def __init__(self, result): - if not (is_dict_like(result) and 'status' in result): - raise RuntimeError('Invalid remote result dictionary: %s' % result) - self.status = result['status'] - self.output = unic(self._get(result, 'output')) - self.return_ = self._get(result, 'return') - self.error = unic(self._get(result, 'error')) - self.traceback = unic(self._get(result, 'traceback')) - self.fatal = bool(self._get(result, 'fatal', False)) - self.continuable = bool(self._get(result, 'continuable', False)) - - def _get(self, result, key, default=''): + if not (is_dict_like(result) and "status" in 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") + self.error = safe_str(self._get(result, "error")) + self.traceback = safe_str(self._get(result, "traceback")) + self.fatal = bool(self._get(result, "fatal", False)) + self.continuable = bool(self._get(result, "continuable", False)) + + def _get(self, result, key, default=""): value = result.get(key, default) return self._convert(value) def _convert(self, value): - if isinstance(value, xmlrpclib.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): @@ -203,73 +212,82 @@ def _convert(self, value): return value -class XmlRpcRemoteClient(object): +class XmlRpcRemoteClient: def __init__(self, uri, timeout=None): - if uri.startswith('https://'): - transport = TimeoutHTTPSTransport(timeout=timeout) - else: - transport = TimeoutHTTPTransport(timeout=timeout) - self._server = xmlrpclib.ServerProxy(uri, encoding='UTF-8', - transport=transport) + self.uri = uri + self.timeout = timeout - def get_keyword_names(self): + @property + @contextmanager + def _server(self): + if self.uri.startswith("https://"): + transport = TimeoutHTTPSTransport(timeout=self.timeout) + else: + transport = TimeoutHTTPTransport(timeout=self.timeout) + server = xmlrpc.client.ServerProxy( + self.uri, + encoding="UTF-8", + use_builtin_types=True, + transport=transport, + ) try: - return self._server.get_keyword_names() - except (socket.error, xmlrpclib.Error) as err: + yield server + except (socket.error, xmlrpc.client.Error) as err: raise TypeError(err) + finally: + server("close")() + + def get_library_information(self): + with self._server as server: + return server.get_library_information() + + def get_keyword_names(self): + with self._server as server: + return server.get_keyword_names() def get_keyword_arguments(self, name): - try: - return self._server.get_keyword_arguments(name) - except xmlrpclib.Error: - raise TypeError + with self._server as server: + return server.get_keyword_arguments(name) def get_keyword_types(self, name): - try: - return self._server.get_keyword_types(name) - except xmlrpclib.Error: - raise TypeError + with self._server as server: + return server.get_keyword_types(name) def get_keyword_tags(self, name): - try: - return self._server.get_keyword_tags(name) - except xmlrpclib.Error: - raise TypeError + with self._server as server: + return server.get_keyword_tags(name) def get_keyword_documentation(self, name): - try: - return self._server.get_keyword_documentation(name) - except xmlrpclib.Error: - raise TypeError + with self._server as server: + return server.get_keyword_documentation(name) def run_keyword(self, name, args, kwargs): - run_keyword_args = [name, args, kwargs] if kwargs else [name, args] - try: - return self._server.run_keyword(*run_keyword_args) - except xmlrpclib.Fault as err: - message = err.faultString - except socket.error as err: - message = 'Connection to remote server broken: %s' % 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) - raise RuntimeError(message) + with self._server as server: + run_keyword_args = [name, args, kwargs] if kwargs else [name, args] + try: + return server.run_keyword(*run_keyword_args) + except xmlrpc.client.Fault as err: + message = err.faultString + except socket.error as err: + message = f"Connection to remote server broken: {err}" + except ExpatError as err: + message = ( + f"Processing XML-RPC return value failed. Most often this happens " + f"when the return value contains characters that are not valid in " + f"XML. Original error was: ExpatError: {err}" + ) + raise RuntimeError(message) # Custom XML-RPC timeouts based on # http://stackoverflow.com/questions/2425799/timeout-for-xmlrpclib-client-requests +class TimeoutHTTPTransport(xmlrpc.client.Transport): + _connection_class = http.client.HTTPConnection -class TimeoutHTTPTransport(xmlrpclib.Transport): - _connection_class = httplib.HTTPConnection - - def __init__(self, use_datetime=0, timeout=None): - xmlrpclib.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]: @@ -279,15 +297,5 @@ def make_connection(self, host): return self._connection[1] -if IRONPYTHON: - - class TimeoutHTTPTransport(xmlrpclib.Transport): - - def __init__(self, use_datetime=0, timeout=None): - xmlrpclib.Transport.__init__(self, use_datetime) - if timeout: - raise RuntimeError('Timeouts are not supported on IronPython.') - - class TimeoutHTTPSTransport(TimeoutHTTPTransport): - _connection_class = httplib.HTTPSConnection + _connection_class = http.client.HTTPSConnection diff --git a/src/robot/libraries/Reserved.py b/src/robot/libraries/Reserved.py deleted file mode 100644 index 7d6426eb53b..00000000000 --- a/src/robot/libraries/Reserved.py +++ /dev/null @@ -1,31 +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. - -RESERVED_KEYWORDS = ['for', 'while', 'break', 'continue', 'end', - 'if', 'else', 'elif', 'else if', 'return'] - - -class Reserved(object): - ROBOT_LIBRARY_SCOPE = 'GLOBAL' - - def get_keyword_names(self): - return RESERVED_KEYWORDS - - def run_keyword(self, name, args): - error = "'%s' is a reserved keyword." % name.title() - if name in ('else', 'else if'): - error += (" It must be in uppercase (%s) when used as a marker" - " with 'Run Keyword If'." % name.upper()) - raise Exception(error) diff --git a/src/robot/libraries/Screenshot.py b/src/robot/libraries/Screenshot.py index d78f47664e1..be8479b8d8c 100644 --- a/src/robot/libraries/Screenshot.py +++ b/src/robot/libraries/Screenshot.py @@ -13,332 +13,348 @@ # See the License for the specific language governing permissions and # limitations under the License. -from __future__ import print_function - import os import subprocess import sys -if sys.platform.startswith('java'): - from java.awt import Toolkit, Robot, Rectangle - from javax.imageio import ImageIO - from java.io import File -elif sys.platform == 'cli': - import clr - clr.AddReference('System.Windows.Forms') - clr.AddReference('System.Drawing') - from System.Drawing import Bitmap, Graphics, Imaging - from System.Windows.Forms import Screen -else: - try: - import wx - except ImportError: - wx = None - try: - from gtk import gdk - except ImportError: - gdk = None - try: - from PIL import ImageGrab # apparently available only on Windows - except ImportError: - ImageGrab = None +from pathlib import Path + +try: + import wx +except ImportError: + wx = None +try: + from gtk import gdk +except ImportError: + gdk = None +try: + from PIL import ImageGrab # apparently available only on Windows +except ImportError: + ImageGrab = None from robot.api import logger from robot.libraries.BuiltIn import BuiltIn +from robot.utils import abspath, get_error_message, get_link_path from robot.version import get_version -from robot.utils import abspath, get_error_message, get_link_path, py2to3 - -class Screenshot(object): - """Test library for taking screenshots on the machine where tests are run. - Notice that successfully taking screenshots requires tests to be run with - a physical or virtual display. +class Screenshot: + """Library for taking screenshots on the machine where tests are executed. - = Using with Python = + Taking the actual screenshot requires a suitable tool or module that may + need to be installed separately. Taking screenshots also requires tests + to be run with a physical or virtual display. - How screenshots are taken when using Python depends on the operating - system. On OSX screenshots are taken using the built-in ``screencapture`` - utility. On other operating systems you need to have one of the following - tools or Python modules installed. You can specify the tool/module to use - when `importing` the library. If no tool or module is specified, the first - one found will be used. + ### Table of contents - - wxPython :: http://wxpython.org :: Required also by RIDE so many Robot - Framework users already have this module installed. - - PyGTK :: http://pygtk.org :: This module is available by default on most - Linux distributions. - - Pillow :: http://python-pillow.github.io :: - Only works on Windows. Also the original PIL package is supported. - - Scrot :: http://en.wikipedia.org/wiki/Scrot :: Not used on Windows. - Install with ``apt-get install scrot`` or similar. + %TOC% - Using ``screencapture`` on OSX and specifying explicit screenshot module - are new in Robot Framework 2.9.2. The support for using ``scrot`` is new - in Robot Framework 3.0. + # Supported screenshot taking tools and modules - = Using with Jython and IronPython = + How screenshots are taken depends on the operating system. On OSX + screenshots are taken using the built-in `screencapture` utility. On + other operating systems you need to have one of the following tools or + Python modules installed. You can specify the tool/module to use when + [importing] the library. If no tool or module is specified, the first + one found will be used. - With Jython and IronPython this library uses APIs provided by JVM and .NET - platforms, respectively. These APIs are always available and thus no - external modules are needed. + - [wxPython](http://wxpython.org) - Generic Python GUI toolkit. + - [PyGTK](http://pygtk.org) - This module is available by default on many + Linux distributions. + - [Pillow](http://python-pillow.github.io) - + Only used on Windows. Also the original PIL package is supported. + - [Scrot](http://en.wikipedia.org/wiki/Scrot) - Not used on Windows. + Install with `apt-get install scrot` or similar. - = Where screenshots are saved = + # Where screenshots are saved - By default screenshots are saved into the same directory where the Robot - Framework log file is written. If no log is created, screenshots are saved - into the directory where the XML output file is written. + Screenshots are saved into the same directory where the Robot Framework log + file is written by default. If no log is created, screenshots are saved + into the directory where the output file is written. It is possible to specify a custom location for screenshots using - ``screenshot_directory`` argument when `importing` the library and - using `Set Screenshot Directory` keyword during execution. It is also + `screenshot_directory` argument when [importing] the library and + using [Set Screenshot Directory] keyword during execution. It is also possible to save screenshots using an absolute path. + + # ScreenCapLibrary + + [ScreenCapLibrary](https://github.com/mihaiparvu/ScreenCapLibrary) is an + external Robot Framework library that can be used as an alternative, + which additionally provides support for multiple formats, adjusting the + quality, using GIFs and video capturing. """ - ROBOT_LIBRARY_SCOPE = 'TEST SUITE' + ROBOT_LIBRARY_SCOPE = "TEST SUITE" ROBOT_LIBRARY_VERSION = get_version() + ROBOT_LIBRARY_DOC_FORMAT = "Markdown" - def __init__(self, screenshot_directory=None, screenshot_module=None): + def __init__( + self, + screenshot_directory: "Path | None" = None, + screenshot_module: "str | None" = None, + ): """Configure where screenshots are saved. - If ``screenshot_directory`` is not given, screenshots are saved into - same directory as the log file. The directory can also be set using - `Set Screenshot Directory` keyword. - - ``screenshot_module`` specifies the module or tool to use when using - this library on Python outside OSX. Possible values are ``wxPython``, - ``PyGTK``, ``PIL`` and ``scrot``, case-insensitively. If no value is - given, the first module/tool found is used in that order. See `Using - with Python` for more information. - - Examples (use only one of these): - | =Setting= | =Value= | =Value= | - | Library | Screenshot | | - | Library | Screenshot | ${TEMPDIR} | - | Library | Screenshot | screenshot_module=PyGTK | - - Specifying explicit screenshot module is new in Robot Framework 2.9.2. + Args: + screenshot_directory: If not given, screenshots are saved into + same directory as the log file. The directory can also be set + using [Set Screenshot Directory] keyword. + screenshot_module: Specifies the module or tool to use when using + this library outside OSX. Possible values are `wxPython`, + `PyGTK`, `PIL` and `scrot`, case-insensitively. If no value is + given, the first module/tool found is used in that order. + + Examples: + + ```robotframework + *** Settings *** + Library Screenshot + Library Screenshot ${TEMPDIR} + Library Screenshot screenshot_module=PyGTK + ``` """ self._given_screenshot_dir = self._norm_path(screenshot_directory) self._screenshot_taker = ScreenshotTaker(screenshot_module) - def _norm_path(self, path): - if not path: - return path - return os.path.normpath(path.replace('/', os.sep)) + def _norm_path(self, path: "Path | str | None") -> "str | None": + return os.path.normpath(path) if path else None @property - def _screenshot_dir(self): + def _screenshot_dir(self) -> str: return self._given_screenshot_dir or self._log_dir @property - def _log_dir(self): + def _log_dir(self) -> str: variables = BuiltIn().get_variables() - outdir = variables['${OUTPUTDIR}'] - log = variables['${LOGFILE}'] - log = os.path.dirname(log) if log != 'NONE' else '.' - return self._norm_path(os.path.join(outdir, log)) + outdir = variables["${OUTPUTDIR}"] + log = variables["${LOGFILE}"] + log = os.path.dirname(log) if log != "NONE" else "." + return os.path.normpath(os.path.join(outdir, log)) - def set_screenshot_directory(self, path): + def set_screenshot_directory(self, path: Path) -> str: """Sets the directory where screenshots are saved. - It is possible to use ``/`` as a path separator in all operating + It is possible to use `/` as a path separator in all operating systems. Path to the old directory is returned. - The directory can also be set in `importing`. + The directory can also be set in [importing]. + + Args: + path: Directory where to save screenshots. Must already exist. + + Returns: + Path to the previous screenshot directory. + + Raises: + RuntimeError: If `path` does not exist or is not a directory. """ path = self._norm_path(path) if not os.path.isdir(path): - raise RuntimeError("Directory '%s' does not exist." % path) + raise RuntimeError(f"Directory '{path}' does not exist.") old = self._screenshot_dir self._given_screenshot_dir = path return old - def take_screenshot(self, name="screenshot", width="800px"): + def take_screenshot(self, name: str = "screenshot", width: str = "800px") -> str: """Takes a screenshot in JPEG format and embeds it into the log file. Name of the file where the screenshot is stored is derived from the - given ``name``. If the ``name`` ends with extension ``.jpg`` or - ``.jpeg``, the screenshot will be stored with that exact name. + given `name`. If the `name` ends with extension `.jpg` or + `.jpeg`, the screenshot will be stored with that exact name. Otherwise a unique name is created by adding an underscore, a running - index and an extension to the ``name``. + index and an extension to the `name`. The name will be interpreted to be relative to the directory where the log file is written. It is also possible to use absolute paths. - Using ``/`` as a path separator works in all operating systems. - - ``width`` specifies the size of the screenshot in the log file. - - Examples: (LOGDIR is determined automatically by the library) - | Take Screenshot | | | # LOGDIR/screenshot_1.jpg (index automatically incremented) | - | Take Screenshot | mypic | | # LOGDIR/mypic_1.jpg (index automatically incremented) | - | Take Screenshot | ${TEMPDIR}/mypic | | # /tmp/mypic_1.jpg (index automatically incremented) | - | Take Screenshot | pic.jpg | | # LOGDIR/pic.jpg (always uses this file) | - | Take Screenshot | images/login.jpg | 80% | # Specify both name and width. | - | Take Screenshot | width=550px | | # Specify only width. | - - The path where the screenshot is saved is returned. + Using `/` as a path separator works in all operating systems. + + `width` specifies the size of the screenshot in the log file. + + Args: + name: Base name or path for the screenshot file. + width: Width of the embedded image in the log file. + + Returns: + Path where the screenshot was saved. + + Examples: + + ```robotframework + *** Test Cases *** + Take Screenshots + Take Screenshot # LOGDIR/screenshot_1.jpg + Take Screenshot # LOGDIR/screenshot_2.jpg + Take Screenshot mypic # LOGDIR/mypic_1.jpg + Take Screenshot mypic # LOGDIR/mypic_2.jpg + Take Screenshot pic.jpg # LOGDIR/pic.jpg + Take Screenshot pic.jpg # LOGDIR/pic.jpg + Take Screenshot ${TEMPDIR}/mypic # /tmp/mypic_1.jpg + Take Screenshot images/login.jpg 80% + Take Screenshot width=550px + ``` """ path = self._save_screenshot(name) self._embed_screenshot(path, width) return path - def take_screenshot_without_embedding(self, name="screenshot"): + def take_screenshot_without_embedding(self, name: str = "screenshot") -> str: """Takes a screenshot and links it from the log file. - This keyword is otherwise identical to `Take Screenshot` but the saved + This keyword is otherwise identical to [Take Screenshot] but the saved screenshot is not embedded into the log file. The screenshot is linked so it is nevertheless easily available. + + Args: + name: Base name or path for the screenshot file. + + Returns: + Path where the screenshot was saved. """ path = self._save_screenshot(name) 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): + path = self._get_screenshot_path(name.replace("/", os.sep)) return self._screenshot_to_file(path) def _screenshot_to_file(self, path): path = self._validate_screenshot_path(path) - logger.debug('Using %s module/tool for taking screenshot.' - % self._screenshot_taker.module) + module = self._screenshot_taker.module + logger.debug(f"Using {module} module/tool for taking screenshot.") try: self._screenshot_taker(path) - except: - logger.warn('Taking screenshot failed: %s\n' - 'Make sure tests are run with a physical or virtual ' - 'display.' % get_error_message()) + except Exception: + logger.warn( + f"Taking screenshot failed: {get_error_message()}\n" + f"Make sure tests are run with a physical or virtual display." + ) return path def _validate_screenshot_path(self, path): path = abspath(self._norm_path(path)) - if not os.path.exists(os.path.dirname(path)): - raise RuntimeError("Directory '%s' where to save the screenshot " - "does not exist" % os.path.dirname(path)) + dire = os.path.dirname(path) + if not os.path.exists(dire): + raise RuntimeError( + f"Directory '{dire}' where to save the screenshot does not exist." + ) return path - def _get_screenshot_path(self, basename, directory): - directory = self._norm_path(directory) if directory else self._screenshot_dir - if basename.lower().endswith(('.jpg', '.jpeg')): - return os.path.join(directory, basename) + def _get_screenshot_path(self, basename): + if basename.lower().endswith((".jpg", ".jpeg")): + 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, f"{basename}_{index}.jpg") if not os.path.exists(path): return path def _embed_screenshot(self, path, width): link = get_link_path(path, self._log_dir) - logger.info('' - % (link, link, width), html=True) + logger.info( + f'', + html=True, + ) def _link_screenshot(self, path): link = get_link_path(path, self._log_dir) - logger.info("Screenshot saved to '%s'." - % (link, path), html=True) + logger.info( + f"Screenshot saved to '{path}'.", + html=True, + ) -@py2to3 -class ScreenshotTaker(object): +class ScreenshotTaker: def __init__(self, module_name=None): self._screenshot = self._get_screenshot_taker(module_name) - self.module = self._screenshot.__name__.split('_')[1] + self.module = self._screenshot.__name__.split("_")[1] self._wx_app_reference = None def __call__(self, path): self._screenshot(path) - def __nonzero__(self): - return self.module != 'no' + def __bool__(self): + return self.module != "no" def test(self, path=None): if not self: print("Cannot take screenshots.") return False - print("Using '%s' to take screenshot." % self.module) + print(f"Using '{self.module}' to take screenshot.") if not path: print("Not taking test screenshot.") return True - print("Taking test screenshot to '%s'." % path) + print(f"Taking test screenshot to '{path}'.") try: self(path) - except: - print("Failed: %s" % get_error_message()) + except Exception: + print(f"Failed: {get_error_message()}") return False else: print("Success!") return True def _get_screenshot_taker(self, module_name=None): - if sys.platform.startswith('java'): - return self._java_screenshot - if sys.platform == 'cli': - return self._cli_screenshot - if sys.platform == 'darwin': + if sys.platform == "darwin": return self._osx_screenshot if module_name: return self._get_named_screenshot_taker(module_name.lower()) return self._get_default_screenshot_taker() def _get_named_screenshot_taker(self, name): - screenshot_takers = {'wxpython': (wx, self._wx_screenshot), - 'pygtk': (gdk, self._gtk_screenshot), - 'pil': (ImageGrab, self._pil_screenshot), - 'scrot': (self._scrot, self._scrot_screenshot)} + screenshot_takers = { + "wxpython": (wx, self._wx_screenshot), + "pygtk": (gdk, self._gtk_screenshot), + "pil": (ImageGrab, self._pil_screenshot), + "scrot": (self._scrot, self._scrot_screenshot), + } if name not in screenshot_takers: - raise RuntimeError("Invalid screenshot module or tool '%s'." % name) + raise RuntimeError(f"Invalid screenshot module or tool '{name}'.") supported, screenshot_taker = screenshot_takers[name] if not supported: - raise RuntimeError("Screenshot module or tool '%s' not installed." - % name) + raise RuntimeError(f"Screenshot module or tool '{name}' not installed.") return screenshot_taker def _get_default_screenshot_taker(self): - for module, screenshot_taker in [(wx, self._wx_screenshot), - (gdk, self._gtk_screenshot), - (ImageGrab, self._pil_screenshot), - (self._scrot, self._scrot_screenshot), - (True, self._no_screenshot)]: + for module, screenshot_taker in [ + (wx, self._wx_screenshot), + (gdk, self._gtk_screenshot), + (ImageGrab, self._pil_screenshot), + (self._scrot, self._scrot_screenshot), + (True, self._no_screenshot), + ]: if module: return screenshot_taker - def _java_screenshot(self, path): - size = Toolkit.getDefaultToolkit().getScreenSize() - rectangle = Rectangle(0, 0, size.width, size.height) - image = Robot().createScreenCapture(rectangle) - ImageIO.write(image, 'jpg', File(path)) - - def _cli_screenshot(self, path): - bmp = Bitmap(Screen.PrimaryScreen.Bounds.Width, - Screen.PrimaryScreen.Bounds.Height) - graphics = Graphics.FromImage(bmp) - try: - graphics.CopyFromScreen(0, 0, 0, 0, bmp.Size) - finally: - graphics.Dispose() - bmp.Save(path, Imaging.ImageFormat.Jpeg) - def _osx_screenshot(self, path): - if self._call('screencapture', '-t', 'jpg', path) != 0: + if self._call("screencapture", "-t", "jpg", path) != 0: raise RuntimeError("Using 'screencapture' failed.") def _call(self, *command): try: - return subprocess.call(command, stdout=subprocess.PIPE, - stderr=subprocess.STDOUT) + return subprocess.call( + command, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + ) except OSError: return -1 @property def _scrot(self): - return os.sep == '/' and self._call('scrot', '--version') == 0 + return os.sep == "/" and self._call("scrot", "--version") == 0 def _scrot_screenshot(self, path): - if not path.endswith(('.jpg', '.jpeg')): - raise RuntimeError("Scrot requires extension to be '.jpg' or " - "'.jpeg', got '%s'." % os.path.splitext(path)[1]) - if self._call('scrot', '--silent', path) != 0: + if not path.endswith((".jpg", ".jpeg")): + ext = os.path.splitext(path)[1] + raise RuntimeError( + f"Scrot requires extension to be '.jpg' or '.jpeg', got '{ext}'." + ) + if os.path.exists(path): + os.remove(path) + if self._call("scrot", "--silent", path) != 0: raise RuntimeError("Using 'scrot' failed.") def _wx_screenshot(self, path): @@ -346,7 +362,7 @@ def _wx_screenshot(self, path): self._wx_app_reference = wx.App(False) context = wx.ScreenDC() width, height = context.GetSize() - if wx.__version__ >= '4': + if wx.__version__ >= "4": bitmap = wx.Bitmap(width, height, -1) else: bitmap = wx.EmptyBitmap(width, height, -1) @@ -359,27 +375,30 @@ def _wx_screenshot(self, path): def _gtk_screenshot(self, path): window = gdk.get_default_root_window() if not window: - raise RuntimeError('Taking screenshot failed.') + raise RuntimeError("Taking screenshot failed.") width, height = window.get_size() pb = gdk.Pixbuf(gdk.COLORSPACE_RGB, False, 8, width, height) - pb = pb.get_from_drawable(window, window.get_colormap(), - 0, 0, 0, 0, width, height) + pb = pb.get_from_drawable( + window, window.get_colormap(), 0, 0, 0, 0, width, height + ) if not pb: - raise RuntimeError('Taking screenshot failed.') - pb.save(path, 'jpeg') + raise RuntimeError("Taking screenshot failed.") + pb.save(path, "jpeg") def _pil_screenshot(self, path): - ImageGrab.grab().save(path, 'JPEG') + ImageGrab.grab().save(path, "JPEG") def _no_screenshot(self, path): - raise RuntimeError('Taking screenshots is not supported on this platform ' - 'by default. See library documentation for details.') + raise RuntimeError( + "Taking screenshots is not supported on this platform " + "by default. See library documentation for details." + ) if __name__ == "__main__": if len(sys.argv) not in [2, 3]: - sys.exit("Usage: %s |test [wx|pygtk|pil|scrot]" - % os.path.basename(sys.argv[0])) - path = sys.argv[1] if sys.argv[1] != 'test' else None + prog = os.path.basename(sys.argv[0]) + sys.exit(f"Usage: {prog} |test [wxpython|pygtk|pil|scrot]") + path = sys.argv[1] if sys.argv[1] != "test" else None module = sys.argv[2] if len(sys.argv) > 2 else None ScreenshotTaker(module).test(path) diff --git a/src/robot/libraries/String.py b/src/robot/libraries/String.py index 4fa9d815dd3..1ead204da4e 100644 --- a/src/robot/libraries/String.py +++ b/src/robot/libraries/String.py @@ -13,31 +13,49 @@ # See the License for the specific language governing permissions and # limitations under the License. -from __future__ import absolute_import - import os import re from fnmatch import fnmatchcase from random import randint from string import ascii_lowercase, ascii_uppercase, digits - +from typing import Callable, Literal from robot.api import logger -from robot.utils import (is_bytes, is_string, is_truthy, is_unicode, lower, - unic, Utf8Reader, PY3) +from robot.api.deco import library +from robot.utils import FileReader, parse_re_flags, plural_or_not as s, type_name from robot.version import get_version +from .normalizer import Normalizer + +MARKERS = { + "[LOWER]": ascii_lowercase, + "[UPPER]": ascii_uppercase, + "[LETTERS]": ascii_lowercase + ascii_uppercase, + "[NUMBERS]": digits, + "[ARABIC]": "".join(chr(c) for c in range(0x0600, 0x0700)), + "[POLISH]": ascii_lowercase + ascii_uppercase + "ąćęłńóśźżĄĆĘŁŃÓŚŹŻ", +} + + +@library( + scope="GLOBAL", + version=get_version(), + doc_format="MARKDOWN", + auto_keywords=True, +) +class String: + """A library for string manipulation and verification. -class String(object): - """A test library for string manipulation and verification. + `String` is Robot Framework's standard library for manipulating + strings (e.g. [Replace String Using Regexp], [Split To Lines]) and + verifying their contents (e.g. [Should Be String]). - ``String`` is Robot Framework's standard library for manipulating - strings (e.g. `Replace String Using Regexp`, `Split To Lines`) and - verifying their contents (e.g. `Should Be String`). + In addition to strings, most of the keywords work also with bytes. + Bytes support was heavily enhanced in Robot Framework 7.4. - Following keywords from ``BuiltIn`` library can also be used with strings: + Following keywords from `BuiltIn` library can also be used with strings + and bytes: - - `Catenate` - `Get Length` - `Length Should Be` - `Should (Not) Be Empty` @@ -49,123 +67,348 @@ class String(object): - `Convert To String` - `Convert To Bytes` """ - ROBOT_LIBRARY_SCOPE = 'GLOBAL' - ROBOT_LIBRARY_VERSION = get_version() - def convert_to_lowercase(self, string): - """Converts string to lowercase. + def convert_to_lower_case(self, string: "str | bytes") -> "str | bytes": + """Converts string to lower case. + + Args: + string: The string or bytes to convert. + + Returns: + The input converted to lower case. + + Uses Python's standard + [str.lower()](https://docs.python.org/library/stdtypes.html#str.lower) or + [bytes.lower()](https://docs.python.org/library/stdtypes.html#bytes.lower) + method, depending on the input value type. Examples: - | ${str1} = | Convert To Lowercase | ABC | - | ${str2} = | Convert To Lowercase | 1A2c3D | - | Should Be Equal | ${str1} | abc | - | Should Be Equal | ${str2} | 1a2c3d | + + ```robotframework + *** Test Cases *** + Convert to lower case + ${str1} = Convert To Lower Case ABC + ${str2} = Convert To Lower Case 1A2c3D + Should Be Equal ${str1} abc + Should Be Equal ${str2} 1a2c3d + ``` + + If this keyword is used with bytes, only ASCII characters are lowercased. """ - # Custom `lower` needed due to IronPython bug. See its code and - # comments for more details. - return lower(string) + return string.lower() + + def convert_to_upper_case(self, string: "str | bytes") -> "str | bytes": + """Converts string to upper case. + + Args: + string: The string or bytes to convert. + + Returns: + The input converted to upper case. - def convert_to_uppercase(self, string): - """Converts string to uppercase. + Uses Python's standard + [str.upper()](https://docs.python.org/library/stdtypes.html#str.upper) or + [bytes.upper()](https://docs.python.org/library/stdtypes.html#bytes.upper) + method, depending on the input value type. Examples: - | ${str1} = | Convert To Uppercase | abc | - | ${str2} = | Convert To Uppercase | 1a2C3d | - | Should Be Equal | ${str1} | ABC | - | Should Be Equal | ${str2} | 1A2C3D | + + ```robotframework + *** Test Cases *** + Convert to upper case + ${str1} = Convert To Upper Case abc + ${str2} = Convert To Upper Case 1a2C3d + Should Be Equal ${str1} ABC + Should Be Equal ${str2} 1A2C3D + ``` + + If this keyword is used with bytes, only ASCII characters are uppercased. """ return string.upper() - def encode_string_to_bytes(self, string, encoding, errors='strict'): - """Encodes the given Unicode ``string`` to bytes using the given ``encoding``. + def convert_to_title_case( + self, + string: "str | bytes", + exclude: "str | bytes | list[str | bytes] | None" = None, + ) -> "str | bytes": + """Converts string to title case. + + Args: + string: The string or bytes to convert. + exclude: Optional words to exclude from title casing. Can be a list + or a comma-separated string. Each word is treated as a regular + expression pattern. + + Returns: + The input converted to title case. + + Titlecasing is done using the following algorithm: + + - Split the string to words from whitespace characters (spaces, + newlines, etc.). + - Exclude words that are not all lower case. This preserves, + for example, "OK" and "iPhone". + - Exclude also words listed in the optional `exclude` argument. + - Title case the first alphabetical character of each word that has + not been excluded. + - Join all words together so that original whitespace is preserved. + + Explicitly excluded words can be given as a list or as a string with + words separated by a comma and an optional space. Excluded words are + actually considered to be regular expression patterns, so it is + possible to use something like `example[.!?]?` to match the word + `example` on its own and also if followed by `.`, `!` or `?`. + See `BuiltIn.Should Match Regexp` for more information about Python + regular expression syntax in general and how to use it in Robot + Framework data in particular. - ``errors`` argument controls what to do if encoding some characters fails. - All values accepted by ``encode`` method in Python are valid, but in - practice the following values are most useful: + Examples: - - ``strict``: fail if characters cannot be encoded (default) - - ``ignore``: ignore characters that cannot be encoded - - ``replace``: replace characters that cannot be encoded with + ```robotframework + *** Test Cases *** + Convert to title case + ${str1} = Convert To Title Case hello, world! + ${str2} = Convert To Title Case it's an OK iPhone exclude=a, an, the + ${str3} = Convert To Title Case distance is 1 km. exclude=is, km.? + Should Be Equal ${str1} Hello, World! + Should Be Equal ${str2} It's an OK iPhone + Should Be Equal ${str3} Distance is 1 km. + ``` + + The reason this keyword does not use Python's standard + [str.title()](https://docs.python.org/library/stdtypes.html#str.title) + method is that it can yield undesired results, for example, if + strings contain upper case letters or special characters like + apostrophes. It would, for example, convert "it's an OK iPhone" + to "It'S An Ok Iphone". + + If this keyword is used with bytes, only ASCII characters are titlecased. + Bytes support is new in Robot Framework 7.4. + """ + if isinstance(exclude, str): + exclude = [e.strip() for e in exclude.split(",")] + elif isinstance(exclude, bytes): + exclude = [e.strip() for e in exclude.split(b",")] + elif not exclude: + exclude = [] + if isinstance(string, str): + splitter, joiner = r"(\s+)", "" + else: + splitter, joiner = rb"(\s+)", b"" + exclude = [self._ensure_bytes(e) for e in exclude] + try: + exclude = [re.compile(e) for e in exclude] + except re.error as err: + raise ValueError( + f"Compiling exclude pattern {err.pattern!r} to a regular expression " + f"failed: {err}" + ) + + def title(word): + if word.islower() and not any(e.fullmatch(word) for e in exclude): + for i, char in enumerate(word): + if isinstance(char, int): # iterating bytes + char = bytes([char]) + if char.isalpha(): + j = i + 1 + return word[:i] + word[i:j].title() + word[j:] + return word + + tokens = re.split(splitter, string) + return joiner.join(title(t) if i % 2 == 0 else t for i, t in enumerate(tokens)) + + def encode_string_to_bytes( + self, + string: str, + encoding: str, + errors: str = "strict", + ) -> bytes: + """Encodes the given `string` to bytes using the given `encoding`. + + Args: + string: The string to encode. + encoding: The encoding to use. + errors: How to handle encoding errors. + + Returns: + The input string encoded to bytes. + + The `errors` argument supports all Python's standard + [error handlers](https://docs.python.org/3/library/codecs.html#error-handlers), + but in practice the following values are most useful: + + - `strict`: fail if characters cannot be encoded (default) + - `ignore`: ignore characters that cannot be encoded + - `replace`: replace characters that cannot be encoded with a replacement character Examples: - | ${bytes} = | Encode String To Bytes | ${string} | UTF-8 | - | ${bytes} = | Encode String To Bytes | ${string} | ASCII | errors=ignore | - Use `Convert To Bytes` in ``BuiltIn`` if you want to create bytes based - on character or integer sequences. Use `Decode Bytes To String` if you - need to convert byte strings to Unicode strings and `Convert To String` - in ``BuiltIn`` if you need to convert arbitrary objects to Unicode. + ```robotframework + *** Test Cases *** + Encode string to bytes + ${bytes} = Encode String To Bytes ${string} UTF-8 + ${bytes} = Encode String To Bytes ${string} ASCII errors=ignore + ``` + + Use [Decode Bytes To String] if you need to convert bytes to strings. + Use `BuiltIn.Convert To Bytes` if you want to create bytes based + on character or integer sequences. """ return bytes(string.encode(encoding, errors)) - def decode_bytes_to_string(self, bytes, encoding, errors='strict'): - """Decodes the given ``bytes`` to a Unicode string using the given ``encoding``. - - ``errors`` argument controls what to do if decoding some bytes fails. - All values accepted by ``decode`` method in Python are valid, but in - practice the following values are most useful: - - - ``strict``: fail if characters cannot be decoded (default) - - ``ignore``: ignore characters that cannot be decoded - - ``replace``: replace characters that cannot be decoded with + def decode_bytes_to_string( + self, + bytes: bytes, + encoding: str, + errors: str = "strict", + ) -> str: + """Decodes the given `bytes` to a string using the given `encoding`. + + Args: + bytes: The bytes to decode. + encoding: The encoding to use. + errors: How to handle encoding errors. + + Returns: + The input bytes decoded to string, + + The `errors` argument supports all Python's standard + [error handlers](https://docs.python.org/3/library/codecs.html#error-handlers), + but in practice the following values are most useful: + + - `strict`: fail if characters cannot be encoded (default) + - `ignore`: ignore characters that cannot be encoded + - `replace`: replace characters that cannot be encoded with a replacement character Examples: - | ${string} = | Decode Bytes To String | ${bytes} | UTF-8 | - | ${string} = | Decode Bytes To String | ${bytes} | ASCII | errors=ignore | - Use `Encode String To Bytes` if you need to convert Unicode strings to - byte strings, and `Convert To String` in ``BuiltIn`` if you need to - convert arbitrary objects to Unicode strings. + ```robotframework + *** Test Cases *** + Decide bytes ti strinog + ${string} = Decode Bytes To String ${bytes} UTF-8 + ${string} = Decode Bytes To String ${bytes} ASCII errors=ignore + ``` + + Use [Encode String To Bytes] if you need to convert strings to bytes, + Use `BuiltIn.Convert To String` if you want to convert arbitrary objects + to strings. """ - if PY3 and is_unicode(bytes): - raise TypeError('Can not decode strings on Python 3.') return bytes.decode(encoding, errors) - def format_string(self, template, *positional, **named): - """Formats a ``template`` using the given ``positional`` and ``named`` arguments. + def format_string( + self, + template: "str | bytes", + /, + *positional: object, + **named: object, + ) -> "str | bytes": + """Formats a `template` using the given `positional` and `named` arguments. + + Args: + template: A template string or an absolute path to a template file. + *positional: Positional arguments used to format the template. + **named: Named arguments used to format the template. + + Returns: + The formatted string or bytes. The template can be either be a string or an absolute path to an existing file. In the latter case the file is read and its contents are used as the template. If the template file contains non-ASCII characters, it must be encoded using UTF-8. - The template is formatted using Python's - [https://docs.python.org/library/string.html#format-string-syntax|format - string syntax]. Placeholders are marked using ``{}`` with possible + The template is formatted using Python's [format string syntax]. + Placeholders are marked using `{}` with possible field name and format specification inside. Literal curly braces can be inserted by doubling them like `{{` and `}}`. Examples: - | ${to} = | Format String | To: {} <{}> | ${user} | ${email} | - | ${to} = | Format String | To: {name} <{email}> | name=${name} | email=${email} | - | ${to} = | Format String | To: {user.name} <{user.email}> | user=${user} | - | ${xx} = | Format String | {:*^30} | centered | - | ${yy} = | Format String | {0:{width}{base}} | ${42} | base=X | width=10 | - | ${zz} = | Format String | ${CURDIR}/template.txt | positional | named=value | - New in Robot Framework 3.1. + ```robotframework + *** Test Cases *** + Format string + ${to} = Format String To: {} <{}> ${user} ${email} + ${to} = Format String To: {name} <{email}> name=${name} email=${email} + ${to} = Format String To: {u.name} <{u.email}> u=${user_object} + ${xx} = Format String {:*^30} centered + ${yy} = Format String {0:{width}{base}} ${42} base=X width=10 + ${zz} = Format String ${CURDIR}/template.txt positional named=value + ``` + + Prior to Robot Framework 7.1, possible equal signs in the template string + needed to be escaped with a backslash like `x\\={}`. Bytes support is new + in Robot Framework 7.4. + + [format string syntax]: https://docs.python.org/library/string.html#format-string-syntax """ if os.path.isabs(template) and os.path.isfile(template): - template = template.replace('/', os.sep) - logger.info('Reading template from file %s.' - % (template, template), html=True) - with Utf8Reader(template) as reader: + template = template.replace("/", os.sep) + logger.info( + f'Reading template from file {template}.', + html=True, + ) + with FileReader(template) as reader: template = reader.read() - return template.format(*positional, **named) - - def get_line_count(self, string): - """Returns and logs the number of lines in the given string.""" + if isinstance(template, bytes): + template = self._bytes_to_string(template) + positional = [self._bytes_to_string(p) for p in positional] + named = {n: self._bytes_to_string(named[n]) for n in named} + return_bytes = True + else: + return_bytes = False + result = template.format(*positional, **named) + if return_bytes: + return self._ensure_bytes(result) + return result + + def _bytes_to_string(self, value: object) -> object: + return value.decode("latin-1") if isinstance(value, bytes) else value + + def _ensure_bytes(self, value: "str | bytes | None") -> "bytes | None": + if isinstance(value, bytes) or value is None: + return value + try: + return value.encode("latin-1") + except UnicodeEncodeError as err: + invalid = value[err.start : err.end] + raise ValueError( + f"String '{value}' cannot be converted to bytes: Characters must have " + f"code point below 256, but '{invalid}' has code point {ord(invalid)}." + ) + + def get_line_count(self, string: "str | bytes") -> int: + """Returns and logs the number of lines in the given string. + + Args: + string: The string or bytes to count lines from. + + Returns: + The number of lines. + """ count = len(string.splitlines()) - logger.info('%d lines' % count) + logger.info(f"{count} lines.") return count - def split_to_lines(self, string, start=0, end=None): + def split_to_lines( + self, + string: "str | bytes", + start: "int | Literal[''] | None" = 0, + end: "int | Literal[''] | None" = None, + ) -> "list[str] | list[bytes]": """Splits the given string to lines. - It is possible to get only a selection of lines from ``start`` - to ``end`` so that ``start`` index is inclusive and ``end`` is + Args: + string: The string or bytes to split. + start: Start index for line selection. Defaults to `0`. + end: End index for line selection. Defaults to `None` (end of string). + + Returns: + A list of lines. + + It is possible to get only a selection of lines from `start` + to `end` so that `start` index is inclusive and `end` is exclusive. Line numbering starts from 0, and it is possible to use negative indices to refer to lines from the end. @@ -173,543 +416,958 @@ def split_to_lines(self, string, start=0, end=None): returned lines is automatically logged. Examples: - | @{lines} = | Split To Lines | ${manylines} | | | - | @{ignore first} = | Split To Lines | ${manylines} | 1 | | - | @{ignore last} = | Split To Lines | ${manylines} | | -1 | - | @{5th to 10th} = | Split To Lines | ${manylines} | 4 | 10 | - | @{first two} = | Split To Lines | ${manylines} | | 1 | - | @{last two} = | Split To Lines | ${manylines} | -2 | | - Use `Get Line` if you only need to get a single line. + ```robotframework + *** Test Cases *** + Split to lines + @{lines} = Split To Lines ${string} + @{ignore first} = Split To Lines ${string} 1 + @{ignore last} = Split To Lines ${string} end=-1 + @{5th to 10th} = Split To Lines ${string} 4 10 + @{first two} = Split To Lines ${string} end=1 + @{last two} = Split To Lines ${string} start=-2 + ``` + + Use [Get Line] if you only need to get a single line. """ - start = self._convert_to_index(start, 'start') - end = self._convert_to_index(end, 'end') + start = self._deprecate_empty_string("start", start, 0) + end = self._deprecate_empty_string("end", end, 0) lines = string.splitlines()[start:end] - logger.info('%d lines returned' % len(lines)) + logger.info(f"{len(lines)} line{s(lines)} returned.") return lines - def get_line(self, string, line_number): - """Returns the specified line from the given ``string``. + def get_line(self, string: "str | bytes", line_number: int) -> "str | bytes": + """Returns the specified line from the given `string`. + + Args: + string: The string or bytes to get the line from. + line_number: Index of the line to return. + + Returns: + The specified line without the newline character. - Line numbering starts from 0 and it is possible to use + Line numbering starts from 0, and it is possible to use negative indices to refer to lines from the end. The line is returned without the newline character. Examples: - | ${first} = | Get Line | ${string} | 0 | - | ${2nd last} = | Get Line | ${string} | -2 | - Use `Split To Lines` if all lines are needed. + ```robotframework + *** Test Cases *** + Get line + ${first} = Get Line ${string} 0 + ${2nd last} = Get Line ${string} -2 + ``` + + Use [Split To Lines] if all lines, or a subset of lines, are needed. """ - line_number = self._convert_to_integer(line_number, 'line_number') return string.splitlines()[line_number] - def get_lines_containing_string(self, string, pattern, case_insensitive=False): - """Returns lines of the given ``string`` that contain the ``pattern``. + def get_lines_containing_string( + self, + string: "str | bytes", + pattern: "str | bytes", + case_insensitive: "bool | None" = None, + ignore_case: bool = False, + ) -> "str | bytes": + """Returns lines of the given `string` that contain the `pattern`. - The ``pattern`` is always considered to be a normal string, not a glob - or regexp pattern. A line matches if the ``pattern`` is found anywhere - on it. - The match is case-sensitive by default, but giving ``case_insensitive`` - a true value makes it case-insensitive. The value is considered true - if it is a non-empty string that is not equal to ``false``, ``none`` or - ``no``. If the value is not a string, its truth value is got directly - in Python. Considering ``none`` false is new in RF 3.0.3. + Args: + string: The string or bytes to search lines from. + pattern: The string pattern to search for. + case_insensitive: Deprecated alias for `ignore_case`. + ignore_case: If given a true value, matching is case-insensitive. - Lines are returned as one string catenated back together with - newlines. Possible trailing newline is never returned. The - number of matching lines is automatically logged. + Returns: + Matching lines joined with newlines. - Examples: - | ${lines} = | Get Lines Containing String | ${result} | An example | - | ${ret} = | Get Lines Containing String | ${ret} | FAIL | case-insensitive | + The `pattern` is always considered to be a normal string, not a glob + or regexp pattern. A line matches if the `pattern` is found anywhere + on it. - See `Get Lines Matching Pattern` and `Get Lines Matching Regexp` - if you need more complex pattern matching. - """ - if is_truthy(case_insensitive): - pattern = pattern.lower() - contains = lambda line: pattern in line.lower() - else: - contains = lambda line: pattern in line - return self._get_matching_lines(string, contains) + The match is case-sensitive by default, but that can be changed by + giving `ignore_case` a true value. This option is new in Robot + Framework 7.0, but with older versions it is possible to use the + nowadays deprecated `case_insensitive` argument. - def get_lines_matching_pattern(self, string, pattern, case_insensitive=False): - """Returns lines of the given ``string`` that match the ``pattern``. + Lines are returned as a single string with lines joined together with + a newline. Possible trailing newline is never returned. The number + of matching lines is automatically logged. - The ``pattern`` is a _glob pattern_ where: - | ``*`` | matches everything | - | ``?`` | matches any single character | - | ``[chars]`` | matches any character inside square brackets (e.g. ``[abc]`` matches either ``a``, ``b`` or ``c``) | - | ``[!chars]`` | matches any character not inside square brackets | + Examples: - A line matches only if it matches the ``pattern`` fully. + ```robotframework + *** Test Cases *** + Get lines containing string + ${lines} = Get Lines Containing String ${string} An example + ${lines} = Get Lines Containing String ${string} FAIL ignore_case=True + ``` - The match is case-sensitive by default, but giving ``case_insensitive`` - a true value makes it case-insensitive. The value is considered true - if it is a non-empty string that is not equal to ``false``, ``none`` or - ``no``. If the value is not a string, its truth value is got directly - in Python. Considering ``none`` false is new in RF 3.0.3. + See [Get Lines Matching Pattern] and [Get Lines Matching Regexp] + if you need more complex pattern matching. - Lines are returned as one string catenated back together with - newlines. Possible trailing newline is never returned. The - number of matching lines is automatically logged. + If the first argument is bytes, the second argument is automatically + converted to bytes as well. Bytes support is new in Robot Framework 7.4. + """ + if isinstance(string, bytes): + pattern = self._ensure_bytes(pattern) + if case_insensitive is not None: + ignore_case = case_insensitive + normalize = Normalizer(ignore_case=ignore_case).normalize + pattern = normalize(pattern) + return self._get_matching_lines(string, lambda line: pattern in normalize(line)) + + def get_lines_matching_pattern( + self, + string: "str | bytes", + pattern: "str | bytes", + case_insensitive: "bool | None" = None, + ignore_case: bool = False, + ) -> "str | bytes": + """Returns lines of the given `string` that match the `pattern`. + + Args: + string: The string or bytes to search lines from. + pattern: The glob pattern to match. + case_insensitive: Deprecated alias for `ignore_case`. + ignore_case: If given a true value, matching is case-insensitive. + + Returns: + Matching lines joined with newlines. + + The `pattern` is a _glob pattern_ where: + + | Pattern | Meaning | + | ---------- | ------------------------------------------------- | + | `*` | Matches everything. | + | `?` | Matches any single character. | + | `[chars]` | Matches any character inside square brackets (e.g. `[abc]` matches either `a`, `b` or `c`). | + | `[!chars]` | Matches any character not inside square brackets. | + + A line matches only if it matches the `pattern` fully. + + The match is case-sensitive by default, but that can be changed by + giving `ignore_case` a true value. This option is new in Robot + Framework 7.0, but with older versions it is possible to use the + nowadays deprecated `case_insensitive` argument. + + Lines are returned as a single string with lines joined together with + a newline. Possible trailing newline is never returned. The number + of matching lines is automatically logged. Examples: - | ${lines} = | Get Lines Matching Pattern | ${result} | Wild???? example | - | ${ret} = | Get Lines Matching Pattern | ${ret} | FAIL: * | case_insensitive=true | - See `Get Lines Matching Regexp` if you need more complex - patterns and `Get Lines Containing String` if searching - literal strings is enough. - """ - if is_truthy(case_insensitive): - pattern = pattern.lower() - matches = lambda line: fnmatchcase(line.lower(), pattern) - else: - matches = lambda line: fnmatchcase(line, pattern) - return self._get_matching_lines(string, matches) + ```robotframework + *** Test Cases *** + Get lines matching regexp + ${lines} = Get Lines Matching Pattern ${string} Wild???? example + ${lines} = Get Lines Matching Pattern ${string} FAIL: * ignore_case=True + ``` - def get_lines_matching_regexp(self, string, pattern, partial_match=False): - """Returns lines of the given ``string`` that match the regexp ``pattern``. - - See `BuiltIn.Should Match Regexp` for more information about - Python regular expression syntax in general and how to use it - in Robot Framework test data in particular. + See [Get Lines Matching Regexp] if you need more complex + patterns and [Get Lines Containing String] if searching + literal strings is enough. - By default lines match only if they match the pattern fully, 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 - ``no``. If the value is not a string, its truth value is got directly - in Python. Considering ``none`` false is new in RF 3.0.3. + If the first argument is bytes, the second argument is automatically + converted to bytes as well. Bytes support is new in Robot Framework 7.4. + """ + if isinstance(string, bytes): + pattern = self._ensure_bytes(pattern) + if case_insensitive is not None: + ignore_case = case_insensitive + normalize = Normalizer(ignore_case=ignore_case).normalize + pattern = normalize(pattern) + return self._get_matching_lines( + string, + lambda line: fnmatchcase(normalize(line), pattern), + ) + + def get_lines_matching_regexp( + self, + string: "str | bytes", + pattern: "str | bytes", + partial_match: bool = False, + flags: "str | None" = None, + ) -> "str | bytes": + """Returns lines of the given `string` that match the regexp `pattern`. + + Args: + string: The string or bytes to search lines from. + pattern: The regular expression pattern to match. + partial_match: If given a true value, lines match if the pattern + is found anywhere on the line. + flags: Optional regular expression flags. + + Returns: + Matching lines joined with newlines. + + 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. 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)``. + 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 + Lines are returned as a single string concatenated back together with newlines. Possible trailing newline is never returned. The number of matching lines is automatically logged. Examples: - | ${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: .* | - - See `Get Lines Matching Pattern` and `Get Lines Containing - String` if you do not need full regular expression powers (and - complexity). - ``partial_match`` argument is new in Robot Framework 2.9. In earlier - versions exact match was always required. + ```robotframework + *** Test Cases *** + Get lines matching regexp + ${lines} = Get Lines Matching Regexp ${string} Reg\\\\w{3} example + ${lines} = Get Lines Matching Regexp ${string} Reg\\\\w{3} example partial_match=True + ${lines} = Get Lines Matching Regexp ${string} (?i)FAIL: .* + ${lines} = Get Lines Matching Regexp ${string} FAIL: .* flags=IGNORECASE + ``` + + Notice that the backslash character that is often used with regular + expressions is an escape character in Robot Framework data. If + a literal backslash is needed, it needs to be doubled as the above + example illustrates. For more information about the supported regular + expression syntax and using regular expressions with Robot Framework + in general, see the documentation of the `BuiltIn.Should Match Regexp` + keyword. + + Use [Get Lines Matching Pattern] and [Get Lines Containing String] if + you do not need the full regular expression powers (and complexity). + + If the first argument is bytes, the second argument is automatically + converted to bytes as well. This is new in Robot Framework 7.4. + + The `flags` argument is new in Robot Framework 6.0. """ - if not is_truthy(partial_match): - pattern = '^%s$' % pattern - return self._get_matching_lines(string, re.compile(pattern).search) - - def _get_matching_lines(self, string, matches): + if isinstance(string, bytes): + pattern = self._ensure_bytes(pattern) + regexp = re.compile(pattern, flags=parse_re_flags(flags)) + match = regexp.search if partial_match else regexp.fullmatch + return self._get_matching_lines(string, match) + + def _get_matching_lines( + self, + string: "str | bytes", + matches: "Callable[[str | bytes], bool | re.Match[str] | None]", + ) -> "str | bytes": lines = string.splitlines() matching = [line for line in lines if matches(line)] - logger.info('%d out of %d lines matched' % (len(matching), len(lines))) - return '\n'.join(matching) - - def get_regexp_matches(self, string, pattern, *groups): + logger.info(f"{len(matching)} out of {len(lines)} lines matched.") + return ("\n" if isinstance(string, str) else b"\n").join(matching) + + def get_regexp_matches( + self, + string: "str | bytes", + pattern: "str | bytes", + *groups: "int | str", + flags: "str | None" = None, + ) -> "list[str] | list[tuple[str, ...]] | list[bytes] | list[tuple[bytes, ...]]": """Returns a list of all non-overlapping matches in the given string. - ``string`` is the string to find matches from and ``pattern`` is the + Args: + string: The string or bytes to find matches from. + pattern: The regular expression pattern to match. + *groups: Optional group indexes or names to return. + flags: Optional regular expression flags. + + Returns: + A list of matches, group contents, or tuples of group contents. + + `string` is the string to find matches from and `pattern` is the regular expression. See `BuiltIn.Should Match Regexp` for more information about Python regular expression syntax in general and how - to use it in Robot Framework test data in particular. + to use it in Robot Framework data in particular. If no groups are used, the returned list contains full matches. If one group is used, the list contains only contents of that group. If multiple groups are used, the list contains tuples that contain individual group contents. All groups can be given as indexes (starting - from 1) and named groups also as names. + from 1) and named groups can also be given 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.. | - | ${one group} = | Get Regexp Matches | the string | t(..) | 1 | - | ${named group} = | Get Regexp Matches | the string | t(?P..) | name | - | ${two groups} = | Get Regexp Matches | the string | t(.)(.) | 1 | 2 | - => - | ${no match} = [] - | ${matches} = ['the', 'tri'] - | ${one group} = ['he', 'ri'] - | ${named group} = ['he', 'ri'] - | ${two groups} = [('h', 'e'), ('r', 'i')] - - New in Robot Framework 2.9. - """ - regexp = re.compile(pattern) + + ```robotframework + *** Variables *** + ${string} the string + + *** Test Cases *** + No match + ${result} = Get Regexp Matches ${string} xxx + Should Be Equal ${result} [] type=list + + Match + ${result} = Get Regexp Matches ${string} t.. + Should Be Equal ${result} ["the", "tri"] type=list + + Flags + ${result} = Get Regexp Matches ${string} T.. flags=IGNORECASE + Should Be Equal ${result} ["the", "tri"] type=list + + One group by index + ${result} = Get Regexp Matches ${string} t(..) 1 + Should Be Equal ${result} ["he", "ri"] type=list + + One group by name + ${result} = Get Regexp Matches ${string} t(?P..) name + Should Be Equal ${result} ["he", "ri"] type=list + + Multiple groups + ${result} = Get Regexp Matches ${string} t(.)(.) 1 2 + Should Be Equal ${result} [("h", "e"), ("r", "i")] type=list + ``` + + If the first argument is bytes, the second argument is automatically + converted to bytes as well. This is new in Robot Framework 7.4. + + The `flags` argument is new in Robot Framework 6.0. + """ + if isinstance(string, bytes): + pattern = self._ensure_bytes(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)] - def _parse_group(self, group): + def _parse_group(self, group: "str | int") -> "str | int": try: return int(group) except ValueError: return group - def replace_string(self, string, search_for, replace_with, count=-1): - """Replaces ``search_for`` in the given ``string`` with ``replace_with``. - - ``search_for`` is used as a literal string. See `Replace String - Using Regexp` if more powerful pattern matching is needed. - If you need to just remove a string see `Remove String`. - - If the optional argument ``count`` is given, only that many - occurrences from left are replaced. Negative ``count`` means - that all occurrences are replaced (default behaviour) and zero + def replace_string( + self, + string: "str | bytes", + search_for: "str | bytes", + replace_with: "str | bytes", + count: int = -1, + ) -> "str | bytes": + """Replaces `search_for` in the given `string` with `replace_with`. + + Args: + string: The string or bytes to modify. + search_for: The string to search for. + replace_with: The replacement string. + count: Maximum number of replacements. `-1` means all (default). + + Returns: + The modified string or bytes. + + If the optional argument `count` is given, only that many + occurrences from left are replaced. Negative `count` means + that all occurrences are replaced (default behavior) and zero means that nothing is done. + `search_for` is used as a literal string. See [Replace String + Using Regexp] if more powerful pattern matching is needed. + If you need to just remove a string, see [Remove String]. + A modified version of the string is returned and the original string is not altered. Examples: - | ${str} = | Replace String | Hello, world! | world | tellus | - | Should Be Equal | ${str} | Hello, tellus! | | | - | ${str} = | Replace String | Hello, world! | l | ${EMPTY} | count=1 | - | Should Be Equal | ${str} | Helo, world! | | | + + ```robotframework + *** Test Cases *** + Replace string + ${str} = Replace String Hello, world! world tellus + Should Be Equal ${str} Hello, tellus! + ${str} = Replace String Hello, world! l e count=1 + Should Be Equal ${str} Heelo, world! + ``` + + If the first argument is bytes, the following two arguments are automatically + converted to bytes as well. This is new in Robot Framework 7.4. """ - count = self._convert_to_integer(count, 'count') + if isinstance(string, bytes): + search_for = self._ensure_bytes(search_for) + replace_with = self._ensure_bytes(replace_with) return string.replace(search_for, replace_with, count) - def replace_string_using_regexp(self, string, pattern, replace_with, count=-1): - """Replaces ``pattern`` in the given ``string`` with ``replace_with``. - - This keyword is otherwise identical to `Replace String`, but - the ``pattern`` to search for is considered to be a regular + def replace_string_using_regexp( + self, + string: "str | bytes", + pattern: "str | bytes", + replace_with: "str | bytes", + count: int = -1, + flags: "str | None" = None, + ) -> "str | bytes": + """Replaces `pattern` in the given `string` with `replace_with`. + + Args: + string: The string or bytes to modify. + pattern: The regular expression pattern to search for. + replace_with: The replacement string. + count: Maximum number of replacements. `-1` means all (default). + flags: Optional regular expression flags. + + Returns: + The modified string or bytes. + + This keyword is otherwise identical to [Replace String], but + the `pattern` to search for is considered to be a regular expression. See `BuiltIn.Should Match Regexp` for more information about Python regular expression syntax in general - and how to use it in Robot Framework test data in particular. + 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`. + 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 | | - | ${str} = | Replace String Using Regexp | ${str} | (Hello|Hi) | ${EMPTY} | count=1 | + + ```robotframework + *** Test Cases *** + Replace string using regexp + ${str} = Replace String Using Regexp ${str} 2026-\\\\d\\\\d-\\\\d\\\\d + ${str} = Replace String Using Regexp ${str} (Hello|Hi) Moi count=1 + ``` + + If the first argument is bytes, the following two arguments are automatically + converted to bytes as well. This is new in Robot Framework 7.4. + + 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 if count == 0: return string - return re.sub(pattern, replace_with, string, max(count, 0)) - - def remove_string(self, string, *removables): - """Removes all ``removables`` from the given ``string``. - - ``removables`` are used as literal strings. Each removable will be + if isinstance(string, bytes): + pattern = self._ensure_bytes(pattern) + replace_with = self._ensure_bytes(replace_with) + return re.sub( + pattern, + replace_with, + string, + count=max(count, 0), + flags=parse_re_flags(flags), + ) + + def remove_string( + self, + string: "str | bytes", + *removables: "str | bytes", + ) -> "str | bytes": + """Removes all `removables` from the given `string`. + + Args: + string: The string or bytes to modify. + *removables: Strings to remove. + + Returns: + The modified string or bytes. + + `removables` are used as literal strings. Each removable will be matched to a temporary string from which preceding removables have - been already removed. See second example below. + been already removed. See the second example below. - Use `Remove String Using Regexp` if more powerful pattern matching is + Use [Remove String Using Regexp] if more powerful pattern matching is needed. If only a certain number of matches should be removed, - `Replace String` or `Replace String Using Regexp` can be used. + [Replace String] or [Replace String Using Regexp] can be used. A modified version of the string is returned and the original string is not altered. Examples: - | ${str} = | Remove String | Robot Framework | work | - | Should Be Equal | ${str} | Robot Frame | - | ${str} = | Remove String | Robot Framework | o | bt | - | Should Be Equal | ${str} | R Framewrk | + + ```robotframework + *** Test Cases *** + Remove string + ${str} = Remove String Robot Framework work + Should Be Equal ${str} Robot Frame + ${str} = Remove String Robot Framework o bt + Should Be Equal ${str} R Framewrk + ``` + + If the first argument is bytes, the second argument is automatically + converted to bytes as well. Bytes support is new in Robot Framework 7.4. """ for removable in removables: - string = self.replace_string(string, removable, '') + string = self.replace_string(string, removable, "") return string - def remove_string_using_regexp(self, string, *patterns): - """Removes ``patterns`` from the given ``string``. - - This keyword is otherwise identical to `Remove String`, but - the ``patterns`` to search for are considered to be a regular - expression. See `Replace String Using Regexp` for more information + def remove_string_using_regexp( + self, + string: "str | bytes", + *patterns: "str | bytes", + flags: "str | None" = None, + ) -> "str | bytes": + """Removes `patterns` from the given `string`. + + Args: + string: The string or bytes to modify. + *patterns: Regular expression patterns to remove. + flags: Optional regular expression flags. + + Returns: + The modified string or bytes. + + This keyword is otherwise identical to [Remove String], but + the `patterns` to search for are considered to be a regular + expression. See [Replace String Using Regexp] for more information 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`). + + If the first argument is bytes, the second argument is automatically + converted to bytes as well. Bytes support is new in Robot Framework 7.4. + + The `flags` argument is new in Robot Framework 6.0. """ for pattern in patterns: - string = self.replace_string_using_regexp(string, pattern, '') + string = self.replace_string_using_regexp(string, pattern, "", flags=flags) return string - def split_string(self, string, separator=None, max_split=-1): - """Splits the ``string`` using ``separator`` as a delimiter string. + def split_string( + self, + string: "str | bytes", + separator: "str | bytes | None" = None, + max_split: int = -1, + ) -> "list[str] | list[bytes]": + """Splits the `string` using `separator` as a delimiter string. + + Args: + string: The string or bytes to split. + separator: The delimiter string. Defaults to splitting on whitespace. + max_split: Maximum number of splits. `-1` means no limit (default). + + Returns: + A list of split parts. - If a ``separator`` is not given, any whitespace string is a + If a `separator` is not given, any whitespace string is a separator. In that case also possible consecutive whitespace as well as leading and trailing whitespace is ignored. - Split words are returned as a list. If the optional - ``max_split`` is given, at most ``max_split`` splits are done, and - the returned list will have maximum ``max_split + 1`` elements. + Split parts are returned as a list. If the optional + `max_split` is given, at most `max_split` splits are done, and + the returned list will have maximum `max_split + 1` elements. Examples: - | @{words} = | Split String | ${string} | - | @{words} = | Split String | ${string} | ,${SPACE} | - | ${pre} | ${post} = | Split String | ${string} | :: | 1 | - See `Split String From Right` if you want to start splitting - from right, and `Fetch From Left` and `Fetch From Right` if + ```robotframework + *** Test Cases *** + Split string + @{words} = Split String ${string} + @{words} = Split String ${string} ,${SPACE} + ${pre} ${post} = Split String ${string} :: 1 + ``` + + See [Split String From Right] if you want to start splitting + from right, and [Fetch From Left] and [Fetch From Right] if you only want to get first/last part of the string. + + If the first argument is bytes, the second argument is automatically + converted to bytes as well. This is new in Robot Framework 7.4. """ - if separator == '': - separator = None - max_split = self._convert_to_integer(max_split, 'max_split') + separator = self._deprecate_empty_string("separator", separator, None) + if isinstance(string, bytes) and separator is not None: + separator = self._ensure_bytes(separator) return string.split(separator, max_split) - def split_string_from_right(self, string, separator=None, max_split=-1): - """Splits the ``string`` using ``separator`` starting from right. + def split_string_from_right( + self, + string: "str | bytes", + separator: "str | bytes | None" = None, + max_split: int = -1, + ) -> "list[str] | list[bytes]": + """Splits the `string` using `separator` starting from right. + + Args: + string: The string or bytes to split. + separator: The delimiter string. Defaults to splitting on whitespace. + max_split: Maximum number of splits. `-1` means no limit (default). + + Returns: + A list of split parts. - Same as `Split String`, but splitting is started from right. This has - an effect only when ``max_split`` is given. + Same as [Split String], but splitting is started from right. This has + an effect only when `max_split` is given. Examples: - | ${first} | ${rest} = | Split String | ${string} | - | 1 | - | ${rest} | ${last} = | Split String From Right | ${string} | - | 1 | + + ```robotframework + *** Test Cases *** + Split string from right + ${first} ${rest} = Split String ${string} - 1 + ${rest} ${last} = Split String From Right ${string} - 1 + ``` + + If the first argument is bytes, the second argument is automatically + converted to bytes as well. This is new in Robot Framework 7.4. """ - if separator == '': - separator = None - max_split = self._convert_to_integer(max_split, 'max_split') + separator = self._deprecate_empty_string("separator", separator, None) + if isinstance(string, bytes) and separator is not None: + separator = self._ensure_bytes(separator) return string.rsplit(separator, max_split) - def split_string_to_characters(self, string): - """Splits the given ``string`` to characters. + def split_string_to_characters( + self, + string: "str | bytes", + ) -> "list[str] | list[bytes]": + """Splits the given `string` to characters. + + Args: + string: The string or bytes to split. + + Returns: + A list of single-character strings or bytes. Example: - | @{characters} = | Split String To Characters | ${string} | + + ```robotframework + *** Test Cases *** + Split string to characters + @{characters} = Split String To Characters ${string} + ``` + + Bytes support is new in Robot Framework 7.4. """ + if isinstance(string, bytes): + return [bytes([o]) for o in string] return list(string) - def fetch_from_left(self, string, marker): - """Returns contents of the ``string`` before the first occurrence of ``marker``. + def fetch_from_left( + self, + string: "str | bytes", + marker: "str | bytes", + ) -> "str | bytes": + """Returns contents of the `string` before the first occurrence of `marker`. + + Args: + string: The string or bytes to search from. + marker: The marker string to search for. + + Returns: + The part of the string before the first `marker`. - If the ``marker`` is not found, whole string is returned. + If the `marker` is not found, whole string is returned. - See also `Fetch From Right`, `Split String` and `Split String - From Right`. + If the first argument is bytes, the second argument is automatically + converted to bytes as well. This is new in Robot Framework 7.4. + + See also [Fetch From Right], [Split String] and [Split String From Right]. """ + if isinstance(string, bytes): + marker = self._ensure_bytes(marker) return string.split(marker)[0] - def fetch_from_right(self, string, marker): - """Returns contents of the ``string`` after the last occurrence of ``marker``. + def fetch_from_right( + self, + string: "str | bytes", + marker: "str | bytes", + ) -> "str | bytes": + """Returns contents of the `string` after the last occurrence of `marker`. + + Args: + string: The string or bytes to search from. + marker: The marker string to search for. + + Returns: + The part of the string after the last `marker`. - If the ``marker`` is not found, whole string is returned. + If the `marker` is not found, whole string is returned. - See also `Fetch From Left`, `Split String` and `Split String - From Right`. + If the first argument is bytes, the second argument is automatically + converted to bytes as well. This is new in Robot Framework 7.4. + + See also [Fetch From Left], [Split String] and [Split String From Right]. """ + if isinstance(string, bytes): + marker = self._ensure_bytes(marker) return string.split(marker)[-1] - def generate_random_string(self, length=8, chars='[LETTERS][NUMBERS]'): - """Generates a string with a desired ``length`` from the given ``chars``. + def generate_random_string( + self, + length: "int | str" = 8, + chars: str = "[LETTERS][NUMBERS]", + ) -> str: + """Generates a string with a desired `length` from the given `chars`. + + Args: + length: Desired length of the generated string. Can be an integer, + a string representation of an integer, or a range like `5-10`. + chars: Characters to use when generating the string. - The population sequence ``chars`` contains the characters to use + Returns: + The generated random string. + + `length` can be given as a number, a string representation of a number, + or as a range of numbers, such as `5-10`. When a range of values is given + the range will be selected by random within the range. + + The population sequence `chars` contains the characters to use when generating the random string. It can contain any characters, and it is possible to use special markers explained in the table below: - | = Marker = | = Explanation = | - | ``[LOWER]`` | Lowercase ASCII characters from ``a`` to ``z``. | - | ``[UPPER]`` | Uppercase ASCII characters from ``A`` to ``Z``. | - | ``[LETTERS]`` | Lowercase and uppercase ASCII characters. | - | ``[NUMBERS]`` | Numbers from 0 to 9. | + | Marker | Explanation | + | ----------- | ---------------------------------------------------- | + | `[LOWER]` | Lowercase ASCII characters from `a` to `z`. | + | `[UPPER]` | Uppercase ASCII characters from `A` to `Z`. | + | `[LETTERS]` | Lowercase and uppercase ASCII characters. | + | `[NUMBERS]` | Numbers from 0 to 9. | + | `[ARABIC]` | Arabic characters from U+0600 to U+06FF (inclusive). | + | `[POLISH]` | ASCII characters and Polish diacritical signs. | Examples: - | ${ret} = | Generate Random String | - | ${low} = | Generate Random String | 12 | [LOWER] | - | ${bin} = | Generate Random String | 8 | 01 | - | ${hex} = | Generate Random String | 4 | [NUMBERS]abcdef | - """ - if length == '': - length = 8 - length = self._convert_to_integer(length, 'length') - for name, value in [('[LOWER]', ascii_lowercase), - ('[UPPER]', ascii_uppercase), - ('[LETTERS]', ascii_lowercase + ascii_uppercase), - ('[NUMBERS]', digits)]: - chars = chars.replace(name, value) - maxi = len(chars) - 1 - return ''.join(chars[randint(0, maxi)] for _ in range(length)) - def get_substring(self, string, start, end=None): - """Returns a substring from ``start`` index to ``end`` index. + ```robotframework + *** Test Cases *** + Generate random string + ${ret} = Generate Random String + ${low} = Generate Random String 12 [LOWER] + ${bin} = Generate Random String 8 01 + ${hex} = Generate Random String 4 [NUMBERS]abcdef + ${rnd} = Generate Random String 5-10 + ``` + + Giving `length` as a range of values is new in Robot Framework 5.0. + Support for `[POLISH]` and `[ARABIC]` markers is new in Robot Framework 7.4. + """ + length = self._deprecate_empty_string("length", length, 8) + if isinstance(length, str) and re.fullmatch(r"\d+-\d+", length): + min_length, max_length = length.split("-") + length = randint( + self._length_to_int(min_length), + self._length_to_int(max_length), + ) + else: + length = self._length_to_int(length) + for name, value in MARKERS.items(): + if name in chars: + chars = chars.replace(name, value) + maxi = len(chars) - 1 + return "".join(chars[randint(0, maxi)] for _ in range(length)) - The ``start`` index is inclusive and ``end`` is exclusive. + def _length_to_int(self, value: "int | str") -> int: + try: + return int(value) + except ValueError: + raise ValueError( + f"Cannot convert 'length' argument {value!r} to an integer." + ) + + def get_substring( + self, + string: "str | bytes", + start: "int | Literal[''] | None" = 0, + end: "int | Literal[''] | None" = None, + ) -> "str | bytes": + """Returns a substring from `start` index to `end` index. + + Args: + string: The string or bytes to get the substring from. + start: Start index. Defaults to `0`. + end: End index. Defaults to `None` (end of string). + + Returns: + The requested substring. + + The `start` index is inclusive and `end` is exclusive. Indexing starts from 0, and it is possible to use negative indices to refer to characters from the end. Examples: - | ${ignore first} = | Get Substring | ${string} | 1 | | - | ${ignore last} = | Get Substring | ${string} | | -1 | - | ${5th to 10th} = | Get Substring | ${string} | 4 | 10 | - | ${first two} = | Get Substring | ${string} | | 1 | - | ${last two} = | Get Substring | ${string} | -2 | | - """ - start = self._convert_to_index(start, 'start') - end = self._convert_to_index(end, 'end') + + ```robotframework + *** Test Cases *** + Get substring + ${ignore first} = Get Substring ${string} 1 + ${ignore last} = Get Substring ${string} 0 -1 + ${5th to 10th} = Get Substring ${string} 4 10 + ${first two} = Get Substring ${string} end=1 + ${last two} = Get Substring ${string} -2 + ``` + + Default value with `start` is new in Robot Framework 7.4. + """ + start = self._deprecate_empty_string("start", start, 0) + end = self._deprecate_empty_string("end", end, 0) return string[start:end] - def strip_string(self, string, mode='both', characters=None): + def strip_string( + self, + string: "str | bytes", + mode: Literal["left", "right", "both", "none"] = "both", + characters: "str | bytes | None" = None, + ) -> "str | bytes": """Remove leading and/or trailing whitespaces from the given string. - ``mode`` is either ``left`` to remove leading characters, ``right`` to - remove trailing characters, ``both`` (default) to remove the - characters from both sides of the string or ``none`` to return the + Args: + string: The string or bytes to strip. + mode: Which end(s) of the string to strip from. + characters: Optional characters to remove instead of whitespace. + + Returns: + The stripped string or bytes. + + `mode` is either `left` to remove leading characters, `right` to + remove trailing characters, `both` (default) to remove the + characters from both sides of the string or `none` to return the unmodified string. - If the optional ``characters`` is given, it must be a string and the - characters in the string will be stripped in the string. Please note, - that this is not a substring to be removed but a list of characters, - see the example below. + This keyword strips whitespace by default, but arbitrary characters + can be stripped by using the optional `characters` argument. + As the example below illustrates, each character is separately stripped + instead of considering `characters` a substring to be stripped. Examples: - | ${stripped}= | Strip String | ${SPACE}Hello${SPACE} | | - | Should Be Equal | ${stripped} | Hello | | - | ${stripped}= | Strip String | ${SPACE}Hello${SPACE} | mode=left | - | Should Be Equal | ${stripped} | Hello${SPACE} | | - | ${stripped}= | Strip String | aabaHelloeee | characters=abe | - | Should Be Equal | ${stripped} | Hello | | - New in Robot Framework 3.0. + ```robotframework + *** Test Cases *** + Strip string + ${stripped} = Strip String ${SPACE}Hello${SPACE} + Should Be Equal ${stripped} Hello + ${stripped} = Strip String ${SPACE}Hello${SPACE} mode=left + Should Be Equal ${stripped} Hello${SPACE} + ${stripped} = Strip String aabaHelloeee characters=abe + Should Be Equal ${stripped} Hello + ``` + + If the first argument is bytes, the `characters` argument is automatically + converted to bytes as well. Bytes support is new in Robot Framework 7.4. """ - try: - method = {'BOTH': string.strip, - 'LEFT': string.lstrip, - 'RIGHT': string.rstrip, - 'NONE': lambda characters: string}[mode.upper()] - except KeyError: - raise ValueError("Invalid mode '%s'." % mode) + if isinstance(string, bytes) and characters is not None: + characters = self._ensure_bytes(characters) + method = { + "both": string.strip, + "left": string.lstrip, + "right": string.rstrip, + "none": lambda chars: string, + }[mode.lower()] return method(characters) - def should_be_string(self, item, msg=None): - """Fails if the given ``item`` is not a string. - - With Python 2, except with IronPython, this keyword passes regardless - is the ``item`` a Unicode string or a byte string. Use `Should Be - Unicode String` or `Should Be Byte String` if you want to restrict - the string type. Notice that with Python 2, except with IronPython, - ``'string'`` creates a byte string and ``u'unicode'`` must be used to - create a Unicode string. - - With Python 3 and IronPython, this keyword passes if the string is - a Unicode string but fails if it is bytes. Notice that with both - Python 3 and IronPython, ``'string'`` creates a Unicode string, and - ``b'bytes'`` must be used to create a byte string. + def should_be_string(self, item: object, msg: "str | None" = None): + """Fails if the given `item` is not a string. - The default error message can be overridden with the optional - ``msg`` argument. + Args: + item: The object to verify. + msg: Optional custom error message. """ - if not is_string(item): - self._fail(msg, "'%s' is not a string.", item) + if not isinstance(item, str): + raise AssertionError(msg or f"{item!r} is {type_name(item)}, not a string.") - def should_not_be_string(self, item, msg=None): - """Fails if the given ``item`` is a string. + def should_not_be_string(self, item: object, msg: "str | None" = None): + """Fails if the given `item` is a string. - See `Should Be String` for more details about Unicode strings and byte - strings. - - The default error message can be overridden with the optional - ``msg`` argument. + Args: + item: The object to verify. + msg: Optional custom error message. """ - if is_string(item): - self._fail(msg, "'%s' is a string.", item) + if isinstance(item, str): + raise AssertionError(msg or f"{item!r} is a string.") - def should_be_unicode_string(self, item, msg=None): - """Fails if the given ``item`` is not a Unicode string. + def should_be_unicode_string(self, item: object, msg: "str | None" = None): + """Fails if the given `item` is not a Unicode string. - Use `Should Be Byte String` if you want to verify the ``item`` is a - byte string, or `Should Be String` if both Unicode and byte strings - are fine. See `Should Be String` for more details about Unicode - strings and byte strings. + On Python 3 this keyword behaves exactly the same way [Should Be String]. + That keyword should be used instead and this keyword will be deprecated. - The default error message can be overridden with the optional - ``msg`` argument. + Args: + item: The object to verify. + msg: Optional custom error message. """ - if not is_unicode(item): - self._fail(msg, "'%s' is not a Unicode string.", item) + self.should_be_string(item, msg) - def should_be_byte_string(self, item, msg=None): - """Fails if the given ``item`` is not a byte string. + def should_be_byte_string(self, item: object, msg: "str | None" = None): + """Fails if the given `item` is not a byte string. - Use `Should Be Unicode String` if you want to verify the ``item`` is a - Unicode string, or `Should Be String` if both Unicode and byte strings - are fine. See `Should Be String` for more details about Unicode strings - and byte strings. + Use [Should Be String] if you want to verify the `item` is a string. - The default error message can be overridden with the optional - ``msg`` argument. + Args: + item: The object to verify. + msg: Optional custom error message. """ - if not is_bytes(item): - self._fail(msg, "'%s' is not a byte string.", item) + if not isinstance(item, bytes): + raise AssertionError(msg or f"{item!r} is not a byte string.") - def should_be_lowercase(self, string, msg=None): - """Fails if the given ``string`` is not in lowercase. + def should_be_lower_case(self, string: "str | bytes", msg: "str | None" = None): + """Fails if the given `string` is not in lower case. - For example, ``'string'`` and ``'with specials!'`` would pass, and - ``'String'``, ``''`` and ``' '`` would fail. + Args: + string: The string or bytes to verify. + msg: Optional custom error message. - The default error message can be overridden with the optional - ``msg`` argument. + For example, `'string'` and `'with specials!'` would pass, and + `'String'`, `''` and `' '` would fail. - See also `Should Be Uppercase` and `Should Be Titlecase`. + See also [Should Be Upper Case] and [Should Be Title Case]. """ if not string.islower(): - self._fail(msg, "'%s' is not lowercase.", string) + raise AssertionError(msg or f"{string!r} is not lower case.") - def should_be_uppercase(self, string, msg=None): - """Fails if the given ``string`` is not in uppercase. + def should_be_upper_case(self, string: "str | bytes", msg: "str | None" = None): + """Fails if the given `string` is not in upper case. - For example, ``'STRING'`` and ``'WITH SPECIALS!'`` would pass, and - ``'String'``, ``''`` and ``' '`` would fail. + Args: + string: The string or bytes to verify. + msg: Optional custom error message. - The default error message can be overridden with the optional - ``msg`` argument. + For example, `'STRING'` and `'WITH SPECIALS!'` would pass, and + `'String'`, `''` and `' '` would fail. - See also `Should Be Titlecase` and `Should Be Lowercase`. + See also [Should Be Title Case] and [Should Be Lower Case]. """ if not string.isupper(): - self._fail(msg, "'%s' is not uppercase.", string) - - def should_be_titlecase(self, string, msg=None): - """Fails if given ``string`` is not title. - - ``string`` is a titlecased string if there is at least one - character in it, uppercase characters only follow uncased - characters and lowercase characters only cased ones. - - For example, ``'This Is Title'`` would pass, and ``'Word In UPPER'``, - ``'Word In lower'``, ``''`` and ``' '`` would fail. - - The default error message can be overridden with the optional - ``msg`` argument. - - See also `Should Be Uppercase` and `Should Be Lowercase`. + raise AssertionError(msg or f"{string!r} is not upper case.") + + def should_be_title_case( + self, + string: "str | bytes", + msg: "str | None" = None, + exclude: "str | bytes | list[str | bytes] | None" = None, + ): + """Fails if given `string` is not title. + + Args: + string: The string or bytes to verify. + msg: Optional custom error message. + exclude: Optional words to exclude from title case verification. + Can be a list or a comma-separated string. Each word is treated + as a regular expression pattern. + + `string` is a title cased string if there is at least one upper case + letter in each word. + + For example, `'This Is Title'` and `'OK, Give Me My iPhone'` + would pass. `'all words lower'` and `'Word In lower'` would fail. + + This logic changed in Robot Framework 4.0 to be compatible with + [Convert To Title Case]. See [Convert To Title Case] for title case + algorithm and reasoning. + + See also [Should Be Upper Case] and [Should Be Lower Case]. """ - if not string.istitle(): - self._fail(msg, "'%s' is not titlecase.", string) - - def _convert_to_index(self, value, name): - if value == '': - return 0 - if value is None: - return None - return self._convert_to_integer(value, name) - - def _convert_to_integer(self, value, name): - try: - return int(value) - except ValueError: - raise ValueError("Cannot convert '%s' argument '%s' to an integer." - % (name, value)) - - def _fail(self, message, default_template, *items): - if not message: - message = default_template % tuple(unic(item) for item in items) - raise AssertionError(message) + if string != self.convert_to_title_case(string, exclude): + raise AssertionError(msg or f"{string!r} is not title case.") + + def _deprecate_empty_string( + self, + name: str, + value: object, + instead: object, + ) -> object: + # Deprecated in RF 7.4. TODO: Remove in RF 9. + if value == "": + logger.warn( + f"Using an empty string as a value with argument '{name}' is " + f"deprecated. Use '{instead}' instead." + ) + return instead + return value diff --git a/src/robot/libraries/Telnet.py b/src/robot/libraries/Telnet.py index 80d2e155eb0..e7b3aa882bd 100644 --- a/src/robot/libraries/Telnet.py +++ b/src/robot/libraries/Telnet.py @@ -13,13 +13,13 @@ # See the License for the specific language governing permissions and # limitations under the License. -from contextlib import contextmanager import inspect import re import socket import struct import telnetlib import time +from contextlib import contextmanager try: import pyte @@ -28,29 +28,32 @@ from robot.api import logger from robot.api.deco import keyword -from robot.utils import (ConnectionCache, is_bytes, is_string, is_truthy, - is_unicode, secs_to_timestr, seq2str, timestr_to_secs) +from robot.utils import ( + ConnectionCache, is_truthy, secs_to_timestr, seq2str, timestr_to_secs +) from robot.version import get_version -class Telnet(object): - """A test library providing communication over Telnet connections. +class Telnet: + """A library providing communication over Telnet connections. ``Telnet`` is Robot Framework's standard library that makes it possible to connect to Telnet servers and execute commands on the opened connections. == Table of contents == - - `Connections` - - `Writing and reading` - - `Configuration` - - `Terminal emulation` - - `Logging` - - `Time string format` - - `Boolean arguments` - - `Importing` - - `Shortcuts` - - `Keywords` + %TOC% + + = Dependencies = + + ``Telnet`` uses the ``telnetlib`` module internally. This module was + part of the Python standard library until Python 3.12, but with newer + versions the [https://pypi.org/project/telnetlib-313-and-up|telnetlib-313-and-up] + package needs to be installed separately. + + If there is a need to do `terminal emulation`, the + [https://pypi.org/project/pyte|pyte] module needs to be installed. It is + an optional dependency and only needed with terminal emulation. = Connections = @@ -115,8 +118,6 @@ class Telnet(object): opening the telnet connection. It is used internally by `Open Connection`. The default value is the system global default timeout. - New in Robot Framework 2.9.2. - == Newline == Newline defines which line separator `Write` keyword should use. The @@ -155,7 +156,7 @@ class Telnet(object): Notice that when writing to the connection, only Unicode strings are encoded using the defined encoding. Byte strings are expected to be already - encoded correctly. Notice also that normal text in test data is passed to + encoded correctly. Notice also that normal text in data is passed to the library as Unicode and you need to use variables to use bytes. It is also possible to configure the error handler to use if encoding or @@ -203,7 +204,7 @@ class Telnet(object): = Terminal emulation = Telnet library supports terminal - emulation with [http://pyte.readthedocs.io|Pyte]. Terminal emulation + emulation with [http://pyte.readthedocs.io|pyte]. Terminal emulation will process the output in a virtual screen. This means that ANSI escape codes, like cursor movements, and also control characters, like carriage returns and backspaces, have the same effect on the result as they @@ -214,7 +215,7 @@ class Telnet(object): argument a true value (see `Boolean arguments`) either in the library initialization or with `Open Connection`. - As Pyte approximates vt-style terminal, you may also want to set the + As ``pyte`` approximates vt-style terminal, you may also want to set the terminal type as ``vt100``. We also recommend that you increase the window size, as the terminal emulation will break all lines that are longer than the window row length. @@ -225,15 +226,8 @@ class Telnet(object): Examples: | `Open Connection` | lolcathost | terminal_emulation=True | terminal_type=vt100 | window_size=400x100 | - As a prerequisite for using terminal emulation, you need to have Pyte - installed. Due to backwards incompatible changes in Pyte, different - Robot Framework versions support different Pyte versions: - - - Pyte 0.6 and newer are supported by Robot Framework 3.0.3. - Latest Pyte version can be installed (or upgraded) with - ``pip install --upgrade pyte``. - - Pyte 0.5.2 and older are supported by Robot Framework 3.0.2 and earlier. - Pyte 0.5.2 can be installed with ``pip install pyte==0.5.2``. + As discussed in the `Dependencies` section, ``pyte`` is an optional dependency. + It needs to be installed separately to be able to use terminal emulation. = Logging = @@ -286,16 +280,26 @@ class Telnet(object): Considering string ``NONE`` false is new in Robot Framework 3.0.3 and considering also ``OFF`` and ``0`` false is new in Robot Framework 3.1. """ - ROBOT_LIBRARY_SCOPE = 'TEST_SUITE' + + ROBOT_LIBRARY_SCOPE = "SUITE" ROBOT_LIBRARY_VERSION = get_version() - def __init__(self, timeout='3 seconds', newline='CRLF', - prompt=None, prompt_is_regexp=False, - encoding='UTF-8', encoding_errors='ignore', - default_log_level='INFO', window_size=None, - environ_user=None, terminal_emulation=False, - terminal_type=None, telnetlib_log_level='TRACE', - connection_timeout=None): + def __init__( + self, + timeout="3 seconds", + newline="CRLF", + prompt=None, + prompt_is_regexp=False, + encoding="UTF-8", + encoding_errors="ignore", + default_log_level="INFO", + window_size=None, + environ_user=None, + terminal_emulation=False, + terminal_type=None, + telnetlib_log_level="TRACE", + connection_timeout=None, + ): """Telnet library can be imported with optional configuration parameters. Configuration parameters are used as default values when new @@ -321,7 +325,7 @@ def __init__(self, timeout='3 seconds', newline='CRLF', """ self._timeout = timeout or 3.0 self._set_connection_timeout(connection_timeout) - self._newline = newline or 'CRLF' + self._newline = newline or "CRLF" self._prompt = (prompt, prompt_is_regexp) self._encoding = encoding self._encoding_errors = encoding_errors @@ -340,24 +344,30 @@ def get_keyword_names(self): def _get_library_keywords(self): if self._lib_kws is None: - self._lib_kws = self._get_keywords(self, ['get_keyword_names']) + self._lib_kws = self._get_keywords(self, ["get_keyword_names"]) return self._lib_kws def _get_keywords(self, source, excluded): - return [name for name in dir(source) - if self._is_keyword(name, source, excluded)] + return [ + name for name in dir(source) if self._is_keyword(name, source, excluded) + ] def _is_keyword(self, name, source, excluded): - return (name not in excluded and - not name.startswith('_') and - name != 'get_keyword_names' and - inspect.ismethod(getattr(source, name))) + return ( + name not in excluded + and not name.startswith("_") + and name != "get_keyword_names" + and inspect.ismethod(getattr(source, name)) + ) def _get_connection_keywords(self): if self._conn_kws is None: conn = self._get_connection() - excluded = [name for name in dir(telnetlib.Telnet()) - if name not in ['write', 'read', 'read_until']] + excluded = [ + name + for name in dir(telnetlib.Telnet()) + if name not in ["write", "read", "read_until"] + ] self._conn_kws = self._get_keywords(conn, excluded) return self._conn_kws @@ -370,13 +380,25 @@ def __getattr__(self, name): return getattr(self._conn or self._get_connection(), name) @keyword(types=None) - def open_connection(self, host, alias=None, port=23, timeout=None, - newline=None, prompt=None, prompt_is_regexp=False, - encoding=None, encoding_errors=None, - default_log_level=None, window_size=None, - environ_user=None, terminal_emulation=None, - terminal_type=None, telnetlib_log_level=None, - connection_timeout=None): + def open_connection( + self, + host, + alias=None, + port=23, + timeout=None, + newline=None, + prompt=None, + prompt_is_regexp=False, + encoding=None, + encoding_errors=None, + default_log_level=None, + window_size=None, + environ_user=None, + terminal_emulation=None, + terminal_type=None, + telnetlib_log_level=None, + connection_timeout=None, + ): """Opens a new Telnet connection to the given host and port. The ``timeout``, ``newline``, ``prompt``, ``prompt_is_regexp``, @@ -394,9 +416,11 @@ def open_connection(self, host, alias=None, port=23, timeout=None, `Close All Connections` keyword. """ timeout = timeout or self._timeout - connection_timeout = (timestr_to_secs(connection_timeout) - if connection_timeout - else self._connection_timeout) + connection_timeout = ( + timestr_to_secs(connection_timeout) + if connection_timeout + else self._connection_timeout + ) newline = newline or self._newline encoding = encoding or self._encoding encoding_errors = encoding_errors or self._encoding_errors @@ -405,33 +429,45 @@ def open_connection(self, host, alias=None, port=23, timeout=None, environ_user = environ_user or self._environ_user if terminal_emulation is None: terminal_emulation = self._terminal_emulation + else: + terminal_emulation = is_truthy(terminal_emulation) terminal_type = terminal_type or self._terminal_type telnetlib_log_level = telnetlib_log_level or self._telnetlib_log_level if not prompt: prompt, prompt_is_regexp = self._prompt - logger.info('Opening connection to %s:%s with prompt: %s%s' - % (host, port, prompt, ' (regexp)' if prompt_is_regexp else '')) - self._conn = self._get_connection(host, port, timeout, newline, - prompt, is_truthy(prompt_is_regexp), - encoding, encoding_errors, - default_log_level, - window_size, - environ_user, - is_truthy(terminal_emulation), - terminal_type, - telnetlib_log_level, - connection_timeout) + logger.info( + f"Opening connection to {host}:{port} with prompt: " + f"{prompt}{' (regexp)' if prompt_is_regexp else ''}" + ) + self._conn = self._get_connection( + host, + port, + timeout, + newline, + prompt, + prompt_is_regexp, + encoding, + encoding_errors, + default_log_level, + window_size, + environ_user, + terminal_emulation, + terminal_type, + telnetlib_log_level, + connection_timeout, + ) return self._cache.register(self._conn, alias) def _parse_window_size(self, window_size): if not window_size: return None try: - cols, rows = window_size.split('x', 1) + cols, rows = window_size.split("x", 1) return int(cols), int(rows) except ValueError: - raise ValueError("Invalid window size '%s'. Should be " - "x." % window_size) + raise ValueError( + f"Invalid window size '{window_size}'. Should be x." + ) def _get_connection(self, *args): """Can be overridden to use a custom connection.""" @@ -483,7 +519,7 @@ def close_all_connections(self): If multiple connections are opened, this keyword should be used in a test or suite teardown to make sure that all connections are closed. - It is not an error is some of the connections have already been closed + It is not an error if some of the connections have already been closed by `Close Connection`. After this keyword, new indexes returned by `Open Connection` @@ -493,22 +529,33 @@ def close_all_connections(self): class TelnetConnection(telnetlib.Telnet): - NEW_ENVIRON_IS = b'\x00' - NEW_ENVIRON_VAR = b'\x00' - NEW_ENVIRON_VALUE = b'\x01' + NEW_ENVIRON_IS = b"\x00" + NEW_ENVIRON_VAR = b"\x00" + NEW_ENVIRON_VALUE = b"\x01" INTERNAL_UPDATE_FREQUENCY = 0.03 - def __init__(self, host=None, port=23, timeout=3.0, newline='CRLF', - prompt=None, prompt_is_regexp=False, - encoding='UTF-8', encoding_errors='ignore', - default_log_level='INFO', window_size=None, environ_user=None, - terminal_emulation=False, terminal_type=None, - telnetlib_log_level='TRACE', connection_timeout=None): + def __init__( + self, + host=None, + port=23, + timeout=3.0, + newline="CRLF", + prompt=None, + prompt_is_regexp=False, + encoding="UTF-8", + encoding_errors="ignore", + default_log_level="INFO", + window_size=None, + environ_user=None, + terminal_emulation=False, + terminal_type=None, + telnetlib_log_level="TRACE", + connection_timeout=None, + ): if connection_timeout is None: - telnetlib.Telnet.__init__(self, host, int(port) if port else 23) + super().__init__(host, int(port) if port else 23) else: - telnetlib.Telnet.__init__(self, host, int(port) if port else 23, - connection_timeout) + super().__init__(host, int(port) if port else 23, connection_timeout) self._set_timeout(timeout) self._set_newline(newline) self._set_prompt(prompt, prompt_is_regexp) @@ -520,7 +567,7 @@ def __init__(self, host=None, port=23, timeout=3.0, newline='CRLF', self._terminal_type = self._encode(terminal_type) if terminal_type else None self.set_option_negotiation_callback(self._negotiate_options) self._set_telnetlib_log_level(telnetlib_log_level) - self._opt_responses = list() + self._opt_responses = [] def set_timeout(self, timeout): """Sets the timeout used for waiting output in the current connection. @@ -562,14 +609,16 @@ def set_newline(self, newline): """ self._verify_connection() if self._terminal_emulator: - raise AssertionError("Newline can not be changed when terminal emulation is used.") + raise AssertionError( + "Newline can not be changed when terminal emulation is used." + ) old = self._newline self._set_newline(newline) return old def _set_newline(self, newline): newline = str(newline).upper() - self._newline = newline.replace('LF', '\n').replace('CR', '\r') + self._newline = newline.replace("LF", "\n").replace("CR", "\r") def set_prompt(self, prompt, prompt_is_regexp=False): """Sets the prompt used by `Read Until Prompt` and `Login` in the current connection. @@ -587,8 +636,7 @@ def set_prompt(self, prompt, prompt_is_regexp=False): See the documentation of [http://docs.python.org/library/re.html|Python re module] for more information about the supported regular expression syntax. - Notice that possible backslashes need to be escaped in Robot Framework - test data. + Notice that possible backslashes need to be escaped in Robot Framework data. See `Configuration` section for more information about global and connection specific configuration. @@ -601,7 +649,7 @@ def set_prompt(self, prompt, prompt_is_regexp=False): return old def _set_prompt(self, prompt, prompt_is_regexp): - if is_truthy(prompt_is_regexp): + if prompt_is_regexp: self._prompt = (re.compile(prompt), True) else: self._prompt = (prompt, False) @@ -631,7 +679,9 @@ def set_encoding(self, encoding=None, errors=None): """ self._verify_connection() if self._terminal_emulator: - raise AssertionError("Encoding can not be changed when terminal emulation is used.") + raise AssertionError( + "Encoding can not be changed when terminal emulation is used." + ) old = self._encoding self._set_encoding(encoding or old[0], errors or old[1]) return old @@ -640,14 +690,14 @@ def _set_encoding(self, encoding, errors): self._encoding = (encoding.upper(), errors) def _encode(self, text): - if is_bytes(text): + if isinstance(text, (bytes, bytearray)): return text - if self._encoding[0] == 'NONE': - return text.encode('ASCII') + if self._encoding[0] == "NONE": + return text.encode("ASCII") return text.encode(*self._encoding) def _decode(self, bytes): - if self._encoding[0] == 'NONE': + if self._encoding[0] == "NONE": return bytes return bytes.decode(*self._encoding) @@ -663,10 +713,10 @@ def set_telnetlib_log_level(self, level): return old def _set_telnetlib_log_level(self, level): - if level.upper() == 'NONE': - self._telnetlib_log_level = 'NONE' + if level.upper() == "NONE": + self._telnetlib_log_level = "NONE" elif self._is_valid_log_level(level) is False: - raise AssertionError("Invalid log level '%s'" % level) + raise AssertionError(f"Invalid log level '{level}'") self._telnetlib_log_level = level.upper() def set_default_log_level(self, level): @@ -685,15 +735,15 @@ def set_default_log_level(self, level): def _set_default_log_level(self, level): if level is None or not self._is_valid_log_level(level): - raise AssertionError("Invalid log level '%s'" % level) + raise AssertionError(f"Invalid log level '{level}'") self._default_log_level = level.upper() def _is_valid_log_level(self, level): if level is None: return True - if not is_string(level): + if not isinstance(level, str): return False - return level.upper() in ('TRACE', 'DEBUG', 'INFO', 'WARN') + return level.upper() in ("TRACE", "DEBUG", "INFO", "WARN") def close_connection(self, loglevel=None): """Closes the current Telnet connection. @@ -713,9 +763,15 @@ def close_connection(self, loglevel=None): self._log(output, loglevel) return output - def login(self, username, password, login_prompt='login: ', - password_prompt='Password: ', login_timeout='1 second', - login_incorrect='Login incorrect'): + def login( + self, + username, + password, + login_prompt="login: ", + password_prompt="Password: ", + login_timeout="1 second", + login_incorrect="Login incorrect", + ): """Logs in to the Telnet server with the given user information. This keyword reads from the connection until the ``login_prompt`` is @@ -740,31 +796,33 @@ def login(self, username, password, login_prompt='login: ', See `Configuration` section for more information about setting newline, timeout, and prompt. """ - output = self._submit_credentials(username, password, login_prompt, - password_prompt) + output = self._submit_credentials( + username, password, login_prompt, password_prompt + ) if self._prompt_is_set(): success, output2 = self._read_until_prompt() else: success, output2 = self._verify_login_without_prompt( - login_timeout, login_incorrect) + login_timeout, login_incorrect + ) output += output2 self._log(output) if not success: - raise AssertionError('Login incorrect') + raise AssertionError("Login incorrect") return output def _submit_credentials(self, username, password, login_prompt, password_prompt): # Using write_bare here instead of write because don't want to wait for # newline: https://github.com/robotframework/robotframework/issues/1371 - output = self.read_until(login_prompt, 'TRACE') + output = self.read_until(login_prompt, "TRACE") self.write_bare(username + self._newline) - output += self.read_until(password_prompt, 'TRACE') + output += self.read_until(password_prompt, "TRACE") self.write_bare(password + self._newline) return output def _verify_login_without_prompt(self, delay, incorrect): time.sleep(timestr_to_secs(delay)) - output = self.read('TRACE') + output = self.read("TRACE") success = incorrect not in output return success, output @@ -787,14 +845,16 @@ def write(self, text, loglevel=None): """ newline = self._get_newline_for(text) if newline in text: - raise RuntimeError("'Write' keyword cannot be used with strings " - "containing newlines. Use 'Write Bare' instead.") + raise RuntimeError( + "'Write' keyword cannot be used with strings " + "containing newlines. Use 'Write Bare' instead." + ) self.write_bare(text + newline) # Can't read until 'text' because long lines are cut strangely in the output return self.read_until(self._newline, loglevel) def _get_newline_for(self, text): - if is_bytes(text): + if isinstance(text, (bytes, bytearray)): return self._encode(self._newline) return self._newline @@ -805,10 +865,16 @@ def write_bare(self, text): Use `Write` if these features are needed. """ self._verify_connection() - telnetlib.Telnet.write(self, self._encode(text)) - - def write_until_expected_output(self, text, expected, timeout, - retry_interval, loglevel=None): + super().write(self._encode(text)) + + def write_until_expected_output( + self, + text, + expected, + timeout, + retry_interval, + loglevel=None, + ): """Writes the given ``text`` repeatedly, until ``expected`` appears in the output. ``text`` is written without appending a newline and it is consumed from @@ -870,18 +936,18 @@ def _get_control_character(self, int_or_name): def _convert_control_code_name_to_character(self, name): code_names = { - 'BRK' : telnetlib.BRK, - 'IP' : telnetlib.IP, - 'AO' : telnetlib.AO, - 'AYT' : telnetlib.AYT, - 'EC' : telnetlib.EC, - 'EL' : telnetlib.EL, - 'NOP' : telnetlib.NOP + "BRK": telnetlib.BRK, + "IP": telnetlib.IP, + "AO": telnetlib.AO, + "AYT": telnetlib.AYT, + "EC": telnetlib.EC, + "EL": telnetlib.EL, + "NOP": telnetlib.NOP, } try: return code_names[name] except KeyError: - raise RuntimeError("Unsupported control character '%s'." % name) + raise RuntimeError(f"Unsupported control character '{name}'.") def read(self, loglevel=None): """Reads everything that is currently available in the output. @@ -918,7 +984,7 @@ def _read_until(self, expected): if self._terminal_emulator: return self._terminal_read_until(expected) expected = self._encode(expected) - output = telnetlib.Telnet.read_until(self, expected, self._timeout) + output = super().read_until(expected, self._timeout) return output.endswith(expected), self._decode(output) @property @@ -931,8 +997,9 @@ def _terminal_read_until(self, expected): if output: return True, output while time.time() < max_time: - output = telnetlib.Telnet.read_until(self, self._encode(expected), - self._terminal_frequency) + output = super().read_until( + self._encode(expected), self._terminal_frequency + ) self._terminal_emulator.feed(self._decode(output)) output = self._terminal_emulator.read_until(expected) if output: @@ -943,15 +1010,15 @@ def _read_until_regexp(self, *expected): self._verify_connection() if self._terminal_emulator: return self._terminal_read_until_regexp(expected) - expected = [self._encode(exp) if is_unicode(exp) else exp - for exp in expected] + expected = [self._encode(e) if isinstance(e, str) else e for e in expected] return self._telnet_read_until_regexp(expected) def _terminal_read_until_regexp(self, expected_list): max_time = time.time() + self._timeout regexps_bytes = [self._to_byte_regexp(rgx) for rgx in expected_list] - regexps_unicode = [re.compile(self._decode(rgx.pattern)) - for rgx in regexps_bytes] + regexps_unicode = [ + re.compile(self._decode(rgx.pattern)) for rgx in regexps_bytes + ] out = self._terminal_emulator.read_until_regexp(regexps_unicode) if out: return True, out @@ -968,16 +1035,16 @@ def _telnet_read_until_regexp(self, expected_list): try: index, _, output = self.expect(expected, self._timeout) except TypeError: - index, output = -1, b'' + index, output = -1, b"" return index != -1, self._decode(output) def _to_byte_regexp(self, exp): - if is_bytes(exp): + if isinstance(exp, (bytes, bytearray)): return re.compile(exp) - if is_string(exp): + if isinstance(exp, str): return re.compile(self._encode(exp)) pattern = exp.pattern - if is_bytes(pattern): + if isinstance(pattern, (bytes, bytearray)): return exp return re.compile(self._encode(pattern)) @@ -996,8 +1063,7 @@ def read_until_regexp(self, *expected): See the documentation of [http://docs.python.org/library/re.html|Python re module] for more information about the supported regular expression syntax. - Notice that possible backslashes need to be escaped in Robot Framework - test data. + Notice that possible backslashes need to be escaped in Robot Framework data. Examples: | `Read Until Regexp` | (#|$) | @@ -1005,7 +1071,7 @@ def read_until_regexp(self, *expected): | `Read Until Regexp` | \\\\d{4}-\\\\d{2}-\\\\d{2} | DEBUG | """ if not expected: - raise RuntimeError('At least one pattern required') + raise RuntimeError("At least one pattern required") if self._is_valid_log_level(expected[-1]): loglevel = expected[-1] expected = expected[:-1] @@ -1014,8 +1080,7 @@ def read_until_regexp(self, *expected): success, output = self._read_until_regexp(*expected) self._log(output, loglevel) if not success: - expected = [exp if is_string(exp) else exp.pattern - for exp in expected] + expected = [e if isinstance(e, str) else e.pattern for e in expected] raise NoMatchError(expected, self._timeout, output) return output @@ -1038,15 +1103,16 @@ def read_until_prompt(self, loglevel=None, strip_prompt=False): See `Logging` section for more information about log levels. """ if not self._prompt_is_set(): - raise RuntimeError('Prompt is not set.') + raise RuntimeError("Prompt is not set.") success, output = self._read_until_prompt() self._log(output, loglevel) if not success: prompt, regexp = self._prompt - raise AssertionError("Prompt '%s' not found in %s." - % (prompt if not regexp else prompt.pattern, - secs_to_timestr(self._timeout))) - if is_truthy(strip_prompt): + pattern = prompt.pattern if regexp else prompt + raise AssertionError( + f"Prompt '{pattern}' not found in {secs_to_timestr(self._timeout)}." + ) + if strip_prompt: output = self._strip_prompt(output) return output @@ -1094,7 +1160,7 @@ def _custom_timeout(self, timeout): def _verify_connection(self): if not self.sock: - raise RuntimeError('No connection open') + raise RuntimeError("No connection open") def _log(self, msg, level=None): msg = msg.strip() @@ -1108,15 +1174,16 @@ def _negotiate_options(self, sock, cmd, opt): if cmd in (telnetlib.DO, telnetlib.DONT, telnetlib.WILL, telnetlib.WONT): if (cmd, opt) in self._opt_responses: return - else: - self._opt_responses.append((cmd, opt)) + self._opt_responses.append((cmd, opt)) # This is supposed to turn server side echoing on and turn other options off. if opt == telnetlib.ECHO and cmd in (telnetlib.WILL, telnetlib.WONT): self._opt_echo_on(opt) elif cmd == telnetlib.DO and opt == telnetlib.TTYPE and self._terminal_type: self._opt_terminal_type(opt, self._terminal_type) - elif cmd == telnetlib.DO and opt == telnetlib.NEW_ENVIRON and self._environ_user: + elif ( + cmd == telnetlib.DO and opt == telnetlib.NEW_ENVIRON and self._environ_user + ): self._opt_environ_user(opt, self._environ_user) elif cmd == telnetlib.DO and opt == telnetlib.NAWS and self._window_size: self._opt_window_size(opt, *self._window_size) @@ -1128,22 +1195,41 @@ def _opt_echo_on(self, opt): def _opt_terminal_type(self, opt, terminal_type): self.sock.sendall(telnetlib.IAC + telnetlib.WILL + opt) - self.sock.sendall(telnetlib.IAC + telnetlib.SB + telnetlib.TTYPE - + self.NEW_ENVIRON_IS + terminal_type - + telnetlib.IAC + telnetlib.SE) + self.sock.sendall( + telnetlib.IAC + + telnetlib.SB + + telnetlib.TTYPE + + self.NEW_ENVIRON_IS + + terminal_type + + telnetlib.IAC + + telnetlib.SE + ) def _opt_environ_user(self, opt, environ_user): self.sock.sendall(telnetlib.IAC + telnetlib.WILL + opt) - self.sock.sendall(telnetlib.IAC + telnetlib.SB + telnetlib.NEW_ENVIRON - + self.NEW_ENVIRON_IS + self.NEW_ENVIRON_VAR - + b"USER" + self.NEW_ENVIRON_VALUE + environ_user - + telnetlib.IAC + telnetlib.SE) + self.sock.sendall( + telnetlib.IAC + + telnetlib.SB + + telnetlib.NEW_ENVIRON + + self.NEW_ENVIRON_IS + + self.NEW_ENVIRON_VAR + + b"USER" + + self.NEW_ENVIRON_VALUE + + environ_user + + telnetlib.IAC + + telnetlib.SE + ) def _opt_window_size(self, opt, window_x, window_y): self.sock.sendall(telnetlib.IAC + telnetlib.WILL + opt) - self.sock.sendall(telnetlib.IAC + telnetlib.SB + telnetlib.NAWS - + struct.pack('!HH', window_x, window_y) - + telnetlib.IAC + telnetlib.SE) + self.sock.sendall( + telnetlib.IAC + + telnetlib.SB + + telnetlib.NAWS + + struct.pack("!HH", window_x, window_y) + + telnetlib.IAC + + telnetlib.SE + ) def _opt_dont_and_wont(self, cmd, opt): if cmd in (telnetlib.DO, telnetlib.DONT): @@ -1153,49 +1239,49 @@ def _opt_dont_and_wont(self, cmd, opt): def msg(self, msg, *args): # Forward telnetlib's debug messages to log - if self._telnetlib_log_level != 'NONE': + if self._telnetlib_log_level != "NONE": logger.write(msg % args, self._telnetlib_log_level) def _check_terminal_emulation(self, terminal_emulation): if not terminal_emulation: return False if not pyte: - raise RuntimeError("Terminal emulation requires pyte module!\n" - "http://pypi.python.org/pypi/pyte/") - return TerminalEmulator(window_size=self._window_size, - newline=self._newline) - + raise RuntimeError( + "Terminal emulation requires pyte module!\n" + "http://pypi.python.org/pypi/pyte/" + ) + return TerminalEmulator(window_size=self._window_size, newline=self._newline) -class TerminalEmulator(object): +class TerminalEmulator: def __init__(self, window_size=None, newline="\r\n"): self._rows, self._columns = window_size or (200, 200) self._newline = newline self._stream = pyte.Stream() - self._screen = pyte.HistoryScreen(self._rows, - self._columns, - history=100000) + self._screen = pyte.HistoryScreen(self._rows, self._columns, history=100000) self._stream.attach(self._screen) - self._buffer = '' - self._whitespace_after_last_feed = '' + self._buffer = "" + self._whitespace_after_last_feed = "" @property def current_output(self): return self._buffer + self._dump_screen() def _dump_screen(self): - return self._get_history(self._screen) + \ - self._get_screen(self._screen) + \ - self._whitespace_after_last_feed + return ( + self._get_history(self._screen) + + self._get_screen(self._screen) + + self._whitespace_after_last_feed + ) def _get_history(self, screen): if not screen.history.top: - return '' + return "" rows = [] for row in screen.history.top: # Newer pyte versions store row data in mappings data = (char.data for _, char in sorted(row.items())) - rows.append(''.join(data).rstrip()) + rows.append("".join(data).rstrip()) return self._newline.join(rows).rstrip(self._newline) + self._newline def _get_screen(self, screen): @@ -1204,19 +1290,19 @@ def _get_screen(self, screen): def feed(self, text): self._stream.feed(text) - self._whitespace_after_last_feed = text[len(text.rstrip()):] + self._whitespace_after_last_feed = text[len(text.rstrip()) :] def read(self): current_out = self.current_output - self._update_buffer('') + self._update_buffer("") return current_out def read_until(self, expected): current_out = self.current_output exp_index = current_out.find(expected) if exp_index != -1: - self._update_buffer(current_out[exp_index+len(expected):]) - return current_out[:exp_index+len(expected)] + self._update_buffer(current_out[exp_index + len(expected) :]) + return current_out[: exp_index + len(expected)] return None def read_until_regexp(self, regexp_list): @@ -1224,13 +1310,13 @@ def read_until_regexp(self, regexp_list): for rgx in regexp_list: match = rgx.search(current_out) if match: - self._update_buffer(current_out[match.end():]) - return current_out[:match.end()] + self._update_buffer(current_out[match.end() :]) + return current_out[: match.end()] return None def _update_buffer(self, terminal_buffer): self._buffer = terminal_buffer - self._whitespace_after_last_feed = '' + self._whitespace_after_last_feed = "" self._screen.reset() @@ -1241,13 +1327,15 @@ def __init__(self, expected, timeout, output=None): self.expected = expected self.timeout = secs_to_timestr(timeout) self.output = output - AssertionError.__init__(self, self._get_message()) + super().__init__(self._get_message()) def _get_message(self): - expected = "'%s'" % self.expected \ - if is_string(self.expected) \ - else seq2str(self.expected, lastsep=' or ') - msg = "No match found for %s in %s." % (expected, self.timeout) + expected = ( + f"'{self.expected}'" + if isinstance(self.expected, str) + else seq2str(self.expected, lastsep=" or ") + ) + msg = f"No match found for {expected} in {self.timeout}." if self.output is not None: - msg += ' Output:\n%s' % self.output + msg += " Output:\n" + self.output return msg diff --git a/src/robot/libraries/XML.py b/src/robot/libraries/XML.py index fb073604d39..846b65be260 100644 --- a/src/robot/libraries/XML.py +++ b/src/robot/libraries/XML.py @@ -15,35 +15,96 @@ import copy import re -import os +from collections.abc import Iterator +from pathlib import Path +from typing import Any, Callable, NoReturn, TYPE_CHECKING +from xml.etree import ElementTree as ET try: from lxml import etree as lxml_etree except ImportError: lxml_etree = None +else: + # `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) # type: ignore + del Attrib, MutableMapping from robot.api import logger +from robot.api.deco import library from robot.libraries.BuiltIn import BuiltIn -from robot.utils import (asserts, ET, ETSource, is_falsy, is_string, is_truthy, - plural_or_not as s) +from robot.utils import asserts, ETSource, plural_or_not as s from robot.version import get_version - should_be_equal = asserts.assert_equal should_match = BuiltIn().should_match -class XML(object): - """Robot Framework test library for verifying and modifying XML documents. +if TYPE_CHECKING: + Source = Any + Element = Any + +else: + + class Source: + """XML source. + + Can be given as a path to an XML file, as a string or bytes containing + XML data, or as an already parsed XML element structure. + + Examples: + | # Give source as a path: + | ${xml} = `Parse XML` ${CURDIR}/example.xml + | + | # Give source as an already parsed element: + | ${elem} = `Get Element` ${xml} xpath=child + | + | # Give source as a string: + | `Elements Should Be Equal` ${elem} text + """ + + class Element: + """A parsed XML element. + + Represented as an ElementTree + [https://docs.python.org/3/library/xml.etree.elementtree.html#xml.etree.ElementTree.Element|Element] + object. Can be used as a ``source`` argument with keywords modifying + or validating XML elements. In addition to that, attributes like ``text`` + can be accessed using the extended variable syntax like ``${elem.text}`` + directly. + + Examples: + | # Get element as a return value: + | ${elem} = `Parse XML` ${CURDIR}/example.xml + | + | # Use element as an argument with other keywords: + | ${child} = `Get Element` ${elem} expath=child + | + | # Access element attributes: + | `Log` ${elem.text} + """ + + +@library( + converters={Source: lambda value: value, Element: lambda value: value}, + auto_keywords=True, +) +class XML: + """Robot Framework library for verifying and modifying XML documents. - As the name implies, _XML_ is a test library for verifying contents of XML - files. In practice it is a pretty thin wrapper on top of Python's + As the name implies, _XML_ is a library for verifying contents of XML files. + In practice, it is a pretty thin wrapper on top of Python's [http://docs.python.org/library/xml.etree.elementtree.html|ElementTree XML API]. The library has the following main usages: - Parsing an XML file, or a string containing XML, into an XML element - structure and finding certain elements from it for for further analysis + structure and finding certain elements from it for further analysis (e.g. `Parse XML` and `Get Element` keywords). - Getting text or attributes of elements (e.g. `Get Element Text` and `Get Element Attribute`). @@ -54,29 +115,21 @@ class XML(object): == Table of contents == - - `Parsing XML` - - `Using lxml` - - `Example` - - `Finding elements with xpath` - - `Element attributes` - - `Handling XML namespaces` - - `Boolean arguments` - - `Pattern matching` - - `Shortcuts` - - `Keywords` + %TOC% = Parsing XML = XML can be parsed into an element structure using `Parse XML` keyword. - It accepts both paths to XML files and strings that contain XML. The - keyword returns the root element of the structure, which then contains - other elements as its children and their children. Possible comments and - processing instructions in the source XML are removed. + The XML to be parsed can be specified using a path to an XML file or as + a string or bytes that contain XML directly. The keyword returns the root + element of the structure, which then contains other elements as its + children and their children. Possible comments and processing instructions + in the source XML are removed. - XML is not validated during parsing even if has a schema defined. How + XML is not validated during parsing even if it has a schema defined. How possible doctype elements are handled otherwise depends on the used XML module and on the platform. The standard ElementTree strips doctypes - altogether but when `using lxml` they are preserved when XML is saved. + altogether, but when `using lxml` they are preserved when XML is saved. The element structure returned by `Parse XML`, as well as elements returned by keywords such as `Get Element`, can be used as the ``source`` @@ -84,18 +137,18 @@ class XML(object): structure, other keywords also accept paths to XML files and strings containing XML similarly as `Parse XML`. Notice that keywords that modify XML do not write those changes back to disk even if the source would be - given as a path to a file. Changes must always saved explicitly using + given as a path to a file. Changes must always be saved explicitly using `Save XML` keyword. When the source is given as a path to a file, the forward slash character (``/``) can be used as the path separator regardless the operating system. - On Windows also the backslash works, but it the test data it needs to be + On Windows also the backslash works, but in the data it needs to be escaped by doubling it (``\\\\``). Using the built-in variable ``${/}`` naturally works too. = Using lxml = - By default this library uses Python's standard + By default, this library uses Python's standard [http://docs.python.org/library/xml.etree.elementtree.html|ElementTree] module for parsing XML, but it can be configured to use [http://lxml.de|lxml] module instead when `importing` the library. @@ -164,7 +217,7 @@ class XML(object): If lxml support is enabled when `importing` the library, the whole [http://www.w3.org/TR/xpath/|xpath 1.0 standard] is supported. - That includes everything listed below but also lot of other useful + That includes everything listed below but also a lot of other useful constructs. == Tag names == @@ -249,10 +302,10 @@ class XML(object): contain several useful attributes that can be accessed directly using the extended variable syntax. - The attributes that are both useful and convenient to use in the test - data are explained below. Also other attributes, including methods, can + The attributes that are both useful and convenient to use in the data + are explained below. Also other attributes, including methods, can be accessed, but that is typically better to do in custom libraries than - directly in the test data. + directly in the data. The examples use the same ``${XML}`` structure as the earlier examples. @@ -300,7 +353,7 @@ class XML(object): = Handling XML namespaces = ElementTree and lxml handle possible namespaces in XML documents by adding - the namespace URI to tag names in so called Clark Notation. That is + the namespace URI to tag names in so-called Clark Notation. That is inconvenient especially with xpaths, and by default this library strips those namespaces away and moves them to ``xmlns`` attribute instead. That can be avoided by passing ``keep_clark_notation`` argument to `Parse XML` @@ -401,37 +454,13 @@ class XML(object): If an attribute has a namespace prefix, ElementTree will replace it with Clark Notation the same way it handles elements. Because stripping namespaces from attributes could cause attribute conflicts, this library - does not handle attribute namespaces at all. Thus the following example + does not handle attribute namespaces at all. The following example thus works the same way regardless how namespaces are handled. | ${root} = | `Parse XML` | | | `Element Attribute Should Be` | ${root} | id | 1 | | `Element Attribute Should Be` | ${root} | {http://my.ns}id | 2 | - = Boolean arguments = - - Some keywords accept arguments that are handled as Boolean values true or - false. If such an argument is given as a string, it is considered false if - it is an empty string or equal to ``FALSE``, ``NONE``, ``NO``, ``OFF`` or - ``0``, case-insensitively. Other strings are considered true regardless - their value, and other argument types are tested using the same - [http://docs.python.org/library/stdtypes.html#truth|rules as in Python]. - - True examples: - | `Parse XML` | ${XML} | keep_clark_notation=True | # Strings are generally true. | - | `Parse XML` | ${XML} | keep_clark_notation=yes | # Same as the above. | - | `Parse XML` | ${XML} | keep_clark_notation=${TRUE} | # Python ``True`` is true. | - | `Parse XML` | ${XML} | keep_clark_notation=${42} | # Numbers other than 0 are true. | - - False examples: - | `Parse XML` | ${XML} | keep_clark_notation=False | # String ``false`` is false. | - | `Parse XML` | ${XML} | keep_clark_notation=no | # Also string ``no`` is false. | - | `Parse XML` | ${XML} | keep_clark_notation=${EMPTY} | # Empty string is false. | - | `Parse XML` | ${XML} | keep_clark_notation=${FALSE} | # Python ``False`` is false. | - - Considering string ``NONE`` false is new in Robot Framework 3.0.3 and - considering also ``OFF`` and ``0`` false is new in Robot Framework 3.1. - == Pattern matching == Some keywords, for example `Elements Should Match`, support so called @@ -447,49 +476,52 @@ class XML(object): Unlike with glob patterns normally, path separator characters ``/`` and ``\\`` and the newline character ``\\n`` are matches by the above wildcards. - - Support for brackets like ``[abc]`` and ``[!a-z]`` is new in - Robot Framework 3.1 """ - ROBOT_LIBRARY_SCOPE = 'GLOBAL' + + ROBOT_LIBRARY_SCOPE = "GLOBAL" ROBOT_LIBRARY_VERSION = get_version() - _xml_declaration = re.compile('^<\?xml .*\?>') - def __init__(self, use_lxml=False): + def __init__(self, use_lxml: bool = 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, 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: - self.etree = lxml_etree + self.etree = lxml_etree # type: ignore self.modern_etree = True self.lxml_etree = True else: - self.etree = ET - self.modern_etree = ET.VERSION >= '1.3' + self.etree = ET # type: ignore + self.modern_etree = ET.VERSION >= "1.3" self.lxml_etree = False if use_lxml and not lxml_etree: - logger.warn('XML library reverted to use standard ElementTree ' - 'because lxml module is not installed.') + logger.warn( + "XML library reverted to use standard ElementTree " + "because lxml module is not installed." + ) self._ns_stripper = NameSpaceStripper(self.etree, self.lxml_etree) - def parse_xml(self, source, keep_clark_notation=False, strip_namespaces=False): + def parse_xml( + self, + source: Source, + keep_clark_notation: bool = False, + strip_namespaces: bool = False, + ) -> Element: """Parses the given XML file or string into an element structure. The ``source`` can either be a path to an XML file or a string containing XML. In both cases the XML is parsed into ElementTree - [http://docs.python.org/library/xml.etree.elementtree.html#element-objects|element structure] - and the root element is returned. Possible comments and processing - instructions in the source XML are removed. + [https://docs.python.org/3/library/xml.etree.elementtree.html#xml.etree.ElementTree.Element|Element] + structure. Possible comments and processing instructions in the source + XML are removed. As discussed in `Handling XML namespaces` section, this keyword, by default, removes namespace information ElementTree has added to tag @@ -498,12 +530,11 @@ def parse_xml(self, source, keep_clark_notation=False, strip_namespaces=False): want that to happen, or want to avoid the small overhead of going through the element structure when your XML does not have namespaces, you can disable this feature by giving ``keep_clark_notation`` argument - a true value (see `Boolean arguments`). + a true value. If you want to strip namespace information altogether so that it is not included even if XML is saved, you can give a true value to - ``strip_namespaces`` argument. This functionality is new in Robot - Framework 3.0.2. + ``strip_namespaces`` argument. Examples: | ${root} = | Parse XML | | @@ -513,18 +544,21 @@ def parse_xml(self, source, keep_clark_notation=False, strip_namespaces=False): Use `Get Element` keyword if you want to get a certain element and not the whole structure. See `Parsing XML` section for more details and examples. + + *NOTE:* This keyword does not accept an already parsed XML element as + an argument. Use `Get Element` instead. """ - with ETSource(source) as source: - tree = self.etree.parse(source) + with ETSource(source) as source_: + tree = self.etree.parse(source_) if self.lxml_etree: - strip = (lxml_etree.Comment, lxml_etree.ProcessingInstruction) - lxml_etree.strip_elements(tree, *strip, **dict(with_tail=False)) + strip = (lxml_etree.Comment, lxml_etree.ProcessingInstruction) # type: ignore + lxml_etree.strip_elements(tree, *strip, with_tail=False) # type: ignore root = tree.getroot() - if not is_truthy(keep_clark_notation): - self._ns_stripper.strip(root, preserve=is_falsy(strip_namespaces)) + if not keep_clark_notation: + self._ns_stripper.strip(root, preserve=not strip_namespaces) return root - def get_element(self, source, xpath='.'): + def get_element(self, source: Source, xpath: str = ".") -> Element: """Returns an element in the ``source`` matching the ``xpath``. The ``source`` can be a path to an XML file, a string containing XML, or @@ -555,19 +589,21 @@ def get_element(self, source, xpath='.'): self._raise_wrong_number_of_matches(len(elements), xpath) return elements[0] - def _raise_wrong_number_of_matches(self, count, xpath, message=None): + def _raise_wrong_number_of_matches( + self, count: int, xpath: str, message: "str | None" = None + ) -> NoReturn: if not message: message = self._wrong_number_of_matches(count, xpath) raise AssertionError(message) - def _wrong_number_of_matches(self, count, xpath): + def _wrong_number_of_matches(self, count: int, xpath: str) -> str: if not count: - return "No element matching '%s' found." % xpath + return f"No element matching '{xpath}' found." if count == 1: - return "One element matching '%s' found." % xpath - return "Multiple elements (%d) matching '%s' found." % (count, xpath) + return f"One element matching '{xpath}' found." + return f"Multiple elements ({count}) matching '{xpath}' found." - def get_elements(self, source, xpath): + def get_elements(self, source: Source, xpath: str) -> "list[Element]": """Returns a list of elements in the ``source`` matching the ``xpath``. The ``source`` can be a path to an XML file, a string containing XML, or @@ -584,12 +620,12 @@ def get_elements(self, source, xpath): | ${children} = | Get Elements | ${XML} | first/child | | Should Be Empty | ${children} | | | """ - if is_string(source): + if isinstance(source, (str, bytes, Path)): source = self.parse_xml(source) finder = ElementFinder(self.etree, self.modern_etree, self.lxml_etree) return finder.find_all(source, xpath) - def get_child_elements(self, source, xpath='.'): + def get_child_elements(self, source: Source, xpath: str = ".") -> "list[Element]": """Returns the child elements of the specified element as a list. The element whose children to return is specified using ``source`` and @@ -607,7 +643,7 @@ def get_child_elements(self, source, xpath='.'): """ return list(self.get_element(source, xpath)) - def get_element_count(self, source, xpath='.'): + def get_element_count(self, source: Source, xpath: str = ".") -> int: """Returns and logs how many elements the given ``xpath`` matches. Arguments ``source`` and ``xpath`` have exactly the same semantics as @@ -616,10 +652,15 @@ def get_element_count(self, source, xpath='.'): See also `Element Should Exist` and `Element Should Not Exist`. """ count = len(self.get_elements(source, xpath)) - logger.info("%d element%s matched '%s'." % (count, s(count), xpath)) + logger.info(f"{count} element{s(count)} matched '{xpath}'.") return count - def element_should_exist(self, source, xpath='.', message=None): + def element_should_exist( + self, + source: Source, + xpath: str = ".", + message: "str | None" = None, + ): """Verifies that one or more element match the given ``xpath``. Arguments ``source`` and ``xpath`` have exactly the same semantics as @@ -634,7 +675,12 @@ def element_should_exist(self, source, xpath='.', message=None): if not count: self._raise_wrong_number_of_matches(count, xpath, message) - def element_should_not_exist(self, source, xpath='.', message=None): + def element_should_not_exist( + self, + source: Source, + xpath: str = ".", + message: "str | None" = None, + ): """Verifies that no element match the given ``xpath``. Arguments ``source`` and ``xpath`` have exactly the same semantics as @@ -649,7 +695,12 @@ def element_should_not_exist(self, source, xpath='.', message=None): if count: self._raise_wrong_number_of_matches(count, xpath, message) - def get_element_text(self, source, xpath='.', normalize_whitespace=False): + def get_element_text( + self, + source: Source, + xpath: str = ".", + normalize_whitespace: bool = False, + ) -> str: """Returns all text of the element, possibly whitespace normalized. The element whose text to return is specified using ``source`` and @@ -661,12 +712,11 @@ def get_element_text(self, source, xpath='.', normalize_whitespace=False): has no text, an empty string is returned. The returned text is thus not always the same as the `text` attribute of the element. - By default all whitespace, including newlines and indentation, inside - the element is returned as-is. If ``normalize_whitespace`` is given - a true value (see `Boolean arguments`), then leading and trailing - whitespace is stripped, newlines and tabs converted to spaces, and - multiple spaces collapsed into one. This is especially useful when - dealing with HTML data. + All whitespace, including newlines and indentation, inside the element + is returned as-is. If ``normalize_whitespace`` is given a true value, + then leading and trailing whitespace is stripped, newlines and tabs + converted to spaces, and multiple spaces collapsed into one. This is + especially useful when dealing with HTML data. Examples using ``${XML}`` structure from `Example`: | ${text} = | Get Element Text | ${XML} | first | @@ -681,24 +731,28 @@ def get_element_text(self, source, xpath='.', normalize_whitespace=False): `Element Text Should Match`. """ element = self.get_element(source, xpath) - text = ''.join(self._yield_texts(element)) - if is_truthy(normalize_whitespace): + text = "".join(self._yield_texts(element)) + if normalize_whitespace: text = self._normalize_whitespace(text) return text - def _yield_texts(self, element, top=True): + def _yield_texts(self, element: Element, top: bool = True) -> "Iterator[str]": if element.text: yield element.text for child in element: - for text in self._yield_texts(child, top=False): - yield text + yield from self._yield_texts(child, top=False) if element.tail and not top: yield element.tail - def _normalize_whitespace(self, text): - return ' '.join(text.split()) + def _normalize_whitespace(self, text: str) -> str: + return " ".join(text.split()) - def get_elements_texts(self, source, xpath, normalize_whitespace=False): + def get_elements_texts( + self, + source: Source, + xpath: str, + normalize_whitespace: bool = False, + ) -> "list[str]": """Returns text of all elements matching ``xpath`` as a list. The elements whose text to return is specified using ``source`` and @@ -715,11 +769,19 @@ def get_elements_texts(self, source, xpath, normalize_whitespace=False): | Should Be Equal | @{texts}[0] | more text | | | Should Be Equal | @{texts}[1] | ${EMPTY} | | """ - return [self.get_element_text(elem, normalize_whitespace=normalize_whitespace) - for elem in self.get_elements(source, xpath)] - - def element_text_should_be(self, source, expected, xpath='.', - normalize_whitespace=False, message=None): + return [ + self.get_element_text(elem, normalize_whitespace=normalize_whitespace) + for elem in self.get_elements(source, xpath) + ] + + def element_text_should_be( + self, + source: Source, + expected: str, + xpath: str = ".", + normalize_whitespace: bool = False, + message: "str | None" = None, + ): """Verifies that the text of the specified element is ``expected``. The element whose text is verified is specified using ``source`` and @@ -745,8 +807,14 @@ def element_text_should_be(self, source, expected, xpath='.', text = self.get_element_text(source, xpath, normalize_whitespace) should_be_equal(text, expected, message, values=False) - def element_text_should_match(self, source, pattern, xpath='.', - normalize_whitespace=False, message=None): + def element_text_should_match( + self, + source: Source, + pattern: str, + xpath: str = ".", + normalize_whitespace: bool = False, + message: "str | None" = None, + ): """Verifies that the text of the specified element matches ``expected``. This keyword works exactly like `Element Text Should Be` except that @@ -765,7 +833,13 @@ def element_text_should_match(self, source, pattern, xpath='.', text = self.get_element_text(source, xpath, normalize_whitespace) should_match(text, pattern, message, values=False) - def get_element_attribute(self, source, name, xpath='.', default=None): + def get_element_attribute( + self, + source: Source, + name: str, + xpath: str = ".", + default: object = None, + ) -> "str | object": """Returns the named attribute of the specified element. The element whose attribute to return is specified using ``source`` and @@ -787,7 +861,11 @@ def get_element_attribute(self, source, name, xpath='.', default=None): """ return self.get_element(source, xpath).get(name, default) - def get_element_attributes(self, source, xpath='.'): + def get_element_attributes( + self, + source: Source, + xpath: str = ".", + ) -> "dict[str, str]": """Returns all attributes of the specified element. The element whose attributes to return is specified using ``source`` and @@ -807,8 +885,14 @@ def get_element_attributes(self, source, xpath='.'): """ return dict(self.get_element(source, xpath).attrib) - def element_attribute_should_be(self, source, name, expected, xpath='.', - message=None): + def element_attribute_should_be( + self, + source: Source, + name: str, + expected: "str | None", + xpath: str = ".", + message: "str | None" = None, + ): """Verifies that the specified attribute is ``expected``. The element whose attribute is verified is specified using ``source`` @@ -832,8 +916,14 @@ def element_attribute_should_be(self, source, name, expected, xpath='.', attr = self.get_element_attribute(source, name, xpath) should_be_equal(attr, expected, message, values=False) - def element_attribute_should_match(self, source, name, pattern, xpath='.', - message=None): + def element_attribute_should_match( + self, + source: Source, + name: str, + pattern: str, + xpath: str = ".", + message: "str | None" = None, + ): """Verifies that the specified attribute matches ``expected``. This keyword works exactly like `Element Attribute Should Be` except @@ -850,11 +940,17 @@ def element_attribute_should_match(self, source, name, pattern, xpath='.', """ attr = self.get_element_attribute(source, name, xpath) if attr is None: - raise AssertionError("Attribute '%s' does not exist." % name) + raise AssertionError(f"Attribute '{name}' does not exist.") should_match(attr, pattern, message, values=False) - def element_should_not_have_attribute(self, source, name, xpath='.', message=None): - """Verifies that the specified element does not have attribute ``name``. + def element_should_not_have_attribute( + self, + source: Source, + name: str, + xpath: str = ".", + message: "str | None" = None, + ): + """Verifies that the specified element does not have attribute ``name``. The element whose attribute is verified is specified using ``source`` and ``xpath``. They have exactly the same semantics as with @@ -872,11 +968,18 @@ def element_should_not_have_attribute(self, source, name, xpath='.', message=Non """ attr = self.get_element_attribute(source, name, xpath) if attr is not None: - raise AssertionError(message or "Attribute '%s' exists and " - "has value '%s'." % (name, attr)) - - def elements_should_be_equal(self, source, expected, exclude_children=False, - normalize_whitespace=False): + raise AssertionError( + message or f"Attribute '{name}' exists and has value '{attr}'." + ) + + def elements_should_be_equal( + self, + source: Source, + expected: Source, + exclude_children: bool = False, + normalize_whitespace: bool = False, + sort_children: bool = False, + ): """Verifies that the given ``source`` element is equal to ``expected``. Both ``source`` and ``expected`` can be given as a path to an XML file, @@ -886,12 +989,14 @@ def elements_should_be_equal(self, source, expected, exclude_children=False, The keyword passes if the ``source`` element and ``expected`` element are equal. This includes testing the tag names, texts, and attributes - of the elements. By default also child elements are verified the same + of the elements. By default, also child elements are verified the same way, but this can be disabled by setting ``exclude_children`` to a - true value (see `Boolean arguments`). + true value. Child elements are expected to + be in the same order, but that can be changed by giving ``sort_children`` + a true value. Notice that elements are sorted solely based on tag names. All texts inside the given elements are verified, but possible text - outside them is not. By default texts must match exactly, but setting + outside them is not. By default, texts must match exactly, but setting ``normalize_whitespace`` to a true value makes text verification independent on newlines, tabs, and the amount of spaces. For more details about handling text see `Get Element Text` keyword and @@ -911,12 +1016,26 @@ def elements_should_be_equal(self, source, expected, exclude_children=False, the ``.`` at the end that is the `tail` text of the ```` element. See also `Elements Should Match`. - """ - self._compare_elements(source, expected, should_be_equal, - exclude_children, normalize_whitespace) - def elements_should_match(self, source, expected, exclude_children=False, - normalize_whitespace=False): + ``sort_children`` is new in Robot Framework 7.0. + """ + self._compare_elements( + source, + expected, + should_be_equal, + exclude_children, + sort_children, + normalize_whitespace, + ) + + def elements_should_match( + self, + source: Source, + expected: Source, + exclude_children: bool = False, + normalize_whitespace: bool = False, + sort_children: bool = False, + ): """Verifies that the given ``source`` element matches ``expected``. This keyword works exactly like `Elements Should Be Equal` except that @@ -933,17 +1052,38 @@ def elements_should_match(self, source, expected, exclude_children=False, See `Elements Should Be Equal` for more examples. """ - self._compare_elements(source, expected, should_match, - exclude_children, normalize_whitespace) - - def _compare_elements(self, source, expected, comparator, exclude_children, - normalize_whitespace): - normalizer = self._normalize_whitespace \ - if is_truthy(normalize_whitespace) else None - comparator = ElementComparator(comparator, normalizer, exclude_children) - comparator.compare(self.get_element(source), self.get_element(expected)) - - def set_element_tag(self, source, tag, xpath='.'): + self._compare_elements( + source, + expected, + should_match, + exclude_children, + sort_children, + normalize_whitespace, + ) + + def _compare_elements( + self, + source: Source, + expected: Source, + comparator: Callable[[Any, Any, str], None], + exclude_children: bool, + sort_children: bool, + normalize_whitespace: bool, + ): + normalizer = self._normalize_whitespace if normalize_whitespace else None + sorter = self._sort_children if sort_children else None + element_comparator = ElementComparator( + comparator, normalizer, sorter, exclude_children + ) + element_comparator.compare(self.get_element(source), self.get_element(expected)) + + def _sort_children(self, element: Element): + tails = [child.tail for child in element] + element[:] = sorted(element, key=lambda child: child.tag) + for child, tail in zip(element, tails): + child.tail = tail + + def set_element_tag(self, source: Source, tag: str, xpath: str = ".") -> Element: """Sets the tag of the specified element. The element whose tag to set is specified using ``source`` and @@ -965,16 +1105,24 @@ def set_element_tag(self, source, tag, xpath='.'): self.get_element(source, xpath).tag = tag return source - def set_elements_tag(self, source, tag, xpath='.'): + def set_elements_tag(self, source: Source, tag: str, xpath: str = ".") -> Element: """Sets the tag of the specified elements. Like `Set Element Tag` but sets the tag of all elements matching the given ``xpath``. """ + source = self.get_element(source) for elem in self.get_elements(source, xpath): self.set_element_tag(elem, tag) + return source - def set_element_text(self, source, text=None, tail=None, xpath='.'): + def set_element_text( + self, + source: Source, + text: "str | None" = None, + tail: "str | None" = None, + xpath: str = ".", + ) -> Element: """Sets text and/or tail text of the specified element. The element whose text to set is specified using ``source`` and @@ -1005,16 +1153,30 @@ def set_element_text(self, source, text=None, tail=None, xpath='.'): element.tail = tail return source - def set_elements_text(self, source, text=None, tail=None, xpath='.'): + def set_elements_text( + self, + source: Source, + text: "str | None" = None, + tail: "str | None" = None, + xpath: str = ".", + ) -> Element: """Sets text and/or tail text of the specified elements. Like `Set Element Text` but sets the text or tail of all elements matching the given ``xpath``. """ + source = self.get_element(source) for elem in self.get_elements(source, xpath): self.set_element_text(elem, text, tail) + return source - def set_element_attribute(self, source, name, value, xpath='.'): + def set_element_attribute( + self, + source: Source, + name: str, + value: str, + xpath: str = ".", + ) -> Element: """Sets attribute ``name`` of the specified element to ``value``. The element whose attribute to set is specified using ``source`` and @@ -1036,21 +1198,34 @@ def set_element_attribute(self, source, name, value, xpath='.'): Attribute` to set an attribute of multiple elements in one call. """ if not name: - raise RuntimeError('Attribute name can not be empty.') + raise RuntimeError("Attribute name can not be empty.") source = self.get_element(source) self.get_element(source, xpath).attrib[name] = value return source - def set_elements_attribute(self, source, name, value, xpath='.'): + def set_elements_attribute( + self, + source: Source, + name: str, + value: str, + xpath: str = ".", + ) -> Element: """Sets attribute ``name`` of the specified elements to ``value``. Like `Set Element Attribute` but sets the attribute of all elements matching the given ``xpath``. """ + source = self.get_element(source) for elem in self.get_elements(source, xpath): self.set_element_attribute(elem, name, value) + return source - def remove_element_attribute(self, source, name, xpath='.'): + def remove_element_attribute( + self, + source: Source, + name: str, + xpath: str = ".", + ) -> Element: """Removes attribute ``name`` from the specified element. The element whose attribute to remove is specified using ``source`` and @@ -1075,16 +1250,23 @@ def remove_element_attribute(self, source, name, xpath='.'): attrib.pop(name) return source - def remove_elements_attribute(self, source, name, xpath='.'): + def remove_elements_attribute( + self, + source: Source, + name: str, + xpath: str = ".", + ) -> Element: """Removes attribute ``name`` from the specified elements. Like `Remove Element Attribute` but removes the attribute of all elements matching the given ``xpath``. """ + source = self.get_element(source) for elem in self.get_elements(source, xpath): self.remove_element_attribute(elem, name) + return source - def remove_element_attributes(self, source, xpath='.'): + def remove_element_attributes(self, source: Source, xpath: str = ".") -> Element: """Removes all attributes from the specified element. The element whose attributes to remove is specified using ``source`` and @@ -1106,16 +1288,24 @@ def remove_element_attributes(self, source, xpath='.'): self.get_element(source, xpath).attrib.clear() return source - def remove_elements_attributes(self, source, xpath='.'): + def remove_elements_attributes(self, source: Source, xpath: str = ".") -> Element: """Removes all attributes from the specified elements. Like `Remove Element Attributes` but removes all attributes of all elements matching the given ``xpath``. """ + source = self.get_element(source) for elem in self.get_elements(source, xpath): self.remove_element_attributes(elem) + return source - def add_element(self, source, element, index=None, xpath='.'): + def add_element( + self, + source: Source, + element: Source, + index: "int | None" = None, + xpath: str = ".", + ) -> Element: """Adds a child element to the specified element. The element to whom to add the new element is specified using ``source`` @@ -1151,7 +1341,12 @@ def add_element(self, source, element, index=None, xpath='.'): parent.insert(int(index), element) return source - def remove_element(self, source, xpath='', remove_tail=False): + def remove_element( + self, + source: Source, + xpath: str = "", + remove_tail: bool = False, + ) -> Element: """Removes the element matching ``xpath`` from the ``source`` structure. The element to remove from the ``source`` is specified with ``xpath`` @@ -1163,9 +1358,8 @@ def remove_element(self, source, xpath='', remove_tail=False): Use `Remove Elements` to remove all matched elements. Element's tail text is not removed by default, but that can be changed - by giving ``remove_tail`` a true value (see `Boolean arguments`). See - `Element attributes` section for more information about `tail` in - general. + by giving ``remove_tail`` a true value. See the `Element attributes` + section for more information about ``tail`` in general. Examples using ``${XML}`` structure from `Example`: | Remove Element | ${XML} | xpath=second | @@ -1177,7 +1371,12 @@ def remove_element(self, source, xpath='', remove_tail=False): self._remove_element(source, self.get_element(source, xpath), remove_tail) return source - def remove_elements(self, source, xpath='', remove_tail=False): + def remove_elements( + self, + source: Source, + xpath: str = "", + remove_tail: bool = False, + ) -> Element: """Removes all elements matching ``xpath`` from the ``source`` structure. The elements to remove from the ``source`` are specified with ``xpath`` @@ -1201,30 +1400,40 @@ def remove_elements(self, source, xpath='', remove_tail=False): self._remove_element(source, element, remove_tail) return source - def _remove_element(self, root, element, remove_tail=False): + def _remove_element( + self, + root: Element, + element: Element, + remove_tail: bool = False, + ): parent = self._find_parent(root, element) - if not is_truthy(remove_tail): + if not remove_tail: self._preserve_tail(element, parent) parent.remove(element) - def _find_parent(self, root, element): - for parent in root.getiterator(): + def _find_parent(self, root: Element, element: Element) -> Element: + for parent in root.iter(): for child in parent: if child is element: return parent - raise RuntimeError('Cannot remove root element.') + raise RuntimeError("Cannot remove root element.") - def _preserve_tail(self, element, parent): + def _preserve_tail(self, element: Element, parent: Element): if not element.tail: return index = list(parent).index(element) if index == 0: - parent.text = (parent.text or '') + element.tail + parent.text = (parent.text or "") + element.tail else: - sibling = parent[index-1] - sibling.tail = (sibling.tail or '') + element.tail - - def clear_element(self, source, xpath='.', clear_tail=False): + sibling = parent[index - 1] + sibling.tail = (sibling.tail or "") + element.tail + + def clear_element( + self, + source: Source, + xpath: str = ".", + clear_tail: bool = False, + ) -> Element: """Clears the contents of the specified element. The element to clear is specified using ``source`` and ``xpath``. They @@ -1234,9 +1443,8 @@ def clear_element(self, source, xpath='.', clear_tail=False): Clearing the element means removing its text, attributes, and children. Element's tail text is not removed by default, but that can be changed - by giving ``clear_tail`` a true value (see `Boolean arguments`). See - `Element attributes` section for more information about tail in - general. + by giving ``clear_tail`` a true value. See the `Element attributes` + section for more information about ``tail`` in general. Examples using ``${XML}`` structure from `Example`: | Clear Element | ${XML} | xpath=first | @@ -1253,17 +1461,17 @@ def clear_element(self, source, xpath='.', clear_tail=False): element = self.get_element(source, xpath) tail = element.tail element.clear() - if not is_truthy(clear_tail): + if not clear_tail: element.tail = tail return source - def copy_element(self, source, xpath='.'): + def copy_element(self, source: Source, xpath: str = ".") -> Element: """Returns a copy of the specified element. The element to copy is specified using ``source`` and ``xpath``. They have exactly the same semantics as with `Get Element` keyword. - If the copy or the original element is modified afterwards, the changes + If the copy or the original element is modified afterward, the changes have no effect on the other. Examples using ``${XML}`` structure from `Example`: @@ -1278,27 +1486,39 @@ def copy_element(self, source, xpath='.'): """ return copy.deepcopy(self.get_element(source, xpath)) - def element_to_string(self, source, xpath='.', encoding=None): + def element_to_string( + self, + source: Source, + xpath: str = ".", + encoding: "str | None" = None, + ) -> "bytes | str": """Returns the string representation of the specified element. The element to convert to a string is specified using ``source`` and ``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. See also `Log Element` and `Save XML`. """ source = self.get_element(source, xpath) - string = self.etree.tostring(source, encoding='UTF-8').decode('UTF-8') - string = self._xml_declaration.sub('', string).strip() + if self.lxml_etree: + source = self._ns_stripper.unstrip(source) + string = self.etree.tostring(source, encoding="UTF-8").decode("UTF-8") + string = re.sub(r"^<\?xml .*\?>", "", string).strip() if encoding: string = string.encode(encoding) return string - def log_element(self, source, level='INFO', xpath='.'): + def log_element( + self, + source: Source, + level: logger.LogLevel = "INFO", + xpath: str = ".", + ) -> str: """Logs the string representation of the specified element. The element specified with ``source`` and ``xpath`` is first converted @@ -1311,7 +1531,12 @@ def log_element(self, source, level='INFO', xpath='.'): logger.write(string, level) return string - def save_xml(self, source, path, encoding='UTF-8'): + def save_xml( + self, + source: Source, + path: Path, + encoding: str = "UTF-8", + ): """Saves the given element to the specified file. The element to save is specified with ``source`` using the same @@ -1331,27 +1556,31 @@ 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 = path.absolute() elem = self.get_element(source) tree = self.etree.ElementTree(elem) - config = {'encoding': encoding} + config: dict[str, object] = {"encoding": encoding} if self.modern_etree: - config['xml_declaration'] = True + config["xml_declaration"] = True if self.lxml_etree: elem = self._ns_stripper.unstrip(elem) # https://bugs.launchpad.net/lxml/+bug/1660433 - if tree.docinfo.doctype: - config['doctype'] = tree.docinfo.doctype + if tree.docinfo.doctype: # type: ignore + config["doctype"] = tree.docinfo.doctype # type: ignore tree = self.etree.ElementTree(elem) - with open(path, 'wb') as output: - if 'doctype' in config: - output.write(self.etree.tostring(tree, **config)) + with open(path, "wb") as output: + if "doctype" in config: + output.write(self.etree.tostring(tree, **config)) # type: ignore else: - tree.write(output, **config) - logger.info('XML saved to %s.' % (path, path), - html=True) - - def evaluate_xpath(self, source, expression, context='.'): + tree.write(output, **config) # type: ignore + logger.info(f'XML saved to {path}.', html=True) + + def evaluate_xpath( + self, + source: Source, + expression: str, + context: str = ".", + ) -> object: """Evaluates the given xpath expression and returns results. The element in which context the expression is executed is specified @@ -1375,80 +1604,125 @@ def evaluate_xpath(self, source, expression, context='.'): """ if not self.lxml_etree: raise RuntimeError("'Evaluate Xpath' keyword only works in lxml mode.") - return self.get_element(source, context).xpath(expression) + return self.get_element(source, context).xpath(expression) # type: ignore -class NameSpaceStripper(object): +class NameSpaceStripper: - def __init__(self, etree, lxml_etree=False): + def __init__(self, etree, lxml_etree: bool = False): self.etree = etree self.lxml_tree = lxml_etree - def strip(self, elem, preserve=True, current_ns=None, top=True): - if elem.tag.startswith('{') and '}' in elem.tag: - ns, elem.tag = elem.tag[1:].split('}', 1) + def strip( + self, + elem: Element, + preserve: bool = True, + current_ns: "str | None" = None, + top: bool = True, + ): + if elem.tag.startswith("{") and "}" in elem.tag: + ns, elem.tag = elem.tag[1:].split("}", 1) if preserve and ns != current_ns: - elem.attrib['xmlns'] = ns + elem.attrib["xmlns"] = ns current_ns = ns elif current_ns: - elem.attrib['xmlns'] = '' + elem.attrib["xmlns"] = "" current_ns = None for child in elem: self.strip(child, preserve, current_ns, top=False) if top and not preserve and self.lxml_tree: - self.etree.cleanup_namespaces(elem) - - def unstrip(self, elem, current_ns=None, copied=False): + self.etree.cleanup_namespaces(elem) # type: ignore + + def unstrip( + self, + elem: Element, + current_ns: "str | None" = None, + copied: bool = False, + ) -> Element: if not copied: elem = copy.deepcopy(elem) - ns = elem.attrib.pop('xmlns', current_ns) + ns = elem.attrib.pop("xmlns", current_ns) if ns: - elem.tag = '{%s}%s' % (ns, elem.tag) + elem.tag = f"{{{ns}}}{elem.tag}" for child in elem: self.unstrip(child, ns, copied=True) return elem -class ElementFinder(object): +class ElementFinder: - def __init__(self, etree, modern=True, lxml=False): + def __init__( + self, + etree, + modern: bool = True, + lxml: bool = False, + ): self.etree = etree self.modern = modern self.lxml = lxml - def find_all(self, elem, xpath): + def find_all(self, elem: Element, xpath: str) -> "list[Element]": xpath = self._get_xpath(xpath) - if xpath == '.': # ET < 1.3 does not support '.' alone. + if xpath == ".": # ET < 1.3 does not support '.' alone. return [elem] if not self.lxml: return elem.findall(xpath) - finder = self.etree.ETXPath(xpath) + finder = self.etree.ETXPath(xpath) # type: ignore return finder(elem) - def _get_xpath(self, xpath): + def _get_xpath(self, xpath: str) -> str: if not xpath: - raise RuntimeError('No xpath given.') + raise RuntimeError("No xpath given.") if self.modern: return xpath try: return str(xpath) except UnicodeError: - if not xpath.replace('/', '').isalnum(): - logger.warn('XPATHs containing non-ASCII characters and ' - 'other than tag names do not always work with ' - 'Python versions prior to 2.7. Verify results ' - 'manually and consider upgrading to 2.7.') + if not xpath.replace("/", "").isalnum(): + logger.warn( + "XPATHs containing non-ASCII characters and other than tag " + "names do not always work with Python versions prior to 2.7. " + "Verify results manually and consider upgrading to 2.7." + ) return xpath -class ElementComparator(object): +class Location: - def __init__(self, comparator, normalizer=None, exclude_children=False): - self._comparator = comparator - self._normalizer = normalizer or (lambda text: text) - self._exclude_children = is_truthy(exclude_children) + def __init__(self, path: str, is_root: bool = True): + self.path = path + self.is_not_root = not is_root + self.children: dict[str, int] = {} - def compare(self, actual, expected, location=None): + def child(self, tag: str) -> "Location": + if tag not in self.children: + self.children[tag] = 1 + else: + self.children[tag] += 1 + tag += f"[{self.children[tag]}]" + return Location(f"{self.path}/{tag}", is_root=False) + + +class ElementComparator: + + def __init__( + self, + comparator: Callable[[object, object, str], None], + normalizer: "Callable[[str], str] | None" = None, + child_sorter: "Callable[[Element], None] | None" = None, + exclude_children: bool = False, + ): + self.comparator = comparator + self.normalizer = normalizer or (lambda text: text) + self.child_sorter = child_sorter + self.exclude_children = exclude_children + + def compare( + self, + actual: Element, + expected: Element, + location: "Location | None" = None, + ): if not location: location = Location(actual.tag) self._compare_tags(actual, expected, location) @@ -1456,56 +1730,82 @@ def compare(self, actual, expected, location=None): self._compare_texts(actual, expected, location) if location.is_not_root: self._compare_tails(actual, expected, location) - if not self._exclude_children: + if not self.exclude_children: self._compare_children(actual, expected, location) - def _compare_tags(self, actual, expected, location): - self._compare(actual.tag, expected.tag, 'Different tag name', location, - should_be_equal) - - def _compare(self, actual, expected, message, location, comparator=None): + def _compare_tags(self, actual: Element, expected: Element, location: Location): + self._compare( + actual.tag, + expected.tag, + "Different tag name", + location, + should_be_equal, + ) + + def _compare( + self, + actual: object, + expected: object, + message: str, + location: Location, + comparator: "Callable[[object, object, str], None] | None" = None, + ): if location.is_not_root: - message = "%s at '%s'" % (message, location.path) + message = f"{message} at '{location.path}'" if not comparator: - comparator = self._comparator + comparator = self.comparator comparator(actual, expected, message) - def _compare_attributes(self, actual, expected, location): - self._compare(sorted(actual.attrib), sorted(expected.attrib), - 'Different attribute names', location, should_be_equal) + def _compare_attributes( + self, + actual: Element, + expected: Element, + location: Location, + ): + self._compare( + sorted(actual.attrib), + sorted(expected.attrib), + "Different attribute names", + location, + should_be_equal, + ) for key in actual.attrib: - self._compare(actual.attrib[key], expected.attrib[key], - "Different value for attribute '%s'" % key, location) - - def _compare_texts(self, actual, expected, location): - self._compare(self._text(actual.text), self._text(expected.text), - 'Different text', location) - - def _text(self, text): - return self._normalizer(text or '') - - def _compare_tails(self, actual, expected, location): - self._compare(self._text(actual.tail), self._text(expected.tail), - 'Different tail text', location) - - def _compare_children(self, actual, expected, location): - self._compare(len(actual), len(expected), 'Different number of child elements', - location, should_be_equal) + self._compare( + actual.attrib[key], + expected.attrib[key], + f"Different value for attribute '{key}'", + location, + ) + + def _compare_texts(self, actual: Element, expected: Element, location: Location): + self._compare( + self._text(actual.text), + self._text(expected.text), + "Different text", + location, + ) + + def _text(self, text: str) -> str: + return self.normalizer(text or "") + + def _compare_tails(self, actual: Element, expected: Element, location: Location): + self._compare( + self._text(actual.tail), + self._text(expected.tail), + "Different tail text", + location, + ) + + def _compare_children(self, actual: Element, expected: Element, location: Location): + self._compare( + len(actual), + len(expected), + "Different number of child elements", + location, + should_be_equal, + ) + if self.child_sorter: + self.child_sorter(actual) + self.child_sorter(expected) for act, exp in zip(actual, expected): self.compare(act, exp, location.child(act.tag)) - - -class Location(object): - - def __init__(self, path, is_root=True): - self.path = path - self.is_not_root = not is_root - self._children = {} - - def child(self, tag): - if tag not in self._children: - self._children[tag] = 1 - else: - self._children[tag] += 1 - tag += '[%d]' % self._children[tag] - return Location('%s/%s' % (self.path, tag), is_root=False) diff --git a/src/robot/libraries/__init__.py b/src/robot/libraries/__init__.py index 5ab5ed45d02..0d6d1109b1b 100644 --- a/src/robot/libraries/__init__.py +++ b/src/robot/libraries/__init__.py @@ -26,6 +26,19 @@ the http://robotframework.org web site. """ -STDLIBS = frozenset(('BuiltIn', 'Collections', 'DateTime', 'Dialogs', 'Easter', - 'OperatingSystem', 'Process', 'Remote', 'Reserved', - 'Screenshot', 'String', 'Telnet', 'XML')) +STDLIBS = frozenset( + ( + "BuiltIn", + "Collections", + "DateTime", + "Dialogs", + "Easter", + "OperatingSystem", + "Process", + "Remote", + "Screenshot", + "String", + "Telnet", + "XML", + ) +) diff --git a/src/robot/libraries/dialogs_ipy.py b/src/robot/libraries/dialogs_ipy.py deleted file mode 100644 index 39d1b9e2d3f..00000000000 --- a/src/robot/libraries/dialogs_ipy.py +++ /dev/null @@ -1,221 +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 wpf # Loads required .NET Assemblies behind the scenes - -from System.Windows import (GridLength, SizeToContent, TextWrapping, Thickness, - Window, WindowStartupLocation) -from System.Windows.Controls import (Button, ColumnDefinition, Grid, Label, ListBox, - PasswordBox, RowDefinition, TextBlock, TextBox, SelectionMode) - - -class _WpfDialog(Window): - _left_button = 'OK' - _right_button = 'Cancel' - - def __init__(self, message, value=None, **extra): - self._initialize_dialog() - self._create_body(message, value, **extra) - self._create_buttons() - self._bind_esc_to_close_dialog() - self._result = None - - def _initialize_dialog(self): - self.Title = 'Robot Framework' - self.SizeToContent = SizeToContent.WidthAndHeight - self.WindowStartupLocation = WindowStartupLocation.CenterScreen - self.MinWidth = 300 - self.MinHeight = 100 - self.MaxWidth = 640 - grid = Grid() - left_column = ColumnDefinition() - right_column = ColumnDefinition() - grid.ColumnDefinitions.Add(left_column) - grid.ColumnDefinitions.Add(right_column) - label_row = RowDefinition() - label_row.Height = GridLength.Auto - selection_row = RowDefinition() - selection_row.Height = GridLength.Auto - button_row = RowDefinition() - button_row.Height = GridLength(50) - grid.RowDefinitions.Add(label_row) - grid.RowDefinitions.Add(selection_row) - grid.RowDefinitions.Add(button_row) - self.Content = grid - - def _create_body(self, message, value, **extra): - _label = Label() - textblock = TextBlock() - textblock.Text = message - textblock.TextWrapping = TextWrapping.Wrap - _label.Content = textblock - _label.Margin = Thickness(10) - _label.SetValue(Grid.ColumnSpanProperty, 2) - _label.SetValue(Grid.RowProperty, 0) - self.Content.AddChild(_label) - selector = self._create_selector(value, **extra) - if selector: - self.Content.AddChild(selector) - selector.Focus() - - def _create_selector(self, value): - return None - - def _create_buttons(self): - self.left_button = self._create_button(self._left_button, - self._left_button_clicked) - self.left_button.SetValue(Grid.ColumnProperty, 0) - self.left_button.IsDefault = True - self.right_button = self._create_button(self._right_button, - self._right_button_clicked) - if self.right_button: - self.right_button.SetValue(Grid.ColumnProperty, 1) - self.Content.AddChild(self.right_button) - self.left_button.SetValue(Grid.ColumnProperty, 0) - self.Content.AddChild(self.left_button) - else: - self.left_button.SetValue(Grid.ColumnSpanProperty, 2) - self.Content.AddChild(self.left_button) - - def _create_button(self, content, callback): - if content: - button = Button() - button.Margin = Thickness(10) - button.MaxHeight = 50 - button.MaxWidth = 150 - button.SetValue(Grid.RowProperty, 2) - button.Content = content - button.Click += callback - return button - - def _bind_esc_to_close_dialog(self): - # There doesn't seem to be easy way to bind esc otherwise than having - # a cancel button that binds it automatically. We don't always have - # actual cancel button so need to create one and make it invisible. - # Cannot actually hide it because it won't work after that so we just - # make it so small it is not seen. - button = Button() - button.IsCancel = True - button.MaxHeight = 1 - button.MaxWidth = 1 - self.Content.AddChild(button) - - def _left_button_clicked(self, sender, event_args): - if self._validate_value(): - self._result = self._get_value() - self._close() - - def _validate_value(self): - return True - - def _get_value(self): - return None - - def _close(self): - self.Close() - - def _right_button_clicked(self, sender, event_args): - self._result = self._get_right_button_value() - self._close() - - def _get_right_button_value(self): - return None - - def show(self): - self.ShowDialog() - return self._result - - -class MessageDialog(_WpfDialog): - _right_button = None - - -class InputDialog(_WpfDialog): - - def __init__(self, message, default='', hidden=False): - _WpfDialog.__init__(self, message, default, hidden=hidden) - - def _create_selector(self, default, hidden): - if hidden: - self._entry = PasswordBox() - self._entry.Password = default if default else '' - else: - self._entry = TextBox() - self._entry.Text = default if default else '' - self._entry.SetValue(Grid.RowProperty, 1) - self._entry.SetValue(Grid.ColumnSpanProperty, 2) - self.Margin = Thickness(10) - self._entry.Height = 30 - self._entry.Width = 150 - self._entry.SelectAll() - return self._entry - - def _get_value(self): - try: - return self._entry.Text - except AttributeError: - return self._entry.Password - - -class SelectionDialog(_WpfDialog): - - def __init__(self, message, values): - _WpfDialog.__init__(self, message, values) - - def _create_selector(self, values): - self._listbox = ListBox() - self._listbox.SetValue(Grid.RowProperty, 1) - self._listbox.SetValue(Grid.ColumnSpanProperty, 2) - self._listbox.Margin = Thickness(10) - for item in values: - self._listbox.Items.Add(item) - return self._listbox - - def _validate_value(self): - return bool(self._listbox.SelectedItem) - - def _get_value(self): - return self._listbox.SelectedItem - - -class MultipleSelectionDialog(_WpfDialog): - - def __init__(self, message, values): - _WpfDialog.__init__(self, message, values) - - def _create_selector(self, values): - self._listbox = ListBox() - self._listbox.SelectionMode = SelectionMode.Multiple - self._listbox.SetValue(Grid.RowProperty, 1) - self._listbox.SetValue(Grid.ColumnSpanProperty, 2) - self._listbox.Margin = Thickness(10) - for item in values: - self._listbox.Items.Add(item) - return self._listbox - - def _get_value(self): - return sorted(self._listbox.SelectedItems, - key=list(self._listbox.Items).index) - - -class PassFailDialog(_WpfDialog): - _left_button = 'PASS' - _right_button = 'FAIL' - - def _get_value(self): - return True - - def _get_right_button_value(self): - return False diff --git a/src/robot/libraries/dialogs_jy.py b/src/robot/libraries/dialogs_jy.py deleted file mode 100644 index 655d413813a..00000000000 --- a/src/robot/libraries/dialogs_jy.py +++ /dev/null @@ -1,156 +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 textwrap -import time - -from java.awt import Component -from java.awt.event import WindowAdapter -from javax.swing import (BoxLayout, JLabel, JOptionPane, JPanel, - JPasswordField, JTextField, JList, JScrollPane) -from javax.swing.JOptionPane import (DEFAULT_OPTION, OK_CANCEL_OPTION, - OK_OPTION, PLAIN_MESSAGE, - UNINITIALIZED_VALUE, YES_NO_OPTION) - -from robot.utils import html_escape - - -MAX_CHARS_PER_LINE = 120 - - -class _SwingDialog(object): - - def __init__(self, pane): - self._pane = pane - - def _create_panel(self, message, widget): - panel = JPanel() - panel.setLayout(BoxLayout(panel, BoxLayout.Y_AXIS)) - label = self._create_label(message) - label.setAlignmentX(Component.LEFT_ALIGNMENT) - panel.add(label) - widget.setAlignmentX(Component.LEFT_ALIGNMENT) - panel.add(widget) - return panel - - def _create_label(self, message): - # JLabel doesn't support multiline text, setting size, or wrapping. - # Need to handle all that ourselves. Feels like 2005... - wrapper = textwrap.TextWrapper(MAX_CHARS_PER_LINE, - drop_whitespace=False) - lines = [] - for line in html_escape(message, linkify=False).splitlines(): - if line: - lines.extend(wrapper.wrap(line)) - else: - lines.append('') - return JLabel('%s' % '
'.join(lines)) - - def show(self): - self._show_dialog(self._pane) - return self._get_value(self._pane) - - def _show_dialog(self, pane): - dialog = pane.createDialog(None, 'Robot Framework') - dialog.setModal(False) - dialog.setAlwaysOnTop(True) - dialog.addWindowFocusListener(pane.focus_listener) - dialog.show() - while dialog.isShowing(): - time.sleep(0.2) - dialog.dispose() - - def _get_value(self, pane): - value = pane.getInputValue() - return value if value != UNINITIALIZED_VALUE else None - - -class MessageDialog(_SwingDialog): - - def __init__(self, message): - pane = WrappedOptionPane(message, PLAIN_MESSAGE, DEFAULT_OPTION) - _SwingDialog.__init__(self, pane) - - -class InputDialog(_SwingDialog): - - def __init__(self, message, default, hidden=False): - self._input_field = JPasswordField() if hidden else JTextField() - self._input_field.setText(default) - self._input_field.selectAll() - panel = self._create_panel(message, self._input_field) - pane = WrappedOptionPane(panel, PLAIN_MESSAGE, OK_CANCEL_OPTION) - pane.set_focus_listener(self._input_field) - _SwingDialog.__init__(self, pane) - - def _get_value(self, pane): - if pane.getValue() != OK_OPTION: - return None - return self._input_field.getText() - - -class SelectionDialog(_SwingDialog): - - def __init__(self, message, options): - pane = WrappedOptionPane(message, PLAIN_MESSAGE, OK_CANCEL_OPTION) - pane.setWantsInput(True) - pane.setSelectionValues(options) - _SwingDialog.__init__(self, pane) - - -class MultipleSelectionDialog(_SwingDialog): - - def __init__(self, message, options): - self._selection_list = JList(options) - self._selection_list.setVisibleRowCount(8) - panel = self._create_panel(message, JScrollPane(self._selection_list)) - pane = WrappedOptionPane(panel, PLAIN_MESSAGE, OK_CANCEL_OPTION) - _SwingDialog.__init__(self, pane) - - def _get_value(self, pane): - if pane.getValue() != OK_OPTION: - return None - return list(self._selection_list.getSelectedValuesList()) - - -class PassFailDialog(_SwingDialog): - - def __init__(self, message): - pane = WrappedOptionPane(message, PLAIN_MESSAGE, YES_NO_OPTION, - None, ['PASS', 'FAIL'], 'PASS') - _SwingDialog.__init__(self, pane) - - def _get_value(self, pane): - value = pane.getValue() - return value == 'PASS' if value in ['PASS', 'FAIL'] else None - - -class WrappedOptionPane(JOptionPane): - focus_listener = None - - def getMaxCharactersPerLineCount(self): - return MAX_CHARS_PER_LINE - - def set_focus_listener(self, component): - self.focus_listener = WindowFocusListener(component) - - -class WindowFocusListener(WindowAdapter): - - def __init__(self, component): - self.component = component - - def windowGainedFocus(self, event): - self.component.requestFocusInWindow() diff --git a/src/robot/libraries/dialogs_py.py b/src/robot/libraries/dialogs_py.py index 0cd61730f36..058332660a8 100644 --- a/src/robot/libraries/dialogs_py.py +++ b/src/robot/libraries/dialogs_py.py @@ -13,184 +13,245 @@ # See the License for the specific language governing permissions and # limitations under the License. -import sys -from threading import currentThread 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) - - -class _TkDialog(Toplevel): - _left_button = 'OK' - _right_button = 'Cancel' - - def __init__(self, message, value=None, **extra): - self._prevent_execution_with_timeouts() - self._parent = self._get_parent() - Toplevel.__init__(self, self._parent) +import tkinter as tk +from collections.abc import Sequence +from importlib.resources import read_binary + +from robot.utils import WINDOWS + +if WINDOWS: + # A hack to override the default taskbar icon on Windows. See, for example: + # https://stackoverflow.com/questions/1551605/how-to-set-applications-taskbar-icon-in-windows-7/1552105 + from ctypes import windll + + windll.shell32.SetCurrentProcessExplicitAppUserModelID("robot.dialogs") + + +class TkDialog(tk.Toplevel): + left_button = "OK" + right_button = "Cancel" + font = (None, 12) + padding = 8 if WINDOWS else 16 + background = None # Can be used to change the dialog background. + + def __init__( + self, + message: str, + value: "str | Sequence[str] | None" = None, + **config, + ): + super().__init__(self._get_root()) + self._button_bindings = {} self._initialize_dialog() - self._create_body(message, value, **extra) + self.widget = self._create_body(message, value, **config) self._create_buttons() + self._finalize_dialog() self._result = None + self._closed = False - def _prevent_execution_with_timeouts(self): - if 'linux' not in sys.platform \ - and currentThread().getName() != 'MainThread': - raise RuntimeError('Dialogs library is not supported with ' - 'timeouts on Python on this platform.') - - def _get_parent(self): - parent = Tk() - parent.withdraw() - return parent + def _get_root(self) -> tk.Tk: + root = tk.Tk() + root.withdraw() + icon = tk.PhotoImage(master=root, data=read_binary("robot", "logo.png")) + root.iconphoto(True, icon) + return root def _initialize_dialog(self): - self.title('Robot Framework') - self.grab_set() + self.withdraw() # Remove from display until finalized. + self.title("Robot Framework") + self.configure(padx=self.padding, background=self.background) self.protocol("WM_DELETE_WINDOW", self._close) self.bind("", self._close) - self.minsize(250, 80) - self.geometry("+%d+%d" % self._get_center_location()) - self._bring_to_front() - - def grab_set(self, timeout=30): - maxtime = time.time() + timeout - while time.time() < maxtime: - 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) - - 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): - self.attributes('-topmost', True) - self.attributes('-topmost', False) - - def _create_body(self, message, value, **extra): - 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() - frame.pack(padx=5, pady=5, expand=1, fill=BOTH) - - def _create_selector(self, frame, value): + if self.left_button == TkDialog.left_button: + self.bind("", self._left_button_clicked) + + 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 // 5 + min_height = screen_height // 8 + 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.deiconify() + if self.widget: + self.widget.focus_set() + + def _create_body( + self, + message: str, + value: "str | Sequence[str] | None", + **config, + ) -> "tk.Entry | tk.Listbox | None": + frame = tk.Frame(self, background=self.background) + max_width = self.winfo_screenwidth() // 2 + label = tk.Label( + frame, + text=message, + anchor=tk.W, + justify=tk.LEFT, + wraplength=max_width, + pady=self.padding, + background=self.background, + font=self.font, + ) + label.pack(fill=tk.BOTH) + widget = self._create_widget(frame, value, **config) + if widget: + widget.pack(fill=tk.BOTH, pady=self.padding) + frame.pack(expand=1, fill=tk.BOTH) + return widget + + def _create_widget(self, parent, value, **config) -> "tk.Entry | tk.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) + frame = tk.Frame(self, pady=self.padding, background=self.background) + 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): if label: - button = Button(parent, text=label, width=10, command=callback) - button.pack(side=LEFT, padx=5, pady=5) + button = tk.Button( + parent, + text=label, + command=callback, + width=10, + underline=0, + font=self.font, + ) + button.pack(side=tk.LEFT, padx=self.padding) + 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(): 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) -> "str | list[str] | bool | None": return None - def _close(self, event=None): - # self.destroy() is not enough on Linux - self._parent.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) -> "str | list[str] | bool | None": return None - def show(self): - self.wait_window(self) + def _close(self, event=None): + self._closed = True + + def show(self) -> "str | list[str] | bool | None": + # Use a loop with `update()` instead of `wait_window()` to allow + # timeouts and signals stop execution. + try: + while not self._closed: + time.sleep(0.1) + self.update() + finally: + self.destroy() + self.update() # Needed on Linux to close the dialog (#1466, #4993) return self._result -class MessageDialog(_TkDialog): - _right_button = None - +class MessageDialog(TkDialog): + right_button = None -class InputDialog(_TkDialog): - def __init__(self, message, default='', hidden=False): - _TkDialog.__init__(self, message, default, hidden=hidden) +class InputDialog(TkDialog): - def _create_selector(self, parent, default, hidden): - self._entry = Entry(parent, show='*' if hidden else '') - self._entry.insert(0, default) - self._entry.select_range(0, END) - return self._entry + def __init__(self, message: str, default: str = "", hidden: bool = False): + super().__init__(message, default, hidden=hidden) - def _get_value(self): - return self._entry.get() + def _create_widget(self, parent, default, hidden=False) -> tk.Entry: + widget = tk.Entry(parent, show="*" if hidden else "", font=self.font) + widget.insert(0, default) + widget.select_range(0, tk.END) + widget.bind("", self._unbind_buttons) + widget.bind("", self._rebind_buttons) + return widget + def _unbind_buttons(self, event): + for char in self._button_bindings: + self.unbind(char) -class SelectionDialog(_TkDialog): + def _rebind_buttons(self, event): + for char, callback in self._button_bindings.items(): + self.bind(char, callback) - def __init__(self, message, values): - _TkDialog.__init__(self, message, values) - - def _create_selector(self, parent, values): - self._listbox = Listbox(parent) - for item in values: - self._listbox.insert(END, item) - self._listbox.config(width=0) - return self._listbox + def _get_value(self) -> str: + return self.widget.get() - def _validate_value(self): - return bool(self._listbox.curselection()) - def _get_value(self): - return self._listbox.get(self._listbox.curselection()) +class SelectionDialog(TkDialog): + def __init__( + self, + message: str, + values: "Sequence[str]", + default: "str | int | None" = None, + ): + super().__init__(message, values, default=default) -class MultipleSelectionDialog(_TkDialog): - - def __init__(self, message, values): - _TkDialog.__init__(self, message, values) - - def _create_selector(self, parent, values): - self._listbox = Listbox(parent, selectmode='multiple') + def _create_widget(self, parent, values, default=None) -> tk.Listbox: + widget = tk.Listbox(parent, font=self.font) + for item in values: + widget.insert(tk.END, item) + if default is not None: + index = self._get_default_value_index(default, values) + widget.select_set(index) + widget.activate(index) + widget.config(width=0) + return widget + + def _get_default_value_index(self, default, values) -> int: + if default in values: + return values.index(default) + try: + index = int(default) - 1 + except ValueError: + raise ValueError(f"Invalid default value '{default}'.") + if index < 0 or index >= len(values): + raise ValueError("Default value index is out of bounds.") + return index + + def _validate_value(self) -> bool: + return bool(self.widget.curselection()) + + def _get_value(self) -> str: + return self.widget.get(self.widget.curselection()) + + +class MultipleSelectionDialog(TkDialog): + + def _create_widget(self, parent, values) -> tk.Listbox: + widget = tk.Listbox(parent, selectmode="multiple", font=self.font) for item in values: - self._listbox.insert(END, item) - self._listbox.config(width=0) - return self._listbox + widget.insert(tk.END, item) + widget.config(width=0) + return widget - def _get_value(self): - selected_values = [self._listbox.get(i) for i in self._listbox.curselection()] - return selected_values + def _get_value(self) -> "list[str]": + return [self.widget.get(i) for i in self.widget.curselection()] -class PassFailDialog(_TkDialog): - _left_button = 'PASS' - _right_button = 'FAIL' +class PassFailDialog(TkDialog): + 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 diff --git a/src/robot/libraries/normalizer.py b/src/robot/libraries/normalizer.py new file mode 100644 index 00000000000..74a80a2d762 --- /dev/null +++ b/src/robot/libraries/normalizer.py @@ -0,0 +1,146 @@ +# 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 re +from collections.abc import Mapping, Sequence +from typing import Any, Literal, Union + +from robot.utils import is_dict_like, is_list_like + +IgnoreCase = Union[Literal["KEY", "KEYS", "VALUE", "VALUES"], bool] +StripSpaces = Union[Literal["LEADING", "TRAILING"], bool] + + +class Normalizer: + + def __init__( + self, + ignore_case: IgnoreCase = False, + strip_spaces: StripSpaces = False, + collapse_spaces: bool = False, + ignore_order: bool = False, + ignore_keys: "Sequence | None" = None, + ): + self.ignore_case = bool(ignore_case) + if isinstance(ignore_case, str): + self.ignore_key_case = ignore_case.upper() not in ("VALUE", "VALUES") + self.ignore_value_case = ignore_case.upper() not in ("KEY", "KEYS") + else: + self.ignore_key_case = self.ignore_value_case = self.ignore_case + if isinstance(strip_spaces, str): + self.strip_leading_spaces = strip_spaces.upper() != "TRAILING" + self.strip_trailing_spaces = strip_spaces.upper() != "LEADING" + else: + self.strip_leading_spaces = self.strip_trailing_spaces = bool(strip_spaces) + self.collapse_spaces = collapse_spaces + self.ignore_order = ignore_order + self.ignore_keys = self._parse_ignored_keys(ignore_keys) + + def _parse_ignored_keys(self, ignore_keys: "Sequence | None") -> set: + if not ignore_keys: + return set() + return {self.normalize_key(k) for k in ignore_keys} + + def normalize(self, value: Any, mapping_to_list: bool = False) -> Any: + if not self: + return value + if isinstance(value, str): + return self.normalize_string(value) + if isinstance(value, (bytes, bytearray)): + return self.normalize_bytes(value) + if is_dict_like(value): + if mapping_to_list: + return self.normalize_list(list(value)) + return self.normalize_dict(value) + if is_list_like(value): + return self.normalize_list(value) + return value + + def normalize_string(self, value: str) -> str: + if self.ignore_case: + value = value.casefold() + if self.strip_leading_spaces: + value = value.lstrip() + if self.strip_trailing_spaces: + value = value.rstrip() + if self.collapse_spaces: + value = re.sub(r"\s+", " ", value) + return value + + def normalize_bytes(self, value: "bytes | bytearray") -> "bytes | bytearray": + if self.ignore_case: + value = value.lower() + if self.strip_leading_spaces: + value = value.lstrip() + if self.strip_trailing_spaces: + value = value.rstrip() + if self.collapse_spaces: + value = re.sub(rb"\s+", b" ", value) + return value + + def normalize_list(self, value: Sequence) -> Sequence: + cls = type(value) + value_list = [self.normalize(v) for v in value] + if self.ignore_order: + value_list = sorted(value_list) + return self._try_to_preserve_type(value_list, cls) + + def _try_to_preserve_type(self, value: Any, cls: type) -> Any: + # Try to preserve original type. Most importantly, preserve tuples to + # allow using them as dictionary keys. + try: + return cls(value) + except TypeError: + return value + + def normalize_dict(self, value: Mapping) -> Mapping: + cls = type(value) + result = {} + for key in value: + normalized = self.normalize_key(key) + if normalized in self.ignore_keys: + continue + if normalized in result: + raise AssertionError( + f"Dictionary {value} contains multiple keys that are normalized " + f"to '{normalized}'. Try normalizing only dictionary values like " + f"'ignore_case=values'." + ) + result[normalized] = self.normalize_value(value[key]) + return self._try_to_preserve_type(result, cls) + + def normalize_key(self, key: object) -> object: + ignore_case, self.ignore_case = self.ignore_case, self.ignore_key_case + try: + return self.normalize(key) + finally: + self.ignore_case = ignore_case + + def normalize_value(self, value: object) -> object: + ignore_case, self.ignore_case = self.ignore_case, self.ignore_value_case + try: + return self.normalize(value) + finally: + self.ignore_case = ignore_case + + def __bool__(self) -> bool: + return bool( + self.ignore_case + or self.strip_leading_spaces + or self.strip_trailing_spaces + or self.collapse_spaces + or self.ignore_order + or getattr(self, "ignore_keys", False) + ) diff --git a/src/robot/libraries/py.typed b/src/robot/libraries/py.typed new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/robot/logo.png b/src/robot/logo.png new file mode 100644 index 00000000000..346b814f4aa Binary files /dev/null and b/src/robot/logo.png differ diff --git a/src/robot/model/__init__.py b/src/robot/model/__init__.py index 38525ebe099..8e1b8af6427 100644 --- a/src/robot/model/__init__.py +++ b/src/robot/model/__init__.py @@ -25,17 +25,42 @@ This package is considered stable. """ -from .configurer import SuiteConfigurer -from .testsuite import TestSuite -from .testcase import TestCase -from .keyword import Keyword, Keywords -from .message import Message -from .modifier import ModelModifier -from .tags import Tags, TagPattern, TagPatterns -from .criticality import Criticality -from .namepatterns import SuiteNamePatterns, TestNamePatterns -from .visitor import SuiteVisitor -from .totalstatistics import TotalStatisticsBuilder -from .statistics import Statistics -from .imports import Imports -from .itemlist import ItemList +from .body import ( + BaseBody as BaseBody, + BaseBranches as BaseBranches, + BaseIterations as BaseIterations, + Body as Body, + BodyItem as BodyItem, +) +from .configurer import SuiteConfigurer as SuiteConfigurer +from .control import ( + Break as Break, + Continue as Continue, + Error as Error, + For as For, + ForIteration as ForIteration, + Group as Group, + If as If, + IfBranch as IfBranch, + Return as Return, + Try as Try, + TryBranch as TryBranch, + Var as Var, + While as While, + WhileIteration as WhileIteration, +) +from .fixture import create_fixture as create_fixture +from .itemlist import ItemList as ItemList +from .keyword import Keyword as Keyword +from .message import Message as Message, MessageLevel as MessageLevel +from .modelobject import DataDict as DataDict, ModelObject as ModelObject +from .modifier import ModelModifier as ModelModifier +from .statistics import Statistics as Statistics +from .tags import TagPattern as TagPattern, TagPatterns as TagPatterns, Tags as Tags +from .testcase import TestCase as TestCase, TestCases as TestCases +from .testsuite import TestSuite as TestSuite, TestSuites as TestSuites +from .totalstatistics import ( + TotalStatistics as TotalStatistics, + TotalStatisticsBuilder as TotalStatisticsBuilder, +) +from .visitor import SuiteVisitor as SuiteVisitor diff --git a/src/robot/model/body.py b/src/robot/model/body.py new file mode 100644 index 00000000000..3fc0665f86a --- /dev/null +++ b/src/robot/model/body.py @@ -0,0 +1,347 @@ +# 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 re +from typing import ( + Any, Callable, cast, Generic, Iterable, Type, TYPE_CHECKING, TypeVar, Union +) + +from robot.errors import DataError +from robot.utils import copy_signature, KnownAtRuntime + +from .itemlist import ItemList +from .modelobject import DataDict, full_name, ModelObject + +if TYPE_CHECKING: + from robot.running.model import ResourceFile, UserKeyword + + from .control import ( + Break, Continue, Error, For, ForIteration, Group, If, IfBranch, Return, Try, + TryBranch, Var, While, WhileIteration + ) + from .keyword import Keyword + from .message import Message + from .testcase import TestCase + from .testsuite import TestSuite + + +BodyItemParent = Union[ + "TestSuite", "TestCase", "UserKeyword", "For", "ForIteration", "If", "IfBranch", + "Try", "TryBranch", "While", "Group", "WhileIteration", "Keyword", "Var", + "Return", "Continue", "Break", "Error", None +] # fmt: skip +BI = TypeVar("BI", bound="BodyItem") +KW = TypeVar("KW", bound="Keyword") +F = TypeVar("F", bound="For") +W = TypeVar("W", bound="While") +G = TypeVar("G", bound="Group") +I = TypeVar("I", bound="If") # noqa: E741 +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") +M = TypeVar("M", bound="Message") +E = TypeVar("E", bound="Error") +IT = TypeVar("IT", bound="IfBranch | TryBranch") +FW = TypeVar("FW", bound="ForIteration | WhileIteration") + + +class BodyItem(ModelObject): + body: "BaseBody" + __slots__ = ("parent",) + + @property + def id(self) -> "str | None": + """Item id in format like ``s1-t3-k1``. + + See :attr:`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. + """ + return self._get_id(self.parent) + + 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) + if hasattr(parent, "body"): + steps.extend(parent.body.flatten(messages=False)) + if getattr(parent, "has_teardown", False): + steps.append(parent.teardown) + index = steps.index(self) if self in steps else len(steps) + pid = parent.id # IF/TRY root id is None. Avoid calling property twice. + return f"{pid}-k{index + 1}" if pid else f"k{index + 1}" + + def to_dict(self) -> DataDict: + raise NotImplementedError + + +class BaseBody(ItemList[BodyItem], Generic[KW, F, W, G, I, T, V, R, C, B, M, E]): + """Base class for Body and Branches objects.""" + + # Set using 'BaseBody.register' when these classes are created. + keyword_class: Type[KW] = KnownAtRuntime + for_class: Type[F] = KnownAtRuntime + while_class: Type[W] = KnownAtRuntime + group_class: Type[G] = 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 + message_class: Type[M] = KnownAtRuntime + error_class: Type[E] = KnownAtRuntime + __slots__ = () + + def __init__( + self, + parent: BodyItemParent = None, + items: "Iterable[BodyItem | DataDict]" = (), + ): + super().__init__(BodyItem, {"parent": parent}, items) + + def _item_from_dict(self, data: DataDict) -> BodyItem: + item_type = data.get("type", None) + if item_type is None: + 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") + item_class = cast(Type[BodyItem], item_class) + return item_class.from_dict(data) + + @classmethod + 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): + raise TypeError(f"Cannot register '{name}'.") + setattr(cls, name, item_class) + return item_class + + @property + def create(self): + raise AttributeError( + f"'{full_name(self)}' object has no attribute 'create'. " + f"Use item specific methods like 'create_keyword' instead." + ) + + def _create( + self, + cls: "Type[BI]", + name: str, + args: "tuple[Any, ...]", + kwargs: "dict[str, Any]", + ) -> BI: + if cls is KnownAtRuntime: + raise TypeError(f"'{full_name(self)}' object does not support '{name}'.") + return self.append(cls(*args, **kwargs)) # type: ignore + + @copy_signature(keyword_class) + def create_keyword(self, *args, **kwargs) -> keyword_class: + return self._create(self.keyword_class, "create_keyword", args, kwargs) + + @copy_signature(for_class) + def create_for(self, *args, **kwargs) -> for_class: + return self._create(self.for_class, "create_for", args, kwargs) + + @copy_signature(if_class) + def create_if(self, *args, **kwargs) -> if_class: + return self._create(self.if_class, "create_if", args, kwargs) + + @copy_signature(try_class) + def create_try(self, *args, **kwargs) -> try_class: + return self._create(self.try_class, "create_try", args, kwargs) + + @copy_signature(while_class) + def create_while(self, *args, **kwargs) -> while_class: + return self._create(self.while_class, "create_while", args, kwargs) + + @copy_signature(group_class) + def create_group(self, *args, **kwargs) -> group_class: + return self._create(self.group_class, "create_group", 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) + + @copy_signature(continue_class) + def create_continue(self, *args, **kwargs) -> continue_class: + return self._create(self.continue_class, "create_continue", args, kwargs) + + @copy_signature(break_class) + def create_break(self, *args, **kwargs) -> break_class: + return self._create(self.break_class, "create_break", args, kwargs) + + @copy_signature(message_class) + def create_message(self, *args, **kwargs) -> message_class: + return self._create(self.message_class, "create_message", args, kwargs) + + @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, + predicate: "Callable[[T], bool] | None" = None, + ) -> "list[BodyItem]": + """Filter body items based on type and/or custom predicate. + + To include or exclude items based on types, give matching arguments + ``True`` or ``False`` values. For example, to include only keywords, + use ``body.filter(keywords=True)`` and to exclude messages use + ``body.filter(messages=False)``. Including and excluding by types + at the same time is not supported and filtering my ``messages`` + is supported only if the ``Body`` object actually supports messages. + + Custom ``predicate`` is a callable getting each body item as an argument + that must return ``True/False`` depending on should the item be included + or not. + + Selected items are returned as a list and the original body is not modified. + + It was earlier possible to filter also based on FOR and IF types. + That support was removed in RF 5.0 because it was not considered useful + in general and because adding support for all new control structures + would have required extra work. To exclude all control structures, use + ``body.filter(keywords=True, messages=True)`` and to only include them + use ``body.filter(keywords=False``, messages=False)``. For more detailed + filtering it is possible to use ``predicate``. + """ + by_type = [(self.keyword_class, keywords), (self.message_class, messages)] + include = tuple(cls for cls, activated in by_type if activated is True and cls) + exclude = tuple(cls for cls, activated in by_type if activated is False and cls) + if include and exclude: + raise ValueError("Items cannot be both included and excluded by type.") + items = list(self) + if include: + items = [item for item in items if isinstance(item, include)] + if exclude: + items = [item for item in items if not isinstance(item, exclude)] + if predicate: + items = [item for item in items if predicate(item)] + return items + + def flatten(self, **filter_config) -> "list[BodyItem]": + """Return steps so that IF and TRY structures are flattened. + + Basically the IF/ELSE and TRY/EXCEPT root elements are replaced + with their branches. This is how they are shown in the log file. + + ``filter_config`` can be used to filter steps using the :meth:`filter` + method before flattening. New in Robot Framework 7.2. + """ + roots = BodyItem.IF_ELSE_ROOT, BodyItem.TRY_EXCEPT_ROOT + steps = self if not filter_config else self.filter(**filter_config) + flat = [] + for item in steps: + if item.type in roots: + flat.extend(item.body) + else: + flat.append(item) + return flat + + +class Body(BaseBody[ + "Keyword", "For", "While", "Group", "If", "Try", "Var", "Return", "Continue", + "Break", "Message", "Error" +]): # fmt: skip + """A list-like object representing a body of a test, keyword, etc. + + Body contains the keywords and other structures such as FOR loops. + """ + + __slots__ = () + + +# BaseBranches cannot extend Generic[IT] directly with BaseBody[...]. +class BranchType(Generic[IT]): + __slots__ = () + + +class BaseBranches(BaseBody[KW, F, W, G, I, T, V, R, C, B, M, E], BranchType[IT]): + """A list-like object representing IF and TRY branches.""" + + branch_type: Type[IT] = KnownAtRuntime + __slots__ = ("branch_class",) + + def __init__( + self, + branch_class: Type[IT], + parent: BodyItemParent = None, + items: "Iterable[IT | DataDict]" = (), + ): + self.branch_class = branch_class + super().__init__(parent, items) + + def _item_from_dict(self, data: DataDict) -> BodyItem: + try: + return self.branch_class.from_dict(data) + except DataError: + return super()._item_from_dict(data) + + @copy_signature(branch_type) + def create_branch(self, *args, **kwargs) -> IT: + return self._create(self.branch_class, "create_branch", args, kwargs) + + +# BaseIterations cannot extend Generic[IT] directly with BaseBody[...]. +class IterationType(Generic[FW]): + __slots__ = () + + +class BaseIterations(BaseBody[KW, F, W, G, I, T, V, R, C, B, M, E], IterationType[FW]): + iteration_type: Type[FW] = KnownAtRuntime + __slots__ = ("iteration_class",) + + def __init__( + self, + iteration_class: Type[FW], + parent: BodyItemParent = None, + items: "Iterable[FW | DataDict]" = (), + ): + self.iteration_class = iteration_class + super().__init__(parent, items) + + def _item_from_dict(self, data: DataDict) -> BodyItem: + # Non-iteration data is typically caused by listeners. + if data.get("type") != "ITERATION": + return super()._item_from_dict(data) + return self.iteration_class.from_dict(data) + + @copy_signature(iteration_type) + def create_iteration(self, *args, **kwargs) -> FW: + return self._create(self.iteration_class, "iteration_class", args, kwargs) diff --git a/src/robot/model/configurer.py b/src/robot/model/configurer.py index 23c1c2869db..e8a639f1953 100644 --- a/src/robot/model/configurer.py +++ b/src/robot/model/configurer.py @@ -13,17 +13,26 @@ # See the License for the specific language governing permissions and # limitations under the License. -from robot.utils import seq2str from robot.errors import DataError +from robot.utils import seq2str from .visitor import SuiteVisitor class SuiteConfigurer(SuiteVisitor): - def __init__(self, name=None, doc=None, metadata=None, set_tags=None, - include_tags=None, exclude_tags=None, include_suites=None, - include_tests=None, empty_suite_ok=False): + def __init__( + self, + name=None, + doc=None, + metadata=None, + set_tags=None, + include_tags=None, + exclude_tags=None, + include_suites=None, + include_tests=None, + empty_suite_ok=False, + ): self.name = name self.doc = doc self.metadata = metadata @@ -36,11 +45,11 @@ def __init__(self, name=None, doc=None, metadata=None, set_tags=None, @property def add_tags(self): - return [t for t in self.set_tags if not t.startswith('-')] + return [t for t in self.set_tags if not t.startswith("-")] @property def remove_tags(self): - return [t[1:] for t in self.set_tags if t.startswith('-')] + return [t[1:] for t in self.set_tags if t.startswith("-")] def visit_suite(self, suite): self._set_suite_attributes(suite) @@ -57,33 +66,44 @@ def _set_suite_attributes(self, suite): 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) + suite.filter( + self.include_suites, + self.include_tests, + self.include_tags, + self.exclude_tags, + ) + 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', - 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))) + 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( + f"Suite '{name}' contains no {' '.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), + ("and", "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: - return '' - return self._format_selector_msg('in suites', self.include_suites) + return "" + return self._format_selector_msg("in suites", self.include_suites) diff --git a/src/robot/model/control.py b/src/robot/model/control.py new file mode 100644 index 00000000000..05a2af0cbeb --- /dev/null +++ b/src/robot/model/control.py @@ -0,0 +1,642 @@ +# 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 +from collections import OrderedDict +from typing import Any, cast, Literal, Mapping, Sequence, TYPE_CHECKING, TypeVar + +from robot.utils import setter + +from .body import BaseBranches, BaseIterations, Body, BodyItem, BodyItemParent +from .modelobject import DataDict +from .visitor import SuiteVisitor + +if TYPE_CHECKING: + from .keyword import Keyword + from .message import Message + + +IT = TypeVar("IT", bound="IfBranch | TryBranch") +FW = TypeVar("FW", bound="ForIteration | WhileIteration") + + +class Branches(BaseBranches[ + "Keyword", "For", "While", "Group", "If", "Try", "Var", "Return", "Continue", + "Break", "Message", "Error", IT +]): # fmt: skip + __slots__ = () + + +class Iterations(BaseIterations[ + "Keyword", "For", "While", "Group", "If", "Try", "Var", "Return", "Continue", + "Break", "Message", "Error", FW +]): # fmt: skip + __slots__ = () + + +class ForIteration(BodyItem): + """Represents one FOR loop iteration.""" + + type = BodyItem.ITERATION + body_class = Body + repr_args = ("assign",) + __slots__ = ("assign", "message", "status") + + def __init__( + self, + assign: "Mapping[str, str] | None" = None, + parent: BodyItemParent = None, + ): + self.assign = OrderedDict(assign or ()) + self.parent = parent + 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) + + def visit(self, visitor: SuiteVisitor): + visitor.visit_for_iteration(self) + + @property + def _log_name(self): + return ", ".join(f"{name} = {value}" for name, value in self.assign.items()) + + def to_dict(self) -> DataDict: + return { + "type": self.type, + "assign": dict(self.assign), + "body": self.body.to_dicts(), + } + + +@Body.register +class For(BodyItem): + """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", + values: Sequence[str] = (), + start: "str | None" = None, + mode: "str | None" = None, + fill: "str | None" = None, + parent: BodyItemParent = None, + ): + self.assign = tuple(assign) + self.flavor = flavor + self.values = tuple(values) + self.start = start + self.mode = mode + self.fill = fill + 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) + + def visit(self, visitor: SuiteVisitor): + visitor.visit_for(self) + + def to_dict(self) -> DataDict: + data = { + "type": self.type, + "assign": self.assign, + "flavor": self.flavor, + "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 + + 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") + + +class WhileIteration(BodyItem): + """Represents one WHILE loop iteration.""" + + type = BodyItem.ITERATION + body_class = Body + __slots__ = () + + def __init__(self, parent: BodyItemParent = None): + self.parent = parent + self.body = () + + @setter + def body(self, body: "Sequence[BodyItem | DataDict]") -> Body: + return self.body_class(self, body) + + def visit(self, visitor: SuiteVisitor): + visitor.visit_while_iteration(self) + + def to_dict(self) -> DataDict: + return {"type": self.type, "body": self.body.to_dicts()} + + +@Body.register +class While(BodyItem): + """Represents ``WHILE`` loops.""" + + type = BodyItem.WHILE + body_class = Body + 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: BodyItemParent = None, + ): + self.condition = condition + self.on_limit = on_limit + self.limit = limit + self.on_limit_message = on_limit_message + self.parent = parent + self.body = () + + @setter + def body(self, body: "Sequence[BodyItem | DataDict]") -> Body: + return self.body_class(self, body) + + def visit(self, visitor: SuiteVisitor): + visitor.visit_while(self) + + def _include_in_repr(self, name: str, value: Any) -> bool: + return name == "condition" or value is not None + + 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 + 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) + + +@Body.register +class Group(BodyItem): + """Represents ``GROUP``.""" + + type = BodyItem.GROUP + body_class = Body + repr_args = ("name",) + __slots__ = ("name",) + + def __init__(self, name: str = "", parent: BodyItemParent = None): + self.name = name + self.parent = parent + self.body = () + + @setter + def body(self, body: "Sequence[BodyItem | DataDict]") -> Body: + return self.body_class(self, body) + + def visit(self, visitor: SuiteVisitor): + visitor.visit_group(self) + + def to_dict(self) -> DataDict: + return {"type": self.type, "name": self.name, "body": self.body.to_dicts()} + + def __str__(self) -> str: + parts = ["GROUP"] + if self.name: + parts.append(self.name) + return " ".join(parts) + + +class IfBranch(BodyItem): + """Represents individual ``IF``, ``ELSE IF`` or ``ELSE`` branch.""" + + body_class = Body + repr_args = ("type", "condition") + __slots__ = ("type", "condition") + + 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: "Sequence[BodyItem | DataDict]") -> Body: + return self.body_class(self, body) + + @property + def id(self) -> str: + """Branch id omits IF/ELSE root from the parent id part.""" + if not self.parent: + return "k1" + if not self.parent.parent: + return self._get_id(self.parent) + return self._get_id(self.parent.parent) + + def visit(self, visitor: SuiteVisitor): + visitor.visit_if_branch(self) + + def to_dict(self) -> DataDict: + data = {"type": self.type} + if self.condition: + data["condition"] = self.condition + 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): + """IF/ELSE structure root. Branches are stored in :attr:`body`.""" + + type = BodyItem.IF_ELSE_ROOT + branch_class = IfBranch + branches_class = Branches[branch_class] + __slots__ = () + + def __init__(self, parent: BodyItemParent = None): + self.parent = parent + self.body = () + + @setter + def body(self, branches: "Sequence[BodyItem | DataDict]") -> branches_class: + return self.branches_class(self.branch_class, self, branches) + + @property + def id(self) -> None: + """Root IF/ELSE id is always ``None``.""" + return None + + def visit(self, visitor: SuiteVisitor): + visitor.visit_if(self) + + def to_dict(self) -> DataDict: + return {"type": self.type, "body": self.body.to_dicts()} + + +class TryBranch(BodyItem): + """Represents individual ``TRY``, ``EXCEPT``, ``ELSE`` or ``FINALLY`` branch.""" + + body_class = Body + 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, + assign: "str | None" = None, + parent: BodyItemParent = None, + ): + 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.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) + + @property + def id(self) -> str: + """Branch id omits TRY/EXCEPT root from the parent id part.""" + if not self.parent: + return "k1" + if not self.parent.parent: + return self._get_id(self.parent) + return self._get_id(self.parent.parent) + + def visit(self, visitor: SuiteVisitor): + visitor.visit_try_branch(self) + + def to_dict(self) -> DataDict: + data: DataDict = {"type": self.type} + if self.type == self.EXCEPT: + data["patterns"] = self.patterns + if self.pattern_type: + data["pattern_type"] = self.pattern_type + if self.assign: + data["assign"] = self.assign + 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): + """TRY/EXCEPT structure root. Branches are stored in :attr:`body`.""" + + type = BodyItem.TRY_EXCEPT_ROOT + branch_class = TryBranch + branches_class = Branches[branch_class] + __slots__ = () + + def __init__(self, parent: BodyItemParent = None): + self.parent = parent + self.body = () + + @setter + def body(self, branches: "Sequence[TryBranch | DataDict]") -> branches_class: + return self.branches_class(self.branch_class, self, branches) + + @property + def try_branch(self) -> TryBranch: + if self.body and self.body[0].type == BodyItem.TRY: + return cast(TryBranch, self.body[0]) + raise TypeError("No 'TRY' branch or 'TRY' branch is not first.") + + @property + 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) -> "TryBranch | None": + for branch in self.body: + if branch.type == BodyItem.ELSE: + return cast(TryBranch, branch) + return None + + @property + def finally_branch(self) -> "TryBranch | None": + if self.body and self.body[-1].type == BodyItem.FINALLY: + return cast(TryBranch, self.body[-1]) + return None + + @property + def id(self) -> None: + """Root TRY/EXCEPT id is always ``None``.""" + return None + + def visit(self, visitor: SuiteVisitor): + visitor.visit_try(self) + + def to_dict(self) -> DataDict: + return {"type": self.type, "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 + + 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): + """Represents ``RETURN``.""" + + type = BodyItem.RETURN + repr_args = ("values",) + __slots__ = ("values",) + + def __init__(self, values: Sequence[str] = (), parent: BodyItemParent = None): + self.values = tuple(values) + self.parent = parent + + def visit(self, visitor: SuiteVisitor): + visitor.visit_return(self) + + def to_dict(self) -> DataDict: + 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 +class Continue(BodyItem): + """Represents ``CONTINUE``.""" + + type = BodyItem.CONTINUE + __slots__ = () + + def __init__(self, parent: BodyItemParent = None): + self.parent = parent + + def visit(self, visitor: SuiteVisitor): + visitor.visit_continue(self) + + def to_dict(self) -> DataDict: + return {"type": self.type} + + def __str__(self): + return "CONTINUE" + + +@Body.register +class Break(BodyItem): + """Represents ``BREAK``.""" + + type = BodyItem.BREAK + __slots__ = () + + def __init__(self, parent: BodyItemParent = None): + self.parent = parent + + def visit(self, visitor: SuiteVisitor): + visitor.visit_break(self) + + def to_dict(self) -> DataDict: + return {"type": self.type} + + def __str__(self): + return "BREAK" + + +@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 + repr_args = ("values",) + __slots__ = ("values",) + + def __init__(self, values: Sequence[str] = (), parent: BodyItemParent = None): + self.values = tuple(values) + self.parent = parent + + def visit(self, visitor: SuiteVisitor): + visitor.visit_error(self) + + 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/model/criticality.py b/src/robot/model/criticality.py deleted file mode 100644 index 70f39ba05c7..00000000000 --- a/src/robot/model/criticality.py +++ /dev/null @@ -1,43 +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 py2to3 - -from .tags import TagPatterns - - -@py2to3 -class Criticality(object): - - def __init__(self, critical_tags=None, non_critical_tags=None): - self.critical_tags = self._get_tag_patterns(critical_tags) - self.non_critical_tags = self._get_tag_patterns(non_critical_tags) - - def _get_tag_patterns(self, tags): - return TagPatterns(tags) if not isinstance(tags, TagPatterns) else tags - - def tag_is_critical(self, tag): - return self.critical_tags.match(tag) - - def tag_is_non_critical(self, tag): - return self.non_critical_tags.match(tag) - - def test_is_critical(self, test): - if self.critical_tags and not self.critical_tags.match(test.tags): - return False - return not self.non_critical_tags.match(test.tags) - - def __nonzero__(self): - return bool(self.critical_tags or self.non_critical_tags) diff --git a/src/robot/model/filter.py b/src/robot/model/filter.py index 83a1590038a..d441909810e 100644 --- a/src/robot/model/filter.py +++ b/src/robot/model/filter.py @@ -13,90 +13,110 @@ # See the License for the specific language governing permissions and # limitations under the License. -from robot.utils import py2to3, setter +from typing import Sequence, TYPE_CHECKING +from robot.utils import setter + +from .namepatterns import NamePatterns 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 end_suite(self, suite): - suite.suites = [s for s in suite.suites if s.test_count] + def __init__(self, preserve_direct_children: bool = False): + self.preserve_direct_children = preserve_direct_children - def visit_test(self, test): + 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: "TestCase"): pass - def visit_keyword(self, kw): + def visit_keyword(self, keyword: "Keyword"): pass -@py2to3 class Filter(EmptySuiteRemover): - def __init__(self, include_suites=None, include_tests=None, - include_tags=None, exclude_tags=None): + def __init__( + self, + 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__() self.include_suites = include_suites self.include_tests = include_tests self.include_tags = include_tags self.exclude_tags = exclude_tags @setter - def include_suites(self, suites): - return SuiteNamePatterns(suites) \ - if not isinstance(suites, SuiteNamePatterns) else suites + def include_suites(self, suites) -> "NamePatterns | None": + return self._patterns_or_none(suites, NamePatterns) @setter - def include_tests(self, tests): - return TestNamePatterns(tests) \ - if not isinstance(tests, TestNamePatterns) else tests + def include_tests(self, tests) -> "NamePatterns | None": + return self._patterns_or_none(tests, NamePatterns) @setter - def include_tags(self, tags): - return TagPatterns(tags) if not isinstance(tags, TagPatterns) else tags + def include_tags(self, tags) -> "TagPatterns | None": + return self._patterns_or_none(tags, TagPatterns, "including tests by tags") @setter - def exclude_tags(self, tags): - return TagPatterns(tags) if not isinstance(tags, TagPatterns) else tags + def exclude_tags(self, tags) -> "TagPatterns | None": + return self._patterns_or_none(tags, TagPatterns, "excluding tests by tags") + + def _patterns_or_none(self, items, pattern_class, *extra): + if items is None or isinstance(items, pattern_class): + return items + return pattern_class(items, *extra) - 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: - return self._filter_by_suite_name(suite) - if self.include_tests: - suite.tests = self._filter(suite, self._included_by_test_name) - if self.include_tags: - suite.tests = self._filter(suite, self._included_by_tags) - if self.exclude_tags: - suite.tests = self._filter(suite, self._not_excluded_by_tags) + if hasattr(suite, "start_time"): + suite.start_time = suite.end_time = suite.elapsed_time = None + if self.include_suites is not None: + return self._filter_based_on_suite_name(suite) + self._filter_tests(suite) return bool(suite.suites) - def _filter_by_suite_name(self, suite): - if self.include_suites.match(suite.name, suite.longname): - suite.visit(Filter(include_suites=[], - include_tests=self.include_tests, - include_tags=self.include_tags, - exclude_tags=self.exclude_tags)) + def _filter_based_on_suite_name(self, suite: "TestSuite") -> bool: + 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, + ) + ) 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 __nonzero__(self): - return bool(self.include_suites or self.include_tests or - self.include_tags or self.exclude_tags) + def _filter_tests(self, suite: "TestSuite"): + tests = self.include_tests + include = self.include_tags + exclude = self.exclude_tags + if tests is not None: + suite.tests = [t for t in suite.tests if tests.match(t.name, t.full_name)] + if include is not None: + suite.tests = [t for t in suite.tests if include.match(t.tags)] + if exclude is not None: + suite.tests = [t for t in suite.tests if not exclude.match(t.tags)] + + 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 self.exclude_tags is not None + ) diff --git a/src/robot/model/fixture.py b/src/robot/model/fixture.py new file mode 100644 index 00000000000..05dfff0c6e2 --- /dev/null +++ b/src/robot/model/fixture.py @@ -0,0 +1,43 @@ +# 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 collections.abc import Mapping +from typing import Type, TYPE_CHECKING, TypeVar + +if TYPE_CHECKING: + from robot.model import DataDict, Keyword, TestCase, TestSuite + from robot.running.model import UserKeyword + + +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/imports.py b/src/robot/model/imports.py deleted file mode 100644 index 99097597fe4..00000000000 --- a/src/robot/model/imports.py +++ /dev/null @@ -1,62 +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 os - -from robot.utils import seq2str - -from .itemlist import ItemList - - -class Import(object): - ALLOWED_TYPES = ('Library', 'Resource', 'Variables') - - def __init__(self, type, name, args=(), alias=None, source=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 '))) - self.type = type - self.name = name - self.args = args - self.alias = alias - self.source = source - - @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 report_invalid_syntax(self, message, level='ERROR'): - from robot.output import LOGGER - LOGGER.write("Error in file '%s': %s" - % (self.source or '', message), level) - - -class Imports(ItemList): - - def __init__(self, source, imports=None): - ItemList.__init__(self, Import, {'source': source}, items=imports) - - def library(self, name, args=(), alias=None): - self.create('Library', name, args, alias) - - def resource(self, path): - self.create('Resource', path) - - def variables(self, path, args=()): - self.create('Variables', path, args) diff --git a/src/robot/model/itemlist.py b/src/robot/model/itemlist.py index 16713ecfed9..ffbb9a189bd 100644 --- a/src/robot/model/itemlist.py +++ b/src/robot/model/itemlist.py @@ -13,86 +13,226 @@ # See the License for the specific language governing permissions and # limitations under the License. -from robot.utils import py2to3, unicode +from functools import total_ordering +from typing import ( + Any, Iterable, Iterator, MutableSequence, overload, Type, TYPE_CHECKING, TypeVar +) +from robot.utils import copy_signature, KnownAtRuntime -@py2to3 -class ItemList(object): - __slots__ = ['_item_class', '_common_attrs', '_items'] +from .modelobject import DataDict, full_name, ModelObject - def __init__(self, item_class, common_attrs=None, items=None): +if TYPE_CHECKING: + from .visitor import SuiteVisitor + + +T = TypeVar("T") +Self = TypeVar("Self", bound="ItemList") + + +@total_ordering +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 + 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 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. + """ + + # TypeVar T needs to be applied to a variable to be compatible with @copy_signature + item_type: Type[T] = KnownAtRuntime + __slots__ = ("_item_class", "_common_attrs", "_items") + + def __init__( + self, + item_class: Type[T], + common_attrs: "dict[str, Any] | None" = None, + items: "Iterable[T | DataDict]" = (), + ): 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): + @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)) - def append(self, item): - self._check_type_and_set_attrs(item) - self._items += (item,) + 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 | DataDict") -> T: + if not isinstance(item, self._item_class): + if isinstance(item, dict): + item = self._item_from_dict(item) + else: + raise TypeError( + f"Only '{self._type_name(self._item_class)}' objects accepted, " + f"got '{self._type_name(item)}'." + ) + if self._common_attrs: + for attr, value in self._common_attrs.items(): + setattr(item, attr, value) 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): - raise TypeError("Only %s objects accepted, got %s." - % (self._item_class.__name__, - item.__class__.__name__)) - for attr in common_attrs: - setattr(item, attr, common_attrs[attr]) - return items - - def extend(self, items): - self._items += self._check_type_and_set_attrs(*items) - - def insert(self, index, item): - self._check_type_and_set_attrs(item) - items = list(self._items) - items.insert(index, item) - self._items = tuple(items) - - def pop(self, *index): - items = list(self._items) - result = items.pop(*index) - self._items = tuple(items) - return result - - def index(self, item, *start_and_end): + def _type_name(self, item: "type | object") -> str: + typ = item if isinstance(item, type) else type(item) + return full_name(typ) if issubclass(typ, ModelObject) else typ.__name__ + + 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 | DataDict]"): + self._items.extend(self._check_type_and_set_attrs(i) for i in items) + + def insert(self, index: int, item: "T | DataDict"): + item = self._check_type_and_set_attrs(item) + self._items.insert(index, item) + + def index(self, item: T, *start_and_end) -> int: return self._items.index(item, *start_and_end) def clear(self): - self._items = () + self._items = [] - def visit(self, visitor): - for item in self._items: - item.visit(visitor) + def visit(self, visitor: "SuiteVisitor"): + for item in self: + item.visit(visitor) # type: ignore - def __iter__(self): - return iter(self._items) + def __iter__(self) -> Iterator[T]: + index = 0 + while index < len(self._items): + yield self._items[index] + index += 1 - def __getitem__(self, index): - if not isinstance(index, slice): - return self._items[index] - items = self.__class__(self._item_class) - items._common_attrs = self._common_attrs - items.extend(self._items[index]) - return items + @overload + def __getitem__(self, index: int, /) -> T: ... - def __setitem__(self, index, item): + @overload + def __getitem__(self: Self, index: slice, /) -> Self: ... + + def __getitem__(self: Self, index: "int | slice", /) -> "T | Self": + if isinstance(index, slice): + return self._create_new_from(self._items[index]) + return self._items[index] + + 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) + new._common_attrs = self._common_attrs + new.extend(items) + return new + + @overload + def __setitem__(self, index: int, item: "T | DataDict", /): ... + + @overload + def __setitem__(self, index: slice, items: "Iterable[T | DataDict]", /): ... + + def __setitem__( + self, index: "int | slice", item: "T | DataDict | Iterable[T | DataDict]", / + ): if isinstance(index, slice): - self._check_type_and_set_attrs(*item) + self._items[index] = [self._check_type_and_set_attrs(i) for i in item] else: - self._check_type_and_set_attrs(item) - items = list(self._items) - items[index] = item - self._items = tuple(items) + self._items[index] = self._check_type_and_set_attrs(item) + + def __delitem__(self, index: "int | slice", /): + del self._items[index] + + def __contains__(self, item: Any, /) -> bool: + return item in self._items - def __len__(self): + def __len__(self) -> int: return len(self._items) - def __unicode__(self): - return u'[%s]' % ', '.join(unicode(item) for item in self) + def __str__(self) -> str: + return str(list(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: T) -> int: + return self._items.count(item) + + def sort(self, **config): + self._items.sort(**config) + + def reverse(self): + self._items.reverse() + + 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: object) -> bool: + return ( + isinstance(other, ItemList) + and self._is_compatible(other) + and self._items == other._items + ) + + 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: "ItemList[T]") -> bool: + if not isinstance(other, ItemList): + raise TypeError(f"Cannot order 'ItemList' and '{self._type_name(other)}'.") + if not self._is_compatible(other): + raise TypeError("Cannot order incompatible 'ItemList' objects.") + return self._items < other._items + + def __add__(self: Self, other: "ItemList[T]") -> Self: + if not isinstance(other, ItemList): + raise TypeError(f"Cannot add 'ItemList' and '{self._type_name(other)}'.") + if not self._is_compatible(other): + raise TypeError("Cannot add incompatible 'ItemList' objects.") + return self._create_new_from(self._items + other._items) + + def __iadd__(self: Self, other: Iterable[T]) -> Self: + if isinstance(other, ItemList) and not self._is_compatible(other): + raise TypeError("Cannot add incompatible 'ItemList' objects.") + self.extend(other) + return self + + def __mul__(self: Self, count: int) -> Self: + return self._create_new_from(self._items * count) + + def __imul__(self: Self, count: int) -> Self: + self._items *= count + return self + + def __rmul__(self: Self, count: int) -> Self: + return self * count + + def to_dicts(self) -> "list[DataDict]": + """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] # type: ignore diff --git a/src/robot/model/keyword.py b/src/robot/model/keyword.py index fc043ea86f7..802797e197c 100644 --- a/src/robot/model/keyword.py +++ b/src/robot/model/keyword.py @@ -13,170 +13,62 @@ # See the License for the specific language governing permissions and # limitations under the License. -from itertools import chain -from operator import attrgetter +from typing import Sequence, TYPE_CHECKING -from robot.utils import setter +from .body import Body, BodyItem, BodyItemParent +from .modelobject import DataDict -from .itemlist import ItemList -from .message import Message, Messages -from .modelobject import ModelObject -from .tags import Tags +if TYPE_CHECKING: + from .visitor import SuiteVisitor -class Keyword(ModelObject): +@Body.register +class Keyword(BodyItem): """Base model for a single keyword. Extended by :class:`robot.running.model.Keyword` and :class:`robot.result.model.Keyword`. """ - __slots__ = ['_name', 'doc', 'args', 'assign', 'timeout', 'type', - '_sort_key', '_next_child_sort_key'] - KEYWORD_TYPE = 'kw' #: Normal keyword :attr:`type`. - SETUP_TYPE = 'setup' #: Setup :attr:`type`. - TEARDOWN_TYPE = 'teardown' #: Teardown :attr:`type`. - FOR_LOOP_TYPE = 'for' #: For loop :attr:`type`. - FOR_ITEM_TYPE = 'foritem' #: Single for loop iteration :attr:`type`. - keyword_class = None #: Internal usage only. - message_class = Message #: Internal usage only. - def __init__(self, name='', doc='', args=(), assign=(), tags=(), - timeout=None, type=KEYWORD_TYPE): - self.parent = None - self._name = name - self.doc = doc - self.args = args #: Keyword arguments as a list of strings. - self.assign = assign #: Assigned variables as a list of strings. - self.tags = tags - self.timeout = timeout - #: Keyword type as a string. The value is either :attr:`KEYWORD_TYPE`, - #: :attr:`SETUP_TYPE`, :attr:`TEARDOWN_TYPE`, :attr:`FOR_LOOP_TYPE` or - #: :attr:`FOR_ITEM_TYPE` constant defined on the class level. + repr_args = ("name", "args", "assign") + __slots__ = ("name", "args", "assign", "type") + + def __init__( + self, + name: "str | None" = "", + args: Sequence[str] = (), + assign: Sequence[str] = (), + type: str = BodyItem.KEYWORD, + parent: BodyItemParent = None, + ): + self.name = name + self.args = tuple(args) + self.assign = tuple(assign) self.type = type - self.messages = None - self.keywords = None - self._sort_key = -1 - self._next_child_sort_key = 0 + self.parent = parent @property - def name(self): - return self._name + def id(self) -> "str | None": + if not self: + return None + return super().id - @name.setter - def name(self, name): - self._name = name - - @setter - def parent(self, parent): - """Parent test suite, test case or keyword.""" - if parent and parent is not self.parent: - self._sort_key = getattr(parent, '_child_sort_key', -1) - return parent - - @property - def _child_sort_key(self): - self._next_child_sort_key += 1 - return self._next_child_sort_key - - @setter - def tags(self, tags): - """Keyword tags as a :class:`~.model.tags.Tags` object.""" - return Tags(tags) - - @setter - def keywords(self, keywords): - """Child keywords as a :class:`~.Keywords` object.""" - return Keywords(self.keyword_class or self.__class__, self, keywords) - - @setter - def messages(self, messages): - """Messages as a :class:`~.model.message.Messages` object.""" - return Messages(self.message_class, self, messages) - - @property - def children(self): - """Child :attr:`keywords` and :attr:`messages` in creation order.""" - # It would be cleaner to store keywords/messages in same `children` - # list and turn `keywords` and `messages` to properties that pick items - # from it. That would require bigger changes to the model, though. - return sorted(chain(self.keywords, self.messages), - key=attrgetter('_sort_key')) - - @property - def id(self): - """Keyword id in format like ``s1-t3-k1``. - - See :attr:`TestSuite.id ` for - more information. - """ - if not self.parent: - return 'k1' - return '%s-k%d' % (self.parent.id, self.parent.keywords.index(self)+1) - - def visit(self, visitor): + def visit(self, visitor: "SuiteVisitor"): """:mod:`Visitor interface ` entry-point.""" - visitor.visit_keyword(self) - - -class Keywords(ItemList): - """A list-like object representing keywords in a suite, a test or a keyword. - - Possible setup and teardown keywords are directly available as - :attr:`setup` and :attr:`teardown` attributes. - """ - __slots__ = [] - - def __init__(self, keyword_class=Keyword, parent=None, keywords=None): - ItemList.__init__(self, keyword_class, {'parent': parent}, keywords) - - @property - def setup(self): - """Keyword used as the setup or ``None`` if no setup. - - Can be set to a new setup keyword or ``None`` since RF 3.0.1. - """ - return self[0] if (self and self[0].type == 'setup') else None - - @setup.setter - def setup(self, kw): - if kw is not None and kw.type != 'setup': - raise TypeError("Setup keyword type must be 'setup', " - "got '%s'." % kw.type) - if self.setup is not None: - self.pop(0) - if kw is not None: - self.insert(0, kw) - - @property - def teardown(self): - """Keyword used as the teardown or ``None`` if no teardown. - - Can be set to a new teardown keyword or ``None`` since RF 3.0.1. - """ - return self[-1] if (self and self[-1].type == 'teardown') else None - - @teardown.setter - def teardown(self, kw): - if kw is not None and kw.type != 'teardown': - raise TypeError("Teardown keyword type must be 'teardown', " - "got '%s'." % kw.type) - if self.teardown is not None: - self.pop() - if kw is not None: - self.append(kw) - - @property - def all(self): - """Iterates over all keywords, including setup and teardown.""" - return self - - @property - def normal(self): - """Iterates over normal keywords, omitting setup and teardown.""" - kws = [kw for kw in self if kw.type not in ('setup', 'teardown')] - return Keywords(self._item_class, self._common_attrs['parent'], kws) - - def __setitem__(self, index, item): - old = self[index] - ItemList.__setitem__(self, index, item) - self[index]._sort_key = old._sort_key + if self: + visitor.visit_keyword(self) + + def __bool__(self) -> bool: + return self.name is not None + + def __str__(self) -> str: + parts = (*self.assign, self.name, *self.args) + return " ".join(str(p) for p in parts) + + def to_dict(self) -> DataDict: + data: DataDict = {"name": self.name} + if self.args: + data["args"] = self.args + if self.assign: + data["assign"] = self.assign + return data diff --git a/src/robot/model/message.py b/src/robot/model/message.py index d8be984cd34..503dcdb62b1 100644 --- a/src/robot/model/message.py +++ b/src/robot/model/message.py @@ -13,63 +13,78 @@ # See the License for the specific language governing permissions and # limitations under the License. -from robot.utils import html_escape, py2to3, setter +from datetime import datetime +from typing import Literal -from .itemlist import ItemList -from .modelobject import ModelObject +from robot.utils import html_escape, setter +from .body import BodyItem -@py2to3 -class Message(ModelObject): +MessageLevel = Literal["TRACE", "DEBUG", "INFO", "WARN", "ERROR", "FAIL", "SKIP"] + + +class Message(BodyItem): """A message created during the test execution. Can be a log message triggered by a keyword, or a warning or an error that occurred during parsing or test execution. + + ``message`` is ``None`` only if explicitly set by listeners to remove messages + altogether. When working with results after execution, ``mesasage`` is always + a string. """ - __slots__ = ['message', 'level', 'html', 'timestamp', '_sort_key'] - def __init__(self, message='', level='INFO', html=False, timestamp=None, - parent=None): - #: The message content as a string. + type = BodyItem.MESSAGE + repr_args = ("message", "level") + __slots__ = ("message", "level", "html", "_timestamp") + + def __init__( + self, + message: "str | None" = "", + 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``, or ``FAIL``. The latest one is 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 - self._sort_key = -1 - #: The object this message was triggered by. self.parent = parent @setter - def parent(self, parent): - if parent and parent is not getattr(self, 'parent', None): - self._sort_key = getattr(parent, '_child_sort_key', -1) - return parent + 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.""" return self.message if self.html else html_escape(self.message) + @property + def id(self): + if not self.parent: + return "m1" + if hasattr(self.parent, "messages"): + messages = self.parent.messages + else: + messages = self.parent.body.filter(messages=True) + 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 ` entry-point.""" visitor.visit_message(self) - def __unicode__(self): - return self.message - - -class Messages(ItemList): - __slots__ = [] + def to_dict(self): + data = {"message": self.message, "level": self.level} + if self.html: + data["html"] = True + if self.timestamp: + data["timestamp"] = self.timestamp.isoformat() + return data - def __init__(self, message_class=Message, parent=None, messages=None): - ItemList.__init__(self, message_class, {'parent': parent}, messages) - - def __setitem__(self, index, item): - old = self[index] - ItemList.__setitem__(self, index, item) - self[index]._sort_key = old._sort_key + def __str__(self): + return self.message diff --git a/src/robot/model/metadata.py b/src/robot/model/metadata.py index b7e28d1ef87..e2355afcb92 100644 --- a/src/robot/model/metadata.py +++ b/src/robot/model/metadata.py @@ -13,21 +13,30 @@ # See the License for the specific language governing permissions and # limitations under the License. -from robot.utils import is_string, NormalizedDict, py2to3, unic +from collections.abc import Iterable, Mapping +from robot.utils import NormalizedDict -@py2to3 -class Metadata(NormalizedDict): - def __init__(self, initial=None): - NormalizedDict.__init__(self, initial, ignore='_') +class Metadata(NormalizedDict[str]): + """Free suite or test metadata as a mapping. - def __setitem__(self, key, value): - if not is_string(key): - key = unic(key) - if not is_string(value): - value = unic(value) - NormalizedDict.__setitem__(self, key, value) + Keys are case, space, and underscore insensitive. + """ - def __unicode__(self): - return u'{%s}' % ', '.join('%s: %s' % (k, self[k]) for k in self) + def __init__( + self, + initial: "Mapping[str, str] | Iterable[tuple[str, str]] | None" = None, + ): + super().__init__(initial, ignore="_") + + def __setitem__(self, key: str, value: str): + if not isinstance(key, str): + key = str(key) + if not isinstance(value, str): + value = str(value) + super().__setitem__(key, value) + + def __str__(self): + items = ", ".join(f"{key}: {self[key]}" for key in self) + return f"{{{items}}}" diff --git a/src/robot/model/modelobject.py b/src/robot/model/modelobject.py index f6a3ceb1d48..fb49701266f 100644 --- a/src/robot/model/modelobject.py +++ b/src/robot/model/modelobject.py @@ -14,72 +14,235 @@ # limitations under the License. import copy +from pathlib import Path +from typing import Any, Dict, Final, overload, TextIO, Type, TypeVar + +from robot.errors import DataError +from robot.utils import JsonDumper, JsonLoader, SetterAwareType, type_name + +T = TypeVar("T", bound="ModelObject") +DataDict = Dict[str, Any] + + +class ModelObject(metaclass=SetterAwareType): + SUITE: Final = "SUITE" + TEST: Final = "TEST" + TASK: Final = TEST + KEYWORD: Final = "KEYWORD" + SETUP: Final = "SETUP" + TEARDOWN: Final = "TEARDOWN" + FOR: Final = "FOR" + ITERATION: Final = "ITERATION" + IF_ELSE_ROOT: Final = "IF/ELSE ROOT" + IF: Final = "IF" + ELSE_IF: Final = "ELSE IF" + ELSE: Final = "ELSE" + TRY_EXCEPT_ROOT: Final = "TRY/EXCEPT ROOT" + TRY: Final = "TRY" + EXCEPT: Final = "EXCEPT" + FINALLY: Final = "FINALLY" + WHILE: Final = "WHILE" + GROUP: Final = "GROUP" + VAR: Final = "VAR" + RETURN: Final = "RETURN" + CONTINUE: Final = "CONTINUE" + BREAK: Final = "BREAK" + ERROR: Final = "ERROR" + MESSAGE: Final = "MESSAGE" + KEYWORD_TYPES: Final = (KEYWORD, SETUP, TEARDOWN) + type: str + repr_args = () + __slots__ = () + + @classmethod + 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. + + With ``robot.running`` model objects new in Robot Framework 6.1, + with ``robot.result`` new in Robot Framework 7.0. + """ + try: + return cls().config(**data) + except (AttributeError, TypeError) as err: + raise DataError( + f"Creating '{full_name(cls)}' object from dictionary failed: {err}" + ) -from robot.utils import SetterAwareType, py2to3, with_metaclass - + @classmethod + def from_json(cls: Type[T], source: "str | bytes | TextIO | Path") -> T: + """Create this object based on JSON data. -@py2to3 -class ModelObject(with_metaclass(SetterAwareType, object)): - __slots__ = [] + The data is given as the ``source`` parameter. It can be: - def copy(self, **attributes): - """Return shallow copy of this object. + - a string (or bytes) containing the data directly, + - an open file object where to read the data from, or + - a path (``pathlib.Path`` or string) to a UTF-8 encoded file to read. - :param attributes: Attributes to be set for the returned copy - automatically. For example, ``test.copy(name='New name')``. + The JSON data is first converted to a Python dictionary and the object + created using the :meth:`from_dict` method. - 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. + 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. - New in Robot Framework 3.0.1. + With ``robot.running`` model objects new in Robot Framework 6.1, + with ``robot.result`` new in Robot Framework 7.0. """ - copied = copy.copy(self) - for name in attributes: - setattr(copied, name, attributes[name]) - return copied + try: + data = JsonLoader().load(source) + except (TypeError, ValueError) as err: + raise DataError(f"Loading JSON data failed: {err}") + return cls.from_dict(data) - def deepcopy(self, **attributes): - """Return deep copy of this object. + def to_dict(self) -> DataDict: + """Serialize this object into a dictionary. - :param attributes: Attributes to be set for the returned copy - automatically. For example, ``test.deepcopy(name='New name')``. + The object can be later restored by using the :meth:`from_dict` method. - 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. + With ``robot.running`` model objects new in Robot Framework 6.1, + with ``robot.result`` new in Robot Framework 7.0. + """ + raise NotImplementedError + + @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: "TextIO | Path | str", + *, + ensure_ascii: bool = False, + indent: int = 0, + separators: "tuple[str, str]" = (",", ":"), + ) -> None: ... + + def to_json( + self, + file: "None | TextIO | Path | str" = None, + *, + ensure_ascii: bool = False, + indent: int = 0, + separators: "tuple[str, str]" = (",", ":"), + ) -> "str | None": + """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 to, or + - 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 + the defaults differ from what ``json`` uses. + + With ``robot.running`` model objects new in Robot Framework 6.1, + with ``robot.result`` new in Robot Framework 7.0. + + __ https://docs.python.org/3/library/json.html + """ + return JsonDumper( + ensure_ascii=ensure_ascii, + indent=indent, + separators=separators, + ).dump(self.to_dict(), file) - New in Robot Framework 3.0.1. + def config(self: T, **attributes) -> T: + """Configure model object with given attributes. + + ``obj.config(name='Example', doc='Something')`` is equivalent to setting + ``obj.name = 'Example'`` and ``obj.doc = 'Something'``. + + New in Robot Framework 4.0. + """ + for name, value in attributes.items(): + try: + orig = getattr(self, name) + except AttributeError: + raise AttributeError( + f"'{full_name(self)}' object does not have 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, value) + except AttributeError as err: + # Ignore error setting attribute if the object already has it. + # 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: T, **attributes) -> T: + """Return a shallow copy of this object. + + :param attributes: Attributes to be set to the returned copy. + For example, ``obj.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. + + __ 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.copy(self).config(**attributes) - def __unicode__(self): - return self.name + def deepcopy(self: T, **attributes) -> T: + """Return a deep copy of this object. - def __repr__(self): - return repr(str(self)) + :param attributes: Attributes to be set to the returned copy. + For example, ``obj.deepcopy(name='New name')``. - def __setstate__(self, state): - """Customize attribute updating when using the `copy` module. + See also :meth:`copy`. The difference between ``deepcopy`` and + ``copy`` is the same as with the methods having same names in + the copy__ module. - This may not be needed in the future if we fix the mess we have with - different timeout types. + __ https://docs.python.org/3/library/copy.html """ - # We have __slots__ so state is always a two-tuple. - # Refer to: https://www.python.org/dev/peps/pep-0307 - dictstate, slotstate = state - if dictstate is not None: - self.__dict__.update(dictstate) - for name in slotstate: - # If attribute is defined in __slots__ and overridden by @setter - # (this is the case at least with 'timeout' of 'running.TestCase') - # we must not set the "real" attribute value because that would run - # the setter method and that would recreate the object when it - # should not. With timeouts recreating object using the object - # itself would also totally fail. - setter_name = '_setter__' + name - if setter_name not in slotstate: - setattr(self, name, slotstate[name]) + return copy.deepcopy(self).config(**attributes) + + def __repr__(self) -> str: + args = [] + for name in self.repr_args: + value = getattr(self, name) + if self._include_in_repr(name, value): + value = self._repr_format(name, value) + args.append(f"{name}={value}") + return f"{full_name(self)}({', '.join(args)})" + + def _include_in_repr(self, name: str, value: Any) -> bool: + return True + + def _repr_format(self, name: str, value: Any) -> str: + return repr(value) + + +def full_name(obj_or_cls): + cls = obj_or_cls if isinstance(obj_or_cls, type) else type(obj_or_cls) + parts = [*cls.__module__.split("."), cls.__name__] + if len(parts) > 1 and parts[0] == "robot": + parts[2:-1] = [] + return ".".join(parts) diff --git a/src/robot/model/modifier.py b/src/robot/model/modifier.py index 9ee8bf5e57e..de17f4c5fb0 100644 --- a/src/robot/model/modifier.py +++ b/src/robot/model/modifier.py @@ -14,8 +14,9 @@ # 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, split_args_from_name_or_path, type_name +) from .visitor import SuiteVisitor @@ -25,28 +26,31 @@ 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)) - if not (suite.test_count or self._empty_suite_ok): - raise DataError("Suite '%s' contains no tests after model " - "modifiers." % suite.name) + self._log_error( + f"Executing model modifier '{type_name(visitor)}' " + f"failed: {message}\n{details}" + ) + if not (suite.has_tests or self._empty_suite_ok): + raise DataError( + f"Suite '{suite.name}' contains no tests after model modifiers." + ) - def _yield_visitors(self, visitors): - importer = Importer('model modifier') + 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 isinstance(visitor, str): + 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 diff --git a/src/robot/model/namepatterns.py b/src/robot/model/namepatterns.py index ae086893880..f3a8b49e299 100644 --- a/src/robot/model/namepatterns.py +++ b/src/robot/model/namepatterns.py @@ -13,42 +13,23 @@ # See the License for the specific language governing permissions and # limitations under the License. -from robot.utils import MultiMatcher, py2to3 +from typing import Iterable, Iterator, Sequence +from robot.utils import MultiMatcher -@py2to3 -class _NamePatterns(object): - def __init__(self, patterns=None): - self._matcher = MultiMatcher(patterns, ignore='_') +class NamePatterns(Iterable[str]): - def match(self, name, longname=None): - return self._match(name) or longname and self._match_longname(longname) + def __init__(self, patterns: Sequence[str] = (), ignore: Sequence[str] = "_"): + self.matcher = MultiMatcher(patterns, ignore) - def _match(self, name): - return self._matcher.match(name) + def match(self, name: str, full_name: "str | None" = None) -> bool: + match = self.matcher.match + return bool(match(name) or full_name and match(full_name)) - def _match_longname(self, name): - raise NotImplementedError + def __iter__(self) -> Iterator[str]: + for matcher in self.matcher: + yield matcher.pattern - def __nonzero__(self): - return bool(self._matcher) - - def __iter__(self): - return iter(self._matcher) - - -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) + def __len__(self) -> int: + return len(self.matcher) diff --git a/src/robot/model/statistics.py b/src/robot/model/statistics.py index 7d20d3f4929..6c6856a711d 100644 --- a/src/robot/model/statistics.py +++ b/src/robot/model/statistics.py @@ -13,33 +13,50 @@ # See the License for the specific language governing permissions and # limitations under the License. -from .totalstatistics import TotalStatisticsBuilder -from .suitestatistics import SuiteStatisticsBuilder -from .tagstatistics import TagStatisticsBuilder +from .suitestatistics import SuiteStatistics, SuiteStatisticsBuilder +from .tagstatistics import TagStatistics, TagStatisticsBuilder +from .totalstatistics import TotalStatistics, TotalStatisticsBuilder from .visitor import SuiteVisitor -class Statistics(object): +class Statistics: """Container for total, suite and tag statistics. Accepted parameters have the same semantics as the matching command line options. """ - def __init__(self, suite, suite_stat_level=-1, tag_stat_include=None, - tag_stat_exclude=None, tag_stat_combine=None, tag_doc=None, - tag_stat_link=None, rpa=False): + + def __init__( + self, + suite, + suite_stat_level=-1, + tag_stat_include=None, + tag_stat_exclude=None, + tag_stat_combine=None, + tag_doc=None, + tag_stat_link=None, + rpa=False, + ): total_builder = TotalStatisticsBuilder(rpa=rpa) suite_builder = SuiteStatisticsBuilder(suite_stat_level) - tag_builder = TagStatisticsBuilder(suite.criticality, tag_stat_include, - tag_stat_exclude, tag_stat_combine, - tag_doc, tag_stat_link) + tag_builder = TagStatisticsBuilder( + tag_stat_include, + tag_stat_exclude, + tag_stat_combine, + tag_doc, + tag_stat_link, + ) suite.visit(StatisticsBuilder(total_builder, suite_builder, tag_builder)) - #: Instance of :class:`~robot.model.totalstatistics.TotalStatistics`. - self.total = total_builder.stats - #: Instance of :class:`~robot.model.suitestatistics.SuiteStatistics`. - self.suite = suite_builder.stats - #: Instance of :class:`~robot.model.tagstatistics.TagStatistics`. - self.tags = tag_builder.stats + self.total: TotalStatistics = total_builder.stats + self.suite: SuiteStatistics = suite_builder.stats + self.tags: TagStatistics = tag_builder.stats + + def to_dict(self): + return { + "total": self.total.stat.get_attributes(include_label=True), + "suites": [s.get_attributes(include_label=True) for s in self.suite], + "tags": [t.get_attributes(include_label=True) for t in self.tags], + } def visit(self, visitor): visitor.visit_statistics(self) diff --git a/src/robot/model/stats.py b/src/robot/model/stats.py index 7a4fc9200a0..23fb9f35eb5 100644 --- a/src/robot/model/stats.py +++ b/src/robot/model/stats.py @@ -13,61 +13,67 @@ # See the License for the specific language governing permissions and # limitations under the License. -from robot.utils import (Sortable, elapsed_time_to_string, html_escape, - is_string, normalize, py2to3, unicode) +from datetime import timedelta + +from robot.utils import elapsed_time_to_string, html_escape, normalize, Sortable from .tags import TagPattern -@py2to3 class Stat(Sortable): """Generic statistic object used for storing all the statistic values.""" def __init__(self, name): #: Human readable identifier of the object these statistics - #: belong to. Either `All Tests` or `Critical Tests` for + #: belong to. `All Tests` for #: :class:`~robot.model.totalstatistics.TotalStatistics`, #: long name of the suite for #: :class:`~robot.model.suitestatistics.SuiteStatistics` #: 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 milliseconds it took to execute. - self.elapsed = 0 - self._norm_name = normalize(name, ignore='_') - - def get_attributes(self, include_label=False, include_elapsed=False, - exclude_empty=True, values_as_strings=False, - html_escape=False): - attrs = {'pass': self.passed, 'fail': self.failed} - attrs.update(self._get_custom_attrs()) - if include_label: - attrs['label'] = self.name + self.skipped = 0 + self.elapsed = timedelta() + self._norm_name = normalize(name, ignore="_") + + def get_attributes( + self, + include_label=False, + include_elapsed=False, + exclude_empty=True, + values_as_strings=False, + html_escape=False, + ): + attrs = { + **({"label": self.name} if include_label else {}), + **self._get_custom_attrs(), + "pass": self.passed, + "fail": self.failed, + "skip": self.skipped, + } 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)) + attrs = {k: v for k, v in attrs.items() if v not in ("", None)} if values_as_strings: - attrs = dict((k, unicode(v if v is not None else '')) - for k, v in attrs.items()) + attrs = {k: str(v if v is not None else "") for k, v in attrs.items()} if html_escape: - attrs = dict((k, self._html_escape(v)) for k, v in attrs.items()) + attrs = {k: self._html_escape(v) for k, v in attrs.items()} return attrs def _get_custom_attrs(self): return {} def _html_escape(self, item): - return html_escape(item) if is_string(item) else item + return html_escape(item) if isinstance(item, str) else item @property def total(self): - return self.passed + self.failed + return self.passed + self.failed + self.skipped def add_test(self, test): self._update_stats(test) @@ -76,17 +82,19 @@ def add_test(self, test): def _update_stats(self, test): if test.passed: self.passed += 1 + elif test.skipped: + self.skipped += 1 else: self.failed += 1 def _update_elapsed(self, test): - self.elapsed += test.elapsedtime + self.elapsed += test.elapsed_time @property def _sort_key(self): return self._norm_name - def __nonzero__(self): + def __bool__(self): return not self.failed def visit(self, visitor): @@ -95,24 +103,23 @@ def visit(self, visitor): class TotalStat(Stat): """Stores statistic values for a test run.""" - type = 'total' + + type = "total" class SuiteStat(Stat): """Stores statistics values for a single suite.""" - type = 'suite' + + type = "suite" def __init__(self, suite): - Stat.__init__(self, suite.longname) - #: Identifier of the suite, e.g. `s1-s2`. + super().__init__(suite.full_name) self.id = suite.id - #: Number of milliseconds it took to execute this suite, - #: including sub-suites. - self.elapsed = suite.elapsedtime + self.elapsed = suite.elapsed_time self._name = suite.name def _get_custom_attrs(self): - return {'id': self.id, 'name': self._name} + return {"name": self._name, "id": self.id} def _update_elapsed(self, test): pass @@ -120,73 +127,55 @@ def _update_elapsed(self, test): def add_stat(self, other): self.passed += other.passed self.failed += other.failed + self.skipped += other.skipped class TagStat(Stat): """Stores statistic values for a single tag.""" - type = 'tag' - def __init__(self, name, doc='', links=None, critical=False, - non_critical=False, combined=None): - Stat.__init__(self, name) + type = "tag" + + def __init__(self, name, doc="", links=None, combined=None): + 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 #: the second is the link title. An empty list by default. self.links = links or [] - #: ``True`` if tag is considered critical, ``False`` otherwise. - self.critical = critical - #: ``True`` if tag is considered non-critical, ``False`` otherwise. - self.non_critical = non_critical #: Pattern as a string if the tag is combined, ``None`` otherwise. self.combined = combined @property def info(self): """Returns additional information of the tag statistics - are about. Either `critical`, `non-critical`, `combined` or an - empty string. + are about. Either `combined` or an empty string. """ - if self.critical: - return 'critical' - if self.non_critical: - return 'non-critical' if self.combined: - return 'combined' - return '' + return "combined" + return "" def _get_custom_attrs(self): - return {'doc': self.doc, 'links': self._get_links_as_string(), - 'info': self.info, 'combined': self.combined} + return { + "doc": self.doc, + "links": self._get_links_as_string(), + "info": self.info, + "combined": self.combined, + } def _get_links_as_string(self): - return ':::'.join('%s:%s' % (title, url) for url, title in self.links) + return ":::".join(f"{title}:{url}" for url, title in self.links) @property def _sort_key(self): - return (not self.critical, - not self.non_critical, - not self.combined, - self._norm_name) + return (not self.combined, self._norm_name) 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) - - def match(self, tags): - return self.pattern.match(tags) - - -class CriticalTagStat(TagStat): - - def __init__(self, tag_pattern, name=None, critical=True, doc='', - links=None): - TagStat.__init__(self, name or unicode(tag_pattern), doc, links, - critical=critical, non_critical=not critical) - self.pattern = tag_pattern + def __init__(self, pattern, name=None, doc="", links=None): + pattern = TagPattern.from_string(pattern) + super().__init__(name or str(pattern), doc, links, combined=str(pattern)) + self.pattern = pattern def match(self, tags): return self.pattern.match(tags) diff --git a/src/robot/model/suitestatistics.py b/src/robot/model/suitestatistics.py index 64618982c06..92a2829d03e 100644 --- a/src/robot/model/suitestatistics.py +++ b/src/robot/model/suitestatistics.py @@ -13,37 +13,36 @@ # See the License for the specific language governing permissions and # limitations under the License. +from typing import Iterator + from .stats import SuiteStat -class SuiteStatistics(object): +class SuiteStatistics: """Container for suite statistics.""" def __init__(self, suite): - #: Instance of :class:`~robot.model.stats.SuiteStat`. self.stat = SuiteStat(suite) - #: List of :class:`~robot.model.testsuite.TestSuite` objects. - self.suites = [] + self.suites: list[SuiteStatistics] = [] def visit(self, visitor): visitor.visit_suite_statistics(self) - def __iter__(self): + def __iter__(self) -> Iterator[SuiteStat]: yield self.stat for child in self.suites: - for stat in child: - yield stat + yield from child -class SuiteStatisticsBuilder(object): +class SuiteStatisticsBuilder: def __init__(self, suite_stat_level): self._suite_stat_level = suite_stat_level - self._stats_stack = [] - self.stats = None + self._stats_stack: list[SuiteStatistics] = [] + self.stats: SuiteStatistics | None = None @property - def current(self): + def current(self) -> "SuiteStatistics | None": return self._stats_stack[-1] if self._stats_stack else None def start_suite(self, suite): diff --git a/src/robot/model/tags.py b/src/robot/model/tags.py index 98eaf91241c..266b4e1d6ed 100644 --- a/src/robot/model/tags.py +++ b/src/robot/model/tags.py @@ -12,169 +12,321 @@ # 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 abc import ABC, abstractmethod +from typing import Iterable, Iterator, overload, Sequence -from robot.utils import (Matcher, NormalizedDict, is_string, py2to3, setter, - unic) +from robot.utils import Matcher, normalize, NormalizedDict -@py2to3 -class Tags(object): +class Tags(Sequence[str]): + __slots__ = ("_tags", "_reserved") - def __init__(self, tags=None): - self._tags = tags + def __init__(self, tags: Iterable[str] = ()): + if isinstance(tags, Tags): + self._tags, self._reserved = tags._tags, tags._reserved + else: + self._tags, self._reserved = self._init_tags(tags) - @setter - def _tags(self, tags): + def robot(self, name: str) -> bool: + """Check do tags contain a reserved tag in format `robot:`. + + `tags.robot('')` is same as `'robot:' in tags`, but using + this method is considerably faster. This method requires the ``name`` + to be in lowercase, though, while checking with `in` is case-insensitive. + """ + return name in self._reserved + + def _init_tags(self, tags) -> "tuple[tuple[str, ...], tuple[str, ...]]": if not tags: - return () - if is_string(tags): + return (), () + if isinstance(tags, str): tags = (tags,) return self._normalize(tags) def _normalize(self, tags): - normalized = NormalizedDict(((unic(t), 1) for t in tags), ignore='_') - for removed in '', 'NONE': - if removed in normalized: - normalized.pop(removed) - return tuple(normalized) - - def add(self, tags): - self._tags = tuple(self) + tuple(Tags(tags)) - - def remove(self, tags): - tags = TagPatterns(tags) - self._tags = [t for t in self if not tags.match(t)] - - def match(self, tags): - return TagPatterns(tags).match(self) - - def __contains__(self, tags): + nd = NormalizedDict.fromkeys([str(t) for t in tags], ignore="_") + # Inspecting already normalized names is performance optimization. + normalized = nd.normalized_keys + if "" in normalized: + del nd[""] + if "none" in normalized: + del nd["none"] + reserved = [n[6:] for n in normalized if n[:6] == "robot:"] + return tuple(nd), tuple(reserved) + + def add(self, tags: Iterable[str], remove_negated: bool = False): + tags = Tags(tags) + if remove_negated: + remove = [t[1:] for t in tags if t[0] == "-"] + if remove: + self._remove(remove, "removing tags using '-tag' syntax") + tags = [t for t in tags if t[0] != "-"] + self.__init__([*self, *tags]) + + def remove(self, tags: Iterable[str]): + self._remove(tags, "removing tags") + + def _remove(self, tags: Iterable[str], usage): + match = TagPatterns(tags, usage).match + self.__init__([t for t in self if not match(t)]) + + def match(self, tags: Iterable[str]) -> bool: + return TagPatterns(tags, "matching tags").match(self) + + def __contains__(self, tags: Iterable[str]) -> 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 __unicode__(self): - return u'[%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 __getitem__(self, index): - item = self._tags[index] - return item if not isinstance(index, slice) else Tags(item) + def __eq__(self, other: object) -> 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] + other_normalized = [normalize(tag, ignore="_") for tag in other] + return sorted(self_normalized) == sorted(other_normalized) - def __add__(self, other): - return Tags(tuple(self) + tuple(Tags(other))) + @overload + def __getitem__(self, index: int) -> str: ... + @overload + def __getitem__(self, index: slice) -> "Tags": ... -@py2to3 -class TagPatterns(object): + def __getitem__(self, index: "int | slice") -> "str | Tags": + if isinstance(index, slice): + return Tags(self._tags[index]) + return self._tags[index] - def __init__(self, patterns): - self._patterns = tuple(TagPattern(p) for p in Tags(patterns)) + def __add__(self, other: Iterable[str]) -> "Tags": + return Tags([*self, *Tags(other)]) - def match(self, tags): - tags = tags if isinstance(tags, Tags) else Tags(tags) - return any(p.match(tags) for p in self._patterns) - def __contains__(self, tag): - return self.match(tag) +class TagPatterns(Sequence["TagPattern"]): + """Represents a list of tag patterns like ``tag``, ``t*`` or ``x OR y``. - def __len__(self): - return len(self._patterns) + The ``usage`` parameter should only be used internally by Robot Framework + itself. See ``TagPattern.from_string`` for more details if needed. + """ - def __iter__(self): - return iter(self._patterns) + def __init__(self, patterns: Iterable[str] = (), usage: "str | None" = None): + self._patterns = tuple(TagPattern.from_string(p, usage) for p in Tags(patterns)) - def __getitem__(self, index): - return self._patterns[index] - - def __unicode__(self): - return u'[%s]' % u', '.join(pattern.__unicode__() for pattern in self) + @property + def is_constant(self): + return all(p.is_constant for p in self._patterns) + def match(self, tags: Iterable[str]) -> bool: + if not self._patterns: + return False + tags = normalize_tags(tags) + return any(p.match(tags) for p in self._patterns) -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) + def __contains__(self, tag: str) -> bool: + return self.match(tag) + def __len__(self) -> int: + return len(self._patterns) -@py2to3 -class SingleTagPattern(object): + def __iter__(self) -> Iterator["TagPattern"]: + return iter(self._patterns) - def __init__(self, pattern): - self._matcher = Matcher(pattern, ignore='_') + def __getitem__(self, index: int) -> "TagPattern": + return self._patterns[index] - def match(self, tags): + def __str__(self) -> str: + patterns = ", ".join(str(pattern) for pattern in self) + return f"[{patterns}]" + + +class TagPattern(ABC): + is_constant = False + + @classmethod + def from_string(cls, pattern: str, usage: "str | None" = None) -> "TagPattern": + """Create ``TagPattern`` object from string like ``tag``, ``t*`` or ``x OR y``. + + ``usage`` is used in a deprecation warning if a pattern uses Boolean operators + in deprecated format like ``XORY``. It should only be used internally by + Robot Framework itself for giving context where the pattern was used. When + a usage is given, warning is logged using Robot's own global ``LOGGER`` and + otherwise the warning is logged using Python's ``logging`` module. + """ + if "NOT" in pattern: + must_match, *must_not_match = cls._split(pattern, "NOT", usage) + return NotTagPattern(must_match, must_not_match) + if "OR" in pattern: + return OrTagPattern(cls._split(pattern, "OR", usage)) + if "&" in pattern: + cls._deprecated( + pattern, + "Boolean operator '&' is deprecated, use 'AND' instead.", + usage, + ) + pattern = pattern.replace("&", " AND ") + if "AND" in pattern: + return AndTagPattern(cls._split(pattern, "AND", usage)) + return SingleTagPattern(pattern) + + @classmethod + def _split(cls, pattern, operator, usage=None): + tokens = f" {pattern} ".split(operator) + for token in tokens: + if not cls._validate(token, operator): + cls._deprecated( + pattern, + f"'{operator}' is currently considered to be a Boolean operator, " + f"but in the future operators must be surrounded with spaces or " + f"tag names must be lower case.", + usage, + ) + break + return tokens + + @classmethod + def _validate(cls, token, operator): + if not token or token[0].isspace() and token[-1].isspace(): + return True + remaining_operators = {"NOT": ("OR", "AND"), "OR": ("AND",), "AND": ()} + for exclude in remaining_operators[operator]: + if exclude in token: + token = token.replace(exclude, "") + return token.lower() == token + + @classmethod + def _deprecated(cls, pattern, message, usage=None): + message = ( + f"The behavior of tag pattern '{pattern}' will change in " + f"Robot Framework 8.0: {message}" + ) + if usage: + from robot.output import LOGGER + + LOGGER.warn(f"Problems when {usage}: {message}") + else: + import warnings + + warnings.warn(message) + + @abstractmethod + def match(self, tags: Iterable[str]) -> bool: + raise NotImplementedError + + @abstractmethod + def __iter__(self) -> Iterator["TagPattern"]: + raise NotImplementedError + + def __getitem__(self, index: int) -> "TagPattern": + return list(self)[index] + + @abstractmethod + def __str__(self) -> str: + raise NotImplementedError + + +class SingleTagPattern(TagPattern): + + def __init__(self, pattern: str): + # Normalization is handled here, not in Matcher, for performance reasons. + # With this configuration all normalizations in Matcher are no-ops. + self._matcher = Matcher( + normalize(pattern, ignore="_"), + caseless=False, + spaceless=False, + ) + # Preserve original patter mostly for string representation purposes. + self._pattern = pattern.strip() + + @property + def is_constant(self): + pattern = self._pattern + return not ("*" in pattern or "?" in pattern or "[" in pattern) + + def match(self, tags: Iterable[str]) -> bool: + tags = normalize_tags(tags) return self._matcher.match_any(tags) - def __iter__(self): + def __iter__(self) -> Iterator["TagPattern"]: yield self - def __unicode__(self): - return self._matcher.pattern + def __str__(self) -> str: + return self._pattern - def __nonzero__(self): + def __bool__(self) -> bool: return bool(self._matcher) -@py2to3 -class AndTagPattern(object): +class AndTagPattern(TagPattern): - def __init__(self, patterns): - self._patterns = tuple(TagPattern(p) for p in patterns) + def __init__(self, patterns: Iterable[str]): + self._patterns = tuple(TagPattern.from_string(p) for p in patterns) - def match(self, tags): + def match(self, tags: Iterable[str]) -> bool: + tags = normalize_tags(tags) return all(p.match(tags) for p in self._patterns) - def __iter__(self): + def __iter__(self) -> Iterator["TagPattern"]: return iter(self._patterns) - def __unicode__(self): - return ' AND '.join(pattern.__unicode__() for pattern in self) + def __str__(self) -> str: + return " AND ".join(str(pattern) for pattern in self) -@py2to3 -class OrTagPattern(object): +class OrTagPattern(TagPattern): - def __init__(self, patterns): - self._patterns = tuple(TagPattern(p) for p in patterns) + def __init__(self, patterns: Iterable[str]): + self._patterns = tuple(TagPattern.from_string(p) for p in patterns) - def match(self, tags): + def match(self, tags: Iterable[str]) -> bool: + tags = normalize_tags(tags) return any(p.match(tags) for p in self._patterns) - def __iter__(self): + def __iter__(self) -> Iterator["TagPattern"]: return iter(self._patterns) - def __unicode__(self): - return ' OR '.join(pattern.__unicode__() for pattern in self) + def __str__(self) -> str: + return " OR ".join(str(pattern) for pattern in self) -@py2to3 -class NotTagPattern(object): +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: Iterable[str]): + self._first = TagPattern.from_string(must_match) self._rest = OrTagPattern(must_not_match) - def match(self, tags): - if not self._first: - return not self._rest.match(tags) - return self._first.match(tags) and not self._rest.match(tags) + def match(self, tags: Iterable[str]) -> bool: + tags = normalize_tags(tags) + if self._first and not self._first.match(tags): + return False + return 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) -> 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]) + - def __unicode__(self): - return ' NOT '.join(pattern.__unicode__() for pattern in self).lstrip() +class NormalizedTags(list): + pass diff --git a/src/robot/model/tagsetter.py b/src/robot/model/tagsetter.py index 820631c022a..44666d286f6 100644 --- a/src/robot/model/tagsetter.py +++ b/src/robot/model/tagsetter.py @@ -13,27 +13,35 @@ # See the License for the specific language governing permissions and # limitations under the License. -from robot.utils import py2to3 +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 + -@py2to3 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 __nonzero__(self): + def __bool__(self): return bool(self.add or self.remove) diff --git a/src/robot/model/tagstatistics.py b/src/robot/model/tagstatistics.py index 71d6f2de282..1c4a2d40a2b 100644 --- a/src/robot/model/tagstatistics.py +++ b/src/robot/model/tagstatistics.py @@ -13,82 +13,70 @@ # See the License for the specific language governing permissions and # limitations under the License. -from itertools import chain import re -from robot.utils import NormalizedDict, unicode +from robot.utils import NormalizedDict -from .stats import CombinedTagStat, CriticalTagStat, TagStat -from .tags import SingleTagPattern, TagPatterns +from .stats import CombinedTagStat, TagStat +from .tags import TagPatterns -class TagStatistics(object): +class TagStatistics: """Container for tag statistics.""" - def __init__(self, critical_stats, non_critical_stats, combined_stats): - #: Dictionary, where key is the name of the tag as a string and value - #: is an instance of :class:`~robot.model.stats.TagStat`. - self.tags = NormalizedDict(ignore='_') - #: List of :class:`~robot.model.stats.CriticalTagStat` objects. - self.critical = critical_stats - #: List of :class:`~robot.model.stats.CriticalTagStat` objects. - self.non_critical = non_critical_stats - #: List of :class:`~robot.model.stats.CombinedTagStat` objects. + def __init__(self, combined_stats): + self.tags = NormalizedDict(ignore="_") self.combined = combined_stats def visit(self, visitor): visitor.visit_tag_statistics(self) def __iter__(self): - crits = self._get_critical_and_non_critical_matcher() - tags = [t for t in self.tags.values() if t.name not in crits] - return iter(sorted(chain(self.critical, self.non_critical, - self.combined, tags))) - - def _get_critical_and_non_critical_matcher(self): - crits = [stat for stat in self.critical + self.non_critical - if isinstance(stat.pattern, SingleTagPattern)] - return NormalizedDict([(unicode(stat.pattern), None) for stat in crits], - ignore='_') - - -class TagStatisticsBuilder(object): - - def __init__(self, criticality=None, included=None, excluded=None, - combined=None, docs=None, links=None): - self._included = TagPatterns(included) - self._excluded = TagPatterns(excluded) + return iter(sorted([*self.combined, *self.tags.values()])) + + +class TagStatisticsBuilder: + + def __init__( + self, + included=None, + excluded=None, + combined=None, + docs=None, + links=None, + ): + self._included = TagPatterns(included, "selecting tag statistics to include") + self._excluded = TagPatterns(excluded, "selecting tag statistics to exclude") + self._reserved = TagPatterns("robot:*") self._info = TagStatInfo(docs, links) - self.stats = TagStatistics( - self._info.get_critical_stats(criticality), - self._info.get_critical_stats(criticality, critical=False), - self._info.get_combined_stats(combined) - ) + self.stats = TagStatistics(self._info.get_combined_stats(combined)) def add_test(self, test): self._add_tags_to_statistics(test) - self._add_to_critical_and_combined_statistics(test) + self._add_to_combined_statistics(test) def _add_tags_to_statistics(self, test): for tag in test.tags: - if self._is_included(tag): + if self._is_included(tag) and not self._suppress_reserved(tag): if tag not in self.stats.tags: self.stats.tags[tag] = self._info.get_stat(tag) self.stats.tags[tag].add_test(test) def _is_included(self, tag): - if self._included and not self._included.match(tag): + if self._included and tag not in self._included: return False - return not self._excluded.match(tag) + return tag not in self._excluded - def _add_to_critical_and_combined_statistics(self, test): - stats = self.stats - for stat in stats.critical + stats.non_critical + stats.combined: + def _suppress_reserved(self, tag): + return tag in self._reserved and tag not in self._included + + def _add_to_combined_statistics(self, test): + for stat in self.stats.combined: if stat.match(test.tags): stat.add_test(test) -class TagStatInfo(object): +class TagStatInfo: def __init__(self, docs=None, links=None): self._docs = [TagStatDoc(*doc) for doc in docs or []] @@ -97,50 +85,41 @@ def __init__(self, docs=None, links=None): def get_stat(self, tag): return TagStat(tag, self.get_doc(tag), self.get_links(tag)) - def get_critical_stats(self, criticality, critical=True): - if not criticality: - return [] - tag_patterns = (criticality.critical_tags - if critical else criticality.non_critical_tags) - return [self._get_critical_stat(p, critical) for p in tag_patterns] - - def _get_critical_stat(self, pattern, critical): - name = unicode(pattern) - return CriticalTagStat(pattern, name, critical, self.get_doc(name), - self.get_links(name)) - def get_combined_stats(self, combined=None): return [self._get_combined_stat(*comb) for comb in combined or []] def _get_combined_stat(self, pattern, name=None): - name = name or pattern - return CombinedTagStat(pattern, name, self.get_doc(name), - self.get_links(name)) + return CombinedTagStat( + pattern, + name, + self.get_doc(name or pattern), + self.get_links(name or pattern), + ) def get_doc(self, tag): - return ' & '.join(doc.text for doc in self._docs if doc.match(tag)) + return " & ".join(doc.text for doc in self._docs if doc.match(tag)) def get_links(self, tag): return [link.get_link(tag) for link in self._links if link.match(tag)] -class TagStatDoc(object): +class TagStatDoc: def __init__(self, pattern, doc): - self._matcher = TagPatterns(pattern) + self._matcher = TagPatterns(pattern, "findings tags to give them documentation") self.text = doc def match(self, tag): return self._matcher.match(tag) -class TagStatLink(object): - _match_pattern_tokenizer = re.compile('(\*|\?+)') +class TagStatLink: + _match_pattern_tokenizer = re.compile(r"(\*|\?+)") def __init__(self, pattern, link, title): self._regexp = self._get_match_regexp(pattern) self._link = link - self._title = title.replace('_', ' ') + self._title = title.replace("_", " ") def match(self, tag): return self._regexp.match(tag) is not None @@ -153,21 +132,23 @@ def get_link(self, tag): return link, title def _replace_groups(self, link, title, match): - for index, group in enumerate(match.groups()): - placefolder = '%%%d' % (index+1) + for index, group in enumerate(match.groups(), start=1): + placefolder = f"%{index}" link = link.replace(placefolder, group) title = title.replace(placefolder, group) return link, title def _get_match_regexp(self, pattern): - pattern = '^%s$' % ''.join(self._yield_match_pattern(pattern)) + pattern = "".join(self._yield_match_pattern(pattern)) return re.compile(pattern, re.IGNORECASE) def _yield_match_pattern(self, pattern): + yield "^" for token in self._match_pattern_tokenizer.split(pattern): - if token.startswith('?'): - yield '(%s)' % ('.'*len(token)) - elif token == '*': - yield '(.*)' + if token.startswith("?"): + yield f"({'.' * len(token)})" + elif token == "*": + yield "(.*)" else: yield re.escape(token) + yield "$" diff --git a/src/robot/model/testcase.py b/src/robot/model/testcase.py index 874e2654cde..577ec856e3c 100644 --- a/src/robot/model/testcase.py +++ b/src/robot/model/testcase.py @@ -13,76 +13,240 @@ # 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 Any, Generic, Sequence, Type, TYPE_CHECKING, TypeVar + from robot.utils import setter +from .body import Body, BodyItem +from .fixture import create_fixture from .itemlist import ItemList -from .keyword import Keyword, Keywords -from .modelobject import ModelObject +from .keyword import Keyword +from .metadata import Metadata +from .modelobject import DataDict, ModelObject from .tags import Tags +if TYPE_CHECKING: + from .testsuite import TestSuite + from .visitor import SuiteVisitor + + +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`. """ - __slots__ = ['parent', 'name', 'doc', 'timeout'] - keyword_class = Keyword #: Internal usage only - - def __init__(self, name='', doc='', tags=None, timeout=None): - self.parent = None #: Parent suite. - self.name = name #: Test case name. - self.doc = doc #: Test case documentation. - self.timeout = timeout #: Test case timeout. + + type = "TEST" + body_class = Body + # 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") + + def __init__( + self, + name: str = "", + doc: str = "", + tags: "Tags | Sequence[str]" = (), + timeout: "str | None" = None, + lineno: "int | None" = None, + metadata: "Mapping[str, str] | None" = None, + parent: "TestSuite[KW, TestCase[KW]] | None" = None, + ): + self.name = name + self.doc = doc self.tags = tags - self.keywords = None + self.metadata = metadata + self.timeout = timeout + self.lineno = lineno + self.parent = parent + self.body = [] + self._setup: KW | None = None + self._teardown: KW | None = None @setter - def tags(self, tags): + def body(self, body: "Sequence[BodyItem | DataDict]") -> Body: + """Test body as a :class:`~robot.model.body.Body` object.""" + return self.body_class(self, body) + + @setter + def tags(self, tags: "Tags | Sequence[str]") -> Tags: """Test tags as a :class:`~.model.tags.Tags` object.""" return Tags(tags) @setter - def keywords(self, keywords): - """Keywords as a :class:`~.Keywords` object. + def metadata(self, metadata: "Mapping[str, str] | None") -> Metadata: + """Test metadata as a :class:`~.model.metadata.Metadata` object. + + New in Robot Framework 7.5. + """ + return Metadata(metadata) + + @property + 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 + but in that case its truth value is ``False``. + + Setup can be modified by setting attributes directly:: + + test.setup.name = 'Example' + test.setup.args = ('First', 'Second') + + Alternatively the :meth:`config` method can be used to set multiple + attributes in one call:: - Contains also possible setup and teardown keywords. + test.setup.config(name='Example', args=('First', 'Second')) + + The easiest way to reset the whole setup is setting it to ``None``. + It will automatically recreate the underlying ``Keyword`` object:: + + test.setup = None + + New in Robot Framework 4.0. Earlier setup was accessed like + ``test.keywords.setup``. """ - return Keywords(self.keyword_class, self, keywords) + if self._setup is None: + self._setup = create_fixture( + self.fixture_class, + None, + self, + Keyword.SETUP, + ) + return self._setup + + @setup.setter + def setup(self, setup: "KW | DataDict | None"): + self._setup = create_fixture( + self.fixture_class, + setup, + self, + Keyword.SETUP, + ) @property - def id(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:`` + is that accessing the :attr:`setup` attribute creates a :class:`Keyword` + object representing the setup even when the test actually does not have + one. This typically does not matter, but with bigger suite structures + containing a huge about of tests it can have an effect on memory usage. + + New in Robot Framework 5.0. + """ + return bool(self._setup) + + @property + def teardown(self) -> KW: + """Test teardown as a :class:`~.model.keyword.Keyword` object. + + See :attr:`setup` for more information. + """ + if self._teardown is None: + self._teardown = create_fixture( + self.fixture_class, + None, + self, + Keyword.TEARDOWN, + ) + return self._teardown + + @teardown.setter + def teardown(self, teardown: "KW | DataDict | None"): + self._teardown = create_fixture( + self.fixture_class, + teardown, + self, + Keyword.TEARDOWN, + ) + + @property + def has_teardown(self) -> bool: + """Check does a test have a teardown without creating a teardown object. + + See :attr:`has_setup` for more information. + + New in Robot Framework 5.0. + """ + return bool(self._teardown) + + @property + def id(self) -> str: """Test case id in format like ``s1-t3``. See :attr:`TestSuite.id ` for more information. """ if not self.parent: - return 't1' - return '%s-t%d' % (self.parent.id, self.parent.tests.index(self)+1) + return "t1" + 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.""" + def full_name(self) -> str: + """Test name prefixed with the full name of the parent suite.""" if not self.parent: return self.name - return '%s.%s' % (self.parent.longname, self.name) - - def visit(self, visitor): - """:mod:`Visitor interface ` entry-point.""" - visitor.visit_test(self) + 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 -class TestCases(ItemList): - __slots__ = [] + @property + def source(self) -> "Path | None": + return self.parent.source if self.parent is not None else None - def __init__(self, test_class=TestCase, parent=None, tests=None): - ItemList.__init__(self, test_class, {'parent': parent}, tests) + def visit(self, visitor: "SuiteVisitor"): + """:mod:`Visitor interface ` entry-point.""" + visitor.visit_test(self) - def _check_type_and_set_attrs(self, *tests): - tests = ItemList._check_type_and_set_attrs(self, *tests) - for test in tests: + def to_dict(self) -> "dict[str, Any]": + data: dict[str, Any] = {"name": self.name} + if self.doc: + data["doc"] = self.doc + if self.tags: + data["tags"] = tuple(self.tags) + if self.metadata: + data["metadata"] = dict(self.metadata) + 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[TC]): + __slots__ = () + + def __init__( + self, + test_class: Type[TC] = TestCase, + parent: "TestSuite | None" = None, + tests: "Sequence[TC | DataDict]" = (), + ): + super().__init__(test_class, {"parent": parent}, tests) + + def _check_type_and_set_attrs(self, test): + test = super()._check_type_and_set_attrs(test) + if test.parent: for visitor in test.parent._visitors: test.visit(visitor) - return tests + return test diff --git a/src/robot/model/testsuite.py b/src/robot/model/testsuite.py index de697138aeb..f2ce9503457 100644 --- a/src/robot/model/testsuite.py +++ b/src/robot/model/testsuite.py @@ -13,83 +13,350 @@ # See the License for the specific language governing permissions and # limitations under the License. -from robot.utils import setter +from collections.abc import Mapping +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 -from .filter import Filter, EmptySuiteRemover +from .filter import EmptySuiteRemover, Filter +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 ModelObject +from .modelobject import DataDict, ModelObject +from .namepatterns import NamePatterns +from .tags import TagPatterns from .tagsetter import TagSetter 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) -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`. """ - __slots__ = ['parent', 'source', '_name', 'doc', '_my_visitors', 'rpa'] - test_class = TestCase #: Internal usage only. - keyword_class = Keyword #: Internal usage only. - def __init__(self, name='', doc='', metadata=None, source=None, rpa=False): - self.parent = None #: Parent suite. ``None`` with the root suite. + type = "SUITE" + # FIXME: Type Ignore declarations: Typevars only accept subclasses of the bound class + # 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 + test_class: Type[TC] = TestCase # type: ignore + + repr_args = ("name",) + __slots__ = ("parent", "_name", "doc", "_setup", "_teardown", "rpa", "_my_visitors") + + def __init__( + self, + name: str = "", + doc: str = "", + metadata: "Mapping[str, str] | None" = None, + source: "Path | str | None" = None, + rpa: "bool | None" = False, + parent: "TestSuite[KW, TC] | None" = None, + ): self._name = name - self.doc = doc #: Test suite documentation. + self.doc = doc self.metadata = metadata - self.source = source #: Path to the source file or directory. + self.source = source + self.parent = parent self.rpa = rpa - self.suites = None - self.tests = None - self.keywords = None - self._my_visitors = [] + self.suites = [] + self.tests = [] + self._setup: KW | None = None + self._teardown: KW | None = None + self._my_visitors: list[SuiteVisitor] = [] + + @staticmethod + 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 + 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 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 + 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 = 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.lower().endswith(ext): + return path.name[: -len(ext)] + valid_extensions = seq2str(extensions, lastsep=" or ") + raise ValueError(f"File '{path}' does not have extension {valid_extensions}.") @property - def _visitors(self): + def _visitors(self) -> "list[SuiteVisitor]": parent_visitors = self.parent._visitors if self.parent else [] return self._my_visitors + parent_visitors @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) + 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 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) + 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: "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.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') + + 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): - """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 '%s.%s' % (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): - """Free test suite metadata as a dictionary.""" + def metadata(self, metadata: "Mapping[str, str] | None") -> Metadata: + """Free suite metadata as a :class:`~.metadata.Metadata` object.""" return Metadata(metadata) - @setter - def suites(self, suites): - """Child suites as a :class:`~.TestSuites` object.""" - return TestSuites(self.__class__, self, suites) + 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.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.full_name}' 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 tests(self, tests): - """Tests as a :class:`~.TestCases` object.""" - return TestCases(self.test_class, self, tests) + def suites( + self, suites: "Sequence[TestSuite | DataDict]" + ) -> "TestSuites[TestSuite[KW, TC]]": + return TestSuites["TestSuite"](self.__class__, self, suites) @setter - def keywords(self, keywords): - """Suite setup and teardown as a :class:`~.Keywords` object.""" - return Keywords(self.keyword_class, self, keywords) + def tests(self, tests: "Sequence[TC | DataDict]") -> TestCases[TC]: + return TestCases[TC](self.test_class, self, tests) @property - def id(self): + def setup(self) -> KW: + """Suite setup. + + This attribute is a ``Keyword`` object also when a suite has no setup + 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:: + + suite.setup.name = 'Example' + suite.setup.args = ('First', 'Second') + + Alternatively the :meth:`config` method can be used to set multiple + attributes in one call:: + + suite.setup.config(name='Example', args=('First', 'Second')) + + The easiest way to reset the whole setup is setting it to ``None``. + It will automatically recreate the underlying ``Keyword`` object:: + + suite.setup = None + + New in Robot Framework 4.0. Earlier setup was accessed like + ``suite.keywords.setup``. + """ + if self._setup is None: + self._setup = create_fixture( + self.fixture_class, + None, + self, + Keyword.SETUP, + ) + return self._setup + + @setup.setter + def setup(self, setup: "KW | DataDict | None"): + self._setup = create_fixture( + self.fixture_class, + setup, + self, + Keyword.SETUP, + ) + + @property + 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 + it can have some effect on memory usage. + + New in Robot Framework 5.0. + """ + return bool(self._setup) + + @property + def teardown(self) -> KW: + """Suite teardown. + + See :attr:`setup` for more information. + """ + if self._teardown is None: + self._teardown = create_fixture( + self.fixture_class, + None, + self, + Keyword.TEARDOWN, + ) + return self._teardown + + @teardown.setter + def teardown(self, teardown: "KW | DataDict | None"): + self._teardown = create_fixture( + self.fixture_class, + teardown, + self, + Keyword.TEARDOWN, + ) + + @property + def has_teardown(self) -> bool: + """Check does a suite have a teardown without creating a teardown object. + + See :attr:`has_setup` for more information. + + New in Robot Framework 5.0. + """ + return bool(self._teardown) + + @property + def id(self) -> str: """An automatically generated unique id. The root suite has id ``s1``, its child suites have ids ``s1-s1``, @@ -97,19 +364,43 @@ 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) + return "s1" + suites = self.parent.suites + index = suites.index(self) if self in suites else len(suites) + return f"{self.parent.id}-s{index + 1}" + + @property + def all_tests(self) -> Iterator[TC]: + """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.""" + def test_count(self) -> int: + """Total number of the tests in this suite and in its child suites. + + This is considerably faster than `len(list(suite.all_tests))`. + """ return len(self.tests) + sum(suite.test_count for suite in self.suites) - def set_tags(self, add=None, remove=None, persist=False): + @property + def has_tests(self) -> bool: + 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, + ): """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, @@ -124,8 +415,13 @@ 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: "NamePatterns | Sequence[str] | None" = None, + included_tests: "NamePatterns | Sequence[str] | None" = None, + included_tags: "TagPatterns | Sequence[str] | None" = None, + excluded_tags: "TagPatterns | Sequence[str] | None" = None, + ): """Select test cases and remove others from this suite. Parameters have the same semantics as ``--suite``, ``--test``, @@ -141,8 +437,9 @@ def filter(self, included_suites=None, included_tests=None, suite.filter(included_tests=['Test 1', '* Example'], included_tags='priority-1') """ - self.visit(Filter(included_suites, included_tests, - included_tags, excluded_tags)) + self.visit( + Filter(included_suites, included_tests, included_tags, excluded_tags) + ) def configure(self, **options): """A shortcut to configure a suite using one method call. @@ -152,24 +449,54 @@ def configure(self, **options): :param options: Passed to :class:`~robot.model.configurer.SuiteConfigurer` that will then set suite attributes, call :meth:`filter`, etc. as needed. + + Not to be confused with :meth:`config` method that suites, tests, + and keywords have to make it possible to set multiple attributes in + one call. """ if self.parent is not None: - raise ValueError("'TestSuite.configure()' can only be used with " - "the root test suite.") + raise ValueError( + "'TestSuite.configure()' can only be used with the root test suite." + ) if options: self.visit(SuiteConfigurer(**options)) - def remove_empty_suites(self): + def remove_empty_suites(self, preserve_direct_children: bool = False): """Removes all child suites not containing any tests, recursively.""" - self.visit(EmptySuiteRemover()) + self.visit(EmptySuiteRemover(preserve_direct_children)) - def visit(self, visitor): + def visit(self, visitor: SuiteVisitor): """:mod:`Visitor interface ` entry-point.""" visitor.visit_suite(self) - -class TestSuites(ItemList): - __slots__ = [] - - def __init__(self, suite_class=TestSuite, parent=None, suites=None): - ItemList.__init__(self, suite_class, {'parent': parent}, suites) + def to_dict(self) -> "dict[str, Any]": + data: dict[str, Any] = {"name": self.name} + if self.doc: + data["doc"] = self.doc + if self.metadata: + data["metadata"] = dict(self.metadata) + if self.source: + data["source"] = str(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[TS]): + __slots__ = () + + 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/model/totalstatistics.py b/src/robot/model/totalstatistics.py index 4ace46263f3..9e148a12cdf 100644 --- a/src/robot/model/totalstatistics.py +++ b/src/robot/model/totalstatistics.py @@ -13,47 +13,59 @@ # See the License for the specific language governing permissions and # limitations under the License. +from collections.abc import Iterator + +from robot.utils import plural_or_not, test_or_task + from .stats import TotalStat from .visitor import SuiteVisitor -class TotalStatistics(object): +class TotalStatistics: """Container for total statistics.""" - def __init__(self, rpa=False): - #: Instance of :class:`~robot.model.stats.TotalStat` for critical tests. - test_or_task = 'Tests' if not rpa else 'Tasks' - self.critical = TotalStat('Critical ' + test_or_task) + def __init__(self, rpa: bool = False): #: Instance of :class:`~robot.model.stats.TotalStat` for all the tests. - self.all = TotalStat('All ' + test_or_task) + self.stat = TotalStat(test_or_task("All {Test}s", rpa)) self._rpa = rpa def visit(self, visitor): - visitor.visit_total_statistics(self) + visitor.visit_total_statistics(self.stat) + + def __iter__(self) -> "Iterator[TotalStat]": + yield self.stat + + @property + def total(self) -> int: + return self.stat.total + + @property + def passed(self) -> int: + return self.stat.passed - def __iter__(self): - return iter([self.critical, self.all]) + @property + def skipped(self) -> int: + return self.stat.skipped @property - def message(self): + def failed(self) -> int: + return self.stat.failed + + def add_test(self, test): + self.stat.add_test(test) + + @property + def message(self) -> str: """String representation of the statistics. For example:: - - 2 critical tests, 1 passed, 1 failed - 2 tests total, 1 passed, 1 failed + 2 tests, 1 passed, 1 failed """ - test_or_task = 'test' if not self._rpa else 'task' - ctotal, cend, cpass, cfail = self._get_counts(self.critical) - atotal, aend, apass, afail = self._get_counts(self.all) - return ('%d critical %s%s, %d passed, %d failed\n' - '%d %s%s total, %d passed, %d failed' - % (ctotal, test_or_task, cend, cpass, cfail, - atotal, test_or_task, aend, apass, afail)) - - def _get_counts(self, stat): - ending = 's' if stat.total != 1 else '' - return stat.total, ending, stat.passed, stat.failed + kind = test_or_task("test", self._rpa) + plural_or_not(self.total) + msg = f"{self.total} {kind}, {self.passed} passed, {self.failed} failed" + if self.skipped: + msg += f", {self.skipped} skipped" + return msg class TotalStatisticsBuilder(SuiteVisitor): @@ -64,9 +76,7 @@ def __init__(self, suite=None, rpa=False): suite.visit(self) def add_test(self, test): - self.stats.all.add_test(test) - if test.critical: - self.stats.critical.add_test(test) + self.stats.add_test(test) def visit_test(self, test): self.add_test(test) diff --git a/src/robot/model/visitor.py b/src/robot/model/visitor.py index 0d973eab3a3..31effd81275 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 @@ -38,7 +38,7 @@ :meth:`~SuiteVisitor.visit_keyword` or :meth:`~SuiteVisitor.visit_message`, depending on the instance where the :meth:`visit` method exists. -The recommended and definitely easiest way to implement a visitor is extending +The recommended and definitely the easiest way to implement a visitor is extending the :class:`SuiteVisitor` base class. The default implementation of its :meth:`visit_x` methods take care of traversing child elements of the object :obj:`x` recursively. A :meth:`visit_x` method first calls a corresponding @@ -47,6 +47,15 @@ finally calls the corresponding :meth:`end_x` method. The default implementations of :meth:`start_x` and :meth:`end_x` do nothing. +All items that can appear inside tests have their own visit methods. These +include :meth:`visit_keyword`, :meth:`visit_message` (only applicable with +results, not with executable data), :meth:`visit_for`, :meth:`visit_if`, and +so on, as well as their appropriate ``start/end`` methods like :meth:`start_keyword` +and :meth:`end_for`. If there is a need to visit all these items, it is possible to +implement only :meth:`start_body_item` and :meth:`end_body_item` methods that are, +by default, called by the appropriate ``start/end`` methods. These generic methods +are new in Robot Framework 5.0. + Visitors extending the :class:`SuiteVisitor` can stop visiting at a certain level either by overriding suitable :meth:`visit_x` method or by returning an explicit ``False`` from any :meth:`start_x` method. @@ -58,106 +67,551 @@ It could be used, for example, with Robot Framework's ``--prerunmodifier`` option to modify test data before execution. -.. literalinclude:: /../../doc/api/code_examples/select_every_xth_test.py +.. literalinclude:: ../../../doc/api/code_examples/SelectEveryXthTest.py + :language: python For more examples it is possible to look at the source code of visitors used internally by Robot Framework itself. Some good examples are :class:`~robot.model.tagsetter.TagSetter` and :mod:`keyword removers `. + +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 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 import TestCase, TestSuite + + + class FailurePrinter(SuiteVisitor): + + def start_suite(self, suite: TestSuite): + print(f"{suite.full_name}: {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 -class SuiteVisitor(object): - """Abstract class to ease traversing through the test suite structure. +if TYPE_CHECKING: + from robot.model import ( + BodyItem, Break, Continue, Error, For, Group, If, IfBranch, Keyword, Message, + Return, TestCase, TestSuite, Try, TryBranch, Var, While + ) + from robot.result import ForIteration, WhileIteration + + +class SuiteVisitor: + """Abstract class to ease traversing through the suite structure. See the :mod:`module level ` documentation for more information and an example. """ - def visit_suite(self, suite): - """Implements traversing through the suite and its direct children. + def visit_suite(self, suite: "TestSuite"): + """Implements traversing through suites. Can be overridden to allow modifying the passed in ``suite`` without - calling :func:`start_suite` or :func:`end_suite` nor visiting child - suites, tests or keywords (setup and teardown) at all. + calling :meth:`start_suite` or :meth:`end_suite` nor visiting child + suites, tests or setup and teardown at all. """ if self.start_suite(suite) is not False: - suite.keywords.visit(self) + if suite.has_setup: + suite.setup.visit(self) suite.suites.visit(self) suite.tests.visit(self) + if suite.has_teardown: + suite.teardown.visit(self) self.end_suite(suite) - def start_suite(self, suite): - """Called when suite starts. Default implementation does nothing. + def start_suite(self, suite: "TestSuite") -> "bool | None": + """Called when a suite starts. Default implementation does nothing. Can return explicit ``False`` to stop visiting. """ pass - def end_suite(self, suite): - """Called when suite ends. Default implementation does nothing.""" + def end_suite(self, suite: "TestSuite"): + """Called when a suite ends. Default implementation does nothing.""" pass - def visit_test(self, test): - """Implements traversing through the test and its keywords. + def visit_test(self, test: "TestCase"): + """Implements traversing through tests. - Can be overridden to allow modifying the passed in ``test`` without - calling :func:`start_test` or :func:`end_test` nor visiting keywords. + Can be overridden to allow modifying the passed in ``test`` without calling + :meth:`start_test` or :meth:`end_test` nor visiting the body of the test. """ if self.start_test(test) is not False: - test.keywords.visit(self) + if test.has_setup: + test.setup.visit(self) + test.body.visit(self) + if test.has_teardown: + test.teardown.visit(self) self.end_test(test) - def start_test(self, test): - """Called when test starts. Default implementation does nothing. + def start_test(self, test: "TestCase") -> "bool | None": + """Called when a test starts. Default implementation does nothing. Can return explicit ``False`` to stop visiting. """ pass - def end_test(self, test): - """Called when test ends. Default implementation does nothing.""" + def end_test(self, test: "TestCase"): + """Called when a test ends. Default implementation does nothing.""" pass - def visit_keyword(self, kw): - """Implements traversing through the keyword and its child keywords. + def visit_keyword(self, keyword: "Keyword"): + """Implements traversing through keywords. Can be overridden to allow modifying the passed in ``kw`` without - calling :func:`start_keyword` or :func:`end_keyword` nor visiting - child keywords. + calling :meth:`start_keyword` or :meth:`end_keyword` nor visiting + the body of the keyword """ - if self.start_keyword(kw) is not False: - kw.keywords.visit(self) - kw.messages.visit(self) - self.end_keyword(kw) + 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 start_keyword(self, keyword): - """Called when keyword starts. Default implementation does nothing. + 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") -> "bool | None": + """Called when a keyword starts. + + By default, calls :meth:`start_body_item` which, by default, does nothing. Can return explicit ``False`` to stop visiting. """ - pass + return self.start_body_item(keyword) - def end_keyword(self, keyword): - """Called when keyword ends. Default implementation does nothing.""" - pass + 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"): + """Implements traversing through FOR loops. + + Can be overridden to allow modifying the passed in ``for_`` without + calling :meth:`start_for` or :meth:`end_for` nor visiting body. + """ + if self.start_for(for_) is not False: + for_.body.visit(self) + self.end_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. + + Can return explicit ``False`` to stop visiting. + """ + return self.start_body_item(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: "ForIteration"): + """Implements traversing through single FOR loop iteration. + + This is only used with the result side model because on the running side + there are no iterations. + + Can be overridden to allow modifying the passed in ``iteration`` without + calling :meth:`start_for_iteration` or :meth:`end_for_iteration` nor visiting + body. + """ + if self.start_for_iteration(iteration) is not False: + iteration.body.visit(self) + self.end_for_iteration(iteration) + + 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. + + Can return explicit ``False`` to stop visiting. + """ + return self.start_body_item(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_: "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 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. + """ + if self.start_if(if_) is not False: + if_.body.visit(self) + self.end_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. + + Can return explicit ``False`` to stop visiting. + """ + return self.start_body_item(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"): + """Implements traversing through single IF/ELSE branch. + + Can be overridden to allow modifying the passed in ``branch`` without + calling :meth:`start_if_branch` or :meth:`end_if_branch` nor visiting body. + """ + if self.start_if_branch(branch) is not False: + branch.body.visit(self) + self.end_if_branch(branch) + + 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. + + Can return explicit ``False`` to stop visiting. + """ + return self.start_body_item(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_: "Try"): + """Implements traversing through TRY/EXCEPT structures. + + This method is used with the TRY/EXCEPT root element. Actual TRY, EXCEPT, ELSE + and FINALLY branches are visited separately using :meth:`visit_try_branch`. + """ + if self.start_try(try_) is not False: + try_.body.visit(self) + self.end_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. + + Can return explicit ``False`` to stop visiting. + """ + return self.start_body_item(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"): + """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") -> "bool | None": + """Called when TRY, EXCEPT, ELSE or FINALLY branches start. + + By default, calls :meth:`start_body_item` which, by default, does nothing. + + Can return explicit ``False`` to stop visiting. + """ + return self.start_body_item(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_: "While"): + """Implements traversing through WHILE loops. + + Can be overridden to allow modifying the passed in ``while_`` without + calling :meth:`start_while` or :meth:`end_while` nor visiting body. + """ + if self.start_while(while_) is not False: + while_.body.visit(self) + self.end_while(while_) + + def start_while(self, while_: "While") -> "bool | None": + """Called when a WHILE loop starts. - def visit_message(self, msg): - """Implements visiting the message. + By default, calls :meth:`start_body_item` which, by default, does nothing. + + Can return explicit ``False`` to stop visiting. + """ + return self.start_body_item(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: "WhileIteration"): + """Implements traversing through single WHILE loop iteration. + + This is only used with the result side model because on the running side + there are no iterations. + + Can be overridden to allow modifying the passed in ``iteration`` without + calling :meth:`start_while_iteration` or :meth:`end_while_iteration` nor visiting + body. + """ + if self.start_while_iteration(iteration) is not False: + iteration.body.visit(self) + self.end_while_iteration(iteration) + + 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. + + Can return explicit ``False`` to stop visiting. + """ + return self.start_body_item(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_group(self, group: "Group"): + """Visits GROUP elements. + + Can be overridden to allow modifying the passed in ``group`` without + calling :meth:`start_group` or :meth:`end_group` nor visiting body. + """ + if self.start_group(group) is not False: + group.body.visit(self) + self.end_group(group) + + def start_group(self, group: "Group") -> "bool | None": + """Called when a GROUP 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(group) + + def end_group(self, group: "Group"): + """Called when a GROUP element ends. + + By default, calls :meth:`end_body_item` which, by default, does nothing. + """ + self.end_body_item(group) + + 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: + self._possible_body(return_) + self.end_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. + + Can return explicit ``False`` to stop visiting. + """ + return self.start_body_item(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"): + """Visits CONTINUE elements.""" + if self.start_continue(continue_) is not False: + self._possible_body(continue_) + self.end_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. + + Can return explicit ``False`` to stop visiting. + """ + return self.start_body_item(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"): + """Visits BREAK elements.""" + if self.start_break(break_) is not False: + self._possible_body(break_) + self.end_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. + + Can return explicit ``False`` to stop visiting. + """ + return self.start_body_item(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"): + """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: + self._possible_body(error) + self.end_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. + + 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. Can be overridden to allow modifying the passed in ``msg`` without - calling :func:`start_message` or :func:`end_message`. + 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): - """Called when message starts. Default implementation does nothing. + 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. Can return explicit ``False`` to stop visiting. """ + return self.start_body_item(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") -> "bool | None": + """Called, by default, when keywords, messages or control structures start. + + More specific :meth:`start_keyword`, :meth:`start_message`, `:meth:`start_for`, + etc. can be implemented to visit only keywords, messages or specific control + structures. + + Can return explicit ``False`` to stop visiting. Default implementation does + nothing. + """ pass - def end_message(self, msg): - """Called when message ends. Default implementation does nothing.""" + 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`, + etc. can be implemented to visit only keywords, messages or specific control + structures. + + Default implementation does nothing. + """ pass diff --git a/src/robot/output/__init__.py b/src/robot/output/__init__.py index 7c782c864d8..73c88e13f0c 100644 --- a/src/robot/output/__init__.py +++ b/src/robot/output/__init__.py @@ -19,7 +19,9 @@ test execution is refactored. """ -from .output import Output -from .logger import LOGGER -from .xmllogger import XmlLogger -from .loggerhelper import LEVELS, Message +from .logger import LOGGER as LOGGER +from .loggerapi import ResultFile as ResultFile +from .loggerhelper import LEVELS as LEVELS, Message as Message +from .loglevel import LogLevel as LogLevel, SettableLevel as SettableLevel +from .output import Output as Output +from .xmllogger import XmlLogger as XmlLogger diff --git a/src/robot/output/console/__init__.py b/src/robot/output/console/__init__.py index 813c58342fc..58f3de0af21 100644 --- a/src/robot/output/console/__init__.py +++ b/src/robot/output/console/__init__.py @@ -13,23 +13,34 @@ # See the License for the specific language governing permissions and # limitations under the License. -from robot.errors import DataError +from io import TextIOBase -from .dotted import DottedOutput -from .quiet import NoOutput, QuietOutput -from .verbose import VerboseOutput +from .base import BaseConsole as BaseConsole +from .dotted import DottedConsole as DottedConsole +from .quiet import NoneConsole as NoneConsole, QuietConsole as QuietConsole +from .types import BuiltInConsole, ConsoleColors, ConsoleLinks, ConsoleMarkers +from .verbose import VerboseConsole -def ConsoleOutput(type='verbose', width=78, colors='AUTO', markers='AUTO', - stdout=None, stderr=None): - upper = type.upper() - if upper == 'VERBOSE': - return VerboseOutput(width, colors, markers, stdout, stderr) - if upper == 'DOTTED': - return DottedOutput(width, colors, stdout, stderr) - if upper == 'QUIET': - return QuietOutput(colors, stderr) - if upper == 'NONE': - return NoOutput() - raise DataError("Invalid console output type '%s'. Available " - "'VERBOSE', 'DOTTED', 'QUIET' and 'NONE'." % type) +def ConsoleOutput( + console: "BuiltInConsole | object" = "VERBOSE", + width: int = 78, + colors: ConsoleColors = "AUTO", + links: ConsoleLinks = "AUTO", + markers: ConsoleMarkers = "AUTO", + stdout: "TextIOBase | None" = None, + stderr: "TextIOBase | None" = None, +): + from ..listeners import ListenerFacade + + if isinstance(console, str): + upper = console.upper() + if upper == "VERBOSE": + console = VerboseConsole(width, colors, links, markers, stdout, stderr) + elif upper == "DOTTED": + console = DottedConsole(width, colors, links, stdout, stderr) + elif upper == "QUIET": + console = QuietConsole(colors, stderr) + elif upper == "NONE": + console = NoneConsole() + return ListenerFacade.create(console, kind="console logger") diff --git a/src/robot/output/console/base.py b/src/robot/output/console/base.py new file mode 100644 index 00000000000..de56f0b3cf8 --- /dev/null +++ b/src/robot/output/console/base.py @@ -0,0 +1,116 @@ +# 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 sys +from io import TextIOBase +from pathlib import Path +from typing import TYPE_CHECKING + +from .highlighting import HighlightingStream +from .types import ConsoleColors, ConsoleLinks, Status + +if TYPE_CHECKING: + from robot.output import Message, ResultFile + + +class BaseConsole: + """Base class for built-in console loggers that also external loggers can use. + + Writes warnings and errors to the stderr and result file paths to the stdout + by default. That can be changed by overriding relevant methods. More + functionality can be added by implementing needed listener interface methods. + + Public methods and attributes are part of the stable API and can be used + by extending classes. Anything private (i.e. starting with an underscore) + may change between releases. + + Supports all the same hook methods that the listener API supports. + + New in Robot Framework 7.5. + """ + + def __init__( + self, + width: int = 78, + colors: ConsoleColors = "AUTO", + links: ConsoleLinks = "AUTO", + stdout: "TextIOBase | None" = None, + stderr: "TextIOBase | None" = None, + ): + self.width = width + self._stdout = HighlightingStream(stdout or sys.__stdout__, colors, links) + self._stderr = HighlightingStream(stderr or sys.__stderr__, colors, links) + + def message(self, msg: "Message"): + """Hook method to handle warnings and errors.""" + if msg.level in ("WARN", "ERROR") and msg.console and msg.message is not None: + self._stderr.error(msg.message, msg.level) + + def output_file(self, path: "Path | None"): + """Hook method to handle output files. + + Called when the output file is finished, even if the output file would + be disabled. Calls :meth:`result_file` by default. + """ + self.result_file("OUTPUT", path) + + def result_file(self, kind: "ResultFile", path: "Path | None"): + """Hook method to handle other result files than the output file. + + Called when result files are finished unless they are disabled. + """ + kind = kind.title() if kind != "XUNIT" else "XUnit" + self.write(f"{kind + ':':9}") + self.link(path) + self.write("\n") + + def write(self, text: str, stderr: bool = False, flush: bool = True): + """Write given text to stdout or stderr. + + :param text: Text to write to stdout/stderr. + :param stderr: When true, write to stderr instead of stdout. + :param flush: When true, stream is flushed after writing to it. + """ + stream = self._stderr if stderr else self._stdout + stream.write(text, flush) + + def highlight( + self, + status: Status, + text: "str | None" = None, + stderr: bool = False, + flush: bool = True, + ): + """Highlight given status or text. + + :param status: Status marker. + :param text: Text to highlight. If not given, highlights the status text. + :param stderr: When true, write to stderr instead of stdout. + :param flush: When true, stream is flushed after writing to it. + """ + stream = self._stderr if stderr else self._stdout + stream.highlight(status, text, flush) + + def link(self, path: "Path | None"): + """Console link given path. + + If console does not support linking, write path as-is. Write "NONE" + if path is `None`. + """ + self._stdout.link(path) + + def clear(self): + """Clear the current line in stdout.""" + self.write(f"\r{' ' * self.width}\r") diff --git a/src/robot/output/console/dotted.py b/src/robot/output/console/dotted.py index 47d601332ff..1b0b92933f6 100644 --- a/src/robot/output/console/dotted.py +++ b/src/robot/output/console/dotted.py @@ -13,78 +13,80 @@ # See the License for the specific language governing permissions and # limitations under the License. -import sys - -from robot.model import SuiteVisitor -from robot.utils import plural_or_not, secs_to_timestr - -from .highlighting import HighlightingStream - - -class DottedOutput(object): - - 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 - - def start_suite(self, suite): - 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 - if test.passed: - self._stdout.write('.') - elif 'robot:exit' in test.tags: - self._stdout.write('x') - elif not test.critical: - self._stdout.write('f') +from io import TextIOBase +from typing import TYPE_CHECKING + +from robot.utils import plural_or_not as s, secs_to_timestr + +from .base import BaseConsole +from .types import ConsoleColors, ConsoleLinks + +if TYPE_CHECKING: + from robot import result, running + + +class DottedConsole(BaseConsole): + """Dotted console logger. + + Reports each test with "." (PASS), "F" (FAIL) or "s" (SKIP). + """ + + def __init__( + self, + width: int = 78, + colors: ConsoleColors = "AUTO", + links: ConsoleLinks = "AUTO", + stdout: "TextIOBase | None" = None, + stderr: "TextIOBase | None" = None, + ): + super().__init__(width, colors, links, stdout, stderr) + self.markers_on_row = 0 + + def start_suite(self, data: "running.TestSuite", result: "result.TestSuite"): + if not data.parent: + count = data.test_count + ts = ("test" if not data.rpa else "task") + s(count) + self.write(f"Running suite '{result.name}' with {count} {ts}.\n") + self.write("=" * self.width + "\n") + + def end_test(self, data: "running.TestCase", result: "result.TestCase"): + if self.markers_on_row == self.width: + self.write("\n") + self.markers_on_row = 0 + self.markers_on_row += 1 + if result.passed: + self.write(".") + elif result.skipped: + self.highlight("SKIP", "s") + elif result.tags.robot("exit"): + self.write("x") else: - self._stdout.highlight('F', 'FAIL') - - def end_suite(self, suite): - if not suite.parent: - 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) - - def output_file(self, name, path): - self._stdout.write('%-8s %s\n' % (name+':', path)) - - -class StatusReporter(SuiteVisitor): - - def __init__(self, stream, width): - self._stream = stream - self._width = width - - def report(self, suite): - suite.visit(self) + self.highlight("FAIL", "F") + + def end_suite(self, data: "running.TestSuite", result: "result.TestSuite"): + if not data.parent: + self.write("\n") + self.report_failures(result) + self.report_status(result) + self.write("\n") + + def report_failures(self, suite: "result.TestSuite"): + """Reports failed tests.""" + for test in suite.all_tests: + if test.failed and not test.tags.robot("exit"): + self.write(f"{'-' * self.width}\n") + self.highlight("FAIL") + self.write(f": {test.full_name}\n{test.message.strip()}\n") + + def report_status(self, suite: "result.TestSuite"): + """Reports overall status.""" stats = suite.statistics - self._stream.write("%s\nRun suite '%s' with %d %s%s in %s.\n\n" - % ('=' * self._width, suite.name, stats.all.total, - 'test' if not suite.rpa else 'task', - plural_or_not(stats.all.total), - secs_to_timestr(suite.elapsedtime/1000.0))) - self._stream.highlight(suite.status + 'ED', suite.status) - self._stream.write('\n%s\n' % stats.message) - - def visit_test(self, test): - if not test.passed and test.critical and 'robot:exit' not in test.tags: - self._stream.write('-' * self._width + '\n') - self._stream.highlight('FAIL') - self._stream.write(': %s\n%s\n' % (test.longname, - test.message.strip())) + ts = ("test" if not suite.rpa else "task") + s(stats.total) + elapsed = secs_to_timestr(suite.elapsed_time) + self.write( + f"{'=' * self.width}\n" + f"Run suite '{suite.name}' with {stats.total} {ts} in {elapsed}.\n\n" + ) + ed = "ED" if suite.status != "SKIP" else "PED" + self.highlight(suite.status, suite.status + ed) + self.write(f"\n{stats.message}\n") diff --git a/src/robot/output/console/highlighting.py b/src/robot/output/console/highlighting.py index 67fbe8a7724..e9b08b1ab43 100644 --- a/src/robot/output/console/highlighting.py +++ b/src/robot/output/console/highlighting.py @@ -17,184 +17,248 @@ # Andre Burgaud, licensed under the MIT License, and available here: # http://www.burgaud.com/bring-colors-to-the-windows-console-with-python/ -from contextlib import contextmanager +import errno import os import sys +from abc import ABC +from contextlib import contextmanager +from io import TextIOBase +from pathlib import Path +from typing import Literal + try: - from ctypes import windll, Structure, c_short, c_ushort, byref -except ImportError: # Not on Windows or using Jython + from ctypes import windll +except ImportError: # Not on Windows windll = None +else: + from ctypes import byref, c_ushort, Structure + from ctypes.wintypes import _COORD, DWORD, SMALL_RECT + + class ConsoleScreenBufferInfo(Structure): + _fields_ = [ + ("dwSize", _COORD), + ("dwCursorPosition", _COORD), + ("wAttributes", c_ushort), + ("srWindow", SMALL_RECT), + ("dwMaximumWindowSize", _COORD), + ] + from robot.errors import DataError -from robot.utils import console_encode, isatty, WINDOWS +from robot.utils import console_encode, isatty, validate_literal, WINDOWS +from .types import ConsoleColors, ConsoleLinks, Status -class HighlightingStream(object): - def __init__(self, stream, colors='AUTO'): - self.stream = stream - self._highlighter = self._get_highlighter(stream, colors) +class HighlightingStream: - def _get_highlighter(self, stream, colors): - options = {'AUTO': Highlighter if isatty(stream) else NoHighlighting, - 'ON': Highlighter, - 'OFF': NoHighlighting, - 'ANSI': AnsiHighlighter} - try: - highlighter = options[colors.upper()] - except KeyError: - raise DataError("Invalid console color value '%s'. Available " - "'AUTO', 'ON', 'OFF' and 'ANSI'." % colors) - return highlighter(stream) + def __init__( + self, + stream: "TextIOBase | None", + colors: ConsoleColors = "AUTO", + links: ConsoleLinks = "AUTO", + ): + self.stream = stream or NullStream() + self.highlighter = Highlighter.from_config(stream, colors, links) - def write(self, text, flush=True): + def write(self, text: str, flush: bool = True): self._write(console_encode(text, stream=self.stream)) if flush: self.flush() - def _write(self, text, retry=5): - # Workaround for Windows 10 console bug: - # https://github.com/robotframework/robotframework/issues/2709 + def _write(self, text: str, retry: int = 5): try: - self.stream.write(text) + with self._suppress_broken_pipe_error(): + self.stream.write(text) except IOError as err: - if not (WINDOWS and err.errno == 0 and retry > 0): + # Workaround for Windows 10 console bug: + # https://github.com/robotframework/robotframework/issues/2709 + if WINDOWS and err.errno == 0 and retry > 0: + self._write(text, retry - 1) + else: raise - self._write(text, retry-1) - def flush(self): - self.stream.flush() + @contextmanager + def _suppress_broken_pipe_error(self): + try: + yield + except IOError as err: + if err.errno not in (errno.EPIPE, errno.EINVAL, errno.EBADF): + raise - def highlight(self, text, status=None, flush=True): - if self._must_flush_before_and_after_highlighting(): + def flush(self): + with self._suppress_broken_pipe_error(): + self.stream.flush() + + def highlight(self, status: Status, text: "str | None" = None, flush: bool = True): + highlighter = self.highlighter + # Must flush before and after highlighting when using Windows APIs to make + # sure colors only affects the actual highlighted text. + if isinstance(highlighter, DosHighlighter): self.flush() flush = True - with self._highlighting(status or text): - self.write(text, flush) - - def _must_flush_before_and_after_highlighting(self): - # Must flush on Windows before and after highlighting to make sure set - # console colors only affect the actual highlighted text. Problems - # only encountered with Python 3, but better to be safe than sorry. - return WINDOWS and not isinstance(self._highlighter, NoHighlighting) - - def error(self, message, level): - self.write('[ ', flush=False) - self.highlight(level, flush=False) - self.write(' ] %s\n' % message) - - @contextmanager - def _highlighting(self, status): - highlighter = self._highlighter - start = {'PASS': highlighter.green, - 'FAIL': highlighter.red, - 'ERROR': highlighter.red, - 'WARN': highlighter.yellow}[status] - start() + { + "PASS": highlighter.green, + "FAIL": highlighter.red, + "SKIP": highlighter.yellow, + "ERROR": highlighter.red, + "WARN": highlighter.yellow, + }[status]() try: - yield + self.write(text or status, flush) finally: highlighter.reset() + def error(self, message: str, level: Literal["ERROR", "WARN"]): + self.write("[ ", flush=False) + self.highlight(level, flush=False) + self.write(f" ] {message}\n") + + def link(self, path: "Path | None"): + self.write(self.highlighter.link(path) if path else "NONE") -def Highlighter(stream): - if os.sep == '/': - return AnsiHighlighter(stream) - return DosHighlighter(stream) if windll else NoHighlighting(stream) +class NullStream(TextIOBase): -class AnsiHighlighter(object): - _ANSI_GREEN = '\033[32m' - _ANSI_RED = '\033[31m' - _ANSI_YELLOW = '\033[33m' - _ANSI_RESET = '\033[0m' + def write(self, text): + pass - def __init__(self, stream): - self._stream = stream + def flush(self): + pass + + +class Highlighter(ABC): + + @classmethod + def from_config( + cls, + stream: "TextIOBase | None", + colors: ConsoleColors = "AUTO", + links: ConsoleLinks = "AUTO", + ) -> "Highlighter": + try: + colors = validate_literal(colors, ConsoleColors, "console color") + links = validate_literal(links, ConsoleLinks, "console link") + except ValueError as err: + raise DataError(str(err)) + if colors == "AUTO": + colors = "ON" if isatty(stream) else "OFF" + if colors == "OFF" or not stream: + return NoHighlighting() + if os.sep == "/" or colors == "ANSI" or virtual_terminal_enabled(stream): + return AnsiHighlighter(stream, links == "AUTO") + return DosHighlighter(stream) def green(self): - self._set_color(self._ANSI_GREEN) + pass def red(self): - self._set_color(self._ANSI_RED) + pass def yellow(self): - self._set_color(self._ANSI_YELLOW) + pass def reset(self): - self._set_color(self._ANSI_RESET) - - def _set_color(self, color): - self._stream.write(color) + pass + def link(self, path: Path) -> str: + return str(path) -class NoHighlighting(AnsiHighlighter): - def _set_color(self, color): - pass +class NoHighlighting(Highlighter): + pass -class DosHighlighter(object): - _FOREGROUND_GREEN = 0x2 - _FOREGROUND_RED = 0x4 - _FOREGROUND_YELLOW = 0x6 - _FOREGROUND_GREY = 0x7 - _FOREGROUND_INTENSITY = 0x8 - _BACKGROUND_MASK = 0xF0 - _STDOUT_HANDLE = -11 - _STDERR_HANDLE = -12 +class AnsiHighlighter(Highlighter): + GREEN = "\033[32m" + RED = "\033[31m" + YELLOW = "\033[33m" + RESET = "\033[0m" - def __init__(self, stream): - self._handle = self._get_std_handle(stream) - self._orig_colors = self._get_colors() - self._background = self._orig_colors & self._BACKGROUND_MASK + def __init__(self, stream: TextIOBase, links: bool = True): + self.stream = stream + self.links = links def green(self): - self._set_foreground_colors(self._FOREGROUND_GREEN) + self._set_color(self.GREEN) def red(self): - self._set_foreground_colors(self._FOREGROUND_RED) + self._set_color(self.RED) def yellow(self): - self._set_foreground_colors(self._FOREGROUND_YELLOW) + self._set_color(self.YELLOW) def reset(self): - self._set_colors(self._orig_colors) + self._set_color(self.RESET) - def _get_std_handle(self, stream): - handle = self._STDOUT_HANDLE \ - if stream is sys.__stdout__ else self._STDERR_HANDLE - return windll.kernel32.GetStdHandle(handle) + def link(self, path: Path) -> str: + if not self.links: + return str(path) + try: + uri = path.as_uri() + except ValueError: + return str(path) + # Terminal hyperlink syntax is documented here: + # https://gist.github.com/egmontkob/eb114294efbcd5adb1944c9f3cb5feda + return f"\033]8;;{uri}\033\\{path}\033]8;;\033\\" - def _get_colors(self): - csbi = _CONSOLE_SCREEN_BUFFER_INFO() - ok = windll.kernel32.GetConsoleScreenBufferInfo(self._handle, byref(csbi)) - if not ok: # Call failed, return default console colors (gray on black) - return self._FOREGROUND_GREY - return csbi.wAttributes + def _set_color(self, color): + self.stream.write(color) - def _set_foreground_colors(self, colors): - self._set_colors(colors | self._FOREGROUND_INTENSITY | self._background) - def _set_colors(self, colors): - windll.kernel32.SetConsoleTextAttribute(self._handle, colors) +class DosHighlighter(Highlighter): + FOREGROUND_GREEN = 0x2 + FOREGROUND_RED = 0x4 + FOREGROUND_YELLOW = 0x6 + FOREGROUND_GREY = 0x7 + FOREGROUND_INTENSITY = 0x8 + BACKGROUND_MASK = 0xF0 + def __init__(self, stream: TextIOBase): + self.handle = get_std_handle(stream) + self.orig_colors = self._get_colors() + self.background = self.orig_colors & self.BACKGROUND_MASK -if windll: + def green(self): + self._set_foreground_colors(self.FOREGROUND_GREEN) - class _COORD(Structure): - _fields_ = [("X", c_short), - ("Y", c_short)] + def red(self): + self._set_foreground_colors(self.FOREGROUND_RED) - class _SMALL_RECT(Structure): - _fields_ = [("Left", c_short), - ("Top", c_short), - ("Right", c_short), - ("Bottom", c_short)] + def yellow(self): + self._set_foreground_colors(self.FOREGROUND_YELLOW) + + def reset(self): + self._set_colors(self.orig_colors) - class _CONSOLE_SCREEN_BUFFER_INFO(Structure): - _fields_ = [("dwSize", _COORD), - ("dwCursorPosition", _COORD), - ("wAttributes", c_ushort), - ("srWindow", _SMALL_RECT), - ("dwMaximumWindowSize", _COORD)] + def _get_colors(self): + info = ConsoleScreenBufferInfo() + ok = windll.kernel32.GetConsoleScreenBufferInfo(self.handle, byref(info)) + if not ok: # Call failed, return default console colors (gray on black) + return self.FOREGROUND_GREY + return info.wAttributes + + def _set_foreground_colors(self, colors): + self._set_colors(colors | self.FOREGROUND_INTENSITY | self.background) + + def _set_colors(self, colors): + windll.kernel32.SetConsoleTextAttribute(self.handle, colors) + + +def get_std_handle(stream: TextIOBase): + handle = -11 if stream is sys.__stdout__ else -12 + return windll.kernel32.GetStdHandle(handle) + + +def virtual_terminal_enabled(stream): + if not windll: + return False + handle = get_std_handle(stream) + enable_vt = 0x0004 + mode = DWORD() + if not windll.kernel32.GetConsoleMode(handle, byref(mode)): + return False # Calling GetConsoleMode failed. + if mode.value & enable_vt: + return True # VT already enabled. + # Try to enable VT. + return windll.kernel32.SetConsoleMode(handle, mode.value | enable_vt) != 0 diff --git a/src/robot/output/console/quiet.py b/src/robot/output/console/quiet.py index 73c7131423e..f6810cfbae7 100644 --- a/src/robot/output/console/quiet.py +++ b/src/robot/output/console/quiet.py @@ -13,20 +13,45 @@ # See the License for the specific language governing permissions and # limitations under the License. -import sys +from io import TextIOBase +from pathlib import Path +from typing import TYPE_CHECKING -from .highlighting import HighlightingStream +from .base import BaseConsole +from .types import ConsoleColors, ResultFile +if TYPE_CHECKING: + from robot.output import Message -class QuietOutput(object): - def __init__(self, colors='AUTO', stderr=None): - self._stderr = HighlightingStream(stderr or sys.__stderr__, colors) +class QuietConsole(BaseConsole): + """Quiet console logger. - def message(self, msg): - if msg.level in ('WARN', 'ERROR'): - self._stderr.error(msg.message, msg.level) + Only reports warnings and errors. + """ + def __init__( + self, + colors: ConsoleColors = "AUTO", + stderr: "TextIOBase | None" = None, + ): + super().__init__(colors=colors, stderr=stderr) -class NoOutput(object): - pass + def result_file(self, kind: ResultFile, path: Path): + pass + + +class NoneConsole(BaseConsole): + """Totally quiet console logger. + + Does not report anything, not even warnings or errors. + """ + + def __init__(self): + super().__init__() + + def message(self, msg: "Message"): + pass + + def result_file(self, kind: ResultFile, path: Path): + pass diff --git a/src/robot/output/console/types.py b/src/robot/output/console/types.py new file mode 100644 index 00000000000..85f82244016 --- /dev/null +++ b/src/robot/output/console/types.py @@ -0,0 +1,23 @@ +# 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 Literal + +BuiltInConsole = Literal["VERBOSE", "DOTTED", "QUIET", "NONE"] +ConsoleColors = Literal["AUTO", "ON", "OFF", "ANSI"] +ConsoleLinks = Literal["AUTO", "OFF"] +ConsoleMarkers = Literal["AUTO", "ON", "OFF"] +ResultFile = Literal["OUTPUT", "REPORT", "LOG", "XUNIT", "DEBUG"] +Status = Literal["PASS", "FAIL", "SKIP", "ERROR", "WARN"] diff --git a/src/robot/output/console/verbose.py b/src/robot/output/console/verbose.py index 5341106f351..352fc6a5567 100644 --- a/src/robot/output/console/verbose.py +++ b/src/robot/output/console/verbose.py @@ -13,165 +13,133 @@ # See the License for the specific language governing permissions and # limitations under the License. -import sys +from io import TextIOBase +from typing import TYPE_CHECKING from robot.errors import DataError -from robot.utils import (get_console_length, getshortdoc, isatty, - pad_console_length) - -from .highlighting import HighlightingStream - - -class VerboseOutput(object): - - 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) - - def message(self, msg): - if msg.level in ('WARN', 'ERROR'): - self._writer.error(msg.message, msg.level, clear=self._running_test) - - def output_file(self, name, path): - self._writer.output(name, path) - - -class VerboseWriter(object): - _status_length = len('| PASS |') - - 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._last_info = None - - def info(self, name, doc, start_suite=False): - width, separator = self._get_info_width_and_separator(start_suite) - self._last_info = self._get_info(name, doc, width) + separator - self._write_info() - self._keyword_marker.reset_count() - - def _write_info(self): - 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, ' ' +from robot.utils import ( + get_console_length, getshortdoc, isatty, pad_console_length, validate_literal +) + +from .base import BaseConsole +from .types import ConsoleColors, ConsoleLinks, ConsoleMarkers, Status + +if TYPE_CHECKING: + from robot import output, result, running + + +class VerboseConsole(BaseConsole): + """Verbose console logger. + + Reports started suites and tests separately. + """ + + status_length = len("| PASS |") + + def __init__( + self, + width: int = 78, + colors: ConsoleColors = "AUTO", + links: ConsoleLinks = "AUTO", + markers: ConsoleMarkers = "AUTO", + stdout: "TextIOBase | None" = None, + stderr: "TextIOBase | None" = None, + ): + super().__init__(width, colors, links, stdout, stderr) + self.test_started = False + self.keywords_started = 0 + self.markers_enabled = self._markers_enabled(markers) + self.marker_count = 0 + self.current_info = "" - 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 - return pad_console_length(info, width) + def _markers_enabled(self, markers: ConsoleMarkers) -> bool: + try: + markers = validate_literal(markers, ConsoleMarkers, "console marker") + except ValueError as err: + raise DataError(str(err)) + return markers == "AUTO" and isatty(self._stdout.stream) or markers == "ON" + + def start_suite(self, data: "running.TestSuite", result: "result.TestSuite"): + if not result.parent: + self.suite_separator() + self.info(result.full_name, result.doc, start_suite=True) + self.suite_separator() + + def end_suite(self, data: "running.TestSuite", result: "result.TestSuite"): + self.info(result.full_name, result.doc) + self.status(result.status, result.full_message) + self.suite_separator() + + def start_test(self, data: "running.TestCase", result: "result.TestCase"): + self.test_started = True + self.info(result.name, result.doc) + + def end_test(self, data: "running.TestCase", result: "result.TestCase"): + self.status(result.status, result.message) + self.test_separator() + self.test_started = False + + def start_body_item(self, data, result): + self.keywords_started += 1 + + def end_body_item(self, data, result): + self.keywords_started -= 1 + if self.test_started and not self.keywords_started: + self.marker(result.status) + + def message(self, msg: "output.Message"): + if self.marker_count: + self.clear() + super().message(msg) + self.write(self.current_info) + self.marker_count = 0 + else: + super().message(msg) def suite_separator(self): - self._fill('=') + """Write suite separator.""" + self.write(f"{'=' * self.width}\n") def test_separator(self): - self._fill('-') - - def _fill(self, char): - self._stdout.write('%s\n' % (char * self._width)) - - 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') - - def _should_clear_markers(self, clear): - return clear and self._keyword_marker.marking_enabled - - def _clear_status(self): - self._clear_info() - self._write_info() - - def _clear_info(self): - self._stdout.write('\r%s\r' % (' ' * self._width)) - self._keyword_marker.reset_count() - - def message(self, message): - if message: - self._stdout.write(message.strip() + '\n') - - def keyword_marker(self, status): - if self._keyword_marker.marker_count == self._status_length: - self._clear_status() - self._keyword_marker.reset_count() - self._keyword_marker.mark(status) - - def error(self, message, level, clear=False): - if self._should_clear_markers(clear): - self._clear_info() - 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)) - + """Write test separator.""" + self.write(f"{'-' * self.width}\n") -class KeywordMarker(object): - - def __init__(self, highlighter, markers): - self._highlighter = highlighter - self.marking_enabled = self._marking_enabled(markers, highlighter) + def info(self, name: str, doc: str, start_suite: bool = False): + """Write info about started or ended suite or test.""" + if start_suite: + width = self.width + separator = "\n" + else: + width = self.width - self.status_length - 1 + separator = " " + if doc and get_console_length(name) < width: + info = f"{name} :: {getshortdoc(doc, linesep=' ')}" + else: + info = name + self.current_info = pad_console_length(info, width) + separator + self.write(self.current_info) self.marker_count = 0 - def _marking_enabled(self, markers, highlighter): - options = {'AUTO': isatty(highlighter.stream), - 'ON': True, - 'OFF': False} - try: - return options[markers.upper()] - except KeyError: - raise DataError("Invalid console marker value '%s'. Available " - "'AUTO', 'ON' and 'OFF'." % markers) - - def mark(self, status): - if self.marking_enabled: - marker, status = ('.', 'PASS') if status != 'FAIL' else ('F', 'FAIL') - self._highlighter.highlight(marker, status) + def status(self, status: Status, message: str): + """Write status information.""" + if self.marker_count: + self._clear_markers() + self.write("| ", flush=False) + self.highlight(status, flush=False) + self.write(" |\n") + if message: + self.write(message.strip() + "\n") + + def marker(self, status: str): + """Write marker when a top level keyword is started.""" + if self.markers_enabled: + if self.marker_count == self.status_length: + self._clear_markers() + marker, status = (".", "PASS") if status != "FAIL" else ("F", "FAIL") + self.highlight(status, marker) self.marker_count += 1 - def reset_count(self): + def _clear_markers(self): + self.clear() + self.write(self.current_info) self.marker_count = 0 diff --git a/src/robot/output/debugfile.py b/src/robot/output/debugfile.py index 80a4bd9bfcc..f79f9f3a9f3 100644 --- a/src/robot/output/debugfile.py +++ b/src/robot/output/debugfile.py @@ -13,100 +13,112 @@ # See the License for the specific language governing permissions and # limitations under the License. -from robot.utils import get_timestamp, file_writer, seq2str2 +from pathlib import Path + +from robot.errors import DataError +from robot.utils import file_writer, seq2str2 from .logger import LOGGER -from .loggerhelper import IsLogged +from .loggerapi import LoggerApi +from .loglevel import LogLevel def DebugFile(path): if not path: - LOGGER.info('No debug file') + LOGGER.info("No debug file") return None try: - outfile = file_writer(path) - except EnvironmentError as err: - LOGGER.error("Opening debug file '%s' failed: %s" % (path, err.strerror)) + outfile = file_writer(path, usage="debug") + except DataError as err: + LOGGER.error(err.message) return None else: - LOGGER.info('Debug file: %s' % path) + LOGGER.info(f"Debug file: {path}") return _DebugFileWriter(outfile) -class _DebugFileWriter: - _separators = {'SUITE': '=', 'TEST': '-', 'KW': '~'} - _setup_or_teardown = ('setup', 'teardown') +class _DebugFileWriter(LoggerApi): + _separators = {"SUITE": "=", "TEST": "-", "KEYWORD": "~"} def __init__(self, outfile): self._indent = 0 self._kw_level = 0 self._separator_written_last = False self._outfile = outfile - self._is_logged = IsLogged('DEBUG') + self._is_logged = LogLevel("DEBUG").is_logged - def start_suite(self, suite): - self._separator('SUITE') - self._start('SUITE', suite.longname) - self._separator('SUITE') + def start_suite(self, data, result): + self._separator("SUITE") + self._start("SUITE", data.full_name, result.start_time) + self._separator("SUITE") - def end_suite(self, suite): - self._separator('SUITE') - self._end('SUITE', suite.longname, suite.elapsedtime) - self._separator('SUITE') + def end_suite(self, data, result): + self._separator("SUITE") + 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) + LOGGER.debug_file(Path(self._outfile.name)) self.close() - def start_test(self, test): - self._separator('TEST') - self._start('TEST', test.name) - self._separator('TEST') + def start_test(self, data, result): + self._separator("TEST") + self._start("TEST", result.name, result.start_time) + self._separator("TEST") - def end_test(self, test): - self._separator('TEST') - self._end('TEST', test.name, test.elapsedtime) - self._separator('TEST') + def end_test(self, data, result): + self._separator("TEST") + 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('KW') - self._start(self._get_kw_type(kw), kw.name, kw.args) + self._separator("KEYWORD") + self._start( + result.type, result.full_name, result.start_time, seq2str2(result.args) + ) self._kw_level += 1 - def end_keyword(self, kw): - self._end(self._get_kw_type(kw), kw.name, kw.elapsedtime) + 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._log_name, result.start_time) + self._kw_level += 1 + + def end_body_item(self, data, result): + self._end(result.type, result._log_name, result.end_time, result.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) + if self._is_logged(msg): + self._write(f"{msg.timestamp} - {msg.level} - {msg.message}") def close(self): if not self._outfile.closed: self._outfile.close() - def _get_kw_type(self, kw): - if kw.type in self._setup_or_teardown: - return kw.type.upper() - return 'KW' - - 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.write(text.rstrip() + "\n") self._outfile.flush() self._separator_written_last = separator diff --git a/src/robot/output/filelogger.py b/src/robot/output/filelogger.py index cf3a626e48d..4b4257b2dfd 100644 --- a/src/robot/output/filelogger.py +++ b/src/robot/output/filelogger.py @@ -16,47 +16,64 @@ from robot.errors import DataError from robot.utils import file_writer +from .loggerapi import LoggerApi from .loggerhelper import AbstractLogger +from .loglevel import LogLevel -class FileLogger(AbstractLogger): +class FileLogger(AbstractLogger, LoggerApi): def __init__(self, path, level): - AbstractLogger.__init__(self, level) + try: + self._log_level = LogLevel(level) + except ValueError as err: + raise DataError(err) self._writer = self._get_writer(path) # unit test hook def _get_writer(self, path): - try: - return file_writer(path) - except EnvironmentError as err: - raise DataError(err.strerror) + return file_writer(path, usage="syslog") + + def set_level(self, level): + self._log_level.set(level) def message(self, msg): - if self._is_logged(msg.level) and not self._writer.closed: - entry = '%s | %s | %s\n' % (msg.timestamp, msg.level.ljust(5), - msg.message) + if self._log_level.is_logged(msg) and not self._writer.closed: + entry = f"{msg.timestamp} | {msg.level:5} | {msg.message}\n" self._writer.write(entry) - def start_suite(self, suite): - self.info("Started test suite '%s'" % suite.name) + def start_suite(self, data, result): + self.info(f"Started suite '{result.name}'.") - def end_suite(self, suite): - self.info("Ended test suite '%s'" % suite.name) + def end_suite(self, data, result): + self.info(f"Ended suite '{result.name}'.") - def start_test(self, test): - self.info("Started test case '%s'" % test.name) + def start_test(self, data, result): + self.info(f"Started test '{result.name}'.") - def end_test(self, test): - self.info("Ended test case '%s'" % test.name) + def end_test(self, data, result): + self.info(f"Ended test '{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: ( + f"Started keyword '{result.name}'." + if result.type in result.KEYWORD_TYPES + else result._log_name + ) + ) - def end_keyword(self, kw): - self.debug(lambda: "Ended keyword '%s'" % kw.name) + def end_body_item(self, data, result): + self.debug( + lambda: ( + f"Ended keyword '{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)) + def result_file(self, kind, path): + kind = kind.title() if kind != "XUNIT" else "XUnit" + self.info(f"{kind}: {path}") def close(self): self._writer.close() diff --git a/src/robot/output/jsonlogger.py b/src/robot/output/jsonlogger.py new file mode 100644 index 00000000000..58f37e7aa0e --- /dev/null +++ b/src/robot/output/jsonlogger.py @@ -0,0 +1,366 @@ +# 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 json +from collections.abc import Mapping, Sequence +from datetime import datetime +from pathlib import Path +from typing import TextIO + +from robot.version import get_full_version + + +class JsonLogger: + + def __init__(self, file: TextIO, rpa: bool = False): + self.writer = JsonWriter(file) + self.writer.start_dict( + generator=get_full_version("Robot"), + generated=datetime.now().isoformat(), + rpa=Raw(self.writer.encode(rpa)), + ) + self.containers = [] + + def start_suite(self, suite): + if not self.containers: + name = "suite" + container = None + else: + name = None + container = "suites" + self._start(container, name, id=suite.id) + + def end_suite(self, suite): + self._end( + name=suite.name, + doc=suite.doc, + metadata=suite.metadata, + source=suite.source, + rpa=suite.rpa, + **self._status(suite), + ) + + def start_test(self, test): + self._start("tests", id=test.id) + + def end_test(self, test): + self._end( + name=test.name, + doc=test.doc, + tags=test.tags, + metadata=test.metadata, + lineno=test.lineno, + timeout=str(test.timeout) if test.timeout else None, + **self._status(test), + ) + + def start_keyword(self, kw): + if kw.type in ("SETUP", "TEARDOWN"): + self._end_container() + name = kw.type.lower() + container = None + else: + name = None + container = "body" + self._start(container, name) + + def end_keyword(self, kw): + self._end( + name=kw.name, + owner=kw.owner, + source_name=kw.source_name, + args=[str(a) for a in kw.args], + assign=kw.assign, + tags=kw.tags, + doc=kw.doc, + timeout=str(kw.timeout) if kw.timeout else None, + **self._status(kw), + ) + + def start_for(self, item): + self._start(type=item.type) + + def end_for(self, item): + self._end( + flavor=item.flavor, + start=item.start, + mode=item.mode, + fill=UnlessNone(item.fill), + assign=item.assign, + values=item.values, + **self._status(item), + ) + + def start_for_iteration(self, item): + self._start(type=item.type) + + def end_for_iteration(self, item): + self._end(assign=item.assign, **self._status(item)) + + def start_while(self, item): + self._start(type=item.type) + + def end_while(self, item): + self._end( + condition=item.condition, + limit=item.limit, + on_limit=item.on_limit, + on_limit_message=item.on_limit_message, + **self._status(item), + ) + + def start_while_iteration(self, item): + self._start(type=item.type) + + def end_while_iteration(self, item): + self._end(**self._status(item)) + + def start_if(self, item): + self._start(type=item.type) + + def end_if(self, item): + self._end(**self._status(item)) + + def start_if_branch(self, item): + self._start(type=item.type) + + def end_if_branch(self, item): + self._end(condition=item.condition, **self._status(item)) + + def start_try(self, item): + self._start(type=item.type) + + def end_try(self, item): + self._end(**self._status(item)) + + def start_try_branch(self, item): + self._start(type=item.type) + + def end_try_branch(self, item): + self._end( + patterns=item.patterns, + pattern_type=item.pattern_type, + assign=item.assign, + **self._status(item), + ) + + def start_group(self, item): + self._start(type=item.type) + + def end_group(self, item): + self._end(name=item.name, **self._status(item)) + + def start_var(self, item): + self._start(type=item.type) + + def end_var(self, item): + self._end( + name=item.name, + scope=item.scope, + separator=UnlessNone(item.separator), + value=item.value, + **self._status(item), + ) + + def start_return(self, item): + self._start(type=item.type) + + def end_return(self, item): + self._end(values=item.values, **self._status(item)) + + def start_continue(self, item): + self._start(type=item.type) + + def end_continue(self, item): + self._end(**self._status(item)) + + def start_break(self, item): + self._start(type=item.type) + + def end_break(self, item): + self._end(**self._status(item)) + + def start_error(self, item): + self._start(type=item.type) + + def end_error(self, item): + self._end(values=item.values, **self._status(item)) + + def message(self, msg): + self._dict(**msg.to_dict()) + + def errors(self, messages): + self._list("errors", [m.to_dict(include_type=False) for m in messages]) + + def statistics(self, stats): + data = stats.to_dict() + self._start(None, "statistics") + self._dict(None, "total", **data["total"]) + self._list("suites", data["suites"]) + self._list("tags", data["tags"]) + self._end() + + def close(self): + self.writer.end_dict() + self.writer.close() + + def _status(self, item): + return { + "status": item.status, + "message": item.message, + "start_time": item.start_time.isoformat() if item.start_time else None, + "elapsed_time": Raw(format(item.elapsed_time.total_seconds(), "f")), + } + + def _dict( + self, + container: "str | None" = "body", + name: "str | None" = None, + /, + **items, + ): + self._start(container, name, **items) + self._end() + + def _list(self, name: "str | None", items: list): + self.writer.start_list(name) + for item in items: + self._dict(None, None, **item) + self.writer.end_list() + + def _start( + self, + container: "str | None" = "body", + name: "str | None" = None, + /, + **items, + ): + if container: + self._start_container(container) + self.writer.start_dict(name, **items) + self.containers.append(None) + + def _start_container(self, container): + if self.containers[-1] != container: + if self.containers[-1]: + self.writer.end_list() + self.writer.start_list(container) + self.containers[-1] = container + + def _end(self, **items): + self._end_container() + self.containers.pop() + self.writer.end_dict(**items) + + def _end_container(self): + if self.containers[-1]: + self.writer.end_list() + self.containers[-1] = None + + +class JsonWriter: + + def __init__(self, file): + self.encode = json.JSONEncoder( + check_circular=False, + separators=(",", ":"), + default=self._handle_custom, + ).encode + self.file = file + self.comma = False + + def _handle_custom(self, value): + if isinstance(value, Path): + return str(value) + if isinstance(value, Mapping): + return dict(value) + if isinstance(value, Sequence): + return list(value) + raise TypeError(type(value).__name__) + + def start_dict(self, name=None, /, **items): + self._start(name, "{") + self.items(**items) + + def _start(self, name, char): + self._newline(newline=name is not None) + self._name(name) + self._write(char) + self.comma = False + + def _newline(self, comma: "bool | None" = None, newline: bool = True): + if self.comma if comma is None else comma: + self._write(",") + if newline: + self._write("\n") + + def _name(self, name): + if name: + self._write(f'"{name}":') + + def _write(self, text): + self.file.write(text) + + def end_dict(self, **items): + self.items(**items) + self._end("}") + + def _end(self, char, newline=True): + self._newline(comma=False, newline=newline) + self._write(char) + self.comma = True + + def start_list(self, name=None, /): + self._start(name, "[") + + def end_list(self): + self._end("]", newline=False) + + def items(self, **items): + for name, value in items.items(): + self._item(value, name) + + def _item(self, value, name=None): + if isinstance(value, UnlessNone) and value: + value = value.value + elif not (value or value == 0 and not isinstance(value, bool)): + return + if isinstance(value, Raw): + value = value.value + else: + value = self.encode(value) + self._newline() + self._name(name) + self._write(value) + self.comma = True + + def close(self): + self._write("\n") + self.file.close() + + +class Raw: + + def __init__(self, value): + self.value = value + + +class UnlessNone: + + def __init__(self, value): + self.value = value + + def __bool__(self): + return self.value is not None diff --git a/src/robot/output/librarylogger.py b/src/robot/output/librarylogger.py index 919e4aac6bd..d53bc2ae83d 100644 --- a/src/robot/output/librarylogger.py +++ b/src/robot/output/librarylogger.py @@ -13,63 +13,62 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""Implementation of the public test library logging API. +"""Implementation of the public logging API for libraries. This is exposed via :py:mod:`robot.api.logger`. Implementation must reside here to avoid cyclic imports. """ -import sys -import threading +from threading import current_thread +from typing import Literal -from robot.errors import DataError -from robot.utils import unic, console_encode +from robot.utils import safe_str from .logger import LOGGER -from .loggerhelper import Message +from .loggerhelper import Message, MessageLevel, PseudoLevel, write_to_console +# This constant is used by BackgroundLogger. +# https://github.com/robotframework/robotbackgroundlogger +LOGGING_THREADS = ["MainThread", "RobotFrameworkTimeoutThread"] -LOGGING_THREADS = ('MainThread', 'RobotFrameworkTimeoutThread') - -def write(msg, level, html=False): - # Callable messages allow lazy logging internally, but we don't want to - # expose this functionality publicly. See the following issue for details: - # https://github.com/robotframework/robotframework/issues/1505 - if callable(msg): - msg = unic(msg) - if level.upper() not in ('TRACE', 'DEBUG', 'INFO', 'HTML', 'WARN', 'ERROR'): - raise DataError("Invalid log level '%s'." % level) - if threading.currentThread().getName() in LOGGING_THREADS: - LOGGER.log_message(Message(msg, level, html)) +def write( + msg: object, + level: "MessageLevel | PseudoLevel" = "INFO", + html: bool = False, + console: "bool | None" = None, +): + if not isinstance(msg, str): + msg = safe_str(msg) + if level == "FAIL": + raise ValueError(f"Invalid log level '{level}'.") + if current_thread().name in LOGGING_THREADS: + LOGGER.log_message(Message(msg, level, html=html, console=console)) def trace(msg, html=False): - write(msg, 'TRACE', html) + write(msg, "TRACE", html) def debug(msg, html=False): - write(msg, 'DEBUG', html) + write(msg, "DEBUG", html) -def info(msg, html=False, also_console=False): - write(msg, 'INFO', html) - if also_console: - console(msg) +def info(msg, html=False, console=False): + write(msg, "INFO", html, console) def warn(msg, html=False): - write(msg, 'WARN', html) + write(msg, "WARN", html) def error(msg, html=False): - write(msg, 'ERROR', html) + write(msg, "ERROR", html) -def console(msg, newline=True, stream='stdout'): - msg = unic(msg) - if newline: - msg += '\n' - stream = sys.__stdout__ if stream.lower() != 'stderr' else sys.__stderr__ - stream.write(console_encode(msg, stream=stream)) - stream.flush() +def console( + msg: object, + newline: bool = True, + stream: Literal["stdout", "stderr"] = "stdout", +): + write_to_console(msg, newline, stream) diff --git a/src/robot/output/listenerarguments.py b/src/robot/output/listenerarguments.py deleted file mode 100644 index 6fda009f86e..00000000000 --- a/src/robot/output/listenerarguments.py +++ /dev/null @@ -1,141 +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 is_list_like, is_dict_like, is_string, unic - - -class ListenerArguments(object): - - 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): - attributes = {'timestamp': msg.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 item.name, attributes - - def _get_attribute_value(self, item, name): - value = getattr(item, name) - 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', 'longname', 'doc', 'metadata', 'starttime') - - 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 ''} - - -class EndSuiteArguments(StartSuiteArguments): - _attribute_names = ('id', 'longname', 'doc', 'metadata', 'starttime', - 'endtime', 'elapsedtime', 'status', 'message') - - def _get_extra_attributes(self, suite): - attrs = StartSuiteArguments._get_extra_attributes(self, suite) - attrs['statistics'] = suite.stat_message - return attrs - - -class StartTestArguments(_ListenerArgumentsFromItem): - _attribute_names = ('id', 'longname', 'doc', 'tags', 'starttime') - - def _get_extra_attributes(self, test): - return {'critical': 'yes' if test.critical else 'no', - 'template': test.template or ''} - - -class EndTestArguments(StartTestArguments): - _attribute_names = ('id', 'longname', 'doc', 'tags', 'starttime', - 'endtime', 'elapsedtime', 'status', 'message') - - -class StartKeywordArguments(_ListenerArgumentsFromItem): - _attribute_names = ('kwname', 'libname', 'doc', 'assign', 'tags', - 'starttime') - _types = {'kw': 'Keyword', 'setup': 'Setup', 'teardown': 'Teardown', - 'for': 'For', 'foritem': 'For Item'} - - def _get_extra_attributes(self, kw): - args = [a if is_string(a) else unic(a) for a in kw.args] - return {'args': args, 'type': self._types[kw.type]} - - -class EndKeywordArguments(StartKeywordArguments): - _attribute_names = ('kwname', 'libname', 'doc', 'args', 'assign', 'tags', - 'starttime', 'endtime', 'elapsedtime', 'status') diff --git a/src/robot/output/listenermethods.py b/src/robot/output/listenermethods.py deleted file mode 100644 index 8bece247bdb..00000000000 --- a/src/robot/output/listenermethods.py +++ /dev/null @@ -1,114 +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.utils import get_error_details, py2to3 - -from .listenerarguments import ListenerArguments -from .logger import LOGGER - - -@py2to3 -class ListenerMethods(object): - - 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 __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 __nonzero__(self): - return bool(self._methods) - - -class LibraryListenerMethods(object): - - 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(object): - # 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/listeners.py b/src/robot/output/listeners.py index f7a73608783..1c25134e1ce 100644 --- a/src/robot/output/listeners.py +++ b/src/robot/output/listeners.py @@ -14,155 +14,636 @@ # limitations under the License. import os.path +from abc import ABC +from collections.abc import Sequence +from pathlib import Path from robot.errors import DataError -from robot.utils import (Importer, is_string, py2to3, - split_args_from_name_or_path, type_name) +from robot.model import BodyItem +from robot.utils import ( + get_error_details, Importer, safe_str, split_args_from_name_or_path, type_name +) -from .listenermethods import ListenerMethods, LibraryListenerMethods -from .loggerhelper import AbstractLoggerProxy, IsLogged from .logger import LOGGER +from .loggerapi import LoggerApi +from .loglevel import LogLevel, SettableLevel -@py2to3 -class Listeners(object): - _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) - for name in self._method_names: - method = ListenerMethods(name, listeners) - if name.endswith(('_file', '_import', 'log_message')): - name = '_' + name - setattr(self, name, method) - - 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): - method = getattr(self, '_%s_import' % import_type.lower()) - method(name, attrs) - - def output_file(self, file_type, path): - method = getattr(self, '_%s_file' % file_type.lower()) - method(path) - - def __nonzero__(self): - return any(isinstance(method, ListenerMethods) and method - for method in self.__dict__.values()) - - -class LibraryListeners(object): - _method_names = ('start_suite', 'end_suite', 'start_test', 'end_test', - 'start_keyword', 'end_keyword', 'log_message', 'message', - 'close') - - 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) - - 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.__dict__.values() - if isinstance(method, LibraryListenerMethods)] +class Listeners: + _listeners: "list[ListenerFacade]" - def unregister(self, library, close=False): - if close: - self.close(library=library) - for method in self._listener_methods(): - method.unregister(library) + def __init__( + self, + listeners: "Sequence[str | object]" = (), + log_level: "LogLevel | SettableLevel" = "INFO", + ): + if isinstance(log_level, str): + log_level = LogLevel(log_level) + self._log_level = log_level + self._listeners = self._import_listeners(listeners) + + # Must be property to allow LibraryListeners to override it. + @property + def listeners(self): + return self._listeners + + def _import_listeners(self, listeners, library=None) -> "list[ListenerFacade]": + imported = [] + for listener in listeners: + if library and isinstance(listener, str) and listener.upper() == "SELF": + listener = library.instance + try: + facade = ListenerFacade.create(listener, self._log_level, library) + except DataError as err: + if library: + raise + LOGGER.error(str(err)) + else: + imported.append(facade) + return imported + + def __iter__(self): + return iter(self.listeners) + + def __len__(self): + return len(self.listeners) + + +class LibraryListeners(Listeners): + _listeners: "list[list[ListenerFacade]]" + + def __init__(self, log_level: "LogLevel | str" = "INFO"): + super().__init__(log_level=log_level) + + @property + def listeners(self): + return self._listeners[-1] if self._listeners else [] def new_suite_scope(self): - for method in self._listener_methods(): - method.new_suite_scope() + self._listeners.append([]) def discard_suite_scope(self): - for method in self._listener_methods(): - method.discard_suite_scope() + self._listeners.pop() - def set_log_level(self, level): - self._is_logged.set_level(level) + def register(self, library): + listeners = self._import_listeners(library.listeners, library=library) + self._listeners[-1].extend(listeners) - def log_message(self, msg): - if self._is_logged(msg.level): - self._log_message(msg) + def unregister(self, library, close=False): + remaining = [] + for listener in self._listeners[-1]: + if listener.library is not library: + remaining.append(listener) + elif close: + listener.close() + self._listeners[-1] = remaining - def imported(self, import_type, name, attrs): - pass - def output_file(self, file_type, path): - pass +class ListenerFacade(LoggerApi, ABC): + def __init__(self, listener, name, log_level, library=None): + self.listener = listener + self.name = name + self._is_logged = log_level.is_logged + self.library = library + self.priority = self._get_priority(listener) -class ListenerProxy(AbstractLoggerProxy): - _no_method = None + def _get_priority(self, listener): + priority = getattr(listener, "ROBOT_LISTENER_PRIORITY", 0) + if isinstance(priority, (int, float)): + return priority + try: + try: + return int(priority) + except ValueError: + return float(priority) + except (ValueError, TypeError): + raise DataError(f"Invalid listener priority '{priority}'.") - 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 + @classmethod + def create( + cls, + listener: "str | object", + log_level: "LogLevel | SettableLevel" = "INFO", + library: object = None, + kind: str = "listener", + ) -> "ListenerFacade": + if isinstance(log_level, str): + log_level = LogLevel(log_level) + try: + return cls._import_listener(listener, log_level, library, kind) + except DataError as err: + name = listener if isinstance(listener, str) else type_name(listener) + raise DataError(f"Taking {kind} '{name}' into use failed: {err}") - def _import_listener(self, listener): - if not is_string(listener): + @classmethod + def _import_listener( + cls, listener, log_level, library=None, kind="listener" + ) -> "ListenerFacade": + if isinstance(listener, str): + name, args = split_args_from_name_or_path(listener) + importer = Importer(kind, logger=LOGGER) + listener = importer.import_class_or_module( + os.path.normpath(name), + instantiate_with_args=args, + ) + else: # 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') - listener = importer.import_class_or_module(os.path.normpath(name), - instantiate_with_args=args) - return listener, name - - def _get_version(self, listener): + name = getattr(listener, "__name__", None) or type_name(listener) + if cls._get_version(listener) == 2: + return ListenerV2Facade(listener, name, log_level, library) + return ListenerV3Facade(listener, name, log_level, library) + + @classmethod + def _get_version(cls, listener): + version = getattr(listener, "ROBOT_LISTENER_API_VERSION", 3) try: - version = int(listener.ROBOT_LISTENER_API_VERSION) + version = int(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)) + raise DataError(f"Unsupported API version '{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 + def _get_method(self, name, fallback=None): + for method_name in self._get_method_names(name): + method = getattr(self.listener, method_name, None) + if method: + return ListenerMethod(method, self.name) + return fallback or ListenerMethod(None, self.name) + + def _get_method_names(self, name): + 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 _to_camelCase(self, name): + first, *rest = name.split("_") + return "".join([first] + [part.capitalize() for part in rest]) + + +class ListenerV3Facade(ListenerFacade): + + def __init__(self, listener, name, log_level, library=None): + super().__init__(listener, name, log_level, library) + get = self._get_method + # Suite + self.start_suite = get("start_suite") + self.end_suite = get("end_suite") + # Test + self.start_test = get("start_test") + self.end_test = get("end_test") + # Fallbacks for body items + start_body_item = get("start_body_item") + end_body_item = get("end_body_item") + # Fallbacks for keywords + start_keyword = get("start_keyword", start_body_item) + end_keyword = get("end_keyword", end_body_item) + # Keywords + self.start_user_keyword = get( + "start_user_keyword", + lambda data, implementation, result: start_keyword(data, result), + ) + self.end_user_keyword = get( + "end_user_keyword", + lambda data, implementation, result: end_keyword(data, result), + ) + self.start_library_keyword = get( + "start_library_keyword", + lambda data, implementation, result: start_keyword(data, result), + ) + self.end_library_keyword = get( + "end_library_keyword", + lambda data, implementation, result: end_keyword(data, result), + ) + self.start_invalid_keyword = get( + "start_invalid_keyword", + lambda data, implementation, result: start_keyword(data, result), + ) + self.end_invalid_keyword = get( + "end_invalid_keyword", + lambda data, implementation, result: end_keyword(data, result), + ) + # IF + self.start_if = get("start_if", start_body_item) + self.end_if = get("end_if", end_body_item) + self.start_if_branch = get("start_if_branch", start_body_item) + self.end_if_branch = get("end_if_branch", end_body_item) + # TRY + self.start_try = get("start_try", start_body_item) + self.end_try = get("end_try", end_body_item) + self.start_try_branch = get("start_try_branch", start_body_item) + self.end_try_branch = get("end_try_branch", end_body_item) + # FOR + self.start_for = get("start_for", start_body_item) + self.end_for = get("end_for", end_body_item) + self.start_for_iteration = get("start_for_iteration", start_body_item) + self.end_for_iteration = get("end_for_iteration", end_body_item) + # WHILE + self.start_while = get("start_while", start_body_item) + self.end_while = get("end_while", end_body_item) + self.start_while_iteration = get("start_while_iteration", start_body_item) + self.end_while_iteration = get("end_while_iteration", end_body_item) + # GROUP + self.start_group = get("start_group", start_body_item) + self.end_group = get("end_group", end_body_item) + # VAR + self.start_var = get("start_var", start_body_item) + self.end_var = get("end_var", end_body_item) + # BREAK + self.start_break = get("start_break", start_body_item) + self.end_break = get("end_break", end_body_item) + # CONTINUE + self.start_continue = get("start_continue", start_body_item) + self.end_continue = get("end_continue", end_body_item) + # RETURN + self.start_return = get("start_return", start_body_item) + self.end_return = get("end_return", end_body_item) + # ERROR + self.start_error = get("start_error", start_body_item) + self.end_error = get("end_error", end_body_item) + # Messages + self._log_message = get("log_message") + self.message = get("message") + # Imports + self.library_import = get("library_import") + self.resource_import = get("resource_import") + self.variables_import = get("variables_import") + # Result files + result_file = get("result_file") + self.output_file = get( + "output_file", + lambda path: result_file("OUTPUT", path) if path is not None else None, + ) + self.report_file = get( + "report_file", + lambda path: result_file("REPORT", path), + ) + self.log_file = get( + "log_file", + lambda path: result_file("LOG", path), + ) + self.xunit_file = get( + "xunit_file", + lambda path: result_file("XUNIT", path), + ) + self.debug_file = get( + "debug_file", + lambda path: result_file("DEBUG", path), + ) + # Close + self.close = get("close") + + def log_message(self, message): + if self._is_logged(message): + self._log_message(message) + + +class ListenerV2Facade(ListenerFacade): + + def __init__(self, listener, name, log_level, library=None): + super().__init__(listener, name, log_level, library) + get = self._get_method + # Suite + self._start_suite = get("start_suite") + self._end_suite = get("end_suite") + # Test + self._start_test = get("start_test") + self._end_test = get("end_test") + # Keyword and control structures + self._start_kw = get("start_keyword") + self._end_kw = get("end_keyword") + # Messages + self._log_message = get("log_message") + self._message = get("message") + # Imports + self._library_import = get("library_import") + self._resource_import = get("resource_import") + self._variables_import = get("variables_import") + # Result files + self._output_file = get("output_file") + self._report_file = get("report_file") + self._log_file = get("log_file") + self._xunit_file = get("xunit_file") + self._debug_file = get("debug_file") + # Close + self._close = get("close") + + def start_suite(self, data, result): + self._start_suite(result.name, self._suite_attrs(data, result)) + + def end_suite(self, data, result): + self._end_suite(result.name, self._suite_attrs(data, result, end=True)) + + def start_test(self, data, result): + self._start_test(result.name, self._test_attrs(data, result)) + + def end_test(self, data, result): + self._end_test(result.name, self._test_attrs(data, result, end=True)) + + def start_keyword(self, data, result): + self._start_kw(result.full_name, self._keyword_attrs(data, result)) + + def end_keyword(self, data, result): + self._end_kw(result.full_name, self._keyword_attrs(data, result, end=True)) + + def start_for(self, data, result): + extra = self._for_extra_attrs(result) + self._start_kw(result._log_name, self._attrs(data, result, **extra)) + + def end_for(self, data, result): + extra = self._for_extra_attrs(result) + self._end_kw(result._log_name, self._attrs(data, result, **extra, end=True)) + + 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, 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=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, + on_limit=result.on_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_group(self, data, result): + self._start_kw(result._log_name, self._attrs(data, result, name=result.name)) + + def end_group(self, data, result): + attrs = self._attrs(data, result, name=result.name, end=True) + self._end_kw(result._log_name, attrs) + + 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_kw(result._log_name, self._attrs(data, result, **extra)) + + def end_try_branch(self, data, result): + extra = self._try_extra_attrs(result) + self._end_kw(result._log_name, self._attrs(data, result, **extra, end=True)) + + 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, result): + attrs = self._attrs(data, result, values=list(result.values)) + self._start_kw(result._log_name, attrs) + + 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, result): + self._start_kw(result._log_name, self._attrs(data, result)) + + def end_continue(self, data, result): + self._end_kw(result._log_name, self._attrs(data, result, end=True)) + + def start_break(self, data, result): + self._start_kw(result._log_name, self._attrs(data, result)) + + def end_break(self, data, result): + self._end_kw(result._log_name, self._attrs(data, result, end=True)) + + def start_error(self, data, result): + self._start_kw(result._log_name, self._attrs(data, result)) + + def end_error(self, data, result): + self._end_kw(result._log_name, self._attrs(data, result, end=True)) + + def start_var(self, 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): + 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): + if self._is_logged(message): + self._log_message(self._message_attributes(message)) + + def message(self, message): + self._message(self._message_attributes(message)) + + def library_import(self, library, importer): + attrs = { + "args": list(importer.args), + "originalname": library.real_name, + "source": str(library.source or ""), + "importer": str(importer.source), + } + self._library_import(library.name, attrs) + + def resource_import(self, resource, importer): + self._resource_import( + resource.name, + {"source": str(resource.source), "importer": str(importer.source)}, + ) + + def variables_import(self, attrs: dict, importer): + self._variables_import( + attrs["name"], + { + "args": list(attrs["args"]), + "source": str(attrs["source"]), + "importer": str(importer.source), + }, + ) + + def output_file(self, path: Path): + self._output_file(str(path)) + + def report_file(self, path: Path): + self._report_file(str(path)) + + def log_file(self, path: Path): + self._log_file(str(path)) + + def xunit_file(self, path: Path): + self._xunit_file(str(path)) + + def debug_file(self, path: Path): + self._debug_file(str(path)) + + def _suite_attrs(self, data, result, end=False): + attrs = dict( + 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 end: + attrs.update( + endtime=result.endtime, + elapsedtime=result.elapsedtime, + status=result.status, + message=result.message, + statistics=result.stat_message, + ) + return attrs + + def _test_attrs(self, data, result, end=False): + attrs = dict( + id=data.id, + doc=result.doc, + tags=list(result.tags), + metadata=dict(result.metadata), + lineno=data.lineno, + starttime=result.starttime, + longname=result.full_name, + source=str(data.source or ""), + template=data.template or "", + originalname=data.name, + ) + if end: + attrs.update( + endtime=result.endtime, + elapsedtime=result.elapsedtime, + status=result.status, + message=result.message, + ) + return attrs + + def _keyword_attrs(self, data, result, end=False): + attrs = dict( + 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 isinstance(a, str) else safe_str(a) for a in result.args], + assign=list(result.assign), + tags=list(result.tags), + ) + if end: + attrs.update( + endtime=result.endtime, + elapsedtime=result.elapsedtime, + ) + return attrs + + def _attrs(self, data, result, end=False, **extra): + attrs = dict( + doc="", + lineno=data.lineno, + type=result.type, + status=result.status, + starttime=result.starttime, + source=str(data.source or ""), + kwname=result._log_name, + libname="", + args=[], + assign=[], + tags=[], + **extra, + ) + if end: + attrs.update( + endtime=result.endtime, + elapsedtime=result.elapsedtime, + ) + return attrs + + def _message_attributes(self, msg): + # Timestamp in our legacy format. + ts = msg.timestamp.isoformat(" ", timespec="milliseconds").replace("-", "") + return { + "timestamp": ts, + "message": msg.message, + "level": msg.level, + "html": "yes" if msg.html else "no", + } + + def close(self): + self._close() + + +class ListenerMethod: + + def __init__(self, method, name): + self.method = method + self.listener_name = name + + def __call__(self, *args): + try: + if self.method is not None: + self.method(*args) + except Exception: + message, details = get_error_details() + LOGGER.error( + f"Calling method '{self.method.__name__}' of listener " + f"'{self.listener_name}' failed: {message}" + ) + LOGGER.info(f"Details:\n{details}") + + def __bool__(self): + return self.method is not None diff --git a/src/robot/output/logger.py b/src/robot/output/logger.py index 3a392d0d68b..7598a797fd1 100644 --- a/src/robot/output/logger.py +++ b/src/robot/output/logger.py @@ -13,17 +13,33 @@ # See the License for the specific language governing permissions and # limitations under the License. -from contextlib import contextmanager import os +from contextlib import contextmanager from robot.errors import DataError from .console import ConsoleOutput from .filelogger import FileLogger -from .loggerhelper import AbstractLogger, AbstractLoggerProxy +from .loggerhelper import AbstractLogger, write_to_console from .stdoutlogsplitter import StdoutLogSplitter +def start_body_item(method): + def wrapper(self, *args): + self._log_message_parents.append(args[-1]) + method(self, *args) + + return wrapper + + +def end_body_item(method): + def wrapper(self, *args): + method(self, *args) + self._log_message_parents.pop() + + return wrapper + + class Logger(AbstractLogger): """A global logger proxy to delegating messages to registered loggers. @@ -31,38 +47,49 @@ class Logger(AbstractLogger): notified. Messages are also cached and cached messages written to new loggers when they are registered. - NOTE: This API is likely to change in future versions. + NOTE: This is a private API and likely to change in the future. """ def __init__(self, register_console_logger=True): - self._console_logger = None + self._auto_register_console_logger = register_console_logger + self.__console = None self._syslog = None - self._xml_logger = None - self._listeners = None - self._library_listeners = None + self._output_file = None + self._cli_listeners = None + self._lib_listeners = None self._other_loggers = [] self._message_cache = [] - self._log_message_cache = None - self._started_keywords = 0 + self._log_message_parents = [] + self._library_import_logging = 0 self._error_occurred = False self._error_listener = None - self._prev_log_message_handlers = [] self._enabled = 0 self._cache_only = False - if register_console_logger: + + @property + def _console(self): + if self.__console is None and self._auto_register_console_logger: self.register_console_logger() + return self.__console + + @property + def _std_loggers(self): + loggers = (self._console, self._syslog, self._output_file) + return [lo for lo in loggers if lo] + + @property + def _listeners(self): + cli_listeners = tuple(self._cli_listeners or ()) + lib_listeners = tuple(self._lib_listeners or ()) + return sorted(cli_listeners + lib_listeners, key=lambda li: -li.priority) @property def start_loggers(self): - loggers = [self._console_logger, self._syslog, self._xml_logger, - self._listeners, self._library_listeners] - return [logger for logger in self._other_loggers + loggers if logger] + return [*self._other_loggers, *self._std_loggers, *self._listeners] @property def end_loggers(self): - loggers = [self._listeners, self._library_listeners, - self._console_logger, self._syslog, self._xml_logger] - return [logger for logger in loggers + self._other_loggers if logger] + return [*self._listeners, *self._std_loggers, *self._other_loggers] def __iter__(self): return iter(self.end_loggers) @@ -77,15 +104,19 @@ def __exit__(self, *exc_info): if not self._enabled: self.close() - def register_console_logger(self, type='verbose', width=78, colors='AUTO', - markers='AUTO', stdout=None, stderr=None): - logger = ConsoleOutput(type, width, colors, markers, stdout, stderr) - self._console_logger = self._wrap_and_relay(logger) - - def _wrap_and_relay(self, logger): - logger = LoggerProxy(logger) - self._relay_cached_messages(logger) - return logger + def register_console_logger( + self, + console="verbose", + width=78, + colors="AUTO", + links="AUTO", + markers="AUTO", + stdout=None, + stderr=None, + ): + console = ConsoleOutput(console, width, colors, links, markers, stdout, stderr) + self.__console = console + self._relay_cached_messages(console) def _relay_cached_messages(self, logger): if self._message_cache: @@ -93,42 +124,43 @@ def _relay_cached_messages(self, logger): logger.message(msg) def unregister_console_logger(self): - self._console_logger = None + self.__console = None + self._auto_register_console_logger = False - def register_syslog(self, path=None, level='INFO'): + def register_syslog(self, path=None, level="INFO"): if not path: - path = os.environ.get('ROBOT_SYSLOG_FILE', 'NONE') - level = os.environ.get('ROBOT_SYSLOG_LEVEL', level) - if path.upper() == 'NONE': + path = os.environ.get("ROBOT_SYSLOG_FILE", "NONE") + level = os.environ.get("ROBOT_SYSLOG_LEVEL", level) + if path.upper() == "NONE": return try: - syslog = FileLogger(path, level) + self._syslog = FileLogger(path, level) except DataError as err: - self.error("Opening syslog file '%s' failed: %s" % (path, err.message)) + self.error(f"Opening syslog file '{path}' failed: {err}") else: - self._syslog = self._wrap_and_relay(syslog) + self._relay_cached_messages(self._syslog) - def register_xml_logger(self, logger): - self._xml_logger = self._wrap_and_relay(logger) + def register_output_file(self, logger): + self._relay_cached_messages(logger) + self._output_file = logger - def unregister_xml_logger(self): - self._xml_logger = None + def unregister_output_file(self): + self._output_file = None def register_listeners(self, listeners, library_listeners): - self._listeners = listeners - self._library_listeners = library_listeners - if listeners: - self._relay_cached_messages(listeners) + self._cli_listeners = listeners + self._lib_listeners = library_listeners + for listener in listeners or (): + self._relay_cached_messages(listener) def register_logger(self, *loggers): for logger in loggers: - logger = self._wrap_and_relay(logger) + self._relay_cached_messages(logger) self._other_loggers.append(logger) 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 = [lo for lo in self._other_loggers if lo is not logger] def disable_message_cache(self): self._message_cache = None @@ -145,7 +177,7 @@ def message(self, msg): logger.message(msg) if self._message_cache is not None: self._message_cache.append(msg) - if msg.level == 'ERROR': + if msg.level == "ERROR": self._error_occurred = True if self._error_listener: self._error_listener() @@ -159,81 +191,270 @@ def cache_only(self): finally: self._cache_only = False - @property - @contextmanager - def delayed_logging(self): - prev_cache = self._log_message_cache - self._log_message_cache = [] - try: - yield - finally: - messages = self._log_message_cache - self._log_message_cache = prev_cache - for msg in messages or (): - self._log_message(msg, no_cache=True) - - def _log_message(self, msg, no_cache=False): + def log_message(self, msg): """Log messages written (mainly) by libraries.""" - if self._log_message_cache is not None and not no_cache: - msg.resolve_delayed_message() - self._log_message_cache.append(msg) - return - for logger in self: - logger.log_message(msg) - if msg.level in ('WARN', 'ERROR'): + logged = False + # Common case. We can log normally. + if self._log_message_parents and not self._library_import_logging: + for logger in self: + logger.log_message(msg) + if self._output_file and self._output_file.is_logged(msg): + self._log_message_parents[-1].body.append(msg) + logged = True + # Handle warnings and errors as well as logging to console. + if msg.level in ("WARN", "ERROR"): + self.message(msg) + logged = True + elif msg.console: + write_to_console(msg.message) + msg.console = False + # If not logged otherwise, log as a system message. + if not logged: self.message(msg) - - log_message = message def log_output(self, output): for msg in StdoutLogSplitter(output): self.log_message(msg) def enable_library_import_logging(self): - self._prev_log_message_handlers.append(self.log_message) - self.log_message = self.message + self._library_import_logging += 1 def disable_library_import_logging(self): - self.log_message = self._prev_log_message_handlers.pop() + self._library_import_logging -= 1 + + def start_suite(self, data, result): + for logger in self.start_loggers: + logger.start_suite(data, result) + + def end_suite(self, data, result): + for logger in self.end_loggers: + logger.end_suite(data, result) + + def start_test(self, data, result): + self._log_message_parents.append(result) + for logger in self.start_loggers: + logger.start_test(data, result) + + def end_test(self, data, result): + for logger in self.end_loggers: + logger.end_test(data, result) + self._log_message_parents.pop() + + @start_body_item + def start_keyword(self, data, result): + for logger in self.start_loggers: + logger.start_keyword(data, result) + + @end_body_item + def end_keyword(self, data, result): + for logger in self.end_loggers: + logger.end_keyword(data, result) + + @start_body_item + def start_user_keyword(self, data, implementation, result): + for logger in self.start_loggers: + logger.start_user_keyword(data, implementation, result) + + @end_body_item + def end_user_keyword(self, data, implementation, result): + for logger in self.end_loggers: + logger.end_user_keyword(data, implementation, result) + + @start_body_item + def start_library_keyword(self, data, implementation, result): + for logger in self.start_loggers: + logger.start_library_keyword(data, implementation, result) + + @end_body_item + def end_library_keyword(self, data, implementation, result): + for logger in self.end_loggers: + logger.end_library_keyword(data, implementation, result) + + @start_body_item + def start_invalid_keyword(self, data, implementation, result): + for logger in self.start_loggers: + logger.start_invalid_keyword(data, implementation, result) + + @end_body_item + def end_invalid_keyword(self, data, implementation, result): + for logger in self.end_loggers: + logger.end_invalid_keyword(data, implementation, result) + + @start_body_item + def start_for(self, data, result): + for logger in self.start_loggers: + logger.start_for(data, result) + + @end_body_item + def end_for(self, data, result): + for logger in self.end_loggers: + logger.end_for(data, result) - def start_suite(self, suite): + @start_body_item + def start_for_iteration(self, data, result): for logger in self.start_loggers: - logger.start_suite(suite) + logger.start_for_iteration(data, result) - def end_suite(self, suite): + @end_body_item + def end_for_iteration(self, data, result): for logger in self.end_loggers: - logger.end_suite(suite) + logger.end_for_iteration(data, result) - def start_test(self, test): + @start_body_item + def start_while(self, data, result): for logger in self.start_loggers: - logger.start_test(test) + logger.start_while(data, result) - def end_test(self, test): + @end_body_item + def end_while(self, data, result): for logger in self.end_loggers: - logger.end_test(test) + logger.end_while(data, result) - def start_keyword(self, keyword): - # TODO: Could _prev_log_message_handlers be used also here? - self._started_keywords += 1 - self.log_message = self._log_message + @start_body_item + def start_while_iteration(self, data, result): for logger in self.start_loggers: - logger.start_keyword(keyword) + logger.start_while_iteration(data, result) - def end_keyword(self, keyword): - self._started_keywords -= 1 + @end_body_item + def end_while_iteration(self, data, result): for logger in self.end_loggers: - logger.end_keyword(keyword) - if not self._started_keywords: - self.log_message = self.message + logger.end_while_iteration(data, result) - def imported(self, import_type, name, **attrs): + @start_body_item + def start_group(self, data, result): + for logger in self.start_loggers: + logger.start_group(data, result) + + @end_body_item + def end_group(self, data, result): + for logger in self.end_loggers: + logger.end_group(data, result) + + @start_body_item + def start_if(self, data, result): + for logger in self.start_loggers: + logger.start_if(data, result) + + @end_body_item + def end_if(self, data, result): + for logger in self.end_loggers: + logger.end_if(data, result) + + @start_body_item + def start_if_branch(self, data, result): + for logger in self.start_loggers: + logger.start_if_branch(data, result) + + @end_body_item + def end_if_branch(self, data, result): + for logger in self.end_loggers: + logger.end_if_branch(data, result) + + @start_body_item + def start_try(self, data, result): + for logger in self.start_loggers: + logger.start_try(data, result) + + @end_body_item + def end_try(self, data, result): + for logger in self.end_loggers: + logger.end_try(data, result) + + @start_body_item + def start_try_branch(self, data, result): + for logger in self.start_loggers: + logger.start_try_branch(data, result) + + @end_body_item + 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: + logger.start_break(data, result) + + @end_body_item + def end_break(self, data, result): + for logger in self.end_loggers: + logger.end_break(data, result) + + @start_body_item + def start_continue(self, data, result): + for logger in self.start_loggers: + logger.start_continue(data, result) + + @end_body_item + def end_continue(self, data, result): + for logger in self.end_loggers: + logger.end_continue(data, result) + + @start_body_item + def start_return(self, data, result): + for logger in self.start_loggers: + logger.start_return(data, result) + + @end_body_item + def end_return(self, data, result): + for logger in self.end_loggers: + logger.end_return(data, result) + + @start_body_item + def start_error(self, data, result): + for logger in self.start_loggers: + logger.start_error(data, result) + + @end_body_item + def end_error(self, data, result): + for logger in self.end_loggers: + logger.end_error(data, result) + + def library_import(self, library, importer): + for logger in self: + logger.library_import(library, importer) + + def resource_import(self, resource, importer): for logger in self: - logger.imported(import_type, name, attrs) + logger.resource_import(resource, importer) - def output_file(self, file_type, path): - """Finished output, report, log, debug, or xunit file""" + def variables_import(self, variables, importer): for logger in self: - logger.output_file(file_type, path) + logger.variables_import(variables, importer) + + def output_file(self, path): + for logger in self: + logger.output_file(path) + + def report_file(self, path): + for logger in self: + logger.report_file(path) + + def log_file(self, path): + for logger in self: + logger.log_file(path) + + def xunit_file(self, path): + for logger in self: + logger.xunit_file(path) + + def debug_file(self, path): + for logger in self: + logger.debug_file(path) + + def result_file(self, kind, path): + kind_file = getattr(self, f"{kind.lower()}_file") + kind_file(path) def close(self): for logger in self: @@ -241,10 +462,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') - - LOGGER = Logger() diff --git a/src/robot/output/loggerapi.py b/src/robot/output/loggerapi.py new file mode 100644 index 00000000000..dd1712839b2 --- /dev/null +++ b/src/robot/output/loggerapi.py @@ -0,0 +1,270 @@ +# 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 pathlib import Path +from typing import Literal, TYPE_CHECKING + +if TYPE_CHECKING: + from robot import model, result, running + + +ResultFile = Literal["OUTPUT", "REPORT", "LOG", "XUNIT", "DEBUG"] + + +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_user_keyword( + self, + data: "running.Keyword", + implementation: "running.UserKeyword", + result: "result.Keyword", + ): + self.start_keyword(data, result) + + def end_user_keyword( + self, + data: "running.Keyword", + implementation: "running.UserKeyword", + result: "result.Keyword", + ): + self.end_keyword(data, result) + + def start_library_keyword( + self, + data: "running.Keyword", + implementation: "running.LibraryKeyword", + result: "result.Keyword", + ): + self.start_keyword(data, result) + + def end_library_keyword( + self, + data: "running.Keyword", + implementation: "running.LibraryKeyword", + result: "result.Keyword", + ): + self.end_keyword(data, result) + + def start_invalid_keyword( + self, + data: "running.Keyword", + implementation: "running.KeywordImplementation", + result: "result.Keyword", + ): + self.start_keyword(data, result) + + def end_invalid_keyword( + self, + data: "running.Keyword", + implementation: "running.KeywordImplementation", + result: "result.Keyword", + ): + self.end_keyword(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.ForIteration", + result: "result.ForIteration", + ): + self.start_body_item(data, result) + + def end_for_iteration( + self, + data: "running.ForIteration", + 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.WhileIteration", + result: "result.WhileIteration", + ): + self.start_body_item(data, result) + + def end_while_iteration( + self, + data: "running.WhileIteration", + result: "result.WhileIteration", + ): + self.end_body_item(data, result) + + def start_group(self, data: "running.Group", result: "result.Group"): + self.start_body_item(data, result) + + def end_group(self, data: "running.Group", result: "result.Group"): + 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.IfBranch", result: "result.IfBranch"): + self.start_body_item(data, result) + + 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"): + 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.TryBranch", result: "result.TryBranch"): + self.start_body_item(data, result) + + 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"): + 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) + + def end_break(self, data: "running.Break", result: "result.Break"): + self.end_body_item(data, result) + + def start_continue(self, data: "running.Continue", result: "result.Continue"): + self.start_body_item(data, result) + + def end_continue(self, data: "running.Continue", result: "result.Continue"): + self.end_body_item(data, result) + + def start_return(self, data: "running.Return", result: "result.Return"): + self.start_body_item(data, result) + + def end_return(self, data: "running.Return", result: "result.Return"): + self.end_body_item(data, result) + + def start_error(self, data: "running.Error", result: "result.Error"): + self.start_body_item(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): + pass + + def end_body_item(self, data, result): + pass + + def log_message(self, message: "model.Message"): + pass + + def message(self, message: "model.Message"): + pass + + def output_file(self, path: Path): + """Called when XML output file is closed. + + Calls :meth:`result_file` by default. + """ + self.result_file("OUTPUT", path) + + def report_file(self, path: Path): + """Called when report file is closed. + + Calls :meth:`result_file` by default. + """ + self.result_file("REPORT", path) + + def log_file(self, path: Path): + """Called when log file is closed. + + Calls :meth:`result_file` by default. + """ + self.result_file("LOG", path) + + def xunit_file(self, path: Path): + """Called when xunit file is closed. + + Calls :meth:`result_file` by default. + """ + self.result_file("XUNIT", path) + + def debug_file(self, path: Path): + """Called when debug file is closed. + + Calls :meth:`result_file` by default. + """ + self.result_file("DEBUG", path) + + def result_file(self, kind: ResultFile, path: Path): + """Called when any result file is closed by default. + + ``kind`` specifies the file type. This method is not called if a result + file specific method like :meth:`output_file` is implemented. + """ + pass + + def imported(self, import_type: str, name: str, attrs): + pass + + def library_import( + self, + library: "running.TestLibrary", + importer: "running.Import", + ): + pass + + def resource_import( + self, + resource: "running.ResourceFile", + importer: "running.Import", + ): + pass + + def variables_import(self, attrs: dict, importer: "running.Import"): + pass + + def close(self): + pass diff --git a/src/robot/output/loggerhelper.py b/src/robot/output/loggerhelper.py index e47a850a9bb..376e9f57baa 100644 --- a/src/robot/output/loggerhelper.py +++ b/src/robot/output/loggerhelper.py @@ -13,51 +13,64 @@ # See the License for the specific language governing permissions and # limitations under the License. -from robot.errors import DataError -from robot.model import Message as BaseMessage -from robot.utils import get_timestamp, is_unicode, unic +import sys +from datetime import datetime +from typing import Callable, Literal +from robot.model import MessageLevel +from robot.result import Message as BaseMessage +from robot.utils import console_encode -LEVELS = { - 'NONE' : 6, - 'FAIL' : 5, - 'ERROR' : 4, - 'WARN' : 3, - 'INFO' : 2, - 'DEBUG' : 1, - 'TRACE' : 0, -} +from .loglevel import LEVELS +PseudoLevel = Literal["HTML", "CONSOLE"] -class AbstractLogger(object): - def __init__(self, level='TRACE'): - self._is_logged = IsLogged(level) +def write_to_console( + msg: object, + newline: bool = True, + stream: Literal["stdout", "stderr"] = "stdout", +): + if not isinstance(msg, str): + msg = str(msg) + if newline: + msg += "\n" + stream = sys.__stdout__ if stream.lower() != "stderr" else sys.__stderr__ + if stream: + stream.write(console_encode(msg, stream=stream)) + stream.flush() - def set_level(self, level): - return self._is_logged.set_level(level) + +class AbstractLogger: def trace(self, msg): - self.write(msg, 'TRACE') + self.write(msg, "TRACE") def debug(self, msg): - self.write(msg, 'DEBUG') + self.write(msg, "DEBUG") def info(self, msg): - self.write(msg, 'INFO') + self.write(msg, "INFO") def warn(self, msg): - self.write(msg, 'WARN') + self.write(msg, "WARN") def fail(self, msg): html = False if msg.startswith("*HTML*"): html = True msg = msg[6:].lstrip() - self.write(msg, 'FAIL', html) + self.write(msg, "FAIL", html) + + def skip(self, msg): + html = False + if msg.startswith("*HTML*"): + html = True + msg = msg[6:].lstrip() + self.write(msg, "SKIP", html) def error(self, msg): - self.write(msg, 'ERROR') + self.write(msg, "ERROR") def write(self, message, level, html=False): self.message(Message(message, level, html)) @@ -67,87 +80,67 @@ def message(self, msg): class Message(BaseMessage): - __slots__ = ['_message'] - - def __init__(self, message, level='INFO', html=False, timestamp=None): - message = self._normalize_message(message) - level, html = self._get_level_and_html(level, html) - timestamp = timestamp or get_timestamp() - BaseMessage.__init__(self, message, level, html, timestamp) - - def _normalize_message(self, msg): - if callable(msg): - return msg - if not is_unicode(msg): - msg = unic(msg) - if '\r\n' in msg: - msg = msg.replace('\r\n', '\n') - return msg - - def _get_level_and_html(self, level, html): + """Represents message logged during execution. + + Most messages are logged by libraries. They typically log strings, but + possible non-string items have been converted to strings already before + they end up here. + + In addition to strings, Robot Framework itself logs also callables to make + constructing messages that are not typically needed lazy. Such messages are + resolved when they are accessed. + + Listeners can remove messages by setting the `message` attribute to `None`. + These messages are not written to the output.xml at all. + + The ``console`` parameter controls writing the message to the console in + addition to the log file. By default, messages with the ``WARN`` and + ``ERROR`` level are logged to the console and others are not. + This parameter is new in Robot Framework 7.4. + """ + + __slots__ = ("_message", "console") + + def __init__( + self, + message: "str | None | Callable[[], str | None]" = "", + level: "MessageLevel | PseudoLevel" = "INFO", + html: bool = False, + timestamp: "datetime | str | None" = None, + console: "bool | None" = None, + ): + level, html, console = self._get_level_html_console(level, html, console) + super().__init__(message, level, html, timestamp or datetime.now()) + self.console = console + + def _get_level_html_console( + self, + level: "MessageLevel | PseudoLevel", + html: bool, + console: "bool | None", + ) -> "tuple[MessageLevel, bool, bool]": level = level.upper() - if level == 'HTML': - return 'INFO', True - if level not in LEVELS: - raise DataError("Invalid log level '%s'." % level) - return level, html + if console is None: + console = level in ("WARN", "ERROR") + if level == "HTML": + return "INFO", True, console + if level == "CONSOLE": + return "INFO", html, True + if level in LEVELS: + return level, html, console + raise ValueError(f"Invalid log level '{level}'.") @property - def message(self): + def message(self) -> "str | None": self.resolve_delayed_message() return self._message @message.setter - def message(self, message): + def message(self, message: "str | None | Callable[[], str | None]"): + if isinstance(message, str) and "\r\n" in message: + message = message.replace("\r\n", "\n") self._message = message def resolve_delayed_message(self): if callable(self._message): - self._message = self._message() - - -class IsLogged(object): - - def __init__(self, level): - self._str_level = level - self._int_level = self._level_to_int(level) - - def __call__(self, level): - return self._level_to_int(level) >= self._int_level - - def set_level(self, level): - old = self._str_level.upper() - self.__init__(level) - return old - - def _level_to_int(self, level): - try: - return LEVELS[level.upper()] - except KeyError: - raise DataError("Invalid log level '%s'." % level) - - -class AbstractLoggerProxy(object): - _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: - setattr(self, 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:]]) + self.message = self._message() diff --git a/src/robot/output/loglevel.py b/src/robot/output/loglevel.py new file mode 100644 index 00000000000..00bd67b7b61 --- /dev/null +++ b/src/robot/output/loglevel.py @@ -0,0 +1,52 @@ +# 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 Literal, TYPE_CHECKING + +if TYPE_CHECKING: + from .loggerhelper import Message + + +SettableLevel = Literal["TRACE", "DEBUG", "INFO", "WARN", "ERROR", "NONE"] +LEVELS = { + "NONE": 7, + "SKIP": 6, + "FAIL": 5, + "ERROR": 4, + "WARN": 3, + "INFO": 2, + "DEBUG": 1, + "TRACE": 0, +} + + +class LogLevel: + + def __init__(self, level: SettableLevel): + self.level, self.priority = self._validate_level(level) + + def is_logged(self, msg: "Message") -> bool: + return LEVELS[msg.level] >= self.priority and msg.message is not None + + def set(self, level: SettableLevel) -> SettableLevel: + old = self.level + self.__init__(level) + return old + + def _validate_level(self, level) -> "tuple[SettableLevel, int]": + upper = level.upper() + if upper not in LEVELS or upper in ("SKIP", "FAIL"): + raise ValueError(f"Invalid log level '{level}'.") + return upper, LEVELS[upper] diff --git a/src/robot/output/output.py b/src/robot/output/output.py index 88a796e1e2b..8d3e7194ebe 100644 --- a/src/robot/output/output.py +++ b/src/robot/output/output.py @@ -17,23 +17,33 @@ from .debugfile import DebugFile from .listeners import LibraryListeners, Listeners from .logger import LOGGER +from .loggerapi import LoggerApi from .loggerhelper import AbstractLogger -from .xmllogger import XmlLogger +from .loglevel import LogLevel +from .outputfile import OutputFile -class Output(AbstractLogger): +class Output(AbstractLogger, LoggerApi): def __init__(self, settings): - AbstractLogger.__init__(self) - self._xmllogger = XmlLogger(settings.output, settings.log_level, - settings.rpa) - self.listeners = Listeners(settings.listeners, settings.log_level) - self.library_listeners = LibraryListeners(settings.log_level) + self.log_level = LogLevel(settings.log_level) + self.output_file = OutputFile( + settings.output, + self.log_level, + settings.rpa, + legacy_output=settings.legacy_output, + ) + self.listeners = Listeners(settings.listeners, self.log_level) + self.library_listeners = LibraryListeners(self.log_level) self._register_loggers(DebugFile(settings.debug_file)) self._settings = settings + @property + def initial_log_level(self): + return self._settings.log_level + def _register_loggers(self, debug_file): - LOGGER.register_xml_logger(self._xmllogger) + LOGGER.register_output_file(self.output_file) LOGGER.register_listeners(self.listeners or None, self.library_listeners) if debug_file: LOGGER.register_logger(debug_file) @@ -41,35 +51,148 @@ def _register_loggers(self, debug_file): def register_error_listener(self, listener): LOGGER.register_error_listener(listener) + @property + def delayed_logging(self): + return self.output_file.delayed_logging + + @property + def delayed_logging_paused(self): + return self.output_file.delayed_logging_paused + def close(self, result): - self._xmllogger.visit_statistics(result.statistics) - self._xmllogger.close() - LOGGER.unregister_xml_logger() - LOGGER.output_file('Output', self._settings['Output']) + self.output_file.statistics(result.statistics) + self.output_file.close() + LOGGER.unregister_output_file() + LOGGER.output_file(self._settings["Output"]) + + def start_suite(self, data, result): + LOGGER.start_suite(data, result) + + def end_suite(self, data, result): + LOGGER.end_suite(data, result) + + def start_test(self, data, result): + LOGGER.start_test(data, result) + + def end_test(self, data, result): + LOGGER.end_test(data, result) + + def start_keyword(self, data, result): + LOGGER.start_keyword(data, result) + + def end_keyword(self, data, result): + LOGGER.end_keyword(data, result) + + def start_user_keyword(self, data, implementation, result): + LOGGER.start_user_keyword(data, implementation, result) + + def end_user_keyword(self, data, implementation, result): + LOGGER.end_user_keyword(data, implementation, result) + + def start_library_keyword(self, data, implementation, result): + LOGGER.start_library_keyword(data, implementation, result) + + def end_library_keyword(self, data, implementation, result): + LOGGER.end_library_keyword(data, implementation, result) + + def start_invalid_keyword(self, data, implementation, result): + LOGGER.start_invalid_keyword(data, implementation, result) + + def end_invalid_keyword(self, data, implementation, result): + LOGGER.end_invalid_keyword(data, implementation, 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_for_iteration(data, result) + + def end_for_iteration(self, data, result): + LOGGER.end_for_iteration(data, result) - def start_suite(self, suite): - LOGGER.start_suite(suite) + def start_while(self, data, result): + LOGGER.start_while(data, result) - def end_suite(self, suite): - LOGGER.end_suite(suite) + def end_while(self, data, result): + LOGGER.end_while(data, result) - def start_test(self, test): - LOGGER.start_test(test) + def start_while_iteration(self, data, result): + LOGGER.start_while_iteration(data, result) - def end_test(self, test): - LOGGER.end_test(test) + def end_while_iteration(self, data, result): + LOGGER.end_while_iteration(data, result) - def start_keyword(self, kw): - LOGGER.start_keyword(kw) + def start_group(self, data, result): + LOGGER.start_group(data, result) - def end_keyword(self, kw): - LOGGER.end_keyword(kw) + def end_group(self, data, result): + LOGGER.end_group(data, result) + + def start_if(self, data, result): + LOGGER.start_if(data, result) + + def end_if(self, data, result): + LOGGER.end_if(data, result) + + def start_if_branch(self, data, result): + LOGGER.start_if_branch(data, result) + + def end_if_branch(self, data, result): + LOGGER.end_if_branch(data, result) + + def start_try(self, data, result): + LOGGER.start_try(data, result) + + def end_try(self, data, result): + LOGGER.end_try(data, result) + + def start_try_branch(self, data, result): + LOGGER.start_try_branch(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) + + def end_break(self, data, result): + LOGGER.end_break(data, result) + + def start_continue(self, data, result): + LOGGER.start_continue(data, result) + + def end_continue(self, data, result): + LOGGER.end_continue(data, result) + + def start_return(self, data, result): + LOGGER.start_return(data, result) + + def end_return(self, data, result): + LOGGER.end_return(data, result) + + def start_error(self, data, result): + LOGGER.start_error(data, result) + + def end_error(self, data, result): + LOGGER.end_error(data, result) def message(self, msg): LOGGER.log_message(msg) + def trace(self, msg, write_if_flat=True): + if write_if_flat or not self.output_file.flatten_level: + self.write(msg, "TRACE") + def set_log_level(self, level): + old = self.log_level.set(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 old diff --git a/src/robot/output/outputfile.py b/src/robot/output/outputfile.py new file mode 100644 index 00000000000..9e1ceb4c844 --- /dev/null +++ b/src/robot/output/outputfile.py @@ -0,0 +1,212 @@ +# 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 contextlib import contextmanager +from pathlib import Path + +from robot.errors import DataError +from robot.utils import get_error_message + +from .jsonlogger import JsonLogger +from .loggerapi import LoggerApi +from .loglevel import LogLevel +from .xmllogger import LegacyXmlLogger, NullLogger, XmlLogger + + +class OutputFile(LoggerApi): + + def __init__( + self, + path: "Path | None", + log_level: LogLevel, + rpa: bool = False, + legacy_output: bool = False, + ): + # `self.logger` is replaced with `NullLogger` when flattening. + self.logger = self.real_logger = self._get_logger(path, rpa, legacy_output) + self.is_logged = log_level.is_logged + self.flatten_level = 0 + self.errors = [] + self._delayed_messages = None + + def _get_logger(self, path, rpa, legacy_output): + if not path: + return NullLogger() + try: + file = open(path, "w", encoding="UTF-8") + except Exception: + raise DataError( + f"Opening output file '{path}' failed: {get_error_message()}" + ) + if path.suffix.lower() == ".json": + return JsonLogger(file, rpa) + if legacy_output: + return LegacyXmlLogger(file, rpa) + return XmlLogger(file, rpa) + + @property + @contextmanager + def delayed_logging(self): + self._delayed_messages, previous = [], self._delayed_messages + try: + yield + finally: + self._release_delayed_messages() + self._delayed_messages = previous + + @property + @contextmanager + def delayed_logging_paused(self): + self._release_delayed_messages() + self._delayed_messages = None + try: + yield + finally: + self._delayed_messages = [] + + def _release_delayed_messages(self): + for msg in self._delayed_messages or (): + self.log_message(msg, no_delay=True) + + 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) + if result.tags.robot("flatten"): + self.flatten_level += 1 + self.logger = NullLogger() + + def end_keyword(self, data, result): + if self.flatten_level and result.tags.robot("flatten"): + self.flatten_level -= 1 + if self.flatten_level == 0: + self.logger = self.real_logger + 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_group(self, data, result): + self.logger.start_group(result) + + def end_group(self, data, result): + self.logger.end_group(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) + + 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, no_delay=False): + if self.is_logged(message): + if self._delayed_messages is None or no_delay: + # Use the real logger also when flattening. + self.real_logger.message(message) + else: + # Logging is delayed when using timeouts to avoid writing to output + # files being interrupted. There are still problems, though: + # https://github.com/robotframework/robotframework/issues/5417 + self._delayed_messages.append(message) + + def message(self, message): + if message.level in ("WARN", "ERROR"): + self.errors.append(message) + + def statistics(self, stats): + self.logger.statistics(stats) + + def close(self): + self.logger.errors(self.errors) + self.logger.close() diff --git a/src/robot/output/pyloggingconf.py b/src/robot/output/pyloggingconf.py index be741b4ae1d..b6ba0bf3128 100644 --- a/src/robot/output/pyloggingconf.py +++ b/src/robot/output/pyloggingconf.py @@ -13,19 +13,20 @@ # See the License for the specific language governing permissions and # limitations under the License. -from contextlib import contextmanager import logging +from contextlib import contextmanager -from robot.utils import get_error_details, unic +from robot.utils import get_error_details, safe_str from . import librarylogger - -LEVELS = {'TRACE': logging.NOTSET, - 'DEBUG': logging.DEBUG, - 'INFO': logging.INFO, - 'WARN': logging.WARNING, - 'ERROR': logging.ERROR} +LEVELS = { + "TRACE": logging.NOTSET, + "DEBUG": logging.DEBUG, + "INFO": logging.INFO, + "WARN": logging.WARNING, + "ERROR": logging.ERROR, +} @contextmanager @@ -36,6 +37,7 @@ def robot_handler_enabled(level): return handler = RobotHandler() old_raise = logging.raiseExceptions + old_level = root.level root.addHandler(handler) logging.raiseExceptions = False set_level(level) @@ -43,6 +45,7 @@ def robot_handler_enabled(level): yield finally: root.removeHandler(handler) + root.setLevel(old_level) logging.raiseExceptions = old_raise @@ -56,29 +59,34 @@ def set_level(level): class RobotHandler(logging.Handler): + def __init__(self, level=logging.NOTSET, library_logger=librarylogger): + super().__init__(level) + self.library_logger = library_logger + def emit(self, record): message, error = self._get_message(record) method = self._get_logger_method(record.levelno) method(message) if error: - librarylogger.debug(error) + self.library_logger.debug(error) def _get_message(self, record): try: - return record.getMessage(), None - except: - message = 'Failed to log following message properly: %s' \ - % unic(record.msg) - error = '\n'.join(get_error_details()) + return self.format(record), None + except Exception: + message = ( + f"Failed to log following message properly: {safe_str(record.msg)}" + ) + error = "\n".join(get_error_details()) return message, error def _get_logger_method(self, level): if level >= logging.ERROR: - return librarylogger.error + return self.library_logger.error if level >= logging.WARNING: - return librarylogger.warn + return self.library_logger.warn if level >= logging.INFO: - return librarylogger.info + return self.library_logger.info if level >= logging.DEBUG: - return librarylogger.debug - return librarylogger.trace + return self.library_logger.debug + return self.library_logger.trace diff --git a/src/robot/output/stdoutlogsplitter.py b/src/robot/output/stdoutlogsplitter.py index b442a370840..6b2f29931d2 100644 --- a/src/robot/output/stdoutlogsplitter.py +++ b/src/robot/output/stdoutlogsplitter.py @@ -14,19 +14,21 @@ # limitations under the License. import re - -from robot.utils import format_time +from datetime import datetime from .loggerhelper import Message -class StdoutLogSplitter(object): +class StdoutLogSplitter: """Splits messages logged through stdout (or stderr) into Message objects""" - _split_from_levels = re.compile('^(?:\*' - '(TRACE|DEBUG|INFO|HTML|WARN|ERROR)' - '(:\d+(?:\.\d+)?)?' # Optional timestamp - '\*)', re.MULTILINE) + _split_from_levels = re.compile( + r"^(?:\*" + r"(TRACE|DEBUG|INFO|CONSOLE|HTML|WARN|ERROR)" + r"(:\d+(?:\.\d+)?)?" # Optional timestamp + r"\*)", + re.MULTILINE, + ) def __init__(self, output): self._messages = list(self._get_messages(output.strip())) @@ -34,25 +36,28 @@ def __init__(self, output): def _get_messages(self, output): for level, timestamp, msg in self._split_output(output): 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): tokens = self._split_from_levels.split(output) tokens = self._add_initial_level_and_time_if_needed(tokens) for i in range(0, len(tokens), 3): - yield tokens[i:i+3] + yield tokens[i : i + 3] def _add_initial_level_and_time_if_needed(self, tokens): if self._output_started_with_level(tokens): return tokens[1:] - return ['INFO', None] + tokens + return ["INFO", None, *tokens] def _output_started_with_level(self, tokens): - return tokens[0] == '' - - def _format_timestamp(self, millis): - return format_time(float(millis)/1000, millissep='.') + return tokens[0] == "" 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/src/robot/output/xmllogger.py b/src/robot/output/xmllogger.py index d9f151f7afb..8cb373a108c 100644 --- a/src/robot/output/xmllogger.py +++ b/src/robot/output/xmllogger.py @@ -13,150 +13,340 @@ # See the License for the specific language governing permissions and # limitations under the License. -from robot.errors import DataError -from robot.utils import XmlWriter, NullMarkupWriter, get_timestamp, unic -from robot.version import get_full_version -from robot.result.visitor import ResultVisitor +from datetime import datetime -from .loggerhelper import IsLogged +from robot.result import Keyword, ResultVisitor, TestCase, TestSuite +from robot.utils import NullMarkupWriter, XmlWriter +from robot.version import get_full_version class XmlLogger(ResultVisitor): + generator = "Robot" + + def __init__(self, output, rpa=False, suite_only=False): + self._writer = self._get_writer(output, preamble=not suite_only) + if not suite_only: + self._writer.start("robot", self._get_start_attrs(rpa)) + + def _get_writer(self, output, preamble=True): + return XmlWriter(output, usage="output", write_empty=False, preamble=preamble) - 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) - self._errors = [] - - def _get_writer(self, path, rpa, generator): - if not path: - return NullMarkupWriter() - try: - writer = XmlWriter(path, write_empty=False) - except EnvironmentError as err: - raise DataError("Opening output file '%s' failed: %s" % - (path, err.strerror)) - writer.start('robot', {'generator': get_full_version(generator), - 'generated': get_timestamp(), - 'rpa': 'true' if rpa else 'false'}) - return writer + def _get_start_attrs(self, rpa): + return { + "generator": get_full_version(self.generator), + "generated": datetime.now().isoformat(), + "rpa": "true" if rpa else "false", + "schemaversion": "5", + } def close(self): - self.start_errors() - for msg in self._errors: - self._write_message(msg) - self.end_errors() - self._writer.end('robot') + self._writer.end("robot") self._writer.close() - def set_log_level(self, level): - return self._log_message_is_logged.set_level(level) + def visit_message(self, msg): + self._write_message(msg) def message(self, msg): - if self._error_message_is_logged(msg.level): - self._errors.append(msg) - - def log_message(self, msg): - if self._log_message_is_logged(msg.level): - self._write_message(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'] = 'yes' - self._writer.element('msg', msg.message, attrs) + attrs["html"] = "true" + self._writer.element("msg", msg.message, attrs) def start_keyword(self, kw): - attrs = {'name': kw.kwname, 'library': kw.libname} - if kw.type != 'kw': - attrs['type'] = kw.type - self._writer.start('kw', attrs) - self._write_list('tags', 'tag', [unic(t) for t in kw.tags]) - self._writer.element('doc', kw.doc) - self._write_list('arguments', 'arg', [unic(a) for a in kw.args]) - self._write_list('assign', 'var', kw.assign) + self._writer.start("kw", self._get_start_keyword_attrs(kw)) + + def _get_start_keyword_attrs(self, kw): + attrs = {"name": kw.name, "owner": kw.owner} + if kw.type != "KEYWORD": + attrs["type"] = kw.type + if kw.source_name: + attrs["source_name"] = kw.source_name + return attrs def end_keyword(self, kw): + self._write_list("var", kw.assign) + self._write_list("arg", [str(a) for a in kw.args]) + self._write_list("tag", kw.tags) + self._writer.element("doc", kw.doc) if kw.timeout: - self._writer.element('timeout', attrs={'value': unic(kw.timeout)}) + self._writer.element("timeout", attrs={"value": str(kw.timeout)}) self._write_status(kw) - self._writer.end('kw') + self._writer.end("kw") + + def start_if(self, if_): + self._writer.start("if") + + def end_if(self, if_): + self._write_status(if_) + self._writer.end("if") + + def start_if_branch(self, branch): + attrs = {"type": branch.type, "condition": branch.condition} + self._writer.start("branch", attrs) + + def end_if_branch(self, branch): + self._write_status(branch) + self._writer.end("branch") + + def start_for(self, for_): + attrs = { + "flavor": for_.flavor, + "start": for_.start, + "mode": for_.mode, + "fill": for_.fill, + } + self._writer.start("for", attrs) + + def end_for(self, for_): + for name in for_.assign: + self._writer.element("var", name) + for value in for_.values: + self._writer.element("value", value) + self._write_status(for_) + self._writer.end("for") + + def start_for_iteration(self, iteration): + self._writer.start("iter") + + def end_for_iteration(self, iteration): + for name, value in iteration.assign.items(): + self._writer.element("var", value, {"name": name}) + self._write_status(iteration) + self._writer.end("iter") + + def start_try(self, root): + self._writer.start("try") + + def end_try(self, root): + self._write_status(root) + self._writer.end("try") + + def start_try_branch(self, branch): + attrs = { + "type": "EXCEPT", + "pattern_type": branch.pattern_type, + "assign": branch.assign, + } + if branch.type == branch.EXCEPT: + self._writer.start("branch", attrs) + self._write_list("pattern", branch.patterns) + else: + self._writer.start("branch", attrs={"type": branch.type}) + + def end_try_branch(self, branch): + self._write_status(branch) + self._writer.end("branch") + + def start_while(self, while_): + attrs = { + "condition": while_.condition, + "limit": while_.limit, + "on_limit": while_.on_limit, + "on_limit_message": while_.on_limit_message, + } + self._writer.start("while", attrs) + + def end_while(self, while_): + self._write_status(while_) + self._writer.end("while") + + def start_while_iteration(self, iteration): + self._writer.start("iter") + + def end_while_iteration(self, iteration): + self._write_status(iteration) + self._writer.end("iter") + + def start_group(self, group): + self._writer.start("group", {"name": group.name}) + + def end_group(self, group): + self._write_status(group) + self._writer.end("group") + + 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) + + def end_var(self, var): + for val in var.value: + self._writer.element("var", val) + self._write_status(var) + self._writer.end("variable") + + def start_return(self, return_): + self._writer.start("return") + + def end_return(self, return_): + for value in return_.values: + self._writer.element("value", value) + self._write_status(return_) + self._writer.end("return") + + def start_continue(self, continue_): + self._writer.start("continue") + + def end_continue(self, continue_): + self._write_status(continue_) + self._writer.end("continue") + + def start_break(self, break_): + self._writer.start("break") + + def end_break(self, break_): + self._write_status(break_) + self._writer.end("break") + + def start_error(self, error): + self._writer.start("error") + + def end_error(self, error): + for value in error.values: + self._writer.element("value", value) + self._write_status(error) + self._writer.end("error") def start_test(self, test): - self._writer.start('test', {'id': test.id, 'name': test.name}) + attrs = {"id": test.id, "name": test.name, "line": str(test.lineno or "")} + self._writer.start("test", attrs) def end_test(self, test): - self._writer.element('doc', test.doc) - self._write_list('tags', 'tag', test.tags) + self._writer.element("doc", test.doc) + for name, value in test.metadata.items(): + self._writer.element("meta", value, {"name": name}) + self._write_list("tag", test.tags) if test.timeout: - self._writer.element('timeout', attrs={'value': unic(test.timeout)}) - self._write_status(test, {'critical': 'yes' if test.critical else 'no'}) - self._writer.end('test') + self._writer.element("timeout", attrs={"value": str(test.timeout)}) + self._write_status(test) + self._writer.end("test") def start_suite(self, suite): - attrs = {'id': suite.id, 'name': suite.name, 'source': suite.source} - self._writer.start('suite', attrs) + 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): - self._writer.element('doc', suite.doc) - if suite.metadata: - self._write_metadata(suite.metadata) + self._writer.element("doc", suite.doc) + for name, value in suite.metadata.items(): + self._writer.element("meta", value, {"name": name}) self._write_status(suite) - self._writer.end('suite') + self._writer.end("suite") - def _write_metadata(self, metadata): - self._writer.start('metadata') - for name, value in metadata.items(): - self._writer.element('item', value, {'name': name}) - self._writer.end('metadata') + def statistics(self, stats): + self.visit_statistics(stats) def start_statistics(self, stats): - self._writer.start('statistics') + self._writer.start("statistics") def end_statistics(self, stats): - self._writer.end('statistics') + self._writer.end("statistics") def start_total_statistics(self, total_stats): - self._writer.start('total') + self._writer.start("total") def end_total_statistics(self, total_stats): - self._writer.end('total') + self._writer.end("total") def start_tag_statistics(self, tag_stats): - self._writer.start('tag') + self._writer.start("tag") def end_tag_statistics(self, tag_stats): - self._writer.end('tag') + self._writer.end("tag") def start_suite_statistics(self, tag_stats): - self._writer.start('suite') + self._writer.start("suite") def end_suite_statistics(self, tag_stats): - self._writer.end('suite') + self._writer.end("suite") def visit_stat(self, stat): - self._writer.element('stat', stat.name, - stat.get_attributes(values_as_strings=True)) - - def start_errors(self, errors=None): - self._writer.start('errors') - - def end_errors(self, errors=None): - self._writer.end('errors') - - def _write_list(self, container_tag, item_tag, items): - if items: - self._writer.start(container_tag) - for item in items: - self._writer.element(item_tag, item) - self._writer.end(container_tag) - - def _write_status(self, item, extra_attrs=None): - 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) - if extra_attrs: - attrs.update(extra_attrs) - self._writer.element('status', item.message, attrs) + attrs = stat.get_attributes(values_as_strings=True) + self._writer.element("stat", stat.name, attrs) + + def errors(self, errors): + self.visit_errors(errors) + + def start_errors(self, errors): + self._writer.start("errors") + + def end_errors(self, errors): + self._writer.end("errors") + + def _write_list(self, tag, items): + for item in 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": format(item.elapsed_time.total_seconds(), "f"), + } + self._writer.element("status", item.message, attrs) + + +class LegacyXmlLogger(XmlLogger): + + def _get_start_attrs(self, rpa): + return { + "generator": get_full_version(self.generator), + "generated": self._datetime_to_timestamp(datetime.now()), + "rpa": "true" if rpa else "false", + "schemaversion": "4", + } + + def _datetime_to_timestamp(self, dt): + if dt is None: + return None + return dt.isoformat(" ", timespec="milliseconds").replace("-", "") + + def _get_start_keyword_attrs(self, kw): + attrs = {"name": kw.kwname, "library": kw.libname} + if kw.type != "KEYWORD": + attrs["type"] = kw.type + if kw.source_name: + attrs["sourcename"] = kw.source_name + return attrs + + def _write_status(self, item): + attrs = { + "status": item.status, + "starttime": self._datetime_to_timestamp(item.start_time), + "endtime": self._datetime_to_timestamp(item.end_time), + } + if ( + isinstance(item, (TestSuite, TestCase)) + or isinstance(item, Keyword) + and item.type == "TEARDOWN" + ): + message = item.message + else: + message = "" + self._writer.element("status", message, attrs) + + def _write_message(self, msg): + ts = self._datetime_to_timestamp(msg.timestamp) if msg.timestamp else None + attrs = {"timestamp": ts, "level": msg.level} + if msg.html: + attrs["html"] = "true" + self._writer.element("msg", msg.message, attrs) + + +class NullLogger(XmlLogger): + + def __init__(self): + super().__init__(None) + + def _get_writer(self, output, preamble=True): + return NullMarkupWriter() diff --git a/src/robot/parsing/__init__.py b/src/robot/parsing/__init__.py index a7c330b1009..50dd8d29d38 100644 --- a/src/robot/parsing/__init__.py +++ b/src/robot/parsing/__init__.py @@ -13,55 +13,34 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""Implements test data parsing. +"""Module implementing test data parsing. -Classes :class:`~.model.TestCaseFile`, :class:`~.model.TestDataDirectory` -and :class:`~.model.ResourceFile` represented parsed test data. Objects -of these classes can be modified and saved back to disk. In addition, -a convenience factory function :func:`~.model.TestData` can be used to -parse a test case file or directory to a corresponding object. - -Aforementioned classes and functions are part of the public API. It is -recommended that they are imported through the :mod:`robot.api` package -like in the example below. - -This package is likely to change radically in Robot Framework 2.9. The main -motivation for the planned changes is making the data easier to use for -external tools that use these modules. - -Example -------- - -:: - - import sys - from robot.api import TestData - - def print_suite(suite): - print 'Suite:', suite.name - for test in suite.testcase_table: - print '-', test.name - for child in suite.children: - print_suite(child) - - suite = TestData(source=sys.argv[1]) - print_suite(suite) +Public API is exposed via the :mod:`robot.api.parsing` module. See its documentation +for more information and examples. If external code needs to import something from +this module directly, issue should be submitted about exposing it explicitly via +:mod:`robot.api.parsing`. """ -from .model import TestData, TestCaseFile, TestDataDirectory, ResourceFile -from . import populators - - -TEST_EXTENSIONS = set(populators.READERS) - - -def disable_curdir_processing(method): - """Decorator to disable processing `${CURDIR}` variable.""" - def decorated(*args, **kwargs): - original = populators.PROCESS_CURDIR - populators.PROCESS_CURDIR = False - try: - return method(*args, **kwargs) - finally: - populators.PROCESS_CURDIR = original - return decorated +from .lexer import ( + get_init_tokens as get_init_tokens, + get_resource_tokens as get_resource_tokens, + get_tokens as get_tokens, + Token as Token, +) +from .model import ( + File as File, + ModelTransformer as ModelTransformer, + ModelVisitor as ModelVisitor, +) +from .parser import ( + get_init_model as get_init_model, + get_model as get_model, + get_resource_model as get_resource_model, +) +from .suitestructure import ( + SuiteDirectory as SuiteDirectory, + SuiteFile as SuiteFile, + SuiteStructure as SuiteStructure, + SuiteStructureBuilder as SuiteStructureBuilder, + SuiteStructureVisitor as SuiteStructureVisitor, +) diff --git a/src/robot/parsing/comments.py b/src/robot/parsing/comments.py deleted file mode 100644 index 6d41ebed6ee..00000000000 --- a/src/robot/parsing/comments.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.utils import is_string - - -class CommentCache(object): - - def __init__(self): - self._comments = [] - - def add(self, comment): - self._comments.append(comment) - - def consume_with(self, function): - for comment in self._comments: - function(comment) - self.__init__() - - -class Comments(object): - - def __init__(self): - self._comments = [] - - def add(self, row): - if row.comments: - self._comments.extend(c.strip() for c in row.comments if c.strip()) - - @property - def value(self): - return self._comments - - -class Comment(object): - - def __init__(self, comment_data): - if is_string(comment_data): - comment_data = [comment_data] if comment_data else [] - self._comment = comment_data or [] - - def __len__(self): - return len(self._comment) - - def as_list(self): - if self._not_commented(): - self._comment[0] = '# ' + self._comment[0] - return self._comment - - def _not_commented(self): - return self._comment and self._comment[0] and self._comment[0][0] != '#' diff --git a/src/robot/parsing/datarow.py b/src/robot/parsing/datarow.py deleted file mode 100644 index 71fe01cfb7c..00000000000 --- a/src/robot/parsing/datarow.py +++ /dev/null @@ -1,110 +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.output import LOGGER -from robot.utils import py2to3 - - -@py2to3 -class DataRow(object): - _row_continuation_marker = '...' - - def __init__(self, cells, source=None): - self.source = source - self.cells, self.comments = self._parse(cells) - - def _parse(self, row): - data = [] - comments = [] - for cell in row: - cell = self._collapse_whitespace(cell) - if cell and cell[0] == '#' or comments: - comments.append(cell) - else: - data.append(cell) - if self._row_continuation_marker in data and self.source: - self._deprecate_escaped_cells_before_continuation(data) - return self._purge_empty_cells(data), self._purge_empty_cells(comments) - - def _collapse_whitespace(self, cell): - return ' '.join(cell.split()) - - def _deprecate_escaped_cells_before_continuation(self, data): - index = data.index(self._row_continuation_marker) - if any(cell == '\\' for cell in data[:index]): - LOGGER.warn("Error in file '%s': Escaping empty cells with " - "'\\' before line continuation marker '...' is " - "deprecated. Remove escaping before Robot " - "Framework 3.2." % self.source) - - def _purge_empty_cells(self, row): - while row and not row[-1]: - row.pop() - # Cells with only a single backslash are considered empty - return [cell if cell != '\\' else '' for cell in row] - - @property - def head(self): - return self.cells[0] if self.cells else '' - - @property - def tail(self): - return self.cells[1:] - - @property - def all(self): - return self.cells - - @property - def data(self): - if self.is_continuing(): - index = self.cells.index(self._row_continuation_marker) + 1 - return self.cells[index:] - return self.cells - - def dedent(self): - datarow = DataRow([]) - datarow.cells = self.tail - datarow.comments = self.comments - return datarow - - def starts_for_loop(self): - head = self.head - if head.startswith(':'): - return head.replace(':', '').replace(' ', '').upper() == 'FOR' - return head == 'FOR' - - def starts_test_or_user_keyword_setting(self): - head = self.head - return head and head[0] == '[' and head[-1] == ']' - - def test_or_user_keyword_setting_name(self): - return self.head[1:-1].strip() - - def is_indented(self): - return self.head == '' - - def is_continuing(self): - for cell in self.cells: - if cell == self._row_continuation_marker: - return True - if cell: - return False - - def is_commented(self): - return bool(not self.cells and self.comments) - - def __nonzero__(self): - return bool(self.cells or self.comments) diff --git a/src/robot/parsing/htmlreader.py b/src/robot/parsing/htmlreader.py deleted file mode 100644 index fd3b02dcb33..00000000000 --- a/src/robot/parsing/htmlreader.py +++ /dev/null @@ -1,209 +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.output import LOGGER -from robot.utils import PY2 - -if PY2: - from htmlentitydefs import entitydefs - from HTMLParser import HTMLParser - -else: - from html.entities import entitydefs - from html.parser import HTMLParser - - unichr = chr - - -NON_BREAKING_SPACE = u'\xA0' - - -class HtmlReader(HTMLParser): - IGNORE = 0 - INITIAL = 1 - PROCESS = 2 - - def __init__(self): - HTMLParser.__init__(self) - self._encoding = 'ISO-8859-1' - self._handlers = {'table_start' : self.table_start, - 'table_end' : self.table_end, - 'tr_start' : self.tr_start, - 'tr_end' : self.tr_end, - 'td_start' : self.td_start, - 'td_end' : self.td_end, - 'th_start' : self.td_start, - 'th_end' : self.td_end, - 'br_start' : self.br_start, - 'meta_start' : self.meta_start} - - def read(self, htmlfile, populator, path=None): - self.populator = populator - self.state = self.IGNORE - self.current_row = None - self.current_cell = None - for line in htmlfile.readlines(): - self.feed(self._decode(line)) - # Calling close is required by the HTMLParser but may cause problems - # if the same instance of our HtmlParser is reused. Currently it's - # used only once so there's no problem. - self.close() - if self.populator.eof(): - LOGGER.warn("Using test data in HTML format is deprecated. " - "Convert '%s' to plain text format." - % (path or htmlfile.name)) - - def _decode(self, line): - return line.decode(self._encoding) - - def handle_starttag(self, tag, attrs): - handler = self._handlers.get(tag+'_start') - if handler is not None: - handler(attrs) - - def handle_endtag(self, tag): - handler = self._handlers.get(tag+'_end') - if handler is not None: - handler() - - def handle_data(self, data): - if self.state == self.IGNORE or self.current_cell is None: - return - if NON_BREAKING_SPACE in data: - data = data.replace(NON_BREAKING_SPACE, ' ') - self.current_cell.append(data) - - def handle_entityref(self, name): - value = self._handle_entityref(name) - self.handle_data(value) - - def _handle_entityref(self, name): - if name == 'apos': # missing from entitydefs - return "'" - try: - value = entitydefs[name] - except KeyError: - return '&'+name+';' - if value.startswith('&#'): - return unichr(int(value[2:-1])) - if PY2: - return value.decode('ISO-8859-1') - return value - - def handle_charref(self, number): - value = self._handle_charref(number) - self.handle_data(value) - - def _handle_charref(self, number): - if number.startswith(('x', 'X')): - base = 16 - number = number[1:] - else: - base = 10 - try: - return unichr(int(number, base)) - except ValueError: - return '&#'+number+';' - - def unknown_decl(self, data): - # Ignore everything even if it's invalid. This kind of stuff comes - # at least from MS Excel - pass - - def table_start(self, attrs=None): - self.state = self.INITIAL - self.current_row = None - self.current_cell = None - - def table_end(self): - if self.current_row is not None: - self.tr_end() - self.state = self.IGNORE - - def tr_start(self, attrs=None): - if self.current_row is not None: - self.tr_end() - self.current_row = [] - - def tr_end(self): - if self.current_row is None: - return - if self.current_cell is not None: - self.td_end() - if self.state == self.INITIAL: - accepted = self.populator.start_table(self.current_row) - self.state = self.PROCESS if accepted else self.IGNORE - elif self.state == self.PROCESS: - self.populator.add(self.current_row) - self.current_row = None - - def td_start(self, attrs=None): - if self.current_cell is not None: - self.td_end() - if self.current_row is None: - self.tr_start() - self.current_cell = [] - - def td_end(self): - if self.current_cell is not None and self.state != self.IGNORE: - cell = ''.join(self.current_cell) - self.current_row.append(cell) - self.current_cell = None - - def br_start(self, attrs=None): - self.handle_data('\n') - - def meta_start(self, attrs): - encoding = self._get_encoding_from_meta(attrs) - if encoding: - self._encoding = encoding - - def _get_encoding_from_meta(self, attrs): - valid_http_equiv = False - encoding = None - for name, value in attrs: - name = name.lower() - if name == 'charset': # html5 - return value - if name == 'http-equiv' and value.lower() == 'content-type': - valid_http_equiv = True - if name == 'content': - encoding = self._get_encoding_from_content_attr(value) - return encoding if valid_http_equiv else None - - def _get_encoding_from_content_attr(self, value): - for token in value.split(';'): - token = token.strip() - if token.lower().startswith('charset='): - return token[8:] - - def handle_pi(self, data): - encoding = self._get_encoding_from_pi(data) - if encoding: - self._encoding = encoding - - def _get_encoding_from_pi(self, data): - data = data.strip() - if not data.lower().startswith('xml '): - return None - if data.endswith('?'): - data = data[:-1] - for token in data.split(): - if token.lower().startswith('encoding='): - encoding = token[9:] - if encoding.startswith("'") or encoding.startswith('"'): - encoding = encoding[1:-1] - return encoding - return None diff --git a/src/robot/parsing/lexer/__init__.py b/src/robot/parsing/lexer/__init__.py new file mode 100644 index 00000000000..069489df1f2 --- /dev/null +++ b/src/robot/parsing/lexer/__init__.py @@ -0,0 +1,21 @@ +# 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 .lexer import ( + get_init_tokens as get_init_tokens, + get_resource_tokens as get_resource_tokens, + get_tokens as get_tokens, +) +from .tokens import StatementTokens as StatementTokens, Token as Token diff --git a/src/robot/parsing/lexer/blocklexers.py b/src/robot/parsing/lexer/blocklexers.py new file mode 100644 index 00000000000..6640b5bea14 --- /dev/null +++ b/src/robot/parsing/lexer/blocklexers.py @@ -0,0 +1,448 @@ +# 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 abc import ABC +from collections.abc import Iterator + +from robot.utils import normalize_whitespace + +from .context import ( + FileContext, KeywordContext, LexingContext, SuiteFileContext, TestCaseContext +) +from .statementlexers import ( + BreakLexer, CommentLexer, CommentSectionHeaderLexer, ContinueLexer, ElseHeaderLexer, + ElseIfHeaderLexer, EndLexer, ExceptHeaderLexer, FinallyHeaderLexer, ForHeaderLexer, + GroupHeaderLexer, IfHeaderLexer, ImplicitCommentLexer, InlineIfHeaderLexer, + InvalidSectionHeaderLexer, KeywordCallLexer, KeywordSectionHeaderLexer, + KeywordSettingLexer, Lexer, ReturnLexer, SettingLexer, SettingSectionHeaderLexer, + SyntaxErrorLexer, TaskSectionHeaderLexer, TestCaseSectionHeaderLexer, + TestCaseSettingLexer, TryHeaderLexer, VariableLexer, VariableSectionHeaderLexer, + VarLexer, WhileHeaderLexer +) +from .tokens import StatementTokens, Token + + +class BlockLexer(Lexer, ABC): + + def __init__(self, ctx: LexingContext): + super().__init__(ctx) + self.lexers: list[Lexer] = [] + + def accepts_more(self, statement: StatementTokens) -> bool: + return True + + def input(self, statement: StatementTokens): + 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) + + def lexer_for(self, statement: StatementTokens) -> Lexer: + for cls in self.lexer_classes(): + lexer = cls(self.ctx) + if lexer.handles(statement): + return lexer + raise TypeError( + f"{type(self).__name__} does not have lexer for statement {statement}." + ) + + def lexer_classes(self) -> "tuple[type[Lexer], ...]": + return () + + def lex(self): + for lexer in self.lexers: + lexer.lex() + + def _lex_with_priority(self, priority: "type[Lexer]"): + for lexer in self.lexers: + if isinstance(lexer, priority): + lexer.lex() + for lexer in self.lexers: + if not isinstance(lexer, priority): + lexer.lex() + + +class FileLexer(BlockLexer): + + def lex(self): + self._lex_with_priority(priority=SettingSectionLexer) + + def lexer_classes(self) -> "tuple[type[Lexer], ...]": + return ( + SettingSectionLexer, + VariableSectionLexer, + TestCaseSectionLexer, + TaskSectionLexer, + KeywordSectionLexer, + CommentSectionLexer, + InvalidSectionLexer, + ImplicitCommentSectionLexer, + ) + + +class SectionLexer(BlockLexer, ABC): + ctx: FileContext + + def accepts_more(self, statement: StatementTokens) -> bool: + return not statement[0].value.startswith("*") + + +class SettingSectionLexer(SectionLexer): + + def handles(self, statement: StatementTokens) -> bool: + return self.ctx.setting_section(statement) + + def lexer_classes(self) -> "tuple[type[Lexer], ...]": + return (SettingSectionHeaderLexer, SettingLexer) + + +class VariableSectionLexer(SectionLexer): + + def handles(self, statement: StatementTokens) -> bool: + return self.ctx.variable_section(statement) + + def lexer_classes(self) -> "tuple[type[Lexer], ...]": + return (VariableSectionHeaderLexer, VariableLexer) + + +class TestCaseSectionLexer(SectionLexer): + + def handles(self, statement: StatementTokens) -> bool: + return self.ctx.test_case_section(statement) + + def lexer_classes(self) -> "tuple[type[Lexer], ...]": + return (TestCaseSectionHeaderLexer, TestCaseLexer) + + +class TaskSectionLexer(SectionLexer): + + def handles(self, statement: StatementTokens) -> bool: + return self.ctx.task_section(statement) + + def lexer_classes(self) -> "tuple[type[Lexer], ...]": + return (TaskSectionHeaderLexer, TestCaseLexer) + + +class KeywordSectionLexer(SettingSectionLexer): + + def handles(self, statement: StatementTokens) -> bool: + return self.ctx.keyword_section(statement) + + def lexer_classes(self) -> "tuple[type[Lexer], ...]": + return (KeywordSectionHeaderLexer, KeywordLexer) + + +class CommentSectionLexer(SectionLexer): + + def handles(self, statement: StatementTokens) -> bool: + return self.ctx.comment_section(statement) + + def lexer_classes(self) -> "tuple[type[Lexer], ...]": + return (CommentSectionHeaderLexer, CommentLexer) + + +class ImplicitCommentSectionLexer(SectionLexer): + + def handles(self, statement: StatementTokens) -> bool: + return True + + def lexer_classes(self) -> "tuple[type[Lexer], ...]": + return (ImplicitCommentLexer,) + + +class InvalidSectionLexer(SectionLexer): + + def handles(self, statement: StatementTokens) -> bool: + return bool(statement and statement[0].value.startswith("*")) + + def lexer_classes(self) -> "tuple[type[Lexer], ...]": + return (InvalidSectionHeaderLexer, CommentLexer) + + +class TestOrKeywordLexer(BlockLexer, ABC): + name_type: str + _name_seen = False + + def accepts_more(self, statement: StatementTokens) -> bool: + return not statement[0].value + + def input(self, statement: StatementTokens): + self._handle_name_or_indentation(statement) + if statement: + super().input(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 + if statement: + 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 + + +class TestCaseLexer(TestOrKeywordLexer): + name_type = Token.TESTCASE_NAME + + def __init__(self, ctx: SuiteFileContext): + super().__init__(ctx.test_case_context()) + + def lex(self): + self._lex_with_priority(priority=TestCaseSettingLexer) + + def lexer_classes(self) -> "tuple[type[Lexer], ...]": + return ( + TestCaseSettingLexer, + ForLexer, + InlineIfLexer, + IfLexer, + TryLexer, + WhileLexer, + GroupLexer, + VarLexer, + SyntaxErrorLexer, + KeywordCallLexer, + ) + + +class KeywordLexer(TestOrKeywordLexer): + name_type = Token.KEYWORD_NAME + + def __init__(self, ctx: FileContext): + super().__init__(ctx.keyword_context()) + + def lexer_classes(self) -> "tuple[type[Lexer], ...]": + return ( + KeywordSettingLexer, + ForLexer, + InlineIfLexer, + IfLexer, + TryLexer, + WhileLexer, + GroupLexer, + VarLexer, + ReturnLexer, + SyntaxErrorLexer, + KeywordCallLexer, + ) + + +class NestedBlockLexer(BlockLexer, ABC): + ctx: "TestCaseContext | KeywordContext" + + def __init__(self, ctx: "TestCaseContext | KeywordContext"): + super().__init__(ctx) + self._block_level = 0 + + def accepts_more(self, statement: StatementTokens) -> bool: + return self._block_level > 0 + + def input(self, statement: StatementTokens): + super().input(statement) + lexer = self.lexers[-1] + block_lexers = ( + ForHeaderLexer, + IfHeaderLexer, + TryHeaderLexer, + WhileHeaderLexer, + GroupHeaderLexer, + ) + if isinstance(lexer, block_lexers): + self._block_level += 1 + elif isinstance(lexer, EndLexer): + self._block_level -= 1 + + +class ForLexer(NestedBlockLexer): + + def handles(self, statement: StatementTokens) -> bool: + return ForHeaderLexer(self.ctx).handles(statement) + + def lexer_classes(self) -> "tuple[type[Lexer], ...]": + return ( + ForHeaderLexer, + InlineIfLexer, + IfLexer, + TryLexer, + WhileLexer, + EndLexer, + GroupLexer, + VarLexer, + ReturnLexer, + ContinueLexer, + BreakLexer, + SyntaxErrorLexer, + KeywordCallLexer, + ) + + +class WhileLexer(NestedBlockLexer): + + def handles(self, statement: StatementTokens) -> bool: + return WhileHeaderLexer(self.ctx).handles(statement) + + def lexer_classes(self) -> "tuple[type[Lexer], ...]": + return ( + WhileHeaderLexer, + ForLexer, + InlineIfLexer, + IfLexer, + TryLexer, + EndLexer, + GroupLexer, + VarLexer, + ReturnLexer, + ContinueLexer, + BreakLexer, + SyntaxErrorLexer, + KeywordCallLexer, + ) + + +class TryLexer(NestedBlockLexer): + + def handles(self, statement: StatementTokens) -> bool: + return TryHeaderLexer(self.ctx).handles(statement) + + def lexer_classes(self) -> "tuple[type[Lexer], ...]": + return ( + TryHeaderLexer, + ExceptHeaderLexer, + ElseHeaderLexer, + FinallyHeaderLexer, + ForLexer, + InlineIfLexer, + IfLexer, + WhileLexer, + EndLexer, + VarLexer, + GroupLexer, + ReturnLexer, + BreakLexer, + ContinueLexer, + SyntaxErrorLexer, + KeywordCallLexer, + ) + + +class GroupLexer(NestedBlockLexer): + + def handles(self, statement: StatementTokens) -> bool: + return GroupHeaderLexer(self.ctx).handles(statement) + + def lexer_classes(self) -> "tuple[type[Lexer], ...]": + return ( + GroupHeaderLexer, + InlineIfLexer, + IfLexer, + ForLexer, + TryLexer, + WhileLexer, + EndLexer, + VarLexer, + ReturnLexer, + ContinueLexer, + BreakLexer, + SyntaxErrorLexer, + KeywordCallLexer, + ) + + +class IfLexer(NestedBlockLexer): + + def handles(self, statement: StatementTokens) -> bool: + return IfHeaderLexer(self.ctx).handles(statement) + + def lexer_classes(self) -> "tuple[type[Lexer], ...]": + return ( + InlineIfLexer, + IfHeaderLexer, + ElseIfHeaderLexer, + ElseHeaderLexer, + ForLexer, + TryLexer, + WhileLexer, + EndLexer, + VarLexer, + GroupLexer, + ReturnLexer, + ContinueLexer, + BreakLexer, + SyntaxErrorLexer, + KeywordCallLexer, + ) + + +class InlineIfLexer(NestedBlockLexer): + + def handles(self, statement: StatementTokens) -> bool: + if len(statement) <= 2: + return False + return InlineIfHeaderLexer(self.ctx).handles(statement) + + def accepts_more(self, statement: StatementTokens) -> bool: + return False + + def lexer_classes(self) -> "tuple[type[Lexer], ...]": + return ( + InlineIfHeaderLexer, + ElseIfHeaderLexer, + ElseHeaderLexer, + VarLexer, + GroupLexer, + ReturnLexer, + ContinueLexer, + BreakLexer, + KeywordCallLexer, + ) + + def input(self, statement: StatementTokens): + for part in self._split(statement): + if part: + super().input(part) + + def _split(self, statement: StatementTokens) -> "Iterator[StatementTokens]": + current = [] + expect_condition = False + for token in statement: + if expect_condition: + if token is not statement[-1]: + token._add_eos_after = True + current.append(token) + yield current + current = [] + expect_condition = False + elif token.value == "IF": + current.append(token) + expect_condition = True + elif normalize_whitespace(token.value) == "ELSE IF": + token._add_eos_before = True + yield current + current = [token] + expect_condition = True + elif token.value == "ELSE": + token._add_eos_before = True + if token is not statement[-1]: + token._add_eos_after = True + yield current + current = [] + yield [token] + else: + current.append(token) + yield current diff --git a/src/robot/parsing/lexer/context.py b/src/robot/parsing/lexer/context.py new file mode 100644 index 00000000000..7b30124cdbe --- /dev/null +++ b/src/robot/parsing/lexer/context.py @@ -0,0 +1,162 @@ +# 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 LanguageLike, Languages, LanguagesLike +from robot.utils import normalize_whitespace + +from .settings import ( + FileSettings, InitFileSettings, KeywordSettings, ResourceFileSettings, Settings, + SuiteFileSettings, TestCaseSettings +) +from .tokens import StatementTokens, Token + + +class LexingContext: + + def __init__(self, settings: Settings, languages: Languages): + self.settings = settings + self.languages = languages + + def lex_setting(self, statement: StatementTokens): + 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(KeywordSettings(self.settings)) + + def setting_section(self, statement: StatementTokens) -> bool: + return self._handles_section(statement, "Settings") + + def variable_section(self, statement: StatementTokens) -> bool: + return self._handles_section(statement, "Variables") + + def test_case_section(self, statement: StatementTokens) -> bool: + return False + + def task_section(self, statement: StatementTokens) -> bool: + return False + + def keyword_section(self, statement: StatementTokens) -> bool: + return self._handles_section(statement, "Keywords") + + def comment_section(self, statement: StatementTokens) -> bool: + return self._handles_section(statement, "Comments") + + def lex_invalid_section(self, statement: StatementTokens): + 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: str) -> str: + raise NotImplementedError + + def _handles_section(self, statement: StatementTokens, header: str) -> bool: + marker = statement[0].value + 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() + + +class SuiteFileContext(FileContext): + settings: SuiteFileSettings + + def test_case_context(self) -> "TestCaseContext": + return TestCaseContext(TestCaseSettings(self.settings)) + + def test_case_section(self, statement: StatementTokens) -> bool: + return self._handles_section(statement, "Test Cases") + + def task_section(self, statement: StatementTokens) -> bool: + return self._handles_section(statement, "Tasks") + + def _get_invalid_section_error(self, header: str) -> str: + return ( + f"Unrecognized section header '{header}'. Valid sections: 'Settings', " + f"'Variables', 'Test Cases', 'Tasks', 'Keywords' and 'Comments'." + ) + + +class ResourceFileContext(FileContext): + settings: ResourceFileSettings + + 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." + return ( + f"Unrecognized section header '{header}'. Valid sections: " + f"'Settings', 'Variables', 'Keywords' and 'Comments'." + ) + + +class InitFileContext(FileContext): + settings: InitFileSettings + + 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." + return ( + f"Unrecognized section header '{header}'. Valid sections: " + f"'Settings', 'Variables', 'Keywords' and 'Comments'." + ) + + +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 + + +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/lexer.py b/src/robot/parsing/lexer/lexer.py new file mode 100644 index 00000000000..a7cc0648514 --- /dev/null +++ b/src/robot/parsing/lexer/lexer.py @@ -0,0 +1,209 @@ +# 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 collections.abc import Iterable, Iterator +from itertools import chain + +from robot.conf import LanguagesLike +from robot.errors import DataError +from robot.utils import FileReader, get_error_message, Source + +from .blocklexers import FileLexer +from .context import ( + InitFileContext, LexingContext, ResourceFileContext, SuiteFileContext +) +from .tokenizer import Tokenizer +from .tokens import END, EOS, Token + + +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 + a source file as a string or as ``pathlib.Path`` object, an already + opened file object, or Unicode text containing the date directly. + Source files must be UTF-8 encoded. + :param data_only: When ``False`` (default), returns all tokens. When set + to ``True``, omits separators, comments, continuation markers, and + other non-data tokens. + :param tokenize_variables: When ``True``, possible variables in keyword + 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` subclass, + 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. + """ + lexer = Lexer(SuiteFileContext(lang=lang), data_only, tokenize_variables) + lexer.input(source) + return lexer.get_tokens() + + +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 + a resource file. This affects, for example, what settings are valid. + """ + lexer = Lexer(ResourceFileContext(lang=lang), data_only, tokenize_variables) + lexer.input(source) + return lexer.get_tokens() + + +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 + a suite initialization file. This affects, for example, what settings are + valid. + """ + lexer = Lexer(InitFileContext(lang=lang), data_only, tokenize_variables) + lexer.input(source) + return lexer.get_tokens() + + +class Lexer: + + 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: list[list[Token]] = [] + + 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: + data = statement[:] + else: + # Separators, comments, etc. already have type, data doesn't. + data = [t for t in statement if t.type is None] + if data: + self.lexer.input(data) + + 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) -> "Iterator[Token]": + self.lexer.lex() + if self.data_only: + statements = self.statements + else: + statements = chain.from_iterable( + 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: "Iterable[list[Token]]") -> "Iterator[Token]": + if self.data_only: + ignored_types = {None, Token.COMMENT} + else: + ignored_types = {None} + inline_if_type = Token.INLINE_IF + for statement in statements: + last = None + inline_if = False + for token in statement: + token_type = token.type + if token_type in ignored_types: + continue + if token._add_eos_before and not (last and last._add_eos_after): + yield EOS.from_token(token, before=True) + yield token + if token._add_eos_after: + yield EOS.from_token(token) + if token_type == inline_if_type: + inline_if = True + last = token + 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]]": + lines = self._split_to_lines(statement) + commented_or_empty = [] + for line in reversed(lines): + if not self._is_commented_or_empty(line): + break + commented_or_empty.append(line) + if not commented_or_empty: + return [statement] + lines = lines[: -len(commented_or_empty)] + statement = list(chain.from_iterable(lines)) + return [statement, *reversed(commented_or_empty)] + + def _split_to_lines(self, statement: "list[Token]") -> "list[list[Token]]": + lines = [] + current = [] + for token in statement: + current.append(token) + if token.type == Token.EOL: + lines.append(current) + current = [] + if current: + lines.append(current) + return lines + + 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: + if token.type not in separator_or_ignore: + return token.type in comment_or_eol + return False + + def _tokenize_variables(self, tokens: "Iterator[Token]") -> "Iterator[Token]": + for token in tokens: + yield from token.tokenize_variables() diff --git a/src/robot/parsing/lexer/settings.py b/src/robot/parsing/lexer/settings.py new file mode 100644 index 00000000000..6fe8481a15e --- /dev/null +++ b/src/robot/parsing/lexer/settings.py @@ -0,0 +1,287 @@ +# 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 abc import ABC, abstractmethod + +from robot.conf import Languages +from robot.utils import normalize, normalize_whitespace, RecommendationFinder + +from .tokens import StatementTokens, Token + + +class Settings(ABC): + names: "tuple[str, ...]" = () + aliases: "dict[str, str]" = {} + multi_use = ( + "Metadata", + "Library", + "Resource", + "Variables", + ) + single_value = ( + "Resource", + "Test Timeout", + "Test Template", + "Timeout", + "Template", + "Name", + ) + name_and_arguments = ( + "Metadata", + "Suite Setup", + "Suite Teardown", + "Test Setup", + "Test Teardown", + "Test Template", + "Setup", + "Teardown", + "Template", + "Resource", + "Variables", + ) + name_arguments_and_with_name = ( + "Library", + ) # fmt: skip + + def __init__(self, languages: Languages): + self.settings: dict[str, list[Token] | None] = dict.fromkeys(self.names) + self.languages = languages + + 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: + name = self.aliases[name] + try: + self._validate(orig, name, statement) + except ValueError as err: + self._lex_error(statement, err.args[0]) + else: + self._lex_setting(statement, name) + + def _format_name(self, name: str) -> str: + return name + + 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) + if self.settings[name] is not None and name not in self.multi_use: + raise ValueError( + f"Setting '{orig}' is allowed only once. Only the first value is used." + ) + if name in self.single_value and len(statement) > 2: + raise ValueError( + f"Setting '{orig}' accepts only one value, got {len(statement) - 1}." + ) + + def _get_non_existing_setting_message(self, name: str, normalized: str) -> str: + if self._is_valid_somewhere(normalized, Settings.__subclasses__()): + 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, 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 + + @abstractmethod + def _not_valid_here(self, name: str) -> str: + raise NotImplementedError + + 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, statement: StatementTokens, name: str): + 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: + self._lex_name_and_arguments(values) + elif name in self.name_arguments_and_with_name: + 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: + tokens[0].type = Token.NAME + self._lex_arguments(tokens[1:]) + + def _lex_name_arguments_and_with_name(self, tokens: StatementTokens): + self._lex_name_and_arguments(tokens) + marker = tokens[-2].value if len(tokens) > 1 else None + if marker and normalize_whitespace(marker) in ("WITH NAME", "AS"): + tokens[-2].type = Token.AS + tokens[-1].type = Token.NAME + + def _lex_arguments(self, tokens: StatementTokens): + for token in tokens: + token.type = Token.ARGUMENT + + +class FileSettings(Settings, ABC): + pass + + +class SuiteFileSettings(FileSettings): + names = ( + "Documentation", + "Metadata", + "Name", + "Suite Setup", + "Suite Teardown", + "Test Setup", + "Test Teardown", + "Test Template", + "Test Timeout", + "Test Tags", + "Default Tags", + "Keyword Tags", + "Library", + "Resource", + "Variables", + ) + aliases = { + "Force Tags": "Test Tags", + "Task Tags": "Test Tags", + "Task Setup": "Test Setup", + "Task Teardown": "Test Teardown", + "Task Template": "Test Template", + "Task Timeout": "Test Timeout", + } + + def _not_valid_here(self, name: str) -> str: + return f"Setting '{name}' is not allowed in suite file." + + +class InitFileSettings(FileSettings): + names = ( + "Documentation", + "Metadata", + "Name", + "Suite Setup", + "Suite Teardown", + "Test Setup", + "Test Teardown", + "Test Timeout", + "Test Tags", + "Keyword Tags", + "Library", + "Resource", + "Variables", + ) + aliases = { + "Force Tags": "Test Tags", + "Task Tags": "Test Tags", + "Task Setup": "Test Setup", + "Task Teardown": "Test Teardown", + "Task Timeout": "Test Timeout", + } + + def _not_valid_here(self, name: str) -> str: + return f"Setting '{name}' is not allowed in suite initialization file." + + +class ResourceFileSettings(FileSettings): + names = ( + "Documentation", + "Keyword Tags", + "Library", + "Resource", + "Variables", + ) + + def _not_valid_here(self, name: str) -> str: + return f"Setting '{name}' is not allowed in resource file." + + +class TestCaseSettings(Settings): + names = ( + "Documentation", + "Metadata", + "Tags", + "Setup", + "Teardown", + "Template", + "Timeout", + ) + + def __init__(self, parent: SuiteFileSettings): + super().__init__(parent.languages) + self.parent = parent + + def _format_name(self, name: str) -> str: + return name[1:-1].strip() + + @property + 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: "StatementTokens | None") -> bool: + if setting is None: + return False + return setting == [] or setting[0].value.upper() == "NONE" + + def _has_value(self, setting: "StatementTokens | None") -> bool: + return bool(setting and setting[0].value) + + def _not_valid_here(self, name: str) -> str: + return f"Setting '{name}' is not allowed with tests or tasks." + + +class KeywordSettings(Settings): + names = ( + "Documentation", + "Arguments", + "Setup", + "Teardown", + "Timeout", + "Tags", + "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() + + 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 new file mode 100644 index 00000000000..aba0784cb54 --- /dev/null +++ b/src/robot/parsing/lexer/statementlexers.py @@ -0,0 +1,417 @@ +# 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 abc import ABC, abstractmethod + +from robot.errors import DataError +from robot.utils import normalize_whitespace +from robot.variables import is_assign + +from .context import FileContext, KeywordContext, LexingContext, TestCaseContext +from .tokens import StatementTokens, Token + + +class Lexer(ABC): + + def __init__(self, ctx: LexingContext): + self.ctx = ctx + + def handles(self, statement: StatementTokens) -> bool: + return True + + @abstractmethod + def accepts_more(self, statement: StatementTokens) -> bool: + raise NotImplementedError + + @abstractmethod + def input(self, statement: StatementTokens): + raise NotImplementedError + + @abstractmethod + def lex(self): + raise NotImplementedError + + +class StatementLexer(Lexer, ABC): + token_type: str + + def __init__(self, ctx: LexingContext): + super().__init__(ctx) + self.statement: StatementTokens = [] + + def accepts_more(self, statement: StatementTokens) -> bool: + return False + + 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): + seen = set() + for token in reversed(self.statement[:end_index]): + 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): + + def lex(self): + for token in self.statement: + token.type = self.token_type + + +class TypeAndArguments(StatementLexer, ABC): + + def lex(self): + self.statement[0].type = self.token_type + for token in self.statement[1:]: + token.type = Token.ARGUMENT + + +class SectionHeaderLexer(SingleType, ABC): + ctx: FileContext + + def handles(self, statement: StatementTokens) -> bool: + return statement[0].value.startswith("*") + + +class SettingSectionHeaderLexer(SectionHeaderLexer): + token_type = Token.SETTING_HEADER + + +class VariableSectionHeaderLexer(SectionHeaderLexer): + token_type = Token.VARIABLE_HEADER + + +class TestCaseSectionHeaderLexer(SectionHeaderLexer): + token_type = Token.TESTCASE_HEADER + + +class TaskSectionHeaderLexer(SectionHeaderLexer): + token_type = Token.TASK_HEADER + + +class KeywordSectionHeaderLexer(SectionHeaderLexer): + token_type = Token.KEYWORD_HEADER + + +class CommentSectionHeaderLexer(SectionHeaderLexer): + token_type = Token.COMMENT_HEADER + + +class InvalidSectionHeaderLexer(SectionHeaderLexer): + token_type = Token.INVALID_HEADER + + def lex(self): + self.ctx.lex_invalid_section(self.statement) + + +class CommentLexer(SingleType): + token_type = Token.COMMENT + + +class ImplicitCommentLexer(CommentLexer): + ctx: FileContext + + def input(self, statement: StatementTokens): + super().input(statement) + if ":" in statement[0].value: + config, value = " ".join(token.value for token in statement).split(":", 1) + config = config.lower() + value = value.strip() + if config == "language": + self._handle_language(statement, value) + + def _handle_language(self, statement: StatementTokens, language: str): + try: + self.ctx.add_language(language) + except DataError: + for token in statement: + token.set_error( + f"Invalid language configuration: Language '{language}' " + f"not found nor importable as a language module." + ) + else: + for token in statement: + token.type = Token.CONFIG + + def lex(self): + for token in self.statement: + if not token.type: + token.type = self.token_type + + +class SettingLexer(StatementLexer): + ctx: FileContext + + def lex(self): + self.ctx.lex_setting(self.statement) + + +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 + return bool(marker and marker[0] == "[" and marker[-1] == "]") + + +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" + + def lex(self): + if self.ctx.template_set: + self._lex_as_template() + else: + self._lex_as_keyword_call() + + def _lex_as_template(self): + for token in self.statement: + token.type = Token.ARGUMENT + + def _lex_as_keyword_call(self): + keyword_seen = False + for token in self.statement: + if keyword_seen: + token.type = Token.ARGUMENT + elif is_assign( + token.value, allow_assign_mark=True, allow_nested=True, allow_items=True + ): + token.type = Token.ASSIGN + else: + token.type = Token.KEYWORD + keyword_seen = True + + +class ForHeaderLexer(StatementLexer): + separators = ("IN", "IN RANGE", "IN ENUMERATE", "IN ZIP") + + def handles(self, statement: StatementTokens) -> bool: + return statement[0].value == "FOR" + + def lex(self): + self.statement[0].type = Token.FOR + separator = None + for token in self.statement[1:]: + if separator: + token.type = Token.ARGUMENT + elif normalize_whitespace(token.value) in self.separators: + token.type = Token.FOR_SEPARATOR + separator = normalize_whitespace(token.value) + else: + token.type = Token.VARIABLE + if separator == "IN ENUMERATE": + self._lex_options("start") + elif separator == "IN ZIP": + self._lex_options("mode", "fill") + + +class IfHeaderLexer(TypeAndArguments): + token_type = Token.IF + + 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: StatementTokens) -> bool: + for token in statement: + if token.value == "IF": + return True + if not is_assign( + token.value, allow_assign_mark=True, allow_nested=True, allow_items=True + ): + return False + return False + + def lex(self): + if_seen = False + for token in self.statement: + if if_seen: + token.type = Token.ARGUMENT + elif token.value == "IF": + token.type = Token.INLINE_IF + if_seen = True + else: + token.type = Token.ASSIGN + + +class ElseIfHeaderLexer(TypeAndArguments): + token_type = Token.ELSE_IF + + 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: StatementTokens) -> bool: + return statement[0].value == "ELSE" + + +class TryHeaderLexer(TypeAndArguments): + token_type = Token.TRY + + def handles(self, statement: StatementTokens) -> bool: + return statement[0].value == "TRY" + + +class ExceptHeaderLexer(StatementLexer): + token_type = Token.EXCEPT + + def handles(self, statement: StatementTokens) -> bool: + return statement[0].value == "EXCEPT" + + def lex(self): + self.statement[0].type = Token.EXCEPT + as_index: int | None = None + for index, token in enumerate(self.statement[1:], start=1): + if token.value == "AS": + token.type = Token.AS + as_index = index + elif as_index: + token.type = Token.VARIABLE + else: + token.type = Token.ARGUMENT + self._lex_options("type", end_index=as_index) + + +class FinallyHeaderLexer(TypeAndArguments): + token_type = Token.FINALLY + + def handles(self, statement: StatementTokens) -> bool: + return statement[0].value == "FINALLY" + + +class WhileHeaderLexer(StatementLexer): + token_type = Token.WHILE + + def handles(self, statement: StatementTokens) -> bool: + return statement[0].value == "WHILE" + + 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") + + +class GroupHeaderLexer(TypeAndArguments): + token_type = Token.GROUP + + def handles(self, statement: StatementTokens) -> bool: + return statement[0].value == "GROUP" + + +class EndLexer(TypeAndArguments): + token_type = Token.END + + 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[:1] == "$" else ["scope"] + self._lex_options(*options) + + +class ReturnLexer(TypeAndArguments): + token_type = Token.RETURN_STATEMENT + + def handles(self, statement: StatementTokens) -> bool: + return statement[0].value == "RETURN" + + +class ContinueLexer(TypeAndArguments): + token_type = Token.CONTINUE + + def handles(self, statement: StatementTokens) -> bool: + return statement[0].value == "CONTINUE" + + +class BreakLexer(TypeAndArguments): + token_type = Token.BREAK + + def handles(self, statement: StatementTokens) -> bool: + return statement[0].value == "BREAK" + + +class SyntaxErrorLexer(TypeAndArguments): + token_type = Token.ERROR + + def handles(self, statement: StatementTokens) -> bool: + return statement[0].value in { + "ELSE", + "ELSE IF", + "EXCEPT", + "FINALLY", + "BREAK", + "CONTINUE", + "RETURN", + "END", + } + + 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/lexer/tokenizer.py b/src/robot/parsing/lexer/tokenizer.py new file mode 100644 index 00000000000..9ce594a9511 --- /dev/null +++ b/src/robot/parsing/lexer/tokenizer.py @@ -0,0 +1,150 @@ +# 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 re +from collections.abc import Iterator + +from .tokens import Token + + +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: str, data_only: bool = False) -> "Iterator[list[Token]]": + 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) + if starts_new: + if current: + yield current + current = tokens + else: + current.extend(tokens) + yield current + + def _tokenize_line( + self, + line: str, + lineno: int, + include_separators: bool, + ) -> "list[Token]": + # Performance optimized code. + tokens = [] + append = tokens.append + offset = 0 + if line[:1] == "|" and line[:2].strip() == "|": + splitter = self._split_from_pipes + else: + splitter = self._split_from_spaces + for value, is_data in splitter(line.rstrip()): + if is_data: + append(Token(None, value, lineno, offset)) + elif include_separators: + append(Token(Token.SEPARATOR, value, lineno, offset)) + offset += len(value) + if include_separators: + trailing_whitespace = line[len(line.rstrip()) :] + append(Token(Token.EOL, trailing_whitespace, lineno, offset)) + return tokens + + 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) -> "Iterator[tuple[str, bool]]": + splitter = self._pipe_splitter + _, separator, rest = splitter.split(line, 1) + yield separator, False + while splitter.search(rest): + token, separator, rest = splitter.split(rest, 1) + yield token, True + yield separator, False + yield rest, True + + def _cleanup_tokens( + self, + tokens: "list[Token]", + data_only: bool, + ) -> "tuple[list[Token], bool]": + has_data, comments, 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 and (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: "list[Token]", + ) -> "tuple[bool, bool, bool]": + first = True + has_data = False + comments = False + continues = False + for token in tokens: + if token.type is None: + # The first token may have a leading space. With others spaces + # have been consumed as separators. + value = token.value.lstrip() if first else token.value + if comments: + token.type = Token.COMMENT + elif value: + if value[0] == "#": + token.type = Token.COMMENT + comments = True + elif not has_data: + if value == "..." and not continues: + token.type = Token.CONTINUATION + continues = True + else: + has_data = True + first = False + return has_data, comments, continues + + 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: "list[Token]"): + data_or_continuation = (None, Token.CONTINUATION) + for token in list(tokens): + if not token.value: + tokens.remove(token) + elif token.type in data_or_continuation: + break + + 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: "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 new file mode 100644 index 00000000000..9c5b1707b1f --- /dev/null +++ b/src/robot/parsing/lexer/tokens.py @@ -0,0 +1,301 @@ +# 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 collections.abc import Iterator +from typing import List + +from robot.variables import VariableMatches + +# Type alias to ease typing elsewhere +StatementTokens = List["Token"] + + +class Token: + """Token representing piece of Robot Framework data. + + Each token has type, value, line number, column offset and end column + offset in :attr:`type`, :attr:`value`, :attr:`lineno`, :attr:`col_offset` + and :attr:`end_col_offset` attributes, respectively. Tokens representing + error also have their error message in :attr:`error` attribute. + + 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 + 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 and :attr:`type` is a special marker like + :attr:`IF` or `:attr:`EOL`, the value is set automatically. + """ + + SETTING_HEADER = "SETTING HEADER" + VARIABLE_HEADER = "VARIABLE HEADER" + TESTCASE_HEADER = "TESTCASE HEADER" + TASK_HEADER = "TASK HEADER" + KEYWORD_HEADER = "KEYWORD HEADER" + COMMENT_HEADER = "COMMENT HEADER" + INVALID_HEADER = "INVALID HEADER" + FATAL_INVALID_HEADER = "FATAL INVALID HEADER" # TODO: Remove in RF 8. + + TESTCASE_NAME = "TESTCASE NAME" + KEYWORD_NAME = "KEYWORD NAME" + SUITE_NAME = "SUITE NAME" + DOCUMENTATION = "DOCUMENTATION" + SUITE_SETUP = "SUITE SETUP" + SUITE_TEARDOWN = "SUITE TEARDOWN" + METADATA = "METADATA" + TEST_SETUP = "TEST SETUP" + TEST_TEARDOWN = "TEST TEARDOWN" + TEST_TEMPLATE = "TEST TEMPLATE" + TEST_TIMEOUT = "TEST TIMEOUT" + TEST_TAGS = "TEST TAGS" + FORCE_TAGS = TEST_TAGS # TODO: Remove in RF 8. + DEFAULT_TAGS = "DEFAULT TAGS" + KEYWORD_TAGS = "KEYWORD TAGS" + LIBRARY = "LIBRARY" + RESOURCE = "RESOURCE" + VARIABLES = "VARIABLES" + SETUP = "SETUP" + TEARDOWN = "TEARDOWN" + TEMPLATE = "TEMPLATE" + TIMEOUT = "TIMEOUT" + TAGS = "TAGS" + ARGUMENTS = "ARGUMENTS" + 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 in RF 8. + + NAME = "NAME" + VARIABLE = "VARIABLE" + ARGUMENT = "ARGUMENT" + ASSIGN = "ASSIGN" + KEYWORD = "KEYWORD" + FOR = "FOR" + FOR_SEPARATOR = "FOR SEPARATOR" + END = "END" + IF = "IF" + INLINE_IF = "INLINE IF" + ELSE_IF = "ELSE IF" + ELSE = "ELSE" + TRY = "TRY" + EXCEPT = "EXCEPT" + FINALLY = "FINALLY" + WHILE = "WHILE" + VAR = "VAR" + RETURN_STATEMENT = "RETURN STATEMENT" + CONTINUE = "CONTINUE" + BREAK = "BREAK" + OPTION = "OPTION" + GROUP = "GROUP" + + SEPARATOR = "SEPARATOR" + COMMENT = "COMMENT" + CONTINUATION = "CONTINUATION" + CONFIG = "CONFIG" + EOL = "EOL" + EOS = "EOS" + ERROR = "ERROR" + FATAL_ERROR = "FATAL ERROR" # TODO: Remove in RF 8. + + NON_DATA_TOKENS = { + SEPARATOR, + COMMENT, + CONTINUATION, + EOL, + EOS, + } + SETTING_TOKENS = { + DOCUMENTATION, + SUITE_NAME, + SUITE_SETUP, + SUITE_TEARDOWN, + METADATA, + TEST_SETUP, + TEST_TEARDOWN, + TEST_TEMPLATE, + TEST_TIMEOUT, + TEST_TAGS, + DEFAULT_TAGS, + KEYWORD_TAGS, + LIBRARY, + RESOURCE, + VARIABLES, + SETUP, + TEARDOWN, + TEMPLATE, + TIMEOUT, + TAGS, + ARGUMENTS, + RETURN, + } + HEADER_TOKENS = { + SETTING_HEADER, + VARIABLE_HEADER, + TESTCASE_HEADER, + TASK_HEADER, + KEYWORD_HEADER, + COMMENT_HEADER, + INVALID_HEADER, + } + ALLOW_VARIABLES = { + NAME, + ARGUMENT, + TESTCASE_NAME, + KEYWORD_NAME, + } + __slots__ = ( + "type", + "value", + "lineno", + "col_offset", + "error", + "_add_eos_before", + "_add_eos_after", + ) + + 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: + defaults = { + 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.VAR: "VAR", + Token.CONTINUE: "CONTINUE", + Token.BREAK: "BREAK", + Token.RETURN_STATEMENT: "RETURN", + Token.CONTINUATION: "...", + Token.EOL: "\n", + Token.WITH_NAME: "AS", + Token.AS: "AS", + Token.GROUP: "GROUP", + } + value = defaults.get(type, "") + self.value = value + self.lineno = lineno + self.col_offset = col_offset + self.error = error + # Used internally be lexer to indicate that EOS is needed before/after. + self._add_eos_before = False + self._add_eos_after = False + + @property + 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: str): + self.type = Token.ERROR + self.error = error + + 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 + before, after, or between variables so that they have the same + type as the original token. + """ + if self.type not in Token.ALLOW_VARIABLES: + return self._tokenize_no_variables() + matches = VariableMatches(self.value) + if not matches: + return self._tokenize_no_variables() + return self._tokenize_variables(matches) + + def _tokenize_no_variables(self) -> "Iterator[Token]": + yield self + + def _tokenize_variables(self, matches) -> "Iterator[Token]": + lineno = self.lineno + col_offset = self.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 + + 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) -> bool: + return ( + isinstance(other, Token) + and self.type == other.type + and self.value == other.value + and self.lineno == other.lineno + and self.col_offset == other.col_offset + and self.error == other.error + ) + + +class EOS(Token): + """Token representing end of a statement.""" + + __slots__ = () + + def __init__(self, lineno: int = -1, col_offset: int = -1): + super().__init__(Token.EOS, "", lineno, col_offset) + + @classmethod + def from_token(cls, token: Token, before: bool = False) -> "EOS": + col_offset = token.col_offset if before else token.end_col_offset + return cls(token.lineno, col_offset) + + +class END(Token): + """Token representing END token used to signify block ending. + + Virtual END tokens have '' as their value, with "real" END tokens the + value is 'END'. + """ + + __slots__ = () + + 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: Token, virtual: bool = False) -> "END": + return cls(token.lineno, token.end_col_offset, virtual) diff --git a/src/robot/parsing/model.py b/src/robot/parsing/model.py deleted file mode 100644 index 66f6c1b9258..00000000000 --- a/src/robot/parsing/model.py +++ /dev/null @@ -1,806 +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 os -import copy -import warnings - -from robot.errors import DataError -from robot.variables import is_var -from robot.output import LOGGER -from robot.writer import DataFileWriter -from robot.utils import abspath, is_string, normalize, py2to3, NormalizedDict - -from .comments import Comment -from .populators import FromFilePopulator, FromDirectoryPopulator, NoTestsFound -from .settings import (Documentation, Fixture, Timeout, Tags, Metadata, - Library, Resource, Variables, Arguments, Return, - Template, MetadataList, ImportList) - - -def TestData(parent=None, source=None, include_suites=None, - warn_on_skipped='DEPRECATED', extensions=None): - """Parses a file or directory to a corresponding model object. - - :param parent: Optional parent to be used in creation of the model object. - :param source: Path where test data is read from. - :param warn_on_skipped: Deprecated. - :param extensions: List/set of extensions to parse. If None, all files - supported by Robot Framework are parsed when searching test cases. - :returns: :class:`~.model.TestDataDirectory` if `source` is a directory, - :class:`~.model.TestCaseFile` otherwise. - """ - # TODO: Remove in RF 3.2. - if warn_on_skipped != 'DEPRECATED': - warnings.warn("Option 'warn_on_skipped' is deprecated and has no " - "effect.", DeprecationWarning) - if os.path.isdir(source): - return TestDataDirectory(parent, source).populate(include_suites, - extensions) - return TestCaseFile(parent, source).populate() - - -class _TestData(object): - _setting_table_names = 'Setting', 'Settings' - _variable_table_names = 'Variable', 'Variables' - _testcase_table_names = 'Test Case', 'Test Cases', 'Task', 'Tasks' - _keyword_table_names = 'Keyword', 'Keywords' - _comment_table_names = 'Comment', 'Comments' - - def __init__(self, parent=None, source=None): - self.parent = parent - self.source = abspath(source) if source else None - self.children = [] - self._tables = dict(self._get_tables()) - - def _get_tables(self): - for names, table in [(self._setting_table_names, self.setting_table), - (self._variable_table_names, self.variable_table), - (self._testcase_table_names, self.testcase_table), - (self._keyword_table_names, self.keyword_table), - (self._comment_table_names, None)]: - for name in names: - yield name, table - - def start_table(self, header_row): - table = self._find_table(header_row) - if table is None or not self._table_is_allowed(table): - return None - table.set_header(header_row) - return table - - def _find_table(self, header_row): - name = header_row[0] if header_row else '' - title = name.title() - if title not in self._tables: - title = self._resolve_deprecated_table(name) - if title is None: - self._report_unrecognized_table(name) - return None - return self._tables[title] - - def _resolve_deprecated_table(self, used_name): - normalized = normalize(used_name) - for name in (self._setting_table_names + self._variable_table_names + - self._testcase_table_names + self._keyword_table_names + - self._comment_table_names): - if normalize(name) == normalized: - self._report_deprecated_table(used_name, name) - return name - return None - - def _report_deprecated_table(self, deprecated, name): - self.report_invalid_syntax( - "Section name '%s' is deprecated. Use '%s' instead." - % (deprecated, name), level='WARN' - ) - - def _report_unrecognized_table(self, name): - self.report_invalid_syntax( - "Unrecognized table header '%s'. Available headers for data: " - "'Setting(s)', 'Variable(s)', 'Test Case(s)', 'Task(s)' and " - "'Keyword(s)'. Use 'Comment(s)' to embedded additional data." - % name - ) - - def _table_is_allowed(self, table): - return True - - @property - def name(self): - return self._format_name(self._get_basename()) if self.source else None - - def _get_basename(self): - return os.path.splitext(os.path.basename(self.source))[0] - - def _format_name(self, name): - name = self._strip_possible_prefix_from_name(name) - name = name.replace('_', ' ').strip() - return name.title() if name.islower() else name - - def _strip_possible_prefix_from_name(self, name): - return name.split('__', 1)[-1] - - @property - def keywords(self): - return self.keyword_table.keywords - - @property - def imports(self): - return self.setting_table.imports - - def report_invalid_syntax(self, message, level='ERROR'): - initfile = getattr(self, 'initfile', None) - path = os.path.join(self.source, initfile) if initfile else self.source - LOGGER.write("Error in file '%s': %s" % (path, message), level) - - def save(self, **options): - """Writes this datafile to disk. - - :param options: Configuration for writing. These are passed to - :py:class:`~robot.writer.datafilewriter.WritingContext` as - keyword arguments. - - See also :py:class:`robot.writer.datafilewriter.DataFileWriter` - """ - return DataFileWriter(**options).write(self) - - -@py2to3 -class TestCaseFile(_TestData): - """The parsed test case file object. - - :param parent: parent object to be used in creation of the model object. - :param source: path where test data is read from. - """ - - def __init__(self, parent=None, source=None): - self.directory = os.path.dirname(source) if source else None - self.setting_table = TestCaseFileSettingTable(self) - self.variable_table = VariableTable(self) - self.testcase_table = TestCaseTable(self) - self.keyword_table = KeywordTable(self) - _TestData.__init__(self, parent, source) - - def populate(self): - FromFilePopulator(self).populate(self.source) - self._validate() - return self - - def _validate(self): - if not self.testcase_table.is_started(): - raise NoTestsFound('File has no tests or tasks.') - - def has_tests(self): - return True - - def __iter__(self): - for table in [self.setting_table, self.variable_table, - self.testcase_table, self.keyword_table]: - yield table - - def __nonzero__(self): - return any(table for table in self) - - -class ResourceFile(_TestData): - """The parsed resource file object. - - :param source: path where resource file is read from. - """ - - def __init__(self, source=None): - self.directory = os.path.dirname(source) if source else None - self.setting_table = ResourceFileSettingTable(self) - self.variable_table = VariableTable(self) - self.testcase_table = TestCaseTable(self) - self.keyword_table = KeywordTable(self) - _TestData.__init__(self, source=source) - - def populate(self): - FromFilePopulator(self).populate(self.source, resource=True) - self._report_status() - return self - - def _report_status(self): - if self.setting_table or self.variable_table or self.keyword_table: - LOGGER.info("Imported resource file '%s' (%d keywords)." - % (self.source, len(self.keyword_table.keywords))) - else: - LOGGER.warn("Imported resource file '%s' is empty." % self.source) - - def _table_is_allowed(self, table): - if table is self.testcase_table: - raise DataError("Resource file '%s' cannot contain tests or " - "tasks." % self.source) - return True - - def __iter__(self): - for table in [self.setting_table, self.variable_table, self.keyword_table]: - yield table - - -class TestDataDirectory(_TestData): - """The parsed test data directory object. Contains hiearchical structure - of other :py:class:`.TestDataDirectory` and :py:class:`.TestCaseFile` - objects. - - :param parent: parent object to be used in creation of the model object. - :param source: path where test data is read from. - """ - - def __init__(self, parent=None, source=None): - self.directory = source - self.initfile = None - self.setting_table = InitFileSettingTable(self) - self.variable_table = VariableTable(self) - self.testcase_table = TestCaseTable(self) - self.keyword_table = KeywordTable(self) - _TestData.__init__(self, parent, source) - - def populate(self, include_suites=None, extensions=None, recurse=True): - FromDirectoryPopulator().populate(self.source, self, include_suites, - extensions, recurse) - self.children = [ch for ch in self.children if ch.has_tests()] - return self - - def _get_basename(self): - return os.path.basename(self.source) - - def _table_is_allowed(self, table): - if table is self.testcase_table: - LOGGER.error("Test suite initialization file in '%s' cannot " - "contain tests or tasks." % self.source) - return False - return True - - def add_child(self, path, include_suites, extensions=None): - self.children.append(TestData(parent=self, - source=path, - include_suites=include_suites, - extensions=extensions)) - - def has_tests(self): - return any(ch.has_tests() for ch in self.children) - - def __iter__(self): - for table in [self.setting_table, self.variable_table, self.keyword_table]: - yield table - - -@py2to3 -class _Table(object): - - def __init__(self, parent): - self.parent = parent - self._header = None - - def set_header(self, header): - self._header = self._prune_old_style_headers(header) - - def _prune_old_style_headers(self, header): - if len(header) < 3: - return header - if self._old_header_matcher.match(header): - return [header[0]] - return header - - @property - def header(self): - return self._header or [self.type.title() + 's'] - - @property - def name(self): - return self.header[0] - - @property - def source(self): - return self.parent.source - - @property - def directory(self): - return self.parent.directory - - def report_invalid_syntax(self, message, level='ERROR'): - self.parent.report_invalid_syntax(message, level) - - def __nonzero__(self): - return bool(self._header or len(self)) - - def __len__(self): - return sum(1 for item in self) - - -class _WithSettings(object): - _setters = {} - _aliases = {} - - def get_setter(self, name): - if name[-1:] == ':': - name = name[:-1] - setter = self._get_setter(name) - if setter is not None: - return setter - setter = self._get_deprecated_setter(name) - if setter is not None: - return setter - self.report_invalid_syntax("Non-existing setting '%s'." % name) - return None - - def _get_setter(self, name): - title = name.title() - if title in self._aliases: - title = self._aliases[name] - if title in self._setters: - return self._setters[title](self) - return None - - def _get_deprecated_setter(self, name): - normalized = normalize(name) - for setting in list(self._setters) + list(self._aliases): - if normalize(setting) == normalized: - self._report_deprecated_setting(name, setting) - return self._get_setter(setting) - return None - - def _report_deprecated_setting(self, deprecated, correct): - self.report_invalid_syntax( - "Setting '%s' is deprecated. Use '%s' instead." - % (deprecated, correct), level='WARN' - ) - - def report_invalid_syntax(self, message, level='ERROR'): - raise NotImplementedError - - -class _SettingTable(_Table, _WithSettings): - type = 'setting' - - def __init__(self, parent): - _Table.__init__(self, parent) - self.doc = Documentation('Documentation', self) - self.suite_setup = Fixture('Suite Setup', self) - self.suite_teardown = Fixture('Suite Teardown', self) - self.test_setup = Fixture('Test Setup', self) - self.test_teardown = Fixture('Test Teardown', self) - self.force_tags = Tags('Force Tags', self) - self.default_tags = Tags('Default Tags', self) - self.test_template = Template('Test Template', self) - self.test_timeout = Timeout('Test Timeout', self) - self.metadata = MetadataList(self) - self.imports = ImportList(self) - - @property - def _old_header_matcher(self): - return OldStyleSettingAndVariableTableHeaderMatcher() - - def add_metadata(self, name, value='', comment=None): - self.metadata.add(Metadata(self, name, value, comment)) - return self.metadata[-1] - - def add_library(self, name, args=None, comment=None): - self.imports.add(Library(self, name, args, comment=comment)) - return self.imports[-1] - - def add_resource(self, name, invalid_args=None, comment=None): - self.imports.add(Resource(self, name, invalid_args, comment=comment)) - return self.imports[-1] - - def add_variables(self, name, args=None, comment=None): - self.imports.add(Variables(self, name, args, comment=comment)) - return self.imports[-1] - - def __len__(self): - return sum(1 for setting in self if setting.is_set()) - - -class TestCaseFileSettingTable(_SettingTable): - _setters = {'Documentation': lambda s: s.doc.populate, - 'Suite Setup': lambda s: s.suite_setup.populate, - 'Suite Teardown': lambda s: s.suite_teardown.populate, - 'Test Setup': lambda s: s.test_setup.populate, - 'Test Teardown': lambda s: s.test_teardown.populate, - 'Force Tags': lambda s: s.force_tags.populate, - 'Default Tags': lambda s: s.default_tags.populate, - 'Test Template': lambda s: s.test_template.populate, - 'Test Timeout': lambda s: s.test_timeout.populate, - 'Library': lambda s: s.imports.populate_library, - 'Resource': lambda s: s.imports.populate_resource, - 'Variables': lambda s: s.imports.populate_variables, - 'Metadata': lambda s: s.metadata.populate} - _aliases = {'Task Setup': 'Test Setup', - 'Task Teardown': 'Test Teardown', - 'Task Template': 'Test Template', - 'Task Timeout': 'Test Timeout'} - - def __iter__(self): - for setting in [self.doc, self.suite_setup, self.suite_teardown, - self.test_setup, self.test_teardown, self.force_tags, - self.default_tags, self.test_template, self.test_timeout] \ - + self.metadata.data + self.imports.data: - yield setting - - -class ResourceFileSettingTable(_SettingTable): - _setters = {'Documentation': lambda s: s.doc.populate, - 'Library': lambda s: s.imports.populate_library, - 'Resource': lambda s: s.imports.populate_resource, - 'Variables': lambda s: s.imports.populate_variables} - - def __iter__(self): - for setting in [self.doc] + self.imports.data: - yield setting - - -class InitFileSettingTable(_SettingTable): - _setters = {'Documentation': lambda s: s.doc.populate, - 'Suite Setup': lambda s: s.suite_setup.populate, - 'Suite Teardown': lambda s: s.suite_teardown.populate, - 'Test Setup': lambda s: s.test_setup.populate, - 'Test Teardown': lambda s: s.test_teardown.populate, - 'Test Timeout': lambda s: s.test_timeout.populate, - 'Force Tags': lambda s: s.force_tags.populate, - 'Library': lambda s: s.imports.populate_library, - 'Resource': lambda s: s.imports.populate_resource, - 'Variables': lambda s: s.imports.populate_variables, - 'Metadata': lambda s: s.metadata.populate} - - def __iter__(self): - for setting in [self.doc, self.suite_setup, self.suite_teardown, - self.test_setup, self.test_teardown, self.force_tags, - self.test_timeout] + self.metadata.data + self.imports.data: - yield setting - - -class VariableTable(_Table): - type = 'variable' - - def __init__(self, parent): - _Table.__init__(self, parent) - self.variables = [] - - @property - def _old_header_matcher(self): - return OldStyleSettingAndVariableTableHeaderMatcher() - - def add(self, name, value, comment=None): - self.variables.append(Variable(self, name, value, comment)) - - def __iter__(self): - return iter(self.variables) - - -class TestCaseTable(_Table): - type = 'test case' - - def __init__(self, parent): - _Table.__init__(self, parent) - self.tests = [] - - def set_header(self, header): - if self._header and header: - self._validate_mode(self._header[0], header[0]) - _Table.set_header(self, header) - - def _validate_mode(self, name1, name2): - tasks1 = normalize(name1) in ('task', 'tasks') - tasks2 = normalize(name2) in ('task', 'tasks') - if tasks1 is not tasks2: - raise DataError('One file cannot have both tests and tasks.') - - @property - def _old_header_matcher(self): - return OldStyleTestAndKeywordTableHeaderMatcher() - - def add(self, name): - self.tests.append(TestCase(self, name)) - return self.tests[-1] - - def __iter__(self): - return iter(self.tests) - - def is_started(self): - return bool(self._header) - - -class KeywordTable(_Table): - type = 'keyword' - - def __init__(self, parent): - _Table.__init__(self, parent) - self.keywords = [] - - @property - def _old_header_matcher(self): - return OldStyleTestAndKeywordTableHeaderMatcher() - - def add(self, name): - self.keywords.append(UserKeyword(self, name)) - return self.keywords[-1] - - def __iter__(self): - return iter(self.keywords) - - -@py2to3 -class Variable(object): - - def __init__(self, parent, name, value, comment=None): - self.parent = parent - self.name = name.rstrip('= ') - if name.startswith('$') and value == []: - value = '' - if is_string(value): - value = [value] - self.value = value - self.comment = Comment(comment) - - def as_list(self): - if self.has_data(): - return [self.name] + self.value + self.comment.as_list() - return self.comment.as_list() - - def is_set(self): - return True - - def is_for_loop(self): - return False - - def has_data(self): - return bool(self.name or ''.join(self.value)) - - def __nonzero__(self): - return self.has_data() - - def report_invalid_syntax(self, message, level='ERROR'): - self.parent.report_invalid_syntax("Setting variable '%s' failed: %s" - % (self.name, message), level) - - -class _WithSteps(object): - - def add_step(self, content, comment=None): - self.steps.append(Step(content, comment)) - return self.steps[-1] - - def copy(self, name): - new = copy.deepcopy(self) - new.name = name - self._add_to_parent(new) - return new - - -class TestCase(_WithSteps, _WithSettings): - - def __init__(self, parent, name): - self.parent = parent - self.name = name - self.doc = Documentation('[Documentation]', self) - self.template = Template('[Template]', self) - self.tags = Tags('[Tags]', self) - self.setup = Fixture('[Setup]', self) - self.teardown = Fixture('[Teardown]', self) - self.timeout = Timeout('[Timeout]', self) - self.steps = [] - if name == '...': - self.report_invalid_syntax( - "Using '...' as test case name is deprecated. It will be " - "considered line continuation in Robot Framework 3.2.", - level='WARN' - ) - - _setters = {'Documentation': lambda s: s.doc.populate, - 'Template': lambda s: s.template.populate, - 'Setup': lambda s: s.setup.populate, - 'Teardown': lambda s: s.teardown.populate, - 'Tags': lambda s: s.tags.populate, - 'Timeout': lambda s: s.timeout.populate} - - @property - def source(self): - return self.parent.source - - @property - def directory(self): - return self.parent.directory - - def add_for_loop(self, declaration, comment=None): - self.steps.append(ForLoop(self, declaration, comment)) - return self.steps[-1] - - def end_for_loop(self): - loop, steps = self._find_last_empty_for_and_steps_after() - if not loop: - return False - loop.steps.extend(steps) - self.steps[-len(steps):] = [] - return True - - def _find_last_empty_for_and_steps_after(self): - steps = [] - for step in reversed(self.steps): - if isinstance(step, ForLoop): - if not step.steps: - steps.reverse() - return step, steps - break - steps.append(step) - return None, [] - - def report_invalid_syntax(self, message, level='ERROR'): - type_ = 'test case' if type(self) is TestCase else 'keyword' - message = "Invalid syntax in %s '%s': %s" % (type_, self.name, message) - self.parent.report_invalid_syntax(message, level) - - def _add_to_parent(self, test): - self.parent.tests.append(test) - - @property - def settings(self): - return [self.doc, self.tags, self.setup, self.template, self.timeout, - self.teardown] - - def __iter__(self): - for element in [self.doc, self.tags, self.setup, - self.template, self.timeout] \ - + self.steps + [self.teardown]: - yield element - - -class UserKeyword(TestCase): - - def __init__(self, parent, name): - self.parent = parent - self.name = name - self.doc = Documentation('[Documentation]', self) - self.args = Arguments('[Arguments]', self) - self.return_ = Return('[Return]', self) - self.timeout = Timeout('[Timeout]', self) - self.teardown = Fixture('[Teardown]', self) - self.tags = Tags('[Tags]', self) - self.steps = [] - if name == '...': - self.report_invalid_syntax( - "Using '...' as keyword name is deprecated. It will be " - "considered line continuation in Robot Framework 3.2.", - level='WARN' - ) - - _setters = {'Documentation': lambda s: s.doc.populate, - 'Arguments': lambda s: s.args.populate, - 'Return': lambda s: s.return_.populate, - 'Timeout': lambda s: s.timeout.populate, - 'Teardown': lambda s: s.teardown.populate, - 'Tags': lambda s: s.tags.populate} - - def _add_to_parent(self, test): - self.parent.keywords.append(test) - - @property - def settings(self): - return [self.args, self.doc, self.tags, self.timeout, self.teardown, self.return_] - - def __iter__(self): - for element in [self.args, self.doc, self.tags, self.timeout] \ - + self.steps + [self.teardown, self.return_]: - yield element - - -class ForLoop(_WithSteps): - """The parsed representation of a for-loop. - - :param list declaration: The literal cell values that declare the loop - (excluding ":FOR"). - :param str comment: A comment, default None. - :ivar str flavor: The value of the 'IN' item, uppercased. - Typically 'IN', 'IN RANGE', 'IN ZIP', or 'IN ENUMERATE'. - :ivar list vars: Variables set per-iteration by this loop. - :ivar list items: Loop values that come after the 'IN' item. - :ivar str comment: A comment, or None. - :ivar list steps: A list of steps in the loop. - """ - flavors = {'IN', 'IN RANGE', 'IN ZIP', 'IN ENUMERATE'} - normalized_flavors = NormalizedDict((f, f) for f in flavors) - - def __init__(self, parent, declaration, comment=None): - self.parent = parent - self.flavor, index = self._get_flavor_and_index(declaration) - self.vars = declaration[:index] - self.items = declaration[index+1:] - self.comment = Comment(comment) - self.steps = [] - - def _get_flavor_and_index(self, declaration): - for index, item in enumerate(declaration): - if item in self.flavors: - return item, index - if item in self.normalized_flavors: - correct = self.normalized_flavors[item] - self._report_deprecated_flavor_syntax(item, correct) - return correct, index - if normalize(item).startswith('in'): - return item.upper(), index - return 'IN', len(declaration) - - def _report_deprecated_flavor_syntax(self, deprecated, correct): - self.parent.report_invalid_syntax( - "Using '%s' as a FOR loop separator is deprecated. " - "Use '%s' instead." % (deprecated, correct), level='WARN' - ) - - def is_comment(self): - return False - - def is_for_loop(self): - return True - - def as_list(self, indent=False, include_comment=True): - comments = self.comment.as_list() if include_comment else [] - return ['FOR'] + self.vars + [self.flavor] + self.items + comments - - def __iter__(self): - return iter(self.steps) - - def is_set(self): - return True - - -class Step(object): - - def __init__(self, content, comment=None): - self.assign = self._get_assign(content) - self.name = content.pop(0) if content else None - self.args = content - self.comment = Comment(comment) - - def _get_assign(self, content): - assign = [] - while content and is_var(content[0].rstrip('= ')): - assign.append(content.pop(0)) - return assign - - def is_comment(self): - return not (self.assign or self.name or self.args) - - def is_for_loop(self): - return False - - def is_set(self): - return True - - def as_list(self, indent=False, include_comment=True): - kw = [self.name] if self.name is not None else [] - comments = self.comment.as_list() if include_comment else [] - data = self.assign + kw + self.args + comments - if indent: - data.insert(0, '') - return data - - -class OldStyleSettingAndVariableTableHeaderMatcher(object): - - def match(self, header): - return all(value.lower() == 'value' for value in header[1:]) - - -class OldStyleTestAndKeywordTableHeaderMatcher(object): - - def match(self, header): - if header[1].lower() != 'action': - return False - for arg in header[2:]: - if not arg.lower().startswith('arg'): - return False - return True diff --git a/src/robot/parsing/model/__init__.py b/src/robot/parsing/model/__init__.py new file mode 100644 index 00000000000..57719442acf --- /dev/null +++ b/src/robot/parsing/model/__init__.py @@ -0,0 +1,38 @@ +# 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 .blocks import ( + Block as Block, + CommentSection as CommentSection, + Container as Container, + File as File, + For as For, + Group as Group, + If as If, + ImplicitCommentSection as ImplicitCommentSection, + InvalidSection as InvalidSection, + Keyword as Keyword, + KeywordSection as KeywordSection, + NestedBlock as NestedBlock, + Section as Section, + SettingSection as SettingSection, + TestCase as TestCase, + TestCaseSection as TestCaseSection, + Try as Try, + VariableSection as VariableSection, + While as While, +) +from .statements import Config as Config, End as End, Statement as Statement +from .visitor import ModelTransformer as ModelTransformer, ModelVisitor as ModelVisitor diff --git a/src/robot/parsing/model/blocks.py b/src/robot/parsing/model/blocks.py new file mode 100644 index 00000000000..b4b37e8c737 --- /dev/null +++ b/src/robot/parsing/model/blocks.py @@ -0,0 +1,586 @@ +# 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 +from abc import ABC +from contextlib import contextmanager +from pathlib import Path +from typing import cast, Iterator, Sequence, TextIO, Union + +from robot.utils import file_writer, test_or_task + +from ..lexer import Token +from .statements import ( + Break, Continue, ElseHeader, ElseIfHeader, End, Error, ExceptHeader, FinallyHeader, + ForHeader, GroupHeader, IfHeader, KeywordCall, KeywordName, Node, ReturnSetting, + ReturnStatement, SectionHeader, Statement, TemplateArguments, TestCaseName, + TryHeader, Var, WhileHeader +) +from .visitor import ModelVisitor + +Body = Sequence[Union[Statement, "Block"]] +Errors = Sequence[str] + + +class Container(Node, ABC): + + @property + def lineno(self) -> int: + statement = FirstStatementFinder.find_from(self) + return statement.lineno if statement else -1 + + @property + def col_offset(self) -> int: + statement = FirstStatementFinder.find_from(self) + return statement.col_offset if statement else -1 + + @property + def end_lineno(self) -> int: + statement = LastStatementFinder.find_from(self) + return statement.end_lineno if statement else -1 + + @property + def end_col_offset(self) -> int: + statement = LastStatementFinder.find_from(self) + return statement.end_col_offset if statement else -1 + + def validate_model(self): + ModelValidator().visit(self) + + def validate(self, ctx: "ValidationContext"): + pass + + +class File(Container): + _fields = ("sections",) + _attributes = ("source", "languages", *Container._attributes) + + def __init__( + self, + sections: "Sequence[Section]" = (), + source: "Path | None" = None, + languages: Sequence[str] = (), + ): + super().__init__() + self.sections = list(sections) + self.source = source + self.languages = list(languages) + + 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 + object. If ``output`` is not given, the original source file will + be overwritten. + """ + output = output or self.source + if output is None: + raise TypeError( + "Saving model requires explicit output when original source " + "is not path." + ) + ModelWriter(output).write(self) + + +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, + Var, + Continue, + Break, + ReturnSetting, + Group, + ReturnStatement, + NestedBlock, + Error, + ) + return not any(isinstance(node, valid) for node in self.body) + + +class Section(Block): + header: "SectionHeader | None" + + +class SettingSection(Section): + header: SectionHeader + + +class VariableSection(Section): + header: SectionHeader + + +# TODO: should there be a separate TaskSection? +class TestCaseSection(Section): + header: SectionHeader + + @property + def tasks(self) -> bool: + return self.header.type == Token.TASK_HEADER + + +class KeywordSection(Section): + header: SectionHeader + + +class CommentSection(Section): + 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(Block): + header: TestCaseName + + @property + def name(self) -> str: + return self.header.name + + def validate(self, ctx: "ValidationContext"): + if self._body_is_empty(): + self.errors += (test_or_task("{Test} cannot be empty.", ctx.tasks),) + + +class Keyword(Block): + header: KeywordName + + @property + def name(self) -> str: + return self.header.name + + def validate(self, ctx: "ValidationContext"): + if self._body_is_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(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: Statement, + body: Body = (), + orelse: "If | None" = None, + end: "End | None" = None, + errors: Errors = (), + ): + super().__init__(header, body, end, errors) + self.orelse = orelse + + @property + def type(self) -> str: + return self.header.type + + @property + def condition(self) -> "str | None": + return self.header.condition + + @property + def assign(self) -> "tuple[str, ...]": + return self.header.assign + + def validate(self, ctx: "ValidationContext"): + self._validate_body() + if self.type == Token.IF: + self._validate_structure() + self._validate_end() + if self.type == Token.INLINE_IF: + self._validate_structure() + self._validate_inline_if() + + def _validate_body(self): + if self._body_is_empty(): + type = self.type if self.type != Token.INLINE_IF else "IF" + self.errors += (f"{type} branch cannot be empty.",) + + def _validate_structure(self): + orelse = self.orelse + else_seen = False + while orelse: + if else_seen: + if orelse.type == Token.ELSE: + error = "Only one ELSE allowed." + else: + error = "ELSE IF not allowed after ELSE." + if error not in self.errors: + self.errors += (error,) + else_seen = else_seen or orelse.type == Token.ELSE + orelse = orelse.orelse + + def _validate_end(self): + if not self.end: + self.errors += ("IF must have closing END.",) + + def _validate_inline_if(self): + branch = self + assign = branch.assign + while branch: + if branch.body: + item = cast(Statement, branch.body[0]) + if assign and item.type != Token.KEYWORD: + self.errors += ( + "Inline IF with assignment can only contain keyword calls.", + ) + if getattr(item, "assign", None): + self.errors += ("Inline IF branches cannot contain assignments.",) + if item.type == Token.INLINE_IF: + self.errors += ("Inline IF cannot be nested.",) + branch = branch.orelse + + +class For(NestedBlock): + header: ForHeader + + @property + 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, ...]": + return self.header.values + + @property + def flavor(self) -> "str | None": + return self.header.flavor + + @property + def start(self) -> "str | None": + return self.header.start + + @property + def mode(self) -> "str | None": + return self.header.mode + + @property + def fill(self) -> "str | None": + return self.header.fill + + 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(NestedBlock): + _fields = ("header", "body", "next", "end") + header: "TryHeader | ExceptHeader | ElseHeader | FinallyHeader" + + 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 + + @property + def type(self) -> str: + return self.header.type + + @property + def patterns(self) -> "tuple[str, ...]": + return getattr(self.header, "patterns", ()) + + @property + def pattern_type(self) -> "str | None": + return getattr(self.header, "pattern_type", None) + + @property + 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() + if self.type == Token.TRY: + self._validate_structure() + self._validate_end() + TemplatesNotAllowed("TRY").check(self) + + def _validate_body(self): + if self._body_is_empty(): + self.errors += (f"{self.type} branch cannot be empty.",) + + def _validate_structure(self): + else_count = 0 + finally_count = 0 + except_count = 0 + empty_except_count = 0 + branch = self.next + while branch: + if branch.type == Token.EXCEPT: + if else_count: + self.errors += ("EXCEPT not allowed after ELSE.",) + if finally_count: + self.errors += ("EXCEPT not allowed after FINALLY.",) + if branch.patterns and empty_except_count: + self.errors += ("EXCEPT without patterns must be last.",) + if not branch.patterns: + empty_except_count += 1 + except_count += 1 + if branch.type == Token.ELSE: + if finally_count: + self.errors += ("ELSE not allowed after FINALLY.",) + else_count += 1 + if branch.type == Token.FINALLY: + finally_count += 1 + branch = branch.next + if finally_count > 1: + self.errors += ("Only one FINALLY allowed.",) + if else_count > 1: + self.errors += ("Only one ELSE allowed.",) + if empty_except_count > 1: + self.errors += ("Only one EXCEPT without patterns allowed.",) + if not (except_count or finally_count): + self.errors += ("TRY structure must have EXCEPT or FINALLY branch.",) + + def _validate_end(self): + if not self.end: + self.errors += ("TRY must have closing END.",) + + +class While(NestedBlock): + header: WhileHeader + + @property + def condition(self) -> str: + return self.header.condition + + @property + def limit(self) -> "str | None": + return self.header.limit + + @property + def on_limit(self) -> "str | None": + return self.header.on_limit + + @property + def on_limit_message(self) -> "str | None": + return self.header.on_limit_message + + def validate(self, ctx: "ValidationContext"): + if self._body_is_empty(): + self.errors += ("WHILE loop cannot be empty.",) + if not self.end: + self.errors += ("WHILE loop must have closing END.",) + TemplatesNotAllowed("WHILE").check(self) + + +class Group(NestedBlock): + header: GroupHeader + + @property + def name(self) -> str: + return self.header.name + + def validate(self, ctx: "ValidationContext"): + if self._body_is_empty(): + self.errors += ("GROUP cannot be empty.",) + if not self.end: + self.errors += ("GROUP must have closing END.",) + + +class ModelWriter(ModelVisitor): + + def __init__(self, output: "Path | str | TextIO"): + 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: Node): + try: + self.visit(model) + finally: + if self.close_writer: + self.writer.close() + + def visit_Statement(self, statement: Statement): + for token in statement: + self.writer.write(token.value) + + +class ModelValidator(ModelVisitor): + + def __init__(self): + self.ctx = ValidationContext() + + def visit_Block(self, node: Block): + with self.ctx.block(node): + node.validate(self.ctx) + super().generic_visit(node) + + def visit_Statement(self, node: Statement): + node.validate(self.ctx) + + +class ValidationContext: + + def __init__(self): + self.blocks = [] + + @contextmanager + def block(self, node: Block) -> Iterator[None]: + self.blocks.append(node) + try: + yield + finally: + self.blocks.pop() + + @property + def parent_block(self) -> "Block | None": + return self.blocks[-1] if self.blocks else None + + @property + def tasks(self) -> bool: + for parent in self.blocks: + if isinstance(parent, TestCaseSection): + return parent.tasks + return False + + @property + def in_keyword(self) -> bool: + return any(isinstance(b, Keyword) for b in self.blocks) + + @property + def in_loop(self) -> bool: + return any(isinstance(b, (For, While)) for b in self.blocks) + + @property + def in_finally(self) -> bool: + parent = self.parent_block + return isinstance(parent, Try) and parent.header.type == Token.FINALLY + + +class FirstStatementFinder(ModelVisitor): + + def __init__(self): + self.statement: Statement | None = None + + @classmethod + def find_from(cls, model: Node) -> "Statement | None": + finder = cls() + finder.visit(model) + return finder.statement + + def visit_Statement(self, statement: Statement): + if self.statement is None: + self.statement = statement + + def generic_visit(self, node: Node): + if self.statement is None: + super().generic_visit(node) + + +class LastStatementFinder(ModelVisitor): + + def __init__(self): + self.statement: Statement | None = None + + @classmethod + def find_from(cls, model: Node) -> "Statement | None": + finder = cls() + finder.visit(model) + return finder.statement + + def visit_Statement(self, statement: Statement): + self.statement = statement + + +class TemplatesNotAllowed(ModelVisitor): + + def __init__(self, kind: str): + self.kind = kind + self.found = False + + def check(self, model: Node): + self.found = False + self.visit(model) + if self.found: + model.errors += (f"{self.kind} does not support templates.",) + + def visit_TemplateArguments(self, node: None): + self.found = True diff --git a/src/robot/parsing/model/statements.py b/src/robot/parsing/model/statements.py new file mode 100644 index 00000000000..eb8d6404499 --- /dev/null +++ b/src/robot/parsing/model/statements.py @@ -0,0 +1,1788 @@ +# 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 ast +import re +import warnings +from abc import ABC, abstractmethod +from collections.abc import Iterator, Sequence +from typing import ClassVar, Literal, overload, Type, TYPE_CHECKING, TypeVar + +from robot.conf import Language +from robot.errors import DataError +from robot.running import TypeInfo +from robot.running.arguments import UserKeywordArgumentParser +from robot.utils import normalize_whitespace, seq2str, split_from_equals, test_or_task +from robot.variables import ( + contains_variable, is_dict_variable, is_scalar_assign, search_variable, + VariableAssignment +) + +from ..lexer import Token + +if TYPE_CHECKING: + from .blocks import ValidationContext + + +T = TypeVar("T", bound="Statement") +FOUR_SPACES = " " +EOL = "\n" + + +class Node(ast.AST, ABC): + _attributes = ("lineno", "col_offset", "end_lineno", "end_col_offset", "errors") + lineno: int + col_offset: int + end_lineno: int + end_col_offset: int + errors: "tuple[str, ...]" = () + + +class Statement(Node, ABC): + _attributes = ("type", "tokens", *Node._attributes) + 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) + self.errors = tuple(errors) + + @property + def lineno(self) -> int: + return self.tokens[0].lineno if self.tokens else -1 + + @property + def col_offset(self) -> int: + return self.tokens[0].col_offset if self.tokens else -1 + + @property + def end_lineno(self) -> int: + return self.tokens[-1].lineno if self.tokens else -1 + + @property + def end_col_offset(self) -> int: + return self.tokens[-1].end_col_offset if self.tokens else -1 + + @classmethod + def register(cls, subcls: Type[T]) -> Type[T]: + types = subcls.handles_types or (subcls.type,) + for typ in types: + cls.statement_handlers[typ] = subcls + return subcls + + @classmethod + 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) + if any(token.type == Token.ASSIGN for token in tokens): + return KeywordCall(tokens) + return EmptyLine(tokens) + + @classmethod + @abstractmethod + def from_params(cls, *args, **kwargs) -> "Statement": + """Create a statement from passed parameters. + + Required and optional arguments in general match class properties. + Values are used to create matching tokens. + + 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'``. + + 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) -> "list[Token]": + return [t for t in self.tokens if t.type not in Token.NON_DATA_TOKENS] + + 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 + matches, return the first match. + """ + for token in self.tokens: + if token.type in types: + return token + return None + + 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] + + @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 + matches, return the value of the first match. + """ + token = self.get_token(type) + return token.value if token else default + + 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: 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``. + + 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. + """ + return self._get_options().get(name, default) + + 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]]": + line = [] + for token in self.tokens: + line.append(token) + if token.type == Token.EOL: + yield line + line = [] + if line: + yield line + + def validate(self, ctx: "ValidationContext"): + pass + + def _validate_options(self): + 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 value '{value}'. " + f"Valid values are {seq2str(expected)}.", + ) + + def __iter__(self) -> "Iterator[Token]": + return iter(self.tokens) + + def __len__(self) -> int: + return len(self.tokens) + + def __getitem__(self, item) -> Token: + return self.tokens[item] + + def __repr__(self) -> str: + 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, ABC): + + @property + def value(self) -> str: + return "".join(self._get_lines()).rstrip() + + 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) + 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_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 + # 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 = [] + if not eol: + line.append(token) + lineno = token.lineno + if line: + yield line + + 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: + 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: str) -> str: + if value and value[-1] == "\\": + match = re.search(r"(\\+)$", value) + if match and len(match.group(1)) % 2 == 1: + value = value[:-1] + return value + + def _has_trailing_backslash_or_newline(self, line: str) -> bool: + match = re.search(r"(\\+)n?$", line) + return bool(match and len(match.group(1)) % 2 == 1) + + +class SingleValue(Statement, ABC): + + @property + def value(self) -> "str | None": + values = self.get_values(Token.NAME, Token.ARGUMENT) + if values and values[0].upper() != "NONE": + return values[0] + return None + + +class MultiValue(Statement, ABC): + + @property + def values(self) -> "tuple[str, ...]": + return self.get_values(Token.ARGUMENT) + + +class Fixture(Statement, ABC): + + @property + def name(self) -> str: + return self.get_value(Token.NAME, "") + + @property + def args(self) -> "tuple[str, ...]": + return self.get_values(Token.ARGUMENT) + + +@Statement.register +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, + ) + + @classmethod + 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", + "Invalid", + ) + name = dict(zip(cls.handles_types, names))[type] + header = f"*** {name} ***" if not name.startswith("*") else name + return cls([Token(type, header), Token(Token.EOL, eol)]) + + @property + def type(self) -> str: + token = self.get_token(*self.handles_types) + return token.type # type: ignore + + @property + def name(self) -> str: + token = self.get_token(*self.handles_types) + return normalize_whitespace(token.value).strip("* ") if token else "" + + +@Statement.register +class LibraryImport(Statement): + type = Token.LIBRARY + + @classmethod + 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), + ] + for arg in args: + tokens += [ + Token(Token.SEPARATOR, separator), + Token(Token.ARGUMENT, arg), + ] + if alias is not None: + tokens += [ + Token(Token.SEPARATOR, separator), + Token(Token.AS), + Token(Token.SEPARATOR, separator), + Token(Token.NAME, alias), + ] + tokens += [Token(Token.EOL, eol)] + return cls(tokens) + + @property + def name(self) -> str: + return self.get_value(Token.NAME, "") + + @property + def args(self) -> "tuple[str, ...]": + return self.get_values(Token.ARGUMENT) + + @property + def alias(self) -> "str | None": + separator = self.get_token(Token.AS) + return self.get_tokens(Token.NAME)[-1].value if separator else None + + +@Statement.register +class ResourceImport(Statement): + type = Token.RESOURCE + + @classmethod + def from_params( + cls, + name: str, + separator: str = FOUR_SPACES, + eol: str = EOL, + ) -> "ResourceImport": + tokens = [ + Token(Token.RESOURCE, "Resource"), + Token(Token.SEPARATOR, separator), + Token(Token.NAME, name), + Token(Token.EOL, eol), + ] + return cls(tokens) + + @property + def name(self) -> str: + return self.get_value(Token.NAME, "") + + +@Statement.register +class VariablesImport(Statement): + type = Token.VARIABLES + + @classmethod + 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), + ] + for arg in args: + tokens += [ + Token(Token.SEPARATOR, separator), + Token(Token.ARGUMENT, arg), + ] + tokens += [Token(Token.EOL, eol)] + return cls(tokens) + + @property + def name(self) -> str: + return self.get_value(Token.NAME, "") + + @property + def args(self) -> "tuple[str, ...]": + return self.get_values(Token.ARGUMENT) + + +@Statement.register +class Documentation(DocumentationOrMetadata): + type = Token.DOCUMENTATION + + @classmethod + 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), + ] + else: + 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 += [ + Token(Token.ARGUMENT, doc_lines[0]), + Token(Token.EOL, eol), + ] + for line in doc_lines[1:]: + if not settings_section: + tokens += [Token(Token.SEPARATOR, indent)] + tokens += [Token(Token.CONTINUATION)] + if line: + tokens += [Token(Token.SEPARATOR, multiline_separator)] + tokens += [ + Token(Token.ARGUMENT, line), + Token(Token.EOL, eol), + ] + return cls(tokens) + + +@Statement.register +class Metadata(DocumentationOrMetadata): + type = Token.METADATA + + @classmethod + 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), + ] + metadata_lines = value.splitlines() + if metadata_lines: + tokens += [ + Token(Token.SEPARATOR, separator), + Token(Token.ARGUMENT, metadata_lines[0]), + Token(Token.EOL, eol), + ] + for line in metadata_lines[1:]: + tokens += [ + Token(Token.CONTINUATION), + Token(Token.SEPARATOR, separator), + Token(Token.ARGUMENT, line), + Token(Token.EOL, eol), + ] + return cls(tokens) + + @property + def name(self) -> str: + return self.get_value(Token.NAME, "") + + +@Statement.register +class TestTags(MultiValue): + type = Token.TEST_TAGS + + @classmethod + def from_params( + cls, + values: "Sequence[str]", + separator: str = FOUR_SPACES, + eol: str = EOL, + ) -> "TestTags": + tokens = [Token(Token.TEST_TAGS, "Test Tags")] + for tag in values: + tokens += [ + Token(Token.SEPARATOR, separator), + Token(Token.ARGUMENT, tag), + ] + tokens += [Token(Token.EOL, eol)] + return cls(tokens) + + +@Statement.register +class DefaultTags(MultiValue): + type = Token.DEFAULT_TAGS + + @classmethod + 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 += [ + Token(Token.SEPARATOR, separator), + Token(Token.ARGUMENT, tag), + ] + tokens += [Token(Token.EOL, eol)] + return cls(tokens) + + +@Statement.register +class KeywordTags(MultiValue): + type = Token.KEYWORD_TAGS + + @classmethod + 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 += [ + Token(Token.SEPARATOR, separator), + Token(Token.ARGUMENT, tag), + ] + tokens += [Token(Token.EOL, eol)] + return cls(tokens) + + +@Statement.register +class SuiteName(SingleValue): + type = Token.SUITE_NAME + + @classmethod + def from_params( + cls, + value: str, + separator: str = FOUR_SPACES, + eol: str = EOL, + ) -> "SuiteName": + tokens = [ + Token(Token.SUITE_NAME, "Name"), + Token(Token.SEPARATOR, separator), + Token(Token.NAME, value), + Token(Token.EOL, eol), + ] + return cls(tokens) + + +@Statement.register +class SuiteSetup(Fixture): + type = Token.SUITE_SETUP + + @classmethod + 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), + ] + for arg in args: + tokens += [ + Token(Token.SEPARATOR, separator), + Token(Token.ARGUMENT, arg), + ] + tokens += [Token(Token.EOL, eol)] + return cls(tokens) + + +@Statement.register +class SuiteTeardown(Fixture): + type = Token.SUITE_TEARDOWN + + @classmethod + 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), + ] + for arg in args: + tokens += [ + Token(Token.SEPARATOR, separator), + Token(Token.ARGUMENT, arg), + ] + tokens += [Token(Token.EOL, eol)] + return cls(tokens) + + +@Statement.register +class TestSetup(Fixture): + type = Token.TEST_SETUP + + @classmethod + 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), + ] + for arg in args: + tokens += [ + Token(Token.SEPARATOR, separator), + Token(Token.ARGUMENT, arg), + ] + tokens += [Token(Token.EOL, eol)] + return cls(tokens) + + +@Statement.register +class TestTeardown(Fixture): + type = Token.TEST_TEARDOWN + + @classmethod + 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), + ] + for arg in args: + tokens += [ + Token(Token.SEPARATOR, separator), + Token(Token.ARGUMENT, arg), + ] + tokens += [Token(Token.EOL, eol)] + return cls(tokens) + + +@Statement.register +class TestTemplate(SingleValue): + type = Token.TEST_TEMPLATE + + @classmethod + def from_params( + cls, + value: str, + separator: str = FOUR_SPACES, + eol: str = EOL, + ) -> "TestTemplate": + tokens = [ + Token(Token.TEST_TEMPLATE, "Test Template"), + Token(Token.SEPARATOR, separator), + Token(Token.NAME, value), + Token(Token.EOL, eol), + ] + return cls(tokens) + + +@Statement.register +class TestTimeout(SingleValue): + type = Token.TEST_TIMEOUT + + @classmethod + def from_params( + cls, + value: str, + separator: str = FOUR_SPACES, + eol: str = EOL, + ) -> "TestTimeout": + tokens = [ + Token(Token.TEST_TIMEOUT, "Test Timeout"), + Token(Token.SEPARATOR, separator), + Token(Token.ARGUMENT, value), + Token(Token.EOL, eol), + ] + return cls(tokens) + + +@Statement.register +class Variable(Statement): + type = Token.VARIABLE + options = {"separator": None} + + @classmethod + 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 += [ + Token(Token.SEPARATOR, separator), + Token(Token.ARGUMENT, value), + ] + if value_separator is not None: + tokens += [ + Token(Token.SEPARATOR, separator), + Token(Token.OPTION, f"separator={value_separator}"), + ] + tokens += [Token(Token.EOL, eol)] + return cls(tokens) + + @property + def name(self) -> str: + name = self.get_value(Token.VARIABLE, "") + if name.endswith("="): + return name[:-1].rstrip() + return name + + @property + 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"): + VariableValidator().validate(self) + self._validate_options() + + +@Statement.register +class TestCaseName(Statement): + type = Token.TESTCASE_NAME + + @classmethod + def from_params(cls, name: str, eol: str = EOL) -> "TestCaseName": + tokens = [Token(Token.TESTCASE_NAME, name)] + if eol: + tokens += [Token(Token.EOL, eol)] + return cls(tokens) + + @property + def name(self) -> str: + return self.get_value(Token.TESTCASE_NAME, "") + + def validate(self, ctx: "ValidationContext"): + if not self.name: + self.errors += (test_or_task("{Test} name cannot be empty.", ctx.tasks),) + + +@Statement.register +class KeywordName(Statement): + type = Token.KEYWORD_NAME + + @classmethod + def from_params(cls, name: str, eol: str = EOL) -> "KeywordName": + tokens = [Token(Token.KEYWORD_NAME, name)] + if eol: + tokens += [Token(Token.EOL, eol)] + return cls(tokens) + + @property + def name(self) -> str: + 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): + type = Token.SETUP + + @classmethod + 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), + Token(Token.NAME, name), + ] + for arg in args: + tokens += [ + Token(Token.SEPARATOR, separator), + Token(Token.ARGUMENT, arg), + ] + tokens += [Token(Token.EOL, eol)] + return cls(tokens) + + +@Statement.register +class Teardown(Fixture): + type = Token.TEARDOWN + + @classmethod + 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), + Token(Token.NAME, name), + ] + for arg in args: + tokens += [ + Token(Token.SEPARATOR, separator), + Token(Token.ARGUMENT, arg), + ] + tokens += [Token(Token.EOL, eol)] + return cls(tokens) + + +@Statement.register +class Tags(MultiValue): + type = Token.TAGS + + @classmethod + 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: + tokens += [ + Token(Token.SEPARATOR, separator), + Token(Token.ARGUMENT, tag), + ] + tokens += [Token(Token.EOL, eol)] + return cls(tokens) + + +@Statement.register +class Template(SingleValue): + type = Token.TEMPLATE + + @classmethod + def from_params( + cls, + value: str, + indent: str = FOUR_SPACES, + separator: str = FOUR_SPACES, + eol: str = EOL, + ) -> "Template": + tokens = [ + Token(Token.SEPARATOR, indent), + Token(Token.TEMPLATE, "[Template]"), + Token(Token.SEPARATOR, separator), + Token(Token.NAME, value), + Token(Token.EOL, eol), + ] + return cls(tokens) + + +@Statement.register +class Timeout(SingleValue): + type = Token.TIMEOUT + + @classmethod + def from_params( + cls, + value: str, + indent: str = FOUR_SPACES, + separator: str = FOUR_SPACES, + eol: str = EOL, + ) -> "Timeout": + tokens = [ + Token(Token.SEPARATOR, indent), + Token(Token.TIMEOUT, "[Timeout]"), + Token(Token.SEPARATOR, separator), + Token(Token.ARGUMENT, value), + Token(Token.EOL, eol), + ] + return cls(tokens) + + +@Statement.register +class Arguments(MultiValue): + type = Token.ARGUMENTS + + @classmethod + 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: + tokens += [ + Token(Token.SEPARATOR, separator), + Token(Token.ARGUMENT, arg), + ] + tokens += [Token(Token.EOL, eol)] + return cls(tokens) + + def validate(self, ctx: "ValidationContext"): + errors: list[str] = [] + UserKeywordArgumentParser(error_reporter=errors.append).parse(self.values) + self.errors = tuple(errors) + + +@Statement.register +class ReturnSetting(MultiValue): + """Represents the deprecated ``[Return]`` setting. + + This class was named ``Return`` prior to Robot Framework 7.0. A forward + compatible ``ReturnSetting`` alias existed already in Robot Framework 6.1. + """ + + type = Token.RETURN + + @classmethod + def from_params( + cls, + args: "Sequence[str]", + indent: str = FOUR_SPACES, + separator: str = FOUR_SPACES, + eol: str = EOL, + ) -> "ReturnSetting": + tokens = [ + Token(Token.SEPARATOR, indent), + Token(Token.RETURN, "[Return]"), + ] + for arg in args: + tokens += [ + Token(Token.SEPARATOR, separator), + Token(Token.ARGUMENT, arg), + ] + tokens += [Token(Token.EOL, eol)] + return cls(tokens) + + +@Statement.register +class KeywordCall(Statement): + type = Token.KEYWORD + + @classmethod + 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 += [ + Token(Token.ASSIGN, assignment), + Token(Token.SEPARATOR, separator), + ] + tokens += [Token(Token.KEYWORD, name)] + for arg in args: + tokens += [ + Token(Token.SEPARATOR, separator), + Token(Token.ARGUMENT, arg), + ] + tokens += [Token(Token.EOL, eol)] + return cls(tokens) + + @property + def keyword(self) -> str: + return self.get_value(Token.KEYWORD, "") + + @property + def args(self) -> "tuple[str, ...]": + return self.get_values(Token.ARGUMENT) + + @property + def assign(self) -> "tuple[str, ...]": + return self.get_values(Token.ASSIGN) + + def validate(self, ctx: "ValidationContext"): + AssignmentValidator().validate(self) + + +@Statement.register +class TemplateArguments(Statement): + type = Token.ARGUMENT + + @classmethod + 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 += [ + Token(Token.SEPARATOR, separator if index else indent), + Token(Token.ARGUMENT, arg), + ] + tokens += [Token(Token.EOL, eol)] + return cls(tokens) + + @property + def args(self) -> "tuple[str, ...]": + return self.get_values(self.type) + + +@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]", + 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), + ] + for variable in assign: + tokens += [ + Token(Token.VARIABLE, variable), + Token(Token.SEPARATOR, separator), + ] + tokens += [Token(Token.FOR_SEPARATOR, flavor)] + for value in values: + tokens += [ + Token(Token.SEPARATOR, separator), + Token(Token.ARGUMENT, value), + ] + tokens += [Token(Token.EOL, eol)] + return cls(tokens) + + @property + def assign(self) -> "tuple[str, ...]": + return self.get_values(Token.VARIABLE) + + @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) + + @property + 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) -> "str | None": + return self.get_option("start") if self.flavor == "IN ENUMERATE" else None + + @property + def mode(self) -> "str | None": + return self.get_option("mode") if self.flavor == "IN ZIP" else None + + @property + def fill(self) -> "str | None": + return self.get_option("fill") if self.flavor == "IN ZIP" else None + + def validate(self, ctx: "ValidationContext"): + if not self.assign: + self.errors += ("FOR loop has no variables.",) + if not self.flavor: + self.errors += ("FOR loop has no 'IN' or other valid separator.",) + else: + for var in self.assign: + match = search_variable(var, ignore_errors=True, parse_type=True) + if not match.is_scalar_assign(): + self.errors += (f"Invalid FOR loop variable '{var}'.",) + elif match.type: + try: + TypeInfo.from_variable(match) + except DataError as err: + self.errors += (f"Invalid FOR loop variable '{var}': {err}",) + if not self.values: + self.errors += ("FOR loop has no values.",) + self._validate_options() + + +class IfElseHeader(Statement, ABC): + + @property + def condition(self) -> "str | None": + values = self.get_values(Token.ARGUMENT) + return ", ".join(values) if values else None + + @property + def assign(self) -> "tuple[str, ...]": + return self.get_values(Token.ASSIGN) + + def validate(self, ctx: "ValidationContext"): + conditions = self.get_tokens(Token.ARGUMENT) + if not conditions: + self.errors += (f"{self.type} must have a 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 +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": + tokens = [ + Token(Token.SEPARATOR, indent), + Token(cls.type), + Token(Token.SEPARATOR, separator), + Token(Token.ARGUMENT, condition), + Token(Token.EOL, eol), + ] + return cls(tokens) + + +@Statement.register +class InlineIfHeader(IfElseHeader): + type = Token.INLINE_IF + + @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 += [ + Token(Token.ASSIGN, assignment), + Token(Token.SEPARATOR, separator), + ] + tokens += [ + Token(Token.INLINE_IF), + Token(Token.SEPARATOR, separator), + Token(Token.ARGUMENT, condition), + ] + return cls(tokens) + + def validate(self, ctx: "ValidationContext"): + super().validate(ctx) + AssignmentValidator().validate(self) + + +@Statement.register +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": + tokens = [ + Token(Token.SEPARATOR, indent), + Token(Token.ELSE_IF), + Token(Token.SEPARATOR, separator), + Token(Token.ARGUMENT, condition), + Token(Token.EOL, eol), + ] + return cls(tokens) + + +@Statement.register +class ElseHeader(IfElseHeader): + type = Token.ELSE + + @classmethod + def from_params(cls, indent: str = FOUR_SPACES, eol: str = EOL) -> "ElseHeader": + tokens = [ + Token(Token.SEPARATOR, indent), + Token(Token.ELSE), + Token(Token.EOL, eol), + ] + return cls(tokens) + + 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)}.",) + + +class NoArgumentHeader(Statement, ABC): + + @classmethod + def from_params(cls, indent: str = FOUR_SPACES, eol: str = EOL): + tokens = [ + Token(Token.SEPARATOR, indent), + Token(cls.type), + Token(Token.EOL, eol), + ] + return cls(tokens) + + def validate(self, ctx: "ValidationContext"): + if self.get_tokens(Token.ARGUMENT): + self.errors += ( + f"{self.type} does not accept arguments, got {seq2str(self.values)}.", + ) + + @property + def values(self) -> "tuple[str, ...]": + return self.get_values(Token.ARGUMENT) + + +@Statement.register +class TryHeader(NoArgumentHeader): + type = Token.TRY + + +@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, + assign: "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 += [ + Token(Token.SEPARATOR, separator), + Token(Token.ARGUMENT, pattern), + ] + if type: + tokens += [ + Token(Token.SEPARATOR, separator), + Token(Token.OPTION, f"type={type}"), + ] + if assign: + tokens += [ + Token(Token.SEPARATOR, separator), + Token(Token.AS), + Token(Token.SEPARATOR, separator), + Token(Token.VARIABLE, assign), + ] + tokens += [Token(Token.EOL, eol)] + return cls(tokens) + + @property + def patterns(self) -> "tuple[str, ...]": + return self.get_values(Token.ARGUMENT) + + @property + def pattern_type(self) -> "str | None": + return self.get_option("type") + + @property + def assign(self) -> "str | None": + return self.get_value(Token.VARIABLE) + + @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"): + as_token = self.get_token(Token.AS) + if as_token: + 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() + + +@Statement.register +class FinallyHeader(NoArgumentHeader): + type = Token.FINALLY + + +@Statement.register +class End(NoArgumentHeader): + type = Token.END + + +@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, + 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(Token.WHILE), + Token(Token.SEPARATOR, separator), + Token(Token.ARGUMENT, condition), + ] + if limit: + tokens += [ + Token(Token.SEPARATOR, separator), + Token(Token.OPTION, f"limit={limit}"), + ] + if on_limit: + tokens += [ + Token(Token.SEPARATOR, separator), + Token(Token.OPTION, f"on_limit={on_limit}"), + ] + if on_limit_message: + tokens += [ + Token(Token.SEPARATOR, separator), + Token(Token.OPTION, f"on_limit_message={on_limit_message}"), + ] + tokens += [Token(Token.EOL, eol)] + return cls(tokens) + + @property + def condition(self) -> str: + return ", ".join(self.get_values(Token.ARGUMENT)) + + @property + def limit(self) -> "str | None": + return self.get_option("limit") + + @property + def on_limit(self) -> "str | None": + return self.get_option("on_limit") + + @property + def on_limit_message(self) -> "str | None": + return self.get_option("on_limit_message") + + def validate(self, ctx: "ValidationContext"): + conditions = self.get_values(Token.ARGUMENT) + if len(conditions) > 1: + self.errors += ( + f"WHILE accepts only one condition, got {len(conditions)} " + f"conditions {seq2str(conditions)}.", + ) + if self.on_limit and not self.limit: + self.errors += ("WHILE option 'on_limit' cannot be used without 'limit'.",) + self._validate_options() + + +@Statement.register +class GroupHeader(Statement): + type = Token.GROUP + + @classmethod + def from_params( + cls, + name: str = "", + indent: str = FOUR_SPACES, + separator: str = FOUR_SPACES, + eol: str = EOL, + ) -> "GroupHeader": + tokens = [ + Token(Token.SEPARATOR, indent), + Token(Token.GROUP), + ] + if name: + tokens += [ + Token(Token.SEPARATOR, separator), + Token(Token.ARGUMENT, name), + ] + tokens += [Token(Token.EOL, eol)] + return cls(tokens) + + @property + def name(self) -> str: + return ", ".join(self.get_values(Token.ARGUMENT)) + + def validate(self, ctx: "ValidationContext"): + names = self.get_values(Token.ARGUMENT) + if len(names) > 1: + self.errors += ( + f"GROUP accepts only one argument as name, got {len(names)} " + f"arguments {seq2str(names)}.", + ) + + +@Statement.register +class Var(Statement): + type = Token.VAR + options = { + "scope": ("LOCAL", "TEST", "TASK", "SUITE", "SUITES", "GLOBAL"), + "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 += [ + Token(Token.SEPARATOR, separator), + Token(Token.ARGUMENT, value), + ] + if scope: + tokens += [ + Token(Token.SEPARATOR, separator), + Token(Token.OPTION, f"scope={scope}"), + ] + if value_separator is not None: + tokens += [ + Token(Token.SEPARATOR, separator), + Token(Token.OPTION, f"separator={value_separator}"), + ] + tokens += [Token(Token.EOL, eol)] + return cls(tokens) + + @property + def name(self) -> str: + name = self.get_value(Token.VARIABLE, "") + if name.endswith("="): + return name[:-1].rstrip() + return name + + @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 Return(Statement): + """Represents the RETURN statement. + + This class named ``ReturnStatement`` prior to Robot Framework 7.0. + The old name still exists as a backwards compatible alias. + """ + + type = Token.RETURN_STATEMENT + + @classmethod + def from_params( + cls, + values: "Sequence[str]" = (), + indent: str = FOUR_SPACES, + separator: str = FOUR_SPACES, + eol: str = EOL, + ) -> "Return": + tokens = [ + Token(Token.SEPARATOR, indent), + Token(Token.RETURN_STATEMENT), + ] + for value in values: + tokens += [ + Token(Token.SEPARATOR, separator), + Token(Token.ARGUMENT, value), + ] + tokens += [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.",) + if ctx.in_finally: + self.errors += ("RETURN cannot be used in FINALLY branch.",) + + +# Backwards compatibility with RF < 7. +ReturnStatement = Return + + +class LoopControl(NoArgumentHeader, ABC): + + 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 +class Continue(LoopControl): + type = Token.CONTINUE + + +@Statement.register +class Break(LoopControl): + type = Token.BREAK + + +@Statement.register +class Comment(Statement): + type = Token.COMMENT + + @classmethod + def from_params( + cls, + comment: str, + indent: str = FOUR_SPACES, + eol: str = EOL, + ) -> "Comment": + tokens = [ + Token(Token.SEPARATOR, indent), + Token(Token.COMMENT, comment), + Token(Token.EOL, eol), + ] + return cls(tokens) + + +@Statement.register +class Config(Statement): + type = Token.CONFIG + + @classmethod + def from_params(cls, config: str, eol: str = EOL) -> "Config": + tokens = [ + Token(Token.CONFIG, config), + Token(Token.EOL, eol), + ] + return cls(tokens) + + @property + def language(self) -> "Language | None": + lang = self._get_value("language") + return Language.from_name(lang) if lang else None + + def _get_value(self, name: str) -> "str | None": + value = " ".join(self.get_values(Token.CONFIG)) + config, value = value.split(":", 1) + return value.strip() if config.lower() == name else None + + +@Statement.register +class Error(Statement): + type = Token.ERROR + _errors: "tuple[str, ...]" = () + + @classmethod + def from_params( + cls, + error: str, + value: str = "", + indent: str = FOUR_SPACES, + eol: str = EOL, + ) -> "Error": + tokens = [ + Token(Token.SEPARATOR, indent), + Token(Token.ERROR, value, error=error), + Token(Token.EOL, eol), + ] + return cls(tokens) + + @property + def values(self) -> "list[str]": + return [token.value for token in self.data_tokens] + + @property + 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 or "" for t in tokens) + self._errors + + @errors.setter + def errors(self, errors: "Sequence[str]"): + self._errors = tuple(errors) + + +class EmptyLine(Statement): + type = Token.EOL + + @classmethod + def from_params(cls, eol: str = EOL): + return cls([Token(Token.EOL, eol)]) + + +class VariableValidator: + + def validate(self, statement: Statement): + name = statement.get_value(Token.VARIABLE, "") + match = search_variable(name, ignore_errors=True, parse_type=True) + if not match.is_assign(allow_assign_mark=True, allow_nested=True): + statement.errors += (f"Invalid variable name '{name}'.",) + return + if match.identifier == "&": + self._validate_dict_items(statement) + try: + TypeInfo.from_variable(match) + except DataError as err: + statement.errors += (f"Invalid variable '{name}': {err}",) + + 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) + + +class AssignmentValidator: + + def validate(self, statement: Statement): + assignment = statement.get_values(Token.ASSIGN) + if assignment: + assignment = VariableAssignment(assignment) + statement.errors += assignment.errors + for variable in assignment: + try: + TypeInfo.from_variable(variable) + except DataError as err: + statement.errors += (f"Invalid variable '{variable}': {err}",) diff --git a/src/robot/parsing/model/visitor.py b/src/robot/parsing/model/visitor.py new file mode 100644 index 00000000000..dd6a074fc63 --- /dev/null +++ b/src/robot/parsing/model/visitor.py @@ -0,0 +1,101 @@ +# 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 ast import NodeTransformer, NodeVisitor +from typing import Callable + +from .statements import Node + +# Unbound method and thus needs `NodeVisitor` as `self`. +VisitorMethod = Callable[[NodeVisitor, Node], "None | Node | list[Node]"] + + +class VisitorFinder: + __visitor_cache: "dict[type[Node], VisitorMethod]" + + def __init_subclass__(cls, **kwargs): + super().__init_subclass__(**kwargs) + cls.__visitor_cache = {} + + @classmethod + def _find_visitor(cls, node_cls: "type[Node]") -> VisitorMethod: + if node_cls not in cls.__visitor_cache: + visitor = cls._find_visitor_from_class(node_cls) + cls.__visitor_cache[node_cls] = visitor or cls.generic_visit + return cls.__visitor_cache[node_cls] + + @classmethod + def _find_visitor_from_class(cls, node_cls: "type[Node]") -> "VisitorMethod | None": + method_name = "visit_" + node_cls.__name__ + method = getattr(cls, method_name, None) + if callable(method): + return method + if method_name in ("visit_TestTags", "visit_Return"): + method = cls._backwards_compatibility(method_name) + if callable(method): + return method + for base in node_cls.__bases__: + if issubclass(base, Node): + method = cls._find_visitor_from_class(base) + if method: + return method + return None + + @classmethod + def _backwards_compatibility(cls, method_name): + name = { + "visit_TestTags": "visit_ForceTags", + "visit_Return": "visit_ReturnStatement", + }[method_name] + return getattr(cls, name, None) + + def generic_visit(self, node: Node) -> "None | Node | list[Node]": + raise NotImplementedError + + +class ModelVisitor(NodeVisitor, VisitorFinder): + """NodeVisitor that supports matching nodes based on their base classes. + + The biggest difference compared to the standard `ast.NodeVisitor + `__, + is that this class allows creating ``visit_ClassName`` methods so that + the ``ClassName`` is one of the base classes of the node. For example, + the following visitor method matches all node classes that extend + ``Statement``:: + + def visit_Statement(self, node): + ... + + Another difference is that visitor methods are cached for performance + reasons. This means that dynamically adding ``visit_Something`` methods + does not work. + """ + + def visit(self, node: Node) -> None: + visitor_method = self._find_visitor(type(node)) + visitor_method(self, node) + + +class ModelTransformer(NodeTransformer, VisitorFinder): + """NodeTransformer that supports matching nodes based on their base classes. + + See :class:`ModelVisitor` for explanation how this is different compared + to the standard `ast.NodeTransformer + `__. + """ + + def visit(self, node: Node) -> "None | Node | list[Node]": + visitor_method = self._find_visitor(type(node)) + return visitor_method(self, node) diff --git a/src/robot/parsing/parser/__init__.py b/src/robot/parsing/parser/__init__.py new file mode 100644 index 00000000000..40fcfaeb1a6 --- /dev/null +++ b/src/robot/parsing/parser/__init__.py @@ -0,0 +1,20 @@ +# 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 .parser import ( + get_init_model as get_init_model, + get_model as get_model, + get_resource_model as get_resource_model, +) diff --git a/src/robot/parsing/parser/blockparsers.py b/src/robot/parsing/parser/blockparsers.py new file mode 100644 index 00000000000..65f95802bbb --- /dev/null +++ b/src/robot/parsing/parser/blockparsers.py @@ -0,0 +1,129 @@ +# 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 abc import ABC, abstractmethod + +from ..lexer import Token +from ..model import ( + Block, Container, End, For, Group, If, Keyword, NestedBlock, Statement, TestCase, + Try, While +) + + +class Parser(ABC): + model: Container + + def __init__(self, model: Container): + self.model = model + + @abstractmethod + def handles(self, statement: Statement) -> bool: + raise NotImplementedError + + @abstractmethod + def parse(self, statement: Statement) -> "Parser | None": + raise NotImplementedError + + +class BlockParser(Parser, ABC): + model: Block + unhandled_tokens = Token.HEADER_TOKENS | {Token.TESTCASE_NAME, Token.KEYWORD_NAME} + + def __init__(self, model: Block): + super().__init__(model) + self.parsers: dict[str, type[NestedBlockParser]] = { + Token.FOR: ForParser, + Token.WHILE: WhileParser, + Token.IF: IfParser, + Token.INLINE_IF: IfParser, + Token.TRY: TryParser, + Token.GROUP: GroupParser, + } + + def handles(self, statement: Statement) -> bool: + return statement.type not in self.unhandled_tokens + + def parse(self, statement: Statement) -> "BlockParser | None": + parser_class = self.parsers.get(statement.type) + if parser_class: + 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) + return None + + +class TestCaseParser(BlockParser): + model: TestCase + + +class KeywordParser(BlockParser): + model: Keyword + + +class NestedBlockParser(BlockParser, ABC): + model: NestedBlock + + def __init__(self, model: NestedBlock, handle_end: bool = True): + super().__init__(model) + self.handle_end = handle_end + + 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 super().parse(statement) + + +class ForParser(NestedBlockParser): + model: For + + +class WhileParser(NestedBlockParser): + model: While + + +class GroupParser(NestedBlockParser): + model: Group + + +class IfParser(NestedBlockParser): + model: If + + def parse(self, statement: Statement) -> "BlockParser | None": + if statement.type in (Token.ELSE_IF, Token.ELSE): + parser = IfParser(If(statement), handle_end=False) + self.model.orelse = parser.model + return parser + return super().parse(statement) + + +class TryParser(NestedBlockParser): + model: Try + + def parse(self, statement) -> "BlockParser | None": + if statement.type in (Token.EXCEPT, Token.ELSE, Token.FINALLY): + parser = TryParser(Try(statement), handle_end=False) + self.model.next = parser.model + return parser + return super().parse(statement) diff --git a/src/robot/parsing/parser/fileparser.py b/src/robot/parsing/parser/fileparser.py new file mode 100644 index 00000000000..fc2e4f249a7 --- /dev/null +++ b/src/robot/parsing/parser/fileparser.py @@ -0,0 +1,121 @@ +# 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 pathlib import Path + +from robot.utils import Source + +from ..lexer import Token +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: "Source | None" = None): + super().__init__(File(source=self._get_path(source))) + self.parsers: dict[str, type[SectionParser]] = { + 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.INVALID_HEADER: InvalidSectionParser, + Token.CONFIG: ImplicitCommentSectionParser, + Token.COMMENT: ImplicitCommentSectionParser, + Token.ERROR: ImplicitCommentSectionParser, + Token.EOL: ImplicitCommentSectionParser, + } + + 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: Section + + def handles(self, statement: Statement) -> bool: + return statement.type not in Token.HEADER_TOKENS + + def parse(self, statement: Statement) -> "Parser | None": + self.model.body.append(statement) + return None + + +class SettingSectionParser(SectionParser): + model: SettingSection + + +class VariableSectionParser(SectionParser): + model: VariableSection + + +class CommentSectionParser(SectionParser): + model: CommentSection + + +class ImplicitCommentSectionParser(SectionParser): + model: ImplicitCommentSection + + +class InvalidSectionParser(SectionParser): + model: InvalidSection + + +class TestCaseSectionParser(SectionParser): + model: TestCaseSection + + def parse(self, statement: Statement) -> "Parser | None": + if statement.type == Token.TESTCASE_NAME: + parser = TestCaseParser(TestCase(statement)) + self.model.body.append(parser.model) + return parser + return super().parse(statement) + + +class KeywordSectionParser(SectionParser): + model: KeywordSection + + def parse(self, statement: Statement) -> "Parser | None": + if statement.type == Token.KEYWORD_NAME: + parser = KeywordParser(Keyword(statement)) + self.model.body.append(parser.model) + return parser + return super().parse(statement) diff --git a/src/robot/parsing/parser/parser.py b/src/robot/parsing/parser/parser.py new file mode 100644 index 00000000000..fb2f7a6c3da --- /dev/null +++ b/src/robot/parsing/parser/parser.py @@ -0,0 +1,149 @@ +# 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 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 Config, File, ModelVisitor, Statement +from .blockparsers import Parser +from .fileparser import FileParser + + +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. + + :param source: The source where to read the data. Can be a path to + a source file as a string or as ``pathlib.Path`` object, an already + opened file object, or Unicode text containing the date directly. + Source files must be UTF-8 encoded. + :param data_only: When ``False`` (default), returns all tokens. When set + to ``True``, omits separators, comments, continuation markers, and + other non-data tokens. Model like this cannot be saved back to + file system. + :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 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` subclass, + 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. + """ + return _get_model(get_tokens, source, data_only, curdir, lang) + + +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. + + 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: Source, + data_only: bool = False, + curdir: "str | None" = None, + lang: LanguagesLike = None, +) -> File: + """Parses the given source into an init file model. + + 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. + """ + return _get_model(get_init_tokens, source, data_only, curdir, lang) + + +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: Iterator[Token], + curdir: "str | None", +) -> Iterator[Statement]: + statement = [] + EOS = Token.EOS + for t in tokens: + if curdir and "${CURDIR}" in t.value: + t.value = t.value.replace("${CURDIR}", curdir) + if t.type != EOS: + statement.append(t) + else: + yield Statement.from_tokens(statement) + statement = [] + + +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) + return root.model + + +class ConfigParser(ModelVisitor): + + def __init__(self, model: File): + self.model = model + + @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: Config): + language = node.language + if language: + self.model.languages.append(language.code) diff --git a/src/robot/parsing/populators.py b/src/robot/parsing/populators.py deleted file mode 100644 index 4090f633b2b..00000000000 --- a/src/robot/parsing/populators.py +++ /dev/null @@ -1,219 +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 os - -from robot.errors import DataError -from robot.model import SuiteNamePatterns -from robot.output import LOGGER -from robot.utils import get_error_message, unic - -from .datarow import DataRow -from .tablepopulators import (SettingTablePopulator, VariableTablePopulator, - TestTablePopulator, KeywordTablePopulator, - NullPopulator) -from .htmlreader import HtmlReader -from .tsvreader import TsvReader -from .robotreader import RobotReader -from .restreader import RestReader - - -READERS = {'html': HtmlReader, 'htm': HtmlReader, 'xhtml': HtmlReader, - 'tsv': TsvReader , 'rst': RestReader, 'rest': RestReader, - 'txt': RobotReader, 'robot': RobotReader} - -# Hook for external tools for altering ${CURDIR} processing -PROCESS_CURDIR = True - - -class NoTestsFound(DataError): - pass - - -class FromFilePopulator(object): - _populators = {'setting': SettingTablePopulator, - 'variable': VariableTablePopulator, - 'test case': TestTablePopulator, - 'keyword': KeywordTablePopulator} - - def __init__(self, datafile): - self._datafile = datafile - self._populator = NullPopulator() - self._curdir = self._get_curdir(datafile.directory) - - def _get_curdir(self, path): - return path.replace('\\','\\\\') if path else None - - def populate(self, path, resource=False): - LOGGER.info("Parsing file '%s'." % path) - source = self._open(path) - try: - self._get_reader(path, resource).read(source, self) - except: - raise DataError(get_error_message()) - finally: - source.close() - - def _open(self, path): - if not os.path.isfile(path): - raise DataError("File or directory to execute does not exist.") - try: - # IronPython handles BOM incorrectly if not using binary mode: - # https://ironpython.codeplex.com/workitem/34655 - return open(path, 'rb') - except: - raise DataError(get_error_message()) - - def _get_reader(self, path, resource=False): - file_format = os.path.splitext(path.lower())[-1][1:] - if resource and file_format == 'resource': - file_format = 'robot' - try: - return READERS[file_format]() - except KeyError: - raise DataError("Unsupported file format '%s'." % file_format) - - def start_table(self, header): - self._populator.populate() - table = self._datafile.start_table(DataRow(header).all) - self._populator = self._populators[table.type](table) \ - if table is not None else NullPopulator() - return bool(self._populator) - - def eof(self): - self._populator.populate() - self._populator = NullPopulator() - return bool(self._datafile) - - def add(self, row): - if PROCESS_CURDIR and self._curdir: - row = self._replace_curdirs_in(row) - data = DataRow(row, self._datafile.source) - if data: - self._populator.add(data) - - def _replace_curdirs_in(self, row): - old, new = '${CURDIR}', self._curdir - return [cell if old not in cell else cell.replace(old, new) - for cell in row] - - -class FromDirectoryPopulator(object): - ignored_prefixes = ('_', '.') - ignored_dirs = ('CVS',) - - def populate(self, path, datadir, include_suites=None, - include_extensions=None, recurse=True): - LOGGER.info("Parsing directory '%s'." % path) - include_suites = self._get_include_suites(path, include_suites) - init_file, children = self._get_children(path, include_extensions, - include_suites) - if init_file: - self._populate_init_file(datadir, init_file) - if recurse: - self._populate_children(datadir, children, include_extensions, - include_suites) - - def _populate_init_file(self, datadir, init_file): - datadir.initfile = init_file - try: - FromFilePopulator(datadir).populate(init_file) - except DataError as err: - LOGGER.error(err.message) - - def _populate_children(self, datadir, children, include_extensions, - include_suites): - for child in children: - try: - datadir.add_child(child, include_suites, include_extensions) - except NoTestsFound: - LOGGER.info("Data source '%s' has no tests or tasks." % child) - except DataError as err: - LOGGER.error("Parsing '%s' failed: %s" % (child, err.message)) - - 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 - - def _get_children(self, dirpath, incl_extensions, incl_suites): - init_file = None - children = [] - for path, is_init_file in self._list_dir(dirpath, incl_extensions, - incl_suites): - if is_init_file: - if not init_file: - init_file = path - else: - LOGGER.error("Ignoring second test suite init file '%s'." % path) - else: - children.append(path) - return init_file, children - - def _list_dir(self, dir_path, incl_extensions, incl_suites): - # os.listdir returns Unicode entries when path is Unicode - dir_path = unic(dir_path) - names = os.listdir(dir_path) - for name in sorted(names, key=lambda item: item.lower()): - name = unic(name) # needed to handle nfc/nfd 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, incl_extensions): - yield path, True - elif self._is_included(path, base, ext, incl_extensions, incl_suites): - yield path, False - else: - LOGGER.info("Ignoring file or directory '%s'." % path) - - def _is_init_file(self, path, base, ext, incl_extensions): - return (base.lower() == '__init__' and - self._extension_is_accepted(ext, incl_extensions) and - os.path.isfile(path)) - - def _extension_is_accepted(self, ext, incl_extensions): - if incl_extensions: - return ext in incl_extensions - return ext in READERS - - def _is_included(self, path, base, ext, incl_extensions, incl_suites): - if base.startswith(self.ignored_prefixes): - return False - if os.path.isdir(path): - return base not in self.ignored_dirs or ext - if not self._extension_is_accepted(ext, incl_extensions): - 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): - return name.split('__', 1)[-1] diff --git a/src/robot/parsing/restreader.py b/src/robot/parsing/restreader.py deleted file mode 100644 index fa7f7cbe8a7..00000000000 --- a/src/robot/parsing/restreader.py +++ /dev/null @@ -1,52 +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 io import BytesIO - -from .htmlreader import HtmlReader -from .robotreader import RobotReader - - -def RestReader(): - from .restsupport import (publish_doctree, publish_from_doctree, - RobotDataStorage) - - class RestReader(object): - - def read(self, rstfile, rawdata): - doctree = publish_doctree( - rstfile.read(), source_path=rstfile.name, - settings_overrides={ - 'input_encoding': 'UTF-8', - 'report_level': 4 - }) - store = RobotDataStorage(doctree) - if store.has_data(): - return self._read_text(store.get_data(), rawdata, rstfile.name) - return self._read_html(doctree, rawdata, rstfile.name) - - def _read_text(self, data, rawdata, path): - robotfile = BytesIO(data.encode('UTF-8')) - return RobotReader().read(robotfile, rawdata, path) - - def _read_html(self, doctree, rawdata, path): - htmlfile = BytesIO() - htmlfile.write(publish_from_doctree( - doctree, writer_name='html', - settings_overrides={'output_encoding': 'UTF-8'})) - htmlfile.seek(0) - return HtmlReader().read(htmlfile, rawdata, path) - - return RestReader() diff --git a/src/robot/parsing/restsupport.py b/src/robot/parsing/restsupport.py deleted file mode 100644 index ac0e3ba116a..00000000000 --- a/src/robot/parsing/restsupport.py +++ /dev/null @@ -1,55 +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 DataError - -try: - from docutils.core import publish_doctree, publish_from_doctree - from docutils.parsers.rst.directives import register_directive - from docutils.parsers.rst.directives.body import CodeBlock -except ImportError: - raise DataError("Using reStructuredText test data requires having " - "'docutils' module version 0.9 or newer installed.") - - -class CaptureRobotData(CodeBlock): - - def run(self): - if 'robotframework' in self.arguments: - store = RobotDataStorage(self.state_machine.document) - store.add_data(self.content) - return [] - - -register_directive('code', CaptureRobotData) -register_directive('code-block', CaptureRobotData) -register_directive('sourcecode', CaptureRobotData) - - -class RobotDataStorage(object): - - def __init__(self, doctree): - if not hasattr(doctree, '_robot_data'): - doctree._robot_data = [] - self._robot_data = doctree._robot_data - - def add_data(self, rows): - self._robot_data.extend(rows) - - def get_data(self): - return '\n'.join(self._robot_data) - - def has_data(self): - return bool(self._robot_data) diff --git a/src/robot/parsing/robotreader.py b/src/robot/parsing/robotreader.py deleted file mode 100644 index ec767650652..00000000000 --- a/src/robot/parsing/robotreader.py +++ /dev/null @@ -1,92 +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 re - -from robot.output import LOGGER -from robot.utils import JYTHON, Utf8Reader, prepr - - -NBSP = u'\xa0' - - -class RobotReader(object): - _space_splitter = re.compile(u'[ \t\xa0]{2,}|\t+') - _pipe_splitter = re.compile(u'[ \t\xa0]+\|(?=[ \t\xa0]+)') - _pipe_starts = ('|', '| ', '|\t', u'|\xa0') - _pipe_ends = (' |', '\t|', u'\xa0|') - - def read(self, file, populator, path=None): - path = path or getattr(file, 'name', '') - process = False - for lineno, line in enumerate(Utf8Reader(file).readlines(), start=1): - cells = self.split_row(line.rstrip()) - cells = list(self._check_deprecations(cells, path, lineno)) - if cells and cells[0].strip().startswith('*') and \ - populator.start_table([c.replace('*', '').strip() - for c in cells]): - process = True - elif process: - populator.add(cells) - return populator.eof() - - @classmethod - def split_row(cls, row): - if row[:2] in cls._pipe_starts: - row = row[1:-1] if row[-2:] in cls._pipe_ends else row[1:] - return [cls._strip_whitespace(cell) - for cell in cls._pipe_splitter.split(row)] - return cls._space_splitter.split(row) - - def _check_deprecations(self, cells, path, line_number): - for original in cells: - normalized = self._normalize_whitespace(original) - if normalized != original: - if len(normalized) != len(original): - msg = 'Collapsing consecutive whitespace' - else: - msg = 'Converting whitespace characters to ASCII spaces' - LOGGER.warn("%s during parsing is deprecated. Fix %s in file " - "'%s' on line %d." - % (msg, prepr(original), path, line_number)) - yield normalized - - # Jython has issues with non-ASCII spaces https://bugs.jython.org/issue2772 - if JYTHON: - - _whitespace = re.compile(u'\\s+', re.UNICODE) - _trailing_whitespace = re.compile(u'\\s+$', re.UNICODE) - - @classmethod - def _strip_whitespace(cls, string): - match = cls._whitespace.match(string) - if match: - string = string[match.end():] - match = cls._trailing_whitespace.search(string) - if match: - string = string[:match.start()] - return string - - def _normalize_whitespace(self, string): - return ' '.join(self._whitespace.split(string)) - - else: - - @classmethod - def _strip_whitespace(cls, string): - return string.strip() - - def _normalize_whitespace(self, string): - return ' '.join(string.split()) diff --git a/src/robot/parsing/settings.py b/src/robot/parsing/settings.py deleted file mode 100644 index 31f0430373f..00000000000 --- a/src/robot/parsing/settings.py +++ /dev/null @@ -1,377 +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 is_string, py2to3, unicode - -from .comments import Comment - - -@py2to3 -class Setting(object): - - def __init__(self, setting_name, parent=None, comment=None): - self.setting_name = setting_name - self.parent = parent - self._set_initial_value() - self._set_comment(comment) - self._populated = False - - def _set_initial_value(self): - self.value = [] - - def _set_comment(self, comment): - self.comment = Comment(comment) - - def reset(self): - self.__init__(self.setting_name, self.parent) - - @property - def source(self): - return self.parent.source if self.parent is not None else None - - @property - def directory(self): - return self.parent.directory if self.parent is not None else None - - def populate(self, value, comment=None): - """Mainly used at parsing time, later attributes can be set directly.""" - if not self._populated: - self._populate(value) - self._set_comment(comment) - self._populated = True - else: - self._set_initial_value() - self._set_comment(None) - self.report_invalid_syntax("Setting '%s' used multiple times." - % self.setting_name, 'ERROR') - - def _populate(self, value): - self.value = value - - def is_set(self): - return bool(self.value) - - def is_for_loop(self): - return False - - def report_invalid_syntax(self, message, level='ERROR'): - self.parent.report_invalid_syntax(message, level) - - def _string_value(self, value): - return value if is_string(value) else ' '.join(value) - - def _concat_string_with_value(self, string, value): - if string: - return string + ' ' + self._string_value(value) - return self._string_value(value) - - def as_list(self): - return self._data_as_list() + self.comment.as_list() - - def _data_as_list(self): - ret = [self.setting_name] - if self.value: - ret.extend(self.value) - return ret - - def __nonzero__(self): - return self.is_set() - - def __iter__(self): - return iter(self.value or ()) - - def __unicode__(self): - return unicode(self.value or '') - - -class StringValueJoiner(object): - - def __init__(self, separator): - self._separator = separator - - def join_string_with_value(self, string, value): - if string: - return string + self._separator + self.string_value(value) - return self.string_value(value) - - def string_value(self, value): - if is_string(value): - return value - return self._separator.join(value) - - -class Documentation(Setting): - - def _set_initial_value(self): - self.value = '' - - def _populate(self, value): - self.value = self._concat_string_with_value(self.value, value) - - def _string_value(self, value): - return value if is_string(value) else ''.join(value) - - def _data_as_list(self): - return [self.setting_name, self.value] - - -class Template(Setting): - - def _set_initial_value(self): - self.value = None - - def _populate(self, value): - self.value = self._concat_string_with_value(self.value, value) - - def is_set(self): - return self.value is not None - - def is_active(self): - return self.value and self.value.upper() != 'NONE' - - def _data_as_list(self): - ret = [self.setting_name] - if self.value: - ret.append(self.value) - return ret - - -class Fixture(Setting): - - # `keyword`, `is_comment` and `assign` make the API compatible with Step. - - @property - def keyword(self): - return self.name or '' - - def is_comment(self): - return False - - def _set_initial_value(self): - self.name = None - self.args = [] - self.assign = () - - def _populate(self, value): - if not self.name: - self.name = value[0] if value else '' - value = value[1:] - self.args.extend(value) - - def is_set(self): - return self.name is not None - - def is_active(self): - return self.name and self.name.upper() != 'NONE' - - def _data_as_list(self): - ret = [self.setting_name] - if self.name or self.args: - ret.append(self.name or '') - if self.args: - ret.extend(self.args) - return ret - - -class Timeout(Setting): - - def _set_initial_value(self): - self.value = None - self.message = '' - - def _populate(self, value): - if not self.value: - self.value = value[0] if value else '' - value = value[1:] - self.message = self._concat_string_with_value(self.message, value) - # TODO: Remove custom timeout message support in RF 3.2. - if value and self.parent: - self.parent.report_invalid_syntax( - 'Using custom timeout messages is deprecated since Robot ' - 'Framework 3.0.1 and will be removed in future versions. ' - "Message that was used is '%s'." % self.message, level='WARN') - - def is_set(self): - return self.value is not None - - def _data_as_list(self): - ret = [self.setting_name] - if self.value or self.message: - ret.append(self.value or '') - if self.message: - ret.append(self.message) - return ret - - -class Tags(Setting): - - def _set_initial_value(self): - self.value = None - - def _populate(self, value): - self.value = (self.value or []) + value - - def is_set(self): - return self.value is not None - - def __add__(self, other): - if not isinstance(other, Tags): - raise TypeError('Tags can only be added with tags') - tags = Tags('Tags') - tags.value = (self.value or []) + (other.value or []) - return tags - - -class Arguments(Setting): - pass - - -class Return(Setting): - pass - - -class Metadata(Setting): - setting_name = 'Metadata' - - def __init__(self, parent, name, value, comment=None, joined=False): - self.parent = parent - self.name = name - joiner = StringValueJoiner('' if joined else ' ') - self.value = joiner.join_string_with_value('', value) - self._set_comment(comment) - - def reset(self): - pass - - def is_set(self): - return True - - def _data_as_list(self): - return [self.setting_name, self.name, self.value] - - -class _Import(Setting): - - def __init__(self, parent, name, args=None, alias=None, comment=None): - self.parent = parent - self.name = name - self.args = args or [] - self.alias = alias - self._set_comment(comment) - - def reset(self): - pass - - @property - def type(self): - return type(self).__name__ - - def is_set(self): - return True - - def _data_as_list(self): - return [self.type, self.name] + self.args - - def report_invalid_syntax(self, message, level='ERROR', parent=None): - parent = parent or getattr(self, 'parent', None) - if parent: - parent.report_invalid_syntax(message, level) - else: - from robot.api import logger - logger.write(message, level) - - -class Library(_Import): - - def __init__(self, parent, name, args=None, alias=None, comment=None): - if args and not alias: - args, alias = self._split_possible_alias(args) - _Import.__init__(self, parent, name, args, alias, comment) - - def _split_possible_alias(self, args): - if len(args) > 1 and args[-2] == 'WITH NAME': - return args[:-2], args[-1] - return args, None - - def _data_as_list(self): - data = ['Library', self.name] + self.args - if self.alias: - data += ['WITH NAME', self.alias] - return data - - -class Resource(_Import): - - def __init__(self, parent, name, invalid_args=None, comment=None): - if invalid_args: - name += ' ' + ' '.join(invalid_args) - _Import.__init__(self, parent, name, comment=comment) - - -class Variables(_Import): - - def __init__(self, parent, name, args=None, comment=None): - _Import.__init__(self, parent, name, args, comment=comment) - - -class _DataList(object): - - def __init__(self, parent): - self._parent = parent - self.data = [] - - def add(self, meta): - self._add(meta) - - def _add(self, meta): - self.data.append(meta) - - def _parse_name_and_value(self, value): - name = value[0] if value else '' - return name, value[1:] - - def __getitem__(self, index): - return self.data[index] - - def __setitem__(self, index, item): - self.data[index] = item - - def __len__(self): - return len(self.data) - - def __iter__(self): - return iter(self.data) - - -class ImportList(_DataList): - - def populate_library(self, data, comment): - self._populate(Library, data, comment) - - def populate_resource(self, data, comment): - self._populate(Resource, data, comment) - - def populate_variables(self, data, comment): - self._populate(Variables, data, comment) - - def _populate(self, item_class, data, comment): - name, value = self._parse_name_and_value(data) - self._add(item_class(self._parent, name, value, comment=comment)) - - -class MetadataList(_DataList): - - def populate(self, name, value, comment): - self._add(Metadata(self._parent, name, value, comment, joined=True)) diff --git a/src/robot/parsing/suitestructure.py b/src/robot/parsing/suitestructure.py new file mode 100644 index 00000000000..96ac9b08fbd --- /dev/null +++ b/src/robot/parsing/suitestructure.py @@ -0,0 +1,255 @@ +# 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 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.output import LOGGER +from robot.utils import get_error_message + + +class SuiteStructure(ABC): + source: "Path | None" + init_file: "Path | None" + children: "list[SuiteStructure] | 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 + 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 + def visit(self, visitor: "SuiteStructureVisitor"): + raise NotImplementedError + + +class SuiteFile(SuiteStructure): + source: Path + + def __init__(self, extensions: "ValidExtensions", source: Path): + super().__init__(extensions, source) + + def _get_source_file(self) -> Path: + return self.source + + def visit(self, visitor: "SuiteStructureVisitor"): + visitor.visit_file(self) + + +class SuiteDirectory(SuiteStructure): + children: "list[SuiteStructure]" + + def __init__( + self, + extensions: "ValidExtensions", + source: "Path | None" = None, + init_file: "Path | None" = None, + children: Sequence[SuiteStructure] = (), + ): + 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 + + def add(self, child: "SuiteStructure"): + self.children.append(child) + + def visit(self, visitor: "SuiteStructureVisitor"): + visitor.visit_directory(self) + + +class SuiteStructureVisitor: + + def visit_file(self, structure: SuiteFile): + pass + + 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: SuiteDirectory): + pass + + def end_directory(self, structure: SuiteDirectory): + pass + + +class SuiteStructureBuilder: + ignored_prefixes = ("_", ".") + ignored_dirs = ("CVS",) + + def __init__( + self, + extensions: Sequence[str] = (".robot", ".rbt", ".robot.rst", ".robot.md"), + included_files: Sequence[str] = (), + ): + self.extensions = ValidExtensions(extensions, included_files) + self.included_files = IncludedFiles(included_files) + + def build(self, *paths: Path) -> SuiteStructure: + if len(paths) == 1: + return self._build(paths[0]) + return self._build_multi_source(paths) + + def _build(self, path: Path) -> SuiteStructure: + if path.is_file(): + return SuiteFile(self.extensions, path) + return self._build_directory(path) + + def _build_directory(self, path: Path) -> SuiteStructure: + structure = SuiteDirectory(self.extensions, path) + 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 + elif self._is_included(item): + structure.add(self._build(item)) + else: + LOGGER.info(f"Ignoring file or directory '{item}'.") + return structure + + 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) -> bool: + return ( + path.stem.lower() == "__init__" + and self.extensions.match(path) + and path.is_file() + ) + + def _is_included(self, path: Path) -> 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 not self.extensions.match(path): + return False + return self.included_files.match(path) + + def _build_multi_source(self, paths: Iterable[Path]) -> SuiteStructure: + structure = SuiteDirectory(self.extensions) + 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)) + 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: + return any(ext in self.extensions for ext in self._extensions_from(path)) + + 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]" = ()): + 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).replace("\\", "/") + 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:)\Z` but we want + # only the `` 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/src/robot/parsing/tablepopulators.py b/src/robot/parsing/tablepopulators.py deleted file mode 100644 index 846f3fb367b..00000000000 --- a/src/robot/parsing/tablepopulators.py +++ /dev/null @@ -1,366 +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 re - -from robot.utils import py2to3 - -from .comments import CommentCache, Comments -from .settings import Documentation, MetadataList - - -class Populator(object): - """Explicit interface for all populators.""" - - def add(self, row): - raise NotImplementedError - - def populate(self): - raise NotImplementedError - - -@py2to3 -class NullPopulator(Populator): - - def add(self, row): - pass - - def populate(self): - pass - - def __nonzero__(self): - return False - - -class _TablePopulator(Populator): - - def __init__(self, table): - self._table = table - self._populator = NullPopulator() - self._comment_cache = CommentCache() - - def add(self, row): - if self._is_cacheable_comment_row(row): - self._comment_cache.add(row) - else: - self._add(row) - - def _is_cacheable_comment_row(self, row): - return row.is_commented() - - def _add(self, row): - if self._is_continuing(row): - self._consume_comments() - else: - self._populator.populate() - self._populator = self._get_populator(row) - self._consume_standalone_comments() - self._populator.add(row) - - def _is_continuing(self, row): - return row.is_continuing() and self._populator - - def _get_populator(self, row): - raise NotImplementedError - - def _consume_comments(self): - self._comment_cache.consume_with(self._populator.add) - - def _consume_standalone_comments(self): - self._consume_comments() - - def populate(self): - self._consume_comments() - self._populator.populate() - - -class SettingTablePopulator(_TablePopulator): - - def _get_populator(self, row): - setter = self._table.get_setter(row.head) - if not setter: - return NullPopulator() - if isinstance(setter.__self__, Documentation): - return DocumentationPopulator(setter) - if isinstance(setter.__self__, MetadataList): - return MetadataPopulator(setter) - return SettingPopulator(setter) - - -class VariableTablePopulator(_TablePopulator): - - def _get_populator(self, row): - return VariablePopulator(self._table.add, row.head) - - def _consume_standalone_comments(self): - self._comment_cache.consume_with(self._populate_standalone_comment) - - def _populate_standalone_comment(self, comment): - populator = self._get_populator(comment) - populator.add(comment) - populator.populate() - - def populate(self): - self._populator.populate() - self._consume_standalone_comments() - - -class _StepContainingTablePopulator(_TablePopulator): - - def _is_continuing(self, row): - return row.is_indented() and self._populator or row.is_commented() - - def _is_cacheable_comment_row(self, row): - return row.is_commented() and not self._populator - - -class TestTablePopulator(_StepContainingTablePopulator): - - def _get_populator(self, row): - return TestCasePopulator(self._table.add) - - -class KeywordTablePopulator(_StepContainingTablePopulator): - - def _get_populator(self, row): - return UserKeywordPopulator(self._table.add) - - -class ForLoopPopulator(Populator): - - def __init__(self, for_loop_creator): - self._for_loop_creator = for_loop_creator - self._loop = None - self._populator = NullPopulator() - self._declaration = [] - self._declaration_comments = [] - - def add(self, row): - dedented_row = row.dedent() - if not self._loop: - declaration_ready = self._populate_declaration(row) - if not declaration_ready: - return - self._create_for_loop() - if not row.is_continuing(): - self._populator.populate() - self._populator = StepPopulator(self._loop.add_step) - self._populator.add(dedented_row) - - def _populate_declaration(self, row): - if row.starts_for_loop() or row.is_continuing(): - self._declaration.extend(row.dedent().data) - self._declaration_comments.extend(row.comments) - return False - return True - - def _create_for_loop(self): - self._loop = self._for_loop_creator(self._declaration, - self._declaration_comments) - - def populate(self): - if not self._loop: - self._create_for_loop() - self._populator.populate() - - -class _TestCaseUserKeywordPopulator(Populator): - - def __init__(self, test_or_uk_creator): - self._test_or_uk_creator = test_or_uk_creator - self._test_or_uk = None - self._populator = NullPopulator() - self._comment_cache = CommentCache() - - def add(self, row): - if row.is_commented(): - self._comment_cache.add(row) - return - if not self._test_or_uk: - self._test_or_uk = self._test_or_uk_creator(row.head) - dedented_row = row.dedent() - if dedented_row: - self._handle_data_row(dedented_row) - - def _handle_data_row(self, row): - ending_for_loop = False - if not self._continues(row): - self._populator.populate() - if row.all == ['END']: - ending_for_loop = self._end_for_loop() - self._populator = self._get_populator(row) - self._comment_cache.consume_with(self._populate_comment_row) - else: - self._comment_cache.consume_with(self._populator.add) - if not ending_for_loop: - self._populator.add(row) - - def _end_for_loop(self): - if self._populating_for_loop(): - return True - return self._test_or_uk.end_for_loop() - - def _populating_for_loop(self): - return isinstance(self._populator, ForLoopPopulator) - - def _continues(self, row): - return (row.is_continuing() and self._populator or - self._populating_for_loop() and row.is_indented()) - - def _populate_comment_row(self, crow): - populator = StepPopulator(self._test_or_uk.add_step) - populator.add(crow) - populator.populate() - - def populate(self): - self._populator.populate() - self._comment_cache.consume_with(self._populate_comment_row) - - def _get_populator(self, row): - if row.starts_test_or_user_keyword_setting(): - setter = self._setting_setter(row) - if not setter: - return NullPopulator() - if isinstance(setter.__self__, Documentation): - return DocumentationPopulator(setter) - return SettingPopulator(setter) - if row.starts_for_loop(): - return ForLoopPopulator(self._test_or_uk.add_for_loop) - return StepPopulator(self._test_or_uk.add_step) - - def _setting_setter(self, row): - setting_name = row.test_or_user_keyword_setting_name() - return self._test_or_uk.get_setter(setting_name) - - -class TestCasePopulator(_TestCaseUserKeywordPopulator): - _item_type = 'test case' - - -class UserKeywordPopulator(_TestCaseUserKeywordPopulator): - _item_type = 'keyword' - - -class _PropertyPopulator(Populator): - - def __init__(self, setter): - self._setter = setter - self._value = [] - self._comments = Comments() - self._data_added = False - - def add(self, row): - if not row.is_commented(): - self._add(row) - self._comments.add(row) - - def _add(self, row): - if row.cells == ['...']: - self._deprecate_continuation_without_values() - self._value.extend(row.tail if not self._data_added else row.data) - self._data_added = True - - def _deprecate_continuation_without_values(self): - location = self._get_deprecation_location() - message = ("%sIgnoring lines with only continuation marker '...' is " - "deprecated." % ('In %s: ' % location if location else '')) - self._setter.__self__.report_invalid_syntax(message, level='WARN') - - def _get_deprecation_location(self): - return '' - - -class VariablePopulator(_PropertyPopulator): - - def __init__(self, setter, name): - _PropertyPopulator.__init__(self, setter) - self._name = name - - def populate(self): - self._setter(self._name, self._value, self._comments.value) - - def _get_deprecation_location(self): - return "'Variables' section" - - -class SettingPopulator(_PropertyPopulator): - - def populate(self): - self._setter(self._value, self._comments.value) - - def _get_deprecation_location(self): - return "'%s' setting" % self._setter.__self__.setting_name - - -class DocumentationPopulator(_PropertyPopulator): - _end_of_line_escapes = re.compile(r'(\\+)n?$') - - def populate(self): - self._setter(self._value, self._comments.value) - - def _add(self, row): - self._add_to_value(row.dedent().data) - - def _add_to_value(self, data): - joiner = self._row_joiner() - if joiner: - self._value.append(joiner) - self._value.append(' '.join(data)) - - def _row_joiner(self): - if self._is_empty(): - return None - return self._joiner_based_on_eol_escapes() - - def _is_empty(self): - return not self._value or \ - (len(self._value) == 1 and self._value[0] == '') - - def _joiner_based_on_eol_escapes(self): - match = self._end_of_line_escapes.search(self._value[-1]) - if not match or len(match.group(1)) % 2 == 0: - return '\\n' - if not match.group(0).endswith('n'): - return ' ' - return None - - -class MetadataPopulator(DocumentationPopulator): - - def __init__(self, setter): - _PropertyPopulator.__init__(self, setter) - self._name = None - - def populate(self): - self._setter(self._name, self._value, self._comments.value) - - def _add(self, row): - data = row.dedent().data - if self._name is None: - self._name = data[0] if data else '' - data = data[1:] - self._add_to_value(data) - - -class StepPopulator(_PropertyPopulator): - - def _add(self, row): - if row.cells == ['...']: - self._deprecate_continuation_without_values() - self._value.extend(row.data) - - def populate(self): - if self._value or self._comments.value: - self._setter(self._value, self._comments.value) diff --git a/src/robot/parsing/tsvreader.py b/src/robot/parsing/tsvreader.py deleted file mode 100644 index 52162c12908..00000000000 --- a/src/robot/parsing/tsvreader.py +++ /dev/null @@ -1,49 +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 itertools import dropwhile - -from robot.output import LOGGER - -from .robotreader import RobotReader - - -class TsvReader(RobotReader): - - @classmethod - def split_row(cls, row): - return [cls._strip_whitespace(cell) for cell in row.split('\t')] - - def _check_deprecations(self, cells, path, line_number): - cells = RobotReader._check_deprecations(self, cells, path, line_number) - cells = [self._deprecate_quoting(c, path, line_number) for c in cells] - self._deprecate_empty_data_cells(cells, path, line_number) - return cells - - def _deprecate_quoting(self, cell, path, line_number): - if len(cell) > 1 and cell[0] == cell[-1] == '"': - LOGGER.warn("TSV file '%s' has quotes around cells which is " - "deprecated and must be fixed. Remove quotes " - "from '%s' on line %d." - % (path, cell, line_number)) - return cell[1:-1].replace('""', '"').strip() - return cell - - def _deprecate_empty_data_cells(self, cells, path, line_number): - data_cells = dropwhile(lambda c: not c, cells) - if not all(data_cells): - LOGGER.warn("TSV file '%s' has empty data cells which is " - "deprecated and must be fixed. Escape empty cells " - "on line %d with '${EMPTY}'." % (path, line_number)) diff --git a/src/robot/parsing/txtreader.py b/src/robot/parsing/txtreader.py deleted file mode 100644 index c27f92eb19a..00000000000 --- a/src/robot/parsing/txtreader.py +++ /dev/null @@ -1,24 +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. - -# TODO: Remove this module in RF 3.2. - -import warnings - -from .robotreader import RobotReader as TxtReader - - -warnings.warn("'TxtReader' is deprecated. Use 'RobotReader' instead.", - DeprecationWarning,) diff --git a/src/robot/pythonpathsetter.py b/src/robot/pythonpathsetter.py index 930fc7cb783..a58427e5e2a 100644 --- a/src/robot/pythonpathsetter.py +++ b/src/robot/pythonpathsetter.py @@ -13,29 +13,21 @@ # 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. -import sys -import fnmatch -from os.path import abspath, dirname - -ROBOTDIR = dirname(abspath(__file__)) +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. -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) +Does nothing if the `robot` module is already imported. +""" -def remove_path(path): - sys.path = [p for p in sys.path if not fnmatch.fnmatch(p, path)] +import sys +from pathlib import 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) +def set_pythonpath(): + 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/src/robot/rebot.py b/src/robot/rebot.py index 0b4f05b3ab8..719793da622 100755 --- a/src/robot/rebot.py +++ b/src/robot/rebot.py @@ -32,18 +32,17 @@ 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__': - import pythonpathsetter +if __name__ == "__main__" and "robot" not in sys.modules: + from pythonpathsetter import set_pythonpath + + set_pythonpath() from robot.conf import RebotSettings from robot.errors import DataError -from robot.reporting import ResultWriter from robot.output import LOGGER -from robot.utils import Application +from robot.reporting import ResultWriter from robot.run import RobotFramework - +from robot.utils import Application USAGE = """Rebot -- Robot Framework report and log generator @@ -52,7 +51,6 @@ Usage: rebot [options] robot_outputs or: python -m robot.rebot [options] robot_outputs or: python path/to/robot/rebot.py [options] robot_outputs - or: java -jar robotframework.jar rebot [options] robot_outputs Rebot can be used to generate logs and reports in HTML format. It can also produce new XML output files which can be further processed with Rebot or @@ -61,10 +59,8 @@ The easiest way to execute Rebot is using the `rebot` command created as part of the normal installation. Alternatively it is possible to execute the `robot.rebot` module directly using `python -m robot.rebot`, where `python` -can be replaced with any supported Python interpreter like `jython`, `ipy` or -`python3`. Yet another alternative is running the `robot/rebot.py` script like -`python path/to/robot/rebot.py`. Finally, there is a standalone JAR -distribution available. +can be replaced with any supported Python interpreter. Yet another alternative +is running the `robot/rebot.py` script like `python path/to/robot/rebot.py`. Inputs to Rebot are XML output files generated by Robot Framework or by earlier Rebot executions. When more than one input file is given, a new top level test @@ -81,53 +77,52 @@ Options ======= - --rpa Turn on generic automation mode. Mainly affects + --rpa Turn on the generic automation mode. Mainly affects terminology so that "test" is replaced with "task" in logs and reports. By default the mode is got - from output files. New in RF 3.1. + from the processed output files. -R --merge When combining results, merge outputs together instead of putting them under a new top level suite. Example: rebot --merge orig.xml rerun.xml - -N --name name Set the name of the top level test suite. Default - name is created from the name of the executed data - source. - -D --doc documentation Set the documentation of the top level test suite. + -N --name name Set the name of the top level suite. + -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. - Example: --doc "Very *good* example" + If the value is path to an existing file, actual + documentation is read from that file. + Examples: --doc "Very *good* example" + --doc doc_from_file.txt -M --metadata name:value * Set metadata of the top level suite. Value can - contain formatting similarly as --doc. - Example: --metadata version:1.2 - -G --settag tag * Sets given tag(s) to all executed test cases. - -t --test name * Select test cases by name or long name. Name is case + contain formatting and be read from a file similarly + as --doc. Example: --metadata Version:1.2 + -G --settag tag * Sets given tag(s) to all tests. + -t --test name * Select tests by name or by long name containing also + parent suite name like `Parent.Test`. Name is case and space insensitive and it can also be a simple - pattern where `*` matches anything and `?` matches - any char. + pattern where `*` matches anything, `?` matches any + single character, and `[chars]` matches one character + in brackets. --task name * Alias to --test. Especially applicable with --rpa. - -s --suite name * Select test suites by name. When this option is used - with --test, --include or --exclude, only test cases - in matching suites and also matching other filtering - criteria are selected. Given name can be a simple - pattern similarly as with --test. - -i --include tag * Select test cases to by tag. Similarly as name with - --test, tag is case and space insensitive and it is - possible to use patterns with `*` and `?` as - wildcards. Tags and patterns can also be combined - together with `AND`, `OR`, and `NOT` operators. + -s --suite name * Select suites by name. When this option is used with + --test, --include or --exclude, only tests in + matching suites and also matching other filtering + criteria are selected. Name can be a simple pattern + similarly as with --test and it can contain parent + name separated with a dot. For example, `-s X.Y` + selects suite `Y` only if its parent is `X`. + -i --include tag * Select tests by tag. Similarly as name with --test, + tag is case and space insensitive and it is possible + to use patterns with `*`, `?` and `[]` as wildcards. + Tags and patterns can also be combined together with + `AND`, `OR`, and `NOT` operators. Examples: --include foo --include bar* --include fooANDbar* - -e --exclude tag * Select test cases not to be included by tag. These - tests are not selected even if included with - --include. Tags are matched using the rules explained - with --include. - --processemptysuite Processes output also if the top level test suite is + -e --exclude tag * Specify tests not to be included by tag. They are not + selected even if included with --include. Tags are + matched using same rules as with --include. + --processemptysuite Processes output also if the top level suite is empty. Useful e.g. with --include/--exclude when it - is not an error that no test matches the condition. - -c --critical tag * Tests having given tag are considered critical. If no - critical tags are set, all tags are critical. Tags - can be given as a pattern like with --include. - -n --noncritical tag * Tests with given tag are not critical even if they - have a tag set with --critical. Tag can be a pattern. + is not an error that there are no matches. -d --outputdir dir Where to create output files. The default is the directory where Rebot is run from and the given path is considered relative to that unless it is absolute. @@ -135,6 +130,8 @@ specified. Given path, similarly as paths given to --log, --report and --xunit, is relative to --outputdir unless given as an absolute path. + --legacyoutput Create XML output file in format compatible with + Robot Framework 6.x and earlier. -l --log file HTML log file. Can be disabled by giving a special name `NONE`. Default: log.html Examples: `--log mylog.html`, `-l none` @@ -142,23 +139,23 @@ similarly as --log. Default: report.html -x --xunit file xUnit compatible result file. Not created unless this option is specified. - --xunitskipnoncritical Mark non-critical tests on xUnit output as skipped. -T --timestampoutputs When this option is used, timestamp in a format `YYYYMMDD-hhmmss` is added to all generated output files between their basename and extension. For example `-T -o output.xml -r report.html -l none` creates files like `output-20070503-154410.xml` and `report-20070503-154410.html`. - --splitlog Split log file into smaller pieces that open in - browser transparently. - --logtitle title Title for the generated test log. The default title - is ` Test Log`. - --reporttitle title Title for the generated test report. The default - title is ` Test Report`. + --splitlog Split the log file into smaller pieces that open in + browsers transparently. + --logtitle title Title for the generated log file. The default title + is ` Log`. + --reporttitle title Title for the generated report file. The default + title is ` Report`. --reportbackground colors Background colors to use in the report file. - Either `all_passed:critical_passed:failed` or - `passed:failed`. Both color names and codes work. - Examples: --reportbackground green:yellow:red + Given in format `passed:failed:skipped` where the + `:skipped` part can be omitted. Both color names and + codes work. + Examples: --reportbackground green:red:yellow --reportbackground #00E:#E00 -L --loglevel level Threshold for selecting messages. Available levels: TRACE (default), DEBUG, INFO, WARN, NONE (no msgs). @@ -170,45 +167,46 @@ in log and report. By default all suite levels are shown. Example: --suitestatlevel 3 --tagstatinclude tag * Include only matching tags in `Statistics by Tag` - and `Test Details` in log and report. By default all - tags set in test cases are shown. Given `tag` can - also be a simple pattern (see e.g. --test). - --tagstatexclude tag * Exclude matching tags from `Statistics by Tag` and - `Test Details`. This option can be used with - --tagstatinclude similarly as --exclude is used with - --include. + in log and report. By default all tags are shown. + Given tag can be a pattern like with --include. + --tagstatexclude tag * Exclude matching tags from `Statistics by Tag`. + This option can be used with --tagstatinclude + similarly as --exclude is used with --include. --tagstatcombine tags:name * Create combined statistics based on tags. - These statistics are added into `Statistics by Tag` - and matching tests into `Test Details`. If optional - `name` is not given, name of the combined tag is got - from the specified tags. Tags are combined using the - rules explained in --include. + These statistics are added into `Statistics by Tag`. + If the optional `name` is not given, name of the + combined tag is got from the specified tags. Tags are + matched using the same rules as with --include. Examples: --tagstatcombine requirement-* --tagstatcombine tag1ANDtag2:My_name - --tagdoc pattern:doc * Add documentation to tags matching given pattern. - Documentation is shown in `Test Details` and also as - a tooltip in `Statistics by Tag`. Pattern can contain - characters `*` (matches anything) and `?` (matches - any char). Documentation can contain formatting - similarly as with --doc option. + --tagdoc pattern:doc * Add documentation to tags matching the given + pattern. Documentation is shown in `Test Details` and + also as a tooltip in `Statistics by Tag`. Pattern can + use `*`, `?` and `[]` as wildcards like --test. Examples: --tagdoc mytag:Example --tagdoc "owner-*:Original author" --tagstatlink pattern:link:title * Add external links into `Statistics by - Tag`. Pattern can contain characters `*` (matches - anything) and `?` (matches any char). Characters - matching to wildcard expressions can be used in link - and title with syntax %N, where N is index of the - match (starting from 1). + Tag`. Pattern can use `*`, `?` and `[]` as wildcards + like --test. Characters matching to `*` and `?` + wildcards can be used in link and title with syntax + %N, where N is index of the match (starting from 1). Examples: --tagstatlink mytag:http://my.domain:Title --tagstatlink "bug-*:http://url/id=%1:Issue Tracker" + --expandkeywords name:|tag: * + Matching keywords will be automatically expanded in + the log file. Matching against keyword name or tags + work using same rules as with --removekeywords. + Examples: --expandkeywords name:BuiltIn.Log + --expandkeywords tag:expand --removekeywords all|passed|for|wuks|name:|tag: * Remove keyword data from all generated outputs. Keywords containing warnings are not removed except - in `all` mode. + in the `all` mode. all: remove data from all keywords passed: remove data only from keywords in passed test cases and suites - for: remove passed iterations from for loops + for: remove passed iterations from FOR loops + while: remove passed iterations from WHILE loops wuks: remove all but the last failing keyword inside `BuiltIn.Wait Until Keyword Succeeds` name:: remove data from keywords that match @@ -216,59 +214,72 @@ against the full name of the keyword (e.g. 'MyLib.Keyword', 'resource.Second Keyword'), is case, space, and underscore insensitive, - and may contain `*` and `?` as wildcards. + and may contain `*`, `?` and `[]` wildcards. Examples: --removekeywords name:Lib.HugeKw --removekeywords name:myresource.* tag:: remove data from keywords that match the given pattern. Tags are case and space - insensitive and it is possible to use - patterns with `*` and `?` as wildcards. - Tags and patterns can also be combined - together with `AND`, `OR`, and `NOT` - operators. + insensitive and patterns can contain `*`, + `?` and `[]` wildcards. Tags and patterns + can also be combined together with `AND`, + `OR`, and `NOT` operators. Examples: --removekeywords foo --removekeywords fooANDbar* - --flattenkeywords for|foritem|name:|tag: * + --flattenkeywords for|while|iteration|name:|tag: * Flattens matching keywords in all generated outputs. Matching keywords get all log messages from their child keywords and children are discarded otherwise. - for: flatten for loops fully - foritem: flatten individual for loop iterations + for: flatten FOR loops fully + while: flatten WHILE loops fully + iteration: flatten FOR/WHILE loop iterations + foritem: deprecated alias for `iteration` name:: flatten matched keywords using same matching rules as with `--removekeywords name:` tag:: flatten matched keywords using same matching rules as with `--removekeywords tag:` - --starttime timestamp Set starting time of test execution when creating - reports. Timestamp must be given in format - `2007-10-01 15:12:42.268` where all separators are - optional (e.g. `20071001151242268` is ok too) and + --starttime timestamp Set execution start time. Timestamp must be given in + format `2007-10-01 15:12:42.268` where all separators + are optional (e.g. `20071001151242268` is ok too) and parts from milliseconds to hours can be omitted if they are zero (e.g. `2007-10-01`). This can be used - to override starttime of the suite when reports are - created from a single suite or to set starttime for - combined suite, which is otherwise set to `N/A`. - --endtime timestamp Same as --starttime but for ending time. If both - options are used, elapsed time of the suite is - calculated based on them. For combined suites, - it is otherwise calculated by adding elapsed times - of combined test suites together. - --nostatusrc Sets the return code to zero regardless of failures - in test cases. Error codes are returned normally. + to override start time of a single suite or to set + start time for a combined suite, which would + otherwise be `N/A`. + --endtime timestamp Same as --starttime but for end time. If both options + are used, elapsed time of the suite is calculated + based on them. For combined suites, it is otherwise + calculated by adding elapsed times of the combined + suites together. + --nostatusrc Sets the return code to zero regardless are there + failures. Error codes are returned normally. --prerebotmodifier class * Class to programmatically modify the result model before creating outputs. + --console console How to report on the console. + Built-in consoles: + verbose: show output file paths (default) + quiet: no output except for errors/warnings + none: no output whatsoever + Other values are interpreted as a custom + console class or module. Argument format is + the same as with --listener. + Examples: --console verbose + --console quiet + --console path/to/Console.py:arg + --quiet Shortcut for `--console quiet`. -C --consolecolors auto|on|ansi|off Use colors on console output or not. auto: use colors when output not redirected (default) on: always use colors ansi: like `on` but use ANSI colors also on Windows off: disable colors altogether - Note that colors do not work with Jython on Windows. + --consolelinks auto|off Control making paths to results files hyperlinks. + auto: use links when colors are enabled (default) + off: disable links unconditionally -P --pythonpath path * Additional locations to add to the module search path that is used when importing Python based extensions. - -E --escape what:with * Deprecated. Use console escape mechanism instead. -A --argumentfile path * Text file to read more arguments from. File can have - both options and data sources one per line. Contents + both options and output files, one per line. Contents do not need to be escaped but spaces in the beginning and end of lines are removed. Empty lines and lines starting with a hash character (#) are ignored. @@ -288,13 +299,11 @@ using the same option again with `no` prefix added or dropped. The last option has precedence regardless of how many times options are used. For example, `--merge --merge --nomerge --nostatusrc --statusrc` would not activate the -merge mode and would return normal status rc. +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 ===================== @@ -309,6 +318,16 @@ Available levels are the same as for --loglevel command line option and the default is INFO. +Return Codes +============ + +0 All tests passed. +1-249 Returned number of tests failed. +250 250 or more failures. +251 Help or version information printed. +252 Invalid data or command line options. +255 Unexpected internal error. + Examples ======== @@ -317,30 +336,41 @@ # Using options. Note that this is one long command split into multiple lines. $ rebot --log smoke_log.html --report smoke_report.html --include smoke - --ReportTitle Smoke_Tests --ReportBackground green:yellow:red + --ReportTitle "Smoke Tests" --ReportBackground green:yellow:red --TagStatCombine tag1ANDtag2 path/to/myoutput.xml # Executing `robot.rebot` module using Python and creating combined outputs. $ python -m robot.rebot --name Combined outputs/*.xml - -# Running `robot/rebot.py` script with Jython. -$ jython path/robot/rebot.py -N Project_X -l none -r x.html output.xml """ class Rebot(RobotFramework): def __init__(self): - Application.__init__(self, USAGE, arg_limits=(1,), - env_options='REBOT_OPTIONS', logger=LOGGER) + Application.__init__( + self, + USAGE, + arg_limits=(1,), + env_options="REBOT_OPTIONS", + logger=LOGGER, + ) def main(self, datasources, **options): - settings = RebotSettings(options) + try: + settings = RebotSettings(options) + except DataError: + LOGGER.register_console_logger( + stdout=options.get("stdout"), + stderr=options.get("stderr"), + ) + raise + if settings.pythonpath: + sys.path = settings.pythonpath + sys.path LOGGER.register_console_logger(**settings.console_output_config) LOGGER.disable_message_cache() rc = ResultWriter(*datasources).write_results(settings) if rc < 0: - raise DataError('No outputs created.') + raise DataError("No outputs created.") return rc @@ -348,9 +378,9 @@ def rebot_cli(arguments=None, exit=True): """Command line execution entry point for post-processing outputs. :param arguments: Command line options and arguments as a list of strings. - Starting from RF 3.1, defaults to ``sys.argv[1:]`` if not given. + Defaults to ``sys.argv[1:]`` if not given. :param exit: If ``True``, call ``sys.exit`` with the return code denoting - execution status, otherwise just return the rc. New in RF 3.0.1. + execution status, otherwise just return the rc. Entry point used when post-processing outputs from the command line, but can also be used by custom scripts. Especially useful if the script itself @@ -391,17 +421,18 @@ def rebot(*outputs, **options): from robot import rebot rebot('path/to/output.xml') + rebot('o1.xml', 'o2.xml', name='Example', log=None, console='quiet') with open('stdout.txt', 'w') as stdout: rebot('o1.xml', 'o2.xml', name='Example', log=None, stdout=stdout) Equivalent command line usage:: rebot path/to/output.xml + rebot --name Example --log NONE --console quiet o1.xml o2.xml rebot --name Example --log NONE o1.xml o2.xml > stdout.txt """ return Rebot().execute(*outputs, **options) -if __name__ == '__main__': +if __name__ == "__main__": rebot_cli(sys.argv[1:]) - diff --git a/src/robot/reporting/__init__.py b/src/robot/reporting/__init__.py index 2847b60a862..152091de760 100644 --- a/src/robot/reporting/__init__.py +++ b/src/robot/reporting/__init__.py @@ -26,4 +26,4 @@ This package is considered stable. """ -from .resultwriter import ResultWriter +from .resultwriter import ResultWriter as ResultWriter diff --git a/src/robot/reporting/expandkeywordmatcher.py b/src/robot/reporting/expandkeywordmatcher.py new file mode 100644 index 00000000000..bee24c7796d --- /dev/null +++ b/src/robot/reporting/expandkeywordmatcher.py @@ -0,0 +1,38 @@ +# 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 collections.abc import Sequence + +from robot.result import Keyword +from robot.utils import MultiMatcher + + +class ExpandKeywordMatcher: + + def __init__(self, expand_keywords: "str | Sequence[str]"): + self.matched_ids: list[str] = [] + if not expand_keywords: + 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:"] + self._match_name = MultiMatcher(names).match + self._match_tags = MultiMatcher(tags).match_any + + def match(self, kw: Keyword): + match = self._match_name(kw.full_name or "") or self._match_tags(kw.tags) + if match and not kw.not_run: + self.matched_ids.append(kw.id) diff --git a/src/robot/reporting/jsbuildingcontext.py b/src/robot/reporting/jsbuildingcontext.py index 4d4c3076f59..693038858f3 100644 --- a/src/robot/reporting/jsbuildingcontext.py +++ b/src/robot/reporting/jsbuildingcontext.py @@ -14,48 +14,70 @@ # limitations under the License. from contextlib import contextmanager -from os.path import exists, dirname +from datetime import datetime +from pathlib import Path from robot.output.loggerhelper import LEVELS -from robot.utils import (attribute_escape, get_link_path, html_escape, - html_format, is_string, is_unicode, timestamp_to_secs, - unic) +from robot.utils import attribute_escape, get_link_path, html_escape, safe_str +from .expandkeywordmatcher import ExpandKeywordMatcher from .stringcache import StringCache -class JsBuildingContext(object): +class JsBuildingContext: - def __init__(self, log_path=None, split_log=False, 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 + def __init__( + self, + log_path=None, + split_log=False, + expand_keywords=None, + prune_input=False, + ): + 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() self.basemillis = None self.split_results = [] - self.min_level = 'NONE' + self.min_level = "NONE" self._msg_links = {} + 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_unicode(string): - string = unic(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) return self._strings.add(string) def html(self, string): - return self.string(html_format(string), escape=False) + return self._strings.add(string, html=True) def relative_source(self, source): - rel_source = get_link_path(source, self._log_dir) \ - if self._log_dir and source and exists(source) else '' + if isinstance(source, str): + source = Path(source) + if self._log_dir and source and source.exists(): + rel_source = get_link_path(source, self._log_dir) + else: + rel_source = "" return self.string(rel_source) - def timestamp(self, time): - if not time: + def timestamp(self, ts: "datetime | None") -> "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 @@ -68,6 +90,14 @@ def create_link_target(self, msg): id = self._top_level_strings.add(msg.parent.id) self._msg_links[self._link_key(msg)] = id + def check_expansion(self, kw): + if self._expand_matcher is not None: + self._expand_matcher.match(kw) + + @property + def expand_keywords(self): + return self._expand_matcher.matched_ids if self._expand_matcher else None + def link(self, msg): return self._msg_links.get(self._link_key(msg)) diff --git a/src/robot/reporting/jsexecutionresult.py b/src/robot/reporting/jsexecutionresult.py index f51fad2fe74..41fcf1fbbe0 100644 --- a/src/robot/reporting/jsexecutionresult.py +++ b/src/robot/reporting/jsexecutionresult.py @@ -14,52 +14,58 @@ # limitations under the License. import time -from collections import OrderedDict - -from robot.utils import IRONPYTHON, PY_VERSION from .stringcache import StringIndex -# http://ironpython.codeplex.com/workitem/31549 -if IRONPYTHON and PY_VERSION < (2, 7, 2): - int = long - -class JsExecutionResult(object): +class JsExecutionResult: - def __init__(self, suite, statistics, errors, strings, basemillis=None, - split_results=None, min_level=None): + def __init__( + self, + suite, + statistics, + errors, + strings, + basemillis=None, + split_results=None, + min_level=None, + expand_keywords=None, + ): self.suite = suite self.strings = strings self.min_level = min_level - self.data = self._get_data(statistics, errors, basemillis or 0) + self.data = self._get_data(statistics, errors, basemillis or 0, expand_keywords) self.split_results = split_results or [] - def _get_data(self, statistics, errors, basemillis): - return OrderedDict([ - ('stats', statistics), - ('errors', errors), - ('baseMillis', basemillis), - ('generated', int(time.time() * 1000) - basemillis) - ]) + def _get_data(self, statistics, errors, basemillis, expand_keywords): + return { + "stats": statistics, + "errors": errors, + "baseMillis": basemillis, + "generated": int(time.time() * 1000) - basemillis, + "expand_keywords": expand_keywords, + } def remove_data_not_needed_in_report(self): - self.data.pop('errors') - remover = _KeywordRemover() - self.suite = remover.remove_keywords(self.suite) - self.suite, self.strings \ - = remover.remove_unused_strings(self.suite, self.strings) + self.data.pop("errors") + rm = _KeywordRemover() + self.suite = rm.remove_keywords(self.suite) + self.suite, self.strings = rm.remove_unused_strings(self.suite, self.strings) -class _KeywordRemover(object): +class _KeywordRemover: def remove_keywords(self, suite): return self._remove_keywords_from_suite(suite) def _remove_keywords_from_suite(self, suite): - return suite[:6] + (self._remove_keywords_from_suites(suite[6]), - self._remove_keywords_from_tests(suite[7]), - (), suite[9]) + return ( + *suite[:6], + self._remove_keywords_from_suites(suite[6]), + self._remove_keywords_from_tests(suite[7]), + (), + suite[9], + ) def _remove_keywords_from_suites(self, suites): return tuple(self._remove_keywords_from_suite(s) for s in suites) @@ -82,8 +88,7 @@ def _get_used_indices(self, model): if isinstance(item, StringIndex): yield item elif isinstance(item, tuple): - for i in self._get_used_indices(item): - yield i + yield from self._get_used_indices(item) def _get_used_strings(self, strings, used_indices, remap): offset = 0 diff --git a/src/robot/reporting/jsmodelbuilders.py b/src/robot/reporting/jsmodelbuilders.py index edee83f618b..4425f7cbed3 100644 --- a/src/robot/reporting/jsmodelbuilders.py +++ b/src/robot/reporting/jsmodelbuilders.py @@ -13,18 +13,52 @@ # See the License for the specific language governing permissions and # limitations under the License. +import re + from robot.output import LEVELS +from robot.result import Error, Keyword, Message, Return from .jsbuildingcontext import JsBuildingContext from .jsexecutionresult import JsExecutionResult - -class JsModelBuilder(object): - - def __init__(self, log_path=None, split_log=False, - prune_input_to_save_memory=False): - self._context = JsBuildingContext(log_path, split_log, - prune_input_to_save_memory) +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, + "VAR": 9, + "TRY": 10, + "EXCEPT": 11, + "FINALLY": 12, + "WHILE": 13, + "GROUP": 14, + "CONTINUE": 15, + "BREAK": 16, + "ERROR": 17, +} + + +class JsModelBuilder: + + def __init__( + self, + log_path=None, + split_log=False, + expand_keywords=None, + prune_input_to_save_memory=False, + ): + self._context = JsBuildingContext( + log_path, + split_log, + expand_keywords, + prune_input_to_save_memory, + ) def build_from(self, result_from_xml): # Statistics must be build first because building suite may prune input. @@ -35,146 +69,226 @@ def build_from(self, result_from_xml): strings=self._context.strings, basemillis=self._context.basemillis, split_results=self._context.split_results, - min_level=self._context.min_level + min_level=self._context.min_level, + expand_keywords=self._context.expand_keywords, ) -class _Builder(object): - _statuses = {'FAIL': 0, 'PASS': 1, 'NOT_RUN': 2} +class Builder: + robot_note = re.compile('(.*)') - def __init__(self, context): + def __init__(self, context: JsBuildingContext): self._context = context self._string = self._context.string self._html = self._context.html self._timestamp = self._context.timestamp - def _get_status(self, item): - model = (self._statuses[item.status], - self._timestamp(item.starttime), - item.elapsedtime) - msg = getattr(item, 'message', '') + def _yield_metadata(self, item): + for name, value in item.metadata.items(): + yield self._string(name) + yield self._html(value) + + def _get_status(self, item, note_only=False): + model = ( + STATUSES[item.status], + self._timestamp(item.start_time), + round(item.elapsed_time.total_seconds() * 1000), + ) + msg = item.message if not msg: return model - elif msg.startswith('*HTML*'): - msg = self._string(msg[6:].lstrip(), escape=False) + if note_only: + if msg.startswith("*HTML*"): + match = self.robot_note.search(msg) + if match: + index = self._string(match.group(1)) + return (*model, index) + return model + if msg.startswith("*HTML*"): + index = self._string(msg[6:].lstrip(), escape=False) else: - msg = self._string(msg) - return model + (msg,) + index = self._string(msg) + return (*model, index) - def _build_keywords(self, kws, split=False): + def _build_body(self, body, split=False): splitting = self._context.start_splitting_if_needed(split) - model = tuple(self._build_keyword(k) for k in kws) + # tuple([]) is faster than tuple() with short lists. + model = tuple([self._build_body_item(item) for item in body]) return model if not splitting else self._context.end_splitting(model) + 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.suites, suite.tests, suite.keywords): + with self._context.prune_input(suite.tests, suite.suites): stats = self._get_statistics(suite) # Must be done before pruning - return (self._string(suite.name, attr=True), - self._string(suite.source), - self._context.relative_source(suite.source), - self._html(suite.doc), - tuple(self._yield_metadata(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 suite.keywords), - stats) - - def _yield_metadata(self, suite): - for name, value in suite.metadata.items(): - yield self._string(name) - yield self._html(value) + 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), + self._html(suite.doc), + tuple(self._yield_metadata(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_body_item(kw, split=True) for kw in fixture), + stats, + ) def _get_statistics(self, suite): stats = suite.statistics # Access property only once - return (stats.all.total, - stats.all.passed, - stats.critical.total, - stats.critical.passed) + 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): - with self._context.prune_input(test.keywords): - return (self._string(test.name, attr=True), - self._string(test.timeout), - int(test.critical), - self._html(test.doc), - tuple(self._string(t) for t in test.tags), - self._get_status(test), - self._build_keywords(test.keywords, split=True)) - - -class KeywordBuilder(_Builder): - _types = {'kw': 0, 'setup': 1, 'teardown': 2, 'for': 3, 'foritem': 4} + 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), + tuple(self._yield_metadata(test)), + self._get_status(test), + self._build_body(body, split=True), + ) + + def _get_body_items(self, test): + body = test.body.flatten() + if test.has_setup: + body.insert(0, test.setup) + if test.has_teardown: + body.append(test.teardown) + return body + + +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, kw, split=False): - with self._context.prune_input(kw.messages, kw.keywords): - return (self._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(kw.tags)), - self._get_status(kw), - self._build_keywords(kw.keywords, split), - tuple(self._build_message(m) for m in kw.messages)) + def build(self, item, split=False): + if isinstance(item, Message): + return self._build_message(item) + with self._context.prune_input(item.body): + if isinstance(item, Keyword): + return self._build_keyword(item, split) + if isinstance(item, (Return, Error)): + 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): + 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], + self._string(name, attr=True), + self._string(owner, attr=True), + self._string(timeout), + self._html(doc), + self._string(args), + self._string(assign), + self._string(tags), + self._get_status(item, note_only=True), + self._build_body(body, split), + ) -class MessageBuilder(_Builder): +class MessageBuilder(Builder): def build(self, msg): - if msg.level in ('WARN','ERROR'): + if msg.level in ("WARN", "ERROR"): self._context.create_link_target(msg) self._context.message_level(msg.level) return self._build(msg) def _build(self, msg): - return (self._timestamp(msg.timestamp), - LEVELS[msg.level], - self._string(msg.html_message, escape=False)) + return ( + self._timestamp(msg.timestamp), + LEVELS[msg.level], + self._string(msg.html_message, escape=False), + ) -class StatisticsBuilder(object): +class StatisticsBuilder: def build(self, statistics): - return (self._build_stats(statistics.total), - self._build_stats(statistics.tags), - self._build_stats(statistics.suite, exclude_empty=False)) + return ( + self._build_stats(statistics.total), + self._build_stats(statistics.tags), + self._build_stats(statistics.suite, exclude_empty=False), + ) def _build_stats(self, stats, exclude_empty=True): - return tuple(stat.get_attributes(include_label=True, - include_elapsed=True, - exclude_empty=exclude_empty, - html_escape=True) - for stat in stats) + return tuple( + stat.get_attributes( + include_label=True, + include_elapsed=True, + exclude_empty=exclude_empty, + html_escape=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): @@ -187,4 +301,4 @@ class ErrorMessageBuilder(MessageBuilder): def build(self, msg): model = self._build(msg) link = self._context.link(msg) - return model if link is None else model + (link,) + return model if link is None else (*model, link) diff --git a/src/robot/reporting/jswriter.py b/src/robot/reporting/jswriter.py index 31fc60a5fc3..f3666fbf4f0 100644 --- a/src/robot/reporting/jswriter.py +++ b/src/robot/reporting/jswriter.py @@ -16,17 +16,20 @@ from robot.htmldata import JsonWriter -class JsResultWriter(object): - _output_attr = 'window.output' - _settings_attr = 'window.settings' - _suite_key = 'suite' - _strings_key = 'strings' - - def __init__(self, output, - start_block='\n', - split_threshold=9500): - writer = JsonWriter(output, separator=end_block+start_block) +class JsResultWriter: + _output_attr = "window.output" + _settings_attr = "window.settings" + _suite_key = "suite" + _strings_key = "strings" + + def __init__( + self, + output, + start_block='\n", + split_threshold=9500, + ): + writer = JsonWriter(output, separator=end_block + start_block) self._write = writer.write self._write_json = writer.write_json self._start_block = start_block @@ -41,8 +44,8 @@ def write(self, result, settings): self._write_settings_and_end_output_block(settings) def _start_output_block(self): - self._write(self._start_block, postfix='', separator=False) - self._write('%s = {}' % self._output_attr) + self._write(self._start_block, postfix="", separator=False) + self._write(f"{self._output_attr} = {{}}") def _write_suite(self, suite): writer = SuiteWriter(self._write_json, self._split_threshold) @@ -50,27 +53,26 @@ def _write_suite(self, suite): def _write_strings(self, strings): variable = self._output_var(self._strings_key) - self._write('%s = []' % variable) - prefix = '%s = %s.concat(' % (variable, variable) - postfix = ');\n' + self._write(f"{variable} = []") + prefix = f"{variable} = {variable}.concat(" + postfix = ");\n" threshold = self._split_threshold for index in range(0, len(strings), threshold): - self._write_json(prefix, strings[index:index+threshold], postfix) + self._write_json(prefix, strings[index : index + threshold], postfix) def _write_data(self, data): for key in data: - self._write_json('%s = ' % self._output_var(key), data[key]) + self._write_json(f"{self._output_var(key)} = ", data[key]) def _write_settings_and_end_output_block(self, settings): - self._write_json('%s = ' % self._settings_attr, settings, - separator=False) - self._write(self._end_block, postfix='', separator=False) + self._write_json(f"{self._settings_attr} = ", settings, separator=False) + self._write(self._end_block, postfix="", separator=False) def _output_var(self, key): - return '%s["%s"]' % (self._output_attr, key) + return f'{self._output_attr}["{key}"]' -class SuiteWriter(object): +class SuiteWriter: def __init__(self, write_json, split_threshold): self._write_json = write_json @@ -79,30 +81,31 @@ def __init__(self, write_json, split_threshold): def write(self, suite, variable): mapping = {} self._write_parts_over_threshold(suite, mapping) - self._write_json('%s = ' % variable, suite, mapping=mapping) + self._write_json(f"{variable} = ", suite, mapping=mapping) def _write_parts_over_threshold(self, data, mapping): if not isinstance(data, tuple): return 1 - not_written = 1 + sum(self._write_parts_over_threshold(item, mapping) - for item in data) + not_written = 1 + for item in data: + not_written += self._write_parts_over_threshold(item, mapping) if not_written > self._split_threshold: self._write_part(data, mapping) return 1 return not_written def _write_part(self, data, mapping): - part_name = 'window.sPart%d' % len(mapping) - self._write_json('%s = ' % part_name, data, mapping=mapping) + part_name = f"window.sPart{len(mapping)}" + self._write_json(f"{part_name} = ", data, mapping=mapping) mapping[data] = part_name -class SplitLogWriter(object): +class SplitLogWriter: def __init__(self, output): self._writer = JsonWriter(output) def write(self, keywords, strings, index, notify): - self._writer.write_json('window.keywords%d = ' % index, keywords) - self._writer.write_json('window.strings%d = ' % index, strings) - self._writer.write('window.fileLoading.notify("%s")' % notify) + self._writer.write_json(f"window.keywords{index} = ", keywords) + self._writer.write_json(f"window.strings{index} = ", strings) + self._writer.write(f'window.fileLoading.notify("{notify}")') diff --git a/src/robot/reporting/logreportwriters.py b/src/robot/reporting/logreportwriters.py index 00672a87630..93250394e3d 100644 --- a/src/robot/reporting/logreportwriters.py +++ b/src/robot/reporting/logreportwriters.py @@ -13,22 +13,25 @@ # See the License for the specific language governing permissions and # limitations under the License. -from os.path import basename, splitext +from pathlib import Path -from robot.htmldata import HtmlFileWriter, ModelWriter, LOG, REPORT -from robot.utils import file_writer, is_string +from robot.htmldata import HtmlFileWriter, LOG, ModelWriter, REPORT +from robot.utils import file_writer from .jswriter import JsResultWriter, SplitLogWriter -class _LogReportWriter(object): +class _LogReportWriter: + usage = None def __init__(self, js_model): self._js_model = js_model - def _write_file(self, path, config, template): - outfile = file_writer(path) \ - if is_string(path) else path # unit test hook + def _write_file(self, path: Path, config, template): + if isinstance(path, Path): + outfile = file_writer(path, usage=self.usage) + else: + outfile = path # unit test hook with outfile: model_writer = RobotModelWriter(outfile, self._js_model, config) writer = HtmlFileWriter(outfile, model_writer) @@ -36,26 +39,32 @@ def _write_file(self, path, config, template): class LogWriter(_LogReportWriter): + usage = "log" - def write(self, path, config): + def write(self, path: "Path | str", config): + if isinstance(path, str): + path = Path(path) self._write_file(path, config, LOG) if self._js_model.split_results: - self._write_split_logs(splitext(path)[0]) + self._write_split_logs(path) - def _write_split_logs(self, base): - for index, (keywords, strings) in enumerate(self._js_model.split_results, - start=1): - self._write_split_log(index, keywords, strings, '%s-%d.js' % (base, index)) + def _write_split_logs(self, path: Path): + for index, (kws, strings) in enumerate(self._js_model.split_results, start=1): + name = f"{path.stem}-{index}.js" + self._write_split_log(index, kws, strings, path.with_name(name)) - def _write_split_log(self, index, keywords, strings, path): - with file_writer(path) as outfile: + def _write_split_log(self, index, kws, strings, path: Path): + with file_writer(path, usage=self.usage) as outfile: writer = SplitLogWriter(outfile) - writer.write(keywords, strings, index, basename(path)) + writer.write(kws, strings, index, path.name) class ReportWriter(_LogReportWriter): + usage = "report" - def write(self, path, config): + def write(self, path: "Path | str", config): + if isinstance(path, str): + path = Path(path) self._write_file(path, config, REPORT) diff --git a/src/robot/reporting/outputwriter.py b/src/robot/reporting/outputwriter.py index 4ba1ea5f043..68c34c4a482 100644 --- a/src/robot/reporting/outputwriter.py +++ b/src/robot/reporting/outputwriter.py @@ -13,26 +13,18 @@ # See the License for the specific language governing permissions and # limitations under the License. -from robot.output.xmllogger import XmlLogger +from robot.output.xmllogger import LegacyXmlLogger, XmlLogger class OutputWriter(XmlLogger): + generator = "Rebot" - def __init__(self, output, rpa=False): - XmlLogger.__init__(self, output, rpa=rpa, generator='Rebot') - - def start_message(self, msg): - self._write_message(msg) + def end_result(self, result): + self.close() - def visit_keyword(self, kw): - self.start_keyword(kw) - for child in kw.children: - child.visit(self) - self.end_keyword(kw) - def close(self): - self._writer.end('robot') - self._writer.close() +class LegacyOutputWriter(LegacyXmlLogger): + generator = "Rebot" def end_result(self, result): self.close() diff --git a/src/robot/reporting/resultwriter.py b/src/robot/reporting/resultwriter.py index 1111a8d5da3..c86b391fc1a 100644 --- a/src/robot/reporting/resultwriter.py +++ b/src/robot/reporting/resultwriter.py @@ -18,14 +18,13 @@ from robot.model import ModelModifier from robot.output import LOGGER from robot.result import ExecutionResult, Result -from robot.utils import unic from .jsmodelbuilders import JsModelBuilder from .logreportwriters import LogWriter, ReportWriter from .xunitwriter import XUnitWriter -class ResultWriter(object): +class ResultWriter: """A class to create log, report, output XML and xUnit files. :param sources: Either one :class:`~robot.result.executionresult.Result` @@ -55,48 +54,41 @@ def write_results(self, settings=None, **options): settings = settings or RebotSettings(options) results = Results(settings, *self._sources) if settings.output: - self._write_output(results.result, settings.output) + self._write_output(results.result, settings.output, settings.legacy_output) if settings.xunit: - self._write_xunit(results.result, settings.xunit, - settings.xunit_skip_noncritical) + self._write_xunit(results.result, settings.xunit) if settings.log: - config = dict(settings.log_config, - minLevel=results.js_result.min_level) + config = dict(settings.log_config, minLevel=results.js_result.min_level) self._write_log(results.js_result, settings.log, config) if settings.report: results.js_result.remove_data_not_needed_in_report() - self._write_report(results.js_result, settings.report, - settings.report_config) + self._write_report( + results.js_result, settings.report, settings.report_config + ) return results.return_code - def _write_output(self, result, path): - self._write('Output', result.save, path) + def _write_output(self, result, path, legacy_output=False): + self._write("Output", result.save, path, legacy_output) - def _write_xunit(self, result, path, skip_noncritical): - self._write('XUnit', XUnitWriter(result, skip_noncritical).write, path) + def _write_xunit(self, result, path): + self._write("XUnit", XUnitWriter(result).write, path) def _write_log(self, js_result, path, config): - self._write('Log', LogWriter(js_result).write, path, config) + self._write("Log", LogWriter(js_result).write, path, config) def _write_report(self, js_result, path, config): - self._write('Report', ReportWriter(js_result).write, path, config) + self._write("Report", ReportWriter(js_result).write, path, config) def _write(self, name, writer, path, *args): try: writer(path, *args) except DataError as err: LOGGER.error(err.message) - except EnvironmentError as err: - # `err.filename` can be different than `path` at least if reading - # log/report templates or writing split log fails. - # `unic` is needed due to http://bugs.jython.org/issue1825. - LOGGER.error("Writing %s file '%s' failed: %s: %s" % - (name.lower(), path, err.strerror, unic(err.filename))) else: - LOGGER.output_file(name, path) + LOGGER.result_file(name, path) -class Results(object): +class Results: def __init__(self, settings, *sources): self._settings = settings @@ -116,29 +108,39 @@ def result(self): if self._result is None: include_keywords = bool(self._settings.log or self._settings.output) flattened = self._settings.flatten_keywords - self._result = ExecutionResult(include_keywords=include_keywords, - flattened_keywords=flattened, - merge=self._settings.merge, - rpa=self._settings.rpa, - *self._sources) + self._result = ExecutionResult( + *self._sources, + include_keywords=include_keywords, + flattened_keywords=flattened, + merge=self._settings.merge, + rpa=self._settings.rpa, + ) if self._settings.rpa is None: self._settings.rpa = self._result.rpa - self._result.configure(self._settings.status_rc, - self._settings.suite_config, - self._settings.statistics_config) - modifier = ModelModifier(self._settings.pre_rebot_modifiers, - self._settings.process_empty_suite, - LOGGER) - self._result.suite.visit(modifier) + if self._settings.pre_rebot_modifiers: + modifier = ModelModifier( + self._settings.pre_rebot_modifiers, + self._settings.process_empty_suite, + LOGGER, + ) + self._result.suite.visit(modifier) + self._result.configure( + self._settings.status_rc, + self._settings.suite_config, + self._settings.statistics_config, + ) self.return_code = self._result.return_code return self._result @property def js_result(self): if self._js_result is None: - builder = JsModelBuilder(log_path=self._settings.log, - split_log=self._settings.split_log, - prune_input_to_save_memory=self._prune) + builder = JsModelBuilder( + log_path=self._settings.log, + split_log=self._settings.split_log, + expand_keywords=self._settings.expand_keywords, + prune_input_to_save_memory=self._prune, + ) self._js_result = builder.build_from(self.result) if self._prune: self._result = None diff --git a/src/robot/reporting/stringcache.py b/src/robot/reporting/stringcache.py index 3b4f2ca4907..0a1cbda3edd 100644 --- a/src/robot/reporting/stringcache.py +++ b/src/robot/reporting/stringcache.py @@ -13,42 +13,38 @@ # See the License for the specific language governing permissions and # limitations under the License. -from collections import OrderedDict - -from robot.utils import compress_text +from robot.utils import compress_text, html_format class StringIndex(int): pass -class StringCache(object): +class StringCache: + empty = StringIndex(0) _compress_threshold = 80 _use_compressed_threshold = 1.1 - _zero_index = StringIndex(0) def __init__(self): - self._cache = OrderedDict({'*': self._zero_index}) + self._cache = {("", False): self.empty} - def add(self, text): + def add(self, text, html=False): if not text: - return self._zero_index - text = self._encode(text) - if text not in self._cache: - self._cache[text] = StringIndex(len(self._cache)) - return self._cache[text] - - def _encode(self, text): - raw = self._raw(text) - if raw in self._cache or len(raw) < self._compress_threshold: - return raw - compressed = compress_text(text) - if len(compressed) * self._use_compressed_threshold < len(raw): - return compressed - return raw - - def _raw(self, text): - return '*'+text + return self.empty + key = (text, html) + if key not in self._cache: + self._cache[key] = StringIndex(len(self._cache)) + return self._cache[key] def dump(self): - return tuple(self._cache) + return tuple(self._encode(text, html) for text, html in self._cache) + + def _encode(self, text, html=False): + if html: + text = html_format(text) + if len(text) > self._compress_threshold: + compressed = compress_text(text) + if len(compressed) * self._use_compressed_threshold < len(text): + return compressed + # Strings starting with '*' are raw, others are compressed. + return "*" + text diff --git a/src/robot/reporting/xunitwriter.py b/src/robot/reporting/xunitwriter.py index 9d81a745374..6d11cc85669 100644 --- a/src/robot/reporting/xunitwriter.py +++ b/src/robot/reporting/xunitwriter.py @@ -13,20 +13,18 @@ # See the License for the specific language governing permissions and # limitations under the License. -from __future__ import division - -from robot.result import ResultVisitor +from robot.result import ResultVisitor, TestCase, TestSuite from robot.utils import XmlWriter -class XUnitWriter(object): +class XUnitWriter: - def __init__(self, execution_result, skip_noncritical): + def __init__(self, execution_result): self._execution_result = execution_result - self._skip_noncritical = skip_noncritical def write(self, output): - writer = XUnitFileWriter(XmlWriter(output), self._skip_noncritical) + xml_writer = XmlWriter(output, usage="xunit") + writer = XUnitFileWriter(xml_writer) self._execution_result.visit(writer) @@ -37,58 +35,52 @@ class XUnitFileWriter(ResultVisitor): http://marc.info/?l=ant-dev&m=123551933508682 """ - def __init__(self, xml_writer, skip_noncritical=False): + def __init__(self, xml_writer: XmlWriter): self._writer = xml_writer - self._root_suite = None - self._skip_noncritical = skip_noncritical - - def start_suite(self, suite): - if self._root_suite: - return - self._root_suite = suite - tests, failures, skipped = self._get_stats(suite.statistics) - attrs = {'name': suite.name, - 'tests': tests, - 'errors': '0', - 'failures': failures, - 'skipped': skipped, - 'time': self._time_as_seconds(suite.elapsedtime)} - self._writer.start('testsuite', attrs) - - def _get_stats(self, statistics): - if self._skip_noncritical: - failures = statistics.critical.failed - skipped = statistics.all.total - statistics.critical.total - else: - failures = statistics.all.failed - skipped = 0 - return str(statistics.all.total), str(failures), str(skipped) - - def end_suite(self, suite): - if suite is self._root_suite: - self._writer.end('testsuite') - - def visit_test(self, test): - self._writer.start('testcase', - {'classname': test.parent.longname, - 'name': test.name, - 'time': self._time_as_seconds(test.elapsedtime)}) - if self._skip_noncritical and not test.critical: - self._skip_test(test) - elif not test.passed: - self._fail_test(test) - self._writer.end('testcase') - - def _skip_test(self, test): - self._writer.element('skipped', '%s: %s' % (test.status, test.message) - if test.message else test.status) - - def _fail_test(self, test): - self._writer.element('failure', attrs={'message': test.message, - 'type': 'AssertionError'}) - - def _time_as_seconds(self, millis): - return '{:.3f}'.format(millis / 1000) + + def start_suite(self, suite: TestSuite): + stats = suite.statistics # Accessing property only once. + attrs = { + "name": suite.name, + "tests": str(stats.total), + "errors": "0", + "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: TestSuite): + if suite.metadata or suite.doc: + self._writer.start("properties") + if 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.end("properties") + self._writer.end("testsuite") + + def visit_test(self, test: TestCase): + attrs = { + "classname": test.parent.full_name, + "name": test.name, + "time": format(test.elapsed_time.total_seconds(), ".3f"), + } + self._writer.start("testcase", attrs) + if test.failed: + self._writer.element( + "failure", attrs={"message": test.message, "type": "AssertionError"} + ) + if test.skipped: + self._writer.element( + "skipped", attrs={"message": test.message, "type": "SkipExecution"} + ) + self._writer.end("testcase") def visit_keyword(self, kw): pass diff --git a/src/robot/result/__init__.py b/src/robot/result/__init__.py index faaef35d839..ce262b983fe 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:`~.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 ------- @@ -41,7 +37,29 @@ __ http://robotframework.org/robotframework/latest/RobotFrameworkUserGuide.html#listener-interface """ -from .executionresult import Result -from .model import Message, Keyword, TestCase, TestSuite -from .resultbuilder import ExecutionResult, ExecutionResultBuilder -from .visitor import ResultVisitor +from .executionresult import Result as Result +from .model import ( + Break as Break, + Continue as Continue, + Error as Error, + For as For, + ForIteration as ForIteration, + Group as Group, + If as If, + IfBranch as IfBranch, + Keyword as Keyword, + Message as Message, + Return as Return, + TestCase as TestCase, + TestSuite as TestSuite, + Try as Try, + TryBranch as TryBranch, + Var as Var, + While as While, + WhileIteration as WhileIteration, +) +from .resultbuilder import ( + ExecutionResult as ExecutionResult, + ExecutionResultBuilder as ExecutionResultBuilder, +) +from .visitor import ResultVisitor as ResultVisitor diff --git a/src/robot/result/configurer.py b/src/robot/result/configurer.py index 9250e775167..761443e666c 100644 --- a/src/robot/result/configurer.py +++ b/src/robot/result/configurer.py @@ -14,16 +14,15 @@ # limitations under the License. from robot import model -from robot.utils import is_string, secs_to_timestamp, timestamp_to_secs +from robot.utils import parse_timestamp class SuiteConfigurer(model.SuiteConfigurer): """Result suite configured. Calls suite's - :meth:`~robot.result.testsuite.TestSuite.remove_keywords`, - :meth:`~robot.result.testsuite.TestSuite.filter_messages` and - :meth:`~robot.result.testsuite.TestSuite.set_criticality` methods + :meth:`~robot.result.testsuite.TestSuite.remove_keywords` and + :meth:`~robot.result.testsuite.TestSuite.filter_messages` methods and sets its start and end time based on the given named parameters. ``base_config`` is forwarded to @@ -31,39 +30,40 @@ class SuiteConfigurer(model.SuiteConfigurer): that will do further configuration based on them. """ - def __init__(self, remove_keywords=None, log_level=None, start_time=None, - end_time=None, critical_tags=None, non_critical_tags=None, - **base_config): - model.SuiteConfigurer.__init__(self, **base_config) + def __init__( + self, + remove_keywords=None, + log_level=None, + start_time=None, + end_time=None, + **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.critical_tags = critical_tags - self.non_critical_tags = non_critical_tags + 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: return [] - if is_string(value): + if isinstance(value, str): 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=' :.-_') + return parse_timestamp(timestamp) except ValueError: return None - return secs_to_timestamp(secs, millis=True) def visit_suite(self, suite): - model.SuiteConfigurer.visit_suite(self, suite) + super().visit_suite(suite) self._remove_keywords(suite) self._set_times(suite) suite.filter_messages(self.log_level) - suite.set_criticality(self.critical_tags, self.non_critical_tags) def _remove_keywords(self, suite): for how in self.remove_keywords: @@ -71,6 +71,10 @@ def _remove_keywords(self, suite): def _set_times(self, suite): if self.start_time: - suite.starttime = 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.endtime = 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/executionerrors.py b/src/robot/result/executionerrors.py index 0446ae2cc36..dd3c0588e83 100644 --- a/src/robot/result/executionerrors.py +++ b/src/robot/result/executionerrors.py @@ -13,39 +13,45 @@ # See the License for the specific language governing permissions and # limitations under the License. -from robot.model import ItemList -from robot.utils import setter +from typing import Iterator, Sequence -from .model import Message +from robot.model import ItemList, Message +from robot.utils import setter -class ExecutionErrors(object): +class ExecutionErrors: """Represents errors occurred during the execution of tests. An error might be, for example, that importing a library has failed. """ - message_class = Message - def __init__(self, messages=None): - #: A :class:`list-like object ` of - #: :class:`~robot.model.message.Message` instances. + id = "errors" + + def __init__(self, messages: Sequence[Message] = ()): self.messages = messages @setter - def messages(self, msgs): - return ItemList(self.message_class, items=msgs) + def messages(self, messages) -> ItemList[Message]: + return ItemList(Message, {"parent": self}, items=messages) - def add(self, other): + def add(self, other: "ExecutionErrors"): self.messages.extend(other.messages) def visit(self, visitor): visitor.visit_errors(self) - def __iter__(self): + def __iter__(self) -> Iterator[Message]: return iter(self.messages) - def __len__(self): + def __len__(self) -> int: return len(self.messages) - def __getitem__(self, index): + def __getitem__(self, index) -> Message: return self.messages[index] + + def __str__(self) -> str: + 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/src/robot/result/executionresult.py b/src/robot/result/executionresult.py index 1fb5e3b18ba..6ef25d7214e 100644 --- a/src/robot/result/executionresult.py +++ b/src/robot/result/executionresult.py @@ -13,14 +13,44 @@ # See the License for the specific language governing permissions and # limitations under the License. +from datetime import datetime +from pathlib import Path +from typing import overload, Sequence, TextIO + from robot.errors import DataError -from robot.model import Statistics +from robot.model import Statistics, SuiteVisitor +from robot.utils import JsonDumper, JsonLoader, setter +from robot.version import get_full_version from .executionerrors import ExecutionErrors +from .flattenkeywordmatcher import Flattener from .model import TestSuite -class Result(object): +def is_json_source(source) -> bool: + if isinstance(source, bytes): + # ISO-8859-1 is most likely *not* the right encoding, but decoding bytes + # with it always succeeds and characters we care about ought to be correct + # at least if the right encoding is UTF-8 or any ISO-8859-x encoding. + source = source.decode("ISO-8859-1") + if isinstance(source, str): + source = source.strip() + first, last = (source[0], source[-1]) if source else ("", "") + if (first, last) == ("{", "}"): + return True + if (first, last) == ("<", ">"): + return False + path = Path(source) + elif isinstance(source, Path): + path = source + elif hasattr(source, "name") and isinstance(source.name, str): + path = Path(source.name) + else: + return False + return bool(path and path.suffix.lower() == ".json") + + +class Result: """Test execution results. Can be created based on XML output files using the @@ -30,27 +60,48 @@ class Result(object): method. """ - def __init__(self, source=None, root_suite=None, errors=None, rpa=None): - #: Path to the XML file where results are read from. - self.source = source - #: Hierarchical execution results as a - #: :class:`~.result.model.TestSuite` object. - self.suite = root_suite or TestSuite() - #: Execution errors as an - #: :class:`~.executionerrors.ExecutionErrors` object. + def __init__( + self, + source: "Path | str | None" = None, + suite: "TestSuite | None" = None, + errors: "ExecutionErrors | None" = None, + rpa: "bool | None" = None, + generator: str = "unknown", + generation_time: "datetime | str | None" = None, + ): + self.source = Path(source) if isinstance(source, str) else source + self.suite = suite or TestSuite() self.errors = errors or ExecutionErrors() - self.generated_by_robot = True + self.rpa = rpa + self.generator = generator + self.generation_time = generation_time self._status_rc = True self._stat_config = {} - self.rpa = rpa + + @setter + def rpa(self, rpa: "bool | None") -> "bool | None": + if rpa is not None: + self._set_suite_rpa(self.suite, rpa) + return rpa + + def _set_suite_rpa(self, suite, rpa): + suite.rpa = rpa + for child in suite.suites: + self._set_suite_rpa(child, rpa) + + @setter + def generation_time(self, timestamp: "datetime | str | None") -> "datetime | None": + if datetime is None: + return None + if isinstance(timestamp, str): + return datetime.fromisoformat(timestamp) + return timestamp @property - def statistics(self): - """Test execution statistics. + def statistics(self) -> Statistics: + """Execution statistics. - Statistics are an instance of - :class:`~robot.model.statistics.Statistics` that is created based - on the contained ``suite`` and possible + Statistics are created based on the contained ``suite`` and possible :func:`configuration `. Statistics are created every time this property is accessed. Saving @@ -63,23 +114,27 @@ def statistics(self): result.configure(stat_config={'suite_stat_level': 2, 'tag_stat_combine': 'tagANDanother'}) stats = result.statistics - print stats.total.critical.failed - print stats.total.critical.passed - print stats.tags.combined[0].total + print(stats.total.failed) + print(stats.total.passed) + print(stats.tags.combined[0].total) """ return Statistics(self.suite, rpa=self.rpa, **self._stat_config) @property - def return_code(self): - """Return code (integer) of test execution. + def return_code(self) -> int: + """Execution return code. - By default returns the number of failed critical tests (max 250), + By default, returns the number of failed tests or tasks (max 250), but can be :func:`configured ` to always return 0. """ if self._status_rc: - return min(self.suite.statistics.critical.failed, 250) + return min(self.suite.statistics.failed, 250) return 0 + @property + def generated_by_robot(self) -> bool: + return self.generator.split()[0].upper() == "ROBOT" + def configure(self, status_rc=True, suite_config=None, stat_config=None): """Configures the result object and objects it contains. @@ -96,14 +151,191 @@ def configure(self, status_rc=True, suite_config=None, stat_config=None): self._status_rc = status_rc self._stat_config = stat_config or {} - def save(self, path=None): - """Save results as a new output XML file. + @classmethod + def from_json( + cls, + source: "str | bytes | TextIO | Path", + include_keywords: bool = True, + flattened_keywords: Sequence[str] = (), + rpa: "bool | None" = None, + ) -> "Result": + """Construct a result object from JSON data. + + :param source: JSON data as a string or bytes containing the data + directly, an open file object where to read the data from, or a path + (``pathlib.Path`` or string) to a UTF-8 encoded file to read. + :param include_keywords: When ``False``, keyword and control structure + information is not parsed. This can save considerable amount of time + and memory. New in RF 7.3.2. + :param flattened_keywords: List of patterns controlling what keywords + and control structures to flatten. See the documentation of + the ``--flattenkeywords`` option for more details. New in RF 7.3.2. + :param rpa: Setting ``rpa`` either to ``True`` (RPA mode) or ``False`` + (test automation) sets the execution mode explicitly. By default, + the mode is got from the parsed data. + :returns: :class:`Result` instance. + + The data can contain either: + + - full result data (contains suite information, execution errors, etc.) + got, for example, from the :meth:`to_json` method, or + - only suite information got, for example, from + :meth:`result.testsuite.TestSuite.to_json `. + + :attr:`statistics` are populated automatically based on suite information + and thus ignored if they are present in the data. + + New in Robot Framework 7.2. + """ + loader = cls._get_json_loader(include_keywords) + try: + data = loader.load(source) + except (TypeError, ValueError) as err: + raise DataError(f"Loading JSON data failed: {err}") + if "suite" in data: + result = cls._from_full_json(data) + else: + result = cls._from_suite_json(data) + result.rpa = data.get("rpa", False) if rpa is None else rpa + if isinstance(source, Path): + result.source = source + elif isinstance(source, str) and source[0] != "{" and Path(source).exists(): + result.source = Path(source) + result.handle_suite_teardown_failures() + if not include_keywords: + result.suite.visit(KeywordRemover()) + if flattened_keywords: + result.suite.visit(Flattener(flattened_keywords)) + return result + + @classmethod + def _get_json_loader(cls, include_keywords: bool) -> JsonLoader: + if include_keywords: + return JsonLoader() + + def remove_keywords(obj): + obj.pop("body", None) + obj.pop("setup", None) + # Teardowns cannot be removed yet, because we need to check suite + # teardown status. They are removed later using KeywordRemover. + return obj + + return JsonLoader(object_hook=remove_keywords) + + @classmethod + def _from_full_json(cls, data) -> "Result": + return Result( + suite=TestSuite.from_dict(data["suite"]), + errors=ExecutionErrors(data.get("errors")), + generator=data.get("generator"), + generation_time=data.get("generated"), + ) - :param path: Path to save results to. If omitted, overwrites the - original file. + @classmethod + def _from_suite_json(cls, data) -> "Result": + return Result(suite=TestSuite.from_dict(data)) + + @overload + def to_json( + self, + file: None = None, + *, + include_statistics: bool = True, + ensure_ascii: bool = False, + indent: int = 0, + separators: "tuple[str, str]" = (",", ":"), + ) -> str: ... + + @overload + def to_json( + self, + file: "TextIO | Path | str", + *, + include_statistics: bool = True, + ensure_ascii: bool = False, + indent: int = 0, + separators: "tuple[str, str]" = (",", ":"), + ) -> None: ... + + def to_json( + self, + file: "None | TextIO | Path | str" = None, + *, + include_statistics: bool = True, + ensure_ascii: bool = False, + indent: int = 0, + separators: "tuple[str, str]" = (",", ":"), + ) -> "str | None": + """Serialize results into 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 to, or + - a path (``pathlib.Path`` or string) to a file where to write + the data using UTF-8 encoding. + + The ``include_statistics`` controls including statistics information + in the resulting JSON data. Statistics are not needed if the serialized + JSON data is converted back to a ``Result`` object, but they can be + useful for external tools. + + The remaining optional parameters are used for JSON formatting. + They are passed directly to the underlying json__ module, but + the defaults differ from what ``json`` uses. + + New in Robot Framework 7.2. + + __ https://docs.python.org/3/library/json.html + """ + data = { + "generator": get_full_version("Rebot"), + "generated": datetime.now().isoformat(), + "rpa": self.rpa, + "suite": self.suite.to_dict(), + } + if include_statistics: + data["statistics"] = self.statistics.to_dict() + data["errors"] = self.errors.messages.to_dicts() + return JsonDumper( + ensure_ascii=ensure_ascii, + indent=indent, + separators=separators, + ).dump(data, file) + + def save(self, target=None, legacy_output=False): + """Save results as XML or JSON file. + + :param target: Target where to save results to. Can be a path + (``pathlib.Path`` or ``str``) or an open file object. If omitted, + uses the :attr:`source` which overwrites the original file. + :param legacy_output: Save XML results in Robot Framework 6.x compatible + format. New in Robot Framework 7.0. + + File type is got based on the ``target``. The type is JSON if the ``target`` + is a path that has a ``.json`` suffix or if it is an open file that has + a ``name`` attribute with a ``.json`` suffix. Otherwise, the type is XML. + + It is also possible to use :meth:`to_json` for JSON serialization. Compared + to this method, it allows returning the JSON in addition to writing it + into a file, and it also supports customizing JSON formatting. + + Support for saving results in JSON is new in Robot Framework 7.0. + Originally only suite information was saved in that case, but starting + from Robot Framework 7.2, also JSON results contain full result data + including, for example, execution errors and statistics. """ - from robot.reporting.outputwriter import OutputWriter - self.visit(OutputWriter(path or self.source, rpa=self.rpa)) + from robot.reporting.outputwriter import LegacyOutputWriter, OutputWriter + + target = target or self.source + if not target: + raise ValueError("Path required.") + if is_json_source(target): + self.to_json(target) + else: + writer = OutputWriter if not legacy_output else LegacyOutputWriter + self.visit(writer(target, rpa=self.rpa)) def visit(self, visitor): """An entry point to visit the whole result object. @@ -131,18 +363,19 @@ def set_execution_mode(self, other): elif self.rpa is None: self.rpa = other.rpa elif self.rpa is not other.rpa: - this, that = ('task', 'test') if other.rpa else ('test', 'task') - raise DataError("Conflicting execution modes. File '%s' has %ss " - "but files parsed earlier have %ss. Use '--rpa' " - "or '--norpa' options to set the execution mode " - "explicitly." % (other.source, this, that)) + this, that = ("task", "test") if other.rpa else ("test", "task") + raise DataError( + f"Conflicting execution modes. File '{other.source}' has {this}s " + f"but files parsed earlier have {that}s. Use '--rpa' or '--norpa' " + f"options to set the execution mode explicitly." + ) class CombinedResult(Result): """Combined results of multiple test executions.""" def __init__(self, results=None): - Result.__init__(self) + super().__init__() for result in results or (): self.add_result(result) @@ -150,3 +383,13 @@ def add_result(self, other): self.set_execution_mode(other) self.suite.suites.append(other.suite) self.errors.add(other.errors) + + +class KeywordRemover(SuiteVisitor): + + def start_suite(self, suite): + suite.setup = suite.teardown = None + + def visit_test(self, test): + test.setup = test.teardown = None + test.body = [] diff --git a/src/robot/result/flattenkeywordmatcher.py b/src/robot/result/flattenkeywordmatcher.py index d1e2352c8c1..f2239f28243 100644 --- a/src/robot/result/flattenkeywordmatcher.py +++ b/src/robot/result/flattenkeywordmatcher.py @@ -14,64 +14,147 @@ # limitations under the License. from robot.errors import DataError -from robot.model import TagPatterns -from robot.utils import MultiMatcher, is_list_like, py2to3 +from robot.model import SuiteVisitor, TagPatterns +from robot.utils import html_escape, MultiMatcher + +from .model import Keyword def validate_flatten_keyword(options): for opt in options: low = opt.lower() - if not (low in ('for', 'foritem') or - low.startswith('name:') or - low.startswith('tag:')): - raise DataError("Expected 'FOR', 'FORITEM', 'TAG:', or " - "'NAME:' but got '%s'." % opt) - - -@py2to3 -class FlattenByTypeMatcher(object): + # TODO: Deprecate 'foritem' in RF 7.4! + if low == "foritem": + low = "iteration" + if not ( + low in ("for", "while", "iteration") or low.startswith(("name:", "tag:")) + ): + raise DataError( + f"Expected 'FOR', 'WHILE', 'ITERATION', 'TAG:' or " + f"'NAME:', got '{opt}'." + ) + + +def create_flatten_message(original): + if not original: + start = "" + elif original.startswith("*HTML*"): + start = original[6:].strip() + "
" + else: + start = html_escape(original) + "
" + return f'*HTML* {start}Content flattened.' + + +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 = [f for f in flatten if f in ('for', 'foritem')] + self.types = set() + if "for" in flatten: + self.types.add("for") + if "while" in flatten: + self.types.add("while") + if "iteration" in flatten or "foritem" in flatten: + self.types.add("iter") # Matches output.xml tag. + self.types.add("iteration") # Matches model object type. - def match(self, kwtype): - return kwtype in self._types + def match(self, tag): + return tag in self.types - def __nonzero__(self): - return bool(self._types) + def __bool__(self): + return bool(self.types) -@py2to3 -class FlattenByNameMatcher(object): +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:'] + 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 __nonzero__(self): + def __bool__(self): return bool(self._matcher) -@py2to3 -class FlattenByTagMatcher(object): +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) + patterns = [p[4:] for p in flatten if p[:4].lower() == "tag:"] + self._matcher = TagPatterns(patterns, "finding flattened keywords by tags") - def match(self, kwtags): - return self._matcher.match(kwtags) + def match(self, tags): + return self._matcher.match(tags) - def __nonzero__(self): + def __bool__(self): return bool(self._matcher) + + +class Flattener(SuiteVisitor): + + def __init__(self, flatten): + self.name_matcher = FlattenByNameMatcher(flatten) + self.tag_matcher = FlattenByTagMatcher(flatten) + self.type_matcher = FlattenByTypeMatcher(flatten) + + def start_suite(self, suite): + return bool(self) + + def start_keyword(self, kw): + if self.name_matcher and self.name_matcher.match(kw.name, kw.owner): + self._flatten(kw) + if self.tag_matcher and self.tag_matcher.match(kw.tags): + self._flatten(kw) + + def start_body_item(self, item): + if self.type_matcher and self.type_matcher.match(item.type.lower()): + self._flatten(item) + + def _flatten(self, item): + item.message = create_flatten_message(item.message) + item.body = MessageFinder(item).messages + + def __bool__(self): + return bool(self.name_matcher or self.tag_matcher or self.type_matcher) + + +class FlattenByTags(SuiteVisitor): + + def __init__(self, flatten): + if isinstance(flatten, str): + flatten = [flatten] + patterns = [p[4:] for p in flatten if p[:4].lower() == "tag:"] + self.matcher = TagPatterns(patterns, "finding flattened keywords by tags") + + def start_suite(self, suite): + return bool(self.matcher) + + def start_keyword(self, keyword: Keyword): + if self.matcher.match(keyword.tags): + keyword.message = create_flatten_message(keyword.message) + keyword.body = MessageFinder(keyword).messages + + +class MessageFinder(SuiteVisitor): + + def __init__(self, keyword: Keyword): + self.messages = [] + keyword.visit(self) + + def visit_message(self, message): + self.messages.append(message) + + +# TODO: Refactor this module! +# - Currently code working with XML tags and model objects is somewhat messy. +# Either separate it better or make it more generic. +# - MessageFinder API is not that nice. +# - The module doesn't anymore contain only matchers so it should be renamed. diff --git a/src/robot/result/keywordremover.py b/src/robot/result/keywordremover.py index 2b9f15794e3..f3f2f0778b7 100644 --- a/src/robot/result/keywordremover.py +++ b/src/robot/result/keywordremover.py @@ -13,37 +13,44 @@ # 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 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, - 'WUKS': WaitUntilKeywordSucceedsRemover}[upper]() - except KeyError: - raise DataError("Expected 'ALL', 'PASSED', 'NAME:', 'TAG:', " - "'FOR', or 'WUKS' but got '%s'." % how) +from robot.utils import html_escape, Matcher, plural_or_not -class _KeywordRemover(SuiteVisitor): - _message = 'Keyword 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) - - def _clear_content(self, kw): - kw.keywords = [] - kw.messages = [] - self._removal_message.set(kw) + 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): + 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) @@ -54,81 +61,115 @@ def _warning_or_error(self, item): return finder.found -class AllKeywordsRemover(_KeywordRemover): +class AllKeywordsRemover(KeywordRemover): - def visit_keyword(self, keyword): - self._clear_content(keyword) + def start_test(self, test): + test.body = test.body.filter(messages=False) + + def start_body_item(self, item): + self._clear_content(item) + + def start_if(self, item): + pass + + def start_if_branch(self, item): + self._clear_content(item) + + def start_try(self, item): + pass + def start_try_branch(self, item): + self._clear_content(item) -class PassedKeywordRemover(_KeywordRemover): + +class PassedKeywordRemover(KeywordRemover): def start_suite(self, suite): - if not suite.statistics.all.failed: - for keyword in suite.keywords: - if not self._warning_or_error(keyword): - self._clear_content(keyword) + if not suite.failed: + self._remove_setup_and_teardown(suite) def visit_test(self, test): if not self._failed_or_warning_or_error(test): - for keyword in test.keywords: - self._clear_content(keyword) + test.body = test.body.filter(messages=False) + for item in test.body: + self._clear_content(item) + self._remove_setup_and_teardown(test) def visit_keyword(self, keyword): pass + def _remove_setup_and_teardown(self, item): + if item.has_setup and not self._warning_or_error(item.setup): + self._clear_content(item.setup) + if item.has_teardown and not self._warning_or_error(item.teardown): + self._clear_content(item.teardown) + -class ByNameKeywordRemover(_KeywordRemover): +class ByNameKeywordRemover(KeywordRemover): def __init__(self, pattern): - _KeywordRemover.__init__(self) - self._matcher = Matcher(pattern, ignore='_') + 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): +class ByTagKeywordRemover(KeywordRemover): def __init__(self, pattern): - _KeywordRemover.__init__(self) - self._pattern = TagPattern(pattern) + super().__init__() + self._pattern = TagPattern.from_string(pattern) def start_keyword(self, kw): if self._pattern.match(kw.tags) and not self._warning_or_error(kw): self._clear_content(kw) -class ForLoopItemsRemover(_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 start_keyword(self, kw): - if kw.type == kw.FOR_LOOP_TYPE: - before = len(kw.keywords) - kw.keywords = self._remove_keywords(kw.keywords) - self._removal_message.set_if_removed(kw, before) + def _remove_from_loop(self, loop): + before = len(loop.body) + self._remove_keywords(loop.body) + self.removal_message.set_to_if_removed(loop, before) - def _remove_keywords(self, keywords): - return [kw for kw in keywords - if self._failed_or_warning_or_error(kw) or kw is keywords[-1]] + def _remove_keywords(self, body): + iterations = body.filter(messages=False) + for it in iterations[:-1]: + if not self._failed_or_warning_or_error(it): + body.remove(it) -class WaitUntilKeywordSucceedsRemover(_KeywordRemover): - _message = '%d failing step%s removed using --RemoveKeywords option.' +class ForLoopItemsRemover(LoopItemsRemover): - def start_keyword(self, kw): - if kw.name == 'BuiltIn.Wait Until Keyword Succeeds' and kw.keywords: - before = len(kw.keywords) - kw.keywords = self._remove_keywords(list(kw.keywords)) - self._removal_message.set_if_removed(kw, before) + def start_for(self, for_): + self._remove_from_loop(for_) + + +class WhileLoopItemsRemover(LoopItemsRemover): - def _remove_keywords(self, keywords): - include_from_end = 2 if keywords[-1].passed else 1 - return self._kws_with_warnings(keywords[:-include_from_end]) \ - + keywords[-include_from_end:] + def start_while(self, while_): + self._remove_from_loop(while_) - def _kws_with_warnings(self, keywords): - return [kw for kw in keywords if self._warning_or_error(kw)] + +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_to_if_removed(kw, before) + + def _remove_keywords(self, body): + keywords = body.filter(keywords=True) + if keywords: + include_from_end = 2 if keywords[-1].passed else 1 + for kw in keywords[:-include_from_end]: + if not self._warning_or_error(kw): + body.remove(kw) class WarningAndErrorFinder(SuiteVisitor): @@ -146,19 +187,27 @@ def start_keyword(self, keyword): return not self.found def visit_message(self, msg): - if msg.level in ('WARN', 'ERROR'): + if msg.level in ("WARN", "ERROR"): self.found = True -class RemovalMessage(object): +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.keywords) + 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))) - - def set(self, kw, message=None): - kw.doc = ('%s\n\n_%s_' % (kw.doc, message or self._message)).strip() + message = self.message.format(count=removed, s=plural_or_not(removed)) + self.set_to(item, message) + + 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) + "
" + message = message or self.message + item.message = f'*HTML* {start}{message}' diff --git a/src/robot/result/merger.py b/src/robot/result/merger.py index 8f1a46852bc..320f3530cf2 100644 --- a/src/robot/result/merger.py +++ b/src/robot/result/merger.py @@ -15,14 +15,15 @@ from robot.errors import DataError from robot.model import SuiteVisitor -from robot.utils import html_escape +from robot.utils import html_escape, test_or_task class Merger(SuiteVisitor): - def __init__(self, result): + def __init__(self, result, rpa=False): self.result = result self.current = None + self.rpa = rpa def merge(self, merged): self.result.set_execution_mode(merged) @@ -30,70 +31,111 @@ def merge(self, merged): self.result.errors.add(merged.errors) def start_suite(self, suite): - try: - self.current = self._find_suite(self.current, suite.name) - except IndexError: - suite.message = self._create_add_message(suite, test=False) - self.current.suites.append(suite) - return False - - def _find_suite(self, parent, name): - if not parent: - suite = self._find_root(name) + if self.current is None: + old = self._find_root(suite.name) + else: + old = self._find(self.current.suites, suite.name) + if old is not None: + old.start_time = old.end_time = old.elapsed_time = None + old.doc = suite.doc + old.metadata.update(suite.metadata) + old.setup = suite.setup + old.teardown = suite.teardown + self.current = old else: - suite = self._find(parent.suites, name) - suite.starttime = suite.endtime = None - return suite + suite.message = self._create_add_message(suite, suite=True) + self.current.suites.append(suite) + return old is not None 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): for item in items: if item.name == name: return item - raise IndexError + return None def end_suite(self, suite): self.current = self.current.parent def visit_test(self, test): - try: - old = self._find(self.current.tests, test.name) - except IndexError: + old = self._find(self.current.tests, test.name) + if old is None: test.message = self._create_add_message(test) self.current.tests.append(test) + elif test.skipped: + old.message = self._create_skip_message(old, test) else: test.message = self._create_merge_message(test, old) index = self.current.tests.index(old) self.current.tests[index] = test - def _create_add_message(self, item, test=True): - prefix = ('*HTML* %s added from merged output.' - % ('Test' if test else 'Suite')) + def _create_add_message(self, item, suite=False): + 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, '
', self._html_escape(item.message)]) + return "".join([prefix, "
", self._html(item.message)]) - def _html_escape(self, message): - if message.startswith('*HTML*'): + def _html(self, message): + if message.startswith("*HTML*"): return message[6:].lstrip() - else: - return html_escape(message) + return html_escape(message) def _create_merge_message(self, new, old): - return ''.join([ - '*HTML* Re-executed test has been merged.
', - 'New status: %s
' % self._format_status(new.status), - 'New message: %s
' % self._html_escape(new.message), - 'Old status: %s
' % self._format_status(old.status), - 'Old message: %s' % self._html_escape(old.message) - ]) - - def _format_status(self, status): - return '%s' % (status.lower(), status) + header = ( + f'*HTML* {test_or_task("Test", self.rpa)} ' + f"has been re-executed and results merged." + ) + parts = [ + header, + "
", + self._format_status_and_message("New", new), + "
", + self._format_old_status_and_message(old, header), + ] + return "".join(parts) + + def _format_status_and_message(self, state, test): + msg = f"{self._status_header(state)} {self._status_text(test.status)}
" + if test.message: + msg += f"{self._message_header(state)} {self._html(test.message)}
" + return msg + + def _status_header(self, state): + return f'{state} status:' + + def _status_text(self, status): + return f'{status}' + + def _message_header(self, state): + return f'{state} message:' + + def _format_old_status_and_message(self, test, merge_header): + if not test.message.startswith(merge_header): + return self._format_status_and_message("Old", test) + status_and_message = test.message.split("
", 1)[1] + return status_and_message.replace( + self._status_header("New"), + self._status_header("Old"), + ).replace( + self._message_header("New"), + self._message_header("Old"), + ) + + def _create_skip_message(self, test, new): + msg = ( + f"*HTML* {test_or_task('Test', self.rpa)} has been re-executed and " + f"results merged. Latter result had {self._status_text('SKIP')} " + f"status and was ignored. Message:\n{self._html(new.message)}" + ) + if test.message: + msg += f"
Original message:\n{self._html(test.message)}" + return msg diff --git a/src/robot/result/messagefilter.py b/src/robot/result/messagefilter.py index 97838ba34c0..8a5fafcaea8 100644 --- a/src/robot/result/messagefilter.py +++ b/src/robot/result/messagefilter.py @@ -13,16 +13,24 @@ # See the License for the specific language governing permissions and # limitations under the License. -from robot.output.loggerhelper import IsLogged +from robot import output -from robot.model import SuiteVisitor +from .visitor import ResultVisitor -class MessageFilter(SuiteVisitor): +class MessageFilter(ResultVisitor): - def __init__(self, loglevel): - self._is_logged = IsLogged(loglevel or 'TRACE') + def __init__(self, level="TRACE"): + log_level = output.LogLevel(level or "TRACE") + self.log_all = log_level.level == "TRACE" + self.is_logged = log_level.is_logged - def start_keyword(self, keyword): - keyword.messages = [msg for msg in keyword.messages - if self._is_logged(msg.level)] + def start_suite(self, suite): + if self.log_all: + return False + + def start_body_item(self, item): + if hasattr(item, "body"): + for msg in item.body.filter(messages=True): + if not self.is_logged(msg): + item.body.remove(msg) diff --git a/src/robot/result/model.py b/src/robot/result/model.py index 35cb8bb5363..a82e020ac15 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,247 +27,1147 @@ by custom scripts and tools. In such usage it is often easiest to inspect and modify these objects using the :mod:`visitor interface `. +If classes defined here are needed, for example, as type hints, they can +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 - """ -from itertools import chain +from datetime import datetime, timedelta +from io import StringIO +from pathlib import Path +from typing import Final, Literal, Mapping, overload, Sequence, TextIO, TypeVar, Union -from robot.model import TotalStatisticsBuilder, Criticality -from robot import model, utils +from robot import model +from robot.model import ( + BodyItem, create_fixture, DataDict, Tags, TestSuites, TotalStatistics, + TotalStatisticsBuilder +) +from robot.utils import setter from .configurer import SuiteConfigurer -from .messagefilter import MessageFilter from .keywordremover import KeywordRemover -from .suiteteardownfailed import (SuiteTeardownFailureHandler, - SuiteTeardownFailed) +from .messagefilter import MessageFilter +from .modeldeprecation import DeprecatedAttributesMixin +from .suiteteardownfailed import SuiteTeardownFailed, SuiteTeardownFailureHandler + +IT = TypeVar("IT", bound="IfBranch | TryBranch") +FW = TypeVar("FW", bound="ForIteration | WhileIteration") +BodyItemParent = Union[ + "TestSuite", "TestCase", "Keyword", "For", "ForIteration", "If", "IfBranch", + "Try", "TryBranch", "While", "WhileIteration", "Group", None +] # fmt: skip + + +class Body(model.BaseBody[ + "Keyword", "For", "While", "Group", "If", "Try", "Var", "Return", "Continue", + "Break", "Message", "Error" +]): # fmt: skip + __slots__ = () + + +class Branches(model.BaseBranches[ + "Keyword", "For", "While", "Group", "If", "Try", "Var", "Return", "Continue", + "Break", "Message", "Error", IT +]): # fmt: skip + __slots__ = () -# TODO: Should remove model.Message altogether and just implement the whole -# thing here. Additionally model.Keyword should not have `message_class` at -# all or it should be None. +class Iterations(model.BaseIterations[ + "Keyword", "For", "While", "Group", "If", "Try", "Var", "Return", "Continue", + "Break", "Message", "Error", FW +]): # fmt: skip + __slots__ = () + +@Body.register +@Branches.register +@Iterations.register class Message(model.Message): - """Represents a single log message. + __slots__ = () - See the base class for documentation of attributes not documented here. - """ - __slots__ = [] + def to_dict(self, include_type: bool = True) -> DataDict: + if not include_type: + return super().to_dict() + return {"type": self.type, **super().to_dict()} -class Keyword(model.Keyword): - """Represents results of a single keyword. +class StatusMixin: + PASS: Final = "PASS" + FAIL: Final = "FAIL" + SKIP: Final = "SKIP" + NOT_RUN: Final = "NOT RUN" + NOT_SET: Final = "NOT SET" + status: Literal["PASS", "FAIL", "SKIP", "NOT RUN", "NOT SET"] + __slots__ = () - See the base class for documentation of attributes not documented here. - """ - __slots__ = ['kwname', 'libname', 'status', 'starttime', 'endtime', 'message'] - message_class = Message - - def __init__(self, kwname='', libname='', doc='', args=(), assign=(), - tags=(), timeout=None, type='kw', status='FAIL', - starttime=None, endtime=None): - model.Keyword.__init__(self, '', doc, args, assign, tags, timeout, type) - #: Name of the keyword without library or resource name. - self.kwname = kwname or '' - #: Name of the library or resource containing this keyword. - self.libname = libname or '' - #: Execution status as a string. Typically ``PASS`` or ``FAIL``, but - #: library keywords have status ``NOT_RUN`` in the dry-ryn mode. - #: See also :attr:`passed`. - 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 = '' + @property + 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 + + @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. + """ + 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``. + + 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. + + 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. + """ + 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 + 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 + 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 elapsedtime(self): - """Total execution time in milliseconds.""" - return utils.get_elapsed_time(self.starttime, self.endtime) + def starttime(self) -> "str | None": + """Execution start time as a string or as a ``None`` if not set. + + The string format is ``%Y%m%d %H:%M:%S.%f``. + + Considered deprecated starting from Robot Framework 7.0. + :attr:`start_time` should be used instead. + """ + return self._datetime_to_timestr(self.start_time) + + @starttime.setter + def starttime(self, starttime: "str | None"): + self.start_time = self._timestr_to_datetime(starttime) @property - def name(self): - """Keyword name in format ``libname.kwname``. + def endtime(self) -> "str | None": + """Execution end time as a string or as a ``None`` if not set. + + The string format is ``%Y%m%d %H:%M:%S.%f``. + + Considered deprecated starting from Robot Framework 7.0. + :attr:`end_time` should be used instead. + """ + return self._datetime_to_timestr(self.end_time) + + @endtime.setter + def endtime(self, endtime: "str | None"): + self.end_time = self._timestr_to_datetime(endtime) - 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. + @property + def elapsedtime(self) -> int: + """Total execution time in milliseconds. - Cannot be set directly. Set :attr:`libname` and :attr:`kwname` - separately instead. + Considered deprecated starting from Robot Framework 7.0. + :attr:`elapsed_time` should be used instead. """ - if not self.libname: - return self.kwname - return '%s.%s' % (self.libname, self.kwname) + return round(self.elapsed_time.total_seconds() * 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 + return dt.isoformat(" ", timespec="milliseconds").replace("-", "") @property - def passed(self): - """``True`` or ``False`` depending on the :attr:`status`.""" - return self.status == 'PASS' + def passed(self) -> bool: + """``True`` when :attr:`status` is 'PASS', ``False`` otherwise.""" + return self.status == self.PASS @passed.setter - def passed(self, passed): - self.status = 'PASS' if passed else 'FAIL' + def passed(self, passed: bool): + self.status = self.PASS if passed else self.FAIL + @property + def failed(self) -> bool: + """``True`` when :attr:`status` is 'FAIL', ``False`` otherwise.""" + return self.status == self.FAIL -class TestCase(model.TestCase): - """Represents results of a single test case. + @failed.setter + def failed(self, failed: bool): + self.status = self.FAIL if failed else self.PASS + + @property + def skipped(self) -> bool: + """``True`` when :attr:`status` is 'SKIP', ``False`` otherwise. + + Setting to ``False`` value is ambiguous and raises an exception. + """ + return self.status == self.SKIP + + @skipped.setter + def skipped(self, skipped: Literal[True]): + if not skipped: + raise ValueError(f"`skipped` value must be truthy, got '{skipped}'.") + self.status = self.SKIP + + @property + def not_run(self) -> bool: + """``True`` when :attr:`status` is 'NOT RUN', ``False`` otherwise. + + Setting to ``False`` value is ambiguous and raises an exception. + """ + return self.status == self.NOT_RUN + + @not_run.setter + 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 + + def to_dict(self): + data = { + "status": self.status, + "elapsed_time": self.elapsed_time.total_seconds(), + } + if self.start_time: + data["start_time"] = self.start_time.isoformat() + if self.message: + data["message"] = self.message + return data - See the base class for documentation of attributes not documented here. - """ - __slots__ = ['status', 'message', 'starttime', 'endtime'] - keyword_class = Keyword - def __init__(self, name='', doc='', tags=None, timeout=None, status='FAIL', - message='', starttime=None, endtime=None): - model.TestCase.__init__(self, name, doc, tags, timeout) - #: Status as a string ``PASS`` or ``FAIL``. See also :attr:`passed`. +class ForIteration(model.ForIteration, StatusMixin, DeprecatedAttributesMixin): + body_class = Body + __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, + parent: BodyItemParent = None, + ): + super().__init__(assign, parent) self.status = status - #: 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 to_dict(self) -> DataDict: + return {**super().to_dict(), **StatusMixin.to_dict(self)} + + +@Body.register +class For(model.For, StatusMixin, DeprecatedAttributesMixin): + iteration_class = ForIteration + iterations_class = Iterations[iteration_class] + __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", + values: Sequence[str] = (), + start: "str | None" = None, + 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, + 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 + + @setter + def body(self, iterations: "Sequence[ForIteration | DataDict]") -> iterations_class: + return self.iterations_class(self.iteration_class, self, iterations) @property - def elapsedtime(self): - """Total execution time in milliseconds.""" - return utils.get_elapsed_time(self.starttime, self.endtime) + def _log_name(self): + return str(self)[7:] # Drop 'FOR ' prefix. + + def to_dict(self) -> DataDict: + return {**super().to_dict(), **StatusMixin.to_dict(self)} + + +class WhileIteration(model.WhileIteration, StatusMixin, DeprecatedAttributesMixin): + body_class = Body + __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, + 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 + + def to_dict(self) -> DataDict: + return {**super().to_dict(), **StatusMixin.to_dict(self)} + + +@Body.register +class While(model.While, StatusMixin, DeprecatedAttributesMixin): + iteration_class = WhileIteration + iterations_class = Iterations[iteration_class] + __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, + 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 + + @setter + def body( + self, iterations: "Sequence[WhileIteration | DataDict]" + ) -> iterations_class: + return self.iterations_class(self.iteration_class, self, iterations) @property - def passed(self): - """``True/False`` depending on the :attr:`status`.""" - return self.status == 'PASS' + def _log_name(self): + return str(self)[9:] # Drop 'WHILE ' prefix. - @passed.setter - def passed(self, passed): - self.status = 'PASS' if passed else 'FAIL' + def to_dict(self) -> DataDict: + return {**super().to_dict(), **StatusMixin.to_dict(self)} + + +@Body.register +class Group(model.Group, StatusMixin, DeprecatedAttributesMixin): + body_class = Body + __slots__ = ("status", "message", "_start_time", "_end_time", "_elapsed_time") + + def __init__( + self, + name: 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__(name, parent) + self.status = status + self.message = message + self.start_time = start_time + self.end_time = end_time + self.elapsed_time = elapsed_time + + @property + def _log_name(self): + return self.name + + def to_dict(self) -> DataDict: + return {**super().to_dict(), **StatusMixin.to_dict(self)} + + +class IfBranch(model.IfBranch, StatusMixin, DeprecatedAttributesMixin): + body_class = Body + __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, + 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 + + @property + def _log_name(self): + return self.condition or "" + + def to_dict(self) -> DataDict: + return {**super().to_dict(), **StatusMixin.to_dict(self)} + + +@Body.register +class If(model.If, StatusMixin, DeprecatedAttributesMixin): + branch_class = IfBranch + branches_class = Branches[branch_class] + __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, + 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 + + def to_dict(self) -> DataDict: + return {**super().to_dict(), **StatusMixin.to_dict(self)} + + +class TryBranch(model.TryBranch, StatusMixin, DeprecatedAttributesMixin): + body_class = Body + __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, + 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 + + @property + def _log_name(self): + return str(self)[len(self.type) + 4 :] # Drop ' ' prefix. + + def to_dict(self) -> DataDict: + return {**super().to_dict(), **StatusMixin.to_dict(self)} + + +@Body.register +class Try(model.Try, StatusMixin, DeprecatedAttributesMixin): + branch_class = TryBranch + branches_class = Branches[branch_class] + __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, + 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 + + def to_dict(self) -> DataDict: + return {**super().to_dict(), **StatusMixin.to_dict(self)} + + +@Body.register +class Var(model.Var, StatusMixin, DeprecatedAttributesMixin): + body_class = Body + __slots__ = ("status", "message", "_start_time", "_end_time", "_elapsed_time") + + 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 messages and possible other constructs. + + Contains the message logged about assignment. Contains something else + only if running VAR has failed due to a syntax error or listeners have + logged messages or executed keywords. + """ + return self.body_class(self, body) + + @property + def _log_name(self): + return str(self)[7:] # Drop 'VAR ' prefix. + + def to_dict(self) -> DataDict: + data = {**super().to_dict(), **StatusMixin.to_dict(self)} + if self.body: + data["body"] = self.body.to_dicts() + return data + + +@Body.register +class Return(model.Return, StatusMixin, DeprecatedAttributesMixin): + body_class = Body + __slots__ = ("status", "message", "_start_time", "_end_time", "_elapsed_time") + + 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 + self.body = () + + @setter + def body(self, body: "Sequence[BodyItem | DataDict]") -> Body: + """Child keywords and messages. + + Typically empty. Only contains something if running RETURN has failed + due to a syntax error or listeners have logged messages or executed + keywords. + """ + return self.body_class(self, body) + + def to_dict(self) -> DataDict: + data = {**super().to_dict(), **StatusMixin.to_dict(self)} + if self.body: + data["body"] = self.body.to_dicts() + return data + + +@Body.register +class Continue(model.Continue, StatusMixin, DeprecatedAttributesMixin): + body_class = Body + __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, + 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.body = () + + @setter + def body(self, body: "Sequence[BodyItem | DataDict]") -> Body: + """Child keywords and messages. + + Typically empty. Only contains something if running CONTINUE has failed + due to a syntax error or listeners have logged messages or executed + keywords. + """ + return self.body_class(self, body) + + def to_dict(self) -> DataDict: + data = {**super().to_dict(), **StatusMixin.to_dict(self)} + if self.body: + data["body"] = self.body.to_dicts() + return data + + +@Body.register +class Break(model.Break, StatusMixin, DeprecatedAttributesMixin): + body_class = Body + __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, + 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.body = () + + @setter + def body(self, body: "Sequence[BodyItem | DataDict]") -> Body: + """Child keywords and messages. + + Typically empty. Only contains something if running BREAK has failed + due to a syntax error or listeners have logged messages or executed + keywords. + """ + return self.body_class(self, body) + + def to_dict(self) -> DataDict: + data = {**super().to_dict(), **StatusMixin.to_dict(self)} + if self.body: + data["body"] = self.body.to_dicts() + return data + + +@Body.register +class Error(model.Error, StatusMixin, DeprecatedAttributesMixin): + body_class = Body + __slots__ = ("status", "message", "_start_time", "_end_time", "_elapsed_time") + + 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 + self.body = () + + @setter + def body(self, body: "Sequence[BodyItem | DataDict]") -> Body: + """Body typically containing only the related error message.""" + return self.body_class(self, body) + + def to_dict(self) -> DataDict: + data = {**super().to_dict(), **StatusMixin.to_dict(self)} + if self.body: + data["body"] = self.body.to_dicts() + return data + + +@Body.register +@Branches.register +@Iterations.register +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", + "_setup", + "_teardown", + ) + + def __init__( + self, + name: "str | None" = "", + owner: "str | None" = None, + source_name: "str | None" = None, + doc: str = "", + args: Sequence[str] = (), + assign: Sequence[str] = (), + tags: Sequence[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, + parent: BodyItemParent = None, + ): + super().__init__(name, args, assign, type, parent) + #: Name of the library or resource containing this keyword. + self.owner = owner + #: Original name of keyword with embedded arguments. + self.source_name = source_name + 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 + self._setup = None + self._teardown = None + self.body = () + + @setter + def body(self, body: "Sequence[BodyItem | DataDict]") -> Body: + """Keyword body. + + Body can consist of child keywords, messages, and control structures + such as IF/ELSE. + """ + return self.body_class(self, body) + + @property + 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) # type: ignore + + @property + def full_name(self) -> "str | None": + """Keyword name in format ``owner.name``. + + 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:`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. + """ + 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 + def setup(self) -> "Keyword": + """Keyword setup. + + 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 critical(self): - """``True/False`` depending on is the test considered critical. + 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. + + Teardown can be modified by setting attributes directly:: + + keyword.teardown.name = 'Example' + keyword.teardown.args = ('First', 'Second') - Criticality is determined based on test's :attr:`tags` and - :attr:`~TestSuite.criticality` of the :attr:`parent` suite. + 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 not self.parent: - return True - return self.parent.criticality.test_is_critical(self) + if self._teardown is None: + self.teardown = None + return self._teardown + @teardown.setter + def teardown(self, teardown: "Keyword | DataDict | None"): + self._teardown = create_fixture(self.__class__, teardown, self, self.TEARDOWN) -class TestSuite(model.TestSuite): + @property + 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:`` + 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: Sequence[str]) -> model.Tags: + """Keyword tags.""" + return Tags(tags) + + def to_dict(self) -> DataDict: + data = {**super().to_dict(), **StatusMixin.to_dict(self)} + if self.owner: + data["owner"] = self.owner + if self.source_name: + data["source_name"] = self.source_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.body: + data["body"] = self.body.to_dicts() + if self.has_setup: + data["setup"] = self.setup.to_dict() + if self.has_teardown: + data["teardown"] = self.teardown.to_dict() + return data + + +class TestCase(model.TestCase[Keyword], StatusMixin): + """Represents results of a single test case. + + See the base class for documentation of attributes not documented here. + """ + + body_class = Body + fixture_class = Keyword + __slots__ = ("status", "message", "_start_time", "_end_time", "_elapsed_time") + + def __init__( + self, + name: str = "", + doc: str = "", + tags: Sequence[str] = (), + timeout: "str | None" = None, + lineno: "int | None" = None, + metadata: "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, + parent: "TestSuite | None" = None, + ): + super().__init__(name, doc, tags, timeout, lineno, metadata, parent) + self.status = status + self.message = message + self.start_time = start_time + self.end_time = end_time + self.elapsed_time = elapsed_time + + @property + def not_run(self) -> bool: + return False + + @setter + def body(self, body: "Sequence[BodyItem | DataDict]") -> Body: + """Test body.""" + return self.body_class(self, body) + + def to_dict(self) -> DataDict: + return {"id": self.id, **super().to_dict(), **StatusMixin.to_dict(self)} + + @classmethod + def from_dict(cls, data: DataDict) -> "TestCase": + data.pop("id", None) + return super().from_dict(data) + + +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. """ - __slots__ = ['message', 'starttime', 'endtime', '_criticality'] + test_class = TestCase - keyword_class = Keyword + fixture_class = Keyword + __slots__ = ("message", "_start_time", "_end_time", "_elapsed_time") - def __init__(self, name='', doc='', metadata=None, source=None, - message='', starttime=None, endtime=None, rpa=False): - model.TestSuite.__init__(self, 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 = False, + message: str = "", + 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._criticality = None + 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 (*self.suites, *self.tests): + elapsed += child.elapsed_time + return elapsed + + @property + 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) -> bool: + """``True`` if any test has failed, ``False`` otherwise.""" + return self.status == self.FAIL + + @property + def skipped(self) -> bool: + """``True`` if there are no passed or failed tests, ``False`` otherwise.""" + return self.status == self.SKIP @property - def passed(self): - """``True`` if no critical test has failed, ``False`` otherwise.""" - return not self.statistics.critical.failed + def not_run(self) -> bool: + return False @property - def status(self): - """``'PASS'`` if no critical test has failed, ``'FAIL'`` otherwise.""" - return 'PASS' if self.passed else 'FAIL' + def status(self) -> Literal["PASS", "SKIP", "FAIL"]: + """'PASS', 'FAIL' or 'SKIP' depending on test statuses. + + - If any test has failed, status is 'FAIL'. + - If no test has failed but at least some test has passed, status is 'PASS'. + - If there are no failed or passed tests, status is 'SKIP'. This covers both + the case when all tests have been skipped and when there are no tests. + """ + stats = self.statistics # Local variable avoids recreating stats. + if stats.failed: + return self.FAIL + if stats.passed: + return self.PASS + return self.SKIP @property - def statistics(self): - """Suite statistics as a :class:`~robot.model.totalstatistics.TotalStatistics` object. + def statistics(self) -> TotalStatistics: + """Suite statistics. Recreated every time this property is accessed, so saving the results to a variable and inspecting it is often a good idea:: stats = suite.statistics - print(stats.critical.failed) - print(stats.all.total) + print(stats.failed) + 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): - """Total execution time in milliseconds.""" - if self.starttime and self.endtime: - return utils.get_elapsed_time(self.starttime, self.endtime) - return sum(child.elapsedtime for child in - chain(self.suites, self.tests, self.keywords)) - - @property - def criticality(self): - """Used by tests to determine are they considered critical or not. - - Normally configured using ``--critical`` and ``--noncritical`` - command line options. Can be set programmatically using - :meth:`set_criticality` of the root test suite. - """ - if self.parent: - return self.parent.criticality - if self._criticality is None: - self.set_criticality() - return self._criticality - - def set_criticality(self, critical_tags=None, non_critical_tags=None): - """Sets which tags are considered critical and which non-critical. - - :param critical_tags: Tags or patterns considered critical. See - the documentation of the ``--critical`` option for more details. - :param non_critical_tags: Tags or patterns considered non-critical. See - the documentation of the ``--noncritical`` option for more details. + @setter + def suites( + self, suites: "Sequence[TestSuite | DataDict]" + ) -> TestSuites["TestSuite"]: + return TestSuites["TestSuite"](self.__class__, self, suites) - Tags can be given as lists of strings or, when giving only one, - as single strings. This information is used by tests to determine - are they considered critical or not. - - Criticality can be set only to the root test suite. - """ - if self.parent is not None: - raise ValueError('Criticality can only be set to the root suite.') - self._criticality = Criticality(critical_tags, non_critical_tags) - - 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 + :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='TRACE'): + def filter_messages(self, log_level: str = "TRACE"): """Remove log messages below the specified ``log_level``.""" self.visit(MessageFilter(log_level)) @@ -283,16 +1183,140 @@ def configure(self, **options): Example:: suite.configure(remove_keywords='PASSED', - critical_tags='smoke', doc='Smoke test results.') + + Not to be confused with :meth:`config` method that suites, tests, + 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, message): + def suite_teardown_failed(self, message: str): """Internal usage only.""" self.visit(SuiteTeardownFailed(message)) + + def suite_teardown_skipped(self, message: str): + """Internal usage only.""" + self.visit(SuiteTeardownFailed(message, skipped=True)) + + def to_dict(self) -> DataDict: + return {"id": self.id, **super().to_dict(), **StatusMixin.to_dict(self)} + + @classmethod + def from_dict(cls, data: DataDict) -> "TestSuite": + """Create suite based on result data in a dictionary. + + ``data`` can either contain only the suite data got, for example, from + the :meth:`to_dict` method, or it can contain full result data with + execution errors and other such information in addition to the suite data. + In the latter case only the suite data is used, though. + + Support for full result data is new in Robot Framework 7.2. + """ + if "suite" in data: + data = data["suite"] + # `body` on the suite level means that a listener has logged something or + # executed a keyword in a `start/end_suite` method. Throwing such data + # away isn't great, but it's better than data being invalid and properly + # handling it would be complicated. We handle such XML outputs (see + # `xmlelementhandlers`), but with JSON there can even be one `body` in + # the beginning and other at the end, and even preserving them both + # would be hard. + data.pop("body", None) + # Don't set attributes that are automatically calculated and unsettable. + data.pop("id", None) + data.pop("status", None) + return super().from_dict(data) + + @classmethod + def from_json(cls, source: "str | bytes | TextIO | Path") -> "TestSuite": + """Create suite based on results in JSON. + + The data is given as the ``source`` parameter. It can be: + + - a string containing the data directly, + - an open file object where to read the data from, or + - a path (``pathlib.Path`` or string) to a UTF-8 encoded file to read. + + Supports JSON produced by :meth:`to_json` that contains only the suite + information, as well as full result JSON that contains also execution + errors and other information. In the latter case errors and all other + information is silently ignored, though. If that is a problem, + :class:`~robot.result.resultbuilder.ExecutionResult` should be used + instead. + + Support for full result JSON is new in Robot Framework 7.2. + """ + return super().from_json(source) + + @overload + def to_xml(self, file: None = None) -> str: ... + + @overload + def to_xml(self, file: "TextIO | Path | str") -> None: ... + + def to_xml(self, file: "None | TextIO | Path | str" = None) -> "str | None": + """Serialize suite into XML. + + The format is the same that is used with normal output.xml files, but + the ```` root node is omitted and the result contains only + the ```` structure. + + The ``file`` parameter controls what to do with the resulting XML data. + It can be: + + - ``None`` (default) to return the data as a string, + - an open file object where to write the data to, or + - a path (``pathlib.Path`` or string) to a file where to write + the data using UTF-8 encoding. + + A serialized suite can be recreated by using the :meth:`from_xml` method. + + New in Robot Framework 7.0. + """ + from robot.reporting.outputwriter import OutputWriter + + output, close = self._get_output(file) + try: + self.visit(OutputWriter(output, suite_only=True)) + finally: + if close: + output.close() + return output.getvalue() if file is None else None + + def _get_output(self, output) -> "tuple[TextIO | StringIO, bool]": + close = False + if output is None: + output = StringIO() + elif isinstance(output, (Path, str)): + output = open(output, "w", encoding="UTF-8") + close = True + return output, close + + @classmethod + def from_xml(cls, source: "str | TextIO | Path") -> "TestSuite": + """Create suite based on results in XML. + + The data is given as the ``source`` parameter. It can be: + + - a string containing the data directly, + - an open file object where to read the data from, or + - a path (``pathlib.Path`` or string) to a UTF-8 encoded file to read. + + Supports both normal output.xml files and files containing only the + ```` structure created, for example, with the :meth:`to_xml` + method. When using normal output.xml files, possible execution errors + listed in ```` are silently ignored. If that is a problem, + :class:`~robot.result.resultbuilder.ExecutionResult` should be used + instead. + + New in Robot Framework 7.0. + """ + from .resultbuilder import ExecutionResult + + return ExecutionResult(source).suite diff --git a/src/robot/result/modeldeprecation.py b/src/robot/result/modeldeprecation.py new file mode 100644 index 00000000000..ad78f2e5ac6 --- /dev/null +++ b/src/robot/result/modeldeprecation.py @@ -0,0 +1,76 @@ +# 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 + +from robot.model import Tags + + +def deprecated(method): + def wrapper(self, *args, **kws): + """Deprecated.""" + warnings.warn( + f"'robot.result.{type(self).__name__}.{method.__name__}' is " + f"deprecated and will be removed in Robot Framework 8.0.", + stacklevel=1, + ) + return method(self, *args, **kws) + + return wrapper + + +class DeprecatedAttributesMixin: + _log_name = "" + __slots__ = () + + @property + @deprecated + def name(self): + return self._log_name + + @property + @deprecated + def kwname(self): + return self._log_name + + @property + @deprecated + def libname(self): + return None + + @property + @deprecated + def args(self): + return () + + @property + @deprecated + def assign(self): + return () + + @property + @deprecated + def tags(self): + return Tags() + + @property + @deprecated + def timeout(self): + return None + + @property + @deprecated + def doc(self): + return "" diff --git a/src/robot/result/resultbuilder.py b/src/robot/result/resultbuilder.py index 1e90deedc78..7aa5250955f 100644 --- a/src/robot/result/resultbuilder.py +++ b/src/robot/result/resultbuilder.py @@ -13,37 +13,60 @@ # See the License for the specific language governing permissions and # limitations under the License. +from typing import Sequence +from xml.etree import ElementTree as ET + from robot.errors import DataError -from robot.model import SuiteVisitor -from robot.utils import ET, ETSource, get_error_message, unic +from robot.utils import ETSource, get_error_message -from .executionresult import Result, CombinedResult -from .flattenkeywordmatcher import (FlattenByNameMatcher, FlattenByTypeMatcher, - FlattenByTagMatcher) +from .executionresult import CombinedResult, is_json_source, KeywordRemover, Result +from .flattenkeywordmatcher import ( + create_flatten_message, FlattenByNameMatcher, FlattenByTags, FlattenByTypeMatcher +) from .merger import Merger from .xmlelementhandlers import XmlElementHandler -def ExecutionResult(*sources, **options): +def ExecutionResult( + *sources, + merge: bool = False, + include_keywords: bool = True, + flattened_keywords: Sequence[str] = (), + rpa: "bool | None" = None, +): """Factory method to constructs :class:`~.executionresult.Result` objects. - :param sources: Path(s) to the XML output file(s). - :param options: Configuration options. - Using ``merge=True`` causes multiple results to be combined so that - tests in the latter results replace the ones in the original. - Setting ``rpa`` either to ``True`` (RPA mode) or ``False`` (test - automation) sets execution mode explicitly. By default it is got + :param sources: XML or JSON source(s) containing execution results. + Can be specified as paths (``pathlib.Path`` or ``str``), opened file + objects, or strings/bytes containing XML/JSON directly. + :param merge: When ``True`` and multiple sources are given, results are merged + instead of combined. + :param include_keywords: When ``False``, keyword and control structure information + is not parsed. This can save considerable amount of time and memory. + :param flattened_keywords: List of patterns controlling what keywords + and control structures to flatten. See the documentation of + the ``--flattenkeywords`` option for more details. + :param rpa: Setting ``rpa`` either to ``True`` (RPA mode) or ``False`` (test + automation) sets the execution mode explicitly. By default, the mode is got from processed output files and conflicting modes cause an error. - Other options are passed directly to the - :class:`ExecutionResultBuilder` object used internally. :returns: :class:`~.executionresult.Result` instance. - Should be imported by external code via the :mod:`robot.api` package. - See the :mod:`robot.result` package for a usage example. + A source is considered to be JSON in these cases: + - It is a path with a ``.json`` suffix. + - It is an open file that has a ``name`` attribute with a ``.json`` suffix. + - It is string or bytes starting with ``{`` and ending with ``}``. + + This method should be imported by external code via the :mod:`robot.api` + package. See the :mod:`robot.result` package for a usage example. """ if not sources: - raise DataError('One or more data source needed.') - if options.pop('merge', False): + raise DataError("One or more data source needed.") + options = { + "include_keywords": include_keywords, + "flattened_keywords": flattened_keywords, + "rpa": rpa, + } + if merge: return _merge_results(sources[0], sources[1:], options) if len(sources) > 1: return _combine_results(sources, options) @@ -52,9 +75,9 @@ def ExecutionResult(*sources, **options): def _merge_results(original, merged, options): result = ExecutionResult(original, **options) - merger = Merger(result) - for path in merged: - merged = ExecutionResult(path, **options) + merger = Merger(result, rpa=result.rpa) + for source in merged: + merged = ExecutionResult(source, **options) merger.merge(merged) return result @@ -64,37 +87,56 @@ def _combine_results(sources, options): def _single_result(source, options): + if is_json_source(source): + return _json_result(source, **options) + return _xml_result(source, **options) + + +def _json_result(source, include_keywords, flattened_keywords, rpa): + try: + return Result.from_json(source, include_keywords, flattened_keywords, rpa) + except IOError as err: + error = err.strerror + except Exception: + error = get_error_message() + raise DataError(f"Reading JSON source '{source}' failed: {error}") + + +def _xml_result(source, include_keywords, flattened_keywords, rpa): ets = ETSource(source) - result = Result(source, rpa=options.pop('rpa', None)) + builder = ExecutionResultBuilder(ets, include_keywords, flattened_keywords) + result = Result(source, rpa=rpa) try: - return ExecutionResultBuilder(ets, **options).build(result) + return builder.build(result) except IOError as err: error = err.strerror - except: + except Exception: error = get_error_message() - raise DataError("Reading XML source '%s' failed: %s" % (unic(ets), error)) + raise DataError(f"Reading XML source '{ets}' failed: {error}") -class ExecutionResultBuilder(object): - """Builds :class:`~.executionresult.Result` objects based on output files. +# TODO: +# - Rename e.g. to XmlExecutionResultBuilder. Probably best done in a major release. +# - Add Result.from_xml as a more convenient API. Could be done in RF 7.4. +class ExecutionResultBuilder: + """Builds :class:`~.executionresult.Result` objects based on XML output files. Instead of using this builder directly, it is recommended to use the :func:`ExecutionResult` factory method. """ - def __init__(self, source, include_keywords=True, flattened_keywords=None): + def __init__(self, source, include_keywords=True, flattened_keywords=()): """ :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. - :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) + self._source = source if isinstance(source, ETSource) else ETSource(source) self._include_keywords = include_keywords self._flattened_keywords = flattened_keywords @@ -104,96 +146,82 @@ def build(self, result): with self._source as source: self._parse(source, handler.start, handler.end) result.handle_suite_teardown_failures() + if self._flattened_keywords: + # Tags are nowadays written after keyword content, so we cannot + # flatten based on them when parsing output.xml. + result.suite.visit(FlattenByTags(self._flattened_keywords)) if not self._include_keywords: - result.suite.visit(RemoveKeywords()) + result.suite.visit(KeywordRemover()) return result def _parse(self, source, start, end): - context = ET.iterparse(source, events=('start', 'end')) + context = ET.iterparse(source, events=("start", "end")) if not self._include_keywords: context = self._omit_keywords(context) elif self._flattened_keywords: context = self._flatten_keywords(context, self._flattened_keywords) for event, elem in context: - if event == 'start': + if event == "start": start(elem) else: end(elem) elem.clear() + # Python 3.15 emits a warning if context is not closed, but `close` is + # new in Python 3.13. + try: + context.close() + except AttributeError: + pass def _omit_keywords(self, context): - omitted_kws = 0 + omitted_elements = {"kw", "for", "while", "if", "try", "group", "variable"} + omitted = 0 for event, elem in context: - # Teardowns aren't omitted to allow checking suite teardown status. - omit = elem.tag == 'kw' and elem.get('type') != 'teardown' - start = event == 'start' + # Teardowns cannot be removed yet, because we need to check suite + # teardown status. They are removed later using KeywordRemover. + omit = elem.tag in omitted_elements and elem.get("type") != "TEARDOWN" + start = event == "start" if omit and start: - omitted_kws += 1 - if not omitted_kws: + omitted += 1 + if not omitted: yield event, elem elif not start: elem.clear() if omit and not start: - omitted_kws -= 1 + omitted -= 1 def _flatten_keywords(self, context, flattened): # Performance optimized. Do not change without profiling! 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 - tags = [] - inside_kw = 0 # to make sure we don't read tags from a test - seen_doc = False + started = -1 # If 0 or more, we are flattening. + include_on_top = {"doc", "tag", "timeout", "status"} for event, elem in context: tag = elem.tag - start = event == 'start' - end = not start - if start and tag == 'kw': - inside_kw += 1 + if event == "start": if started >= 0: started += 1 - elif by_name and name_match(elem.get('name', ''), elem.get('library')): + elif ( + by_name + and tag == "kw" + and name_match( + elem.get("name", ""), + elem.get("owner") or elem.get("library"), + # 'library' is for RF < 7 compatibility + ) + ): started = 0 - seen_doc = False - elif by_type and type_match(elem.get('type', 'kw')): + elif by_type and type_match(tag): started = 0 - seen_doc = False - elif started < 0 and by_tags and inside_kw: - if end and tag == 'tag': - tags.append(elem.text or '') - elif end and tag == 'tags': - if tags_match(tags): - started = 0 - seen_doc = False - tags = [] - if end and tag == 'kw': - inside_kw -= 1 - if started == 0 and not seen_doc: - doc = ET.Element('doc') - doc.text = '_*Keyword content flattened.*_' - yield 'start', doc - yield 'end', doc - if started == 0 and end and tag == 'doc': - seen_doc = True - elem.text = ('%s\n\n_*Keyword content flattened.*_' - % (elem.text or '')).strip() - if started <= 0 or tag == 'msg': + elif started == 1 and tag == "status": + elem.text = create_flatten_message(elem.text) + if started <= 0 or (started == 1 and tag in include_on_top) or tag == "msg": yield event, elem else: elem.clear() - if started >= 0 and end and tag == 'kw': + if started >= 0 and event == "end": started -= 1 def _get_matcher(self, matcher_class, flattened): matcher = matcher_class(flattened) return matcher.match, bool(matcher) - - -class RemoveKeywords(SuiteVisitor): - - def start_suite(self, suite): - suite.keywords = [] - - def visit_test(self, test): - test.keywords = [] diff --git a/src/robot/result/suiteteardownfailed.py b/src/robot/result/suiteteardownfailed.py index aab32176de8..cea8f4f3c9f 100644 --- a/src/robot/result/suiteteardownfailed.py +++ b/src/robot/result/suiteteardownfailed.py @@ -19,10 +19,12 @@ class SuiteTeardownFailureHandler(SuiteVisitor): def end_suite(self, suite): - teardown = suite.keywords.teardown - # Both 'PASS' and 'NOT_RUN' (used in dry-run) statuses are OK. - if teardown and teardown.status == 'FAIL': - suite.suite_teardown_failed(teardown.message) + if suite.has_teardown: + teardown = suite.teardown + if teardown.status == teardown.FAIL: + suite.suite_teardown_failed(teardown.message) + if teardown.status == teardown.SKIP: + suite.suite_teardown_skipped(teardown.message) def visit_test(self, test): pass @@ -32,16 +34,33 @@ def visit_keyword(self, keyword): class SuiteTeardownFailed(SuiteVisitor): - _normal_msg = 'Parent suite teardown failed:\n' - _also_msg = '\n\nAlso parent suite teardown failed:\n' + _normal_msg = "Parent suite teardown failed:\n%s" + _also_msg = "\n\nAlso parent suite teardown failed:\n%s" + _normal_skip_msg = "Skipped in parent suite teardown:\n%s" + _also_skip_msg = "Skipped in parent suite teardown:\n%s\n\nEarlier message:\n%s" - def __init__(self, error): - self._normal_msg += error - self._also_msg += error + def __init__(self, message, skipped=False): + self.message = message + self.skipped = skipped def visit_test(self, test): - test.status = 'FAIL' - test.message += self._also_msg if test.message else self._normal_msg + if not self.skipped: + self._suite_teardown_failed(test) + else: + self._suite_teardown_skipped(test) + + def _suite_teardown_failed(self, test): + if not test.skipped: + test.status = test.FAIL + prefix = self._also_msg if test.message else self._normal_msg + test.message += prefix % self.message + + def _suite_teardown_skipped(self, test): + test.status = test.SKIP + if test.message: + test.message = self._also_skip_msg % (self.message, test.message) + else: + test.message = self._normal_skip_msg % self.message def visit_keyword(self, keyword): pass diff --git a/src/robot/result/visitor.py b/src/robot/result/visitor.py index ca0d654c0de..3a67c32cd9e 100644 --- a/src/robot/result/visitor.py +++ b/src/robot/result/visitor.py @@ -39,6 +39,7 @@ class ResultVisitor(SuiteVisitor): For more information about the visitor algorithm see documentation in :mod:`robot.model.visitor` module. """ + def visit_result(self, result): if self.start_result(result) is not False: result.suite.visit(self) @@ -67,8 +68,7 @@ def end_statistics(self, stats): def visit_total_statistics(self, stats): if self.start_total_statistics(stats) is not False: - for stat in stats: - stat.visit(self) + stats.visit(self) self.end_total_statistics(stats) def start_total_statistics(self, stats): diff --git a/src/robot/result/xmlelementhandlers.py b/src/robot/result/xmlelementhandlers.py index 382c05ef986..66c3ad50602 100644 --- a/src/robot/result/xmlelementhandlers.py +++ b/src/robot/result/xmlelementhandlers.py @@ -13,38 +13,44 @@ # See the License for the specific language governing permissions and # limitations under the License. +from datetime import datetime + from robot.errors import DataError -class XmlElementHandler(object): +class XmlElementHandler: def __init__(self, execution_result, root_handler=None): self._stack = [(root_handler or RootHandler(), execution_result)] def start(self, elem): handler, result = self._stack[-1] - handler = handler.get_child_handler(elem) - result = handler.start(elem, result) + handler = handler.get_child_handler(elem.tag) + # Previous `result` being `None` means child elements should be ignored. + if result is not None: + result = handler.start(elem, result) self._stack.append((handler, result)) def end(self, elem): handler, result = self._stack.pop() - handler.end(elem, result) - + if result is not None: + handler.end(elem, result) -class _Handler(object): - def __init__(self): - self._child_handlers = dict((c.tag, c) for c in self._children()) +class ElementHandler: + element_handlers = {} + tag = None + children = frozenset() - def _children(self): - return [] + @classmethod + def register(cls, handler): + cls.element_handlers[handler.tag] = handler() + return handler - def get_child_handler(self, elem): - try: - return self._child_handlers[elem.tag] - except KeyError: - raise DataError("Incompatible XML element '%s'." % elem.tag) + def get_child_handler(self, tag): + if tag not in self.children: + raise DataError(f"Incompatible child element '{tag}' for '{self.tag}'.") + return self.element_handlers[tag] def start(self, elem, result): return result @@ -52,211 +58,463 @@ 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 - - -class RootHandler(_Handler): - - def _children(self): - return [RobotHandler()] + def _legacy_timestamp(self, elem, attr_name): + ts = elem.get(attr_name) + return self._parse_legacy_timestamp(ts) + + def _parse_legacy_timestamp(self, ts): + 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): + children = frozenset(("robot", "suite")) + + def get_child_handler(self, tag): + try: + return super().get_child_handler(tag) + except DataError: + raise DataError(f"Incompatible root element '{tag}'.") -class RobotHandler(_Handler): - tag = 'robot' +@ElementHandler.register +class RobotHandler(ElementHandler): + tag = "robot" + children = frozenset(("suite", "statistics", "errors")) def start(self, elem, result): - generator = elem.get('generator', 'unknown').split()[0].upper() - result.generated_by_robot = generator == 'ROBOT' + result.generator = elem.get("generator", "unknown") + result.generation_time = self._parse_generation_time(elem.get("generated")) if result.rpa is None: - result.rpa = elem.get('rpa', 'false') == 'true' + result.rpa = elem.get("rpa", "false") == "true" return result - def _children(self): - return [RootSuiteHandler(), StatisticsHandler(), ErrorsHandler()] + def _parse_generation_time(self, generated): + if not generated: + return None + try: + return datetime.fromisoformat(generated) + except ValueError: + return self._parse_legacy_timestamp(generated) + +@ElementHandler.register +class SuiteHandler(ElementHandler): + tag = "suite" + # "metadata" is for RF < 4 compatibility. + children = frozenset(("doc", "metadata", "meta", "status", "kw", "test", "suite")) -class SuiteHandler(_Handler): - tag = 'suite' + def start(self, elem, result): + if hasattr(result, "suite"): # root + return result.suite.config( + name=elem.get("name", ""), + source=elem.get("source"), + rpa=result.rpa, + ) + return result.suites.create( + name=elem.get("name", ""), + source=elem.get("source"), + rpa=result.rpa, + ) + + def get_child_handler(self, tag): + if tag == "status": + return StatusHandler(set_status=False) + return super().get_child_handler(tag) + + +@ElementHandler.register +class TestHandler(ElementHandler): + tag = "test" + # "tags" is for RF < 4 compatibility. + children = frozenset(( + "doc", "meta", "tags", "tag", "timeout", "status", "kw", "if", "for", "try", "while", + "group", "variable", "return", "break", "continue", "error", "msg" + )) # fmt: skip def start(self, elem, result): - return result.suites.create(name=elem.get('name', ''), - source=elem.get('source'), - rpa=result.rpa) + lineno = elem.get("line") + if lineno: + lineno = int(lineno) + return result.tests.create(name=elem.get("name", ""), lineno=lineno) + + +@ElementHandler.register +class KeywordHandler(ElementHandler): + tag = "kw" + # "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", "group", "variable", "return", + "break", "continue", "error" + )) # fmt: skip + + def start(self, elem, result): + elem_type = elem.get("type") + if not elem_type: + creator = self._create_keyword + else: + creator = getattr(self, "_create_" + elem_type.lower()) + return creator(elem, result) + + def _create_keyword(self, elem, result): + try: + body = result.body + except AttributeError: + body = self._get_body_for_suite_level_keyword(result) + 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 _children(self): - return [DocHandler(), MetadataHandler(), SuiteStatusHandler(), - KeywordHandler(), TestCaseHandler(), self] + def _get_body_for_suite_level_keyword(self, result): + # Someone, most likely a listener, has created a `` element on suite level. + # Add the keyword into a suite setup or teardown, depending on have we already + # seen tests or not. Create an implicit setup/teardown if needed. Possible real + # setup/teardown parsed later will reset the implicit one otherwise, but leaves + # the added keyword into its body. + kw_type = "teardown" if result.tests or result.suites else "setup" + keyword = getattr(result, kw_type) + if not keyword: + keyword.config(name=f"Implicit {kw_type}", status=keyword.PASS) + return keyword.body + def _create_setup(self, elem, result): + return result.setup.config(**self._get_keyword_attrs(elem)) -class RootSuiteHandler(SuiteHandler): + def _create_teardown(self, elem, result): + return result.teardown.config(**self._get_keyword_attrs(elem)) + + # RF < 4 compatibility. + + def _create_for(self, elem, result): + return result.body.create_keyword(name=elem.get("name"), type="FOR") + + def _create_foritem(self, elem, result): + return result.body.create_keyword(name=elem.get("name"), type="ITERATION") + + _create_iteration = _create_foritem + + +@ElementHandler.register +class ForHandler(ElementHandler): + tag = "for" + children = frozenset(("var", "value", "iter", "status", "doc", "msg", "kw")) def start(self, elem, result): - result.suite.name = elem.get('name', '') - result.suite.source = elem.get('source') - result.suite.rpa = result.rpa - return result.suite + return result.body.create_for( + flavor=elem.get("flavor"), + start=elem.get("start"), + mode=elem.get("mode"), + fill=elem.get("fill"), + ) - def _children(self): - return SuiteHandler._children(self)[:-1] + [SuiteHandler()] +@ElementHandler.register +class WhileHandler(ElementHandler): + tag = "while" + children = frozenset(("iter", "status", "doc", "msg", "kw")) -class TestCaseHandler(_Handler): - tag = 'test' + 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"), + ) + + +@ElementHandler.register +class IterationHandler(ElementHandler): + tag = "iter" + children = frozenset(( + "var", "doc", "status", "kw", "if", "for", "msg", "try", "while", "group", + "variable", "return", "break", "continue", "error" + )) # fmt: skip def start(self, elem, result): - return result.tests.create(name=elem.get('name', '')) + return result.body.create_iteration() - def _children(self): - return [DocHandler(), TagsHandler(), TimeoutHandler(), - TestStatusHandler(), KeywordHandler()] +@ElementHandler.register +class GroupHandler(ElementHandler): + tag = "group" + children = frozenset(( + "status", "kw", "if", "for", "try", "while", "group", "msg", "variable", + "return", "break", "continue", "error" + )) # fmt: skip + + def start(self, elem, result): + return result.body.create_group(name=elem.get("name", "")) -class KeywordHandler(_Handler): - tag = 'kw' + +@ElementHandler.register +class IfHandler(ElementHandler): + tag = "if" + children = frozenset(("branch", "status", "doc", "msg", "kw")) def start(self, elem, result): - return result.keywords.create(kwname=elem.get('name', ''), - libname=elem.get('library', ''), - type=elem.get('type', 'kw')) + return result.body.create_if() - def _children(self): - return [DocHandler(), ArgumentsHandler(), AssignHandler(), - TagsHandler(), TimeoutHandler(), KeywordStatusHandler(), - MessageHandler(), self] +@ElementHandler.register +class BranchHandler(ElementHandler): + tag = "branch" + children = frozenset(( + "status", "kw", "if", "for", "try", "while", "group", "msg", "doc", "variable", + "return", "pattern", "break", "continue", "error" + )) # fmt: skip -class MessageHandler(_Handler): - tag = 'msg' + 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) + + +@ElementHandler.register +class TryHandler(ElementHandler): + tag = "try" + children = frozenset(("branch", "status", "doc", "msg", "kw")) + + def start(self, elem, result): + return result.body.create_try() + + +@ElementHandler.register +class PatternHandler(ElementHandler): + tag = "pattern" + children = frozenset() def end(self, elem, result): - result.messages.create(elem.text or '', - elem.get('level', 'INFO'), - elem.get('html', 'no') == 'yes', - self._timestamp(elem, 'timestamp')) + result.patterns += (elem.text or "",) -class _StatusHandler(_Handler): - tag = 'status' +@ElementHandler.register +class VariableHandler(ElementHandler): + tag = "variable" + children = frozenset(("var", "status", "msg", "kw")) - def _set_status(self, elem, result): - result.status = elem.get('status', 'FAIL') + def start(self, elem, result): + return result.body.create_var( + name=elem.get("name", ""), + scope=elem.get("scope"), + separator=elem.get("separator"), + ) - def _set_message(self, elem, result): - result.message = elem.text or '' - def _set_times(self, elem, result): - result.starttime = self._timestamp(elem, 'starttime') - result.endtime = self._timestamp(elem, 'endtime') +@ElementHandler.register +class ReturnHandler(ElementHandler): + tag = "return" + children = frozenset(("value", "status", "msg", "kw")) + def start(self, elem, result): + return result.body.create_return() -class KeywordStatusHandler(_StatusHandler): - def end(self, elem, result): - self._set_status(elem, result) - self._set_times(elem, result) - if result.type == result.TEARDOWN_TYPE: - self._set_message(elem, result) +@ElementHandler.register +class ContinueHandler(ElementHandler): + tag = "continue" + children = frozenset(("status", "msg", "kw")) + + def start(self, elem, result): + return result.body.create_continue() + +@ElementHandler.register +class BreakHandler(ElementHandler): + tag = "break" + children = frozenset(("status", "msg", "kw")) + + def start(self, elem, result): + return result.body.create_break() -class SuiteStatusHandler(_StatusHandler): + +@ElementHandler.register +class ErrorHandler(ElementHandler): + tag = "error" + children = frozenset(("status", "msg", "value", "kw")) + + def start(self, elem, result): + return result.body.create_error() + + +@ElementHandler.register +class MessageHandler(ElementHandler): + tag = "msg" def end(self, elem, result): - self._set_message(elem, result) - self._set_times(elem, result) + 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 TestStatusHandler(_StatusHandler): + +class ErrorMessageHandler(MessageHandler): def end(self, elem, result): - self._set_status(elem, result) - self._set_message(elem, result) - self._set_times(elem, result) + self._create_message(elem, result.messages.create) + +@ElementHandler.register +class StatusHandler(ElementHandler): + tag = "status" -class DocHandler(_Handler): - tag = 'doc' + def __init__(self, set_status=True): + self.set_status = set_status def end(self, elem, result): - result.doc = elem.text or '' + if self.set_status: + result.status = elem.get("status", "FAIL") + if "elapsed" in elem.attrib: # RF >= 7 + result.elapsed_time = float(elem.attrib["elapsed"]) + result.start_time = elem.get("start") + 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 + + +@ElementHandler.register +class DocHandler(ElementHandler): + tag = "doc" + def end(self, elem, result): + 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 "" -class MetadataHandler(_Handler): - tag = 'metadata' - def _children(self): - return [MetadataItemHandler()] +@ElementHandler.register +class MetadataHandler(ElementHandler): # RF < 4 compatibility. + tag = "metadata" + children = frozenset(("item",)) -class MetadataItemHandler(_Handler): - tag = 'item' +@ElementHandler.register +class MetadataItemHandler(ElementHandler): # RF < 4 compatibility. + tag = "item" def end(self, elem, result): - result.metadata[elem.get('name', '')] = elem.text or '' + result.metadata[elem.get("name", "")] = elem.text or "" -class TagsHandler(_Handler): - tag = 'tags' +@ElementHandler.register +class MetaHandler(ElementHandler): + tag = "meta" - def _children(self): - return [TagHandler()] + def end(self, elem, result): + result.metadata[elem.get("name", "")] = elem.text or "" -class TagHandler(_Handler): - tag = 'tag' +@ElementHandler.register +class TagsHandler(ElementHandler): # RF < 4 compatibility. + tag = "tags" + children = frozenset(("tag",)) + + +@ElementHandler.register +class TagHandler(ElementHandler): + tag = "tag" def end(self, elem, result): - result.tags.add(elem.text or '') + result.tags.add(elem.text or "") -class TimeoutHandler(_Handler): - tag = 'timeout' +@ElementHandler.register +class TimeoutHandler(ElementHandler): + tag = "timeout" def end(self, elem, result): - result.timeout = elem.get('value') - + result.timeout = elem.get("value") -class AssignHandler(_Handler): - tag = 'assign' - def _children(self): - return [AssignVarHandler()] +@ElementHandler.register +class AssignHandler(ElementHandler): # RF < 4 compatibility. + tag = "assign" + children = frozenset(("var",)) -class AssignVarHandler(_Handler): - tag = 'var' +@ElementHandler.register +class VarHandler(ElementHandler): + tag = "var" def end(self, elem, result): - result.assign += (elem.text or '',) + value = elem.text or "" + if result.type in (result.KEYWORD, result.FOR): + 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}'.") -class ArgumentsHandler(_Handler): - tag = 'arguments' +@ElementHandler.register +class ArgumentsHandler(ElementHandler): # RF < 4 compatibility. + tag = "arguments" + children = frozenset(("arg",)) - def _children(self): - return [ArgumentHandler()] + +@ElementHandler.register +class ArgumentHandler(ElementHandler): + tag = "arg" + + def end(self, elem, result): + result.args += (elem.text or "",) -class ArgumentHandler(_Handler): - tag = 'arg' +@ElementHandler.register +class ValueHandler(ElementHandler): + tag = "value" def end(self, elem, result): - result.args += (elem.text or '',) + result.values += (elem.text or "",) -class ErrorsHandler(_Handler): - tag = 'errors' +@ElementHandler.register +class ErrorsHandler(ElementHandler): + tag = "errors" def start(self, elem, result): return result.errors - def _children(self): - return [MessageHandler()] + def get_child_handler(self, tag): + return ErrorMessageHandler() -class StatisticsHandler(_Handler): - tag = 'statistics' +@ElementHandler.register +class StatisticsHandler(ElementHandler): + tag = "statistics" - def get_child_handler(self, elem): + def get_child_handler(self, tag): return self diff --git a/src/robot/run.py b/src/robot/run.py index e76872a975c..022e47a5d7d 100755 --- a/src/robot/run.py +++ b/src/robot/run.py @@ -31,43 +31,41 @@ """ import sys +from threading import current_thread -# 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__': - import pythonpathsetter +if __name__ == "__main__" and "robot" not in sys.modules: + from pythonpathsetter import set_pythonpath + + set_pythonpath() from robot.conf import RobotSettings +from robot.errors import DataError from robot.model import ModelModifier -from robot.output import LOGGER, pyloggingconf +from robot.output import librarylogger, LOGGER, pyloggingconf from robot.reporting import ResultWriter -from robot.running import TestSuiteBuilder -from robot.utils import Application, unic, text - +from robot.running.builder import TestSuiteBuilder +from robot.utils import Application, text USAGE = """Robot Framework -- A generic automation framework Version: -Usage: robot [options] data_sources - or: python -m robot [options] data_sources - or: python path/to/robot [options] data_sources - or: java -jar robotframework.jar [options] data_sources +Usage: robot [options] paths + or: python -m robot [options] paths + or: python path/to/robot [options] paths Robot Framework is a generic open source automation framework for acceptance testing, acceptance test-driven development (ATDD) and robotic process automation (RPA). It has simple, easy-to-use syntax that utilizes the keyword-driven automation approach. Keywords adding new capabilities are -implemented in libraries using either Python or Java. New higher level +implemented in libraries using Python. New higher level keywords can also be created using Robot Framework's own syntax. The easiest way to execute Robot Framework is using the `robot` command created as part of the normal installation. Alternatively it is possible to execute -the `robot` module directly using `python -m robot`, where `python` can be -replaced with any supported Python interpreter such as `jython`, `ipy` or -`python3`. Yet another alternative is running the `robot` directory like -`python path/to/robot`. Finally, there is a standalone JAR distribution -available. +the `robot` module directly like `python -m robot`, where `python` can be +replaced with any supported Python interpreter. Yet another alternative +is running the `robot` directory like `python path/to/robot`. Tests (or tasks in RPA terminology) are created in files typically having the `*.robot` extension. Files automatically create test (or task) suites and @@ -88,67 +86,83 @@ Options ======= - --rpa Turn on generic automation mode. Mainly affects + --rpa Turn on the generic automation mode. Mainly affects 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. New in RF 3.1. + from test/task header in data files. + --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 extension is needed, separate them with a colon. - Examples: `--extension robot`, `-F robot:txt` - New in RF 3.0.1. - -N --name name Set the name of the top level test suite. Default - name is created from the name of the executed data - source. - -D --doc documentation Set the documentation of the top level test suite. - Simple formatting is supported (e.g. *bold*). If - the documentation contains spaces, it must be quoted. - Example: --doc "Very *good* example" + Examples: `--extension txt`, `--extension robot:txt` + Only `*.robot` files are parsed by default. + -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 + - 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. + -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. + If the value is path to an existing file, actual + documentation is read from that file. + Examples: --doc "Very *good* example" + --doc doc_from_file.txt -M --metadata name:value * Set metadata of the top level suite. Value can - contain formatting similarly as --doc. - Example: --metadata version:1.2 - -G --settag tag * Sets given tag(s) to all executed test cases. - -t --test name * Select test cases to run by name or long name. Name - is case and space insensitive and it can also be a - simple pattern where `*` matches anything and `?` - matches any char. + contain formatting and be read from a file similarly + as --doc. Example: --metadata Version:1.2 + -G --settag tag * Sets given tag(s) to all executed tests. + -t --test name * Select tests by name or by long name containing also + parent suite name like `Parent.Test`. Name is case + and space insensitive and it can also be a simple + pattern where `*` matches anything, `?` matches any + single character, and `[chars]` matches one character + in brackets. --task name * Alias to --test. Especially applicable with --rpa. - -s --suite name * Select test suites to run by name. When this option - is used with --test, --include or --exclude, only - test cases in matching suites and also matching other - filtering criteria are selected. Name can be a simple - pattern similarly as with --test and it can contain - parent name separated with a dot. For example - `-s X.Y` selects suite `Y` only if its parent is `X`. - -i --include tag * Select test cases to run by tag. Similarly as name - with --test, tag is case and space insensitive and it - is possible to use patterns with `*` and `?` as - wildcards. Tags and patterns can also be combined - together with `AND`, `OR`, and `NOT` operators. + -s --suite name * Select suites by name. When this option is used with + --test, --include or --exclude, only tests in + matching suites and also matching other filtering + criteria are selected. Name can be a simple pattern + similarly as with --test and it can contain parent + name separated with a dot. For example, `-s X.Y` + selects suite `Y` only if its parent is `X`. + -i --include tag * Select tests by tag. Similarly as name with --test, + tag is case and space insensitive and it is possible + to use patterns with `*`, `?` and `[]` as wildcards. + Tags and patterns can also be combined together with + `AND`, `OR`, and `NOT` operators. Examples: --include foo --include bar* --include fooANDbar* -e --exclude tag * Select test cases not to run by tag. These tests are not run even if included with --include. Tags are - matched using the rules explained with --include. + matched using same rules as with --include. -R --rerunfailed output Select failed tests from an earlier output file to be re-executed. Equivalent to selecting same tests - individually using --test option. - -S --rerunfailedsuites output Select failed suite from an earlier output file - to be re-executed. New in RF 3.0.1. - -c --critical tag * Tests having given tag are considered critical. If no - critical tags are set, all tags are critical. Tags - can be given as a pattern like with --include. - -n --noncritical tag * Tests with given tag are not critical even if they - have a tag set with --critical. Tag can be a pattern. + individually using --test. + -S --rerunfailedsuites output Select failed suites from an earlier output + file to be re-executed. + --runemptysuite Executes suite even if it contains no tests. Useful + e.g. with --include/--exclude when it is not an error + that no test matches the condition. + --skip tag * Tests having given tag will be skipped. Tag can be + a pattern. + --skiponfailure tag * Tests having given tag will be skipped if they fail. + Tag can be a pattern -v --variable name:value * Set variables in the test data. Only scalar variables with string value are supported and name is 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. @@ -163,9 +177,10 @@ path. Other output files are created based on XML output files after the test execution and XML outputs can also be further processed with Rebot tool. Can be - disabled by giving a special value `NONE`. In this - case, also log and report are automatically disabled. + disabled by giving a special value `NONE`. Default: output.xml + --legacyoutput Create XML output file in format compatible with + Robot Framework 6.x and earlier. -l --log file HTML log file. Can be disabled by giving a special value `NONE`. Default: log.html Examples: `--log mylog.html`, `-l NONE` @@ -173,7 +188,6 @@ similarly as --log. Default: report.html -x --xunit file xUnit compatible result file. Not created unless this option is specified. - --xunitskipnoncritical Mark non-critical tests on xUnit output as skipped. -b --debugfile file Debug file written during execution. Not created unless this option is specified. -T --timestampoutputs When this option is used, timestamp in a format @@ -182,20 +196,25 @@ example `-T -o output.xml -r report.html -l none` creates files like `output-20070503-154410.xml` and `report-20070503-154410.html`. - --splitlog Split log file into smaller pieces that open in - browser transparently. - --logtitle title Title for the generated test log. The default title - is ` Test Log`. - --reporttitle title Title for the generated test report. The default - title is ` Test Report`. + --splitlog Split the log file into smaller pieces that open in + browsers transparently. + --logtitle title Title for the generated log file. The default title + is ` Log`. + --reporttitle title Title for the generated report file. The default + title is ` Report`. --reportbackground colors Background colors to use in the report file. - Either `all_passed:critical_passed:failed` or - `passed:failed`. Both color names and codes work. - Examples: --reportbackground green:yellow:red + Given in format `passed:failed:skipped` where the + `:skipped` part can be omitted. Both color names and + codes work. + Examples: --reportbackground green:red:yellow --reportbackground #00E:#E00 --maxerrorlines lines Maximum number of error message lines to show in report when tests fail. Default is 40, minimum is 10 and `NONE` can be used to show the full message. + --maxassignlength characters Maximum number of characters to show in log + when variables are assigned. Zero or negative values + can be used to avoid showing assigned values at all. + Default is 200. -L --loglevel level Threshold level for logging. Available levels: TRACE, DEBUG, INFO (default), WARN, NONE (no logging). Use syntax `LOGLEVEL:DEFAULT` to define the default @@ -206,45 +225,46 @@ in log and report. By default all suite levels are shown. Example: --suitestatlevel 3 --tagstatinclude tag * Include only matching tags in `Statistics by Tag` - and `Test Details` in log and report. By default all - tags set in test cases are shown. Given `tag` can - also be a simple pattern (see e.g. --test). - --tagstatexclude tag * Exclude matching tags from `Statistics by Tag` and - `Test Details`. This option can be used with - --tagstatinclude similarly as --exclude is used with - --include. + in log and report. By default all tags are shown. + Given tag can be a pattern like with --include. + --tagstatexclude tag * Exclude matching tags from `Statistics by Tag`. + This option can be used with --tagstatinclude + similarly as --exclude is used with --include. --tagstatcombine tags:name * Create combined statistics based on tags. - These statistics are added into `Statistics by Tag` - and matching tests into `Test Details`. If optional - `name` is not given, name of the combined tag is got - from the specified tags. Tags are combined using the - rules explained in --include. + These statistics are added into `Statistics by Tag`. + If the optional `name` is not given, name of the + combined tag is got from the specified tags. Tags are + matched using the same rules as with --include. Examples: --tagstatcombine requirement-* --tagstatcombine tag1ANDtag2:My_name - --tagdoc pattern:doc * Add documentation to tags matching given pattern. - Documentation is shown in `Test Details` and also as - a tooltip in `Statistics by Tag`. Pattern can contain - characters `*` (matches anything) and `?` (matches - any char). Documentation can contain formatting - similarly as with --doc option. + --tagdoc pattern:doc * Add documentation to tags matching the given + pattern. Documentation is shown in `Test Details` and + also as a tooltip in `Statistics by Tag`. Pattern can + use `*`, `?` and `[]` as wildcards like --test. Examples: --tagdoc mytag:Example --tagdoc "owner-*:Original author" --tagstatlink pattern:link:title * Add external links into `Statistics by - Tag`. Pattern can contain characters `*` (matches - anything) and `?` (matches any char). Characters - matching to wildcard expressions can be used in link - and title with syntax %N, where N is index of the - match (starting from 1). + Tag`. Pattern can use `*`, `?` and `[]` as wildcards + like --test. Characters matching to `*` and `?` + wildcards can be used in link and title with syntax + %N, where N is index of the match (starting from 1). Examples: --tagstatlink mytag:http://my.domain:Title --tagstatlink "bug-*:http://url/id=%1:Issue Tracker" + --expandkeywords name:|tag: * + Matching keywords will be automatically expanded in + the log file. Matching against keyword name or tags + work using same rules as with --removekeywords. + Examples: --expandkeywords name:BuiltIn.Log + --expandkeywords tag:expand --removekeywords all|passed|for|wuks|name:|tag: * Remove keyword data from the generated log file. Keywords containing warnings are not removed except - in `all` mode. + in the `all` mode. all: remove data from all keywords passed: remove data only from keywords in passed test cases and suites - for: remove passed iterations from for loops + for: remove passed iterations from FOR loops + while: remove passed iterations from WHILE loops wuks: remove all but the last failing keyword inside `BuiltIn.Wait Until Keyword Succeeds` name:: remove data from keywords that match @@ -252,46 +272,36 @@ against the full name of the keyword (e.g. 'MyLib.Keyword', 'resource.Second Keyword'), is case, space, and underscore insensitive, - and may contain `*` and `?` as wildcards. + and may contain `*`, `?` and `[]` wildcards. Examples: --removekeywords name:Lib.HugeKw --removekeywords name:myresource.* tag:: remove data from keywords that match the given pattern. Tags are case and space - insensitive and it is possible to use - patterns with `*` and `?` as wildcards. - Tags and patterns can also be combined - together with `AND`, `OR`, and `NOT` - operators. + insensitive and patterns can contain `*`, + `?` and `[]` wildcards. Tags and patterns + can also be combined together with `AND`, + `OR`, and `NOT` operators. Examples: --removekeywords foo --removekeywords fooANDbar* - --flattenkeywords for|foritem|name:|tag: * + --flattenkeywords for|while|iteration|name:|tag: * Flattens matching keywords in the generated log file. Matching keywords get all log messages from their child keywords and children are discarded otherwise. - for: flatten for loops fully - foritem: flatten individual for loop iterations + for: flatten FOR loops fully + while: flatten WHILE loops fully + iteration: flatten FOR/WHILE loop iterations + foritem: deprecated alias for `iteration` name:: flatten matched keywords using same matching rules as with `--removekeywords name:` tag:: flatten matched keywords using same matching rules as with `--removekeywords tag:` - --listener class * A class for monitoring test execution. Gets - notifications e.g. when a test case starts and ends. - Arguments to the listener class can be given after - the name using colon or semicolon as a separator. - Examples: --listener MyListenerClass - --listener path/to/Listener.py:arg1:arg2 - --warnonskippedfiles Deprecated. Nowadays all skipped files are reported. --nostatusrc Sets the return code to zero regardless of failures in test cases. Error codes are returned normally. - --runemptysuite Executes tests also if the top level test suite is - empty. Useful e.g. with --include/--exclude when it - is not an error that no test matches the condition. --dryrun Verifies test data and runs tests so that library keywords are not executed. - -X --exitonfailure Stops test execution if any critical test fails. - Short option -X is new in RF 3.0.1. + -X --exitonfailure Stops test execution if any test fails. --exitonerror Stops test execution if any error occurs when parsing test data, importing libraries, and so on. --skipteardownonexit Causes teardowns to be skipped if test execution is @@ -305,51 +315,67 @@ The seed must be an integer. Examples: --randomize all --randomize tests:1234 - --prerunmodifier class * Class to programmatically modify the test suite - structure before execution. - --prerebotmodifier class * Class to programmatically modify the result - model before creating reports and logs. - --console type How to report execution on the console. - verbose: report every suite and test (default) - dotted: only show `.` for passed test, `f` for - failed non-critical tests, and `F` for - failed critical tests - quiet: no output except for errors and warnings - none: no output whatsoever + --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 console How to report execution on the console. + Built-in consoles: + verbose: report every suite and test (default) + dotted: only show `.` for passed test, `s` + for skipped and `F` for failed tests + quiet: no output except for errors/warnings + none: no output whatsoever + Other values are interpreted as a custom + console class or module. Argument format is + the same as with --listener. + Examples: --console verbose + --console MyConsole + --console path/to/Console.py:arg -. --dotted Shortcut for `--console dotted`. --quiet Shortcut for `--console quiet`. - -W --consolewidth chars Width of the monitor output. Default is 78. + -W --consolewidth chars Width of the console output. Default is 78. -C --consolecolors auto|on|ansi|off Use colors on console output or not. auto: use colors when output not redirected (default) on: always use colors ansi: like `on` but use ANSI colors also on Windows off: disable colors altogether - Note that colors do not work with Jython on Windows. + --consolelinks auto|off Control making paths to results files hyperlinks. + auto: use links when colors are enabled (default) + off: disable links unconditionally -K --consolemarkers auto|on|off Show markers on the console when top level keywords in a test case end. Values have same semantics as with --consolecolors. - -P --pythonpath path * Additional locations (directories, ZIPs, JARs) where - to search test libraries and other extensions when - they are imported. Multiple paths can be given by - separating them with a colon (`:`) or by using this - option several times. Given path can also be a glob - pattern matching multiple paths. - Examples: - --pythonpath libs/ --pythonpath resources/*.jar - --pythonpath /opt/testlibs:mylibs.zip:yourlibs - -E --escape what:with * Deprecated. Use console escape mechanism instead. + -P --pythonpath path * Additional locations (directories, ZIPs) where to + search libraries and other extensions when they are + imported. Multiple paths can be given by separating + them with a colon (`:`) or by using this option + several times. Given path can also be a glob pattern + matching multiple paths. + Examples: --pythonpath libs/ + --pythonpath /opt/libs:libraries.zip -A --argumentfile path * Text file to read more arguments from. Use special path `STDIN` to read contents from the standard input - stream. File can have both options and data sources - one per line. Contents do not need to be escaped but - spaces in the beginning and end of lines are removed. - Empty lines and lines starting with a hash character - (#) are ignored. + stream. File can have both options and input files + or directories, one per line. Contents do not need to + be escaped but spaces in the beginning and end of + lines are removed. Empty lines and lines starting + with a hash character (#) are ignored. Example file: | --include regression | --name Regression Tests | # This is a comment line - | my_tests.robot + | tests.robot | path/to/test/directory/ Examples: --argumentfile argfile.txt --argumentfile STDIN @@ -364,13 +390,11 @@ using the same option again with `no` prefix added or dropped. The last option has precedence regardless of how many times options are used. For example, `--dryrun --dryrun --nodryrun --nostatusrc --statusrc` would not activate the -dry-run mode and would return normal status rc. +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 ===================== @@ -388,6 +412,17 @@ ROBOT_INTERNAL_TRACES When set to any non-empty value, Robot Framework's internal methods are included in error tracebacks. +Return Codes +============ + +0 All tests passed. +1-249 Returned number of tests failed. +250 250 or more failures. +251 Help or version information printed. +252 Invalid data or command line options. +253 Execution stopped by user. +255 Unexpected internal error. + Examples ======== @@ -400,14 +435,11 @@ # Executing `robot` module using Python. $ python -m robot path/to/tests -# Running `robot` directory with Jython. -$ jython /opt/robot tests.robot - # Executing multiple test case files and using case-insensitive long options. $ robot --SuiteStatLevel 2 --Metadata Version:3 tests/*.robot more/tests.robot # Setting default options and syslog file before running tests. -$ export ROBOT_OPTIONS="--critical regression --suitestatlevel 2" +$ export ROBOT_OPTIONS="--outputdir results --suitestatlevel 2" $ export ROBOT_SYSLOG_FILE=/tmp/syslog.txt $ robot tests.robot """ @@ -416,34 +448,61 @@ 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): - settings = RobotSettings(options) + try: + settings = RobotSettings(options) + except DataError: + LOGGER.register_console_logger( + stdout=options.get("stdout"), + stderr=options.get("stderr"), + ) + raise + if settings.pythonpath: + sys.path = settings.pythonpath + sys.path LOGGER.register_console_logger(**settings.console_output_config) - LOGGER.info('Settings:\n%s' % unic(settings)) - builder = TestSuiteBuilder(settings['SuiteNames'], - extension=settings.extension, - rpa=settings.rpa) + LOGGER.info(f"Settings:\n{settings}") + builder = TestSuiteBuilder( + included_extensions=settings.extension, + included_files=settings.parse_include, + custom_parsers=settings.parsers, + rpa=settings.rpa, + lang=settings.languages, + allow_empty_suite=settings.run_empty_suite, + ) suite = builder.build(*datasources) - settings.rpa = builder.rpa - suite.configure(**settings.suite_config) if settings.pre_run_modifiers: - suite.visit(ModelModifier(settings.pre_run_modifiers, - settings.run_empty_suite, LOGGER)) + modifier = ModelModifier( + settings.pre_run_modifiers, + settings.run_empty_suite, + LOGGER, + ) + suite.visit(modifier) + 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 text.MAX_ERROR_LINES = settings.max_error_lines + text.MAX_ASSIGN_LENGTH = settings.max_assign_length + librarylogger.LOGGING_THREADS[0] = current_thread().name try: result = suite.run(settings) finally: text.MAX_ERROR_LINES = old_max_error_lines - LOGGER.info("Tests execution ended. Statistics:\n%s" - % result.suite.stat_message) + text.MAX_ASSIGN_LENGTH = old_max_assign_length + librarylogger.LOGGING_THREADS[0] = "MainThread" + LOGGER.info( + f"Tests execution ended. Statistics:\n{result.suite.stat_message}" + ) if settings.log or settings.report or settings.xunit: - writer = ResultWriter(settings.output if settings.log - else result) + writer = ResultWriter(settings.output if settings.log else result) writer.write_results(settings.get_rebot_settings()) return result.return_code @@ -451,17 +510,16 @@ def validate(self, options, arguments): return self._filter_options_without_value(options), arguments def _filter_options_without_value(self, options): - return dict((name, value) for name, value in options.items() - if value not in (None, [])) + return {n: v for n, v in options.items() if v not in (None, [])} def run_cli(arguments=None, exit=True): """Command line execution entry point for running tests. :param arguments: Command line options and arguments as a list of strings. - Starting from RF 3.1, defaults to ``sys.argv[1:]`` if not given. + Defaults to ``sys.argv[1:]`` if not given. :param exit: If ``True``, call ``sys.exit`` with the return code denoting - execution status, otherwise just return the rc. New in RF 3.0.1. + execution status, otherwise just return the rc. Entry point used when running tests from the command line, but can also be used by custom scripts that execute tests. Especially useful if the @@ -514,18 +572,19 @@ def run(*tests, **options): with Python ``None``. For example, using ``log=None`` is equivalent to ``--log NONE``. - ``listener``, ``prerunmodifier`` and ``prerebotmodifier`` options allow - passing values as Python objects in addition to module names these command - line options support. For example, ``run('tests', listener=MyListener())``. + ``listener``, ``prerunmodifier``, ``prerebotmodifier`` and ``console`` + options allow passing values as Python objects in addition to module + names these command line options support. For example, + ``run('tests', listener=MyListener())``. To capture the standard output and error streams, pass an open file or file-like object as special keyword arguments ``stdout`` and ``stderr``, respectively. A return code is returned similarly as when running on the command line. - Zero means that tests were executed and no critical test failed, values up - to 250 denote the number of failed critical tests, and values between - 251-255 are for other statuses documented in the Robot Framework User Guide. + Zero means that tests were executed and no test failed, values up to 250 + denote the number of failed tests, and values between 251-255 are for other + statuses documented in the Robot Framework User Guide. Example:: @@ -545,5 +604,5 @@ def run(*tests, **options): return RobotFramework().execute(*tests, **options) -if __name__ == '__main__': +if __name__ == "__main__": run_cli(sys.argv[1:]) diff --git a/src/robot/running/__init__.py b/src/robot/running/__init__.py index b3b6ee53c38..1dbe7adf718 100644 --- a/src/robot/running/__init__.py +++ b/src/robot/running/__init__.py @@ -15,20 +15,41 @@ """Implements the core test execution logic. -The main public entry points of this package are of the following two classes: - -* :class:`~robot.running.builder.TestSuiteBuilder` for creating executable - test suites based on existing test case files and directories. +The public API of this module consists of the following objects: * :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. +* :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 ` + classmethod that uses it internally. + +* Classes used by :class:`~robot.running.model.TestSuite`, such as + :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. + +* Keyword implementation related classes :class:`~robot.running.resourcemodel.UserKeyword`, + :class:`~robot.running.librarykeyword.LibraryKeyword`, + :class:`~robot.running.invalidkeyword.InvalidKeyword` and their common base class + :class:`~robot.running.keywordimplementation.KeywordImplementation`. Also these + classes are mainly 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 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`. + Keyword implementation related classes are new in Robot Framework 7.0. Examples -------- @@ -45,23 +66,21 @@ [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 suite = TestSuite('Activate Skynet') suite.resource.imports.library('OperatingSystem') test = suite.tests.create('Should Activate Skynet', tags=['smoke']) - test.keywords.create('Set Environment Variable', args=['SKYNET', 'activated'], type='setup') - test.keywords.create('Environment Variable Should Be Set', args=['SKYNET']) + test.setup.config(name='Set Environment Variable', args=['SKYNET', 'activated']) + test.body.create_keyword('Environment Variable Should Be Set', args=['SKYNET']) Not that complicated either, especially considering the flexibility. Notice that the suite created based on the file could also be edited further using @@ -72,15 +91,15 @@ :class:`~robot.result.executionresult.Result` object contains correct information:: - result = suite.run(critical='smoke', output='skynet.xml') + result = suite.run(output='skynet.xml') assert result.return_code == 0 assert result.suite.name == 'Activate Skynet' test = result.suite.tests[0] assert test.name == 'Should Activate Skynet' - assert test.passed and test.critical + assert test.passed stats = result.suite.statistics - assert stats.critical.total == 1 and stats.critical.failed == 0 + assert stats.total == 1 and stats.passed == 1 and stats.failed == 0 Running the suite generates a normal output XML file, unless it is disabled by using ``output=None``. Generating log, report, and xUnit files based on @@ -95,10 +114,45 @@ ResultWriter('skynet.xml').write_results() """ -from .builder import TestSuiteBuilder, ResourceFileBuilder -from .context import EXECUTION_CONTEXTS -from .model import Keyword, TestCase, TestSuite -from .testlibraries import TestLibrary -from .usererrorhandler import UserErrorHandler -from .userkeyword import UserLibrary -from .runkwregister import RUN_KW_REGISTER +from .arguments import ( + ArgInfo as ArgInfo, + ArgumentSpec as ArgumentSpec, + TypeConverter as TypeConverter, + TypeInfo as TypeInfo, +) +from .builder import ( + ResourceFileBuilder as ResourceFileBuilder, + TestDefaults as TestDefaults, + TestSuiteBuilder as TestSuiteBuilder, +) +from .context import EXECUTION_CONTEXTS as EXECUTION_CONTEXTS +from .invalidkeyword import InvalidKeyword as InvalidKeyword +from .keywordimplementation import KeywordImplementation as KeywordImplementation +from .librarykeyword import LibraryKeyword as LibraryKeyword +from .model import ( + Break as Break, + Continue as Continue, + Error as Error, + For as For, + ForIteration as ForIteration, + Group as Group, + If as If, + IfBranch as IfBranch, + Keyword as Keyword, + Return as Return, + TestCase as TestCase, + TestSuite as TestSuite, + Try as Try, + TryBranch as TryBranch, + Var as Var, + While as While, + WhileIteration as WhileIteration, +) +from .resourcemodel import ( + Import as Import, + ResourceFile as ResourceFile, + UserKeyword as UserKeyword, + Variable as Variable, +) +from .runkwregister import RUN_KW_REGISTER as RUN_KW_REGISTER +from .testlibraries import TestLibrary as TestLibrary diff --git a/src/robot/running/arguments/__init__.py b/src/robot/running/arguments/__init__.py index 57c5cdecae5..2c545f80e88 100644 --- a/src/robot/running/arguments/__init__.py +++ b/src/robot/running/arguments/__init__.py @@ -13,14 +13,14 @@ # See the License for the specific language governing permissions and # limitations under the License. -from robot.utils import JYTHON - -from .argumentmapper import DefaultValue -from .argumentparser import (PythonArgumentParser, UserKeywordArgumentParser, - DynamicArgumentParser, JavaArgumentParser) -from .argumentspec import ArgumentSpec -from .embedded import EmbeddedArguments -if JYTHON: - from .javaargumentcoercer import JavaArgumentCoercer -else: - JavaArgumentCoercer = None +from .argumentmapper import DefaultValue as DefaultValue +from .argumentparser import ( + DynamicArgumentParser as DynamicArgumentParser, + PythonArgumentParser as PythonArgumentParser, + UserKeywordArgumentParser as UserKeywordArgumentParser, +) +from .argumentspec import ArgInfo as ArgInfo, ArgumentSpec as ArgumentSpec +from .customconverters import CustomArgumentConverters as CustomArgumentConverters +from .embedded import EmbeddedArguments as EmbeddedArguments +from .typeconverters import TypeConverter as TypeConverter +from .typeinfo import TypeInfo as TypeInfo diff --git a/src/robot/running/arguments/argumentconverter.py b/src/robot/running/arguments/argumentconverter.py index 3f1d0415bee..2ae5500a747 100644 --- a/src/robot/running/arguments/argumentconverter.py +++ b/src/robot/running/arguments/argumentconverter.py @@ -13,52 +13,109 @@ # See the License for the specific language governing permissions and # limitations under the License. -from robot.utils import is_unicode -from robot.variables import contains_var +from typing import TYPE_CHECKING -from .typeconverters import TypeConverter +from robot.variables import contains_variable +from .typeconverters import UnknownConverter +from .typeinfo import TypeInfo -class ArgumentConverter(object): +if TYPE_CHECKING: + from robot.conf import LanguagesLike - def __init__(self, argspec, dry_run=False): - """:type argspec: :py:class:`robot.running.arguments.ArgumentSpec`""" - self._argspec = argspec - self._dry_run = dry_run + from .argumentspec import ArgumentSpec + from .customconverters import CustomArgumentConverters + + +class ArgumentConverter: + + def __init__( + self, + arg_spec: "ArgumentSpec", + custom_converters: "CustomArgumentConverters", + dry_run: bool = False, + languages: "LanguagesLike" = None, + ): + self.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 - converted = [self._convert(name, value) - for name, value in zip(names, positional)] - if self._argspec.varargs: - converted.extend(self._convert(self._argspec.varargs, value) - for value in positional[len(names):]) + names = self.spec.positional + converted = [self._convert(n, v) for n, v in zip(names, positional)] + 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._argspec.positional) | set(self._argspec.kwonlyargs) - kwargs = self._argspec.kwargs - return [(name, self._convert(name if name in names else kwargs, value)) - for name, value in 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): - type_, explicit_type = self._get_type(name, value) - if type_ is None or (self._dry_run and - contains_var(value, identifiers='$@&%')): + spec = self.spec + if ( + spec.types is None + or self.dry_run + and contains_variable(value, identifiers="$@&%") + ): return value - converter = TypeConverter.converter_for(type_) - if converter is None: + 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. 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 - return converter.convert(name, value, explicit_type) - - def _get_type(self, name, value): - if self._argspec.types is None or not is_unicode(value): - return None, None - if name in self._argspec.types: - return self._argspec.types[name], True - if name in self._argspec.defaults: - return type(self._argspec.defaults[name]), False - return None, None + # Primarily convert arguments based on type hints. + if name in spec.types: + info: TypeInfo = spec.types[name] + converter = info.get_converter( + self.custom_converters, + self.languages, + allow_unknown=True, + ) + # If type is unknown, don't attempt conversion. It would succeed, but + # we want to, for now, attempt conversion based on the default value. + if not isinstance(converter, UnknownConverter): + try: + return converter.convert(value, name) + 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 is str: + # Don't convert arguments to strings. + info = TypeInfo() + elif typ is int: + # Try also conversion to float. + info = TypeInfo.from_sequence([int, float]) + elif typ is type(None) and isinstance(value, str) and value == "": + # Don't convert empty string to None. + info = TypeInfo() + else: + info = TypeInfo.from_type(typ) + try: + return info.convert(value, name, languages=self.languages) + except (ValueError, TypeError): + pass + if conversion_error: + raise conversion_error + return value diff --git a/src/robot/running/arguments/argumentmapper.py b/src/robot/running/arguments/argumentmapper.py index 0c6357915de..6a35f45225c 100644 --- a/src/robot/running/arguments/argumentmapper.py +++ b/src/robot/running/arguments/argumentmapper.py @@ -13,62 +13,66 @@ # 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(object): +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: template.replace_defaults() - return template.args, template.kwargs + return template.positional, template.named -class KeywordCallTemplate(object): +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] - self.kwargs = [] + def __init__(self, spec: "ArgumentSpec"): + self.spec = spec + self.positional = [ + DefaultValue(spec.defaults[arg]) if arg in spec.defaults else None + for arg in spec.positional + ] + self.named = [] def fill_positional(self, positional): - self.args[:len(positional)] = positional + self.positional[: len(positional)] = positional def fill_named(self, named): - spec = self._argspec + spec = self.spec for name, value in named: - if name in spec.positional and spec.supports_named: - index = spec.positional.index(name) - self.args[index] = value - elif spec.kwargs or name in spec.kwonlyargs: - self.kwargs.append((name, value)) + if name in spec.positional_or_named: + index = spec.positional_or_named.index(name) + self.positional[index] = value + elif spec.var_named or name in spec.named_only: + self.named.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.kwonlyargs: + for name in spec.named_only: if name not in named_names: value = DefaultValue(spec.defaults[name]) - self.kwargs.append((name, value)) + self.named.append((name, value)) def replace_defaults(self): is_default = lambda arg: isinstance(arg, DefaultValue) - while self.args and is_default(self.args[-1]): - self.args.pop() - self.args = [a if not is_default(a) else a.value for a in self.args] - self.kwargs = [(n, v) for n, v in self.kwargs if not is_default(v)] + while self.positional and is_default(self.positional[-1]): + self.positional.pop() + self.positional = [a if not is_default(a) else a.value for a in self.positional] + self.named = [(n, v) for n, v in self.named if not is_default(v)] -class DefaultValue(object): +class DefaultValue: def __init__(self, value): self.value = value @@ -77,5 +81,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/argumentparser.py b/src/robot/running/arguments/argumentparser.py index a630a886d90..4bd2e6d37ab 100644 --- a/src/robot/running/arguments/argumentparser.py +++ b/src/robot/running/arguments/argumentparser.py @@ -13,275 +13,316 @@ # See the License for the specific language governing permissions and # limitations under the License. -from robot.errors import DataError -from robot.utils import JYTHON, PY_VERSION, PY2 -from robot.variables import is_dict_var, is_list_var, is_scalar_var - -from .argumentspec import ArgumentSpec +from abc import ABC, abstractmethod +from inspect import isclass, Parameter, signature +from typing import Any, Callable, get_type_hints +try: + from annotationlib import Format +except ImportError: + Format = None -if PY2: - from inspect import getargspec, ismethod +from robot.errors import DataError +from robot.utils import NOT_SET, split_from_equals +from robot.variables import search_variable - def getfullargspec(func): - return getargspec(func) + ([], None, {}) -else: - from inspect import getfullargspec, ismethod +from .argumentspec import ArgumentSpec +from .typeinfo import TypeInfo -if PY_VERSION >= (3, 5): - import typing -else: - typing = None -if JYTHON: - from java.lang import Class - from java.util import List, Map +class ArgumentParser(ABC): + def __init__( + self, + type: str = "Keyword", + error_reporter: "Callable[[str], None] | None" = None, + ): + self.type = type + self.error_reporter = error_reporter -class _ArgumentParser(object): + @abstractmethod + def parse(self, source: Any, name: "str | None" = None) -> ArgumentSpec: + raise NotImplementedError - def __init__(self, type='Keyword'): - self._type = type + def _report_error(self, error: str): + if self.error_reporter: + self.error_reporter(error) + else: + raise DataError(f"Invalid argument specification: {error}") - def parse(self, source, name=None): - raise NotImplementedError +class PythonArgumentParser(ArgumentParser): -class PythonArgumentParser(_ArgumentParser): + def parse(self, method, name=None): + try: + config = {"annotation_format": Format.FORWARDREF} if Format else {} + sig = signature(method, **config) + except ValueError: # Can occur with C functions (incl. many builtins). + return ArgumentSpec(name, self.type, var_positional="args") + except Exception as err: + 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) + # so we need to handle that case ourselves. + # Partial objects do not have __name__ at least in Python =< 3.10. + if getattr(method, "__name__", None) == "__init__": + parameters = parameters[1:] + spec = self._create_spec(parameters, name) + self._set_types(spec, method) + return spec - def parse(self, handler, name=None): - args, varargs, kwargs, defaults, kwonly, kwonlydefaults, annotations \ - = getfullargspec(handler) - if ismethod(handler) or handler.__name__ == '__init__': - args = args[1:] # drop 'self' - spec = ArgumentSpec( + def _create_spec(self, parameters, name): + positional_only = [] + positional_or_named = [] + var_positional = None + named_only = [] + var_named = None + defaults = {} + for param in parameters: + kind = param.kind + if kind == Parameter.POSITIONAL_ONLY: + positional_only.append(param.name) + elif kind == Parameter.POSITIONAL_OR_KEYWORD: + positional_or_named.append(param.name) + elif kind == Parameter.VAR_POSITIONAL: + var_positional = param.name + elif kind == Parameter.KEYWORD_ONLY: + named_only.append(param.name) + elif kind == Parameter.VAR_KEYWORD: + var_named = param.name + if param.default is not param.empty: + defaults[param.name] = param.default + return ArgumentSpec( name, - self._type, - positional=args, - varargs=varargs, - kwargs=kwargs, - kwonlyargs=kwonly, - defaults=self._get_defaults(args, defaults, kwonlydefaults) + self.type, + positional_only, + positional_or_named, + var_positional, + named_only, + var_named, + defaults, ) - spec.types = self._get_types(handler, annotations, spec) - return spec - def _get_defaults(self, args, default_values, kwonlydefaults): - if default_values: - defaults = dict(zip(args[-len(default_values):], default_values)) - else: - defaults = {} - if kwonlydefaults: - defaults.update(kwonlydefaults) - return defaults - - def _get_types(self, handler, annotations, spec): - types = getattr(handler, 'robot_types', ()) - if types is None: - return None - if types: + def _set_types(self, spec, method): + types = self._get_types(method) + if isinstance(types, dict) and "return" in types: + spec.return_type = types.pop("return") + spec.types = types + + def _get_types(self, method): + # If types are set using the `@keyword` decorator, use them. Including when + # types are explicitly disabled with `@keyword(types=None)`. Otherwise get + # type hints. + if isclass(method): + method = method.__init__ + types = getattr(method, "robot_types", ()) + if types or types is None: return types - return self._get_type_hints(handler, annotations, spec) - - def _get_type_hints(self, handler, annotations, spec): - if not typing: - return annotations try: - type_hints = typing.get_type_hints(handler) + return get_type_hints(method) except Exception: # Can raise pretty much anything - return annotations - self._remove_mismatching_type_hints(type_hints, spec.argument_names) - self._remove_optional_none_type_hints(type_hints, spec.defaults) - return type_hints - - def _remove_mismatching_type_hints(self, type_hints, argument_names): - # typing.get_type_hints returns info from the original function even - # if it is decorated. Argument names are got from the wrapping - # decorator and thus there is a mismatch that needs to be resolved. - mismatch = set(type_hints) - set(argument_names) - for name in mismatch: - type_hints.pop(name) - - def _remove_optional_none_type_hints(self, type_hints, defaults): - # If argument has None as a default, typing.get_type_hints adds - # optional None to the information it returns. We don't want that. - for arg in defaults: - if defaults[arg] is None and arg in type_hints: - type_ = type_hints[arg] - if self._is_union(type_): - types = type_.__args__ - if len(types) == 2 and types[1] is type(None): - type_hints[arg] = types[0] - - def _is_union(self, type_): - if PY_VERSION >= (3, 7) and hasattr(type_, '__origin__'): - type_ = type_.__origin__ - return isinstance(type_, type(typing.Union)) - - -class JavaArgumentParser(_ArgumentParser): - - def parse(self, signatures, name=None): - if not signatures: - return self._no_signatures_arg_spec(name) - elif len(signatures) == 1: - return self._single_signature_arg_spec(signatures[0], name) - else: - return self._multi_signature_arg_spec(signatures, name) - - def _no_signatures_arg_spec(self, name): - # Happens when a class has no public constructors - return self._format_arg_spec(name) - - def _single_signature_arg_spec(self, signature, name): - varargs, kwargs = self._get_varargs_and_kwargs_support(signature.args) - positional = len(signature.args) - int(varargs) - int(kwargs) - return self._format_arg_spec(name, positional, varargs=varargs, - kwargs=kwargs) - - def _get_varargs_and_kwargs_support(self, args): - if not args: - return False, False - if self._is_varargs_type(args[-1]): - return True, False - if not self._is_kwargs_type(args[-1]): - return False, False - if len(args) > 1 and self._is_varargs_type(args[-2]): - return True, True - return False, True - - def _is_varargs_type(self, arg): - return arg is List or isinstance(arg, Class) and arg.isArray() - - def _is_kwargs_type(self, arg): - return arg is Map - - def _multi_signature_arg_spec(self, signatures, name): - mina = maxa = len(signatures[0].args) - for sig in signatures[1:]: - argc = len(sig.args) - mina = min(argc, mina) - maxa = max(argc, maxa) - return self._format_arg_spec(name, maxa, maxa-mina) - - def _format_arg_spec(self, name, positional=0, defaults=0, varargs=False, - kwargs=False): - positional = ['arg%d' % (i+1) for i in range(positional)] - if defaults: - defaults = {name: '' for name in positional[-defaults:]} - else: - defaults = {} - return ArgumentSpec(name, self._type, - positional=positional, - varargs='varargs' if varargs else None, - kwargs='kwargs' if kwargs else None, - defaults=defaults, - supports_named=False) - - -class _ArgumentSpecParser(_ArgumentParser): - - def parse(self, argspec, name=None): - spec = ArgumentSpec(name, self._type) - kw_only_args = False - for arg in argspec: - if spec.kwargs: - self._raise_invalid_spec('Only last argument can be kwargs.') - elif self._is_kwargs(arg): - self._add_kwargs(spec, arg) - elif self._is_kw_only_separator(arg): - if spec.varargs or kw_only_args: - self._raise_invalid_spec('Cannot have multiple varargs.') - kw_only_args = True - elif self._is_varargs(arg): - if spec.varargs or kw_only_args: - self._raise_invalid_spec('Cannot have multiple varargs.') - self._add_varargs(spec, arg) - kw_only_args = True - elif '=' in arg: - self._add_arg_with_default(spec, arg, kw_only_args) - elif spec.defaults and not kw_only_args: - self._raise_invalid_spec('Non-default argument after default ' - 'arguments.') + try: + # Not all functions have `__annotations__`. + # https://github.com/robotframework/robotframework/issues/4059 + return getattr(method, "__annotations__", {}) + except Exception: + # Handle deferred annotations on Python 3.14. + # https://github.com/robotframework/robotframework/issues/5658 + if Format: + return get_type_hints(method, format=Format.STRING) + return {} + + +class ArgumentSpecParser(ArgumentParser): + + def parse(self, arguments, name=None): + positional_only = [] + positional_or_named = [] + var_positional = None + named_only = [] + var_named = None + defaults = {} + types = {} + named_only_separator_seen = positional_only_separator_seen = False + target = positional_or_named + for arg in arguments: + arg, default = self._validate_arg(arg) + if var_named: + self._report_error("Only last argument can be kwargs.") + elif self._is_positional_only_separator(arg): + if positional_only_separator_seen: + self._report_error("Too many positional-only separators.") + if named_only_separator_seen: + self._report_error( + "Positional-only separator must be before named-only arguments." + ) + positional_only = positional_or_named + target = positional_or_named = [] + positional_only_separator_seen = True + elif default is not NOT_SET: + self._parse_type(arg, types) + arg = self._format_arg(arg) + target.append(arg) + defaults[arg] = default + elif self._is_var_named(arg): + self._parse_type(arg, types) + var_named = self._format_var_named(arg) + elif self._is_var_positional(arg): + if named_only_separator_seen: + self._report_error("Cannot have multiple varargs.") + elif not self._is_named_only_separator(arg): + self._parse_type(arg, types) + var_positional = self._format_var_positional(arg) + named_only_separator_seen = True + target = named_only else: - self._add_arg(spec, arg, kw_only_args) - return spec + if defaults and not named_only_separator_seen: + self._report_error("Non-default argument after default arguments.") + self._parse_type(arg, types) + arg = self._format_arg(arg) + target.append(arg) + return ArgumentSpec( + name, + self.type, + positional_only, + positional_or_named, + var_positional, + named_only, + var_named, + defaults, + types=types, + ) - def _raise_invalid_spec(self, error): - raise DataError('Invalid argument specification: %s' % error) + @abstractmethod + def _validate_arg(self, arg): + raise NotImplementedError - def _is_kwargs(self, arg): + @abstractmethod + def _is_var_positional(self, arg): raise NotImplementedError - def _add_kwargs(self, spec, kwargs): - spec.kwargs = self._format_kwargs(kwargs) + @abstractmethod + def _is_var_named(self, arg): + raise NotImplementedError - def _format_kwargs(self, kwargs): + @abstractmethod + def _is_positional_only_separator(self, arg): 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 _format_arg(self, arg): raise NotImplementedError - def _add_varargs(self, spec, varargs): - spec.varargs = self._format_varargs(varargs) + @abstractmethod + def _format_var_named(self, arg): + raise NotImplementedError - def _format_varargs(self, varargs): + @abstractmethod + def _format_var_positional(self, arg): raise NotImplementedError - def _add_arg_with_default(self, spec, arg, kw_only_arg=False): - arg, default = arg.split('=', 1) - arg = self._add_arg(spec, arg, kw_only_arg) - spec.defaults[arg] = default + @abstractmethod + def _parse_type(self, arg, types): + raise NotImplementedError - def _format_arg(self, arg): - return arg - def _add_arg(self, spec, arg, kw_only_arg=False): - arg = self._format_arg(arg) - target = spec.positional if not kw_only_arg else spec.kwonlyargs - target.append(arg) - return arg +class DynamicArgumentParser(ArgumentSpecParser): + + def _validate_arg(self, arg): + if isinstance(arg, tuple): + if not self._is_valid_tuple(arg): + self._report_error(f'Invalid argument "{arg}".') + return None, NOT_SET + if len(arg) == 1: + return arg[0], NOT_SET + return arg[0], arg[1] + if "=" in arg: + return tuple(arg.split("=", 1)) + return arg, NOT_SET + + def _is_valid_tuple(self, arg): + return ( + len(arg) in (1, 2) + and isinstance(arg[0], str) + and not (arg[0].startswith("*") and len(arg) == 2) + ) + def _is_var_positional(self, arg): + return arg[:1] == "*" -class DynamicArgumentParser(_ArgumentSpecParser): + def _is_var_named(self, arg): + return arg[:2] == "**" - def _is_kwargs(self, arg): - return arg.startswith('**') + def _is_positional_only_separator(self, arg): + return arg == "/" - def _format_kwargs(self, kwargs): - return kwargs[2:] + def _is_named_only_separator(self, arg): + return arg == "*" - def _is_kw_only_separator(self, arg): - return arg == '*' + def _format_arg(self, arg): + return arg - def _is_varargs(self, arg): - return arg.startswith('*') and not self._is_kwargs(arg) + def _format_var_positional(self, arg): + return arg[1:] - def _format_varargs(self, varargs): - return varargs[1:] + def _format_var_named(self, arg): + return arg[2:] + def _parse_type(self, arg, types): + pass -class UserKeywordArgumentParser(_ArgumentSpecParser): - def _is_kwargs(self, arg): - return is_dict_var(arg) +class UserKeywordArgumentParser(ArgumentSpecParser): - def _format_kwargs(self, kwargs): - return kwargs[2:-1] + def _validate_arg(self, arg): + arg, default = split_from_equals(arg) + match = search_variable(arg, parse_type=True, ignore_errors=True) + if not (match.is_assign() or self._is_named_only_separator(match)): + self._report_error(f"Invalid argument syntax '{arg}'.") + match = search_variable("") + default = NOT_SET + elif default is None: + default = NOT_SET + elif arg[0] != "$": + kind = "list" if arg[0] == "@" else "dictionary" + self._report_error( + f"Only normal arguments accept default values, " + f"{kind} arguments like '{arg}' do not." + ) + default = NOT_SET + return match, default - def _is_varargs(self, arg): - return is_list_var(arg) + def _is_var_positional(self, match): + return match.identifier == "@" - def _format_varargs(self, varargs): - return varargs[2:-1] + def _is_var_named(self, match): + return match.identifier == "&" - def _is_kw_only_separator(self, arg): - return arg == '@{}' + def _is_positional_only_separator(self, arg): + return False - def _format_arg(self, arg): - if not is_scalar_var(arg): - self._raise_invalid_spec("Invalid argument syntax '%s'." % arg) - return arg[2:-1] + def _is_named_only_separator(self, match): + return match.identifier == "@" and not match.base + + def _format_arg(self, match): + return match.base + + def _format_var_named(self, match): + return match.base + + def _format_var_positional(self, match): + return match.base + + def _parse_type(self, match, types): + try: + info = TypeInfo.from_variable(match, handle_list_and_dict=False) + except DataError as err: + self._report_error(f"Invalid argument '{match}': {err}") + else: + if info: + types[match.base] = info diff --git a/src/robot/running/arguments/argumentresolver.py b/src/robot/running/arguments/argumentresolver.py index dcd2fcf20e6..082bee0d594 100644 --- a/src/robot/running/arguments/argumentresolver.py +++ b/src/robot/running/arguments/argumentresolver.py @@ -13,115 +13,148 @@ # 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 VariableSplitter +from robot.utils import is_dict_like, split_from_equals +from robot.variables import is_dict_variable +from ..model import Argument from .argumentvalidator import ArgumentValidator - -class ArgumentResolver(object): - - 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 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) +if TYPE_CHECKING: + from .argumentspec import ArgumentSpec + + +class ArgumentResolver: + + def __init__( + self, + spec: "ArgumentSpec", + resolve_named: bool = True, + resolve_args_until: "int | None" = None, + dict_to_kwargs: bool = False, + ): + self.named_resolver = ( + NamedArgumentResolver(spec) + if resolve_named + else NullNamedArgumentResolver() + ) + self.variable_replacer = VariableReplacer(spec, resolve_args_until) + self.dict_to_kwargs = DictToKwargs(spec, dict_to_kwargs) + self.argument_validator = ArgumentValidator(spec) + + def resolve(self, args, named_args=None, variables=None): + if named_args is None: + positional, named = self.named_resolver.resolve(args, variables) + else: + positional, named = args, list(named_args.items()) + 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(object): +class NamedArgumentResolver: - def __init__(self, argspec): - self._argspec = argspec + def __init__(self, spec: "ArgumentSpec"): + self.spec = spec def resolve(self, arguments, variables=None): - positional = [] + known_positional_count = max( + len(self.spec.positional_only), + len(self.spec.embedded), + ) + positional = list(arguments[:known_positional_count]) named = [] - for arg in arguments: - if self._is_dict_var(arg): + for arg in arguments[known_positional_count:]: + 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) + name, value = self._split_named(arg, named, variables) + if name is not None: + named.append((name, value)) + elif named: + self._raise_positional_after_named() + else: + positional.append(value) return positional, named - def _is_dict_var(self, arg): - return (is_string(arg) and arg[:2] == '&{' and arg[-1] == '}' and - VariableSplitter(arg).is_dict_variable()) - - def _is_named(self, arg, previous_named, variables=None): + def _split_named(self, arg, previous_named, variables): + if isinstance(arg, Argument): + return arg.name, arg.value name, value = split_from_equals(arg) - if value is None: - return False - if variables: - name = variables.replace_scalar(name) - argspec = self._argspec - if previous_named or name in argspec.kwonlyargs or argspec.kwargs: + if value is None or not self._is_named(name, previous_named, variables): + return None, arg + return name, value + + def _is_named(self, name, previous_named, variables): + if previous_named or self.spec.var_named: return True - return argspec.supports_named and name in argspec.positional + if variables: + try: + name = variables.replace_scalar(name) + except DataError: + return False + return name in self.spec.named def _raise_positional_after_named(self): - raise DataError("%s '%s' got positional argument after named arguments." - % (self._argspec.type, self._argspec.name)) + raise DataError( + f"{self.spec.type.capitalize()} '{self.spec.name}' " + f"got positional argument after named arguments." + ) -class NullNamedArgumentResolver(object): +class NullNamedArgumentResolver: def resolve(self, arguments, variables=None): - return arguments, {} + return arguments, [] -class DictToKwargs(object): +class DictToKwargs: - def __init__(self, argspec, enabled=False): - self._maxargs = argspec.maxargs - self._enabled = enabled and bool(argspec.kwargs) + 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): + 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(object): +class VariableReplacer: - def __init__(self, resolve_until=None): - self._resolve_until = resolve_until + 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: - positional = list(positional) - named = [item for item in named if isinstance(item, tuple)] + # If `var` isn't a tuple, it's a &{dict} variables. + named = [var if isinstance(var, tuple) else (var, var) for var in named] return positional, named def _replace_named(self, named, replace_scalar): for item in named: for name, value in self._get_replaced_named(item, replace_scalar): - if not is_string(name): - raise DataError('Argument names must be strings.') + if not isinstance(name, str): + raise DataError("Argument names must be strings.") yield name, value def _get_replaced_named(self, item, replace_scalar): diff --git a/src/robot/running/arguments/argumentspec.py b/src/robot/running/arguments/argumentspec.py index 49bd99135e1..3396f730739 100644 --- a/src/robot/running/arguments/argumentspec.py +++ b/src/robot/running/arguments/argumentspec.py @@ -14,58 +14,309 @@ # limitations under the License. import sys +from enum import Enum +from typing import Any, Callable, Iterator, Mapping, NoReturn, Sequence -from robot.utils import setter +if sys.version_info >= (3, 11): + from typing import Never +else: + try: + from typing_extensions import Never + except ImportError: + Never = NoReturn + +from robot.utils import NOT_SET, safe_str, setter, SetterAwareType from .argumentconverter import ArgumentConverter from .argumentmapper import ArgumentMapper from .argumentresolver import ArgumentResolver -from .typevalidator import TypeValidator +from .typeinfo import TypeInfo +from .validators import DocValidator, TypeValidator -class ArgumentSpec(object): +class ArgumentSpec(metaclass=SetterAwareType): + __slots__ = ( + "_name", + "type", + "positional_only", + "positional_or_named", + "var_positional", + "named_only", + "var_named", + "embedded", + "defaults", + "return_doc", + "raises", + ) - def __init__(self, name=None, type='Keyword', positional=None, - varargs=None, kwonlyargs=None, kwargs=None, defaults=None, - types=None, supports_named=True): + def __init__( + self, + name: "str | Callable[[], str] | None" = None, + type: str = "Keyword", + positional_only: Sequence[str] = (), + positional_or_named: Sequence[str] = (), + var_positional: "str | None" = None, + named_only: Sequence[str] = (), + var_named: "str | None" = None, + defaults: "Mapping[str, Any] | None" = None, + embedded: Sequence[str] = (), + types: "Mapping | Sequence | None" = None, + return_type: "object | TypeInfo | type[NOT_SET]" = NOT_SET, + docs: "Mapping[str, str] | None" = None, + return_doc: str = "", + raises: "Mapping[str, str] | None" = None, + ): self.name = name self.type = type - self.positional = positional or [] - self.varargs = varargs - self.kwonlyargs = kwonlyargs or [] - self.kwargs = kwargs - self.defaults = defaults or {} + self.positional_only = tuple(positional_only) + self.positional_or_named = tuple(positional_or_named) + self.var_positional = var_positional + self.named_only = tuple(named_only) + self.var_named = var_named + self.embedded = tuple(embedded) + self.defaults = dict(defaults or {}) self.types = types - self.supports_named = supports_named + self.return_type = return_type + # Following are only used by Libdoc. + self.docs = docs + self.return_doc = return_doc + self.raises = raises + + @property + def name(self) -> "str | None": + return self._name if not callable(self._name) else self._name() + + @name.setter + def name(self, name: "str | Callable[[], str] | None"): + self._name = name @setter - def types(self, types): + def types(self, types: "Mapping | Sequence | None") -> "dict[str, TypeInfo] | None": return TypeValidator(self).validate(types) + @setter + def return_type(self, hint: object) -> TypeInfo: + if isinstance(hint, TypeInfo): + return hint + if hint is Never or hint is NoReturn: + hint = NOT_SET + return TypeInfo.from_type_hint(hint, sequence_is_union=True) + + @setter + def docs(self, docs: "Mapping[str, str] | None") -> "dict[str, str] | None": + return DocValidator(self).validate(docs) + @property - def minargs(self): - required = [arg for arg in self.positional if arg not in self.defaults] - return len(required) + def positional(self) -> "tuple[str, ...]": + return self.positional_only + self.positional_or_named @property - def maxargs(self): - return len(self.positional) if not self.varargs else sys.maxsize + def named(self) -> "tuple[str, ...]": + return self.named_only + self.positional_or_named @property - def argument_names(self): - return (self.positional + ([self.varargs] if self.varargs else []) + - self.kwonlyargs + ([self.kwargs] if self.kwargs else [])) - - def resolve(self, arguments, variables=None, resolve_named=True, - resolve_variables_until=None, dict_to_kwargs=False): - resolver = ArgumentResolver(self, resolve_named, - resolve_variables_until, dict_to_kwargs) - positional, named = resolver.resolve(arguments, variables) + def minargs(self) -> int: + return len([arg for arg in self.positional if arg not in self.defaults]) + + @property + def maxargs(self) -> int: + return len(self.positional) if not self.var_positional else sys.maxsize + + @property + def argument_names(self) -> "tuple[str, ...]": + var_positional = (self.var_positional,) if self.var_positional else () + var_named = (self.var_named,) if self.var_named else () + return ( + self.positional_only + + self.positional_or_named + + var_positional + + self.named_only + + var_named + ) + + def resolve( + self, + args, + named_args=None, + variables=None, + converters=None, + resolve_named=True, + resolve_args_until=None, + dict_to_kwargs=False, + languages=None, + ) -> "tuple[list, list]": + resolver = ArgumentResolver( + self, + resolve_named, + resolve_args_until, + dict_to_kwargs, + ) + positional, named = resolver.resolve(args, named_args, variables) + return self.convert( + positional, + named, + converters, + dry_run=not variables, + languages=languages, + ) + + def convert( + self, + positional, + named, + converters=None, + dry_run=False, + languages=None, + ) -> "tuple[list, list]": if self.types or self.defaults: - converter = ArgumentConverter(self, dry_run=not variables) + converter = ArgumentConverter(self, converters, dry_run, languages) positional, named = converter.convert(positional, named) return positional, named - def map(self, positional, named, replace_defaults=True): + def map( + self, + positional, + named, + replace_defaults=True, + ) -> "tuple[list, list]": mapper = ArgumentMapper(self) return mapper.map(positional, named, replace_defaults) + + def copy(self) -> "ArgumentSpec": + types = dict(self.types) if self.types is not None else None + return type(self)( + self.name, + self.type, + self.positional_only, + self.positional_or_named, + self.var_positional, + self.named_only, + self.var_named, + dict(self.defaults), + self.embedded, + types, + self.return_type, + self.docs, + self.return_doc, + self.raises, + ) + + def __iter__(self) -> Iterator["ArgInfo"]: + get_type = (self.types or {}).get + get_doc = (self.docs or {}).get + get_default = self.defaults.get + for arg in self.positional_only: + yield ArgInfo( + ArgInfo.POSITIONAL_ONLY, + arg, + get_type(arg), + get_default(arg, NOT_SET), + get_doc(arg, ""), + ) + 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), + get_default(arg, NOT_SET), + get_doc(arg, ""), + ) + if self.var_positional: + yield ArgInfo( + ArgInfo.VAR_POSITIONAL, + self.var_positional, + get_type(self.var_positional), + doc=get_doc(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), + get_default(arg, NOT_SET), + get_doc(arg, ""), + ) + if self.var_named: + yield ArgInfo( + ArgInfo.VAR_NAMED, + self.var_named, + get_type(self.var_named), + doc=get_doc(self.var_named, ""), + ) + + def __bool__(self): + return any(self) + + def __str__(self): + return ", ".join(str(arg) for arg in self) + + +class ArgInfo: + """Contains argument information. Only used by Libdoc.""" + + POSITIONAL_ONLY = "POSITIONAL_ONLY" + POSITIONAL_ONLY_MARKER = "POSITIONAL_ONLY_MARKER" + POSITIONAL_OR_NAMED = "POSITIONAL_OR_NAMED" + VAR_POSITIONAL = "VAR_POSITIONAL" + NAMED_ONLY_MARKER = "NAMED_ONLY_MARKER" + NAMED_ONLY = "NAMED_ONLY" + VAR_NAMED = "VAR_NAMED" + + def __init__( + self, + kind: str, + name: str = "", + type: "TypeInfo | None" = None, + default: Any = NOT_SET, + doc: str = "", + ): + self.kind = kind + self.name = name + self.type = type or TypeInfo() + self.default = default + self.doc = doc + + @property + def required(self) -> bool: + if self.kind in ( + self.POSITIONAL_ONLY, + self.POSITIONAL_OR_NAMED, + self.NAMED_ONLY, + ): + return self.default is NOT_SET + return False + + @property + def default_repr(self) -> "str | None": + if self.default is NOT_SET: + return None + if isinstance(self.default, Enum): + return self.default.name + return safe_str(self.default) + + @property + def is_marker(self): + return self.kind in (self.POSITIONAL_ONLY_MARKER, self.NAMED_ONLY_MARKER) + + def __str__(self): + if self.kind == self.POSITIONAL_ONLY_MARKER: + return "/" + if self.kind == self.NAMED_ONLY_MARKER: + return "*" + ret = self.name + if self.kind == self.VAR_POSITIONAL: + ret = "*" + ret + elif self.kind == self.VAR_NAMED: + ret = "**" + ret + if self.type: + ret = f"{ret}: {self.type}" + default_sep = " = " + else: + default_sep = "=" + if self.default is not NOT_SET: + ret = f"{ret}{default_sep}{self.default_repr}" + return ret diff --git a/src/robot/running/arguments/argumentvalidator.py b/src/robot/running/arguments/argumentvalidator.py index c71f25f97b7..20c79bac3b0 100644 --- a/src/robot/running/arguments/argumentvalidator.py +++ b/src/robot/running/arguments/argumentvalidator.py @@ -13,26 +13,42 @@ # 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.variables import is_list_var +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(object): +class ArgumentValidator: - def __init__(self, argspec): - """:type argspec: :py:class:`robot.running.arguments.ArgumentSpec`""" - self._argspec = argspec + def __init__(self, arg_spec: "ArgumentSpec"): + self.spec = arg_spec def validate(self, positional, named, dryrun=False): - if dryrun and any(is_list_var(arg) for arg in positional): + named = {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 - named = set(name for name, value in named) - self._validate_no_multiple_values(positional, 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.spec) + self._validate_positional_limits(positional, named, self.spec) + self._validate_no_mandatory_missing(positional, named, self.spec) + self._validate_no_named_only_missing(named, self.spec) + self._validate_no_extra_named(named, self.spec) + + def _validate_no_multiple_values(self, positional, named, spec): + 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}'") + + def _raise_error(self, message): + name = f"'{self.spec.name}' " if self.spec.name else "" + raise DataError(f"{self.spec.type.capitalize()} {name}{message}.") def _validate_positional_limits(self, positional, named, spec): count = len(positional) + self._named_positionals(named, spec) @@ -40,48 +56,39 @@ def _validate_positional_limits(self, positional, named, spec): self._raise_wrong_count(count, spec) def _named_positionals(self, named, spec): - if not spec.supports_named: - return 0 - return sum(1 for n in named if n in spec.positional) + 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) - elif not spec.varargs: - expected = '%d to %d arguments' % (spec.minargs, spec.maxargs) + 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"{minargs} to {maxargs} arguments" else: - expected = 'at least %d argument%s' % (spec.minargs, minend) - if spec.kwargs or spec.kwonlyargs: - expected = expected.replace('argument', 'non-named argument') - raise DataError("%s '%s' expected %s, got %d." - % (spec.type, spec.name, expected, count)) - - def _validate_no_multiple_values(self, positional, named, spec): - if named and spec.supports_named: - for name in spec.positional[:len(positional)]: - if name in named: - raise DataError("%s '%s' got multiple values for argument " - "'%s'." % (spec.type, spec.name, name)) + 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 - embedded}") def _validate_no_mandatory_missing(self, positional, named, spec): - for name in spec.positional[len(positional):spec.minargs]: - if name not in named: - raise DataError("%s '%s' missing value for argument '%s'." - % (spec.type, spec.name, name)) + for name in spec.positional[len(positional) :]: + if name not in spec.defaults and name not in named: + 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.kwonlyargs if arg not in defined] + missing = [arg for arg in spec.named_only if arg not in defined] if missing: - raise DataError("%s '%s' missing named-only argument%s %s." - % (spec.type, spec.name, plural_or_not(missing), - seq2str(sorted(missing)))) + self._raise_error( + f"missing named-only argument{s(missing)} {seq2str(sorted(missing))}" + ) def _validate_no_extra_named(self, named, spec): - if not spec.kwargs: - extra = set(named) - set(spec.positional) - set(spec.kwonlyargs) + if not spec.var_named: + extra = set(named) - set(spec.positional_or_named) - set(spec.named_only) if extra: - raise DataError("%s '%s' got unexpected named argument%s %s." - % (spec.type, spec.name, plural_or_not(extra), - seq2str(sorted(extra)))) + self._raise_error( + f"got unexpected named argument{s(extra)} {seq2str(sorted(extra))}" + ) diff --git a/src/robot/running/arguments/customconverters.py b/src/robot/running/arguments/customconverters.py new file mode 100644 index 00000000000..a30a3ba3508 --- /dev/null +++ b/src/robot/running/arguments/customconverters.py @@ -0,0 +1,130 @@ +# 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 getdoc, seq2str, type_name + + +class CustomArgumentConverters: + + def __init__(self, converters): + self.converters = converters + + @classmethod + 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) + return cls(valid) + + def get_converter_info(self, type_): + if isinstance(type_, type): + for conv in self.converters: + if issubclass(type_, conv.type): + return conv + return None + + def __iter__(self): + return iter(self.converters) + + def __len__(self): + return len(self.converters) + + +class ConverterInfo: + + 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): + return type_name(self.type) + + @property + def doc(self): + return getdoc(self.converter) or getdoc(self.type) + + @classmethod + 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}." + ) + 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)}." + ) + spec = cls._get_arg_spec(converter) + type_info = spec.types.get( + spec.positional[0] if spec.positional else spec.var_positional + ) + if type_info is None: + accepts = () + elif type_info.is_union: + accepts = type_info.nested + else: + 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) + + @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]) + raise TypeError( + f"Custom converters cannot have more than two mandatory " + f"arguments, '{converter.__name__}' has {required}." + ) + 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): + 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 + + def convert(self, value): + if not self.library: + return self.converter(value) + return self.converter(value, self.library.instance) diff --git a/src/robot/running/arguments/embedded.py b/src/robot/running/arguments/embedded.py index 98540fa45d7..0a48997e9bb 100644 --- a/src/robot/running/arguments/embedded.py +++ b/src/robot/running/arguments/embedded.py @@ -14,75 +14,201 @@ # limitations under the License. import re +import warnings +from typing import Mapping, Sequence from robot.errors import DataError -from robot.utils import get_error_message, py2to3 -from robot.variables import VariableIterator - - -@py2to3 -class EmbeddedArguments(object): - - def __init__(self, name): - if '${' in name: - self.name, self.args = EmbeddedArgumentParser().parse(name) - else: - self.name, self.args = None, [] - - def __nonzero__(self): - return self.name is not None - - -class EmbeddedArgumentParser(object): - _regexp_extension = re.compile(r'(? "EmbeddedArguments | None": + return EmbeddedArgumentParser().parse(name) if "${" in name else None + + def match(self, name: str) -> "re.Match | None": + """Deprecated since Robot Framework 7.3.""" + warnings.warn( + "'EmbeddedArguments.match()' is deprecated since Robot Framework 7.3. Use " + "new 'EmbeddedArguments.matches()' or 'EmbeddedArguments.parse_args()' " + "instead. Alternatively, use 'EmbeddedArguments.name.fullmatch()' to " + "preserve the old behavior and to be compatible with earlier Robot " + "Framework versions." + ) + return self.name.fullmatch(name) + + def matches(self, name: str) -> bool: + """Return ``True`` if ``name`` matches these embedded arguments.""" + args, _ = self._parse_args(name) + return bool(args) + + def parse_args(self, name: str) -> "tuple[str, ...]": + """Parse arguments matching these embedded arguments from ``name``.""" + args, placeholders = self._parse_args(name) + if not placeholders: + return args + return tuple([self._replace_placeholders(a, placeholders) for a in args]) + + def _parse_args(self, name: str) -> "tuple[tuple[str, ...], dict[str, str]]": + parts = [] + placeholders = {} + for match in VariableMatches(name): + ph = f"={VARIABLE_PLACEHOLDER}-{len(placeholders) + 1}=" + placeholders[ph] = match.match + parts[-1:] = [match.before, ph, match.after] + name = "".join(parts) if parts else name + match = self.name.fullmatch(name) + args = match.groups() if match else () + return args, placeholders + + def _replace_placeholders(self, arg: str, placeholders: "dict[str, str]") -> str: + for ph in placeholders: + if ph in arg: + arg = arg.replace(ph, placeholders[ph]) + return arg + + def map(self, args: Sequence[object]) -> "list[tuple[str, object]]": + args = [ + info.convert(value, name) if info else value + for info, name, value in zip(self.types, self.args, args) + ] + self.validate(args) + return list(zip(self.args, args)) + + def validate(self, args: Sequence[object]): + """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 ``ValueError`` in RF 8.0: + https://github.com/robotframework/robotframework/issues/4069 + """ + if not self.custom_patterns: + return + for name, value in zip(self.args, args): + if name in self.custom_patterns and isinstance(value, str): + pattern = self.custom_patterns[name] + if not re.fullmatch(pattern, value): + # TODO: Change to `raise ValueError(...)` in RF 8.0. + context = EXECUTION_CONTEXTS.current + context.warn( + f"Embedded argument '{name}' 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." + ) + + +class EmbeddedArgumentParser: + _inline_flag = re.compile(r"\(\?[aiLmsux]+(-[imsx]+)?\)") + _regexp_group_start = re.compile(r"(? "EmbeddedArguments | None": + name_parts = [] args = [] - name_regexp = ['^'] - for before, variable, string in VariableIterator(string, identifiers='$'): - name, pattern = self._get_name_and_pattern(variable[2:-1]) - args.append(name) - name_regexp.extend([re.escape(before), '(%s)' % pattern]) - name_regexp.extend([re.escape(string), '$']) - name = self._compile_regexp(name_regexp) if args else None - return name, args - - 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) - - def _format_custom_regexp(self, pattern): - for formatter in (self._regexp_extensions_are_not_allowed, - self._make_groups_non_capturing, - self._unescape_closing_curly, - self._add_automatic_variable_pattern): + custom_patterns = {} + after = string = " ".join(string.split()) + types = [] + for match in VariableMatches(string, identifiers="$"): + arg, typ, pattern = self._parse_arg(match.base) + args.append(arg) + types.append(None if typ is None else self._get_type_info(arg, typ)) + if pattern is None: + pattern = self._default_pattern + else: + custom_patterns[arg] = pattern + pattern = self._format_custom_regexp(pattern) + name_parts.extend([re.escape(match.before), "(", pattern, ")"]) + after = match.after + if not args: + return None + name_parts.append(re.escape(after)) + name = self._compile_regexp("".join(name_parts)) + return EmbeddedArguments(name, args, custom_patterns, types) + + def _parse_arg(self, arg: str) -> "tuple[str, str | None, str | None]": + if ":" not in arg: + return arg, None, None + match = re.fullmatch("([^:]+): ([^:]+)(:(.*))?", arg) + if match: + arg, typ, _, pattern = match.groups() + return arg, typ, pattern + arg, pattern = arg.split(":", 1) + return arg, None, pattern + + def _format_custom_regexp(self, pattern: str) -> str: + for formatter in ( + self._remove_inline_flags, + self._make_groups_non_capturing, + self._unescape_curly_braces, + self._escape_escapes, + self._add_variable_placeholder_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.') + def _remove_inline_flags(self, pattern: str) -> str: + # Inline flags are included in custom regexp stored separately, but they + # must be removed from the full pattern. + match = self._inline_flag.match(pattern) + return pattern if match is None else pattern[match.end() :] - def _make_groups_non_capturing(self, pattern): + def _make_groups_non_capturing(self, pattern: str) -> str: return self._regexp_group_start.sub(self._regexp_group_escape, pattern) - def _unescape_closing_curly(self, pattern): - return pattern.replace('\\}', '}') + def _unescape_curly_braces(self, pattern: str) -> str: + # 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(unescape, pattern) + + def _escape_escapes(self, pattern: str) -> str: + # 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) + def _add_variable_placeholder_pattern(self, pattern: str) -> str: + return rf"{pattern}|={VARIABLE_PLACEHOLDER}-\d+=" + + def _get_type_info(self, name: str, typ: str) -> "TypeInfo | None": + var = f"${{{name}: {typ}}}" + try: + return TypeInfo.from_variable(var) + except DataError as err: + raise DataError(f"Invalid embedded argument '{var}': {err}") - def _compile_regexp(self, pattern): + def _compile_regexp(self, pattern: str) -> re.Pattern: try: - return re.compile(''.join(pattern), re.IGNORECASE) - except: - raise DataError("Compiling embedded arguments regexp failed: %s" - % get_error_message()) + return re.compile(pattern.replace(r"\ ", r"\s"), re.IGNORECASE | re.DOTALL) + except Exception: + raise DataError( + f"Compiling embedded arguments regexp failed: {get_error_message()}" + ) diff --git a/src/robot/running/arguments/javaargumentcoercer.py b/src/robot/running/arguments/javaargumentcoercer.py deleted file mode 100644 index 76052eb0073..00000000000 --- a/src/robot/running/arguments/javaargumentcoercer.py +++ /dev/null @@ -1,146 +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 java.lang import Byte, Short, Integer, Long, Boolean, Float, Double - -from robot.variables import contains_var -from robot.utils import is_string, is_list_like - - -class JavaArgumentCoercer(object): - - def __init__(self, signatures, argspec): - self._argspec = argspec - self._coercers = CoercerFinder().find_coercers(signatures) - self._varargs_handler = VarargsHandler(argspec) - - def coerce(self, arguments, named, dryrun=False): - arguments = self._varargs_handler.handle(arguments) - arguments = [c.coerce(a, dryrun) - for c, a in zip(self._coercers, arguments)] - if self._argspec.kwargs: - arguments.append(dict(named)) - return arguments - - -class CoercerFinder(object): - - def find_coercers(self, signatures): - return [self._get_coercer(types, position) - for position, types in self._parse_types(signatures)] - - def _parse_types(self, signatures): - types = {} - for sig in signatures: - for index, arg in enumerate(sig.args): - types.setdefault(index + 1, []).append(arg) - return sorted(types.items()) - - def _get_coercer(self, types, position): - possible = [BooleanCoercer(position), IntegerCoercer(position), - FloatCoercer(position), NullCoercer(position)] - coercers = [self._get_coercer_for_type(t, possible) for t in types] - if self._coercers_conflict(*coercers): - return NullCoercer() - return coercers[0] - - def _get_coercer_for_type(self, type, coercers): - for coercer in coercers: - if coercer.handles(type): - return coercer - - def _coercers_conflict(self, first, *rest): - return not all(coercer is first for coercer in rest) - - -class _Coercer(object): - _name = '' - _types = [] - _primitives = [] - - def __init__(self, position=None): - self._position = position - - def handles(self, type): - return type in self._types or type.__name__ in self._primitives - - def coerce(self, argument, dryrun=False): - if not is_string(argument) \ - or (dryrun and contains_var(argument)): - return argument - try: - return self._coerce(argument) - except ValueError: - raise ValueError('Argument at position %d cannot be coerced to %s.' - % (self._position, self._name)) - - def _coerce(self, argument): - raise NotImplementedError - - -class BooleanCoercer(_Coercer): - _name = 'boolean' - _types = [Boolean] - _primitives = ['boolean'] - - def _coerce(self, argument): - try: - return {'false': False, 'true': True}[argument.lower()] - except KeyError: - raise ValueError - - -class IntegerCoercer(_Coercer): - _name = 'integer' - _types = [Byte, Short, Integer, Long] - _primitives = ['byte', 'short', 'int', 'long'] - - def _coerce(self, argument): - return int(argument) - - -class FloatCoercer(_Coercer): - _name = 'floating point number' - _types = [Float, Double] - _primitives = ['float', 'double'] - - def _coerce(self, argument): - return float(argument) - - -class NullCoercer(_Coercer): - - def handles(self, argument): - return True - - def _coerce(self, argument): - return argument - - -class VarargsHandler(object): - - def __init__(self, argspec): - self._index = argspec.minargs if argspec.varargs else -1 - - def handle(self, arguments): - if self._index > -1 and not self._passing_list(arguments): - arguments[self._index:] = [arguments[self._index:]] - return arguments - - def _passing_list(self, arguments): - return self._correct_count(arguments) and is_list_like(arguments[-1]) - - def _correct_count(self, arguments): - return len(arguments) == self._index + 1 diff --git a/src/robot/running/arguments/typealiasresolver.py b/src/robot/running/arguments/typealiasresolver.py new file mode 100644 index 00000000000..79d8dd4ce4f --- /dev/null +++ b/src/robot/running/arguments/typealiasresolver.py @@ -0,0 +1,110 @@ +# 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 sys +from types import UnionType +from typing import get_args, get_origin, TypeAliasType, TypeVar, Union + +if sys.version_info >= (3, 13): + from typing import NoDefault +else: + NoDefault = None + + +class RecursiveAlias: + + def __init__(self, alias): + self.name = alias.__name__ if isinstance(alias, TypeAliasType) else None + self.value = None + + +def resolve_type_alias(alias, context=None): + if context is None: + context = {} + if alias in context: + return context[alias] + # RecursiveAlias is used if an alias is used in its own value. + context[alias] = RecursiveAlias(alias) + origin = get_origin(alias) + if origin: + value = _resolve_generic(origin, get_args(alias), context) + else: + value = _resolve(alias, context) + # Add the resolved value to RecursiveAlias that may be used in the value itself. + context[alias].value = value + # Set value in context to the resolved value. + context[alias] = value + return value + + +def _resolve(alias, context): + value = _get_value(alias) + origin = get_origin(value) + if origin: + origin = Union if origin is UnionType else origin + args = [resolve_type_alias(a, context) for a in get_args(value)] + return origin[*args] + return value + + +def _get_value(alias): + seen = set() + while isinstance(alias, TypeAliasType): + try: + value = alias.__value__ + if value in seen: + raise ValueError("Invalid recursion.") + except Exception as err: + raise ValueError(f"Resolving type alias '{alias}' failed: {err}") from None + else: + alias = value + seen.add(value) + return alias + + +def _resolve_generic(alias, args, context): + value = resolve_type_alias(alias, context) + origin = get_origin(value) + if origin: + type_vars = _get_type_var_mapping(value.__parameters__, args) + args = [_resolve_type_var(arg, type_vars) for arg in get_args(value)] + return _resolve_generic(origin, args, context) + if isinstance(alias, TypeAliasType): + type_vars = _get_type_var_mapping(alias.__parameters__, args) + return _resolve_type_var(value, type_vars) + args = [resolve_type_alias(a, context) for a in args] + return value[*args] + + +def _get_type_var_mapping(type_vars, values): + mapping = dict(zip(type_vars, values)) + if len(type_vars) > len(values) and NoDefault is not None: + for var in type_vars: + if var not in mapping and var.__default__ is not NoDefault: + mapping[var] = var.__default__ + return mapping + + +def _resolve_type_var(arg, type_vars): + if isinstance(arg, TypeVar): + try: + return type_vars[arg] + except KeyError: + raise ValueError(f"Type variable '{arg.__name__}' has not value.") + origin = get_origin(arg) + if origin: + args = [_resolve_type_var(a, type_vars) for a in get_args(arg)] + return origin[*args] + return arg diff --git a/src/robot/running/arguments/typeconverters.py b/src/robot/running/arguments/typeconverters.py index b4d5147b790..2535fc69746 100644 --- a/src/robot/running/arguments/typeconverters.py +++ b/src/robot/running/arguments/typeconverters.py @@ -13,126 +13,321 @@ # See the License for the specific language governing permissions and # limitations under the License. +import sys from ast import literal_eval from collections import OrderedDict -try: - from collections import abc -except ImportError: # Python 2 - import collections as abc -from datetime import datetime, date, timedelta -from decimal import InvalidOperation, Decimal -try: - from enum import Enum -except ImportError: # Standard in Py 3.4+ but can be separately installed - class Enum(object): - pass +from collections.abc import Collection, Mapping, Sequence, Set +from datetime import date, datetime, timedelta +from decimal import Decimal, InvalidOperation +from enum import Enum from numbers import Integral, Real +from os import PathLike +from pathlib import Path, PurePath +from typing import Any, Literal, TYPE_CHECKING, Union +from robot.conf import Languages from robot.libraries.DateTime import convert_date, convert_time -from robot.utils import (FALSE_STRINGS, IRONPYTHON, TRUE_STRINGS, PY_VERSION, - PY2, seq2str, type_name, unicode) +from robot.utils import ( + eq, get_error_message, plural_or_not as s, safe_str, Secret, seq2str, type_name +) +if sys.version_info >= (3, 12): + from .typealiasresolver import RecursiveAlias +else: + RecursiveAlias = type("RecursiveAlias", (), {}) -class TypeConverter(object): +if TYPE_CHECKING: + from .customconverters import ConverterInfo, CustomArgumentConverters + from .typeinfo import TypedDictInfo, TypeInfo + + +NoneType = type(None) + + +class TypeConverter: type = None + type_name = None # Used also by Libdoc. Can be overridden by instances. abc = None - aliases = () - convert_none = True + value_types = (str,) + doc = None + nested: "list[TypeConverter] | dict[str, TypeConverter] | None" _converters = OrderedDict() - _type_aliases = {} + + def __init__( + self, + type_info: "TypeInfo", + custom_converters: "CustomArgumentConverters | None" = None, + languages: "Languages | None" = None, + ): + self.type_info = type_info + self.custom_converters = custom_converters + self.languages = languages + self.nested = self._get_nested(type_info, custom_converters, languages) + self.type_name = self._get_type_name() + + def _get_nested( + self, + type_info: "TypeInfo", + custom_converters: "CustomArgumentConverters | None", + languages: "Languages | None", + ) -> "list[TypeConverter] | None": + if not type_info.nested: + return None + return [ + self.converter_for(info, custom_converters, languages) + for info in type_info.nested + ] + + def _get_type_name(self) -> str: + if self.type_name and not self.nested: + return self.type_name + return str(self.type_info) @property - def type_name(self): - return self.type.__name__.lower() + def languages(self) -> Languages: + # Initialize only when needed to save time especially with Libdoc. + if self._languages is None: + self._languages = Languages() + return self._languages + + @languages.setter + def languages(self, languages: "Languages | None"): + self._languages = languages @classmethod - def register(cls, converter_class): - converter = converter_class() + def register(cls, converter: "type[TypeConverter]") -> "type[TypeConverter]": cls._converters[converter.type] = converter - for name in (converter.type_name,) + converter.aliases: - if name is not None: - cls._type_aliases[name.lower()] = converter.type - return converter_class + return converter @classmethod - def converter_for(cls, type_): - # Types defined in the typing module in Python 3.7+. For details see - # https://bugs.python.org/issue34568 - if PY_VERSION >= (3, 7) and hasattr(type_, '__origin__'): - type_ = type_.__origin__ - if isinstance(type_, (str, unicode)): - try: - type_ = cls._type_aliases[type_.lower()] - except KeyError: - return None - if not isinstance(type_, type) or issubclass(type_, unicode): - return None - if type_ in cls._converters: - return cls._converters[type_] + def converter_for( + cls, + type_info: "TypeInfo", + custom_converters: "CustomArgumentConverters | None" = None, + languages: "Languages | None" = None, + ) -> "TypeConverter": + if type_info.type is None: + return UnknownConverter(type_info) + if custom_converters: + info = custom_converters.get_converter_info(type_info.type) + if info: + return CustomConverter(type_info, info, languages) + if type_info.type in cls._converters: + conv_class = cls._converters[type_info.type] + return conv_class(type_info, custom_converters, languages) for converter in cls._converters.values(): - if converter.handles(type_): - return converter.get_converter(type_) - return None + if converter.handles(type_info): + return converter(type_info, custom_converters, languages) + return UnknownConverter(type_info) - def handles(self, type_): - return (issubclass(type_, self.type) or - self.abc and issubclass(type_, self.abc)) + @classmethod + 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, + value: object, + name: "str | None" = None, + kind: str = "Argument", + ) -> object: + if self.no_conversion_needed(value): + return value + if not self._handles_value(value): + return self._handle_error(value, name, kind) + try: + if not isinstance(value, str): + return self._non_string_convert(value) + return self._string_convert(value) + except ValueError as error: + return self._handle_error(value, name, kind, error) + + def no_conversion_needed(self, value: object) -> bool: + try: + return isinstance(value, self.type_info.type) + except (TypeError, AttributeError): + # Used type wasn't a proper class. Compare to generic type instead. + if self.type and self.type is not self.type_info.type: + try: + return isinstance(value, self.type) + except AttributeError: + return False + return False - def get_converter(self, type_): - return self + def validate(self): + """Validate converter. Raise ``TypeError`` for unrecognized types.""" + if self.nested: + self._validate(self.nested) - def convert(self, name, value, explicit_type=True): - if self.convert_none and value.upper() == 'NONE': - return None + def _validate(self, nested): + for converter in nested: + converter.validate() + + def _handles_value(self, value): try: - return self._convert(value, explicit_type) - except ValueError as error: - return self._handle_error(name, value, error, explicit_type) + return isinstance(value, self.value_types) + except AttributeError: + return False - def _convert(self, value, explicit_type=True): + def _non_string_convert(self, value): + return self._convert(value) + + def _string_convert(self, value): + return self._convert(value) + + def _convert(self, value): raise NotImplementedError - def _handle_error(self, name, value, error, explicit_type=True): - if not explicit_type: - return value - ending = u': %s' % error if error.args else '.' - raise ValueError("Argument '%s' got value '%s' that cannot be " - "converted to %s%s" - % (name, value, self.type_name, ending)) - - def _literal_eval(self, value, expected): - # ast.literal_eval has some issues with sets: - if expected is set: - # On Python 2 it doesn't handle sets at all. - if PY2: - raise ValueError('Sets are not supported on Python 2.') - # There is no way to define an empty set. - if value == 'set()': - return set() + def _handle_error(self, value, name, kind, error=None): + typ = "" if isinstance(value, str) else f" ({type_name(value)})" + value = safe_str(value) + kind = kind.capitalize() if kind.islower() else kind + ending = f": {error}" if (error and error.args) else "." + cannot_be_converted = f"cannot be converted to {self.type_name}{ending}" + if name is None: + raise ValueError(f"{kind} '{value}'{typ} {cannot_be_converted}") + raise ValueError( + f"{kind} '{name}' got value '{value}'{typ} that {cannot_be_converted}" + ) + + def _literal_eval(self, value, expected, name=None): try: value = literal_eval(value) except (ValueError, SyntaxError): # Original errors aren't too informative in these cases. - raise ValueError('Invalid expression.') + raise ValueError("Invalid expression.") except TypeError as err: - raise ValueError('Evaluating expression failed: %s' % err) + raise ValueError(f"Evaluating expression failed: {err}") if not isinstance(value, expected): - raise ValueError('Value is %s, not %s.' % (type_name(value), - expected.__name__)) + raise ValueError( + f"Value is {type_name(value)}, not {name or expected.__name__}." + ) + return value + + def _remove_number_separators(self, value): + if isinstance(value, str): + for sep in " ", "_": + if sep in value: + value = value.replace(sep, "") return value +@TypeConverter.register +class EnumConverter(TypeConverter): + type = Enum + + @property + def value_types(self): + return (str, int) if issubclass(self.type_info.type, int) else (str,) + + def _non_string_convert(self, value): + return self._find_by_int_value(self.type_info.type, value) + + def _string_convert(self, value): + enum = self.type_info.type + try: + return enum[value] + except KeyError: + return self._find_by_normalized_name_or_int_value(enum, value) + + def _find_by_normalized_name_or_int_value(self, enum, value): + members = sorted(enum.__members__) + matches = [m for m in members if eq(m, value, ignore="_-")] + if len(matches) == 1: + return getattr(enum, matches[0]) + if len(matches) > 1: + raise ValueError( + f"{self.type_name} has multiple members matching '{value}'. " + f"Available: {seq2str(matches)}" + ) + try: + 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] + raise ValueError( + f"{self.type_name} does not have member '{value}'. " + f"Available: {seq2str(members)}" + ) + + def _find_by_int_value(self, enum, value): + value = int(value) + for member in enum: + if member.value == value: + return member + values = sorted(member.value for member in enum) + raise ValueError( + f"{self.type_name} does not have value '{value}'. " + f"Available: {seq2str(values)}" + ) + + +@TypeConverter.register +class AnyConverter(TypeConverter): + type = Any + type_name = "Any" + value_types = (Any,) + + @classmethod + def handles(cls, type_info: "TypeInfo"): + return type_info.type is cls.type + + def no_conversion_needed(self, value): + return True + + def _convert(self, value): + return value + + def _handles_value(self, value): + return True + + +@TypeConverter.register +class ObjectConverter(AnyConverter): + type = object + type_name = "object" + value_types = (Any,) + + +@TypeConverter.register +class StringConverter(TypeConverter): + type = str + type_name = "string" + value_types = (Any,) + + def _handles_value(self, value): + return True + + def _string_convert(self, value): + return value + + def _non_string_convert(self, value): + if isinstance(value, Secret): + raise ValueError + if isinstance(value, (bytes, bytearray)): + return value.decode("latin-1") + try: + return str(value) + except Exception: + raise ValueError(get_error_message()) + + @TypeConverter.register class BooleanConverter(TypeConverter): type = bool - type_name = 'boolean' - aliases = ('bool',) + type_name = "boolean" + value_types = (str, int, float, NoneType) - def _convert(self, value, explicit_type=True): - upper = value.upper() - if upper in TRUE_STRINGS: + def _non_string_convert(self, value): + return value + + def _string_convert(self, value): + normalized = value.title() + if normalized == "None": + return None + if normalized in self.languages.true_strings: return True - if upper in FALSE_STRINGS: + if normalized in self.languages.false_strings: return False return value @@ -141,30 +336,51 @@ def _convert(self, value, explicit_type=True): class IntegerConverter(TypeConverter): type = int abc = Integral - type_name = 'integer' - aliases = ('int', 'long') + type_name = "integer" + value_types = (str, float) - def _convert(self, value, explicit_type=True): - try: + def _non_string_convert(self, value): + if value.is_integer(): return int(value) + raise ValueError("Conversion would lose precision.") + + def _string_convert(self, value): + value = self._remove_number_separators(value) + value, base = self._get_base(value) + try: + return int(value, base) except ValueError: - if not explicit_type: + if base == 10: try: - return float(value) - except ValueError: + value, denominator = Decimal(value).as_integer_ratio() + except (InvalidOperation, ValueError, OverflowError): pass - raise ValueError + else: + if denominator != 1: + raise ValueError("Conversion would lose precision.") + return value + raise ValueError + + def _get_base(self, value): + value = value.lower() + for prefix, base in [("0x", 16), ("0o", 8), ("0b", 2)]: + if prefix in value: + parts = value.split(prefix) + if len(parts) == 2 and parts[0] in ("", "-", "+"): + return "".join(parts), base + return value, 10 @TypeConverter.register class FloatConverter(TypeConverter): type = float abc = Real - aliases = ('double',) + type_name = "float" + value_types = (str, Real) - def _convert(self, value, explicit_type=True): + def _convert(self, value): try: - return float(value) + return float(self._remove_number_separators(value)) except ValueError: raise ValueError @@ -172,10 +388,12 @@ def _convert(self, value, explicit_type=True): @TypeConverter.register class DecimalConverter(TypeConverter): type = Decimal + type_name = "decimal" + value_types = (str, int, float) - def _convert(self, value, explicit_type=True): + def _convert(self, value): try: - return Decimal(value) + return Decimal(self._remove_number_separators(value)) except InvalidOperation: # With Python 3 error messages by decimal module are not very # useful and cannot be included in our error messages: @@ -186,48 +404,69 @@ def _convert(self, value, explicit_type=True): @TypeConverter.register class BytesConverter(TypeConverter): type = bytes - abc = getattr(abc, 'ByteString', None) # ByteString is new in Python 3 - type_name = 'bytes' # Needed on Python 2 - convert_none = False + type_name = "bytes" + value_types = (str, bytearray, Sequence, int) - def _convert(self, value, explicit_type=True): - if PY2 and not explicit_type: - return value + def _non_string_convert(self, value: "bytearray | Sequence | int") -> bytes: + if isinstance(value, bytearray): + return bytes(value) + if isinstance(value, int): + value = [value] + return bytes([self._validate_int(v) for v in value]) + + def _validate_int(self, value: object) -> int: try: - value = value.encode('latin-1') + value = int(value) # type: ignore + except Exception: + raise ValueError(f"{value!r} is not an integer.") + if value < 0 or value > 255: + raise ValueError(f"{value} is not in range 0-255.") + return value + + def _string_convert(self, value: str) -> bytes: + try: + return value.encode("latin-1") except UnicodeEncodeError as err: - raise ValueError("Character '%s' cannot be mapped to a byte." - % value[err.start:err.start+1]) - return value if not IRONPYTHON else bytes(value) + invalid = value[err.start : err.start + 1] + raise ValueError(f"Character '{invalid}' cannot be mapped to a byte.") @TypeConverter.register -class ByteArrayConverter(TypeConverter): +class ByteArrayConverter(BytesConverter): type = bytearray - convert_none = False + type_name = "bytearray" + value_types = (str, bytes, Sequence, int) - def _convert(self, value, explicit_type=True): - try: - return bytearray(value, 'latin-1') - except UnicodeEncodeError as err: - raise ValueError("Character '%s' cannot be mapped to a byte." - % value[err.start:err.start+1]) + def _non_string_convert(self, value: "bytes | Sequence | int") -> bytearray: + if not isinstance(value, bytes): + value = super()._non_string_convert(value) + return bytearray(value) + + def _string_convert(self, value: str) -> bytearray: + return bytearray(super()._string_convert(value)) @TypeConverter.register class DateTimeConverter(TypeConverter): type = datetime + type_name = "datetime" + value_types = (str, int, float) - def _convert(self, value, explicit_type=True): - return convert_date(value, result_format='datetime') + def _convert(self, value): + if isinstance(value, str) and value.lower() in ("now", "today"): + return datetime.now() + return convert_date(value, result_format="datetime") @TypeConverter.register class DateConverter(TypeConverter): type = date + type_name = "date" - def _convert(self, value, explicit_type=True): - dt = convert_date(value, result_format='datetime') + def _convert(self, value): + if isinstance(value, str) and value.lower() in ("now", "today"): + return date.today() + 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.") return dt.date() @@ -236,94 +475,503 @@ def _convert(self, value, explicit_type=True): @TypeConverter.register class TimeDeltaConverter(TypeConverter): type = timedelta + type_name = "timedelta" + value_types = (str, int, float) - def _convert(self, value, explicit_type=True): - return convert_time(value, result_format='timedelta') + def _convert(self, value): + return convert_time(value, result_format="timedelta") @TypeConverter.register -class EnumConverter(TypeConverter): - type = Enum +class PathConverter(TypeConverter): + type = Path + abc = PathLike + type_name = "Path" + value_types = (str, PurePath) - def __init__(self, enum=None): - self._enum = enum + def _convert(self, value): + return Path(value) - @property - def type_name(self): - return self._enum.__name__ if self._enum else None - def get_converter(self, type_): - return EnumConverter(type_) +@TypeConverter.register +class NoneConverter(TypeConverter): + type = NoneType + type_name = "None" - def _convert(self, value, explicit_type=True): - try: - # This is compatible with the enum module in Python 3.4, its - # enum34 backport, and the older enum module. `self._enum[value]` - # wouldn't work with the old enum module. - return getattr(self._enum, value) - except AttributeError: - members = self._get_members(self._enum) - raise ValueError("%s does not have member '%s'. Available: %s" - % (self.type_name, value, seq2str(members))) + @classmethod + def handles(cls, type_info: "TypeInfo") -> bool: + return type_info.type in (NoneType, None) - def _get_members(self, enum): - try: - return list(enum.__members__) - except AttributeError: # old enum module - return [attr for attr in dir(enum) if not attr.startswith('_')] + def _string_convert(self, value): + if value.upper() in ("NONE", ""): + return None + raise ValueError @TypeConverter.register -class NoneConverter(TypeConverter): - type = type(None) +class SequenceConverter(TypeConverter): + type = Sequence + type_name = "Sequence" + value_types = (str, Sequence) + + def no_conversion_needed(self, value): + if ( + isinstance(value, str) + or not super().no_conversion_needed(value) + or self._must_make_mutable(value) + ): + return False + if not self.nested: + return True + no_conversion_needed = self.nested[0].no_conversion_needed + return all(no_conversion_needed(v) for v in value) + + def _must_make_mutable(self, value): + return not isinstance(value, self.type_info.type) - def _convert(self, value, explicit_type=True): + def _string_convert(self, value): + return self._convert(self._literal_eval(value, Sequence)) + + def _convert(self, value): + if self._must_make_mutable(value): + value = list(value) + if self.nested: + value = self._convert_nested(value) return value + def _convert_nested(self, value): + result_type = type(value) + convert = self.nested[0].convert + value = [convert(v, name=str(i), kind="Item") for i, v in enumerate(value)] + try: + return result_type(value) + except Exception: + raise ValueError("Cannot recreate object after converting items.") + @TypeConverter.register -class ListConverter(TypeConverter): +class ListConverter(SequenceConverter): type = list - abc = abc.Sequence + type_name = "list" - def _convert(self, value, explicit_type=True): - return self._literal_eval(value, list) + def _must_make_immutable(self): + return False + + def _string_convert(self, value): + value = self._literal_eval(value, Sequence, name="list") + return self._convert(list(value)) + + def _non_string_convert(self, value): + return self._convert(list(value)) @TypeConverter.register class TupleConverter(TypeConverter): type = tuple + type_name = "tuple" + value_types = (str, Sequence) + + @property + def homogenous(self) -> bool: + nested = self.type_info.nested + return nested and nested[-1].type is Ellipsis - def _convert(self, value, explicit_type=True): - return self._literal_eval(value, tuple) + def no_conversion_needed(self, value): + if isinstance(value, str) or not super().no_conversion_needed(value): + return False + if not self.nested: + return True + if self.homogenous: + converter = self.nested[0] + return all(converter.no_conversion_needed(v) for v in value) + if len(value) != len(self.nested): + return False + return all(c.no_conversion_needed(v) for c, v in zip(self.nested, value)) + + def _string_convert(self, value): + value = self._literal_eval(value, Sequence, name="tuple") + return self._convert_nested(tuple(value)) + + def _non_string_convert(self, value): + return self._convert_nested(tuple(value)) + + def _convert_nested(self, value): + if not self.nested: + return value + if self.homogenous: + convert = self.nested[0].convert + return tuple( + convert(v, name=str(i), kind="Item") for i, v in enumerate(value) + ) + if len(value) != len(self.nested): + raise ValueError( + f"Expected {len(self.nested)} item{s(self.nested)}, got {len(value)}." + ) + return tuple( + c.convert(v, name=str(i), kind="Item") + for i, (c, v) in enumerate(zip(self.nested, value)) + ) + + def _validate(self, nested: "list[TypeConverter]"): + if self.homogenous: + nested = nested[:-1] + super()._validate(nested) @TypeConverter.register -class DictionaryConverter(TypeConverter): +class TypedDictConverter(TypeConverter): + type = "TypedDict" + value_types = (str, Mapping) + type_info: "TypedDictInfo" + nested: "dict[str, TypeConverter]" + + def _get_nested( + self, + type_info: "TypedDictInfo", + custom_converters: "CustomArgumentConverters | None", + languages: "Languages | None", + ) -> "dict[str, TypeConverter]": + return { + name: self.converter_for(info, custom_converters, languages) + for name, info in type_info.annotations.items() + } + + @classmethod + def handles(cls, type_info: "TypeInfo") -> bool: + return type_info.is_typed_dict + + def no_conversion_needed(self, value): + try: + if not isinstance(value, Mapping): + return False + except AttributeError: + return False + for key in value: + try: + converter = self.nested[key] + except KeyError: + return False + else: + if not converter.no_conversion_needed(value[key]): + return False + return set(value).issuperset(self.type_info.required) + + def _non_string_convert(self, value): + return self._convert_items(value) + + def _string_convert(self, value): + return self._convert_items(self._literal_eval(value, dict)) + + def _convert_items(self, value): + not_allowed = [] + for key in value: + try: + converter = self.nested[key] + except KeyError: + not_allowed.append(key) + else: + if converter: + 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.nested 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.type_info.required if key not in value] + if missing: + raise ValueError( + f"Required item{s(missing)} {seq2str(sorted(missing))} missing." + ) + return value + + def _validate(self, nested: "dict[str, TypeConverter]"): + super()._validate(nested.values()) + + +@TypeConverter.register +class MappingConverter(TypeConverter): + type = Mapping + type_name = "Mapping" + value_types = (str, Mapping) + + def no_conversion_needed(self, value): + if ( + isinstance(value, str) + or not super().no_conversion_needed(value) + or self._must_make_mutable(value) + ): + return False + if not self.nested: + return True + no_key_conversion = self.nested[0].no_conversion_needed + no_value_conversion = self.nested[1].no_conversion_needed + return all( + no_key_conversion(k) and no_value_conversion(value[k]) for k in value + ) + + def _must_make_mutable(self, value): + return not isinstance(value, self.type_info.type) + + def _string_convert(self, value): + return self._convert(self._literal_eval(value, Mapping)) + + def _convert(self, value): + if self._must_make_mutable(value): + value = dict(value) + if self.nested: + value = self._convert_nested(value) + return value + + def _convert_nested(self, value): + result_type = type(value) + convert_key = self._get_converter(self.nested[0], "Key") + convert_value = self._get_converter(self.nested[1], "Item") + value = {convert_key(None, k): convert_value(k, value[k]) for k in value} + try: + return result_type(value) + except Exception: + raise ValueError("Cannot recreate object after converting items.") + + def _get_converter(self, converter, kind): + return lambda name, value: converter.convert(value, name, kind=kind) + + +@TypeConverter.register +class DictionaryConverter(MappingConverter): type = dict - abc = abc.Mapping - type_name = 'dictionary' - aliases = ('dict', 'map') + type_name = "dictionary" + + def _must_make_immutable(self): + return False + + def _string_convert(self, value): + return self._convert(self._literal_eval(value, dict)) - def _convert(self, value, explicit_type=True): - return self._literal_eval(value, dict) + def _non_string_convert(self, value): + return self._convert(dict(value)) @TypeConverter.register class SetConverter(TypeConverter): type = set - abc = abc.Set + abc = Set + type_name = "set" + value_types = (str, Collection) - def _convert(self, value, explicit_type=True): - return self._literal_eval(value, set) + def no_conversion_needed(self, value): + if isinstance(value, str) or not super().no_conversion_needed(value): + return False + if not self.nested: + return True + no_conversion_needed = self.nested[0].no_conversion_needed + return all(no_conversion_needed(v) for v in value) + + def _string_convert(self, value): + if value == "set()": # literal_eval doesn't support "set()" with Python 3.8. + return set() + value = self._literal_eval(value, (set, Sequence), name="set") + return self._convert_nested(set(value)) + + def _non_string_convert(self, value): + return self._convert_nested(set(value)) + + def _convert_nested(self, value): + if self.nested: + convert = self.nested[0].convert + value = {convert(v, kind="Item") for v in value} + return value @TypeConverter.register -class FrozenSetConverter(TypeConverter): +class FrozenSetConverter(SetConverter): type = frozenset + type_name = "frozenset" - def _convert(self, value, explicit_type=True): - # There are issues w/ literal_eval. See self._literal_eval for details. - if value == 'frozenset()' and not PY2: + def _string_convert(self, value): + if value == "frozenset()": # literal_eval doesn't support "frozenset()". return frozenset() - return frozenset(self._literal_eval(value, set)) + return frozenset(super()._string_convert(value)) + + def _non_string_convert(self, value): + return frozenset(super()._non_string_convert(value)) + + +@TypeConverter.register +class UnionConverter(TypeConverter): + type = Union + + def _get_type_name(self) -> str: + names = [converter.type_name for converter in self.nested] + return seq2str(names, quote="", lastsep=" or ") + + @classmethod + 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 in self.nested: + if ( + converter.no_conversion_needed(value) + and not isinstance(converter, ObjectConverter) + ): # fmt:skip + return True + return False + + def _convert(self, value): + unknown_types = False + for converter in self.nested: + if converter: + try: + return converter.convert(value) + except ValueError: + pass + else: + unknown_types = True + if unknown_types: + return value + raise ValueError + + +@TypeConverter.register +class LiteralConverter(TypeConverter): + type = Literal + type_name = "Literal" + value_types = (Any,) + + def _get_type_name(self) -> str: + names = [info.name for info in self.type_info.nested] + return seq2str(names, quote="", lastsep=" or ") + + @classmethod + def converter_for( + cls, + type_info: "TypeInfo", + custom_converters: "CustomArgumentConverters | None" = None, + languages: "Languages | None" = None, + ) -> TypeConverter: + info = type(type_info)(type_info.name, type(type_info.type)) + return super().converter_for(info, custom_converters, languages) + + @classmethod + def handles(cls, type_info: "TypeInfo") -> bool: + return type_info.type is Literal + + def no_conversion_needed(self, value: object) -> bool: + for info in self.type_info.nested: + expected = info.type + if value == expected and type(value) is type(expected): + return True + return False + + def _handles_value(self, value): + return True + + def _convert(self, value): + matches = [] + for info, converter in zip(self.type_info.nested, self.nested): + expected = info.type + if value == expected and type(value) is type(expected): + return expected + try: + converted = converter.convert(value) + except ValueError: + pass + else: + if ( + isinstance(expected, str) + and eq(converted, expected, ignore="_-") + or converted == expected + ): + matches.append(expected) + if len(matches) == 1: + return matches[0] + if matches: + raise ValueError("No unique match found.") + raise ValueError + + +@TypeConverter.register +class SecretConverter(TypeConverter): + type = Secret + type_name = "Secret" + + def _convert(self, value): + raise ValueError + + def _handle_error(self, value, name, kind, error=None): + kind = kind.capitalize() if kind.islower() else kind + typ = type_name(value) + if name is None: + raise ValueError(f"{kind} must have type 'Secret', got {typ}.") + raise ValueError(f"{kind} '{name}' must have type 'Secret', got {typ}.") + + +@TypeConverter.register +class RecursiveConverter(TypeConverter): + type = RecursiveAlias + + def _get_type_name(self) -> str: + return self.type_info.name + + def _handles_value(self, value): + return True + + def _get_nested(self, type_info, custom_converters, languages): + return None + + def _convert(self, value): + from .typeinfo import TypeInfo + + info = TypeInfo.from_type_hint(self.type_info.nested[0]) + converter = TypeConverter.converter_for(info) + return converter.convert(value) + + +class CustomConverter(TypeConverter): + + def __init__( + self, + type_info: "TypeInfo", + converter_info: "ConverterInfo", + languages: "Languages | None" = None, + ): + self.converter_info = converter_info + super().__init__(type_info, languages=languages) + + def _get_type_name(self) -> str: + return self.converter_info.name + + @property + def doc(self): + return self.converter_info.doc + + @property + def value_types(self): + return self.converter_info.value_types + + def _handles_value(self, value): + return not self.value_types or isinstance(value, self.value_types) + + def _convert(self, value): + try: + return self.converter_info.convert(value) + except ValueError: + raise + except Exception: + raise ValueError(get_error_message()) + + +class UnknownConverter(TypeConverter): + + def convert(self, value, name=None, kind="Argument"): + return value + + def validate(self): + raise TypeError(f"Unrecognized type '{self.type_name}'.") + + def __bool__(self): + return False diff --git a/src/robot/running/arguments/typeinfo.py b/src/robot/running/arguments/typeinfo.py new file mode 100644 index 00000000000..ed66db39bc1 --- /dev/null +++ b/src/robot/running/arguments/typeinfo.py @@ -0,0 +1,508 @@ +# 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 sys +from collections.abc import Mapping, Sequence, Set +from datetime import date, datetime, timedelta +from decimal import Decimal +from enum import Enum +from pathlib import Path +from typing import Any, ForwardRef, get_args, get_origin, get_type_hints, Literal, Union + +# Standard get_args and get_origin handle at least Annotated wrong in Python 3.8. +if sys.version_info < (3, 9): + try: + from typing_extensions import get_args, get_origin + except ImportError: + pass +# typing_extensions.Literal is typing.Literal with Python 3.10.1 and newer. +if sys.version_info < (3, 10, 1): + try: + from typing_extensions import Literal as ExtLiteral + except ImportError: + ExtLiteral = Literal +else: + ExtLiteral = Literal +# NotRequired and Required are new in Python 3.11. +if sys.version_info >= (3, 11): + from typing import NotRequired, Required +else: + try: + from typing_extensions import NotRequired, Required + except ImportError: + NotRequired = Required = object() +# TypeAliasType and `type` syntax are new in Python 3.12. +if sys.version_info >= (3, 12): + from typing import TypeAliasType + + from .typealiasresolver import resolve_type_alias, RecursiveAlias +else: + TypeAliasType = type("TypeAliasType", (), {}) + RecursiveAlias = type("RecursiveAlias", (), {}) + resolve_type_alias = lambda alias: alias + + +from robot.conf import Languages, LanguagesLike +from robot.errors import DataError +from robot.utils import ( + is_union, NOT_SET, plural_or_not as s, Secret, setter, SetterAwareType, type_name, + type_repr, typeddict_types +) +from robot.variables import search_variable, VariableMatch + +from ..context import EXECUTION_CONTEXTS +from .customconverters import CustomArgumentConverters +from .typeconverters import TypeConverter + +TYPE_NAMES = { + "...": Ellipsis, + "ellipsis": Ellipsis, + "any": Any, + "object": object, + "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), + "sequence": Sequence, + "list": list, + "tuple": tuple, + "set": set, + "frozenset": frozenset, + "mapping": Mapping, + "map": Mapping, + "dictionary": dict, + "dict": dict, + "union": Union, + "literal": Literal, + "secret": Secret, +} +LITERAL_TYPES = (int, str, bytes, bool, Enum, type(None)) + + +class TypeInfo(metaclass=SetterAwareType): + """Represents an argument type. + + Normally created using the :meth:`from_type_hint` classmethod. + With unions and parametrized types, :attr:`nested` contains nested types. + + Values can be converted according to this type info by using the + :meth:`convert` method. + + Part of the public API starting from Robot Framework 7.0. In such usage + should be imported via the :mod:`robot.api` package. + """ + + is_typed_dict = False + __slots__ = ("name", "type") + + def __init__( + self, + name: "str | None" = None, + type: Any = NOT_SET, + nested: "Sequence[TypeInfo] | None" = None, + ): + if type is NOT_SET: + type = TYPE_NAMES.get(name.lower()) if name else None + self.name = name + self.type = type + self.nested = nested + + @setter + def nested( + self, nested: "Sequence[TypeInfo] | None" + ) -> "tuple[TypeInfo, ...] | None": + """Nested types as a tuple of ``TypeInfo`` objects. + + Used with parameterized types and unions. + """ + typ = self.type + if self.is_union: + return tuple(nested or ()) + if nested is None: + return None + if typ is None: + return tuple(nested) + if typ is Literal: + return self._validate_literal(nested) + if isinstance(typ, type): + if issubclass(typ, tuple): + if nested[-1].type is Ellipsis: + return self._validate_nested_count( + nested, 2, "Homogenous tuple", offset=-1 + ) + return tuple(nested) + if ( + issubclass(typ, Sequence) + and not issubclass(typ, (str, bytes, bytearray, memoryview)) + ): # fmt: skip + return self._validate_nested_count(nested, 1) + if issubclass(typ, Set): + return self._validate_nested_count(nested, 1) + if issubclass(typ, Mapping): + return self._validate_nested_count(nested, 2) + if typ in TYPE_NAMES.values(): + self._report_nested_error(nested) + return tuple(nested) + + def _validate_literal(self, nested): + if not nested: + raise DataError("Literal cannot be empty.") + for info in nested: + if not isinstance(info.type, LITERAL_TYPES): + raise DataError( + f"Literal supports only integers, strings, bytes, Booleans, enums " + f"and None, value {info.name} is {type_name(info.type)}." + ) + return tuple(nested) + + def _validate_nested_count(self, nested, expected, kind=None, offset=0): + if len(nested) != expected: + self._report_nested_error(nested, expected, kind, offset) + return tuple(nested) + + def _report_nested_error(self, nested, expected=0, kind=None, offset=0): + expected += offset + actual = len(nested) + offset + 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 parameters, " + f"'{self.name}[{args}]' has {actual}." + ) + raise DataError( + f"{kind} requires exactly {expected} parameter{s(expected)}, " + f"'{self.name}[{args}]' has {actual}." + ) + + @property + def is_union(self) -> bool: + return bool(self.name and self.name.title() == "Union") + + @property + def is_recursive(self) -> bool: + return self.type is RecursiveAlias + + @classmethod + def from_type_hint(cls, hint: Any, sequence_is_union: bool = False) -> "TypeInfo": + """Construct a ``TypeInfo`` based on a type hint. + + The type hint can be in various different formats: + + - an actual type such as ``int`` + - a parameterized type such as ``list[int]`` + - a union such as ``int | float`` + - a string such as ``'int'``, ``'list[int]'`` or ``'int | float'`` + - a ``TypedDict`` (represented as a :class:`TypedDictInfo`) + - if ``sequence_is_union`` is ``True``, a sequence of type hints like + ``[int, float]`` or ``('int', 'list[int]')`` creates a union + + In special cases using a more specialized method like :meth:`from_type` + may be more appropriate than using this generic method. + + Prior to Robot Framework 7.4, sequences always created a union. If you + need to handle sequences as unions, it is recommended to call + :meth:`from_sequence` explicitly. + """ + if hint is NOT_SET: + return cls() + if isinstance(hint, cls): + return hint + if isinstance(hint, TypeAliasType): + hint = cls._resolve_type_alias(hint) + if isinstance(hint, ForwardRef): + hint = hint.__forward_arg__ + if isinstance(hint, typeddict_types): + return TypedDictInfo(hint.__name__, hint) + if is_union(hint) or hint is Union: + nested = [cls.from_type_hint(a) for a in get_args(hint)] + return cls("Union", nested=cls._validate_union_params(nested)) + origin = get_origin(hint) + if origin: + if isinstance(origin, TypeAliasType): + hint = cls._resolve_type_alias(hint) + return cls.from_type_hint(hint, sequence_is_union) + args = get_args(hint) + if origin is Literal or origin is ExtLiteral: + origin = Literal + nested = [ + cls(a.name if isinstance(a, Enum) else repr(a), a) for a in args + ] + elif args: + nested = [cls.from_type_hint(a) for a in args] + else: + nested = None + return cls(type_repr(hint, nested=False), origin, nested) + if isinstance(hint, str): + return cls.from_string(hint) + if hint is Any: + return cls("Any", hint) + if hint is None: + return cls("None", type(None)) + if hint is Ellipsis: + return cls("...", hint) + if isinstance(hint, RecursiveAlias): + return cls(hint.name, RecursiveAlias, nested=[hint.value]) + if isinstance(hint, type): + return cls(type_repr(hint), hint) + if isinstance(hint, Sequence): + if sequence_is_union: + return cls.from_sequence(cls._validate_union_params(hint)) + if isinstance(hint, list): + # Better string representation with Callable params and other lists. + items = [t.__name__ if isinstance(t, type) else repr(t) for t in hint] + return cls(f"[{', '.join(items)}]") + return cls(str(hint)) + + @classmethod + def _resolve_type_alias(cls, alias): + try: + return resolve_type_alias(alias) + except ValueError as err: + raise DataError(str(err)) + + @classmethod + def _validate_union_params(cls, nested): + if not nested: + raise DataError("Union cannot be empty.") + return tuple(nested) + + @classmethod + def from_type(cls, hint: type) -> "TypeInfo": + """Construct a ``TypeInfo`` based on an actual type. + + Use :meth:`from_type_hint` if the type hint can also be something else + than a concrete type such as a string or a type expression. + """ + return cls(type_repr(hint), hint) + + @classmethod + def from_string(cls, hint: str) -> "TypeInfo": + """Construct a ``TypeInfo`` based on a string. + + In addition to just types names or their aliases like ``int`` or ``integer``, + supports also parameterized types like ``list[int]`` as well as unions like + ``int | float``. + + Use :meth:`from_type_hint` if the type hint can also be something else + than a string such as an actual type. + """ + # Needs to be imported here due to cyclic dependency. + from .typeinfoparser import TypeInfoParser + + try: + return TypeInfoParser(hint).parse() + except ValueError as err: + raise DataError(str(err)) + + @classmethod + def from_sequence(cls, sequence: Sequence) -> "TypeInfo": + """Construct a ``TypeInfo`` based on a sequence of types. + + Types can be actual types, strings, or anything else accepted by + :meth:`from_type_hint`. If the sequence contains just one type, + a ``TypeInfo`` created based on it is returned. If there are more + types, the returned ``TypeInfo`` represents a union. Using an empty + sequence is an error. + + Use :meth:`from_type_hint` if other types than sequences need to + supported. + """ + infos = [] + for typ in sequence: + info = cls.from_type_hint(typ, sequence_is_union=True) + if info.is_union: + infos.extend(info.nested) + else: + infos.append(info) + if len(infos) == 1: + return infos[0] + return cls("Union", nested=infos) + + @classmethod + def from_variable( + cls, + variable: "str | VariableMatch", + handle_list_and_dict: bool = True, + ) -> "TypeInfo": + """Construct a ``TypeInfo`` based on a variable. + + Type can be specified using syntax like ``${x: int}``. + + :param variable: Variable as a string or as an already parsed + ``VariableMatch`` object. + :param handle_list_and_dict: When ``True``, types in list and dictionary + variables get ``list[]`` and ``dict[]`` decoration implicitly. + For example, ``@{x: int}``, ``&{x: int}`` and ``&{x: str=int}`` + yield types ``list[int]``, ``dict[Any, int]`` and ``dict[str, int]``, + respectively. + :raises: ``DataError`` if variable has an unrecognized type. Variable + not having a type is not an error. + + New in Robot Framework 7.3. + """ + if isinstance(variable, str): + variable = search_variable(variable, parse_type=True) + if not variable.type: + return cls() + type_ = variable.type + if handle_list_and_dict: + if variable.identifier == "@": + type_ = f"list[{type_}]" + elif variable.identifier == "&": + if "=" in type_: + kt, vt = type_.split("=", 1) + else: + kt, vt = "Any", type_ + type_ = f"dict[{kt}, {vt}]" + info = cls.from_string(type_) + cls._validate_var_type(info) + return info + + @classmethod + def _validate_var_type(cls, info): + if info.type is None: + raise DataError(f"Unrecognized type '{info.name}'.") + if info.nested and info.type is not Literal: + for nested in info.nested: + cls._validate_var_type(nested) + + def convert( + self, + value: Any, + name: "str | None" = None, + custom_converters: "CustomArgumentConverters | dict | None" = None, + languages: "LanguagesLike" = None, + kind: str = "Argument", + allow_unknown: bool = False, + ) -> object: + """Convert ``value`` based on type information this ``TypeInfo`` contains. + + :param value: Value to convert. + :param name: Name of the argument or other thing to convert. + Used only for error reporting. + :param custom_converters: Custom argument converters. + :param languages: Language configuration. During execution, uses the + current language configuration by default. + :param kind: Type of the thing to be converted. + Used only for error reporting. + :param allow_unknown: If ``False``, a ``TypeError`` is raised if there + is no converter for this type or to its nested types. If ``True``, + conversion returns the original value instead. + :raises: ``ValueError`` if conversion fails and ``TypeError`` if there is + no converter for this type and unknown converters are not accepted. + :return: Converted value. + """ + converter = self.get_converter(custom_converters, languages, allow_unknown) + return converter.convert(value, name, kind) + + def get_converter( + self, + custom_converters: "CustomArgumentConverters | dict | None" = None, + languages: "LanguagesLike" = None, + allow_unknown: bool = False, + ) -> TypeConverter: + """Get argument converter for this ``TypeInfo``. + + :param custom_converters: Custom argument converters. + :param languages: Language configuration. During execution, uses the + current language configuration by default. + :param allow_unknown: If ``False``, a ``TypeError`` is raised if there + is no converter for this type or to its nested types. If ``True``, + a special ``UnknownConverter`` is returned instead. + :raises: ``TypeError`` if there is no converter and unknown converters + are not accepted. + :return: ``TypeConverter``. + + The :meth:`convert` method handles the common conversion case, but this + method can be used if the converter is needed multiple times or its + needed also for other purposes than conversion. + + New in Robot Framework 7.2. + """ + if isinstance(custom_converters, dict): + custom_converters = CustomArgumentConverters.from_dict(custom_converters) + if not languages and EXECUTION_CONTEXTS.current: + languages = EXECUTION_CONTEXTS.current.languages + elif not isinstance(languages, Languages): + languages = Languages(languages) + converter = TypeConverter.converter_for(self, custom_converters, languages) + if not allow_unknown: + converter.validate() + return converter + + def __str__(self): + if self.is_union: + return " | ".join(str(n) for n in self.nested) + name = self.name or "" + if self.nested is None or self.type is RecursiveAlias: + return name + nested = ", ".join(str(n) for n in self.nested) + return f"{name}[{nested}]" + + def __bool__(self): + return self.name is not None + + +class TypedDictInfo(TypeInfo): + """Represents ``TypedDict`` used as an argument.""" + + is_typed_dict = True + __slots__ = ("annotations", "required") + + def __init__(self, name: str, type: type): + super().__init__(name, type) + type_hints = self._get_type_hints(type) + # __required_keys__ is new in Python 3.9. + self.required = getattr(type, "__required_keys__", frozenset()) + if sys.version_info < (3, 11): + self._handle_typing_extensions_required_and_not_required(type_hints) + self.annotations = { + name: TypeInfo.from_type_hint(hint) for name, hint in type_hints.items() + } + + def _get_type_hints(self, type) -> "dict[str, Any]": + try: + return get_type_hints(type) + except Exception: + return type.__annotations__ + + def _handle_typing_extensions_required_and_not_required(self, type_hints): + # NotRequired and Required are handled automatically by Python 3.11 and newer, + # but with older they appear in type hints and need to be handled separately. + required = set(self.required) + for key, hint in type_hints.items(): + origin = get_origin(hint) + if origin is Required: + required.add(key) + type_hints[key] = get_args(hint)[0] + elif origin is NotRequired: + required.discard(key) + type_hints[key] = get_args(hint)[0] + self.required = frozenset(required) diff --git a/src/robot/running/arguments/typeinfoparser.py b/src/robot/running/arguments/typeinfoparser.py new file mode 100644 index 00000000000..0da0cb2529c --- /dev/null +++ b/src/robot/running/arguments/typeinfoparser.py @@ -0,0 +1,226 @@ +# 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 ast import literal_eval +from dataclasses import dataclass +from enum import auto, Enum +from typing import Literal, NoReturn + +from .typeinfo import LITERAL_TYPES, TypeInfo + + +class TokenType(Enum): + NAME = auto() + LEFT_SQUARE = auto() + RIGHT_SQUARE = auto() + PIPE = auto() + COMMA = auto() + + def __repr__(self): + return str(self) + + +@dataclass +class Token: + type: TokenType + value: str + position: int = -1 + + +class TypeInfoTokenizer: + markers = { + "[": TokenType.LEFT_SQUARE, + "]": TokenType.RIGHT_SQUARE, + "|": TokenType.PIPE, + ",": TokenType.COMMA, + } + + def __init__(self, source: str): + self.source = source + self.tokens: list[Token] = [] + self.start = 0 + self.current = 0 + + @property + def at_end(self) -> bool: + return self.current >= len(self.source) + + def tokenize(self) -> "list[Token]": + 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} + closing_quote = None + char = self.source[self.current - 1] + if char in ('"', "'"): + end_at = {None} + closing_quote = char + elif char == "b" and self.peek() in ('"', "'"): + end_at = {None} + closing_quote = self.advance() + while True: + char = self.peek() + if char in end_at: + break + self.current += 1 + if char == closing_quote: + break + self.add_token(TokenType.NAME) + + def add_token(self, type: TokenType): + value = self.source[self.start : self.current].strip() + self.tokens.append(Token(type, value, self.start)) + + +class TypeInfoParser: + + def __init__(self, source: str): + self.source = source + self.tokens: list[Token] = [] + 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(f"Extra content after '{info}'.") + if info.is_union and len(info.nested) < 2: + if not len(info.nested): + self.error("Union cannot be empty.") + return info.nested[0] + return info + + def type(self) -> TypeInfo: + if not self.check(TokenType.NAME): + self.error("Type name missing.") + info = TypeInfo(self.advance().value) + if self.match(TokenType.LEFT_SQUARE): + info.nested = self.params(literal=info.type is Literal) + if self.match(TokenType.PIPE): + nested = [info, *self.union()] + info = TypeInfo("Union", nested=nested) + return info + + def params(self, literal: bool = False) -> "list[TypeInfo]": + params = [] + prev = None + while True: + token = self.peek() + if not token: + self.error("Closing ']' missing.") + if token.type is TokenType.RIGHT_SQUARE: + self.advance() + break + if token.type is TokenType.COMMA: + if not prev or prev.type is TokenType.COMMA: + self.error("Type missing before ','.") + self.advance() + prev = token + continue + if token.type is TokenType.NAME: + param = self.type() + elif token.type is TokenType.LEFT_SQUARE: + self.advance() + param = TypeInfo() + param.nested = self.params() + else: + self.error("Type name missing.") + if literal: + param = self._literal_param(param) + params.append(param) + prev = token + if literal and not params: + self.error("Literal cannot be empty.") + return params + + def _literal_param(self, param: TypeInfo) -> TypeInfo: + try: + if param.name is None: + raise ValueError + try: + value = literal_eval(param.name) + except ValueError: + if param.name.isidentifier(): + return TypeInfo(param.name, None) + raise + if not isinstance(value, LITERAL_TYPES): + raise ValueError + except (ValueError, SyntaxError): + self.error(f"Invalid literal value {str(param)!r}.") + else: + return TypeInfo(repr(value), value) + + def union(self) -> "list[TypeInfo]": + types = [] + while not types or self.match(TokenType.PIPE): + info = self.type() + if info.is_union: + types.extend(info.nested) + else: + types.append(info) + return types + + def match(self, *types: TokenType) -> bool: + for typ in types: + if self.check(typ): + self.advance() + return True + return False + + def check(self, expected: TokenType) -> bool: + peeked = self.peek() + return peeked and peeked.type == expected + + def advance(self) -> "Token | None": + token = self.peek() + if token: + self.current += 1 + return token + + def peek(self) -> "Token | None": + try: + return self.tokens[self.current] + except IndexError: + return None + + def error(self, message: str, token: "Token | None" = None) -> NoReturn: + if not token: + token = self.peek() + position = f"index {token.position}" if token else "end" + raise ValueError( + f"Parsing type {self.source!r} failed: Error at {position}: {message}" + ) diff --git a/src/robot/running/arguments/typevalidator.py b/src/robot/running/arguments/typevalidator.py deleted file mode 100644 index 56a1c0f002c..00000000000 --- a/src/robot/running/arguments/typevalidator.py +++ /dev/null @@ -1,56 +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 DataError -from robot.utils import (is_dict_like, is_list_like, plural_or_not as s, - seq2str, type_name) - - -class TypeValidator(object): - - def __init__(self, argspec): - """:type argspec: :py:class:`robot.running.arguments.ArgumentSpec`""" - self._argspec = argspec - - def validate(self, types): - 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('Type information must be given as a dictionary or ' - 'a list, got %s.' % type_name(types)) - - def validate_type_dict(self, types): - # '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']) - 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)))) - return types - - def convert_type_list_to_dict(self, types): - names = self._argspec.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))) - return {name: value for name, value in zip(names, types) if value} diff --git a/src/robot/running/arguments/validators.py b/src/robot/running/arguments/validators.py new file mode 100644 index 00000000000..1f27da24136 --- /dev/null +++ b/src/robot/running/arguments/validators.py @@ -0,0 +1,89 @@ +# 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 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 + + +class TypeValidator: + + def __init__(self, spec: "ArgumentSpec"): + self.spec = spec + + 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): + 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 a list, " + f"got {type_name(types)}." + ) + return { + k: TypeInfo.from_type_hint(types[k], sequence_is_union=True) for k in types + } + + def _validate_type_dict(self, types: Mapping): + names = set(self.spec.argument_names) + extra = [t for t in types if t not in names] + if extra: + raise DataError( + f"Type information given to non-existing " + f"argument{s(extra)} {seq2str(sorted(extra))}." + ) + + def _type_list_to_dict(self, types: Sequence) -> dict: + names = self.spec.argument_names + if len(types) > len(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} + + +class DocValidator: + + def __init__(self, spec: "ArgumentSpec"): + self.spec = spec + + def validate(self, docs: "Mapping[str, str] | None") -> "dict[str, str] | None": + if not docs: + return None if docs is None else {} + names = set(self.spec.argument_names) + extra = [d for d in docs if d not in names] + if extra: + raise DataError( + f"Documentation given to non-existing " + f"argument{s(extra)} {seq2str(sorted(extra))}." + ) + return dict(docs) diff --git a/src/robot/running/bodyrunner.py b/src/robot/running/bodyrunner.py new file mode 100644 index 00000000000..af586616c28 --- /dev/null +++ b/src/robot/running/bodyrunner.py @@ -0,0 +1,939 @@ +# 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 re +import time +from collections import OrderedDict +from contextlib import contextmanager +from datetime import datetime +from itertools import zip_longest + +from robot.errors import ( + BreakLoop, ContinueLoop, DataError, ExecutionFailed, ExecutionFailures, + ExecutionPassed, ExecutionStatus +) +from robot.output import librarylogger as logger +from robot.utils import ( + cut_assign_value, frange, get_error_message, is_list_like, Matcher, normalize, + plural_or_not as s, secs_to_timestr, seq2str, split_from_equals, timestr_to_secs, + type_name +) +from robot.variables import evaluate_expression, is_dict_variable, search_variable + +from .statusreporter import StatusReporter + +DEFAULT_WHILE_LIMIT = 10_000 + + +class BodyRunner: + + def __init__(self, context, run=True, templated=False): + self._context = context + self._run = run + self._templated = templated + + def run(self, data, result): + errors = [] + passed = None + for item in data.body: + try: + item.run(result, self._context, self._run, self._templated) + except ExecutionPassed as exception: + exception.set_earlier_failures(errors) + passed = exception + self._run = False + except ExecutionFailed as exception: + errors.extend(exception.get_errors()) + self._run = exception.can_continue(self._context, self._templated) + if passed: + raise passed + if errors and self._templated: + errors = self._handle_skip_with_templates(errors, result) + if errors: + raise ExecutionFailures(errors) + + def _handle_skip_with_templates(self, errors, result): + iterations = result.body.filter(messages=False) + if len(iterations) < 2 or not any(e.skip for e in errors): + return errors + if all(i.skipped for i in iterations): + raise ExecutionFailed("All iterations skipped.", skip=True) + return [e for e in errors if not e.skip] + + +class KeywordRunner: + + def __init__(self, context, run=True): + self._context = context + self._run = run + + def run(self, data, result, setup_or_teardown=False): + context = self._context + if setup_or_teardown: + runner = self._get_setup_teardown_runner(data, context) + else: + runner = context.get_runner(data.name, recommend_on_failure=self._run) + if not runner: + return None + if context.dry_run: + return runner.dry_run(data, result, context) + return runner.run(data, result, context, self._run) + + def _get_setup_teardown_runner(self, data, context): + try: + name = context.variables.replace_string(data.name) + except DataError as err: + if context.dry_run: + return None + raise ExecutionFailed(err.message) + if name.upper() in ("NONE", ""): + return None + # If the matched runner accepts embedded arguments, use the original name + # instead of the one where variables are already replaced and converted to + # strings. This allows using non-string values as embedded arguments also + # in this context. An exact match after variables have been replaced has + # a precedence over a possible embedded match with the original name, though. + # BuiltIn.run_keyword has the same logic. + runner = context.get_runner(name, recommend_on_failure=self._run) + if hasattr(runner, "embedded_args") and name != data.name: + candidate = context.get_runner(data.name) + if hasattr(candidate, "embedded_args"): + runner = candidate + return runner + + +def ForRunner(context, flavor="IN", run=True, templated=False): + runners = { + "IN": ForInRunner, + "IN RANGE": ForInRangeRunner, + "IN ZIP": ForInZipRunner, + "IN ENUMERATE": ForInEnumerateRunner, + } + runner = runners[flavor or "IN"] + return runner(context, run, templated) + + +class ForInRunner: + flavor = "IN" + + def __init__(self, context, run=True, templated=False): + self._context = context + self._run = run + self._templated = templated + + def run(self, data, result): + error = None + run = False + if self._run: + if data.error: + error = DataError(data.error, syntax=True) + else: + run = True + with StatusReporter(data, result, self._context, run) as status: + if run: + try: + assign, types = self._split_types(data) + values_for_rounds = self._get_values_for_rounds(data) + except DataError as err: + error = err + else: + if self._run_loop(data, result, assign, types, values_for_rounds): + return + status.pass_status = result.NOT_RUN + self._no_run_one_round(data, result) + if error: + raise error + + def _run_loop(self, data, result, assign, types, values_for_rounds): + errors = [] + executed = False + for values in values_for_rounds: + executed = True + try: + self._run_one_round(data, result, assign, types, values) + 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: + if self._templated and len(errors) > 1 and all(e.skip for e in errors): + raise ExecutionFailed("All iterations skipped.", skip=True) + raise ExecutionFailures(errors) + return executed + + def _split_types(self, data): + from .arguments import TypeInfo + + assign = [] + types = [] + for variable in data.assign: + match = search_variable(variable, parse_type=True) + assign.append(match.name) + try: + types.append(TypeInfo.from_variable(match) if match.type else None) + except DataError as err: + raise DataError(f"Invalid FOR loop variable '{variable}': {err}") + return assign, types + + def _get_values_for_rounds(self, data): + if self._context.dry_run: + return [[""] * len(data.assign)] + 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) + else: + values = self._resolve_values(data.values) + values = self._map_values_to_rounds(values, values_per_round) + return values + + def _is_dict_iteration(self, values): + all_name_value = True + for item in values: + if is_dict_variable(item): + return True + if split_from_equals(item)[1] is None: + all_name_value = False + 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' format " + f"like '{values[0]}' is deprecated. In the future this syntax will " + f"mean iterating over names and values separately like when iterating " + f"over '&{{dict}} variables. Escape at least one of the values like " + f"'{name}\\={value}' to use normal FOR loop iteration and to disable " + f"this warning." + ) + return False + + def _resolve_dict_values(self, values): + result = OrderedDict() + replace_scalar = self._context.variables.replace_scalar + for item in values: + if is_dict_variable(item): + result.update(replace_scalar(item)) + else: + key, value = split_from_equals(item) + 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.", + syntax=True, + ) + try: + result[replace_scalar(key)] = replace_scalar(value) + except TypeError: + 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( + f"Number of FOR loop variables must be 1 or 2 when iterating " + f"over dictionaries, got {per_round}.", + syntax=True, + ) + return values + + def _resolve_values(self, values): + return self._context.variables.replace_list(values) + + def _map_values_to_rounds(self, values, per_round): + count = len(values) + if count % per_round != 0: + self._raise_wrong_variable_count(per_round, count) + # Map list of values to list of lists containing 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( + f"Number of FOR loop values should be multiple of its variables. " + f"Got {variables} variables but {values} value{s(values)}." + ) + + def _run_one_round(self, data, result, assign, types, values, run=True): + ctx = self._context + iter_data = data.get_iteration() + iter_result = result.body.create_iteration() + variables = ctx.variables if run and not ctx.dry_run else {} + if len(assign) == 1 and len(values) != 1: + values = [tuple(values)] + for orig, name, type_info, value in zip(data.assign, assign, types, values): + if type_info and not ctx.dry_run: + value = type_info.convert(value, orig, kind="FOR loop variable") + variables[name] = value + iter_data.assign[orig] = value + iter_result.assign[orig] = cut_assign_value(value) + runner = BodyRunner(self._context, run, self._templated) + with StatusReporter(iter_data, iter_result, self._context, run): + runner.run(iter_data, iter_result) + + def _no_run_one_round(self, data, result): + self._run_one_round( + data, + result, + assign=data.assign, + types=[None] * len(data.assign), + values=[""] * len(data.assign), + run=False, + ) + + +class ForInRangeRunner(ForInRunner): + flavor = "IN RANGE" + + def _resolve_dict_values(self, values): + raise DataError( + "FOR IN RANGE loops do not support iterating over 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)}.", + syntax=True, + ) + try: + values = [self._to_number_with_arithmetic(v) for v in values] + except Exception: + msg = get_error_message() + raise DataError(f"Converting FOR IN RANGE values failed: {msg}.") + values = frange(*values) + return super()._map_values_to_rounds(values, per_round) + + def _to_number_with_arithmetic(self, item): + if isinstance(item, (int, float)): + return item + number = eval(str(item), {}) + if not isinstance(number, (int, float)): + raise TypeError(f"Expected number, got {type_name(item)}.") + return number + + +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) + valid = ("STRICT", "SHORTEST", "LONGEST") + if mode.upper() in valid: + return mode.upper() + raise DataError( + f"Value '{mode}' is not accepted. Valid values are {seq2str(valid)}." + ) + except DataError as err: + raise DataError(f"Invalid FOR IN ZIP 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 FOR IN ZIP 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): + self._validate_types(values) + if len(values) % per_round != 0: + self._raise_wrong_variable_count(per_round, len(values)) + if self._mode == "LONGEST": + 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): + 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, " + f"but item {index} 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 must have length in the STRICT mode, " + f"but item {index} does not." + ) + if len(set(lengths)) > 1: + raise DataError( + f"FOR IN ZIP items must have equal lengths in the STRICT mode, " + f"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 STRICT in " + f"Robot Framework 8.0. Use 'mode=SHORTEST' to keep using the SHORTEST " + f"mode. If the mode is not changed, execution will fail like this in " + f"the future: {err}" + ) + + +class ForInEnumerateRunner(ForInRunner): + flavor = "IN ENUMERATE" + _start = 0 + + def _get_values_for_rounds(self, data): + self._start = self._resolve_start(data.start) + return super()._get_values_for_rounds(data) + + def _resolve_start(self, start): + if not start or self._context.dry_run: + return 0 + try: + start = self._context.variables.replace_string(start) + try: + return int(start) + except ValueError: + raise DataError(f"Value must be an integer, got '{start}'.") + except DataError as 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: + 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)) + + def _map_values_to_rounds(self, values, per_round): + values = super()._map_values_to_rounds(values, max(per_round - 1, 1)) + return ((i, *v) for i, v in enumerate(values, start=self._start)) + + def _raise_wrong_variable_count(self, variables, values): + raise DataError( + f"Number of FOR IN ENUMERATE loop values should be multiple of its " + f"variables (excluding the index). Got {variables} variables but " + f"{values} value{s(values)}." + ) + + +class WhileRunner: + + def __init__(self, context, run=True, templated=False): + self._context = context + self._run = run + self._templated = templated + + def run(self, data, result): + ctx = self._context + error = None + run = False + result.start_time = datetime.now() + iter_result = result.body.create_iteration(start_time=datetime.now()) + if self._run: + if data.error: + error = DataError(data.error, syntax=True) + elif not ctx.dry_run: + try: + run = self._should_run(data.condition, ctx.variables) + except DataError as err: + error = err + with StatusReporter(data, result, self._context, run): + iter_data = data.get_iteration() + if run: + try: + limit = WhileLimit.create(data, ctx.variables) + except DataError as err: + error = err + run = False + if ctx.dry_run or not run: + self._run_iteration(iter_data, iter_result, run) + if error: + raise error + return + errors = [] + while True: + try: + with limit: + self._run_iteration(iter_data, iter_result) + 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 LimitExceeded as exceeded: + if exceeded.on_limit_pass: + self._context.info(exceeded.message) + else: + errors.append(exceeded) + break + except ExecutionFailed as failed: + errors.extend(failed.get_errors()) + if not failed.can_continue(ctx, self._templated): + break + iter_result = result.body.create_iteration(start_time=datetime.now()) + iter_data = data.get_iteration() + if not self._should_run(data.condition, ctx.variables): + break + if errors: + raise ExecutionFailures(errors) + + def _run_iteration(self, data, result, run=True): + runner = BodyRunner(self._context, run, self._templated) + with StatusReporter(data, result, self._context, run): + runner.run(data, result) + + def _should_run(self, condition, variables): + if not condition: + return True + try: + return evaluate_expression( + condition, + variables.current, + resolve_variables=True, + ) + except Exception: + msg = get_error_message() + raise DataError(f"Invalid WHILE loop condition: {msg}") + + +class GroupRunner: + + def __init__(self, context, run=True, templated=False): + self._context = context + self._run = run + self._templated = templated + + def run(self, data, result): + if self._run: + error = self._initialize(data, result) + run = error is None + else: + error = None + run = False + with StatusReporter(data, result, self._context, run=run): + runner = BodyRunner(self._context, run, self._templated) + runner.run(data, result) + if error: + raise error + + def _initialize(self, data, result): + if data.error: + return DataError(data.error, syntax=True) + try: + result.name = self._context.variables.replace_string(result.name) + except DataError as err: + return err + return None + + +class IfRunner: + _dry_run_stack = [] + + def __init__(self, context, run=True, templated=False): + self._context = context + self._run = run + self._templated = templated + + def run(self, data, result): + with self._dry_run_recursion_detection(data) as recursive_dry_run: + error = None + with StatusReporter(data, result, self._context, self._run): + for branch in data.body: + try: + if self._run_if_branch( + branch, + result, + recursive_dry_run, + data.error, + ): + self._run = False + except ExecutionStatus as err: + self._run = False + error = err + if error: + raise error + + @contextmanager + def _dry_run_recursion_detection(self, data): + if not self._context.dry_run: + yield False + else: + data = data.to_dict() + recursive = data in self._dry_run_stack + self._dry_run_stack.append(data) + try: + yield recursive + finally: + self._dry_run_stack.pop() + + def _run_if_branch(self, data, result, recursive_dry_run=False, syntax_error=None): + context = self._context + result = result.body.create_branch( + data.type, + data.condition, + start_time=datetime.now(), + ) + error = None + if syntax_error: + run_branch = False + error = DataError(syntax_error, syntax=True) + else: + try: + run_branch = self._should_run_branch(data, context, recursive_dry_run) + except DataError as err: + run_branch = False + error = err + with StatusReporter(data, result, context, run_branch): + runner = BodyRunner(context, run_branch, self._templated) + if not recursive_dry_run: + runner.run(data, result) + if error and self._run: + raise error + return run_branch + + def _should_run_branch(self, data, context, recursive_dry_run=False): + if context.dry_run: + return not recursive_dry_run + if not self._run: + return False + if data.condition is None: + return True + try: + condition = evaluate_expression( + data.condition, + context.variables.current, + resolve_variables=True, + ) + except Exception: + msg = get_error_message() + raise DataError(f"Invalid {data.type} condition: {msg}") + else: + return bool(condition) + + +class TryRunner: + + def __init__(self, context, run=True, templated=False): + self._context = context + self._run = run + self._templated = templated + + def run(self, data, result): + run = self._run + with StatusReporter(data, result, self._context, run): + if data.error: + self._run_invalid(data, result) + return + 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, result, error, run=run_excepts_or_else) + self._run_else(data, result, run=False) + else: + 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, result): + error_reported = False + for branch in data.body: + 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, branch_result) + if not error_reported: + error_reported = True + raise DataError(data.error, syntax=True) + raise ExecutionFailed(data.error, syntax=True) + + 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): + if not run: + return False + if not error: + return True + return not (error.skip or error.syntax or isinstance(error, ExecutionPassed)) + + def _run_branch(self, data, result, run=True, error=None): + try: + with StatusReporter(data, result, self._context, run): + if error: + raise error + runner = BodyRunner(self._context, run, self._templated) + runner.run(data, result) + except ExecutionStatus as err: + return err + else: + return None + + 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) + except DataError as err: + run_branch = True + pattern_error = err + else: + pattern_error = None + 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, branch_result, error=pattern_error) + run = False + else: + self._run_branch(branch, branch_result, run=False) + return error + + def _should_run_except(self, branch, error): + if not branch.patterns: + return True + matchers = { + "GLOB": lambda m, p: Matcher(p, spaceless=False, caseless=False).match(m), + "REGEXP": lambda m, p: re.fullmatch(p, m) is not None, + "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) + else: + pattern_type = "LITERAL" + matcher = matchers.get(pattern_type.upper()) + if not matcher: + 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 + return False + + def _run_else(self, data, result, run): + if data.else_branch: + result = result.body.create_branch(data.ELSE) + return self._run_branch(data.else_branch, result, run) + + def _run_finally(self, data, result, run): + if data.finally_branch: + result = result.body.create_branch(data.FINALLY) + try: + with StatusReporter(data.finally_branch, result, self._context, run): + runner = BodyRunner(self._context, run, self._templated) + runner.run(data.finally_branch, result) + except ExecutionStatus as err: + return err + else: + return None + + +class WhileLimit: + + 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, data, variables): + limit = cls._parse_limit(data.limit, variables) + on_limit = cls._parse_on_limit(data.on_limit, variables) + on_limit_msg = cls._parse_on_limit_message(data.on_limit_message, variables) + if not limit: + return IterationCountLimit(DEFAULT_WHILE_LIMIT, on_limit, on_limit_msg) + if limit.upper() == "NONE": + return NoLimit() + try: + count = cls._parse_limit_as_count(limit) + except ValueError: + seconds = cls._parse_limit_as_timestr(limit) + return DurationLimit(seconds, on_limit, on_limit_msg) + else: + return IterationCountLimit(count, on_limit, on_limit_msg) + + @classmethod + def _parse_limit(cls, limit, variables): + if not limit: + return None + try: + return variables.replace_string(limit) + except DataError as err: + raise DataError(f"Invalid WHILE loop limit: {err}") + + @classmethod + def _parse_on_limit(cls, on_limit, variables): + if not on_limit: + return None + try: + on_limit = variables.replace_string(on_limit) + if on_limit.upper() in ("PASS", "FAIL"): + return on_limit.upper() + raise DataError( + f"Value '{on_limit}' is not accepted. Valid values are " + f"'PASS' and 'FAIL'." + ) + except DataError as err: + raise DataError(f"Invalid WHILE loop 'on_limit': {err}") + + @classmethod + def _parse_on_limit_message(cls, on_limit_message, variables): + if not on_limit_message: + return None + try: + return variables.replace_string(on_limit_message) + except DataError as err: + raise DataError(f"Invalid WHILE loop 'on_limit_message': '{err}") + + @classmethod + def _parse_limit_as_count(cls, limit): + limit = normalize(limit) + if limit.endswith("times"): + limit = limit[:-5] + elif limit.endswith("x"): + limit = limit[:-1] + count = int(limit) + if count <= 0: + raise DataError( + f"Invalid WHILE loop limit: Iteration count must be a positive " + f"integer, got '{count}'." + ) + return count + + @classmethod + def _parse_limit_as_timestr(cls, limit): + try: + return timestr_to_secs(limit) + except ValueError as err: + raise DataError(f"Invalid WHILE loop limit: {err.args[0]}") + + def limit_exceeded(self): + if self.on_limit_message: + message = self.on_limit_message + else: + message = ( + f"WHILE loop was aborted because it did not finish within the limit " + f"of {self}. Use the 'limit' argument to increase or remove the limit " + f"if needed." + ) + raise LimitExceeded(self.on_limit == "PASS", message) + + def __enter__(self): + raise NotImplementedError + + def __exit__(self, exc_type, exc_val, exc_tb): + return None + + +class DurationLimit(WhileLimit): + + 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 + + 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() + + def __str__(self): + return secs_to_timestr(self.max_time) + + +class IterationCountLimit(WhileLimit): + + 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 + + def __enter__(self): + if self.current_iterations >= self.max_iterations: + self.limit_exceeded() + self.current_iterations += 1 + + def __str__(self): + return f"{self.max_iterations} iterations" + + +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.py b/src/robot/running/builder.py deleted file mode 100644 index fa1fc1453d9..00000000000 --- a/src/robot/running/builder.py +++ /dev/null @@ -1,271 +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 os.path -import warnings - -from robot.errors import DataError -from robot.output import LOGGER -from robot.parsing import TestData, ResourceFile as ResourceData, TEST_EXTENSIONS -from robot.running.defaults import TestDefaults -from robot.utils import abspath, is_string, normalize, unic -from robot.variables import VariableIterator - -from .model import ForLoop, Keyword, ResourceFile, TestSuite - - -class TestSuiteBuilder(object): - """Creates executable :class:`~robot.running.model.TestSuite` objects. - - Suites are build based on existing test data on the file system. - - See the overall documentation of the :mod:`robot.running` package for - more information and examples. - """ - - def __init__(self, include_suites=None, warn_on_skipped='DEPRECATED', - extension=None, rpa=None): - """ - :param include_suites: List of suite names to include. If ``None`` or - an empty list, all suites are included. When executing tests - normally, these names are specified using the ``--suite`` option. - :param warn_on_skipped: Deprecated. - :param extension: Limit parsing test data to only these files. Files - are specified as an extension that is handled case-insensitively. - Same as ``--extension`` on the command line. - :param rpa: Explicit test execution mode. ``True`` for RPA and - ``False`` for test automation. By default mode is got from test - data headers and possible conflicting headers cause an error. - """ - self.include_suites = include_suites - self.extensions = self._get_extensions(extension) - builder = StepBuilder() - self._build_steps = builder.build_steps - self._build_step = builder.build_step - self.rpa = rpa - self._rpa_not_given = rpa is None - # TODO: Remove in RF 3.2. - if warn_on_skipped != 'DEPRECATED': - warnings.warn("Option 'warn_on_skipped' is deprecated and has no " - "effect.", DeprecationWarning) - - def _get_extensions(self, extension): - if not extension: - return None - extensions = set(ext.lower().lstrip('.') for ext in extension.split(':')) - if not all(ext in TEST_EXTENSIONS for ext in extensions): - raise DataError("Invalid extension to limit parsing '%s'." % extension) - return extensions - - def build(self, *paths): - """ - :param paths: Paths to test data files or directories. - :return: :class:`~robot.running.model.TestSuite` instance. - """ - if not paths: - raise DataError('One or more source paths required.') - if len(paths) == 1: - return self._parse_and_build(paths[0]) - root = TestSuite() - for path in paths: - root.suites.append(self._parse_and_build(path)) - root.rpa = self.rpa - return root - - def _parse_and_build(self, path): - suite = self._build_suite(self._parse(path)) - suite.remove_empty_suites() - return suite - - def _parse(self, path): - try: - return TestData(source=abspath(path), - include_suites=self.include_suites, - extensions=self.extensions) - except DataError as err: - raise DataError("Parsing '%s' failed: %s" % (path, err.message)) - - def _build_suite(self, data, parent_defaults=None): - if self._rpa_not_given and data.testcase_table.is_started(): - self._set_execution_mode(data) - self._check_deprecated_extensions(data.source) - defaults = TestDefaults(data.setting_table, parent_defaults) - suite = TestSuite(name=data.name, - source=data.source, - doc=unic(data.setting_table.doc), - metadata=self._get_metadata(data.setting_table)) - self._build_setup(suite, data.setting_table.suite_setup) - self._build_teardown(suite, data.setting_table.suite_teardown) - for test_data in data.testcase_table.tests: - self._build_test(suite, test_data, defaults) - for child in data.children: - suite.suites.append(self._build_suite(child, defaults)) - suite.rpa = self.rpa - ResourceFileBuilder().build(data, target=suite.resource) - return suite - - def _set_execution_mode(self, data): - rpa = normalize(data.testcase_table.header[0]) in ('task', 'tasks') - if self.rpa is None: - self.rpa = rpa - elif self.rpa is not rpa: - this, that = ('tasks', 'tests') if rpa else ('tests', 'tasks') - raise DataError("Conflicting execution modes. File '%s' has %s " - "but files parsed earlier have %s. Fix headers " - "or use '--rpa' or '--norpa' options to set the " - "execution mode explicitly." - % (data.source, this, that)) - - def _check_deprecated_extensions(self, source): - if os.path.isdir(source): - return - ext = os.path.splitext(source)[1][1:].lower() - if self.extensions and ext in self.extensions: - return - # HTML files cause deprecation warning that cannot be avoided with - # --extension at parsing time. No need for double warning. - if ext not in ('robot', 'html', 'htm', 'xhtml'): - LOGGER.warn("Automatically parsing other than '*.robot' files is " - "deprecated. Convert '%s' to '*.robot' format or use " - "'--extension' to explicitly configure which files to " - "parse." % source) - - def _get_metadata(self, settings): - # Must return as a list to preserve ordering - return [(meta.name, meta.value) for meta in settings.metadata] - - def _build_test(self, suite, data, defaults): - values = defaults.get_test_values(data) - template = self._get_template(values.template) - test = suite.tests.create(name=data.name, - doc=unic(data.doc), - tags=values.tags.value, - template=template, - timeout=self._get_timeout(values.timeout)) - self._build_setup(test, values.setup) - self._build_steps(test, data, template) - self._build_teardown(test, values.teardown) - - def _get_timeout(self, timeout): - return (timeout.value, timeout.message) if timeout else None - - def _get_template(self, template): - return unic(template) if template.is_active() else None - - def _build_setup(self, parent, data): - if data.is_active(): - self._build_step(parent, data, kw_type='setup') - - def _build_teardown(self, parent, data): - if data.is_active(): - self._build_step(parent, data, kw_type='teardown') - - -class ResourceFileBuilder(object): - - def __init__(self): - builder = StepBuilder() - self._build_steps = builder.build_steps - self._build_step = builder.build_step - - def build(self, path_or_data, target=None): - data, source = self._import_resource_if_needed(path_or_data) - if not target: - target = ResourceFile(doc=data.setting_table.doc.value, source=source) - self._build_imports(target, data.setting_table.imports) - self._build_variables(target, data.variable_table.variables) - for kw_data in data.keyword_table.keywords: - self._build_keyword(target, kw_data) - return target - - def _import_resource_if_needed(self, path_or_data): - if not is_string(path_or_data): - return path_or_data, path_or_data.source - return ResourceData(path_or_data).populate(), path_or_data - - def _build_imports(self, target, imports): - for data in imports: - target.imports.create(type=data.type, - name=data.name, - args=tuple(data.args), - alias=data.alias) - - def _build_variables(self, target, variables): - for data in variables: - if data: - target.variables.create(name=data.name, value=data.value) - - def _build_keyword(self, target, data): - kw = target.keywords.create(name=data.name, - args=tuple(data.args), - doc=unic(data.doc), - tags=tuple(data.tags), - return_=tuple(data.return_), - timeout=self._get_timeout(data.timeout)) - self._build_steps(kw, data) - if data.teardown.is_active(): - self._build_step(kw, data.teardown, kw_type='teardown') - - def _get_timeout(self, timeout): - return (timeout.value, timeout.message) if timeout else None - - -class StepBuilder(object): - - def build_steps(self, parent, data, template=None, kw_type='kw'): - steps = [self._build(step, template, kw_type) for step in data.steps - if step and not step.is_comment()] - parent.keywords.extend(steps) - - def build_step(self, parent, data, template=None, kw_type='kw'): - if data and not data.is_comment(): - step = self._build(data, template, kw_type) - parent.keywords.append(step) - - def _build(self, data, template=None, kw_type='kw'): - if data.is_for_loop(): - return self._build_for_loop(data, template) - if template: - return self._build_templated_step(data, template) - return self._build_normal_step(data, kw_type) - - def _build_for_loop(self, data, template): - loop = ForLoop(variables=data.vars, - values=data.items, - flavor=data.flavor) - self.build_steps(loop, data, template) - return loop - - def _build_templated_step(self, data, template): - args = data.as_list(include_comment=False) - template, args = self._format_template(template, args) - return Keyword(name=template, args=args) - - def _format_template(self, template, args): - iterator = VariableIterator(template, identifiers='$') - variables = len(iterator) - if not variables or variables != len(args): - return template, tuple(args) - temp = [] - for before, variable, after in iterator: - temp.extend([before, args.pop(0)]) - temp.append(after) - return ''.join(temp), () - - def _build_normal_step(self, data, kw_type): - return Keyword(name=data.name, - args=tuple(data.args), - assign=tuple(data.assign), - type=kw_type) diff --git a/src/robot/running/builder/__init__.py b/src/robot/running/builder/__init__.py new file mode 100644 index 00000000000..41d53951005 --- /dev/null +++ b/src/robot/running/builder/__init__.py @@ -0,0 +1,21 @@ +# 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 .builders import ( + ResourceFileBuilder as ResourceFileBuilder, + TestSuiteBuilder as TestSuiteBuilder, +) +from .parsers import RobotParser as RobotParser +from .settings import TestDefaults as TestDefaults diff --git a/src/robot/running/builder/builders.py b/src/robot/running/builder/builders.py new file mode 100644 index 00000000000..e7403388d73 --- /dev/null +++ b/src/robot/running/builder/builders.py @@ -0,0 +1,332 @@ +# 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 +from os.path import normpath +from pathlib import Path +from typing import cast, Sequence + +from robot.conf import LanguagesLike +from robot.errors import DataError +from robot.output import LOGGER +from robot.parsing import ( + SuiteDirectory, SuiteFile, SuiteStructure, SuiteStructureBuilder, + SuiteStructureVisitor +) +from robot.utils import Importer, seq2str, split_args_from_name_or_path, type_name + +from ..model import TestSuite +from ..resourcemodel import ResourceFile +from .parsers import ( + CustomParser, JsonParser, MarkdownParser, NoInitFileDirectoryParser, Parser, + RestParser, RobotParser +) +from .settings import TestDefaults + + +class TestSuiteBuilder: + """Builder to construct ``TestSuite`` objects based on data on the disk. + + The :meth:`build` method constructs executable + :class:`~robot.running.model.TestSuite` objects based on test data files + or directories. There are two main use cases for this API: + + - Execute the created suite by using its + :meth:`~robot.running.model.TestSuite.run` method. The suite can be + 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 + :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. An alternative is using the + :meth:`TestSuite.from_file_system ` + classmethod that uses this class internally. + """ + + def __init__( + self, + included_suites: str = "DEPRECATED", + included_extensions: Sequence[str] = ( + ".robot", + ".rbt", + ".robot.rst", + ".robot.md", + ), + 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, + ): + """ + :param included_suites: + 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: + 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. + :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. + 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 + :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``. + :param process_curdir: + Control processing the special ``${CURDIR}`` variable. It is + 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.defaults = defaults + 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 8.0. + 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 created suite instead." + ) + + 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() + markdown_parser = MarkdownParser(lang, process_curdir) + return { + "robot": robot_parser, + "rst": rest_parser, + "rest": rest_parser, + "robot.rst": rest_parser, + "rbt": json_parser, + "json": json_parser, + "md": markdown_parser, + "markdown": markdown_parser, + "robot.md": markdown_parser, + } + + def _get_custom_parsers(self, parsers: Sequence[str]) -> "dict[str, CustomParser]": + custom_parsers = {} + importer = Importer("parser", LOGGER) + 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: + custom_parser = CustomParser(parser) + except TypeError as err: + raise DataError(f"Importing parser '{name}' failed: {err}") + for ext in custom_parser.extensions: + custom_parsers[ext] = custom_parser + return custom_parsers + + 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 = self.included_extensions + tuple(self.custom_parsers) + structure = SuiteStructureBuilder(extensions, self.included_files).build(*paths) + suite = SuiteStructureParser( + self._get_parsers(paths), + self.defaults, + self.rpa, + ).parse(structure) + 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 + + 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 = [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 tuple(paths) + + 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 ( + *self.included_extensions, + *[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 and ext.replace(".", "").isalnum(): + 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: + 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, + parsers: "dict[str | None, Parser]", + defaults: "TestDefaults | None" = None, + rpa: "bool | None" = None, + ): + self.parsers = parsers + self.rpa = rpa + self.defaults = defaults + 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 self.defaults + + def parse(self, structure: SuiteStructure) -> TestSuite: + structure.visit(self) + 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: + self._stack[-1][0].suites.append(suite) + + def start_directory(self, structure: SuiteDirectory): + if structure.source: + LOGGER.info(f"Parsing directory '{structure.source}'.") + suite, defaults = self._build_suite_directory(structure) + if self.suite is None: + self.suite = suite + else: + self._stack[-1][0].suites.append(suite) + self._stack.append((suite, defaults)) + + def end_directory(self, structure: SuiteDirectory): + suite, _ = self._stack.pop() + 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): + parser = self.parsers[structure.extension] + defaults = self.parent_defaults or TestDefaults() + try: + suite = parser.parse_suite_file(structure.source, defaults) + if not suite.tests: + LOGGER.info(f"Data source '{structure.source}' has no tests or tasks.") + except DataError as err: + raise DataError(f"Parsing '{structure.source}' failed: {err}") from err + return suite + + def _build_suite_directory(self, structure: SuiteDirectory): + parser = self.parsers[structure.extension] + defaults = TestDefaults(self.parent_defaults) + source = cast(Path, structure.init_file or structure.source) + 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}") from err + return suite, defaults + + +class ResourceFileBuilder: + + def __init__(self, lang: LanguagesLike = None, process_curdir: bool = True): + self.lang = lang + self.process_curdir = process_curdir + + def build(self, source: Path) -> ResourceFile: + 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: + kws = len(resource.keywords) + LOGGER.info(f"Imported resource file '{source}' ({kws} keywords).") + else: + LOGGER.warn(f"Imported resource file '{source}' is empty.") + return resource + + def _parse(self, source: Path) -> ResourceFile: + suffix = source.suffix.lower() + if suffix in (".rst", ".rest"): + parser = RestParser(self.lang, self.process_curdir) + elif suffix in (".json", ".rsrc"): + parser = JsonParser() + elif suffix in (".md", ".markdown"): + parser = MarkdownParser(self.lang, self.process_curdir) + 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 new file mode 100644 index 00000000000..20d8e6bcbff --- /dev/null +++ b/src/robot/running/builder/parsers.py @@ -0,0 +1,230 @@ +# 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 re +from abc import ABC +from inspect import signature +from pathlib import Path +from textwrap import dedent + +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, type_name + +from ..model import TestSuite +from ..resourcemodel import ResourceFile +from .settings import FileSettings, InitFileSettings, TestDefaults +from .transformers import ResourceBuilder, SuiteBuilder + + +class Parser(ABC): + + @property + def name(self) -> str: + return type(self).__name__ + + 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: TestDefaults) -> TestSuite: + raise DataError(f"'{self.name}' does not support parsing initialization files.") + + def parse_resource_file(self, source: Path) -> ResourceFile: + raise DataError(f"'{self.name}' does not support parsing resource files.") + + +class RobotParser(Parser): + extensions = () + + 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: TestDefaults) -> TestSuite: + model = get_model( + self._get_source(source), + data_only=True, + curdir=self._get_curdir(source), + lang=self.lang, + ) + 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, + ) + 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: + name = TestSuite.name_from_source(model.source, self.extensions) + suite = TestSuite(name=name, source=model.source) + SuiteBuilder(suite, FileSettings(defaults)).build(model) + return suite + + def _get_curdir(self, source: Path) -> "str | None": + return str(source.parent).replace("\\", "\\\\") if self.process_curdir else None + + def _get_source(self, source: Path) -> "Path | str": + return 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, + ) + model.source = source + return self.parse_resource_model(model) + + def parse_resource_model(self, model: File) -> ResourceFile: + resource = ResourceFile(source=model.source) + ResourceBuilder(resource).build(model) + return resource + + +class RestParser(RobotParser): + extensions = (".robot.rst", ".rst", ".rest") + + def _get_source(self, source: Path) -> str: + from .restreader import read_rest_data + + with FileReader(source) as reader: + return read_rest_data(reader) + + +class MarkdownParser(RobotParser): + extensions = (".robot.md", ".md", ".markdown") + + def _get_source(self, source: Path) -> str: + with FileReader(source) as reader: + return self._read_markdown_data(reader) + + def _read_markdown_data(self, mdfile: FileReader) -> str: + fence_open = re.compile(r"\s*(`{3,}|~{3,})\s*(\S+)") + fence_close = None + blocks = [] + block = None + for line in mdfile.readlines(): + if block is None: + match = fence_open.match(line) + if match: + fence, lang = match.groups() + if lang.lower() in ("robotframework", "robot"): + fence_close = re.compile(rf"\s*{fence[0]}{{{len(fence)},}}\s*") + block = [] + elif fence_close.fullmatch(line): + blocks.append(block) + block = None + else: + block.append(line) + if block: + blocks.append(block) + return "\n".join(dedent("".join(blk)) for blk in blocks) + + +class JsonParser(Parser): + + def parse_suite_file(self, source: Path, defaults: TestDefaults) -> TestSuite: + return TestSuite.from_json(source) + + def parse_init_file(self, source: Path, defaults: TestDefaults) -> TestSuite: + return TestSuite.from_json(source) + + def parse_resource_file(self, source: Path) -> ResourceFile: + try: + return ResourceFile.from_json(source) + except DataError as err: + raise DataError(f"Parsing JSON resource file '{source}' failed: {err}") + + +class NoInitFileDirectoryParser(Parser): + + def parse_init_file(self, source: Path, defaults: TestDefaults) -> TestSuite: + return TestSuite( + name=TestSuite.name_from_source(source), + source=source, + rpa=None, + ) + + +class CustomParser(Parser): + + def __init__(self, parser): + self.parser = parser + 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' or 'extension' " + f"attribute." + ) + + @property + def name(self) -> str: + return type_name(self.parser) + + @property + def extensions(self) -> "tuple[str, ...]": + ext = ( + getattr(self.parser, "EXTENSION", None) + or getattr(self.parser, "extension", None) + ) # fmt: skip + 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: TestDefaults) -> TestSuite: + return self._parse(self.parser.parse, source, defaults) + + 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) + except NotImplementedError: + return super().parse_init_file(source, defaults) # Raises DataError + + def _parse(self, method, source, defaults, init=False) -> TestSuite: + if not method: + raise NotImplementedError + accepts_defaults = len(signature(method).parameters) == 2 + try: + 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', got " + f"'{type_name(suite)}'." + ) + except Exception: + method_name = "parse" if not init else "parse_init" + raise DataError( + f"Calling '{self.name}.{method_name}()' failed: {get_error_message()}" + ) + return suite diff --git a/src/robot/running/builder/restreader.py b/src/robot/running/builder/restreader.py new file mode 100644 index 00000000000..08e8e7adc64 --- /dev/null +++ b/src/robot/running/builder/restreader.py @@ -0,0 +1,97 @@ +# 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 contextlib import contextmanager +from functools import wraps +from inspect import unwrap + +from robot.errors import DataError + +try: + from docutils.core import publish_doctree + from docutils.parsers.rst import directives, roles + from docutils.parsers.rst.directives import register_directive + from docutils.parsers.rst.directives.body import CodeBlock + from docutils.parsers.rst.directives.misc import Include +except ImportError: + raise DataError( + "Using reStructuredText test data requires having " + "'docutils' module version 0.9 or newer installed." + ) + + +class RobotDataStorage: + + def __init__(self, doctree): + if not hasattr(doctree, "_robot_data"): + doctree._robot_data = [] + self._robot_data = doctree._robot_data + + def add_data(self, rows): + self._robot_data.extend(rows) + + def get_data(self): + return "\n".join(self._robot_data) + + +class RobotCodeBlock(CodeBlock): + + def run(self): + if "robotframework" in self.arguments: + store = RobotDataStorage(self.state_machine.document) + store.add_data(self.content) + return [] + + +@wraps(directives.directive) +def directive(*args, **kwargs): + directive_class, messages = unwrap(directive)(*args, **kwargs) + if directive_class not in (RobotCodeBlock, Include): + directive_class = lambda *args, **kwargs: [] + return directive_class, messages + + +@wraps(roles.role) +def role(*args, **kwargs): + role_function = unwrap(role)(*args, **kwargs) + if role_function is None: + role_function = (lambda *args, **kwargs: [], []) + return role_function + + +@contextmanager +def docutils_config(): + orig_directive, orig_role = directives.directive, roles.role + directives.directive, roles.role = directive, role + register_directive("code", RobotCodeBlock) + register_directive("code-block", RobotCodeBlock) + register_directive("sourcecode", RobotCodeBlock) + try: + yield + finally: + directives.directive, roles.role = orig_directive, orig_role + register_directive("code", CodeBlock) + register_directive("code-block", CodeBlock) + register_directive("sourcecode", CodeBlock) + + +def read_rest_data(rstfile): + with docutils_config(): + doc = publish_doctree( + rstfile.read(), + source_path=rstfile.name, + settings_overrides={"input_encoding": "UTF-8", "report_level": 4}, + ) + return RobotDataStorage(doc).get_data() diff --git a/src/robot/running/builder/settings.py b/src/robot/running/builder/settings.py new file mode 100644 index 00000000000..835f750a5c4 --- /dev/null +++ b/src/robot/running/builder/settings.py @@ -0,0 +1,220 @@ +# 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 collections.abc import Sequence +from typing import TypedDict + +from ..model import TestCase + + +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 + + +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. 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 :mod:`robot.running` or :mod:`robot.api.interfaces`. + + __ http://robotframework.org/robotframework/latest/RobotFrameworkUserGuide.html#parser-interface + """ + + def __init__( + self, + parent: "TestDefaults | None" = None, + setup: "FixtureDict | None" = None, + teardown: "FixtureDict | None" = None, + tags: "Sequence[str]" = (), + timeout: "str | None" = None, + ): + self.parent = parent + self.setup = setup + self.teardown = teardown + self.tags = tags + self.timeout = timeout + + @property + def setup(self) -> "FixtureDict | None": + """Default 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 None + + @setup.setter + def setup(self, setup: "FixtureDict | None"): + self._setup = setup + + @property + def teardown(self) -> "FixtureDict | None": + """Default 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 None + + @teardown.setter + def teardown(self, teardown: "FixtureDict | None"): + self._teardown = teardown + + @property + 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 = tuple(tags) + + @property + def timeout(self) -> "str | None": + """Default timeout.""" + if self._timeout: + return self._timeout + if self.parent: + return self.parent.timeout + return None + + @timeout.setter + 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.config(**self.setup) + if self.teardown and not test.has_teardown: + test.teardown.config(**self.teardown) + if self.timeout and not test.timeout: + test.timeout = self.timeout + + +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 = () + self.test_timeout = None + self.test_template = None + self.default_tags = () + self.keyword_tags = () + + @property + def test_setup(self) -> "FixtureDict | None": + return self._test_setup or self.test_defaults.setup + + @test_setup.setter + def test_setup(self, setup: "FixtureDict | None"): + self._test_setup = setup + + @property + def test_teardown(self) -> "FixtureDict | None": + return self._test_teardown or self.test_defaults.teardown + + @test_teardown.setter + def test_teardown(self, teardown: "FixtureDict | None"): + self._test_teardown = teardown + + @property + 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 = 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 | None"): + self._test_timeout = timeout + + @property + def test_template(self) -> "str | None": + return self._test_template + + @test_template.setter + def test_template(self, template: "str | None"): + self._test_template = template + + @property + def default_tags(self) -> "tuple[str, ...]": + return self._default_tags + + @default_tags.setter + def default_tags(self, tags: "Sequence[str]"): + self._default_tags = tuple(tags) + + @property + def keyword_tags(self) -> "tuple[str, ...]": + return self._keyword_tags + + @keyword_tags.setter + def keyword_tags(self, tags: "Sequence[str]"): + self._keyword_tags = tuple(tags) + + +class InitFileSettings(FileSettings): + + @FileSettings.test_setup.setter + def test_setup(self, setup: "FixtureDict | None"): + self.test_defaults.setup = setup + + @FileSettings.test_teardown.setter + def test_teardown(self, teardown: "FixtureDict | None"): + 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 | None"): + self.test_defaults.timeout = timeout diff --git a/src/robot/running/builder/transformers.py b/src/robot/running/builder/transformers.py new file mode 100644 index 00000000000..bce972a7a2c --- /dev/null +++ b/src/robot/running/builder/transformers.py @@ -0,0 +1,623 @@ +# 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.errors import DataError +from robot.output import LOGGER +from robot.parsing import File, ModelVisitor, Token +from robot.utils import NormalizedDict +from robot.variables import VariableMatches + +from ..model import For, Group, If, IfBranch, TestCase, TestSuite, Try, TryBranch, While +from ..resourcemodel import ResourceFile, UserKeyword +from .settings import FileSettings + +HasBody = Union[TestCase, UserKeyword, For, If, Try, While, Group] + + +class SettingsBuilder(ModelVisitor): + + def __init__(self, suite: TestSuite, settings: FileSettings): + self.suite = suite + self.settings = settings + + def visit_Documentation(self, node): + self.suite.doc = node.value + + 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) + + def visit_SuiteTeardown(self, node): + self.suite.teardown.config(name=node.name, args=node.args, lineno=node.lineno) + + def visit_TestSetup(self, node): + self.settings.test_setup = { + "name": node.name, + "args": node.args, + "lineno": node.lineno, + } + + def visit_TestTeardown(self, node): + self.settings.test_teardown = { + "name": node.name, + "args": node.args, + "lineno": node.lineno, + } + + def visit_TestTimeout(self, node): + self.settings.test_timeout = node.value + + def visit_DefaultTags(self, node): + self.settings.default_tags = node.values + + def visit_TestTags(self, node): + for tag in node.values: + if tag.startswith("-"): + LOGGER.warn( + f"Error in file '{self.suite.source}' on line {node.lineno}: " + f"Setting tags starting with a hyphen like '{tag}' using the " + f"'Test Tags' setting is deprecated. In Robot Framework 8.0 this " + f"syntax will be used for removing tags. Escape the tag like " + f"'\\{tag}' to use the literal value and to avoid this warning." + ) + self.settings.test_tags = node.values + + def visit_KeywordTags(self, node): + self.settings.keyword_tags = node.values + + def visit_TestTemplate(self, node): + self.settings.test_template = node.value + + def visit_LibraryImport(self, node): + 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.variables(node.name, node.args, node.lineno) + + def visit_VariableSection(self, node): + pass + + def visit_TestCaseSection(self, node): + pass + + def visit_KeywordSection(self, node): + pass + + +class SuiteBuilder(ModelVisitor): + + def __init__(self, suite: TestSuite, settings: FileSettings): + self.suite = suite + self.settings = settings + self.seen_keywords = NormalizedDict(ignore="_") + self.rpa = None + + def build(self, model: File): + ErrorReporter(model.source).visit(model) + SettingsBuilder(self.suite, self.settings).visit(model) + self.visit(model) + if self.rpa is not None: + self.suite.rpa = self.rpa + + def visit_SettingSection(self, node): + pass + + 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), + ) + + 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.settings).build(node) + + def visit_Keyword(self, node): + KeywordBuilder( + self.suite.resource, + self.settings, + self.seen_keywords, + ).build(node) + + +class ResourceBuilder(ModelVisitor): + + def __init__(self, resource: ResourceFile): + self.resource = resource + self.settings = FileSettings() + self.seen_keywords = NormalizedDict(ignore="_") + + def build(self, model: File): + ErrorReporter(model.source, raise_on_invalid_header=True).visit(model) + self.visit(model) + + def visit_Documentation(self, node): + self.resource.doc = node.value + + def visit_KeywordTags(self, node): + self.settings.keyword_tags = node.values + + def visit_LibraryImport(self, node): + self.resource.imports.library(node.name, node.args, node.alias, node.lineno) + + def visit_ResourceImport(self, node): + self.resource.imports.resource(node.name, node.lineno) + + def visit_VariablesImport(self, node): + self.resource.imports.variables(node.name, node.args, node.lineno) + + 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), + ) + + def visit_Keyword(self, node): + KeywordBuilder(self.resource, self.settings, self.seen_keywords).build(node) + + +class BodyBuilder(ModelVisitor): + + def __init__(self, model: "HasBody | 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_Group(self, node): + GroupBuilder(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_Return(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( + node.values, + lineno=node.lineno, + error=format_error(node.errors), + ) + + +class TestCaseBuilder(BodyBuilder): + model: TestCase + + def __init__(self, suite: TestSuite, settings: FileSettings): + super().__init__(suite.tests.create()) + self.settings = settings + self._test_has_tags = False + + 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.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.model.setup.config(**settings.test_setup) + if settings.test_teardown: + self.model.teardown.config(**settings.test_teardown) + self.generic_visit(node) + if not self._test_has_tags: + 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: + if item.type in (item.FOR, item.GROUP): + self._set_template(item, template) + elif item.type == item.IF_ELSE_ROOT: + for branch in item.body: + self._set_template(branch, template) + elif item.type == item.KEYWORD: + name, args = self._format_template(template, item.args) + item.name = name + item.args = args + + def _format_template(self, template, arguments): + matches = VariableMatches(template, identifiers="$") + count = len(matches) + if count == 0 or count != len(arguments): + return template, arguments + temp = [] + for match, arg in zip(matches, arguments): + temp[-1:] = [match.before, arg, match.after] + return "".join(temp), () + + def visit_Documentation(self, node): + self.model.doc = node.value + + def visit_Metadata(self, node): + self.model.metadata[node.name] = node.value + + def visit_Setup(self, node): + self.model.setup.config(name=node.name, args=node.args, lineno=node.lineno) + + def visit_Teardown(self, node): + self.model.teardown.config(name=node.name, args=node.args, lineno=node.lineno) + + def visit_Timeout(self, node): + self.model.timeout = node.value + + def visit_Tags(self, node): + self.model.tags.add(node.values, remove_negated=True) + self._test_has_tags = True + + def visit_Template(self, node): + self.model.template = node.value + + +class KeywordBuilder(BodyBuilder): + model: UserKeyword + + def __init__( + self, + resource: ResourceFile, + settings: FileSettings, + seen_keywords: NormalizedDict, + ): + super().__init__(resource.keywords.create(tags=settings.keyword_tags)) + self.resource = resource + self.seen_keywords = seen_keywords + self.return_setting = None + + def build(self, node): + kw = self.model + try: + # Validate only name here. Reporting all parsing errors would report also + # body being empty, but we want to validate it only at parsing time. + if not node.name: + raise DataError("User keyword name cannot be empty.") + kw.config(name=node.name, lineno=node.lineno) + except DataError as err: + # Errors other than name being empty mean that name contains invalid + # embedded arguments. Need to set `_name` to bypass `@property`. + kw.config(_name=node.name, lineno=node.lineno, error=str(err)) + self._report_error(node, err) + self.generic_visit(node) + if self.return_setting: + kw.body.create_return(self.return_setting) + if not kw.embedded: + self._handle_duplicates(kw, self.seen_keywords, node) + + def _report_error(self, node, error): + error = f"Creating keyword '{self.model.name}' failed: {error}" + ErrorReporter(self.model.source).report_error(node, error) + + def _handle_duplicates(self, kw, seen, node): + if kw.name in seen: + error = "Keyword with same name defined multiple times." + seen[kw.name].error = error + self.resource.keywords.pop() + self._report_error(node, error) + else: + seen[kw.name] = kw + + def visit_Documentation(self, node): + self.model.doc = node.value + + def visit_Arguments(self, node): + if node.errors: + error = "Invalid argument specification: " + format_error(node.errors) + self.model.error = error + self._report_error(node, error) + else: + self.model.args = node.values + + def visit_Tags(self, node): + self.model.tags.add(node.values, remove_negated=True) + + def visit_ReturnSetting(self, node): + ErrorReporter(self.model.source).visit(node) + self.return_setting = node.values + + def visit_Timeout(self, node): + self.model.timeout = node.value + + def visit_Setup(self, node): + self.model.setup.config(name=node.name, args=node.args, lineno=node.lineno) + + def visit_Teardown(self, node): + self.model.teardown.config(name=node.name, args=node.args, lineno=node.lineno) + + def visit_KeywordCall(self, node): + self.model.body.create_keyword( + name=node.keyword, + args=node.args, + assign=node.assign, + lineno=node.lineno, + ) + + +class ForBuilder(BodyBuilder): + model: For + + def __init__(self, parent: HasBody): + super().__init__(parent.body.create_for()) + + def build(self, node): + error = format_error(self._get_errors(node)) + self.model.config( + assign=node.assign, + flavor=node.flavor or "IN", + 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 + + def _get_errors(self, node): + errors = node.header.errors + node.errors + if node.end: + errors += node.end.errors + return errors + + +class IfBuilder(BodyBuilder): + model: "IfBranch | None" + + def __init__(self, parent: HasBody): + super().__init__() + self.root = parent.body.create_if() + + def build(self, 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 = self.root.body.create_branch( + node_type, + node.condition, + lineno=node.lineno, + ) + for step in node.body: + self.visit(step) + if assign: + for item in self.model.body: + # Having assign when model item doesn't support assign is an error, + # but it has been handled already when model was validated. + if hasattr(item, "assign"): + item.assign = assign + node = node.orelse + # Smallish hack to make sure assignment is always run. + if assign and node_type != "ELSE": + self.root.body.create_branch("ELSE").body.create_keyword( + assign=assign, name="BuiltIn.Set Variable", args=["${NONE}"] + ) + return self.root + + def _get_errors(self, node): + errors = node.header.errors + node.errors + if node.orelse: + errors += self._get_errors(node.orelse) + if node.end: + errors += node.end.errors + return errors + + +class TryBuilder(BodyBuilder): + model: "TryBranch | None" + + def __init__(self, parent: HasBody): + super().__init__() + self.root = parent.body.create_try() + + def build(self, node): + self.root.config(lineno=node.lineno, error=format_error(self._get_errors(node))) + while node: + 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 + return self.root + + def _get_errors(self, node): + errors = node.header.errors + node.errors + if node.next: + errors += self._get_errors(node.next) + if node.end: + errors += node.end.errors + return errors + + +class WhileBuilder(BodyBuilder): + model: While + + def __init__(self, parent: HasBody): + super().__init__(parent.body.create_while()) + + def build(self, node): + 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=format_error(self._get_errors(node)), + ) + for step in node.body: + self.visit(step) + return self.model + + def _get_errors(self, node): + errors = node.header.errors + node.errors + if node.end: + errors += node.end.errors + return errors + + +class GroupBuilder(BodyBuilder): + model: Group + + def __init__(self, parent: HasBody): + super().__init__(parent.body.create_group()) + + def build(self, node): + error = format_error(self._get_errors(node)) + self.model.config(name=node.name, lineno=node.lineno, error=error) + for step in node.body: + self.visit(step) + return self.model + + def _get_errors(self, node): + errors = node.header.errors + node.errors + if node.end: + errors += node.end.errors + return errors + + +def format_error(errors): + if not errors: + return None + if len(errors) == 1: + return errors[0] + return "\n- ".join(["Multiple errors:", *errors]) + + +class ErrorReporter(ModelVisitor): + + 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 + + def visit_Keyword(self, node): + pass + + def visit_ReturnSetting(self, node): + # Empty 'visit_Keyword' above prevents calling this when visiting the whole + # model, but 'KeywordBuilder.visit_ReturnSetting' visits the node it gets. + self.report_error(node.get_token(Token.RETURN_SETTING), warn=True) + + def visit_SectionHeader(self, node): + token = node.get_token(*Token.HEADER_TOKENS) + if not token.error: + return + if token.type == Token.INVALID_HEADER: + self.report_error(token, throw=self.raise_on_invalid_header) + 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. + self.report_error(token, warn=True) + + def visit_Error(self, node): + for token in node.get_tokens(Token.ERROR): + self.report_error(token) + + def report_error(self, source, error=None, warn=False, throw=False): + if not error: + if isinstance(source, Token): + error = source.error + else: + error = format_error(source.errors) + message = f"Error in file '{self.source}' on line {source.lineno}: {error}" + if throw: + raise DataError(message) + LOGGER.write(message, level="WARN" if warn else "ERROR") diff --git a/src/robot/running/context.py b/src/robot/running/context.py index 87f51bc1a35..ea2192efa41 100644 --- a/src/robot/running/context.py +++ b/src/robot/running/context.py @@ -13,16 +13,60 @@ # See the License for the specific language governing permissions and # limitations under the License. +import asyncio +import inspect +import sys from contextlib import contextmanager -from robot.errors import DataError -from robot.utils import unic +from robot.errors import DataError, ExecutionFailed -class ExecutionContexts(object): +class Asynchronous: + + def __init__(self): + 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() + self._loop_ref = None + + def run_until_complete(self, coroutine): + task = self.event_loop.create_task(coroutine) + try: + return self.event_loop.run_until_complete(task) + except ExecutionFailed as err: + if err.dont_continue: + task.cancel() + # Wait for task and its children to cancel. + self.event_loop.run_until_complete( + asyncio.gather(task, return_exceptions=True) + ) + raise err + + def is_loop_required(self, obj): + return inspect.iscoroutine(obj) and not self._is_loop_running() + + def _is_loop_running(self): + 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): @@ -40,33 +84,41 @@ 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 EXECUTION_CONTEXTS = ExecutionContexts() -class _ExecutionContext(object): - _started_keywords_threshold = 42 # Jython on Windows don't work with higher +class _ExecutionContext: - 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() + self.timeouts = [] + self.active_timeouts = [] self.namespace = namespace self.output = output self.dry_run = dry_run self.in_suite_teardown = False self.in_test_teardown = False self.in_keyword_teardown = 0 - self._started_keywords = 0 self.timeout_occurred = False + self.steps = [] + self.user_keywords = [] + self.asynchronous = asynchronous + + @property + def languages(self): + return self.namespace.languages @contextmanager def suite_teardown(self): @@ -78,10 +130,10 @@ def suite_teardown(self): @contextmanager def test_teardown(self, test): - self.variables.set_test('${TEST_STATUS}', test.status) - self.variables.set_test('${TEST_MESSAGE}', test.message) + self.variables.set_test("${TEST_STATUS}", test.status) + self.variables.set_test("${TEST_MESSAGE}", test.message) self.in_test_teardown = True - self._remove_timeout(test.timeout) + self.timeouts = [] # Clear current timeouts. try: yield finally: @@ -89,72 +141,142 @@ def test_teardown(self, test): @contextmanager def keyword_teardown(self, error): - self.variables.set_keyword('${KEYWORD_STATUS}', 'FAIL' if error else 'PASS') - self.variables.set_keyword('${KEYWORD_MESSAGE}', unic(error or '')) + self.variables.set_keyword("${KEYWORD_STATUS}", "FAIL" if error else "PASS") + self.variables.set_keyword("${KEYWORD_MESSAGE}", str(error or "")) self.in_keyword_teardown += 1 + self.timeouts, timeouts = [], self.timeouts # Disable current timeouts. try: yield finally: self.in_keyword_teardown -= 1 + self.timeouts = timeouts - @property @contextmanager - def user_keyword(self): + def user_keyword(self, handler): + self.user_keywords.append(handler) self.namespace.start_user_keyword() try: yield finally: self.namespace.end_user_keyword() + self.user_keywords.pop() + + 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.full_name}' is private and should only " + f"be called by keywords in the same file." + ) @contextmanager - def timeout(self, timeout): + def keyword_timeout(self, timeout): self._add_timeout(timeout) try: yield finally: self._remove_timeout(timeout) + @contextmanager + def timeout(self, timeout): + runner = timeout.get_runner() + self.active_timeouts.append(runner) + with self.output.delayed_logging: + self.output.debug(timeout.get_message) + try: + yield runner + finally: + self.active_timeouts.pop() + + @property + @contextmanager + def paused_timeouts(self): + if not self.active_timeouts: + yield + return + for runner in self.active_timeouts: + runner.pause() + with self.output.delayed_logging_paused: + try: + yield + finally: + for runner in self.active_timeouts: + runner.resume() + @property def in_teardown(self): - return bool(self.in_suite_teardown or - self.in_test_teardown or - self.in_keyword_teardown) + return bool( + self.in_suite_teardown or self.in_test_teardown or self.in_keyword_teardown + ) @property def variables(self): return self.namespace.variables - def end_suite(self, suite): - for name in ['${PREV_TEST_NAME}', - '${PREV_TEST_STATUS}', - '${PREV_TEST_MESSAGE}']: + def continue_on_failure(self, default=False): + parents = [ + result + for _, result, implementation in reversed(self.steps) + if implementation and implementation.type == "USER KEYWORD" + ] + if self.test: + parents.append(self.test) + for index, parent in enumerate(parents): + robot = parent.tags.robot + if index == 0 and robot("stop-on-failure"): + return False + if index == 0 and robot("continue-on-failure"): + return True + if robot("recursive-stop-on-failure"): + return False + if robot("recursive-continue-on-failure"): + return True + return default or self.in_teardown + + @property + def allow_loop_control(self): + for _, result, _ in reversed(self.steps): + if result.type == "ITERATION": + return True + if result.type == "KEYWORD" and result.owner != "BuiltIn": + return False + return False + + 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): - self.variables['${SUITE_NAME}'] = suite.longname - self.variables['${SUITE_SOURCE}'] = suite.source or '' - self.variables['${SUITE_DOCUMENTATION}'] = suite.doc - self.variables['${SUITE_METADATA}'] = suite.metadata.copy() + 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() def report_suite_status(self, status, message): - self.variables['${SUITE_STATUS}'] = status - self.variables['${SUITE_MESSAGE}'] = 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_METADATA}", result.metadata.copy()) + self.variables.set_test("@{TEST_TAGS}", list(result.tags)) + self.output.start_test(data, result) def _add_timeout(self, timeout): if timeout: timeout.start() - self.timeouts.add(timeout) + self.timeouts.append(timeout) def _remove_timeout(self, timeout): if timeout in self.timeouts: @@ -162,25 +284,104 @@ def _remove_timeout(self, timeout): def end_test(self, test): self.test = None - self._remove_timeout(test.timeout) + self.timeouts = [] self.namespace.end_test() - self.variables.set_suite('${PREV_TEST_NAME}', test.name) - self.variables.set_suite('${PREV_TEST_STATUS}', test.status) - self.variables.set_suite('${PREV_TEST_MESSAGE}', test.message) + self.variables.set_suite("${PREV_TEST_NAME}", test.name) + self.variables.set_suite("${PREV_TEST_STATUS}", test.status) + self.variables.set_suite("${PREV_TEST_MESSAGE}", test.message) self.timeout_occurred = False - def start_keyword(self, keyword): - self._started_keywords += 1 - if self._started_keywords > self._started_keywords_threshold: - raise DataError('Maximum limit of started keywords exceeded.') - self.output.start_keyword(keyword) - - def end_keyword(self, keyword): - self.output.end_keyword(keyword) - self._started_keywords -= 1 - - def get_runner(self, name): - return self.namespace.get_runner(name) + def start_body_item(self, data, result, implementation=None): + self._prevent_execution_close_to_recursion_limit() + self.steps.append((data, result, implementation)) + output = self.output + args = (data, result) + if implementation: + if implementation.error: + method = output.start_invalid_keyword + elif implementation.type == implementation.LIBRARY_KEYWORD: + method = output.start_library_keyword + else: + method = output.start_user_keyword + args = (data, implementation, result) + elif result.type in (result.ELSE, result.ITERATION): + method = { + 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.FOR: output.start_for, + result.WHILE: output.start_while, + result.GROUP: output.start_group, + 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(*args) + + def _prevent_execution_close_to_recursion_limit(self): + try: + sys._getframe(sys.getrecursionlimit() - 100) + except (ValueError, AttributeError): + pass + else: + raise DataError("Recursive execution stopped.") + + def end_body_item(self, data, result, implementation=None): + output = self.output + args = (data, result) + if implementation: + if implementation.error: + method = output.end_invalid_keyword + elif implementation.type == implementation.LIBRARY_KEYWORD: + method = output.end_library_keyword + else: + method = output.end_user_keyword + args = (data, implementation, result) + elif result.type in (result.ELSE, result.ITERATION): + method = { + 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.FOR: output.end_for, + result.WHILE: output.end_while, + result.GROUP: output.end_group, + 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(*args) + self.steps.pop() + + 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) @@ -196,3 +397,6 @@ def warn(self, message): def fail(self, message): self.output.fail(message) + + def skip(self, message): + self.output.skip(message) diff --git a/src/robot/running/defaults.py b/src/robot/running/defaults.py deleted file mode 100644 index 9fcf3745a9a..00000000000 --- a/src/robot/running/defaults.py +++ /dev/null @@ -1,43 +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. - - -class TestDefaults(object): - - def __init__(self, settings, parent=None): - self.setup = settings.test_setup - self.teardown = settings.test_teardown - self.timeout = settings.test_timeout - self.force_tags = settings.force_tags - self.default_tags = settings.default_tags - self.template = settings.test_template - if parent: - self.setup = self.setup or parent.setup - self.teardown = self.teardown or parent.teardown - self.timeout = self.timeout or parent.timeout - self.force_tags += parent.force_tags - - def get_test_values(self, test): - return TestValues(test, self) - - -class TestValues(object): - - def __init__(self, test, defaults): - self.setup = test.setup or defaults.setup - self.teardown = test.teardown or defaults.teardown - self.timeout = test.timeout or defaults.timeout - self.template = test.template or defaults.template - self.tags = (test.tags or defaults.default_tags) + defaults.force_tags diff --git a/src/robot/running/docstringparser.py b/src/robot/running/docstringparser.py new file mode 100644 index 00000000000..da6947b880e --- /dev/null +++ b/src/robot/running/docstringparser.py @@ -0,0 +1,210 @@ +# 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 re +from collections.abc import Iterable, Sequence +from dataclasses import dataclass, field +from inspect import cleandoc + +from robot.output import LOGGER + +__all__ = ["parse_docstring", "DocInfo"] + + +@dataclass +class DocInfo: + doc: str = "" + args: "dict[str, str]" = field(default_factory=dict) + returns: str = "" + raises: "dict[str, str]" = field(default_factory=dict) + tags: "list[str]" = field(default_factory=list) + + +def parse_docstring(doc: str, name: "str | None" = None) -> DocInfo: + """Parses information from docstring. + + :param doc: Docstring to parse. + :param name: Name of the keyword/function keyword. Used in possible error + and warning messages. + + Parsing is done according to the Google Python Style Guide. + https://google.github.io/styleguide/pyguide.html#s3.8-comments-and-docstrings + + Example:: + + def keyword(arg) -> str: + \"""Example keyword documentation. + + Args: + arg: Doc for the argument. + + Returns: + Argument in uppercase. + + This continues the documentation. + \""" + return arg.upper() + + Information is parsed from `Args`, `Returns` and `Raises` sections as well + as from Robot Framework's custom `Tags` section. + + It is assumed that the given `doc` is already normalized using `inspect.cleandoc` + or otherwise. + """ + return DocStringParser(name).parse(doc) + + +class Block: + """Represents a block of text with an optional name. + + Used for representing documentation sections and arguments. + """ + + def __init__(self, name: str = "", body: "Sequence[str]" = ()): + """ + :param name: Name of the block. + :param body: Block body. + + If a block has a name, body *must* contain the text on the same line as + the name to make sure `cleandoc` works properly. This text can be empty. + """ + assert body or not name + self.name = name.strip() + self._body = list(body) + + @property + def content(self) -> str: + """Return block content. With named blocks content is dedent.""" + content = "\n".join(self._body) + if self.name: + content = cleandoc(content) + return content.rstrip() + + def accepts(self, line: str) -> bool: + if not self.name: + return True + return not line or line[:2].isspace() + + def add(self, line: str): + self._body.append(line) + + +class DocStringParser: + args_headers = ("args", "arguments", "parameters") + returns_headers = ("returns", "return", "yields") + raises_headers = ("raises", "raise") + tags_headers = ("tags",) + header_re = re.compile( + rf""" + [*_]* # Start of optional bold/italics formatting + ({'|'.join(args_headers + returns_headers + raises_headers + tags_headers)}) + [*_]* # End of optional bold/italics formatting + ::? # One or two colons + [*_]* # Alternative end of optional bold/italics formatting + (\s+(.*))? # Optional inline text + """, + re.IGNORECASE | re.VERBOSE, + ) + named_doc_re = re.compile(r"\s?(\S.*?):(\s+(.*))?") + + def __init__(self, name: "str | None" = None): + self.name = name + + def parse(self, doc: str) -> DocInfo: + info = DocInfo() + for section in self._parse_sections(doc): + name = section.name.lower() + if name in self.args_headers: + info.args.update(self._parse_named_docs(section.content)) + elif name in self.returns_headers: + info.returns = section.content.strip() + elif name in self.raises_headers: + info.raises.update(self._parse_named_docs(section.content)) + elif name in self.tags_headers: + info.tags.extend(self._parse_tags(section.content)) + else: + info.doc += section.content + "\n\n" + info.doc = info.doc.strip() + return info + + def _parse_sections(self, doc: str) -> "Iterable[Block]": + section = Block() + can_start = True + for line in doc.splitlines(): + header, inline_text = self._match_header(line, can_start) + if header: + yield section + section = Block(header, [inline_text or ""]) + elif section.accepts(line): + section.add(line) + else: + yield section + section = Block(body=[line]) + can_start = section.name or not line.strip() + yield section + + def _match_header(self, line: str, can_start: bool) -> "tuple[str|None, str|None]": + # TODO: Require empty row also before Tags in RF 8 (or 9). + # After that regexp matching can be done only when `can_start` is true. + # Possibly this method can be inlined like it was earlier eas well. + match = self.header_re.fullmatch(line) + if not match: + return None, None + header, _, inline_text = match.groups() + if can_start: + return header, inline_text + if header.lower() in self.tags_headers: + LOGGER.warn( + f"Invalid documentation in '{self.name}': Not having " + f"an empty row before 'Tags:' is deprecated." + ) + return header, inline_text + return None, None + + def _parse_named_docs(self, data: str) -> "Iterable[tuple[str, str]]": + block = Block() + for line in data.splitlines(): + match = self.named_doc_re.fullmatch(line) + if match: + yield from self._yield_name_and_doc(block) + name, _, inline_text = match.groups() + block = Block(name, [inline_text or ""]) + else: + block.add(line) + yield from self._yield_name_and_doc(block) + + def _yield_name_and_doc(self, block: Block) -> "Iterable[tuple[str, str]]": + name, doc = block.name, block.content + if name or doc: + name = self._normalize_name(name) + if name not in ("/", "*"): + yield name, doc + + def _normalize_name(self, name: str) -> str: + if name.startswith(("- ", "+ ", "* ")): + name = name[2:] + if name.startswith("`"): + name = name.strip("`").strip() + if "(" in name: + name = name.split("(")[0].rstrip() + if name.startswith("*") and name != "*": + name = name.lstrip("*").strip() + elif name.startswith(("${", "@{", "&{")) and name.endswith("}"): + name = name[2:-1].strip() + return name or "" + + def _parse_tags(self, doc: str) -> "list[str]": + doc = " ".join(doc.splitlines()) + return [tag for tag in [tag.strip() for tag in doc.split(",")] if tag] diff --git a/src/robot/running/dynamicmethods.py b/src/robot/running/dynamicmethods.py index 1ef3facd33b..31b34a053bf 100644 --- a/src/robot/running/dynamicmethods.py +++ b/src/robot/running/dynamicmethods.py @@ -14,71 +14,93 @@ # limitations under the License. from robot.errors import DataError -from robot.utils import (get_error_message, is_java_method, is_bytes, - is_unicode, py2to3) +from robot.utils import get_error_message, is_list_like, type_name -from .arguments import JavaArgumentParser, PythonArgumentParser +from .arguments import PythonArgumentParser +from .context import EXECUTION_CONTEXTS def no_dynamic_method(*args): - pass + return None -@py2to3 -class _DynamicMethod(object): +class DynamicMethod: _underscore_name = NotImplemented - def __init__(self, lib): - self.method = self._get_method(lib) + def __init__(self, instance): + self.instance = instance + self.method = self._get_method(instance) - def _get_method(self, lib): + def _get_method(self, instance): for name in self._underscore_name, self._camelCaseName: - method = getattr(lib, name, None) + method = getattr(instance, name, None) if callable(method): return method return no_dynamic_method @property def _camelCaseName(self): - tokens = self._underscore_name.split('_') - return ''.join([tokens[0]] + [t.capitalize() for t in tokens[1:]]) + tokens = self._underscore_name.split("_") + return "".join([tokens[0]] + [t.capitalize() for t in tokens[1:]]) @property def name(self): return self.method.__name__ - def __call__(self, *args): + def __call__(self, *args, **kwargs): try: - return self._handle_return_value(self.method(*args)) - except: - raise DataError("Calling dynamic method '%s' failed: %s" - % (self.method.__name__, get_error_message())) + ctx = EXECUTION_CONTEXTS.current + result = self.method(*args, **kwargs) + if ctx and ctx.asynchronous.is_loop_required(result): + result = ctx.asynchronous.run_until_complete(result) + return self._handle_return_value(result) + except Exception: + raise DataError( + f"Calling dynamic method '{self.name}' failed: {get_error_message()}" + ) def _handle_return_value(self, value): raise NotImplementedError - def _to_string(self, value): - if is_unicode(value): + def _to_string(self, value, allow_tuple=False, allow_none=False): + if isinstance(value, str): return value - if is_bytes(value): - return value.decode('UTF-8') - raise DataError('Return value must be string.') + if isinstance(value, bytes): + return value.decode("UTF-8") + if allow_tuple and is_list_like(value) and len(value) > 0: + return tuple(value) + if allow_none and value is None: + return value + allowed = "a string or a non-empty tuple" if allow_tuple else "a string" + raise DataError(f"Return value must be {allowed}, got {type_name(value)}.") - def _to_list_of_strings(self, value): - try: - return [self._to_string(v) for v in value] - except (TypeError, DataError): - raise DataError('Return value must be list of strings.') + def _to_list(self, value): + if value is None: + return () + if not is_list_like(value): + raise DataError + return value - def __nonzero__(self): + def _to_list_of_strings(self, value, allow_tuples=False): + try: + return [ + self._to_string(item, allow_tuples) for item in self._to_list(value) + ] + except DataError: + allowed = "strings or non-empty tuples" if allow_tuples else "strings" + raise DataError( + f"Return value must be a list of {allowed}, got {type_name(value)}." + ) + + def __bool__(self): return self.method is not no_dynamic_method -class GetKeywordNames(_DynamicMethod): - _underscore_name = 'get_keyword_names' +class GetKeywordNames(DynamicMethod): + _underscore_name = "get_keyword_names" def _handle_return_value(self, value): - names = self._to_list_of_strings(value or []) + names = self._to_list_of_strings(value) return list(self._remove_duplicates(names)) def _remove_duplicates(self, names): @@ -89,64 +111,81 @@ def _remove_duplicates(self, names): yield name -class RunKeyword(_DynamicMethod): - _underscore_name = 'run_keyword' - - @property - def supports_kwargs(self): - if is_java_method(self.method): - return self._supports_java_kwargs(self.method) - return self._supports_python_kwargs(self.method) +class RunKeyword(DynamicMethod): + _underscore_name = "run_keyword" - def _supports_python_kwargs(self, method): - spec = PythonArgumentParser().parse(method) - return len(spec.positional) == 3 + def __init__( + self, + instance, + keyword_name: "str | None" = None, + supports_named_args: "bool | None" = None, + ): + super().__init__(instance) + self.keyword_name = keyword_name + self._supports_named_args = supports_named_args - def _supports_java_kwargs(self, method): - func = self.method.im_func if hasattr(method, 'im_func') else method - signatures = func.argslist[:func.nargs] - spec = JavaArgumentParser().parse(signatures) - return (self._java_single_signature_kwargs(spec) or - self._java_multi_signature_kwargs(spec)) + @property + def supports_named_args(self) -> bool: + if self._supports_named_args is None: + spec = PythonArgumentParser().parse(self.method) + self._supports_named_args = len(spec.positional) == 3 + return self._supports_named_args + + def __call__(self, *positional, **named): + if self.supports_named_args: + args = (self.keyword_name, positional, named) + elif named: + # This should never happen. + raise ValueError( + f"'named' should not be used when named-argument support is " + f"not enabled, got {named}." + ) + else: + args = (self.keyword_name, positional) + return self.method(*args) + + +class GetKeywordDocumentation(DynamicMethod): + _underscore_name = "get_keyword_documentation" - def _java_single_signature_kwargs(self, spec): - return len(spec.positional) == 1 and spec.varargs and spec.kwargs + def _handle_return_value(self, value): + return self._to_string(value or "") - def _java_multi_signature_kwargs(self, spec): - return len(spec.positional) == 3 and not (spec.varargs or spec.kwargs) +class GetKeywordArguments(DynamicMethod): + _underscore_name = "get_keyword_arguments" -class GetKeywordDocumentation(_DynamicMethod): - _underscore_name = 'get_keyword_documentation' + def __init__(self, instance, supports_named_args: "bool | None" = None): + super().__init__(instance) + if supports_named_args is None: + self.supports_named_args = RunKeyword(instance).supports_named_args + else: + self.supports_named_args = supports_named_args def _handle_return_value(self, value): - return self._to_string(value or '') - + if value is None: + if self.supports_named_args: + return ["*varargs", "**kwargs"] + return ["*varargs"] + return self._to_list_of_strings(value, allow_tuples=True) -class GetKeywordArguments(_DynamicMethod): - _underscore_name = 'get_keyword_arguments' - def __init__(self, lib): - _DynamicMethod.__init__(self, lib) - self._supports_kwargs = RunKeyword(lib).supports_kwargs +class GetKeywordTypes(DynamicMethod): + _underscore_name = "get_keyword_types" def _handle_return_value(self, value): - if value is None: - if self._supports_kwargs: - return ['*varargs', '**kwargs'] - return ['*varargs'] - return self._to_list_of_strings(value) + return value if self else {} -class GetKeywordTypes(_DynamicMethod): - _underscore_name = 'get_keyword_types' +class GetKeywordTags(DynamicMethod): + _underscore_name = "get_keyword_tags" def _handle_return_value(self, value): - return value + return self._to_list_of_strings(value) -class GetKeywordTags(_DynamicMethod): - _underscore_name = 'get_keyword_tags' +class GetKeywordSource(DynamicMethod): + _underscore_name = "get_keyword_source" def _handle_return_value(self, value): - return self._to_list_of_strings(value or []) + return self._to_string(value, allow_none=True) diff --git a/src/robot/running/handlers.py b/src/robot/running/handlers.py deleted file mode 100644 index aff29dbb30f..00000000000 --- a/src/robot/running/handlers.py +++ /dev/null @@ -1,289 +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 copy import copy - -from robot.utils import (getdoc, getshortdoc, is_java_init, is_java_method, - is_list_like, printable_name, split_tags_from_doc, - type_name) -from robot.errors import DataError -from robot.model import Tags - -from .arguments import (ArgumentSpec, DynamicArgumentParser, - JavaArgumentCoercer, JavaArgumentParser, - PythonArgumentParser) -from .dynamicmethods import GetKeywordTypes -from .librarykeywordrunner import (EmbeddedArgumentsRunner, - LibraryKeywordRunner, RunKeywordRunner) -from .runkwregister import RUN_KW_REGISTER - - -def Handler(library, name, method): - if RUN_KW_REGISTER.is_run_keyword(library.orig_name, name): - return _RunKeywordHandler(library, name, method) - if is_java_method(method): - return _JavaHandler(library, name, method) - else: - return _PythonHandler(library, name, method) - - -def DynamicHandler(library, name, method, doc, argspec, tags=None): - if RUN_KW_REGISTER.is_run_keyword(library.orig_name, name): - return _DynamicRunKeywordHandler(library, name, method, doc, argspec, tags) - return _DynamicHandler(library, name, method, doc, argspec, tags) - - -def InitHandler(library, method, docgetter=None): - Init = _PythonInitHandler if not is_java_init(method) else _JavaInitHandler - return Init(library, '__init__', method, docgetter) - - -class _RunnableHandler(object): - - def __init__(self, library, handler_name, handler_method, doc='', tags=None): - self.library = library - self._handler_name = handler_name - self.name = self._get_name(handler_name, handler_method) - self.arguments = self._parse_arguments(handler_method) - self._method = self._get_initial_handler(library, handler_name, - handler_method) - doc, tags_from_doc = split_tags_from_doc(doc or '') - tags_from_attr = self._get_tags_from_attribute(handler_method) - self._doc = doc - self.tags = Tags(tuple(tags_from_doc) + - tuple(tags_from_attr) + - tuple(tags or ())) - - def _get_name(self, handler_name, handler_method): - robot_name = getattr(handler_method, 'robot_name', None) - name = robot_name or printable_name(handler_name, code_style=True) - if not name: - raise DataError('Keyword name cannot be empty.') - return name - - def _parse_arguments(self, handler_method): - raise NotImplementedError - - 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)) - return tags - - def _get_initial_handler(self, library, name, method): - if library.scope.is_global: - return self._get_global_handler(method, name) - return None - - def resolve_arguments(self, args, variables=None): - return self.arguments.resolve(args, variables) - - @property - def doc(self): - return self._doc - - @property - def longname(self): - return '%s.%s' % (self.library.name, self.name) - - @property - def shortdoc(self): - return getshortdoc(self.doc) - - @property - def libname(self): - return self.library.name - - def create_runner(self, name): - return LibraryKeywordRunner(self) - - def current_handler(self): - if self._method: - return self._method - return self._get_handler(self.library.get_instance(), self._handler_name) - - def _get_global_handler(self, method, name): - return method - - def _get_handler(self, lib_instance, handler_name): - return getattr(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)) - - def _parse_arguments(self, handler_method): - return PythonArgumentParser().parse(handler_method, self.longname) - - -class _JavaHandler(_RunnableHandler): - - def __init__(self, library, handler_name, handler_method): - _RunnableHandler.__init__(self, library, handler_name, handler_method) - signatures = self._get_signatures(handler_method) - self._arg_coercer = JavaArgumentCoercer(signatures, self.arguments) - - def _parse_arguments(self, handler_method): - signatures = self._get_signatures(handler_method) - return JavaArgumentParser().parse(signatures, self.longname) - - def _get_signatures(self, handler): - code_object = getattr(handler, 'im_func', handler) - return code_object.argslist[:code_object.nargs] - - def resolve_arguments(self, args, variables=None): - positional, named = self.arguments.resolve(args, variables, - dict_to_kwargs=True) - arguments = self._arg_coercer.coerce(positional, named, - dryrun=not variables) - return arguments, [] - - -class _DynamicHandler(_RunnableHandler): - - 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) - - def _parse_arguments(self, handler_method): - spec = DynamicArgumentParser().parse(self._argspec, self.longname) - if not self._supports_kwargs: - if spec.kwargs: - raise DataError("Too few '%s' method parameters for **kwargs " - "support." % self._run_keyword_method_name) - if spec.kwonlyargs: - raise DataError("Too few '%s' method parameters for " - "keyword-only arguments support." - % self._run_keyword_method_name) - spec.types = GetKeywordTypes(self.library.get_instance())(self._handler_name) - return spec - - def resolve_arguments(self, arguments, variables=None): - positional, named = self.arguments.resolve(arguments, variables) - if not self._supports_kwargs: - positional, named = self.arguments.map(positional, named) - return positional, named - - def _get_handler(self, lib_instance, handler_name): - runner = getattr(lib_instance, self._run_keyword_method_name) - return self._get_dynamic_handler(runner, handler_name) - - def _get_global_handler(self, method, name): - return self._get_dynamic_handler(method, name) - - def _get_dynamic_handler(self, runner, name): - def handler(*positional, **kwargs): - if self._supports_kwargs: - return runner(name, positional, kwargs) - else: - return runner(name, positional) - return handler - - -class _RunKeywordHandler(_PythonHandler): - - def create_runner(self, name): - default_dry_run_keywords = ('name' in self.arguments.positional and - self._args_to_process) - return RunKeywordRunner(self, default_dry_run_keywords) - - @property - 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): - args_to_process = self._args_to_process - return self.arguments.resolve(args, variables, resolve_named=False, - resolve_variables_until=args_to_process) - - -class _DynamicRunKeywordHandler(_DynamicHandler, _RunKeywordHandler): - _parse_arguments = _RunKeywordHandler._parse_arguments - resolve_arguments = _RunKeywordHandler.resolve_arguments - - -class _PythonInitHandler(_PythonHandler): - - def __init__(self, library, handler_name, handler_method, docgetter): - _PythonHandler.__init__(self, library, handler_name, handler_method) - self._docgetter = docgetter - - @property - def doc(self): - if self._docgetter: - self._doc = self._docgetter() or self._doc - self._docgetter = None - return self._doc - - def _parse_arguments(self, handler_method): - parser = PythonArgumentParser(type='Test Library') - return parser.parse(handler_method, self.library.name) - - -class _JavaInitHandler(_JavaHandler): - - def __init__(self, library, handler_name, handler_method, docgetter): - _JavaHandler.__init__(self, library, handler_name, handler_method) - self._docgetter = docgetter - - @property - def doc(self): - if self._docgetter: - self._doc = self._docgetter() or self._doc - self._docgetter = None - return self._doc - - def _parse_arguments(self, handler_method): - parser = JavaArgumentParser(type='Test Library') - signatures = self._get_signatures(handler_method) - return parser.parse(signatures, self.library.name) - - -class EmbeddedArgumentsHandler(object): - - def __init__(self, name_regexp, orig_handler): - self.arguments = ArgumentSpec() # Show empty argument spec for Libdoc - self.name_regexp = name_regexp - self._orig_handler = orig_handler - - def __getattr__(self, item): - return getattr(self._orig_handler, item) - - @property - def library(self): - return self._orig_handler.library - - @library.setter - def library(self, library): - self._orig_handler.library = library - - def matches(self, name): - return self.name_regexp.match(name) is not None - - def create_runner(self, name): - return EmbeddedArgumentsRunner(self, name) - - def __copy__(self): - orig_handler = copy(self._orig_handler) - return EmbeddedArgumentsHandler(self.name_regexp, orig_handler) diff --git a/src/robot/running/handlerstore.py b/src/robot/running/handlerstore.py deleted file mode 100644 index 656f359732e..00000000000 --- a/src/robot/running/handlerstore.py +++ /dev/null @@ -1,85 +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 operator import attrgetter - -from robot.errors import DataError, KeywordError -from robot.utils import NormalizedDict - -from .usererrorhandler import UserErrorHandler - - -class HandlerStore(object): - TEST_LIBRARY_TYPE = 'Test 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 - self._normal = NormalizedDict(ignore='_') - self._embedded = [] - - def add(self, handler, embedded=False): - if embedded: - self._embedded.append(handler) - elif handler.name not in self._normal: - self._normal[handler.name] = handler - else: - error = DataError('Keyword with same name defined multiple times.') - self._normal[handler.name] = UserErrorHandler(error, handler.name, - handler.libname) - raise error - - def __iter__(self): - handlers = list(self._normal.values()) + self._embedded - return iter(sorted(handlers, key=attrgetter('name'))) - - def __len__(self): - return len(self._normal) + len(self._embedded) - - def __contains__(self, name): - if name in self._normal: - return True - return any(template.matches(name) for template in self._embedded) - - def create_runner(self, name): - return self[name].create_runner(name) - - 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) - - 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)) diff --git a/src/robot/running/importer.py b/src/robot/running/importer.py index 330f5cd7213..39328f6decf 100644 --- a/src/robot/running/importer.py +++ b/src/robot/running/importer.py @@ -13,19 +13,30 @@ # See the License for the specific language governing permissions and # limitations under the License. -import copy -import os.path +import os +from robot.errors import DataError, FrameworkError from robot.output import LOGGER -from robot.errors import FrameworkError -from robot.utils import normpath, seq2str2, is_string +from robot.utils import normpath, seq2str, seq2str2 from .builder import ResourceFileBuilder -from .handlerstore import HandlerStore from .testlibraries import TestLibrary +RESOURCE_EXTENSIONS = { + ".resource", + ".robot", + ".txt", + ".tsv", + ".rst", + ".rest", + ".json", + ".rsrc", + ".md", + ".markdown", +} -class Importer(object): + +class Importer: def __init__(self): self._library_cache = ImportCache() @@ -36,73 +47,63 @@ def reset(self): def close_global_library_listeners(self): for lib in self._library_cache.values(): - lib.close_global_listeners() + lib.scope_manager.close_global_listeners() def import_library(self, name, args, alias, variables): - lib = TestLibrary(name, args, variables, create_handlers=False) - positional, named = lib.positional_args, lib.named_args - lib = self._import_library(name, positional, named, lib) + lib = TestLibrary.from_name( + name, + args=args, + variables=variables, + create_keywords=False, + ) + positional, named = lib.init.positional, lib.init.named + args_str = seq2str2(positional + [f"{n}={named[n]}" for n in named]) + key = (name, positional, named) + if key in self._library_cache: + LOGGER.info(f"Found library '{name}' with arguments {args_str} from cache.") + lib = self._library_cache[key] + else: + lib.create_keywords() + if lib.scope is not lib.scope.GLOBAL: + lib.instance = None + self._library_cache[key] = lib + self._log_imported_library(name, args_str, lib) if alias: alias = variables.replace_scalar(alias) - lib = self._copy_library(lib, alias) - LOGGER.info("Imported library '%s' with name '%s'" % (name, alias)) + lib = lib.copy(name=alias) + LOGGER.info(f"Imported library '{name}' with 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) + LOGGER.info(f"Found resource file '{path}' from cache.") else: - resource = ResourceFileBuilder().build(path) + resource = ResourceFileBuilder(lang=lang).build(path) self._resource_cache[path] = resource return self._resource_cache[path] - def _import_library(self, name, positional, named, lib): - args = positional + ['%s=%s' % arg for arg in named] - key = (name, positional, named) - if key in self._library_cache: - LOGGER.info("Found test library '%s' with arguments %s from cache" - % (name, seq2str2(args))) - return self._library_cache[key] - lib.create_handlers() - self._library_cache[key] = lib - self._log_imported_library(name, args, lib) - return 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 '', - type, lib.scope, len(lib), listener)) - if not lib and not lib.has_listener: - LOGGER.warn("Imported library '%s' contains no keywords." % name) - - def _copy_library(self, orig, name): - # This is pretty ugly. Hopefully we can remove cache and copying - # altogether in 3.0 and always just re-import libraries: - # https://github.com/robotframework/robotframework/issues/2106 - # Could then also remove __copy__ methods added to some handlers as - # a workaround for this IronPython bug: - # https://github.com/IronLanguages/main/issues/1192 - lib = copy.copy(orig) - lib.name = name - lib.scope = type(lib.scope)(lib) - lib.reset_instance() - lib.handlers = HandlerStore(orig.handlers.source, - orig.handlers.source_type) - for handler in orig.handlers._normal.values(): - handler = copy.copy(handler) - handler.library = lib - lib.handlers.add(handler) - for handler in orig.handlers._embedded: - handler = copy.copy(handler) - handler.library = lib - lib.handlers.add(handler, embedded=True) - return lib - - -class ImportCache(object): + def _validate_resource_extension(self, path): + extension = os.path.splitext(path)[1] + if extension.lower() not in RESOURCE_EXTENSIONS: + raise DataError( + f"Invalid resource file extension '{extension}'. " + f"Supported extensions are {seq2str(sorted(RESOURCE_EXTENSIONS))}." + ) + + def _log_imported_library(self, name, args_str, lib): + kind = type(lib).__name__.replace("Library", "").lower() + listener = ", with listener" if lib.listeners else "" + LOGGER.info( + f"Imported library '{name}' with arguments {args_str} " + f"(version {lib.version or ''}, {kind} type, " + f"{lib.scope.name} scope, {len(lib.keywords)} keywords{listener})." + ) + if not (lib.keywords or lib.listeners): + LOGGER.warn(f"Imported library '{name}' contains no keywords.") + + +class ImportCache: """Keeps track on and optionally caches imported items. Handles paths in keys case-insensitively on case-insensitive OSes. @@ -114,8 +115,8 @@ def __init__(self): self._items = [] def __setitem__(self, key, item): - if not is_string(key) and not isinstance(key, tuple): - raise FrameworkError('Invalid key for ImportCache') + if not isinstance(key, (str, tuple)): + raise FrameworkError("Invalid key for ImportCache") key = self._norm_path_key(key) if key not in self._keys: self._keys.append(key) @@ -146,4 +147,4 @@ def _norm_path_key(self, key): return key def _is_path(self, key): - return is_string(key) and os.path.isabs(key) and os.path.exists(key) + return isinstance(key, str) and os.path.isabs(key) and os.path.exists(key) diff --git a/src/robot/running/invalidkeyword.py b/src/robot/running/invalidkeyword.py new file mode 100644 index 00000000000..2b6a1f73801 --- /dev/null +++ b/src/robot/running/invalidkeyword.py @@ -0,0 +1,73 @@ +# 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 DataError +from robot.result import Keyword as KeywordResult +from robot.variables import VariableAssignment + +from .arguments import EmbeddedArguments +from .keywordimplementation import KeywordImplementation +from .model import Keyword as KeywordData +from .statusreporter import StatusReporter + + +class InvalidKeyword(KeywordImplementation): + """Represents an invalid keyword call. + + Keyword may not have been found, there could have been multiple matches, + or the keyword call itself could have been invalid. + """ + + type = KeywordImplementation.INVALID_KEYWORD + + def _get_embedded(self, name) -> "EmbeddedArguments | None": + try: + return super()._get_embedded(name) + except DataError: + return None + + def create_runner(self, name, languages=None): + return InvalidKeywordRunner(self, name) + + def bind(self, data: KeywordData) -> "InvalidKeyword": + return self.copy(parent=data.parent) + + +class InvalidKeywordRunner: + + def __init__(self, keyword: InvalidKeyword, name: "str | None" = None): + self.keyword = keyword + self.name = name or keyword.name + if not keyword.error: + raise ValueError("Executed 'InvalidKeyword' instance requires 'error'.") + + def run(self, data: KeywordData, result: KeywordResult, context, run=True): + kw = self.keyword.bind(data) + args = tuple(data.args) + if data.named_args: + args += tuple(f"{n}={v}" for n, v in data.named_args.items()) + result.config( + name=self.name, + owner=kw.owner.name if kw.owner else None, + args=args, + assign=tuple(VariableAssignment(data.assign)), + type=data.type, + ) + with StatusReporter(data, result, context, run, implementation=kw): + # 'error' is can be set to 'None' by a listener that handles it. + if run and kw.error is not None: + raise DataError(kw.error) + + dry_run = run diff --git a/src/robot/running/keywordfinder.py b/src/robot/running/keywordfinder.py new file mode 100644 index 00000000000..792b81258f2 --- /dev/null +++ b/src/robot/running/keywordfinder.py @@ -0,0 +1,91 @@ +# 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 Generic, Literal, overload, TYPE_CHECKING, TypeVar + +from robot.utils import NormalizedDict, plural_or_not as s, seq2str + +from .keywordimplementation import KeywordImplementation + +if TYPE_CHECKING: + from .resourcemodel import ResourceFile + from .testlibraries import TestLibrary + + +K = TypeVar("K", bound=KeywordImplementation) + + +class KeywordFinder(Generic[K]): + + def __init__(self, owner: "TestLibrary | ResourceFile"): + self.owner = owner + self.cache: KeywordCache | None = None + + @overload + def find(self, name: str, count: Literal[1]) -> "K": ... + + @overload + def find(self, name: str, count: "int | None" = None) -> "list[K]": ... + + def find(self, name: str, count: "int | None" = None) -> "list[K] | K": + """Find keywords based on the given ``name``. + + With normal keywords matching is a case, space and underscore insensitive + string comparison and there cannot be more than one match. With keywords + accepting embedded arguments, matching is done against the name and + there can be multiple matches. + + Returns matching keywords as a list, possibly as an empty list, without + any validation by default. If the optional ``count`` is used, raises + a ``ValueError`` if the number of found keywords does not match. If + ``count`` is ``1`` and exactly one keyword is found, returns that keyword + directly and not as a list. + """ + if self.cache is None: + self.cache = KeywordCache[K](self.owner.keywords) + return self.cache.find(name, count) + + def invalidate_cache(self): + self.cache = None + + +class KeywordCache(Generic[K]): + + def __init__(self, keywords: "list[K]"): + self.normal = NormalizedDict[K](ignore="_") + self.embedded: list[K] = [] + add_normal = self.normal.__setitem__ + add_embedded = self.embedded.append + for kw in keywords: + if kw.embedded: + add_embedded(kw) + else: + add_normal(kw.name, kw) + + def find(self, name: str, count: "int | None" = None) -> "list[K] | K": + try: + keywords = [self.normal[name]] + except KeyError: + keywords = [kw for kw in self.embedded if kw.matches(name)] + if count is not None: + if len(keywords) != count: + names = ": " + seq2str([k.name for k in keywords]) if keywords else "." + raise ValueError( + f"Expected {count} keyword{s(count)} matching name '{name}', " + f"found {len(keywords)}{names}" + ) + if count == 1: + return keywords[0] + return keywords diff --git a/src/robot/running/keywordimplementation.py b/src/robot/running/keywordimplementation.py new file mode 100644 index 00000000000..375275ba4ad --- /dev/null +++ b/src/robot/running/keywordimplementation.py @@ -0,0 +1,230 @@ +# 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 re +from pathlib import Path +from typing import Any, Final, Literal, Mapping, Sequence, TYPE_CHECKING + +from robot.errors import DataError +from robot.model import ModelObject, Tags +from robot.running.docstringparser import parse_docstring +from robot.utils import eq, getshortdoc, setter, unescape + +from .arguments import ArgInfo, ArgumentSpec, EmbeddedArguments +from .model import BodyItemParent, Keyword + +if TYPE_CHECKING: + from robot.conf import LanguagesLike + + from .librarykeywordrunner import LibraryKeywordRunner + from .resourcemodel import ResourceFile + from .testlibraries import TestLibrary + from .userkeywordrunner import UserKeywordRunner + + +class KeywordImplementation(ModelObject): + """Base class for different keyword implementations.""" + + USER_KEYWORD: Final = "USER KEYWORD" + LIBRARY_KEYWORD: Final = "LIBRARY KEYWORD" + INVALID_KEYWORD: Final = "INVALID KEYWORD" + type: Literal["USER KEYWORD", "LIBRARY KEYWORD", "INVALID KEYWORD"] + repr_args = ("name", "args") + _has_tags = re.compile(r"^Tags:|\nTags:", re.IGNORECASE).search + __slots__ = ("_name", "embedded", "_doc", "_lineno", "owner", "parent", "error") + + def __init__( + self, + name: str = "", + args: "ArgumentSpec | None" = None, + doc: str = "", + tags: "Tags | Sequence[str]" = (), + lineno: "int | None" = None, + owner: "ResourceFile | TestLibrary | None" = None, + parent: "BodyItemParent | None" = None, + error: "str | None" = None, + ): + self._name = name + self.embedded = self._get_embedded(name) + self.args = args + self._doc = doc + self.tags = tags + self._lineno = lineno + self.owner = owner + self.parent = parent + self.error = error + + def _get_embedded(self, name) -> "EmbeddedArguments | None": + return EmbeddedArguments.from_name(name) + + @property + def name(self) -> str: + return self._name + + @name.setter + def name(self, name: str): + self.embedded = self._get_embedded(name) + if self.owner and self._name: + self.owner.keyword_finder.invalidate_cache() + self._name = name + + @property + def full_name(self) -> str: + if self.owner and self.owner.name: + return f"{self.owner.name}.{self.name}" + return self.name + + @setter + def args(self, spec: "ArgumentSpec | None") -> ArgumentSpec: + """Information about accepted arguments. + + It would be more correct to use term *parameter* instead of + *argument* in this context, and this attribute may be renamed + accordingly in the future. A forward compatible :attr:`params` + attribute exists already now. + """ + if spec is None: + spec = ArgumentSpec() + spec.name = lambda: self.full_name + return spec + + @property + def params(self) -> ArgumentSpec: + """Keyword parameter information. + + This is a forward compatible alias for :attr:`args`. + """ + return self.args + + @property + def doc(self) -> str: + return self._doc + + @doc.setter + def doc(self, doc: str): + self._doc = doc + + @property + def short_doc(self) -> str: + return getshortdoc(self.doc) + + @setter + def tags(self, tags: "Tags | Sequence[str]") -> Tags: + return Tags(tags) + + @property + def all_tags(self) -> Tags: + """Return tags taking possible tags in documentation into account. + + Returned tags should not be modified. Use :attr:`tags` instead if + that is needed. + + New in Robot Framework 7.5. + """ + if self._has_tags(self.doc): + info = parse_docstring(self.doc, self.name) + tags = Tags(self.tags) + tags.add(info.tags, remove_negated=self.type == self.USER_KEYWORD) + return tags + return self.tags + + @property + def lineno(self) -> "int | None": + return self._lineno + + @lineno.setter + def lineno(self, lineno: "int | None"): + self._lineno = lineno + + @property + def private(self) -> bool: + return bool(self.tags and self.tags.robot("private")) + + @property + def source(self) -> "Path | None": + return self.owner.source if self.owner is not None else None + + def update_docs(self, user_keyword_unescape: bool = False): + """Parse information from docstring and update it accordingly. + + :param user_keyword_unescape: When true, remove backslash + escapes from user keyword documentation. + + Updates argument, return value and exception documentation as well + as tags. The information is defined in appropriate sections using + the Google Python Style Guide docstring conventions. + + All this information is needed by Libdoc. During execution, we only + need tags, and they can be got via :attr:`all_tags`. + + New in Robot Framework 7.5. + """ + if self.type == self.USER_KEYWORD: + doc = unescape(self.doc) if user_keyword_unescape else self.doc + remove_negated_tags = True + else: + doc = self.doc + remove_negated_tags = False + result = parse_docstring(doc, self.name) + self.doc = result.doc + try: + self.args.docs = result.args + except DataError as err: + self.args.docs = {} + self.error = str(err) + self.args.return_doc = result.returns + self.args.raises = result.raises + self.tags.add(result.tags, remove_negated=remove_negated_tags) + + def matches(self, name: str) -> bool: + """Returns true if ``name`` matches the keyword name. + + With normal keywords matching is a case, space and underscore insensitive + string comparison. With keywords accepting embedded arguments, matching + is done against the name. + """ + if self.embedded: + return self.embedded.matches(name) + return eq(self.name, name, ignore="_") + + def resolve_arguments( + self, + args: "Sequence[str | Any]", + named_args: "Mapping[str, Any] | None" = None, + variables=None, + languages: "LanguagesLike" = None, + ) -> "tuple[list, list]": + return self.args.resolve(args, named_args, variables, languages=languages) + + def create_runner( + self, + name: "str | None", + languages: "LanguagesLike" = None, + ) -> "LibraryKeywordRunner | UserKeywordRunner": + raise NotImplementedError + + def bind(self, data: Keyword) -> "KeywordImplementation": + raise NotImplementedError + + def _include_in_repr(self, name: str, value: Any) -> bool: + return name == "name" or value + + def _repr_format(self, name: str, value: Any) -> str: + if name == "args": + value = [self._decorate_arg(a) for a in self.args] + return super()._repr_format(name, value) + + def _decorate_arg(self, arg: ArgInfo) -> str: + return str(arg) diff --git a/src/robot/running/librarykeyword.py b/src/robot/running/librarykeyword.py new file mode 100644 index 00000000000..0be58e7f52e --- /dev/null +++ b/src/robot/running/librarykeyword.py @@ -0,0 +1,481 @@ +# 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 inspect +from os.path import normpath +from pathlib import Path +from typing import Any, Callable, Generic, Mapping, Sequence, TYPE_CHECKING, TypeVar + +from robot.errors import DataError +from robot.model import Tags +from robot.utils import is_init, is_list_like, printable_name, type_name + +from .arguments import ArgumentSpec, DynamicArgumentParser, PythonArgumentParser +from .dynamicmethods import ( + GetKeywordArguments, GetKeywordDocumentation, GetKeywordSource, GetKeywordTags, + GetKeywordTypes, RunKeyword +) +from .keywordimplementation import KeywordImplementation +from .librarykeywordrunner import ( + EmbeddedArgumentsRunner, LibraryKeywordRunner, RunKeywordRunner +) +from .model import BodyItemParent, Keyword +from .runkwregister import RUN_KW_REGISTER + +if TYPE_CHECKING: + from robot.conf import LanguagesLike + + from .testlibraries import DynamicLibrary, TestLibrary + + +Self = TypeVar("Self", bound="LibraryKeyword") +K = TypeVar("K", bound="LibraryKeyword") + + +class LibraryKeyword(KeywordImplementation): + """Base class for different library keywords.""" + + type = KeywordImplementation.LIBRARY_KEYWORD + owner: "TestLibrary" + __slots__ = ("_resolve_args_until",) + + def __init__( + self, + owner: "TestLibrary", + name: str = "", + args: "ArgumentSpec | None" = None, + doc: str = "", + tags: "Tags | Sequence[str]" = (), + resolve_args_until: "int | None" = None, + parent: "BodyItemParent | None" = None, + error: "str | None" = None, + ): + super().__init__(name, args, doc, tags, owner=owner, parent=parent, error=error) + self._resolve_args_until = resolve_args_until + + @property + def method(self) -> Callable[..., Any]: + raise NotImplementedError + + @property + def lineno(self) -> "int | None": + method = self.method + try: + lines, start_lineno = inspect.getsourcelines(inspect.unwrap(method)) + except (TypeError, OSError, IOError): + return None + for increment, line in enumerate(lines): + if line.strip().startswith("def "): + return start_lineno + increment + return start_lineno + + def create_runner( + self, + name: "str | None", + languages: "LanguagesLike" = None, + ) -> LibraryKeywordRunner: + if self.embedded: + return EmbeddedArgumentsRunner(self, name) + if self._resolve_args_until is not None: + dry_run = RUN_KW_REGISTER.get_dry_run(self.owner.real_name, self.name) + return RunKeywordRunner(self, dry_run_children=dry_run) + return LibraryKeywordRunner(self, languages=languages) + + def resolve_arguments( + self, + args: "Sequence[str | Any]", + named_args: "Mapping[str, Any] | None" = None, + variables=None, + languages: "LanguagesLike" = None, + ) -> "tuple[list, list]": + resolve_args_until = self._resolve_args_until + positional, named = self.args.resolve( + args, + named_args, + variables, + self.owner.converters, + resolve_named=resolve_args_until is None, + resolve_args_until=resolve_args_until, + languages=languages, + ) + if self.embedded: + self.embedded.validate(positional) + return positional, named + + def bind(self: Self, data: Keyword) -> Self: + return self.copy(parent=data.parent) + + def copy(self: Self, **attributes) -> Self: + raise NotImplementedError + + +class StaticKeyword(LibraryKeyword): + """Represents a keyword in a static library.""" + + __slots__ = ("method_name",) + + def __init__( + self, + method_name: str, + owner: "TestLibrary", + name: str = "", + args: "ArgumentSpec | None" = None, + doc: str = "", + tags: "Tags | Sequence[str]" = (), + resolve_args_until: "int | None" = None, + parent: "BodyItemParent | None" = None, + error: "str | None" = None, + ): + super().__init__( + owner, + name, + args, + doc, + tags, + resolve_args_until, + parent, + error, + ) + self.method_name = method_name + + @property + def method(self) -> Callable[..., Any]: + """Keyword method.""" + return getattr(self.owner.instance, self.method_name) + + @property + def source(self) -> "Path | None": + # `getsourcefile` can return None and raise TypeError. + try: + if self.method is None: + raise TypeError + source = inspect.getsourcefile(inspect.unwrap(self.method)) + except TypeError: + source = None + return Path(normpath(source)) if source else super().source + + @classmethod + def from_name(cls, name: str, owner: "TestLibrary") -> "StaticKeyword": + return StaticKeywordCreator(name, owner).create(method_name=name) + + def copy(self, **attributes) -> "StaticKeyword": + return StaticKeyword( + self.method_name, + self.owner, + self.name, + self.args, + self._doc, + self.tags, + self._resolve_args_until, + self.parent, + self.error, + ).config(**attributes) + + +class DynamicKeyword(LibraryKeyword): + """Represents a keyword in a dynamic library.""" + + owner: "DynamicLibrary" + __slots__ = ("run_keyword", "_orig_name", "__source_info") + + def __init__( + self, + owner: "DynamicLibrary", + name: str = "", + args: "ArgumentSpec | None" = None, + doc: str = "", + tags: "Tags | Sequence[str]" = (), + resolve_args_until: "int | None" = None, + parent: "BodyItemParent | None" = None, + error: "str | None" = None, + ): + # TODO: It would probably be better not to convert name we got from + # `get_keyword_names`. That would have some backwards incompatibility + # effects, but we can consider it in RF 8.0. + super().__init__( + owner, + printable_name(name, code_style=True), + args, + doc, + tags, + resolve_args_until, + parent, + error, + ) + self._orig_name = name + self.__source_info = None + + @property + def method(self) -> Callable[..., Any]: + """Dynamic ``run_keyword`` method.""" + return RunKeyword( + self.owner.instance, + self._orig_name, + self.owner.supports_named_args, + ) + + @property + def source(self) -> "Path | None": + return self._source_info[0] or super().source + + @property + def lineno(self) -> "int | None": + return self._source_info[1] + + @property + def _source_info(self) -> "tuple[Path | None, int]": + if not self.__source_info: + get_keyword_source = GetKeywordSource(self.owner.instance) + try: + source = get_keyword_source(self._orig_name) + except DataError as err: + source = None + self.owner.report_error( + f"Getting source information for keyword '{self.name}' " + f"failed: {err}", + err.details, + ) + if source and ":" in source and source.rsplit(":", 1)[1].isdigit(): + source, lineno = source.rsplit(":", 1) + lineno = int(lineno) + else: + lineno = None + self.__source_info = Path(normpath(source)) if source else None, lineno + return self.__source_info + + @classmethod + def from_name(cls, name: str, owner: "DynamicLibrary") -> "DynamicKeyword": + return DynamicKeywordCreator(name, owner).create() + + def resolve_arguments( + self, + args: "Sequence[str | Any]", + named_args: "Mapping[str, Any] | None" = None, + variables=None, + languages: "LanguagesLike" = None, + ) -> "tuple[list, list]": + positional, named = super().resolve_arguments( + args, + named_args, + variables, + languages, + ) + if not self.owner.supports_named_args: + positional, named = self.args.map(positional, named) + return positional, named + + def copy(self, **attributes) -> "DynamicKeyword": + return DynamicKeyword( + self.owner, + self._orig_name, + self.args, + self._doc, + self.tags, + self._resolve_args_until, + self.parent, + self.error, + ).config(**attributes) + + +class LibraryInit(LibraryKeyword): + """Represents a library initializer. + + :attr:`positional` and :attr:`named` contain arguments used for initializing + the library. + """ + + def __init__( + self, + owner: "TestLibrary", + name: str = "", + args: "ArgumentSpec | None" = None, + doc: str = "", + tags: "Tags | Sequence[str]" = (), + positional: "list | None" = None, + named: "dict | None" = None, + ): + super().__init__(owner, name, args, doc, tags) + self.positional = positional or [] + self.named = named or {} + self._dynamic_doc_set = False + + @property + def doc(self) -> str: + if not self._dynamic_doc_set: + from .testlibraries import DynamicLibrary + + if isinstance(self.owner, DynamicLibrary): + doc = GetKeywordDocumentation(self.owner.instance)("__init__") + if doc: + self._doc = doc + self._dynamic_doc_set = True + return self._doc + + @doc.setter + def doc(self, doc: str): + self._doc = doc + + @property + def method(self) -> "Callable[..., None] | None": + """Initializer method. + + ``None`` with module based libraries and when class based libraries + do not have ``__init__``. + """ + return getattr(self.owner.instance, "__init__", None) + + @classmethod + def from_class(cls, klass) -> "LibraryInit": + method = getattr(klass, "__init__", None) + return LibraryInitCreator(method).create() + + @classmethod + def null(cls) -> "LibraryInit": + return LibraryInitCreator(None).create() + + def copy(self, **attributes) -> "LibraryInit": + return LibraryInit( + self.owner, + self.name, + self.args, + self._doc, + self.tags, + self.positional, + self.named, + ).config(**attributes) + + +class KeywordCreator(Generic[K]): + keyword_class: "type[K]" + + def __init__(self, name: str, library: "TestLibrary | None" = None): + self.name = name + self.library = library + self.extra = {} + if library and RUN_KW_REGISTER.is_run_keyword(library.real_name, name): + resolve_until = RUN_KW_REGISTER.get_args_to_process(library.real_name, name) + self.extra["resolve_args_until"] = resolve_until + + @property + def instance(self) -> Any: + return self.library.instance + + def create(self, **extra) -> K: + kw = self.keyword_class( + owner=self.library, + name=self.get_name(), + args=self.get_args(), + tags=self.get_tags(), + doc=self.get_doc(), + **self.extra, + **extra, + ) + kw.args.name = lambda: kw.full_name + return kw + + def get_name(self) -> str: + raise NotImplementedError + + def get_args(self) -> ArgumentSpec: + raise NotImplementedError + + def get_doc(self) -> str: + raise NotImplementedError + + def get_tags(self) -> "list[str]": + raise NotImplementedError + + +class StaticKeywordCreator(KeywordCreator[StaticKeyword]): + keyword_class = StaticKeyword + + def __init__(self, name: str, library: "TestLibrary"): + super().__init__(name, library) + self.method = getattr(library.instance, name) + + def get_name(self) -> str: + robot_name = getattr(self.method, "robot_name", None) + name = robot_name or printable_name(self.name, code_style=True) + if not name: + raise DataError("Keyword name cannot be empty.") + return name + + def get_args(self) -> ArgumentSpec: + return PythonArgumentParser().parse(self.method) + + def get_doc(self) -> str: + return inspect.getdoc(self.method) or "" + + def get_tags(self) -> "list[str]": + tags = getattr(self.method, "robot_tags", ()) + if not is_list_like(tags): + raise DataError(f"Expected tags to be list-like, got {type_name(tags)}.") + return list(tags) + + +class DynamicKeywordCreator(KeywordCreator[DynamicKeyword]): + keyword_class = DynamicKeyword + library: "DynamicLibrary" + + def get_name(self) -> str: + return self.name + + def get_args(self) -> ArgumentSpec: + supports_named_args = self.library.supports_named_args + get_keyword_arguments = GetKeywordArguments(self.instance, supports_named_args) + spec = DynamicArgumentParser().parse(get_keyword_arguments(self.name)) + if not supports_named_args: + name = RunKeyword(self.instance).name + prefix = f"Too few '{name}' method parameters to support " + if spec.named_only: + raise DataError(prefix + "named-only arguments.") + if spec.var_named: + raise DataError(prefix + "free named arguments.") + types = GetKeywordTypes(self.instance)(self.name) + if isinstance(types, dict) and "return" in types: + spec.return_type = types.pop("return") + spec.types = types + return spec + + def get_doc(self) -> str: + return GetKeywordDocumentation(self.instance)(self.name) + + def get_tags(self) -> "list[str]": + return GetKeywordTags(self.instance)(self.name) + + +class LibraryInitCreator(KeywordCreator[LibraryInit]): + keyword_class = LibraryInit + + def __init__(self, method: "Callable[..., None] | None"): + super().__init__("__init__") + self.method = method if is_init(method) else lambda: None + + def create(self, **extra) -> LibraryInit: + init = super().create(**extra) + init.args.name = lambda: init.owner.name + return init + + def get_name(self) -> str: + return self.name + + def get_args(self) -> ArgumentSpec: + return PythonArgumentParser("Library").parse(self.method) + + def get_doc(self) -> str: + return inspect.getdoc(self.method) or "" + + def get_tags(self) -> "list[str]": + return [] diff --git a/src/robot/running/librarykeywordrunner.py b/src/robot/running/librarykeywordrunner.py index 1d047dda728..d90c33da521 100644 --- a/src/robot/running/librarykeywordrunner.py +++ b/src/robot/running/librarykeywordrunner.py @@ -13,217 +13,301 @@ # See the License for the specific language governing permissions and # limitations under the License. +from contextlib import contextmanager +from typing import TYPE_CHECKING + from robot.errors import DataError -from robot.model import Keywords -from robot.output import LOGGER from robot.result import Keyword as KeywordResult -from robot.utils import prepr, unic -from robot.variables import VariableAssignment, contains_var, is_list_var +from robot.utils import prepr, safe_str +from robot.variables import contains_variable, is_list_variable, VariableAssignment -from .steprunner import StepRunner -from .model import Keyword +from .bodyrunner import BodyRunner +from .model import Keyword as KeywordData from .outputcapture import OutputCapturer +from .resourcemodel import UserKeyword from .signalhandler import STOP_SIGNAL_MONITOR from .statusreporter import StatusReporter +if TYPE_CHECKING: + from .librarykeyword import LibraryKeyword + + +class LibraryKeywordRunner: + + def __init__( + self, + keyword: "LibraryKeyword", + name: "str | None" = None, + languages=None, + ): + self.keyword = keyword + self.name = name or keyword.name + self.pre_run_messages = () + self.languages = languages + + def run(self, data: KeywordData, result: KeywordResult, context, run=True): + kw = self.keyword.bind(data) + assignment = VariableAssignment(data.assign) + self._config_result(result, data, kw, assignment) + with StatusReporter(data, result, context, run, implementation=kw): + if run: + with assignment.assigner(context) as assigner: + return_value = self._run(data, kw, context) + assigner.assign(return_value) + return return_value + return None -class LibraryKeywordRunner(object): - _executed_in_dry_run = ('BuiltIn.Import Library', - 'BuiltIn.Set Library Search Order') - - def __init__(self, handler, name=None): - self._handler = handler - self.name = name or handler.name - self.pre_run_messages = None - - @property - 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 run(self, kw, context): - assignment = VariableAssignment(kw.assign) - with StatusReporter(context, self._get_result(kw, assignment)): - with assignment.assigner(context) as assigner: - return_value = self._run(context, kw.args) - assigner.assign(return_value) - return return_value - - def _get_result(self, kw, assignment): - handler = self._handler - return KeywordResult(kwname=self.name, - libname=handler.libname, - doc=handler.shortdoc, - args=kw.args, - assign=tuple(assignment), - tags=handler.tags, - type=kw.type) - - def _run(self, context, args): + def _config_result( + self, + result: KeywordResult, + data: KeywordData, + kw: "LibraryKeyword", + assignment, + ): + args = tuple(data.args) + if data.named_args: + args += tuple(f"{n}={v}" for n, v in data.named_args.items()) + result.config( + name=self.name, + owner=kw.owner.name, + doc=kw.short_doc, + args=args, + assign=tuple(assignment), + tags=kw.all_tags, + type=data.type, + ) + + def _run(self, data: KeywordData, kw: "LibraryKeyword", context): if self.pre_run_messages: for message in self.pre_run_messages: context.output.message(message) - positional, named = \ - self._handler.resolve_arguments(args, context.variables) - context.output.trace(lambda: self._trace_log_args(positional, named)) - runner = self._runner_for(context, self._handler.current_handler(), - positional, dict(named)) - return self._run_with_output_captured_and_signal_monitor(runner, context) + variables = context.variables if not context.dry_run else None + positional, named = self._resolve_arguments(data, kw, variables) + context.output.trace( + lambda: self._trace_log_args(positional, named), write_if_flat=False + ) + if kw.error: + raise DataError(kw.error) + return self._execute(kw.method, positional, named, context) + + def _resolve_arguments( + self, + data: KeywordData, + kw: "LibraryKeyword", + variables=None, + ): + return kw.resolve_arguments( + data.args, + data.named_args, + variables, + self.languages, + ) def _trace_log_args(self, positional, named): - args = [prepr(arg) for arg in positional] - args += ['%s=%s' % (unic(n), prepr(v)) for n, v in named] - return 'Arguments: [ %s ]' % ' | '.join(args) - - def _runner_for(self, context, handler, positional, named): - timeout = self._get_timeout(context) - if timeout and timeout.active: - def runner(): - with LOGGER.delayed_logging: - context.output.debug(timeout.get_message) - return timeout.run(handler, args=positional, kwargs=named) - return runner - return lambda: handler(*positional, **named) + args = [ + *[prepr(arg) for arg in positional], + *[f"{safe_str(n)}={prepr(v)}" for n, v in named], + ] + return f"Arguments: [ {' | '.join(args)} ]" def _get_timeout(self, context): return min(context.timeouts) if context.timeouts else None - def _run_with_output_captured_and_signal_monitor(self, runner, context): - with OutputCapturer(): - return self._run_with_signal_monitoring(runner, context) - - def _run_with_signal_monitoring(self, runner, context): + def _execute(self, method, positional, named, context): + timeout = self._get_timeout(context) + if timeout: + method = self._wrap_with_timeout(method, timeout, context) + with self._monitor(context): + result = method(*positional, **dict(named)) + if context.asynchronous.is_loop_required(result): + return context.asynchronous.run_until_complete(result) + return result + + def _wrap_with_timeout(self, method, timeout, context): + def wrapper(*args, **kwargs): + with context.timeout(timeout) as runner: + return runner.run(method, args=args, kwargs=kwargs) + + return wrapper + + @contextmanager + def _monitor(self, context): + STOP_SIGNAL_MONITOR.start_running_keyword(context.in_teardown) + capturer = OutputCapturer() + capturer.start() try: - STOP_SIGNAL_MONITOR.start_running_keyword(context.in_teardown) - return runner() + yield finally: + capturer.stop() STOP_SIGNAL_MONITOR.stop_running_keyword() - def dry_run(self, kw, context): - assignment = VariableAssignment(kw.assign) - result = self._get_result(kw, assignment) - with StatusReporter(context, result, dry_run_lib_kw=True): + def dry_run(self, data: KeywordData, result: KeywordResult, context): + kw = self.keyword.bind(data) + assignment = VariableAssignment(data.assign) + self._config_result(result, data, kw, assignment) + with StatusReporter( + data, + result, + context, + implementation=kw, + run=self._get_initial_dry_run_status(kw), + ): assignment.validate_assignment() - self._dry_run(context, kw.args) - - def _dry_run(self, context, args): - if self._handler.longname in self._executed_in_dry_run: - self._run(context, args) - else: - self._handler.resolve_arguments(args) + if self._executed_in_dry_run(kw): + self._run(data, kw, context) + else: + self._resolve_arguments(data, kw) + self._dry_run(data, kw, result, context) + + def _get_initial_dry_run_status(self, kw): + return self._executed_in_dry_run(kw) + + def _executed_in_dry_run(self, kw: "LibraryKeyword"): + return kw.owner.name == "BuiltIn" and kw.name in ( + "Import Library", + "Set Library Search Order", + "Set Tags", + "Remove Tags", + "Import Resource", + ) + + def _dry_run( + self, + data: KeywordData, + kw: "LibraryKeyword", + result: KeywordResult, + context, + ): + pass class EmbeddedArgumentsRunner(LibraryKeywordRunner): - def __init__(self, handler, name): - LibraryKeywordRunner.__init__(self, handler, name) - self._embedded_args = handler.name_regexp.match(name).groups() - - def _run(self, context, args): - if args: - raise DataError("Positional arguments are not allowed when using " - "embedded arguments.") - return LibraryKeywordRunner._run(self, context, self._embedded_args) - - def _dry_run(self, context, args): - return LibraryKeywordRunner._dry_run(self, context, self._embedded_args) + def __init__(self, keyword: "LibraryKeyword", name: "str"): + super().__init__(keyword, name) + self.embedded_args = keyword.embedded.parse_args(name) + + def _resolve_arguments( + self, + data: KeywordData, + kw: "LibraryKeyword", + variables=None, + ): + return kw.resolve_arguments( + self.embedded_args + data.args, + data.named_args, + variables, + self.languages, + ) + + def _config_result( + self, + result: KeywordResult, + data: KeywordData, + kw: "LibraryKeyword", + assignment, + ): + super()._config_result(result, data, kw, assignment) + result.source_name = kw.name class RunKeywordRunner(LibraryKeywordRunner): - def __init__(self, handler, default_dry_run_keywords=False): - LibraryKeywordRunner.__init__(self, handler) - self._default_dry_run_keywords = default_dry_run_keywords + def __init__(self, keyword: "LibraryKeyword", dry_run_children=False): + super().__init__(keyword) + self._dry_run_children = dry_run_children - # TODO: Should this be removed altogether? - # - Doesn't seem to be really needed. - # - Not used with dynamic run kws in the new design (at least currently) - def _get_timeout(self, namespace): + 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): - return self._run_with_signal_monitoring(runner, context) - - def _dry_run(self, context, args): - LibraryKeywordRunner._dry_run(self, context, args) - keywords = self._get_runnable_dry_run_keywords(args) - StepRunner(context).run_steps(keywords) - - def _get_runnable_dry_run_keywords(self, args): - keywords = Keywords() - for keyword in self._get_dry_run_keywords(args): - if contains_var(keyword.name): - continue - keywords.append(keyword) - return keywords - - def _get_dry_run_keywords(self, args): - name = self._handler.name - if name == 'Run Keyword If': - return list(self._get_run_kw_if_keywords(args)) - if name == 'Run Keywords': - return list(self._get_run_kws_keywords(args)) - if self._default_dry_run_keywords: - return self._get_default_run_kw_keywords(args) - return [] - - def _get_run_kw_if_keywords(self, given_args): + @contextmanager + def _monitor(self, context): + STOP_SIGNAL_MONITOR.start_running_keyword(context.in_teardown) + try: + yield + finally: + STOP_SIGNAL_MONITOR.stop_running_keyword() + + def _get_initial_dry_run_status(self, kw): + return self._dry_run_children or super()._get_initial_dry_run_status(kw) + + def _dry_run( + self, + data: KeywordData, + kw: "LibraryKeyword", + result: KeywordResult, + context, + ): + wrapper = UserKeyword( + name=kw.name, + doc=f"Wraps keywords executed by '{kw.name}' in dry-run.", + parent=kw.parent, + ) + for child in self._get_dry_run_children(kw, data.args): + if not contains_variable(child.name): + child.lineno = data.lineno + wrapper.body.append(child) + BodyRunner(context).run(wrapper, result) + + def _get_dry_run_children(self, kw: "LibraryKeyword", args): + if not self._dry_run_children: + return [] + if kw.name == "Run Keyword If": + return self._get_dry_run_children_for_run_keyword_if(args) + if kw.name == "Run Keywords": + return self._get_dry_run_children_for_run_keyword(args) + index = kw.args.positional.index("name") + return [KeywordData(name=args[index], args=args[index + 1 :])] + + def _get_dry_run_children_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:]) + yield KeywordData(name=kw_call[0], args=kw_call[1:]) def _get_run_kw_if_calls(self, given_args): - while 'ELSE IF' in given_args: - kw_call, given_args = self._split_run_kw_if_args(given_args, 'ELSE IF', 2) + while "ELSE IF" in given_args: + kw_call, given_args = self._split_run_kw_if_args(given_args, "ELSE IF", 2) yield kw_call - if 'ELSE' in given_args: - kw_call, else_call = self._split_run_kw_if_args(given_args, 'ELSE', 1) + if "ELSE" in given_args: + kw_call, else_call = self._split_run_kw_if_args(given_args, "ELSE", 1) yield kw_call yield else_call elif self._validate_kw_call(given_args): expr, kw_call = given_args[0], given_args[1:] - if not is_list_var(expr): + if not is_list_variable(expr): yield kw_call def _split_run_kw_if_args(self, given_args, control_word, required_after): index = list(given_args).index(control_word) expr_and_call = given_args[:index] - remaining = given_args[index+1:] - if not (self._validate_kw_call(expr_and_call) and - self._validate_kw_call(remaining, required_after)): + remaining = given_args[index + 1 :] + if not ( + self._validate_kw_call(expr_and_call) + and self._validate_kw_call(remaining, required_after) + ): raise DataError("Invalid 'Run Keyword If' usage.") - if is_list_var(expr_and_call[0]): + if is_list_variable(expr_and_call[0]): return (), remaining return expr_and_call[1:], remaining def _validate_kw_call(self, kw_call, min_length=2): if len(kw_call) >= min_length: return True - return any(is_list_var(item) for item in kw_call) + return any(is_list_variable(item) for item in kw_call) - def _get_run_kws_keywords(self, given_args): + def _get_dry_run_children_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:]) + yield KeywordData(name=kw_call[0], args=kw_call[1:]) def _get_run_kws_calls(self, given_args): - if 'AND' not in given_args: + if "AND" not in given_args: for kw_call in given_args: - yield [kw_call,] + yield [kw_call] else: - while 'AND' in given_args: - index = list(given_args).index('AND') - kw_call, given_args = given_args[:index], given_args[index + 1:] + while "AND" in given_args: + index = list(given_args).index("AND") + kw_call, given_args = given_args[:index], given_args[index + 1 :] yield kw_call if given_args: yield given_args - - def _get_default_run_kw_keywords(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/libraryscopes.py b/src/robot/running/libraryscopes.py index abba39fbba3..f183bb20a91 100644 --- a/src/robot/running/libraryscopes.py +++ b/src/robot/running/libraryscopes.py @@ -13,39 +13,42 @@ # See the License for the specific language governing permissions and # limitations under the License. -import inspect +from enum import auto, Enum +from typing import TYPE_CHECKING -from robot.utils import normalize, unic +from robot.errors import DataError +from .context import EXECUTION_CONTEXTS -def LibraryScope(libcode, library): - scope = _get_scope(libcode) - if scope == 'global': - return GlobalScope(library) - if scope == 'testsuite': - return TestSuiteScope(library) - return TestCaseScope(library) +if TYPE_CHECKING: + from .testlibraries import TestLibrary -def _get_scope(libcode): - if inspect.ismodule(libcode): - return 'global' - scope = getattr(libcode, 'ROBOT_LIBRARY_SCOPE', '') - return normalize(unic(scope), ignore='_') +class Scope(Enum): + GLOBAL = auto() + SUITE = auto() + TEST = auto() -class GlobalScope(object): - is_global = True +class ScopeManager: - def __init__(self, library): - self._register_listeners = library.register_listeners - self._unregister_listeners = library.unregister_listeners + def __init__(self, library: "TestLibrary"): + self.library = library + + @classmethod + def for_library(cls, library): + manager = { + Scope.GLOBAL: GlobalScopeManager, + Scope.SUITE: SuiteScopeManager, + Scope.TEST: TestScopeManager, + }[library.scope] + return manager(library) def start_suite(self): - self._register_listeners() + pass def end_suite(self): - self._unregister_listeners() + pass def start_test(self): pass @@ -53,49 +56,62 @@ def start_test(self): def end_test(self): pass - def __str__(self): - return 'global' + def close_global_listeners(self): + pass + def register_listeners(self): + if self.library.listeners: + try: + listeners = EXECUTION_CONTEXTS.current.output.library_listeners + listeners.register(self.library) + except DataError as err: + self.library._has_listeners = False + self.library.report_error(f"Registering listeners failed: {err}") -class TestSuiteScope(GlobalScope): - is_global = False + def unregister_listeners(self, close=False): + if self.library.listeners: + listeners = EXECUTION_CONTEXTS.current.output.library_listeners + listeners.unregister(self.library, close) - def __init__(self, library): - GlobalScope.__init__(self, library) - self._reset_instance = library.reset_instance - self._instance_cache = [] - @property - def is_global(self): - return False +class GlobalScopeManager(ScopeManager): def start_suite(self): - prev = self._reset_instance() - self._instance_cache.append(prev) - self._register_listeners() + self.register_listeners() def end_suite(self): - self._unregister_listeners(close=True) - prev = self._instance_cache.pop() - self._reset_instance(prev) + self.unregister_listeners() - def __str__(self): - return 'test suite' + def close_global_listeners(self): + self.register_listeners() + self.unregister_listeners(close=True) -class TestCaseScope(TestSuiteScope): +class SuiteScopeManager(ScopeManager): + + def __init__(self, library): + super().__init__(library) + self.instance_cache = [] + + def start_suite(self): + self.instance_cache.append(self.library._instance) + self.library.instance = None + self.register_listeners() + + def end_suite(self): + self.unregister_listeners(close=True) + self.library.instance = self.instance_cache.pop() + + +class TestScopeManager(SuiteScopeManager): def start_test(self): - self._unregister_listeners() - prev = self._reset_instance() - self._instance_cache.append(prev) - self._register_listeners() + self.unregister_listeners() + self.instance_cache.append(self.library._instance) + self.library.instance = None + self.register_listeners() def end_test(self): - self._unregister_listeners(close=True) - prev = self._instance_cache.pop() - self._reset_instance(prev) - self._register_listeners() - - def __str__(self): - return 'test case' + self.unregister_listeners(close=True) + self.library.instance = self.instance_cache.pop() + self.register_listeners() diff --git a/src/robot/running/model.py b/src/robot/running/model.py index 2fcf2598bab..0b1e5f7bcbb 100644 --- a/src/robot/running/model.py +++ b/src/robot/running/model.py @@ -15,115 +15,800 @@ """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. -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`__. -The aforementioned objects are considered stable, but other objects in this -module may still be changed in the future major releases. +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 """ +import warnings +from pathlib import Path +from typing import Any, Literal, Mapping, Sequence, TYPE_CHECKING, TypeVar, Union + from robot import model from robot.conf import RobotSettings +from robot.errors import ( + BreakLoop, ContinueLoop, DataError, ReturnFromKeyword, VariableError +) +from robot.model import BodyItem, DataDict, TestSuites from robot.output import LOGGER, Output, pyloggingconf -from robot.utils import setter +from robot.result import Result +from robot.utils import format_assign_message, setter +from robot.variables import VariableResolver -from .steprunner import StepRunner +from .bodyrunner import ( + ForRunner, GroupRunner, IfRunner, KeywordRunner, TryRunner, WhileRunner +) from .randomizer import Randomizer +from .statusreporter import StatusReporter +if TYPE_CHECKING: + from robot.parsing import File -class Keyword(model.Keyword): - """Represents a single executable keyword. + from .builder import TestDefaults + from .resourcemodel import ResourceFile, UserKeyword - These keywords never have child keywords or messages. The actual keyword - that is executed depends on the context where this model is executed. - See the base class for documentation of attributes not documented here. - """ - __slots__ = [] - message_class = None #: Internal usage only. +IT = TypeVar("IT", bound="IfBranch | TryBranch") +BodyItemParent = Union[ + "TestSuite", "TestCase", "UserKeyword", "For", "While", "If", "IfBranch", + "Try", "TryBranch", "Group", None +] # fmt: skip - def run(self, context): - """Execute the keyword. - Typically called internally by :meth:`TestSuite.run`. - """ - return StepRunner(context).run_step(self) +class Body(model.BaseBody[ + "Keyword", "For", "While", "Group", "If", "Try", "Var", "Return", "Continue", + "Break", "model.Message", "Error" +]): # fmt: skip + __slots__ = () -class ForLoop(Keyword): - """Represents a for loop in test data. +class Branches(model.BaseBranches[ + "Keyword", "For", "While", "Group", "If", "Try", "Var", "Return", "Continue", + "Break", "model.Message", "Error", IT +]): # fmt: skip + __slots__ = () - Contains keywords in the loop body as child :attr:`keywords`. - """ - __slots__ = ['flavor'] - keyword_class = Keyword #: Internal usage only. - def __init__(self, variables, values, flavor): - Keyword.__init__(self, assign=variables, args=values, - type=Keyword.FOR_LOOP_TYPE) - self.flavor = flavor +class WithSource: + parent: BodyItemParent + __slots__ = () @property - def variables(self): - return self.assign + def source(self) -> "Path | None": + return self.parent.source if self.parent is not None else None - @property - def values(self): - return self.args + +class Argument: + """A temporary API for creating named arguments with non-string values. + + This class was added in RF 7.0.1 (#5031) after a failed attempt to add a public + API for this purpose in RF 7.0 (#5000). A better public API that allows passing + named arguments separately was added in RF 7.1 (#5143). + + If you need to support also RF 7.0, you can pass named arguments as two-item tuples + like `(name, value)` and positional arguments as one-item tuples like `(value,)`. + That approach does not work anymore in RF 7.0.1, though, so the code needs to be + conditional depending on Robot Framework version. + + The main limitation of this class is that it is not compatible with the JSON model. + The current plan is to remove this in the future, possibly already in RF 8.0, but + we can consider preserving it if it turns out to be useful. + """ + + def __init__(self, name: "str | None", value: Any): + """ + :param name: Argument name. If ``None``, argument is considered positional. + :param value: Argument value. + """ + self.name = name + self.value = value + + def __str__(self): + return str(self.value) if self.name is None else f"{self.name}={self.value}" -class TestCase(model.TestCase): +@Body.register +class Keyword(model.Keyword, WithSource): + """Represents an executable keyword call. + + A keyword call consists only of a keyword name, arguments and possible + assignment in the data:: + + Keyword arg + ${result} = Another Keyword arg1 arg2 + + The actual keyword that is executed depends on the context where this model + is executed. + + Arguments originating from normal Robot Framework data are stored in the + :attr:`args` attribute as a tuple of strings in the exact same format as in + the data. This means that arguments can have variables and escape characters, + and that named arguments are specified using the ``name=value`` syntax. + + When creating keywords programmatically, it is possible to set :attr:`named_args` + separately and use :attr:`args` only for positional arguments. Argument values + do not need to be strings, but also in this case strings can contain variables + and normal Robot Framework escaping rules must be taken into account. + """ + + __slots__ = ("named_args", "lineno") + + def __init__( + self, + name: str = "", + args: "Sequence[str | Argument | Any]" = (), + named_args: "Mapping[str, Any] | None" = None, + assign: Sequence[str] = (), + type: str = BodyItem.KEYWORD, + parent: BodyItemParent = None, + lineno: "int | None" = None, + ): + super().__init__(name, args, assign, type, parent) + self.named_args = named_args + self.lineno = lineno + + def to_dict(self) -> DataDict: + data = super().to_dict() + if self.named_args is not None: + data["named_args"] = self.named_args + if self.lineno: + data["lineno"] = self.lineno + return data + + def run(self, result, context, run=True, templated=None): + return KeywordRunner(context, run).run(self, result.body.create_keyword()) + + +class ForIteration(model.ForIteration, WithSource): + body_class = Body + __slots__ = ("lineno", "error") + + def __init__( + self, + assign: "Mapping[str, str] | None" = None, + parent: BodyItemParent = None, + lineno: "int | None" = None, + error: "str | None" = None, + ): + super().__init__(assign, parent) + self.lineno = lineno + self.error = error + + +@Body.register +class For(model.For, WithSource): + body_class = Body + __slots__ = ("lineno", "error") + + 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, + lineno: "int | None" = None, + error: "str | None" = None, + ): + 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: + data["lineno"] = self.lineno + if self.error: + data["error"] = self.error + return data + + def run(self, result, context, run=True, templated=False): + result = result.body.create_for( + self.assign, + self.flavor, + self.values, + self.start, + self.mode, + self.fill, + ) + return ForRunner(context, self.flavor, run, templated).run(self, result) + + def get_iteration(self, assign: "Mapping[str, str] | None" = None) -> ForIteration: + iteration = ForIteration(assign, self, self.lineno, self.error) + iteration.body = [item.to_dict() for item in self.body] + return iteration + + +class WhileIteration(model.WhileIteration, WithSource): + body_class = Body + __slots__ = ("lineno", "error") + + def __init__( + self, + parent: BodyItemParent = None, + lineno: "int | None" = None, + error: "str | None" = None, + ): + super().__init__(parent) + self.lineno = lineno + self.error = error + + +@Body.register +class While(model.While, WithSource): + body_class = Body + __slots__ = ("lineno", "error") + + 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 + + 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 + + def run(self, result, context, run=True, templated=False): + result = result.body.create_while( + self.condition, + self.limit, + self.on_limit, + self.on_limit_message, + ) + return WhileRunner(context, run, templated).run(self, result) + + def get_iteration(self) -> WhileIteration: + iteration = WhileIteration(self, self.lineno, self.error) + iteration.body = [item.to_dict() for item in self.body] + return iteration + + +@Body.register +class Group(model.Group, WithSource): + body_class = Body + __slots__ = ("lineno", "error") + + def __init__( + self, + name: str = "", + parent: BodyItemParent = None, + lineno: "int | None" = None, + error: "str | None" = None, + ): + super().__init__(name, parent) + self.lineno = lineno + self.error = error + + 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 + + def run(self, result, context, run=True, templated=False): + result = result.body.create_group(self.name) + return GroupRunner(context, run, templated).run(self, result) + + +class IfBranch(model.IfBranch, WithSource): + body_class = Body + __slots__ = ("lineno",) + + 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 + + def to_dict(self) -> DataDict: + data = super().to_dict() + if self.lineno: + data["lineno"] = self.lineno + return data + + +@Body.register +class If(model.If, WithSource): + branch_class = IfBranch + branches_class = Branches[branch_class] + __slots__ = ("lineno", "error") + + def __init__( + self, + parent: BodyItemParent = None, + lineno: "int | None" = None, + error: "str | None" = None, + ): + super().__init__(parent) + self.lineno = lineno + self.error = error + + def run(self, result, context, run=True, templated=False): + return IfRunner(context, run, templated).run(self, result.body.create_if()) + + 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 + + +class TryBranch(model.TryBranch, WithSource): + body_class = Body + __slots__ = ("lineno",) + + def __init__( + self, + type: str = BodyItem.TRY, + patterns: Sequence[str] = (), + pattern_type: "str | None" = None, + assign: "str | None" = None, + parent: BodyItemParent = None, + lineno: "int | None" = None, + ): + 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: + data["lineno"] = self.lineno + return data + + +@Body.register +class Try(model.Try, WithSource): + branch_class = TryBranch + branches_class = Branches[branch_class] + __slots__ = ("lineno", "error") + + def __init__( + self, + parent: BodyItemParent = None, + lineno: "int | None" = None, + error: "str | None" = None, + ): + super().__init__(parent) + self.lineno = lineno + self.error = error + + def run(self, result, context, run=True, templated=False): + return TryRunner(context, run, templated).run(self, result.body.create_try()) + + 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 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, result, context, run=True, templated=False): + result = result.body.create_var( + self.name, + self.value, + self.scope, + self.separator, + ) + with StatusReporter(self, result, context, run): + if self.error and run: + raise DataError(self.error, syntax=True) + if not run or context.dry_run: + return + scope, config = self._get_scope(context.variables) + if scope != "local" and not self.value and self.name[:1] == "$": + context.warn( + f"Using the VAR syntax to create a scalar variable without " + f"a value in other than the local scope like " + f"'VAR {self.name} scope={self.scope}' " + f"is deprecated. In the future this syntax will promote " + f"an existing variable to the new scope. Use " + f"'VAR {self.name} ${{EMPTY}} scope={self.scope}' " + f"instead." + ) + set_variable = getattr(context.variables, f"set_{scope}") + try: + name, value = self._resolve_name_and_value(context.variables) + set_variable(name, value, **config) + context.info(format_assign_message(name, value)) + except DataError as err: + raise VariableError(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() == "SUITES": + return "suite", {"children": True} + if scope.upper() in ("LOCAL", "TEST", "SUITE", "GLOBAL"): + return scope.lower(), {} + raise DataError( + f"Value '{scope}' is not accepted. Valid values are " + f"'LOCAL', 'TEST', 'TASK', 'SUITE', 'SUITES' and 'GLOBAL'." + ) + except DataError as err: + raise DataError(f"Invalid VAR scope: {err}") + + def _resolve_name_and_value(self, variables): + resolver = VariableResolver.from_variable(self) + resolver.resolve(variables) + return resolver.name, resolver.value + + 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") + + 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 + + def run(self, result, context, run=True, templated=False): + result = result.body.create_return(self.values) + with StatusReporter(self, result, context, run): + if run: + if self.error: + raise DataError(self.error, syntax=True) + if not context.dry_run: + raise ReturnFromKeyword(self.values) + + 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 Continue(model.Continue, WithSource): + __slots__ = ("lineno", "error") + + def __init__( + self, + parent: BodyItemParent = None, + lineno: "int | None" = None, + error: "str | None" = None, + ): + super().__init__(parent) + self.lineno = lineno + self.error = error + + def run(self, result, context, run=True, templated=False): + result = result.body.create_continue() + with StatusReporter(self, result, context, run): + if run: + if self.error: + raise DataError(self.error, syntax=True) + if not context.dry_run: + raise ContinueLoop + + 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 Break(model.Break, WithSource): + __slots__ = ("lineno", "error") + + def __init__( + self, + parent: BodyItemParent = None, + lineno: "int | None" = None, + error: "str | None" = None, + ): + super().__init__(parent) + self.lineno = lineno + self.error = error + + def run(self, result, context, run=True, templated=False): + result = result.body.create_break() + with StatusReporter(self, result, context, run): + if run: + if self.error: + raise DataError(self.error, syntax=True) + if not context.dry_run: + raise BreakLoop + + 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 Error(model.Error, WithSource): + __slots__ = ("lineno", "error") + + 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 + + def run(self, result, context, run=True, templated=False): + result = result.body.create_error(self.values) + with StatusReporter(self, result, context, run): + if run: + raise DataError(self.error) + + def to_dict(self) -> DataDict: + data = super().to_dict() + if self.lineno: + data["lineno"] = self.lineno + data["error"] = self.error + return data + + +class TestCase(model.TestCase[Keyword]): """Represents a single executable test case. See the base class for documentation of attributes not documented here. """ - __slots__ = ['template'] - keyword_class = Keyword #: Internal usage only. - def __init__(self, name='', doc='', tags=None, timeout=None, template=None): - model.TestCase.__init__(self, name, doc, tags, timeout) - #: Name of the keyword that has been used as template - #: when building the test. ``None`` if no is template used. + body_class = Body #: Internal usage only. + fixture_class = Keyword #: Internal usage only. + __slots__ = ("template", "error") + + def __init__( + self, + name: str = "", + doc: str = "", + tags: Sequence[str] = (), + timeout: "str | None" = None, + lineno: "int | None" = None, + metadata: "Mapping[str, str] | None" = None, + parent: "TestSuite | None" = None, + template: "str | None" = None, + error: "str | None" = None, + ): + super().__init__(name, doc, tags, timeout, lineno, metadata, 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 - @setter - def timeout(self, timeout): - """Test timeout as a :class:`Timeout` instance or ``None``. + def to_dict(self) -> DataDict: + data = super().to_dict() + if self.template: + data["template"] = self.template + if self.error: + data["error"] = self.error + return data - This attribute is likely to change in the future. - """ - return Timeout(*timeout) if timeout else None + @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): +class TestSuite(model.TestSuite[Keyword, TestCase]): """Represents a single executable test suite. See the base class for documentation of attributes not documented here. """ - __slots__ = ['resource'] - test_class = TestCase #: Internal usage only. - keyword_class = Keyword #: Internal usage only. - def __init__(self, name='', doc='', metadata=None, source=None, rpa=False): - model.TestSuite.__init__(self, name, doc, metadata, source, rpa) + test_class = TestCase #: Internal usage only. + fixture_class = Keyword #: Internal usage only. + __slots__ = () + + def __init__( + self, + name: str = "", + doc: str = "", + metadata: "Mapping[str, str] | None" = None, + source: "Path | str | 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 #: 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 = None + + @setter + def resource(self, resource: "ResourceFile | dict | None") -> "ResourceFile": + from .resourcemodel import ResourceFile + + if resource is None: + resource = ResourceFile() + if isinstance(resource, dict): + resource = ResourceFile.from_dict(resource) + resource.owner = self + return resource + + @classmethod + def from_file_system(cls, *paths: "Path | str", **config) -> "TestSuite": + """Create a :class:`TestSuite` object based on the given ``paths``. + + :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. + + See also :meth:`from_model` and :meth:`from_string`. + """ + from .builder import TestSuiteBuilder + + return TestSuiteBuilder(**config).build(*paths) + + @classmethod + 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``. - def configure(self, randomize_suites=False, randomize_tests=False, - randomize_seed=None, **options): + :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. + + 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') + + See also :meth:`from_file_system` and :meth:`from_string`. + """ + from .builder import RobotParser + + suite = RobotParser().parse_model(model, defaults) + if name is not None: + # TODO: Remove 'name' in RF 8.0. + warnings.warn( + "'name' argument of 'TestSuite.from_model' is deprecated. " + "Set the name to the returned suite separately." + ) + suite.name = name + return suite + + @classmethod + def from_string( + cls, + string: str, + *, + defaults: "TestDefaults | None" = None, + **config, + ) -> "TestSuite": + """Create a :class:`TestSuite` object based on the given ``string``. + + :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. + + 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 + + model = get_model(string, data_only=True, **config) + return cls.from_model(model, defaults=defaults) + + 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. @@ -135,13 +820,22 @@ def configure(self, randomize_suites=False, randomize_tests=False, Example:: - suite.configure(included_tags=['smoke'], + suite.configure(include_tags=['smoke'], doc='Smoke test results.') + + Not to be confused with :meth:`config` method that suites, tests, + 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. @@ -151,8 +845,14 @@ 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``. + @setter + def suites( + self, suites: "Sequence[TestSuite | DataDict]" + ) -> TestSuites["TestSuite"]: + return TestSuites["TestSuite"](self.__class__, self, suites) + + def run(self, settings=None, **options) -> Result: + """Executes the suite based on the given ``settings`` or ``options``. :param settings: :class:`~robot.conf.settings.RobotSettings` object to configure test execution. @@ -170,7 +870,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 @@ -187,11 +887,10 @@ def run(self, settings=None, **options): stdout = StringIO() result = suite.run(variable='EXAMPLE:value', - critical='regression', output='example.xml', exitonfailure=True, stdout=stdout) - print result.return_code + print(result.return_code) To save memory, the returned :class:`~robot.result.executionresult.Result` object does not @@ -208,7 +907,7 @@ def run(self, settings=None, **options): """ from .namespace import IMPORTER from .signalhandler import STOP_SIGNAL_MONITOR - from .runner import Runner + from .suiterunner import SuiteRunner with LOGGER: if not settings: @@ -218,76 +917,12 @@ def run(self, settings=None, **options): with STOP_SIGNAL_MONITOR: IMPORTER.reset() output = Output(settings) - runner = Runner(output, settings) + runner = SuiteRunner(output, settings) self.visit(runner) output.close(runner.result) return runner.result - -class Variable(object): - - def __init__(self, name, value, source=None): - self.name = name - self.value = value - self.source = source - - def report_invalid_syntax(self, message, level='ERROR'): - LOGGER.write("Error in file '%s': Setting variable '%s' failed: %s" - % (self.source or '', self.name, message), level) - - -class Timeout(object): - - def __init__(self, value, message=None): - self.value = value - self.message = message - - def __str__(self): - return self.value - - -class ResourceFile(object): - - def __init__(self, doc='', source=None): - self.doc = doc - self.source = source - self.imports = [] - self.keywords = [] - self.variables = [] - - @setter - def imports(self, imports): - return model.Imports(self.source, imports) - - @setter - def keywords(self, keywords): - return model.ItemList(UserKeyword, items=keywords) - - @setter - def variables(self, variables): - return model.ItemList(Variable, {'source': self.source}, items=variables) - - -class UserKeyword(object): - - def __init__(self, name, args=(), doc='', tags=(), return_=None, timeout=None): - self.name = name - self.args = args - self.doc = doc - self.tags = tags - self.return_ = return_ or () - self.timeout = timeout - self.keywords = [] - - @setter - def keywords(self, keywords): - return model.Keywords(Keyword, self, keywords) - - @setter - def timeout(self, timeout): - """Keyword timeout as a :class:`Timeout` instance or ``None``.""" - return Timeout(*timeout) if timeout else None - - @setter - def tags(self, tags): - return model.Tags(tags) + def to_dict(self) -> DataDict: + data = super().to_dict() + data["resource"] = self.resource.to_dict() + return data diff --git a/src/robot/running/namespace.py b/src/robot/running/namespace.py index 62b2491dbae..ee828b3cd45 100644 --- a/src/robot/running/namespace.py +++ b/src/robot/running/namespace.py @@ -16,34 +16,40 @@ import copy import os from collections import OrderedDict -from itertools import chain +from robot.conf import Languages from robot.errors import DataError, KeywordError from robot.libraries import STDLIBS from robot.output import LOGGER, Message -from robot.parsing.settings import Library, Resource, Variables -from robot.utils import (RecommendationFinder, eq, find_file, is_string, - printable_name, seq2str2) +from robot.utils import eq, find_file, normalize, RecommendationFinder, seq2str2 +from .context import EXECUTION_CONTEXTS from .importer import ImportCache, Importer +from .invalidkeyword import InvalidKeyword +from .resourcemodel import Import from .runkwregister import RUN_KW_REGISTER -from .usererrorhandler import UserErrorHandler -from .userkeyword import UserLibrary IMPORTER = Importer() -class Namespace(object): - _default_libraries = ('BuiltIn', 'Reserved', 'Easter') - _library_import_by_path_endings = ('.py', '.java', '.class', '/', os.sep) +class Namespace: + _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", + ) - def __init__(self, variables, suite, resource): - LOGGER.info("Initializing namespace for test suite '%s'" % suite.longname) + def __init__(self, variables, suite, resource, languages): + 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) + 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 @@ -56,111 +62,139 @@ def handle_imports(self): def _import_default_libraries(self): for name in self._default_libraries: - self.import_library(name, notify=name == 'BuiltIn') + self._import_library(Import(Import.LIBRARY, name), notify=name == "BuiltIn") def _handle_imports(self, import_settings): for item in import_settings: try: if not item.name: - raise DataError('%s setting requires a name' % item.type) + 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 = {'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(Resource(None, name), overwrite=overwrite) - - def _import_resource(self, import_setting, overwrite=False): - path = self._resolve_name(import_setting) + owner, lineno = self._current_owner_and_lineno() + import_ = Import(Import.RESOURCE, name, owner=owner, lineno=lineno) + self._import_resource(import_, overwrite=overwrite) + + def _current_owner_and_lineno(self): + ctx = EXECUTION_CONTEXTS.current + if not ctx.steps: + return None, -1 + owner = ctx.steps[-1][0] + return owner, owner.lineno + + def _import_resource(self, import_, overwrite=False): + path = self._resolve_name(import_) self._validate_not_importing_init_file(path) if overwrite or path not in self._kw_store.resources: - resource = IMPORTER.import_resource(path) - self.variables.set_from_variable_table(resource.variables, overwrite) - user_library = UserLibrary(resource) - self._kw_store.resources[path] = user_library + resource = IMPORTER.import_resource(path, self.languages) + self.variables.set_from_variable_section(resource.variables, overwrite) + self._kw_store.resources[path] = resource self._handle_imports(resource.imports) - LOGGER.imported("Resource", user_library.name, - importer=import_setting.source, - source=path) + LOGGER.resource_import(resource, import_) else: - LOGGER.info("Resource file '%s' already imported by suite '%s'" - % (path, self._suite_name)) + name = self._suite_name + LOGGER.info(f"Resource file '{path}' already imported by suite '{name}'.") def _validate_not_importing_init_file(self, path): name = os.path.splitext(os.path.basename(path))[0] - if name.lower() == '__init__': - raise DataError("Initialization file '%s' cannot be imported as " - "a resource file." % path) + if name.lower() == "__init__": + raise DataError( + f"Initialization file '{path}' cannot be imported as a resource file." + ) def import_variables(self, name, args, overwrite=False): - self._import_variables(Variables(None, name, args), overwrite) + owner, lineno = self._current_owner_and_lineno() + import_ = Import(Import.VARIABLES, name, args, owner=owner, lineno=lineno) + self._import_variables(import_, overwrite=overwrite) - def _import_variables(self, import_setting, overwrite=False): - path = self._resolve_name(import_setting) - args = self._resolve_args(import_setting) + def _import_variables(self, import_, overwrite=False): + path = self._resolve_name(import_) + args = self._resolve_args(import_) if overwrite or (path, args) not in self._imported_variable_files: self._imported_variable_files.add((path, args)) self.variables.set_from_file(path, args, overwrite) - LOGGER.imported("Variables", os.path.basename(path), - args=list(args), - importer=import_setting.source, - source=path) + LOGGER.variables_import( + {"name": os.path.basename(path), "args": args, "source": path}, + importer=import_, + ) else: - msg = "Variable file '%s'" % path + msg = f"Variable file '{path}'" if args: - msg += " with arguments %s" % seq2str2(args) - LOGGER.info("%s already imported by suite '%s'" - % (msg, self._suite_name)) - - def import_library(self, name, args=None, alias=None, notify=True): - self._import_library(Library(None, name, args=args, alias=alias), - notify=notify) - - def _import_library(self, import_setting, notify=True): - name = self._resolve_name(import_setting) - lib = IMPORTER.import_library(name, import_setting.args, - import_setting.alias, self.variables) + msg += f" with arguments {seq2str2(args)}" + LOGGER.info(f"{msg} already imported by suite '{self._suite_name}'.") + + def import_library(self, name, args=(), alias=None): + owner, lineno = self._current_owner_and_lineno() + import_ = Import(Import.LIBRARY, name, args, alias, owner, lineno) + self._import_library(import_) + + def _import_library(self, import_, notify=True): + name = self._resolve_name(import_) + lib = IMPORTER.import_library(name, import_.args, import_.alias, self.variables) if lib.name in self._kw_store.libraries: - LOGGER.info("Test library '%s' already imported by suite '%s'" - % (lib.name, self._suite_name)) + self._duplicate_library_import_warning(lib, import_.source, import_.lineno) return if notify: - LOGGER.imported("Library", lib.name, - args=list(import_setting.args), - originalname=lib.orig_name, - importer=import_setting.source, - source=lib.source) + LOGGER.library_import(lib, import_) self._kw_store.libraries[lib.name] = lib - lib.start_suite() + lib.scope_manager.start_suite() if self._running_test: - lib.start_test() + lib.scope_manager.start_test() - def _resolve_name(self, import_setting): - name = import_setting.name + def _resolve_name(self, setting): + name = setting.name try: name = self.variables.replace_string(name) except DataError as err: - self._raise_replacing_vars_failed(import_setting, err) - return self._get_name(name, import_setting) - - def _raise_replacing_vars_failed(self, import_setting, err): - raise DataError("Replacing variables from setting '%s' failed: %s" - % (import_setting.type, err.message)) - - def _get_name(self, name, import_setting): - if import_setting.type == 'Library' and not self._is_library_by_path(name): - return name - return find_file(name, import_setting.directory, - file_type=import_setting.type) - - def _is_library_by_path(self, path): - return path.lower().endswith(self._library_import_by_path_endings) + self._raise_replacing_vars_failed(setting, err) + if self._is_import_by_path(setting.type, name): + 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.setting_name}' failed: {error}" + ) + + def _is_import_by_path(self, import_type, path): + if import_type == Import.LIBRARY: + return path.lower().endswith(self._library_import_by_path_ends) + if import_type == Import.VARIABLES: + return path.lower().endswith(self._variables_import_by_path_ends) + return True + + def _duplicate_library_import_warning(self, lib, source, lineno): + prev = self._kw_store.libraries[lib.name] + prefix = f"Error in file '{source}' on line {lineno}: " if source else "" + level = "WARN" + if lib.real_name != prev.real_name: + explanation = f"another library with name '{lib.name}'" + elif ( + lib.init.positional != prev.init.positional + or lib.init.named != prev.init.named + ): + explanation = f"library '{lib.name}' with different arguments" + else: + explanation = f"library '{lib.name}' with same arguments" + prefix = "" + level = "INFO" + LOGGER.write( + f"{prefix}Suite '{self._suite_name}' has already imported {explanation}. " + f"This import is ignored.", + level, + ) def _resolve_args(self, import_setting): try: @@ -177,12 +211,12 @@ def start_test(self): self._running_test = True self.variables.start_test() for lib in self.libraries: - lib.start_test() + lib.scope_manager.start_test() def end_test(self): self.variables.end_test() for lib in self.libraries: - lib.end_test() + lib.scope_manager.end_test() self._running_test = True def start_suite(self): @@ -190,7 +224,7 @@ def start_suite(self): def end_suite(self, suite): for lib in self.libraries: - lib.end_suite() + lib.scope_manager.end_suite() if not suite.parent: IMPORTER.close_global_library_listeners() self.variables.end_suite() @@ -201,38 +235,41 @@ 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).instance def get_library_instances(self): - return dict((name, lib.get_instance()) - for name, lib in self._kw_store.libraries.items()) + return {name: lib.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) - library.reload() + def reload_library(self, name_or_instance): + library = self._kw_store.get_library(name_or_instance) + library.create_keywords() return library - def get_runner(self, name): + def get_runner(self, name, recommend_on_failure=True): + # TODO: Consider changing the default value of `recommend_on_failure` to False. + # Recommendations are not needed in all contexts and collecting them has a + # performance effect that has caused issues #4659 and #5051. It is possible to + # opt-out from collecting recommendations, but making it opt-in could be safer. try: - return self._kw_store.get_runner(name) - except DataError as error: - return UserErrorHandler(error, name) + return self._kw_store.get_runner(name, recommend_on_failure) + except DataError as err: + return InvalidKeyword(str(name), error=str(err)).create_runner(name) -class KeywordStore(object): +class KeywordStore: - def __init__(self, resource): - self.user_keywords = UserLibrary(resource, - UserLibrary.TEST_CASE_FILE_TYPE) + def __init__(self, suite_file, languages: Languages): + self.suite_file = suite_file 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: raise DataError("Library can not be None.") - if is_string(name_or_instance): + if isinstance(name_or_instance, str): return self._get_lib_by_name(name_or_instance) return self._get_lib_by_instance(name_or_instance) @@ -246,190 +283,298 @@ def _get_lib_by_name(self, name): def _no_library_found(self, name, multiple=False): if multiple: - raise DataError("Multiple libraries matching '%s' found." % name) - raise DataError("No library '%s' found." % name) + raise DataError(f"Multiple libraries matching '{name}' found.") + raise DataError(f"No library '{name}' found.") def _get_lib_by_instance(self, instance): for lib in self.libraries.values(): - if lib.get_instance(create=False) is instance: + if lib._instance is 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): - msg = "No keyword with name '%s' found." % name - finder = KeywordRecommendationFinder(self.user_keywords, - self.libraries, - self.resources) - recommendations = finder.recommend_similar_keywords(name) - msg = finder.format_recommendations(msg, recommendations) - raise KeywordError(msg) - - def _get_runner(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. " + f"Replace '{name}' with 'FOR', end the loop with 'END', and " + f"remove escaping backslashes." + ) + if name == "\\": + raise KeywordError( + "No keyword with name '\\' found. If it is used inside a for " + "loop, remove escaping backslashes and end the loop with 'END'." + ) + message = f"No keyword with name '{name}' found." + if recommend: + finder = KeywordRecommendationFinder( + self.suite_file, + *self.libraries.values(), + *self.resources.values(), + ) + raise KeywordError(finder.recommend_similar_keywords(name, message)) + raise KeywordError(message) + + def _get_runner(self, name, strip_bdd_prefix=True): if not 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) - if not runner and '.' in name: + raise DataError("Keyword name cannot be empty.") + if not isinstance(name, str): + raise DataError("Keyword name must be a string.") + runner = None + if strip_bdd_prefix: + runner = self._get_bdd_style_runner(name) + if runner: + runner = copy.copy(runner) + runner.name = name + if not runner: + runner = self._get_runner_from_suite_file(name) + if not runner and "." in name: runner = self._get_explicit_runner(name) if not runner: runner = self._get_implicit_runner(name) - if not runner: - runner = self._get_bdd_style_runner(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):]) + # TODO: Consider using 'startswith' instead of regexps for checking does any + # prefix match. That ought to make that check a bit faster (especially if the + # tuple of prefixes is pre-built in 'Languages'), but finding the keyword if + # there's a match can be a bit slower. It also makes it explicit that prefixes + # are constants, not patterns, and allows deprecating 'bdd_prefix_regexp'. + match = self.languages.bdd_prefix_regexp.match(name) + if not match: + return None + runner = self._get_runner(name[match.end() :], strip_bdd_prefix=False) + if runner: + return runner + # Some prefix matched, but there was no matching keyword. Go through all + # prefixes individually to see were there possibly multiple matching ones. + # https://github.com/robotframework/robotframework/issues/5456 + name = " ".join(name.split()).title() # Normalize spaces and case. + for prefix in sorted(self.languages.bdd_prefixes, key=len, reverse=True): + prefix += " " + if name.startswith(prefix): + runner = self._get_runner(name[len(prefix) :], strip_bdd_prefix=False) if runner: - runner = copy.copy(runner) - runner.name = name - return runner + return runner 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 ( + self._get_runner_from_resource_files(name) + or self._get_runner_from_libraries(name) + ) # fmt: skip + + def _get_runner_from_suite_file(self, name): + keywords = self.suite_file.find_keywords(name) + if not keywords: + return None + if len(keywords) > 1: + keywords = self._select_best_matches(keywords) + if len(keywords) > 1: + self._raise_multiple_keywords_found(keywords, name) + runner = keywords[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.keyword.source != caller.source + and self._exists_in_resource_file(name, caller.source) + ): + message = ( + 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 8.0." + ) + runner.pre_run_messages += (Message(message, level="WARN"),) 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) + def _select_best_matches(self, keywords): + # "Normal" matches are considered exact and win over embedded matches. + normal = [kw for kw in keywords if not kw.embedded] + if normal: + return normal + matches = [ + kw for kw in keywords if not self._is_worse_match_than_others(kw, keywords) + ] + return matches or keywords + + def _is_worse_match_than_others(self, candidate, alternatives): + for other in alternatives: + 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(): + if resource.source == source and resource.find_keywords(name): + 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: + keywords = [ + kw for res in self.resources.values() for kw in res.find_keywords(name) + ] + if not keywords: return None - if len(found) > 1: - found = self._get_runner_based_on_search_order(found) - if len(found) == 1: - return found[0] - self._raise_multiple_keywords_found(name, found) + if len(keywords) > 1: + keywords = self._filter_based_on_search_order(keywords) + if len(keywords) > 1: + keywords = self._prioritize_same_file_or_public(keywords) + if len(keywords) > 1: + keywords = self._select_best_matches(keywords) + if len(keywords) > 1: + self._raise_multiple_keywords_found(keywords, name) + return keywords[0].create_runner(name, self.languages) def _get_runner_from_libraries(self, name): - found = [lib.handlers.create_runner(name) for lib in self.libraries.values() - if name in lib.handlers] - if not found: + keywords = [ + kw for lib in self.libraries.values() for kw in lib.find_keywords(name) + ] + if not keywords: 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(name, found) - - def _get_runner_based_on_search_order(self, runners): - 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): - 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 - else: - return [runner1, runner2] - 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] - - def _custom_and_standard_keyword_conflict_warning(self, custom, standard): - custom_with_name = standard_with_name = '' - if custom.library.name != custom.library.orig_name: - custom_with_name = " imported as '%s'" % custom.library.name - if standard.library.name != standard.library.orig_name: - standard_with_name = " imported as '%s'" % standard.library.name - warning = Message("Keyword '%s' found both from a custom test library " - "'%s'%s and a standard library '%s'%s. The custom " - "keyword is used. To select explicitly, and to get " - "rid of this warning, use either '%s' or '%s'." - % (standard.name, - custom.library.orig_name, custom_with_name, - standard.library.orig_name, standard_with_name, - custom.longname, standard.longname), level='WARN') - if custom.pre_run_messages: - custom.pre_run_messages.append(warning) + pre_run_message = None + if len(keywords) > 1: + keywords = self._filter_based_on_search_order(keywords) + if len(keywords) > 1: + keywords = self._select_best_matches(keywords) + if len(keywords) > 1: + keywords, pre_run_message = self._filter_stdlib_handler(keywords) + if len(keywords) > 1: + self._raise_multiple_keywords_found(keywords, name) + runner = keywords[0].create_runner(name, self.languages) + if pre_run_message: + runner.pre_run_messages += (pre_run_message,) + return runner + + def _prioritize_same_file_or_public(self, keywords): + user_keywords = EXECUTION_CONTEXTS.current.user_keywords + if user_keywords: + parent_source = user_keywords[-1].source + matches = [kw for kw in keywords if kw.source == parent_source] + if matches: + return matches + matches = [kw for kw in keywords if not kw.private] + return matches or keywords + + def _filter_based_on_search_order(self, keywords): + for name in self.search_order: + matches = [kw for kw in keywords if eq(name, kw.owner.name)] + if matches: + return matches + return keywords + + def _filter_stdlib_handler(self, keywords): + warning = None + if len(keywords) != 2: + return keywords, warning + stdlibs_without_remote = STDLIBS - {"Remote"} + if keywords[0].owner.real_name in stdlibs_without_remote: + standard, custom = keywords + elif keywords[1].owner.real_name in stdlibs_without_remote: + custom, standard = keywords else: - custom.pre_run_messages = [warning] + return keywords, warning + if not RUN_KW_REGISTER.is_run_keyword(custom.owner.real_name, custom.name): + warning = self._get_conflict_warning(custom, standard) + return [custom], warning + + def _get_conflict_warning(self, custom, standard): + custom_with_name = standard_with_name = "" + if custom.owner.name != custom.owner.real_name: + custom_with_name = f" imported as '{custom.owner.name}'" + if standard.owner.name != standard.owner.real_name: + standard_with_name = f" imported as '{standard.owner.name}'" + return Message( + f"Keyword '{standard.name}' found both from a custom library " + f"'{custom.owner.real_name}'{custom_with_name} and a standard library " + f"'{standard.owner.real_name}'{standard_with_name}. The custom keyword " + f"is used. To select explicitly, and to get rid of this warning, use " + f"either '{custom.full_name}' or '{standard.full_name}'.", + 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(name, found, implicit=False) - return found[0] if found else None - - 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 _raise_multiple_keywords_found(self, name, found, implicit=True): - error = "Multiple keywords with name '%s' found" % name - if implicit: - error += ". Give the full name of the keyword you want to use" - names = sorted(runner.longname for runner in found) - raise KeywordError('\n '.join([error+':'] + names)) - - -class KeywordRecommendationFinder(object): - - def __init__(self, user_keywords, libraries, resources): - self.user_keywords = user_keywords - self.libraries = libraries - self.resources = resources - - def recommend_similar_keywords(self, name): + kws_and_names = [] + for owner_name, kw_name in self._get_owner_and_kw_names(name): + for owner in (*self.libraries.values(), *self.resources.values()): + if eq(owner.name, owner_name): + for kw in owner.find_keywords(kw_name): + kws_and_names.append((kw, kw_name)) + if not kws_and_names: + return None + if len(kws_and_names) == 1: + kw, kw_name = kws_and_names[0] + else: + keywords = [kw for kw, _ in kws_and_names] + matches = self._select_best_matches(keywords) + if len(matches) > 1: + self._raise_multiple_keywords_found(keywords, name, implicit=False) + kw, kw_name = kws_and_names[keywords.index(matches[0])] + return kw.create_runner(kw_name, self.languages) + + def _get_owner_and_kw_names(self, full_name): + tokens = full_name.split(".") + return [ + (".".join(tokens[:index]), ".".join(tokens[index:])) + for index in range(1, len(tokens)) + ] + + def _raise_multiple_keywords_found(self, keywords, name, implicit=True): + if any(kw.embedded for kw in keywords): + 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(kw.full_name for kw in keywords) + raise KeywordError("\n ".join([error + ":", *names])) + + +class KeywordRecommendationFinder: + + def __init__(self, *owners): + self.owners = owners + + def recommend_similar_keywords(self, name, message): """Return keyword names similar to `name`.""" - candidates = self._get_candidates('.' in name) - normalizer = lambda name: candidates.get(name, name).lower().replace( - '_', ' ') - finder = RecommendationFinder(normalizer) - return finder.find_recommendations(name, candidates) + candidates = self._get_candidates(use_full_name="." in name) + finder = RecommendationFinder( + lambda name: normalize(candidates.get(name, name), ignore="_") + ) + return finder.find_and_format( + name, + candidates, + message, + check_missing_argument_separator=True, + ) @staticmethod - def format_recommendations(msg, recommendations): - return RecommendationFinder.format_recommendations( - msg, recommendations) - - def _get_candidates(self, use_full_name): - names = {} - for owner, name in self._get_all_handler_names(): - full_name = '%s.%s' % (owner, name) if owner else name - names[full_name] = full_name if use_full_name else name - return names - - def _get_all_handler_names(self): - """Return a list of `(library_name, handler_name)` tuples.""" - 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)) - # sort handlers to ensure consistent ordering between Jython and Python - return sorted(handlers) + def format_recommendations(message, recommendations): + return RecommendationFinder().format(message, recommendations) + + def _get_candidates(self, use_full_name=False): + candidates = {} + names = sorted( + (owner.name or "", kw.name) + for owner in self.owners + for kw in owner.keywords + ) + for owner, name in names: + full_name = f"{owner}.{name}" if owner else name + candidates[full_name] = full_name if use_full_name else name + return candidates diff --git a/src/robot/running/outputcapture.py b/src/robot/running/outputcapture.py index 4cc585752b8..fc1de79966f 100644 --- a/src/robot/running/outputcapture.py +++ b/src/robot/running/outputcapture.py @@ -14,30 +14,36 @@ # limitations under the License. import sys -from robot.utils import StringIO +from io import StringIO from robot.output import LOGGER -from robot.utils import console_decode, console_encode, JYTHON +from robot.utils import console_decode, console_encode -class OutputCapturer(object): +class OutputCapturer: def __init__(self, library_import=False): - self._library_import = library_import - self._python_out = PythonCapturer(stdout=True) - self._python_err = PythonCapturer(stdout=False) - self._java_out = JavaCapturer(stdout=True) - self._java_err = JavaCapturer(stdout=False) + self.library_import = library_import + self.stdout = None + self.stderr = None + + def start(self): + self.stdout = StreamCapturer(stdout=True) + self.stderr = StreamCapturer(stdout=False) + if self.library_import: + LOGGER.enable_library_import_logging() + + def stop(self): + self._release_and_log() + if self.library_import: + LOGGER.disable_library_import_logging() def __enter__(self): - if self._library_import: - LOGGER.enable_library_import_logging() + self.start() return self def __exit__(self, exc_type, exc_value, exc_trace): - self._release_and_log() - if self._library_import: - LOGGER.disable_library_import_logging() + self.stop() return False def _release_and_log(self): @@ -46,15 +52,16 @@ def _release_and_log(self): LOGGER.log_output(stdout) if stderr: LOGGER.log_output(stderr) - sys.__stderr__.write(console_encode(stderr, stream=sys.__stderr__)) + if sys.__stderr__: + sys.__stderr__.write(console_encode(stderr, stream=sys.__stderr__)) def _release(self): - stdout = self._python_out.release() + self._java_out.release() - stderr = self._python_err.release() + self._java_err.release() + stdout = self.stdout.release() + stderr = self.stderr.release() return stdout, stderr -class PythonCapturer(object): +class StreamCapturer: def __init__(self, stdout=True): if stdout: @@ -94,44 +101,7 @@ def _avoid_at_exit_errors(self, stream): # Avoid ValueError at program exit when logging module tries to call # methods of streams it has intercepted that are already closed. # Which methods are called, and does logging silence possible errors, - # depends on Python/Jython version. For related discussion see + # depends on Python version. For related discussion see # http://bugs.python.org/issue6333 stream.write = lambda s: None stream.flush = lambda: None - - -if not JYTHON: - - class JavaCapturer(object): - - def __init__(self, stdout=True): - pass - - def release(self): - return u'' - -else: - - from java.io import ByteArrayOutputStream, PrintStream - from java.lang import System - - class JavaCapturer(object): - - def __init__(self, stdout=True): - if stdout: - self._original = System.out - self._set_stream = System.setOut - else: - self._original = System.err - self._set_stream = System.setErr - self._bytes = ByteArrayOutputStream() - self._stream = PrintStream(self._bytes, False, 'UTF-8') - self._set_stream(self._stream) - - def release(self): - # Original stream must be restored before closing the current - self._set_stream(self._original) - self._stream.close() - output = self._bytes.toString('UTF-8') - self._bytes.reset() - return output diff --git a/src/robot/running/randomizer.py b/src/robot/running/randomizer.py index 372620df7dc..6dd09c1c44d 100644 --- a/src/robot/running/randomizer.py +++ b/src/robot/running/randomizer.py @@ -24,10 +24,7 @@ def __init__(self, randomize_suites=True, randomize_tests=True, seed=None): self.randomize_suites = randomize_suites self.randomize_tests = randomize_tests self.seed = seed - # Cannot use just Random(seed) due to - # https://ironpython.codeplex.com/workitem/35155 - args = (seed,) if seed is not None else () - self._shuffle = Random(*args).shuffle + self._shuffle = Random(seed).shuffle def start_suite(self, suite): if not self.randomize_suites and not self.randomize_tests: @@ -37,14 +34,15 @@ def start_suite(self, suite): if self.randomize_tests: self._shuffle(suite.tests) if not suite.parent: - suite.metadata['Randomized'] = self._get_message() + suite.metadata["Randomized"] = self._get_message() def _get_message(self): - possibilities = {(True, True): 'Suites and tests', - (True, False): 'Suites', - (False, True): 'Tests'} - randomized = (self.randomize_suites, self.randomize_tests) - return '%s (seed %s)' % (possibilities[randomized], self.seed) + randomized = { + (True, True): "Suites and tests", + (True, False): "Suites", + (False, True): "Tests", + }[(self.randomize_suites, self.randomize_tests)] + return f"{randomized} (seed {self.seed})" def visit_test(self, test): pass diff --git a/src/robot/running/resourcemodel.py b/src/robot/running/resourcemodel.py new file mode 100644 index 00000000000..4882385a90c --- /dev/null +++ b/src/robot/running/resourcemodel.py @@ -0,0 +1,531 @@ +# 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 pathlib import Path +from typing import Any, Final, Iterable, Literal, overload, Sequence, TYPE_CHECKING + +from robot import model +from robot.model import BodyItem, create_fixture, DataDict, ModelObject, Tags +from robot.output import LOGGER +from robot.utils import NOT_SET, setter, unescape as unescape_util + +from .arguments import ArgInfo, ArgumentSpec, UserKeywordArgumentParser +from .keywordfinder import KeywordFinder +from .keywordimplementation import KeywordImplementation +from .model import Body, BodyItemParent, Keyword, TestSuite +from .userkeywordrunner import EmbeddedArgumentsRunner, UserKeywordRunner + +if TYPE_CHECKING: + from robot.conf import LanguagesLike + from robot.parsing import File + + +class ResourceFile(ModelObject): + """Represents a resource file.""" + + repr_args = ("source",) + __slots__ = ("_source", "owner", "doc", "keyword_finder") + + def __init__( + self, + source: "Path | str | None" = None, + owner: "TestSuite | None" = None, + doc: str = "", + ): + self.source = source + self.owner = owner + self.doc = doc + self.keyword_finder = KeywordFinder["UserKeyword"](self) + self.imports = [] + self.variables = [] + self.keywords = [] + + @property + def source(self) -> "Path | None": + if self._source: + return self._source + if self.owner: + return self.owner.source + return None + + @source.setter + def source(self, source: "Path | str | None"): + if isinstance(source, str): + source = Path(source) + self._source = source + + @property + def name(self) -> "str | None": + """Resource file name. + + ``None`` if resource file is part of a suite or if it does not have + :attr:`source`, name of the source file without the extension otherwise. + """ + if self.owner or not self.source: + return None + return self.source.stem + + @setter + def imports(self, imports: Sequence["Import"]) -> "Imports": + return Imports(self, imports) + + @setter + def variables(self, variables: Sequence["Variable"]) -> "Variables": + return Variables(self, variables) + + @setter + 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) + + @overload + def find_keywords(self, name: str, count: Literal[1]) -> "UserKeyword": ... + + @overload + def find_keywords( + self, + name: str, + count: "int | None" = None, + ) -> "list[UserKeyword]": ... + + def find_keywords( + self, + name: str, + count: "int | None" = None, + ) -> "list[UserKeyword] | UserKeyword": + return self.keyword_finder.find(name, count) + + def update_docs(self, unescape: bool = False): + """Parse information from keyword docstring and update it accordingly. + + :param unescape: When true, remove backslash escapes from resource file + and keyword documentation. + + Updates argument, return value and exception documentation as well + as tags. The information is defined in appropriate sections using + the Google Python Style Guide docstring conventions. + + New in Robot Framework 7.5. + """ + if unescape: + self.doc = unescape_util(self.doc) + for kw in self.keywords: + kw.update_docs(unescape) + + def to_dict(self) -> DataDict: + data = {} + if self._source: + data["source"] = str(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(KeywordImplementation): + """Represents a user keyword.""" + + type = KeywordImplementation.USER_KEYWORD + fixture_class = Keyword + __slots__ = ("timeout", "_setup", "_teardown") + + def __init__( + self, + name: str = "", + args: "ArgumentSpec | Sequence[str] | None" = (), + doc: str = "", + tags: "Tags | Sequence[str]" = (), + timeout: "str | None" = None, + lineno: "int | None" = None, + owner: "ResourceFile | None" = None, + parent: "BodyItemParent | None" = None, + error: "str | None" = None, + ): + super().__init__(name, args, doc, tags, lineno, owner, parent, error) + self.timeout = timeout + self._setup = None + self._teardown = None + self.body = [] + + @setter + def args(self, spec: "ArgumentSpec | Sequence[str] | None") -> ArgumentSpec: + if not spec: + spec = ArgumentSpec() + elif not isinstance(spec, ArgumentSpec): + spec = UserKeywordArgumentParser().parse(spec) + spec.name = lambda: self.full_name + return spec + + @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 = None + return self._teardown + + @teardown.setter + def teardown(self, teardown: "Keyword | DataDict | None"): + self._teardown = create_fixture( + self.fixture_class, + teardown, + self, + Keyword.TEARDOWN, + ) + + @property + 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:`` + 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) + + def create_runner( + self, + name: "str | None", + languages: "LanguagesLike" = None, + ) -> "UserKeywordRunner | EmbeddedArgumentsRunner": + if self.embedded: + return EmbeddedArgumentsRunner(self, name) + return UserKeywordRunner(self) + + def bind(self, data: Keyword) -> "UserKeyword": + kw = UserKeyword( + "", + self.args.copy(), + self.doc, + self.tags, + self.timeout, + self.lineno, + self.owner, + data.parent, + self.error, + ) + # Avoid possible errors setting name with invalid embedded args. + kw._name = self._name + kw.embedded = self.embedded + if self.has_setup: + kw.setup = self.setup.to_dict() + if self.has_teardown: + kw.teardown = self.teardown.to_dict() + kw.body = self.body.to_dicts() + return kw + + def to_dict(self) -> DataDict: + data: DataDict = {"name": self.name} + for name, value in [ + ("args", tuple(self._decorate_arg(a) for a in self.args)), + ("doc", self.doc), + ("tags", tuple(self.tags)), + ("timeout", self.timeout), + ("lineno", self.lineno), + ("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() + return data + + def _decorate_arg(self, arg: ArgInfo) -> str: + if arg.kind == arg.VAR_NAMED: + deco = "&" + elif arg.kind in (arg.VAR_POSITIONAL, arg.NAMED_ONLY_MARKER): + deco = "@" + else: + deco = "$" + result = f"{deco}{{{arg.name}}}" + if arg.default is not NOT_SET: + result += f"={arg.default}" + return result + + +class Variable(ModelObject): + repr_args = ("name", "value", "separator") + + def __init__( + self, + name: str = "", + value: Sequence[str] = (), + separator: "str | None" = None, + owner: "ResourceFile | None" = None, + lineno: "int | None" = None, + error: "str | None" = None, + ): + self.name = name + self.value = tuple(value) + self.separator = separator + self.owner = owner + self.lineno = lineno + self.error = error + + @property + def source(self) -> "Path | None": + return self.owner.source if self.owner is not None else None + + 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}: " + f"Setting variable '{self.name}' failed: {message}", + level, + ) + + def to_dict(self) -> DataDict: + data = {"name": self.name, "value": self.value} + if self.lineno: + data["lineno"] = self.lineno + if self.error: + data["error"] = self.error + return data + + def _include_in_repr(self, name: str, value: Any) -> bool: + return not (name == "separator" and value is None) + + +class Import(ModelObject): + """Represents library, resource file or variable file import.""" + + LIBRARY: Final = "LIBRARY" + RESOURCE: Final = "RESOURCE" + VARIABLES: Final = "VARIABLES" + repr_args = ("type", "name", "args", "alias") + + def __init__( + self, + type: Literal["LIBRARY", "RESOURCE", "VARIABLES"], + name: str, + args: Sequence[str] = (), + alias: "str | None" = None, + owner: "ResourceFile | Keyword | 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 = tuple(args) + self.alias = alias + self.owner = owner + self.lineno = lineno + + @property + def source(self) -> "Path | None": + return self.owner.source if self.owner is not None else None + + @property + 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) -> str: + return self.type.title() + + def select(self, library: Any, resource: Any, variables: Any) -> Any: + return { + self.LIBRARY: library, + self.RESOURCE: resource, + self.VARIABLES: variables, + }[self.type] + + 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) + + @classmethod + def from_dict(cls, data) -> "Import": + return cls(**data) + + def to_dict(self) -> DataDict: + data: DataDict = {"type": self.type, "name": self.name} + if 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: str, value: Any) -> bool: + return name in ("type", "name") or value + + +class Imports(model.ItemList): + + def __init__(self, owner: ResourceFile, imports: Sequence[Import] = ()): + super().__init__(Import, {"owner": owner}, items=imports) + + def library( + self, + name: str, + args: Sequence[str] = (), + alias: "str | None" = None, + lineno: "int | None" = None, + ) -> Import: + """Create library import.""" + return self.create(Import.LIBRARY, name, args, alias, lineno=lineno) + + def resource(self, name: str, lineno: "int | None" = None) -> Import: + """Create resource import.""" + return self.create(Import.RESOURCE, name, lineno=lineno) + + def variables( + self, + name: str, + args: Sequence[str] = (), + lineno: "int | None" = None, + ) -> Import: + """Create variables import.""" + return self.create(Import.VARIABLES, name, args, lineno=lineno) + + def create(self, *args, **kwargs) -> Import: + """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) + + +class Variables(model.ItemList[Variable]): + + def __init__(self, owner: ResourceFile, variables: Sequence[Variable] = ()): + super().__init__(Variable, {"owner": owner}, items=variables) + + +class UserKeywords(model.ItemList[UserKeyword]): + + def __init__(self, owner: ResourceFile, keywords: Sequence[UserKeyword] = ()): + self.invalidate_keyword_cache = owner.keyword_finder.invalidate_cache + self.invalidate_keyword_cache() + super().__init__(UserKeyword, {"owner": owner}, items=keywords) + + def append(self, item: "UserKeyword | DataDict") -> UserKeyword: + self.invalidate_keyword_cache() + return super().append(item) + + def extend(self, items: "Iterable[UserKeyword | DataDict]"): + self.invalidate_keyword_cache() + return super().extend(items) + + def __setitem__( + self, index: "int | slice", item: "Iterable[UserKeyword | DataDict]" + ): + self.invalidate_keyword_cache() + return super().__setitem__(index, item) + + def insert(self, index: int, item: "UserKeyword | DataDict"): + self.invalidate_keyword_cache() + super().insert(index, item) + + def clear(self): + self.invalidate_keyword_cache() + super().clear() diff --git a/src/robot/running/runkwregister.py b/src/robot/running/runkwregister.py index f45fbb17462..0337ec9aa0a 100644 --- a/src/robot/running/runkwregister.py +++ b/src/robot/running/runkwregister.py @@ -13,53 +13,80 @@ # See the License for the specific language governing permissions and # limitations under the License. -import inspect import warnings -from robot.utils import NormalizedDict, PY3 +from robot.utils import NormalizedDict -class _RunKeywordRegister(object): +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(self._deprecation_warning(), UserWarning) - if args_to_process is None: - args_to_process = self._get_args_from_method(keyword) - keyword = keyword.__name__ + warnings.warn( + "The API to register run keyword variants and to disable variable " + "resolving in keyword arguments will change in the future. " + "For more information see " + "https://github.com/robotframework/robotframework/issues/2190. " + "Use with `deprecation_warning=False` to avoid this warning.", + UserWarning, + ) if libname not in self._libs: - self._libs[libname] = NormalizedDict(ignore=['_']) - self._libs[libname][keyword] = int(args_to_process) - - def _deprecation_warning(self): - return ("The API to register run keyword variants and to disable " - "variable resolving in keyword arguments will change in Robot " - "Framework 3.1. For more information see issue #2190 <" - "https://github.com/robotframework/robotframework/issues/2190" - ">. Use with 'deprecation_warning=False' to avoid related " - "deprecation warnings.") + self._libs[libname] = NormalizedDict(ignore=["_"]) + 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): - if PY3: - raise RuntimeError('Cannot determine arguments to process ' - 'automatically in Python 3.') - if inspect.ismethod(method): - return method.__code__.co_argcount - 1 - elif inspect.isfunction(method): - return method.__code__.co_argcount - raise ValueError('Needs function or method') - RUN_KW_REGISTER = _RunKeywordRegister() diff --git a/src/robot/running/runner.py b/src/robot/running/runner.py deleted file mode 100644 index fc7b144f58e..00000000000 --- a/src/robot/running/runner.py +++ /dev/null @@ -1,219 +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 ExecutionFailed, ExecutionStatus, DataError, PassExecution -from robot.model import SuiteVisitor -from robot.result import TestSuite, Result -from robot.utils import get_timestamp, is_list_like, NormalizedDict, unic -from robot.variables import VariableScopes - -from .context import EXECUTION_CONTEXTS -from .steprunner import StepRunner -from .namespace import Namespace -from .status import SuiteStatus, TestStatus -from .timeouts import TestTimeout - - -# Some 'extract method' love needed here. Perhaps even 'extract class'. - -class Runner(SuiteVisitor): - - def __init__(self, output, settings): - self.result = None - self._output = output - self._settings = settings - self._variables = VariableScopes(settings) - self._suite = None - self._suite_status = None - self._executed_tests = None - - @property - def _context(self): - return EXECUTION_CONTEXTS.current - - def start_suite(self, suite): - self._output.library_listeners.new_suite_scope() - result = TestSuite(source=suite.source, - name=suite.name, - doc=suite.doc, - metadata=suite.metadata, - starttime=get_timestamp(), - rpa=self._settings.rpa) - if not self.result: - result.set_criticality(self._settings.critical_tags, - self._settings.non_critical_tags) - self.result = Result(root_suite=result, rpa=self._settings.rpa) - self.result.configure(status_rc=self._settings.status_rc, - stat_config=self._settings.statistics_config) - else: - self._suite.suites.append(result) - self._suite = result - self._suite_status = SuiteStatus(self._suite_status, - self._settings.exit_on_failure, - self._settings.exit_on_error, - self._settings.skip_teardown_on_exit) - ns = Namespace(self._variables, result, suite.resource) - ns.start_suite() - ns.variables.set_from_variable_table(suite.resource.variables) - EXECUTION_CONTEXTS.start_suite(result, ns, self._output, - self._settings.dry_run) - self._context.set_suite_variables(result) - if not self._suite_status.failures: - ns.handle_imports() - ns.variables.resolve_delayed() - result.doc = self._resolve_setting(result.doc) - 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.register_error_listener(self._suite_status.error_occurred) - self._run_setup(suite.keywords.setup, self._suite_status) - self._executed_tests = NormalizedDict(ignore='_') - - def _resolve_setting(self, value): - if is_list_like(value): - return self._variables.replace_list(value, ignore_errors=True) - return self._variables.replace_string(value, ignore_errors=True) - - def end_suite(self, suite): - self._suite.message = self._suite_status.message - self._context.report_suite_status(self._suite.status, - self._suite.full_message) - with self._context.suite_teardown(): - failure = self._run_teardown(suite.keywords.teardown, self._suite_status) - if failure: - self._suite.suite_teardown_failed(unic(failure)) - if self._suite.statistics.critical.failed: - self._suite_status.critical_failure_occurred() - self._suite.endtime = get_timestamp() - self._suite.message = self._suite_status.message - self._context.end_suite(ModelCombiner(suite, self._suite)) - self._suite = self._suite.parent - self._suite_status = self._suite_status.parent - self._output.library_listeners.discard_suite_scope() - - def visit_test(self, test): - if test.name in self._executed_tests: - self._output.warn("Multiple test cases with name '%s' executed in " - "test suite '%s'." % (test.name, self._suite.longname)) - self._executed_tests[test.name] = True - result = self._suite.tests.create(name=test.name, - doc=self._resolve_setting(test.doc), - tags=self._resolve_setting(test.tags), - starttime=get_timestamp(), - timeout=self._get_timeout(test)) - self._context.start_test(result) - self._output.start_test(ModelCombiner(test, result)) - status = TestStatus(self._suite_status, result.critical) - if status.exit: - self._add_exit_combine() - result.tags.add('robot:exit') - if not status.failures and not test.name: - status.test_failed('Test case name cannot be empty.') - if not status.failures and not test.keywords.normal: - status.test_failed('Test case contains no keywords.') - self._run_setup(test.keywords.setup, status, result) - try: - if not status.failures: - StepRunner(self._context, - test.template).run_steps(test.keywords.normal) - else: - status.test_failed(status.message) - except PassExecution as exception: - err = exception.earlier_failures - if err: - status.test_failed(err) - else: - result.message = exception.message - except ExecutionStatus as err: - status.test_failed(err) - result.status = status.status - result.message = status.message or result.message - if status.teardown_allowed: - with self._context.test_teardown(result): - failure = self._run_teardown(test.keywords.teardown, status, - result) - if failure and result.critical: - status.critical_failure_occurred() - if not status.failures and result.timeout and result.timeout.timed_out(): - status.test_failed(result.timeout.get_message()) - result.message = status.message - result.status = status.status - result.endtime = get_timestamp() - self._output.end_test(ModelCombiner(test, result)) - self._context.end_test(result) - - def _add_exit_combine(self): - exit_combine = ('NOT robot:exit', '') - if exit_combine not in self._settings['TagStatCombine']: - self._settings['TagStatCombine'].append(exit_combine) - - def _get_timeout(self, test): - if not test.timeout: - return None - return TestTimeout(test.timeout.value, test.timeout.message, - self._variables, rpa=test.parent.rpa) - - def _run_setup(self, setup, status, result=None): - if not status.failures: - exception = self._run_setup_or_teardown(setup) - status.setup_executed(exception) - if result and isinstance(exception, PassExecution): - result.message = exception.message - - def _run_teardown(self, teardown, status, result=None): - if status.teardown_allowed: - exception = self._run_setup_or_teardown(teardown) - status.teardown_executed(exception) - failed = not isinstance(exception, PassExecution) - if result and exception: - result.message = status.message if failed else exception.message - 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: - if self._settings.dry_run: - return None - return err - if name.upper() in ('', 'NONE'): - return None - try: - StepRunner(self._context).run_step(data, name=name) - except ExecutionStatus as err: - return err - - -class ModelCombiner(object): - - def __init__(self, data, result, **priority): - self.data = data - self.result = result - 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) diff --git a/src/robot/running/signalhandler.py b/src/robot/running/signalhandler.py index f346afa185e..da0dfc333ca 100644 --- a/src/robot/running/signalhandler.py +++ b/src/robot/running/signalhandler.py @@ -13,21 +13,15 @@ # See the License for the specific language governing permissions and # limitations under the License. -import sys -from threading import currentThread import signal +import sys +from threading import current_thread, main_thread from robot.errors import ExecutionFailed from robot.output import LOGGER -from robot.utils import JYTHON - -if JYTHON: - from java.lang import IllegalArgumentException -else: - IllegalArgumentException = ValueError -class _StopSignalMonitor(object): +class _StopSignalMonitor: def __init__(self): self._signal_count = 0 @@ -37,16 +31,20 @@ def __init__(self): def __call__(self, signum, frame): self._signal_count += 1 - LOGGER.info('Received signal: %s.' % signum) + LOGGER.info(f"Received signal: {signum}.") if self._signal_count > 1: - sys.__stderr__.write('Execution forcefully stopped.\n') - raise SystemExit() - sys.__stderr__.write('Second signal will force exit.\n') - if self._running_keyword and not JYTHON: + self._write_to_stderr("Execution forcefully stopped.") + raise SystemExit + self._write_to_stderr("Second signal will force exit.") + if self._running_keyword: self._stop_execution_gracefully() + def _write_to_stderr(self, message): + if sys.__stderr__: + sys.__stderr__.write(message + "\n") + def _stop_execution_gracefully(self): - raise ExecutionFailed('Execution terminated by signal', exit=True) + raise ExecutionFailed("Execution terminated by signal", exit=True) def __enter__(self): if self._can_register_signal: @@ -57,27 +55,29 @@ def __enter__(self): return self def __exit__(self, *exc_info): + self._signal_count = 0 if self._can_register_signal: signal.signal(signal.SIGINT, self._orig_sigint or signal.SIG_DFL) signal.signal(signal.SIGTERM, self._orig_sigterm or signal.SIG_DFL) @property def _can_register_signal(self): - return signal and currentThread().getName() == 'MainThread' + return signal and current_thread() is main_thread() def _register_signal_handler(self, signum): try: signal.signal(signum, self) - except (ValueError, IllegalArgumentException) as err: - # IllegalArgumentException due to http://bugs.jython.org/issue1729 - self._warn_about_registeration_error(signum, err) - - def _warn_about_registeration_error(self, signum, err): - name, ctrlc = {signal.SIGINT: ('INT', 'or with Ctrl-C '), - signal.SIGTERM: ('TERM', '')}[signum] - LOGGER.warn('Registering signal %s failed. Stopping execution ' - 'gracefully with this signal %sis not possible. ' - 'Original error was: %s' % (name, ctrlc, err)) + except ValueError as err: + if signum == signal.SIGINT: + name = "INT" + or_ctrlc = "or with Ctrl-C " + else: + name = "TERM" + or_ctrlc = "" + LOGGER.warn( + f"Registering signal {name} failed. Stopping execution gracefully with " + f"this signal {or_ctrlc}is not possible. Original error was: {err}" + ) def start_running_keyword(self, in_teardown): self._running_keyword = True diff --git a/src/robot/running/status.py b/src/robot/running/status.py index 4cbb1b66176..6973e8c1f28 100644 --- a/src/robot/running/status.py +++ b/src/robot/running/status.py @@ -13,27 +13,37 @@ # See the License for the specific language governing permissions and # limitations under the License. -from robot.errors import ExecutionFailed, PassExecution -from robot.utils import html_escape, py2to3, unic +from abc import ABC +from robot.errors import PassExecution +from robot.model import TagPatterns +from robot.utils import html_escape, plural_or_not as s, seq2str, test_or_task -@py2to3 -class Failure(object): + +class Failure: def __init__(self): self.setup = None self.test = None self.teardown = None + self.setup_skipped = None + self.test_skipped = None + self.teardown_skipped = None - def __nonzero__(self): - return bool(self.setup or self.test or self.teardown) + def __bool__(self): + return ( + self.setup is not None + or self.test is not None + or self.teardown is not None + or self.setup_skipped is not None + or self.test_skipped is not None + or self.teardown_skipped is not None + ) -@py2to3 -class Exit(object): +class Exit: - def __init__(self, failure_mode=False, error_mode=False, - skip_teardown_mode=False): + def __init__(self, failure_mode=False, error_mode=False, skip_teardown_mode=False): self.failure_mode = failure_mode self.error_mode = error_mode self.skip_teardown_mode = skip_teardown_mode @@ -41,10 +51,10 @@ def __init__(self, failure_mode=False, error_mode=False, self.error = False self.fatal = False - def failure_occurred(self, failure=None, critical=False): - if isinstance(failure, ExecutionFailed) and failure.exit: + def failure_occurred(self, fatal=False, suite_setup=False): + if fatal: self.fatal = True - if critical and self.failure_mode: + if self.failure_mode and not suite_setup: self.failure = True def error_occurred(self): @@ -55,34 +65,58 @@ def error_occurred(self): def teardown_allowed(self): return not (self.skip_teardown_mode and self) - def __nonzero__(self): - return self.failure or self.error or self.fatal + def __bool__(self): + return bool(self.failure or self.error or self.fatal) -class _ExecutionStatus(object): +class ExecutionStatus(ABC): - def __init__(self, parent=None, *exit_modes): + def __init__(self, parent, exit=None): self.parent = parent - self.children = [] + self.exit = exit if exit is not None else parent.exit self.failure = Failure() - self.exit = parent.exit if parent else Exit(*exit_modes) + self.skipped = False self._teardown_allowed = False - if parent: - parent.children.append(self) - def setup_executed(self, failure=None): - if failure and not isinstance(failure, PassExecution): - self.failure.setup = unic(failure) - self.exit.failure_occurred(failure) - self._teardown_allowed = True + @property + def failed(self): + return bool(self.parent and self.parent.failed or self.failure or self.exit) - def teardown_executed(self, failure=None): - if failure and not isinstance(failure, PassExecution): - self.failure.teardown = unic(failure) - self.exit.failure_occurred(failure) + @property + def passed(self): + return not self.failed + + def setup_executed(self, error=None): + if error and not isinstance(error, PassExecution): + msg = str(error) + if error.skip: + self.failure.setup_skipped = msg + self.skipped = True + elif self._skip_on_failure(): + self.failure.test = self._skip_on_fail_msg(f"Setup failed:\n{msg}") + self.skipped = True + else: + self.failure.setup = msg + self.exit.failure_occurred( + error.exit, suite_setup=isinstance(self, SuiteStatus) + ) + self._teardown_allowed = True - def critical_failure_occurred(self): - self.exit.failure_occurred(critical=True) + def teardown_executed(self, error=None): + if error and not isinstance(error, PassExecution): + msg = str(error) + if error.skip: + self.failure.teardown_skipped = msg + self.skipped = True + elif self._skip_on_failure(): + self.failure.test = self._skip_on_fail_msg(f"Teardown failed:\n{msg}") + self.skipped = True + else: + self.failure.teardown = msg + self.exit.failure_occurred(error.exit) + + def failure_occurred(self): + self.exit.failure_occurred() def error_occurred(self): self.exit.error_occurred() @@ -92,24 +126,26 @@ def teardown_allowed(self): return self.exit.teardown_allowed and self._teardown_allowed @property - def failures(self): - return bool(self.parent and self.parent.failures or - self.failure or self.exit) + def status(self): + if self.skipped or (self.parent and self.parent.skipped): + return "SKIP" + if self.failed: + return "FAIL" + return "PASS" - def _parent_failures(self): - return self.parent and self.parent.failures + def _skip_on_failure(self): + return False - @property - def status(self): - return 'FAIL' if self.failures else 'PASS' + def _skip_on_fail_msg(self, msg): + return msg @property def message(self): if self.failure or self.exit: return self._my_message() - if self.parent and self.parent.failures: + if self.parent and not self.parent.passed: return self._parent_message() - return '' + return "" def _my_message(self): raise NotImplementedError @@ -118,41 +154,95 @@ def _parent_message(self): return ParentMessage(self.parent).message -class SuiteStatus(_ExecutionStatus): +class SuiteStatus(ExecutionStatus): - def __init__(self, parent=None, exit_on_failure_mode=False, - exit_on_error_mode=False, - skip_teardown_on_exit_mode=False): - _ExecutionStatus.__init__(self, parent, exit_on_failure_mode, - exit_on_error_mode, - skip_teardown_on_exit_mode) + def __init__( + self, + parent=None, + exit_on_failure=False, + exit_on_error=False, + skip_teardown_on_exit=False, + ): + if parent is None: + exit = Exit(exit_on_failure, exit_on_error, skip_teardown_on_exit) + else: + exit = None + super().__init__(parent, exit) def _my_message(self): return SuiteMessage(self).message -class TestStatus(_ExecutionStatus): - - def __init__(self, parent, critical): - _ExecutionStatus.__init__(self, parent) - self.exit = parent.exit - self._critical = critical +class TestStatus(ExecutionStatus): + + def __init__(self, parent, test, skip_on_failure=(), rpa=False): + super().__init__(parent) + self.test = test + self.skip_on_failure_tags = TagPatterns( + skip_on_failure, "finding tests to be skipped on failure based on tags" + ) + self.rpa = rpa + + def test_failed(self, message=None, error=None): + if error is not None: + message = str(error) + skip = error.skip + fatal = error.exit or self.test.tags.robot("exit-on-failure") + else: + skip = fatal = False + if skip: + self.test_skipped(message) + elif self._skip_on_failure(): + self.failure.test = self._skip_on_fail_msg(message) + self.skipped = True + else: + self.failure.test = message + self.exit.failure_occurred(fatal) + + def test_skipped(self, message): + self.skipped = True + self.failure.test_skipped = message - def test_failed(self, failure): - self.failure.test = unic(failure) - self.exit.failure_occurred(failure, self._critical) + @property + def skip_on_failure_after_tag_changes(self): + if not self.skipped and self.failed and self._skip_on_failure(): + self.failure.test = self._skip_on_fail_msg(self.failure.test) + self.skipped = True + return True + return False + + def _skip_on_failure(self): + tags = self.test.tags + return tags.robot("skip-on-failure") or self.skip_on_failure_tags.match(tags) + + def _skip_on_fail_msg(self, fail_msg): + if self.test.tags.robot("skip-on-failure"): + tags = ["robot:skip-on-failure"] + kind = "tag" + else: + tags = self.skip_on_failure_tags + kind = "tag" if tags.is_constant else "tag pattern" + return test_or_task( + f"Failed {{test}} skipped using {seq2str(tags)} {kind}{s(tags)}.\n\n" + f"Original failure:\n{fail_msg}", + rpa=self.rpa, + ) def _my_message(self): return TestMessage(self).message -class _Message(object): - setup_message = NotImplemented - teardown_message = NotImplemented - also_teardown_message = NotImplemented +class Message(ABC): + setup_message = "" + setup_skipped_message = "" + teardown_skipped_message = "" + teardown_message = "" + also_teardown_message = "" + also_teardown_skip_message = "" def __init__(self, status): self.failure = status.failure + self.skipped = status.skipped @property def message(self): @@ -160,52 +250,67 @@ def message(self): return self._get_message_after_teardown(message) def _get_message_before_teardown(self): + if self.failure.setup_skipped: + return self._format_setup_or_teardown_message( + self.setup_skipped_message, self.failure.setup_skipped + ) if self.failure.setup: - return self._format_setup_or_teardown_message(self.setup_message, - self.failure.setup) - return self.failure.test or '' + return self._format_setup_or_teardown_message( + self.setup_message, self.failure.setup + ) + return self.failure.test_skipped or self.failure.test or "" def _format_setup_or_teardown_message(self, prefix, message): - if message.startswith('*HTML*'): - prefix = '*HTML* ' + prefix + if message.startswith("*HTML*"): + prefix = "*HTML* " + prefix message = message[6:].lstrip() return prefix % message def _get_message_after_teardown(self, message): - if not self.failure.teardown: + if not (self.failure.teardown or self.failure.teardown_skipped): return message if not message: - return self._format_setup_or_teardown_message(self.teardown_message, - self.failure.teardown) + if self.failure.teardown: + prefix, msg = self.teardown_message, self.failure.teardown + else: + prefix, msg = ( + self.teardown_skipped_message, + self.failure.teardown_skipped, + ) + return self._format_setup_or_teardown_message(prefix, msg) return self._format_message_with_teardown_message(message) def _format_message_with_teardown_message(self, message): - teardown = self.failure.teardown - if teardown.startswith('*HTML*'): + teardown = self.failure.teardown or self.failure.teardown_skipped + if teardown.startswith("*HTML*"): teardown = teardown[6:].lstrip() - if not message.startswith('*HTML*'): - message = '*HTML* ' + html_escape(message) - elif message.startswith('*HTML*'): + if not message.startswith("*HTML*"): + message = "*HTML* " + html_escape(message) + elif message.startswith("*HTML*"): teardown = html_escape(teardown) - return self.also_teardown_message % (message, teardown) - - -class TestMessage(_Message): - setup_message = 'Setup failed:\n%s' - teardown_message = 'Teardown failed:\n%s' - also_teardown_message = '%s\n\nAlso teardown failed:\n%s' - exit_on_fatal_message = 'Test execution stopped due to a fatal error.' - exit_on_failure_message = \ - 'Critical failure occurred and exit-on-failure mode is in use.' - exit_on_error_message = 'Error occurred and exit-on-error mode is in use.' + if self.failure.teardown: + return self.also_teardown_message % (message, teardown) + return self.also_teardown_skip_message % (teardown, message) + + +class TestMessage(Message): + setup_message = "Setup failed:\n%s" + teardown_message = "Teardown failed:\n%s" + setup_skipped_message = "%s" + teardown_skipped_message = "%s" + also_teardown_message = "%s\n\nAlso teardown failed:\n%s" + also_teardown_skip_message = "Skipped in teardown:\n%s\n\nEarlier message:\n%s" + exit_on_fatal_message = "Test execution stopped due to a fatal error." + exit_on_failure_message = "Failure occurred and exit-on-failure mode is in use." + exit_on_error_message = "Error occurred and exit-on-error mode is in use." def __init__(self, status): - _Message.__init__(self, status) + super().__init__(status) self.exit = status.exit @property def message(self): - message = super(TestMessage, self).message + message = super().message if message: return message if self.exit.failure: @@ -214,21 +319,28 @@ def message(self): return self.exit_on_fatal_message if self.exit.error: return self.exit_on_error_message - return '' + return "" -class SuiteMessage(_Message): - setup_message = 'Suite setup failed:\n%s' - teardown_message = 'Suite teardown failed:\n%s' - also_teardown_message = '%s\n\nAlso suite teardown failed:\n%s' +class SuiteMessage(Message): + setup_message = "Suite setup failed:\n%s" + setup_skipped_message = "Skipped in suite setup:\n%s" + teardown_skipped_message = "Skipped in suite teardown:\n%s" + teardown_message = "Suite teardown failed:\n%s" + also_teardown_message = "%s\n\nAlso suite teardown failed:\n%s" + also_teardown_skip_message = ( + "Skipped in suite teardown:\n%s\n\nEarlier message:\n%s" + ) class ParentMessage(SuiteMessage): - setup_message = 'Parent suite setup failed:\n%s' - teardown_message = 'Parent suite teardown failed:\n%s' - also_teardown_message = '%s\n\nAlso parent suite teardown failed:\n%s' + setup_message = "Parent suite setup failed:\n%s" + setup_skipped_message = "Skipped in parent suite setup:\n%s" + teardown_skipped_message = "Skipped in parent suite teardown:\n%s" + teardown_message = "Parent suite teardown failed:\n%s" + also_teardown_message = "%s\n\nAlso parent suite teardown failed:\n%s" def __init__(self, status): - while status.parent and status.parent.failures: + while status.parent and status.parent.failed: status = status.parent - SuiteMessage.__init__(self, status) + super().__init__(status) diff --git a/src/robot/running/statusreporter.py b/src/robot/running/statusreporter.py index 64d1b4ee6bf..693db63f889 100644 --- a/src/robot/running/statusreporter.py +++ b/src/robot/running/statusreporter.py @@ -13,49 +13,78 @@ # See the License for the specific language governing permissions and # limitations under the License. -from robot.errors import (ExecutionFailed, ExecutionStatus, DataError, - HandlerExecutionFailed, KeywordError, VariableError) -from robot.utils import ErrorDetails, get_timestamp +from datetime import datetime +from robot.errors import ( + BreakLoop, ContinueLoop, DataError, ExecutionFailed, ExecutionStatus, + HandlerExecutionFailed, ReturnFromKeyword +) +from robot.utils import ErrorDetails -class StatusReporter(object): - def __init__(self, context, result, dry_run_lib_kw=False): - self._context = context - self._result = result - self._pass_status = 'PASS' if not dry_run_lib_kw else 'NOT_RUN' - self._test_passed = None +class StatusReporter: + + def __init__( + self, + data, + result, + context, + run=True, + suppress=False, + implementation=None, + ): + self.data = data + self.result = result + self.implementation = implementation + self.context = context + if run: + self.pass_status = result.PASS + result.status = result.NOT_SET + else: + self.pass_status = result.status = result.NOT_RUN + self.suppress = suppress + self.initial_test_status = None def __enter__(self): - if self._context.test: - self._test_passed = self._context.test.passed - self._result.starttime = get_timestamp() - self._context.start_keyword(self._result) - self._warn_if_deprecated(self._result.doc, self._result.name) + context = self.context + result = self.result + 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, self.implementation) + if result.type in result.KEYWORD_TYPES: + 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)) + if doc.startswith("*DEPRECATED") and "*" in doc[1:]: + message = " " + doc.split("*", 2)[-1].strip() + self.context.warn(f"Keyword '{name}' is deprecated.{message}") - def __exit__(self, exc_type, exc_val, exc_tb): - context = self._context - result = self._result - failure = self._get_failure(exc_type, exc_val, exc_tb, context) + def __exit__(self, exc_type, exc_value, exc_traceback): + context = self.context + result = self.result + failure = self._get_failure(exc_value, context) if failure is None: - result.status = self._pass_status + result.status = self.pass_status else: result.status = failure.status - if result.type == result.TEARDOWN_TYPE: + if not isinstance(failure, (BreakLoop, ContinueLoop, ReturnFromKeyword)): result.message = failure.message - if context.test: - context.test.passed = self._test_passed and result.passed - result.endtime = get_timestamp() - context.end_keyword(result) - if failure is not exc_val: + if self.initial_test_status == "PASS" and result.status != "NOT RUN": + context.test.status = result.status + result.elapsed_time = datetime.now() - result.start_time + orig_status = (result.status, result.message) + context.end_body_item(self.data, result, self.implementation) + if orig_status != (result.status, result.message): + if result.passed or result.not_run: + return True + raise ExecutionFailed(result.message, skip=result.skipped) + if failure is not exc_value and not self.suppress: raise failure + return self.suppress - def _get_failure(self, exc_type, exc_value, exc_tb, context): + def _get_failure(self, exc_value, context): if exc_value is None: return None if isinstance(exc_value, ExecutionStatus): @@ -63,13 +92,15 @@ 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) - exc_info = (exc_type, exc_value, exc_tb) - failure = HandlerExecutionFailed(ErrorDetails(exc_info)) + return ExecutionFailed(msg, syntax=exc_value.syntax) + error = ErrorDetails(exc_value) + failure = HandlerExecutionFailed(error) if failure.timeout: context.timeout_occurred = True - context.fail(failure.full_message) - if failure.traceback: - context.debug(failure.traceback) + if failure.skip: + context.skip(error.message) + else: + context.fail(error.message) + if error.traceback: + context.debug(error.traceback) return failure diff --git a/src/robot/running/steprunner.py b/src/robot/running/steprunner.py deleted file mode 100644 index a34a95c1cbd..00000000000 --- a/src/robot/running/steprunner.py +++ /dev/null @@ -1,272 +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 (ExecutionFailed, ExecutionFailures, ExecutionPassed, - ExitForLoop, ContinueForLoop, DataError) -from robot.result import Keyword as KeywordResult -from robot.utils import (format_assign_message, frange, get_error_message, - is_list_like, is_number, plural_or_not as s, type_name) -from robot.variables import is_scalar_var - -from .statusreporter import StatusReporter - - -class StepRunner(object): - - def __init__(self, context, templated=False): - self._context = context - self._templated = bool(templated) - - def run_steps(self, steps): - errors = [] - for step in steps: - try: - self.run_step(step) - 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.in_teardown, - self._templated, - self._context.dry_run): - break - if errors: - raise ExecutionFailures(errors) - - def run_step(self, step, name=None): - context = self._context - if step.type == step.FOR_LOOP_TYPE: - runner = ForRunner(context, self._templated, step.flavor) - return runner.run(step) - runner = context.get_runner(name or step.name) - if context.dry_run: - return runner.dry_run(step, context) - return runner.run(step, context) - - -def ForRunner(context, templated=False, flavor='IN'): - runners = {'IN': ForInRunner, - 'IN RANGE': ForInRangeRunner, - 'IN ZIP': ForInZipRunner, - 'IN ENUMERATE': ForInEnumerateRunner} - try: - runner = runners[flavor] - except KeyError: - return InvalidForRunner(context, flavor) - return runner(context, templated) - - -class ForInRunner(object): - - def __init__(self, context, templated=False): - self._context = context - self._templated = templated - - def run(self, data, name=None): - result = KeywordResult(kwname=self._get_name(data), - type=data.FOR_LOOP_TYPE) - with StatusReporter(self._context, result): - self._validate(data) - self._run(data) - - def _get_name(self, data): - return '%s %s [ %s ]' % (' | '.join(data.variables), - self._flavor_name(), - ' | '.join(data.values)) - - def _flavor_name(self): - return 'IN' - - def _validate(self, data): - if not data.variables: - raise DataError('FOR loop has no loop variables.') - for var in data.variables: - if not is_scalar_var(var): - raise DataError("Invalid FOR loop variable '%s'." % var) - if not data.values: - raise DataError('FOR loop has no loop values.') - if not data.keywords: - raise DataError('FOR loop contains no keywords.') - - def _run(self, data): - errors = [] - for values in self._get_values_for_one_round(data): - try: - self._run_one_round(data, values) - except ExitForLoop as exception: - if exception.earlier_failures: - errors.extend(exception.earlier_failures.get_errors()) - break - except ContinueForLoop 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.in_teardown, - self._templated, - self._context.dry_run): - break - if errors: - raise ExecutionFailures(errors) - - def _get_values_for_one_round(self, data): - if not self._context.dry_run: - values = self._replace_variables(data) - var_count = self._values_per_iteration(data.variables) - for i in range(0, len(values), var_count): - yield values[i:i+var_count] - else: - yield data.variables - - def _replace_variables(self, data): - values = self._context.variables.replace_list(data.values) - values = self._transform_items(values) - values_per_iteration = self._values_per_iteration(data.variables) - if len(values) % values_per_iteration == 0: - return values - self._raise_wrong_variable_count(values_per_iteration, len(values)) - - 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))) - - def _run_one_round(self, data, values): - name = ', '.join(format_assign_message(var, item) - for var, item in zip(data.variables, values)) - result = KeywordResult(kwname=name, - type=data.FOR_ITEM_TYPE) - for var, value in zip(data.variables, values): - self._context.variables[var] = value - runner = StepRunner(self._context, self._templated) - with StatusReporter(self._context, result): - runner.run_steps(data.keywords) - - def _transform_items(self, items): - return items - - def _values_per_iteration(self, variables): - """ - The number of values per iteration; - used to check if we have (a multiple of this) values. - - This is its own method to support loops like ForInEnumerate - which add/remove items to the pool. - """ - return len(variables) - - -class ForInRangeRunner(ForInRunner): - - def __init__(self, context, templated=False): - super(ForInRangeRunner, self).__init__(context, templated) - - def _flavor_name(self): - return 'IN RANGE' - - def _transform_items(self, items): - try: - items = [self._to_number_with_arithmetics(item) for item in items] - except: - raise DataError('Converting argument of FOR IN RANGE failed: %s.' - % get_error_message()) - if not 1 <= len(items) <= 3: - raise DataError('FOR IN RANGE expected 1-3 arguments, got %d.' - % len(items)) - return frange(*items) - - def _to_number_with_arithmetics(self, item): - if is_number(item): - return item - number = eval(str(item), {}) - if not is_number(number): - raise TypeError("Expected number, got %s." % type_name(item)) - return number - - -class ForInZipRunner(ForInRunner): - - def __init__(self, context, templated=False): - super(ForInZipRunner, self).__init__(context, templated) - - def _flavor_name(self): - return 'IN ZIP' - - def _replace_variables(self, data): - values = super(ForInZipRunner, self)._replace_variables(data) - if len(data.variables) == len(data.values): - return values - raise DataError('FOR IN ZIP expects an equal number of variables and ' - 'iterables. Got %d variable%s and %d iterable%s.' - % (len(data.variables), s(data.variables), - len(data.values), s(data.values))) - - def _transform_items(self, items): - answer = list() - for item in items: - if not is_list_like(item): - raise DataError('FOR IN ZIP items must all be list-like, ' - 'got %s.' % type_name(item)) - for zipped_item in zip(*[list(item) for item in items]): - answer.extend(zipped_item) - return answer - - -class ForInEnumerateRunner(ForInRunner): - - def __init__(self, context, templated=False): - super(ForInEnumerateRunner, self).__init__(context, templated) - - def _flavor_name(self): - return 'IN ENUMERATE' - - def _values_per_iteration(self, variables): - if len(variables) < 2: - raise DataError('FOR IN ENUMERATE expected 2 or more loop ' - 'variables, got %d.' % len(variables)) - return len(variables) - 1 - - def _get_values_for_one_round(self, data): - parent = super(ForInEnumerateRunner, self) - for index, values in enumerate(parent._get_values_for_one_round(data)): - yield [index] + values - - 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 variable%s but %d value%s.' - % (variables, s(variables), values, s(values))) - - -class InvalidForRunner(ForInRunner): - """Used to send an error from ForRunner() if it sees an unexpected error. - - We can't simply throw a DataError from ForRunner() because that happens - outside the "with StatusReporter(...)" blocks. - """ - - def __init__(self, context, flavor): - super(InvalidForRunner, self).__init__(context, False) - self.flavor = flavor - - def _run(self, data, *args, **kwargs): - raise DataError("Invalid FOR loop type '%s'. Expected 'IN', " - "'IN RANGE', 'IN ZIP', or 'IN ENUMERATE'." - % self.flavor) diff --git a/src/robot/running/suiterunner.py b/src/robot/running/suiterunner.py new file mode 100644 index 00000000000..92370174edf --- /dev/null +++ b/src/robot/running/suiterunner.py @@ -0,0 +1,306 @@ +# 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 datetime import datetime + +from robot.errors import ExecutionStatus, PassExecution +from robot.model import SuiteVisitor, TagPatterns +from robot.result import ( + Keyword as KeywordResult, Result, TestCase as TestResult, TestSuite as SuiteResult +) +from robot.utils import ( + is_list_like, NormalizedDict, plural_or_not as s, seq2str, test_or_task +) +from robot.variables import VariableScopes + +from .bodyrunner import BodyRunner, KeywordRunner +from .context import EXECUTION_CONTEXTS +from .model import Keyword as KeywordData, TestCase as TestData, TestSuite as SuiteData +from .namespace import Namespace +from .status import SuiteStatus, TestStatus +from .timeouts import TestTimeout + + +class SuiteRunner(SuiteVisitor): + + def __init__(self, output, settings): + self.result = None + self.output = output + self.settings = settings + self.variables = VariableScopes(settings) + self.suite_result = None + self.suite_status = None + self.executed = [NormalizedDict(ignore="_")] + self.skipped_tags = TagPatterns( + settings.skip, "finding tests to be skipped based on tags" + ) + + @property + def context(self): + return EXECUTION_CONTEXTS.current + + def start_suite(self, data: SuiteData): + if data.name in self.executed[-1] and data.parent.source: + self.output.warn( + f"Multiple suites with name '{data.name}' executed in " + f"suite '{data.parent.full_name}'." + ) + self.executed[-1][data.name] = True + self.executed.append(NormalizedDict(ignore="_")) + self.output.library_listeners.new_suite_scope() + result = SuiteResult( + source=data.source, + name=data.name, + doc=data.doc, + metadata=data.metadata, + start_time=datetime.now(), + rpa=self.settings.rpa, + ) + if not self.result: + self.result = Result(suite=result, rpa=self.settings.rpa) + self.result.configure( + status_rc=self.settings.status_rc, + stat_config=self.settings.statistics_config, + ) + else: + self.suite_result.suites.append(result) + self.suite_result = result + self.suite_status = SuiteStatus( + self.suite_status, + self.settings.exit_on_failure, + self.settings.exit_on_error, + self.settings.skip_teardown_on_exit, + ) + ns = Namespace(self.variables, result, data.resource, self.settings.languages) + ns.start_suite() + ns.variables.set_from_variable_section(data.resource.variables) + EXECUTION_CONTEXTS.start_suite(result, ns, self.output, self.settings.dry_run) + self.context.set_suite_variables(result) + if not self.suite_status.failed: + ns.handle_imports() + ns.variables.resolve_delayed() + result.doc = self._resolve_setting(result.doc) + 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(data, result) + self.output.register_error_listener(self.suite_status.error_occurred) + self._run_setup( + data, + self.suite_status, + self.suite_result, + run=self._any_test_run(data), + ) + + def _any_test_run(self, suite: SuiteData): + 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") + ): # fmt: skip + return True + return False + + def _resolve_setting(self, value): + if is_list_like(value): + return self.variables.replace_list(value, ignore_errors=True) + return self.variables.replace_string(value, ignore_errors=True) + + def end_suite(self, suite: SuiteData): + self.suite_result.message = self.suite_status.message + self.context.report_suite_status( + self.suite_result.status, self.suite_result.full_message + ) + with self.context.suite_teardown(): + failure = self._run_teardown(suite, self.suite_status, self.suite_result) + if failure: + if failure.skip: + self.suite_result.suite_teardown_skipped(str(failure)) + else: + self.suite_result.suite_teardown_failed(str(failure)) + self.suite_result.end_time = datetime.now() + self.suite_result.message = self.suite_status.message + self.context.end_suite(suite, self.suite_result) + self._clear_result(self.suite_result) + self.executed.pop() + self.suite_result = self.suite_result.parent + self.suite_status = self.suite_status.parent + self.output.library_listeners.discard_suite_scope() + + def visit_test(self, data: TestData): + settings = self.settings + result = self.suite_result.tests.create( + self._resolve_setting(data.name), + self._resolve_setting(data.doc), + self._resolve_setting(data.tags), + self._get_timeout(data), + data.lineno, + metadata=[ + (self._resolve_setting(n), self._resolve_setting(v)) + for n, v in data.metadata.items() + ], + start_time=datetime.now(), + ) + if result.tags.robot("exclude"): + self.suite_result.tests.pop() + return + if result.name in self.executed[-1]: + self.output.warn( + test_or_task( + f"Multiple {{test}}s with name '{result.name}' executed " + f"in suite '{result.parent.full_name}'.", + settings.rpa, + ) + ) + self.executed[-1][result.name] = True + self.context.start_test(data, result) + status = TestStatus( + self.suite_status, + result, + settings.skip_on_failure, + settings.rpa, + ) + if status.exit: + self._add_exit_combine() + result.tags.add("robot:exit") + if status.passed: + if not data.error: + if not data.name: + data.error = "Test name cannot be empty." + elif not data.body: + data.error = "Test cannot be empty." + if data.error: + if settings.rpa: + data.error = data.error.replace("Test", "Task") + status.test_failed(data.error) + elif result.tags.robot("skip"): + status.test_skipped( + self._get_skipped_message(["robot:skip"], settings.rpa) + ) + elif self.skipped_tags.match(result.tags): + status.test_skipped( + self._get_skipped_message(self.skipped_tags, settings.rpa) + ) + self._run_setup(data, status, result) + if status.passed: + runner = BodyRunner(self.context, templated=bool(data.template)) + try: + runner.run(data, result) + except PassExecution as exception: + err = exception.earlier_failures + if err: + status.test_failed(error=err) + else: + result.message = exception.message + except ExecutionStatus as err: + status.test_failed(error=err) + elif status.skipped: + status.test_skipped(status.message) + else: + status.test_failed(status.message) + result.status = status.status + result.message = status.message or result.message + with self.context.test_teardown(result): + self._run_teardown(data, status, result) + if status.passed and result.timeout and result.timeout.timed_out(): + status.test_failed(result.timeout.get_message()) + result.message = status.message + if status.skip_on_failure_after_tag_changes: + result.message = status.message or result.message + result.status = status.status + result.end_time = datetime.now() + failed_before_listeners = result.failed + # TODO: can this be removed to context + self.output.end_test(data, result) + if result.failed and not failed_before_listeners: + status.failure_occurred() + self.context.end_test(result) + self._clear_result(result) + + def _get_skipped_message(self, tags, rpa): + kind = "tag" if getattr(tags, "is_constant", True) else "tag pattern" + return test_or_task( + f"{{Test}} skipped using {seq2str(tags)} {kind}{s(tags)}.", rpa + ) + + def _clear_result(self, result: "SuiteResult | TestResult"): + if result.has_setup: + result.setup = None + if result.has_teardown: + result.teardown = None + if hasattr(result, "body"): + result.body.clear() + + def _add_exit_combine(self): + exit_combine = ("NOT robot:exit", "") + if exit_combine not in self.settings["TagStatCombine"]: + self.settings["TagStatCombine"].append(exit_combine) + + def _get_timeout(self, test: TestData): + if not test.timeout: + return None + return TestTimeout(test.timeout, self.variables, rpa=test.parent.rpa) + + def _run_setup( + self, + item: "SuiteData | TestData", + status: "SuiteStatus | TestStatus", + result: "SuiteResult | TestResult", + run: bool = True, + ): + if run and status.passed: + if item.has_setup: + exception = self._run_setup_or_teardown(item.setup, result.setup) + else: + exception = None + status.setup_executed(exception) + if isinstance(exception, PassExecution) and isinstance(result, TestResult): + result.message = exception.message + elif status.parent and status.parent.skipped: + status.skipped = True + + def _run_teardown( + self, + item: "SuiteData | TestData", + status: "SuiteStatus | TestStatus", + result: "SuiteResult | TestResult", + ): + if not status.teardown_allowed: + return None + if item.has_teardown: + exception = self._run_setup_or_teardown(item.teardown, result.teardown) + else: + exception = None + status.teardown_executed(exception) + failed = exception and not isinstance(exception, PassExecution) + if isinstance(result, TestResult) and exception: + if failed or status.skipped or exception.skip: + result.message = status.message + else: + # Pass execution has been used in teardown, + # and it overrides previous failure message + result.message = exception.message + return exception if failed else None + + def _run_setup_or_teardown(self, data: KeywordData, result: KeywordResult): + try: + KeywordRunner(self.context).run(data, result, setup_or_teardown=True) + except ExecutionStatus as err: + return err diff --git a/src/robot/running/testlibraries.py b/src/robot/running/testlibraries.py index 14dc154d94e..3a3da80b86f 100644 --- a/src/robot/running/testlibraries.py +++ b/src/robot/running/testlibraries.py @@ -14,398 +14,590 @@ # limitations under the License. import inspect -import os +from functools import cached_property, partial +from pathlib import Path +from types import ModuleType +from typing import Any, Literal, overload, Sequence, TypeVar from robot.errors import DataError from robot.libraries import STDLIBS from robot.output import LOGGER -from robot.utils import (getdoc, get_error_details, Importer, is_java_init, - is_java_method, JYTHON, normalize, seq2str2, unic, - is_list_like, PY2, PYPY, type_name) - -from .arguments import EmbeddedArguments -from .context import EXECUTION_CONTEXTS -from .dynamicmethods import (GetKeywordArguments, GetKeywordDocumentation, - GetKeywordNames, GetKeywordTags, RunKeyword) -from .handlers import Handler, InitHandler, DynamicHandler, EmbeddedArgumentsHandler -from .handlerstore import HandlerStore -from .libraryscopes import LibraryScope +from robot.utils import ( + get_error_details, getdoc, Importer, is_dict_like, is_list_like, normalize, + NormalizedDict, seq2str2, setter, type_name +) + +from .arguments import CustomArgumentConverters +from .dynamicmethods import GetKeywordDocumentation, GetKeywordNames, RunKeyword +from .keywordfinder import KeywordFinder +from .librarykeyword import DynamicKeyword, LibraryInit, LibraryKeyword, StaticKeyword +from .libraryscopes import Scope, ScopeManager from .outputcapture import OutputCapturer +Self = TypeVar("Self", bound="TestLibrary") + + +class TestLibrary: + """Represents imported test library.""" + + def __init__( + self, + code: "type | ModuleType", + init: LibraryInit, + name: "str | None" = None, + real_name: "str | None" = None, + source: "Path | None" = None, + logger=LOGGER, + ): + self.code = code + self.init = init + self.init.owner = self + self.instance = None + self.name = name or code.__name__ + self.real_name = real_name or self.name + self.source = source + self._logger = logger + self.keywords: list[LibraryKeyword] = [] + self._has_listeners = None + self.scope_manager = ScopeManager.for_library(self) + self.keyword_finder = KeywordFinder[LibraryKeyword](self) -if JYTHON: - from java.lang import Object -else: - Object = None - - -def TestLibrary(name, args=None, variables=None, create_handlers=True): - if name in STDLIBS: - import_name = 'robot.libraries.' + name - else: - import_name = name - with OutputCapturer(library_import=True): - importer = Importer('test library') - libcode, source = importer.import_class_or_module(import_name, - return_source=True) - libclass = _get_lib_class(libcode) - lib = libclass(libcode, name, args or [], source, variables) - if create_handlers: - lib.create_handlers() - return lib - - -def _get_lib_class(libcode): - if inspect.ismodule(libcode): - return _ModuleLibrary - if GetKeywordNames(libcode): - if RunKeyword(libcode): - return _DynamicLibrary - else: - return _HybridLibrary - return _ClassLibrary + @property + def instance(self) -> Any: + """Current library instance. + With module based libraries this is the module itself. -class _BaseTestLibrary(object): - _failure_level = 'INFO' + With class based libraries this is an instance of the class. Instances are + cleared automatically during execution based on their scope. Accessing this + property creates a new instance if needed. - def __init__(self, libcode, name, args, source, variables): - if os.path.exists(name): - name = os.path.splitext(os.path.basename(os.path.abspath(name)))[0] - self.version = self._get_version(libcode) - self.name = name - self.orig_name = name # Stores original name when importing WITH NAME - self.source = source - self.handlers = HandlerStore(self.name, HandlerStore.TEST_LIBRARY_TYPE) - self.has_listener = None # Set when first instance is created - self._doc = None - self.doc_format = self._get_doc_format(libcode) - self.scope = LibraryScope(libcode, self) - self.init = self._create_init_handler(libcode) - self.positional_args, self.named_args \ - = self.init.resolve_arguments(args, variables) - self._libcode = libcode - self._libinst = None - - def __len__(self): - return len(self.handlers) + :attr:`code` contains the original library code. With module based libraries + it is the same as :attr:`instance`. With class based libraries it is + the library class. + """ + instance = self.code if self._instance is None else self._instance + if self._has_listeners is None: + self._has_listeners = self._instance_has_listeners(instance) + return instance + + @instance.setter + def instance(self, instance: Any): + self._instance = instance @property - def doc(self): - if self._doc is None: - self._doc = getdoc(self.get_instance()) - return self._doc + def listeners(self) -> "list[Any]": + if self._has_listeners is None: + self._has_listeners = self._instance_has_listeners(self.instance) + if self._has_listeners is False: + return [] + listener = self.instance.ROBOT_LIBRARY_LISTENER + return list(listener) if is_list_like(listener) else [listener] - def create_handlers(self): - self._create_handlers(self.get_instance()) - self.reset_instance() + def _instance_has_listeners(self, instance) -> bool: + return getattr(instance, "ROBOT_LIBRARY_LISTENER", None) is not None - def reload(self): - self.handlers = HandlerStore(self.name, HandlerStore.TEST_LIBRARY_TYPE) - self._create_handlers(self.get_instance()) + @property + def converters(self) -> "CustomArgumentConverters | None": + converters = getattr(self.code, "ROBOT_LIBRARY_CONVERTERS", None) + if not converters: + return None + if not is_dict_like(converters): + 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) - def start_suite(self): - self.scope.start_suite() + @property + def doc(self) -> str: + return getdoc(self.instance) - def end_suite(self): - self.scope.end_suite() + @property + def doc_format(self) -> str: + return self._attr("ROBOT_LIBRARY_DOC_FORMAT", upper=True) - def start_test(self): - self.scope.start_test() + @property + def scope(self) -> Scope: + scope = self._attr("ROBOT_LIBRARY_SCOPE", "TEST", upper=True) + if scope == "GLOBAL": + return Scope.GLOBAL + if scope in ("SUITE", "TESTSUITE"): + return Scope.SUITE + return Scope.TEST + + @setter + def source(self, source: "Path | str | None") -> "Path | None": + return Path(source) if source else None - def end_test(self): - self.scope.end_test() + @property + def version(self) -> str: + return self._attr("ROBOT_LIBRARY_VERSION") or self._attr("__version__") - def _get_version(self, libcode): - return self._get_attr(libcode, 'ROBOT_LIBRARY_VERSION') \ - or self._get_attr(libcode, '__version__') + @property + def lineno(self) -> int: + return 1 - def _get_attr(self, object, attr, default='', upper=False): - value = unic(getattr(object, attr, default)) + def _attr(self, name, default="", upper=False) -> str: + value = str(getattr(self.code, name, default)) if upper: - value = normalize(value, ignore='_').upper() + value = normalize(value, ignore="_").upper() return value - def _get_doc_format(self, libcode): - return self._get_attr(libcode, 'ROBOT_LIBRARY_DOC_FORMAT', upper=True) - - def _create_init_handler(self, libcode): - return InitHandler(self, self._resolve_init_method(libcode)) - - def _resolve_init_method(self, libcode): - init = getattr(libcode, '__init__', None) - return init if init and self._valid_init(init) else lambda: None - - def _valid_init(self, method): - # https://bitbucket.org/pypy/pypy/issues/2462/ - if PYPY: - if PY2: - return method.__func__ is not object.__init__.__func__ - return method is not object.__init__ - return (inspect.ismethod(method) or # PY2 - inspect.isfunction(method) or # PY3 - is_java_init(method)) - - def reset_instance(self, instance=None): - prev = self._libinst - if not self.scope.is_global: - self._libinst = instance - return prev - - def get_instance(self, create=True): - if not create: - return self._libinst - if self._libinst is None: - self._libinst = self._get_instance(self._libcode) - if self.has_listener is None: - self.has_listener = bool(self.get_listeners(self._libinst)) - return self._libinst - - def _get_instance(self, libcode): + @classmethod + def from_name( + cls, + name: str, + real_name: "str | None" = None, + args: "Sequence[str] | None" = None, + variables=None, + create_keywords: bool = True, + logger=LOGGER, + ) -> "TestLibrary": + if name in STDLIBS: + import_name = "robot.libraries." + name + else: + import_name = name + if Path(name).exists(): + name = Path(name).stem with OutputCapturer(library_import=True): + importer = Importer("library", logger=logger) + code, source = importer.import_class_or_module( + import_name, return_source=True + ) + return cls.from_code( + code, name, real_name, source, args, variables, create_keywords, logger + ) + + @classmethod + def from_code( + cls, + code: "type | ModuleType", + name: "str | None" = None, + real_name: "str | None" = None, + source: "Path | None" = None, + args: "Sequence[str] | None" = None, + variables=None, + create_keywords: bool = True, + logger=LOGGER, + ) -> "TestLibrary": + if inspect.ismodule(code): + lib = cls.from_module( + code, name, real_name, source, create_keywords, logger + ) + if args: # Resolving arguments reports an error. + lib.init.resolve_arguments(args, variables=variables) + return lib + if args is None: + args = () + return cls.from_class( + code, name, real_name, source, args, variables, create_keywords, logger + ) + + @classmethod + def from_module( + cls, + module: ModuleType, + name: "str | None" = None, + real_name: "str | None" = None, + source: "Path | None" = None, + create_keywords: bool = True, + logger=LOGGER, + ) -> "TestLibrary": + return ModuleLibrary.from_module( + module, name, real_name, source, create_keywords, logger + ) + + @classmethod + def from_class( + cls, + klass: type, + name: "str | None" = None, + real_name: "str | None" = None, + source: "Path | None" = None, + args: Sequence[str] = (), + variables=None, + create_keywords: bool = True, + logger=LOGGER, + ) -> "TestLibrary": + if not GetKeywordNames(klass): + library = ClassLibrary + elif not RunKeyword(klass): + library = HybridLibrary + else: + library = DynamicLibrary + return library.from_class( + klass, name, real_name, source, args, variables, create_keywords, logger + ) + + def create_keywords(self): + raise NotImplementedError + + @overload + def find_keywords( + self, + name: str, + count: Literal[1], + ) -> LibraryKeyword: ... + + @overload + def find_keywords( + self, + name: str, + count: "int | None" = None, + ) -> "list[LibraryKeyword]": ... + + def find_keywords( + self, + name: str, + count: "int | None" = None, + ) -> "list[LibraryKeyword] | LibraryKeyword": + return self.keyword_finder.find(name, count) + + def update_docs(self): + """Parse information from keyword docstring and update it accordingly. + + Updates argument, return value and exception documentation as well + as tags. The information is defined in appropriate sections using + the Google Python Style Guide docstring conventions. + + New in Robot Framework 7.5. + """ + self.init.update_docs() + for kw in self.keywords: + kw.update_docs() + + def copy(self: Self, name: str) -> Self: + lib = type(self)( + self.code, + self.init.copy(), + name, + self.real_name, + self.source, + self._logger, + ) + lib.instance = self.instance + lib.keywords = [kw.copy(owner=lib) for kw in self.keywords] + return lib + + def report_error( + self, + message: str, + details: "str | None" = None, + level: str = "ERROR", + details_level: str = "INFO", + ): + prefix = "Error in" if level in ("ERROR", "WARN") else "In" + self._logger.write(f"{prefix} library '{self.name}': {message}", level) + if details: + self._logger.write(f"Details:\n{details}", details_level) + + +class ModuleLibrary(TestLibrary): + + @property + def scope(self) -> Scope: + return Scope.GLOBAL + + @classmethod + def from_module( + cls, + module: ModuleType, + name: "str | None" = None, + real_name: "str | None" = None, + source: "Path | None" = None, + create_keywords: bool = True, + logger=LOGGER, + ) -> "ModuleLibrary": + library = cls(module, LibraryInit.null(), name, real_name, source, logger) + if create_keywords: + library.create_keywords() + return library + + @classmethod + def from_class(cls, *args, **kws) -> "TestLibrary": + raise TypeError(f"Cannot create '{cls.__name__}' from class.") + + def create_keywords(self): + includes = getattr(self.code, "__all__", None) + StaticKeywordCreator(self, included_names=includes).create_keywords() + + +class ClassLibrary(TestLibrary): + + @property + def instance(self) -> Any: + if self._instance is None: + positional, named = self.init.positional, self.init.named try: - return libcode(*self.positional_args, **dict(self.named_args)) - except: - self._raise_creating_instance_failed() - - def get_listeners(self, libinst=None): - if libinst is None: - libinst = self.get_instance() - listeners = getattr(libinst, 'ROBOT_LIBRARY_LISTENER', None) - if listeners is None: - return [] - if is_list_like(listeners): - return listeners - return [listeners] + with OutputCapturer(library_import=True): + self._instance = self.code(*positional, **named) + except Exception: + message, details = get_error_details() + if positional or named: + args = seq2str2(positional + [f"{n}={named[n]}" for n in named]) + args_text = f"arguments {args}" + else: + args_text = "no arguments" + raise DataError( + f"Initializing library '{self.name}' with {args_text} " + f"failed: {message}\n{details}" + ) + if self._has_listeners is None: + self._has_listeners = self._instance_has_listeners(self._instance) + return self._instance + + @instance.setter + def instance(self, instance): + self._instance = instance + + @property + def lineno(self) -> int: + try: + lines, start_lineno = inspect.getsourcelines(self.code) + except (TypeError, OSError, IOError): + return 1 + for increment, line in enumerate(lines): + if line.strip().startswith("class "): + return start_lineno + increment + return start_lineno + + @classmethod + def from_module(cls, *args, **kws) -> "TestLibrary": + raise TypeError(f"Cannot create '{cls.__name__}' from module.") + + @classmethod + def from_class( + cls, + klass: type, + name: "str | None" = None, + real_name: "str | None" = None, + source: "Path | None" = None, + args: Sequence[str] = (), + variables=None, + create_keywords: bool = True, + logger=LOGGER, + ) -> "ClassLibrary": + init = LibraryInit.from_class(klass) + library = cls(klass, init, name, real_name, source, logger) + positional, named = init.args.resolve(args, variables=variables) + init.positional, init.named = list(positional), dict(named) + if create_keywords: + library.create_keywords() + return library + + def create_keywords(self): + StaticKeywordCreator(self, avoid_properties=True).create_keywords() + + +class HybridLibrary(ClassLibrary): + + def create_keywords(self): + names = DynamicKeywordCreator(self).get_keyword_names() + creator = StaticKeywordCreator(self, getting_method_failed_level="ERROR") + creator.create_keywords(names) + + +class DynamicLibrary(ClassLibrary): + _supports_named_args = None + + @property + def supports_named_args(self) -> bool: + if self._supports_named_args is None: + self._supports_named_args = RunKeyword(self.instance).supports_named_args + return self._supports_named_args + + @property + def doc(self) -> str: + return GetKeywordDocumentation(self.instance)("__intro__") or super().doc + + def create_keywords(self): + DynamicKeywordCreator(self).create_keywords() + - def register_listeners(self): - if self.has_listener: +class KeywordCreator: + + def __init__(self, library: TestLibrary, getting_method_failed_level="INFO"): + self.library = library + self.getting_method_failed_level = getting_method_failed_level + + def get_keyword_names(self) -> "list[str]": + raise NotImplementedError + + def create_keywords(self, names: "list[str] | None" = None): + library = self.library + library.keyword_finder.invalidate_cache() + instance = library.instance + keywords = library.keywords = [] + if names is None: + names = self.get_keyword_names() + seen = NormalizedDict(ignore="_") + for name in names: try: - listeners = EXECUTION_CONTEXTS.current.output.library_listeners - listeners.register(self.get_listeners(), self) + kw = self._create_keyword(instance, name) 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. - LOGGER.error("Registering listeners for library '%s' failed: %s" - % (self.name, err)) - - def unregister_listeners(self, close=False): - if self.has_listener: - listeners = EXECUTION_CONTEXTS.current.output.library_listeners - listeners.unregister(self, close) - - def close_global_listeners(self): - if self.scope.is_global: - for listener in self.get_listeners(): - self._close_listener(listener) - - def _close_listener(self, listener): - method = (getattr(listener, 'close', None) or - getattr(listener, '_close', None)) + self._adding_keyword_failed( + name, err.message, err.details, self.getting_method_failed_level + ) + else: + if not kw: + continue + try: + if kw.embedded: + self._validate_embedded(kw) + else: + self._handle_duplicates(kw, seen) + except DataError as err: + self._adding_keyword_failed(kw.name, err.message, err.details) + else: + keywords.append(kw) + library._logger.debug(f"Created keyword '{kw.name}'.") + + def _create_keyword(self, instance, name) -> "LibraryKeyword | None": + raise NotImplementedError + + def _handle_duplicates(self, kw: LibraryKeyword, seen: NormalizedDict): + if kw.name in seen: + error = "Keyword with same name defined multiple times." + seen[kw.name].error = error + raise DataError(error) + seen[kw.name] = kw + + def _validate_embedded(self, kw: LibraryKeyword): + if len(kw.embedded.args) > kw.args.maxargs: + raise DataError( + "Keyword must accept at least as many positional arguments " + "as it has embedded arguments." + ) + if any(kw.embedded.types): + arg, typ = next( + (a, t) for a, t in zip(kw.embedded.args, kw.embedded.types) if t + ) + raise DataError( + f"Library keywords do not support type information with " + f"embedded arguments like '${{{arg}: {typ}}}'. " + f"Use type hints with function arguments instead." + ) + kw.args.embedded = kw.embedded.args + + def _adding_keyword_failed(self, name, error, details, level="ERROR"): + self.library.report_error( + f"Adding keyword '{name}' failed: {error}", + details, + level=level, + details_level="DEBUG", + ) + + +class StaticKeywordCreator(KeywordCreator): + + def __init__( + self, + library: TestLibrary, + getting_method_failed_level="INFO", + included_names=None, + avoid_properties=False, + ): + super().__init__(library, getting_method_failed_level) + self.included_names = included_names + self.avoid_properties = avoid_properties + + def get_keyword_names(self) -> "list[str]": + instance = self.library.instance try: - if method: - method() - except: + return self._get_names(instance) + except Exception: message, details = get_error_details() - name = getattr(listener, '__name__', None) or type_name(listener) - LOGGER.error("Calling method '%s' of listener '%s' failed: %s" - % (method.__name__, name, message)) - LOGGER.info("Details:\n%s" % details) + raise DataError( + f"Getting keyword names from library '{self.library.name}' " + f"failed: {message}", + details, + ) + + def _get_names(self, instance) -> "list[str]": + names = [] + auto_keywords = getattr(instance, "ROBOT_AUTO_KEYWORDS", True) + included_names = self.included_names + for name in dir(instance): + if self._is_included(name, instance, auto_keywords, included_names): + names.append(name) + return names + + def _is_included(self, name, instance, auto_keywords, included_names) -> bool: + if not ( + auto_keywords + and name[:1] != "_" + or self._is_explicitly_included(name, instance) + ): + return False + return included_names is None or name in included_names - def _create_handlers(self, libcode): + def _is_explicitly_included(self, name, instance) -> bool: try: - names = self._get_handler_names(libcode) - except: - message, details = get_error_details() - raise DataError("Getting keyword names from library '%s' failed: %s" - % (self.name, message), details) - for name in names: - method = self._try_to_get_handler_method(libcode, name) - if method: - handler, embedded = self._try_to_create_handler(name, method) - if handler: - try: - self.handlers.add(handler, embedded) - except DataError as err: - LOGGER.error("Error in test library '%s': " - "Creating keyword '%s' failed: %s" - % (self.name, handler.name, err.message)) - else: - LOGGER.debug("Created keyword '%s'" % handler.name) - - def _get_handler_names(self, libcode): - return [name for name in dir(libcode) - if name[:1] != '_' or self._has_robot_name(name, libcode)] - - def _has_robot_name(self, name, libcode): + candidate = inspect.getattr_static(instance, name) + except AttributeError: # Attribute is dynamic. Try harder. + try: + candidate = getattr(instance, name) + except Exception: # Attribute is invalid. Report. + msg, details = get_error_details() + self._adding_keyword_failed( + name, msg, details, self.getting_method_failed_level + ) + return False + if isinstance(candidate, (classmethod, staticmethod)): + candidate = candidate.__func__ try: - handler = self._get_handler_method(libcode, name) - except DataError: + return hasattr(candidate, "robot_name") + except Exception: return False - return hasattr(handler, 'robot_name') - def _try_to_get_handler_method(self, libcode, name): + def _create_keyword(self, instance, name) -> "StaticKeyword | None": + if self.avoid_properties: + self._pre_validate_method(instance, name) try: - return self._get_handler_method(libcode, name) - # TODO: RF 3.1: Catch only DataError or at least consider others errors. - # Don't want to do that in a minor release. - except: - self._report_adding_keyword_failed(name) - return None - - def _report_adding_keyword_failed(self, name, message=None, details=None, - level=None): - if not message: + method = getattr(instance, name) + except Exception: message, details = get_error_details() - LOGGER.write("Adding keyword '%s' to library '%s' failed: %s" - % (name, self.name, message), level or self._failure_level) - if details: - LOGGER.debug('Details:\n%s' % details) - - def _get_handler_method(self, libcode, name): - method = getattr(libcode, name) - if not inspect.isroutine(method): - raise DataError('Not a method or function') - return method - - def _try_to_create_handler(self, name, method): - try: - handler = self._create_handler(name, method) - except DataError as err: - self._report_adding_keyword_failed(name, err.message, level='ERROR') - return None, False - # TODO: RF 3.1: Catch only DataError or at least consider others errors. - # Don't want to do that in a minor release. - except: - self._report_adding_keyword_failed(name) - return None, False + raise DataError(f"Getting handler method failed: {message}", details) + self._validate_method(method) try: - return self._get_possible_embedded_args_handler(handler) + return StaticKeyword.from_name(name, self.library) except DataError as err: - self._report_adding_keyword_failed(handler.name, err.message, - level='ERROR') - return None, False - - 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) - if embedded: - self._validate_embedded_count(embedded, handler.arguments) - return EmbeddedArgumentsHandler(embedded.name, 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): - msg, 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) - else: - args_text = 'no arguments' - raise DataError("Initializing test library '%s' with %s failed: %s\n%s" - % (self.name, args_text, msg, details)) - - -class _ClassLibrary(_BaseTestLibrary): - - def _get_handler_method(self, libinst, name): - # Type is checked before using getattr to avoid calling properties, - # most importantly bean properties generated by Jython (issue 188). - for item in (libinst,) + inspect.getmro(libinst.__class__): - if item in (object, Object): - continue - if hasattr(item, '__dict__') and name in item.__dict__: - self._validate_handler(item.__dict__[name]) - return getattr(libinst, name) - raise DataError('No non-implicit implementation found') - - def _validate_handler(self, handler): - if not inspect.isroutine(handler): - raise DataError('Not a method or function') - if self._is_implicit_java_or_jython_method(handler): - raise DataError('Implicit methods are ignored') - - def _is_implicit_java_or_jython_method(self, handler): - if not is_java_method(handler): - return False - for signature in handler.argslist[:handler.nargs]: - cls = signature.declaringClass - if not (cls is Object or cls.__module__ == 'org.python.proxies'): - return False - return True - - -class _ModuleLibrary(_BaseTestLibrary): - - def _get_handler_method(self, libcode, name): - method = _BaseTestLibrary._get_handler_method(self, libcode, name) - if hasattr(libcode, '__all__') and name not in libcode.__all__: - raise DataError('Not exposed as a keyword') - return method - - def get_instance(self, create=True): - if not create: - return self._libcode - if self.has_listener is None: - self.has_listener = bool(self.get_listeners(self._libcode)) - return self._libcode - - def _create_init_handler(self, libcode): - return InitHandler(self, lambda: None) + self._adding_keyword_failed(name, err.message, err.details) + return None + def _pre_validate_method(self, instance, name): + try: + candidate = inspect.getattr_static(instance, name) + except AttributeError: # Attribute is dynamic. Cannot pre-validate. + return + if isinstance(candidate, classmethod): + candidate = candidate.__func__ + if isinstance(candidate, cached_property) or not inspect.isroutine(candidate): + raise DataError("Not a method or function.") -class _HybridLibrary(_BaseTestLibrary): - _failure_level = 'ERROR' + def _validate_method(self, candidate): + if not (inspect.isroutine(candidate) or isinstance(candidate, partial)): + raise DataError("Not a method or function.") + if getattr(candidate, "robot_not_keyword", False): + raise DataError("Not exposed as a keyword.") - def _get_handler_names(self, instance): - return GetKeywordNames(instance)() +class DynamicKeywordCreator(KeywordCreator): + library: DynamicLibrary -class _DynamicLibrary(_BaseTestLibrary): - _failure_level = 'ERROR' + def __init__(self, library: "DynamicLibrary | HybridLibrary"): + super().__init__(library, getting_method_failed_level="ERROR") - def __init__(self, libcode, name, args, source, variables=None): - _BaseTestLibrary.__init__(self, libcode, name, args, source, variables) + def get_keyword_names(self) -> "list[str]": + try: + return GetKeywordNames(self.library.instance)() + except DataError as err: + raise DataError( + f"Getting keyword names from library '{self.library.name}' " + f"failed: {err}" + ) - @property - def doc(self): - if self._doc is None: - self._doc = (self._get_kw_doc('__intro__') or - _BaseTestLibrary.doc.fget(self)) - return self._doc - - def _get_kw_doc(self, name): - getter = GetKeywordDocumentation(self.get_instance()) - return getter(name) - - def _get_kw_args(self, name): - getter = GetKeywordArguments(self.get_instance()) - return getter(name) - - def _get_kw_tags(self, name): - getter = GetKeywordTags(self.get_instance()) - return getter(name) - - def _get_handler_names(self, instance): - return GetKeywordNames(instance)() - - def _get_handler_method(self, instance, name): - return RunKeyword(instance) - - def _create_handler(self, name, method): - argspec = self._get_kw_args(name) - tags = self._get_kw_tags(name) - doc = self._get_kw_doc(name) - return DynamicHandler(self, name, method, doc, argspec, tags) - - def _create_init_handler(self, libcode): - docgetter = lambda: self._get_kw_doc('__init__') - return InitHandler(self, self._resolve_init_method(libcode), docgetter) + def _create_keyword(self, instance, name) -> DynamicKeyword: + return DynamicKeyword.from_name(name, self.library) diff --git a/src/robot/running/timeouts/__init__.py b/src/robot/running/timeouts/__init__.py index b3740ce75d0..df2dc2a42c5 100644 --- a/src/robot/running/timeouts/__init__.py +++ b/src/robot/running/timeouts/__init__.py @@ -13,126 +13,4 @@ # See the License for the specific language governing permissions and # limitations under the License. -import time - -from robot.utils import (Sortable, py2to3, secs_to_timestr, timestr_to_secs, - IRONPYTHON, JYTHON, WINDOWS) -from robot.errors import TimeoutError, DataError, FrameworkError - -if JYTHON: - from .jython import Timeout -elif IRONPYTHON: - from .ironpython import Timeout -elif WINDOWS: - from .windows import Timeout -else: - from .posix import Timeout - - -@py2to3 -class _Timeout(Sortable): - - def __init__(self, timeout=None, message='', variables=None): - self.string = timeout or '' - self.message = message - self.secs = -1 - self.starttime = -1 - self.error = None - if variables: - self.replace_variables(variables) - - @property - def active(self): - return self.starttime > 0 - - def replace_variables(self, variables): - try: - self.string = variables.replace_string(self.string) - if not self: - return - self.secs = timestr_to_secs(self.string) - self.string = secs_to_timestr(self.secs) - self.message = variables.replace_string(self.message) - except (DataError, ValueError) as err: - self.secs = 0.000001 # to make timeout active - self.error = (u'Setting %s timeout failed: %s' - % (self.type.lower(), err)) - - def start(self): - if self.secs > 0: - self.starttime = time.time() - - def time_left(self): - if not self.active: - return -1 - elapsed = time.time() - self.starttime - # Timeout granularity is 1ms. Without rounding some timeout tests fail - # intermittently on Windows, probably due to threading.Event.wait(). - return round(self.secs - elapsed, 3) - - def timed_out(self): - return self.active and self.time_left() <= 0 - - def run(self, runnable, args=None, kwargs=None): - if self.error: - raise DataError(self.error) - if not self.active: - raise FrameworkError('Timeout is not active') - timeout = self.time_left() - error = TimeoutError(self._timeout_error, - test_timeout=isinstance(self, TestTimeout)) - if timeout <= 0: - raise error - executable = lambda: runnable(*(args or ()), **(kwargs or {})) - return Timeout(timeout, error).execute(executable) - - def get_message(self): - if not self.active: - return '%s timeout not active.' % self.type - if not self.timed_out(): - return '%s timeout %s active. %s seconds left.' \ - % (self.type, self.string, self.time_left()) - return self._timeout_error - - @property - def _timeout_error(self): - if self.message: - return self.message - return '%s timeout %s exceeded.' % (self.type, self.string) - - def __unicode__(self): - return self.string - - def __nonzero__(self): - return bool(self.string and self.string.upper() != 'NONE') - - @property - def _sort_key(self): - return not self.active, self.time_left() - - def __eq__(self, other): - return self is other - - def __hash__(self): - return id(self) - - -class TestTimeout(_Timeout): - type = 'Test' - _keyword_timeout_occurred = False - - def __init__(self, timeout=None, message='', variables=None, rpa=False): - if rpa: - self.type = 'Task' - _Timeout.__init__(self, timeout, message, variables) - - def set_keyword_timeout(self, timeout_occurred): - if timeout_occurred: - self._keyword_timeout_occurred = True - - def any_timeout_occurred(self): - return self.timed_out() or self._keyword_timeout_occurred - - -class KeywordTimeout(_Timeout): - type = 'Keyword' +from .timeout import KeywordTimeout as KeywordTimeout, TestTimeout as TestTimeout diff --git a/src/robot/running/timeouts/ironpython.py b/src/robot/running/timeouts/ironpython.py deleted file mode 100644 index faed0c0d4c8..00000000000 --- a/src/robot/running/timeouts/ironpython.py +++ /dev/null @@ -1,58 +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 sys -import threading - -from System.Threading import Thread, ThreadStart - - -class Timeout(object): - - def __init__(self, timeout, error): - self._timeout = timeout - self._error = error - - def execute(self, runnable): - runner = Runner(runnable) - thread = Thread(ThreadStart(runner)) - thread.IsBackground = True - thread.Start() - if not thread.Join(self._timeout * 1000): - thread.Abort() - raise self._error - return runner.get_result() - - -class Runner(object): - - def __init__(self, runnable): - self._runnable = runnable - self._result = None - self._error = None - - def __call__(self): - threading.currentThread().setName('RobotFrameworkTimeoutThread') - try: - self._result = self._runnable() - except: - self._error = sys.exc_info() - - def get_result(self): - if not self._error: - return self._result - # `exec` used to avoid errors with easy_install on Python 3: - # https://github.com/robotframework/robotframework/issues/2785 - exec('raise self._error[0], self._error[1], self._error[2]') diff --git a/src/robot/running/timeouts/jython.py b/src/robot/running/timeouts/jython.py deleted file mode 100644 index 52e32a0a85e..00000000000 --- a/src/robot/running/timeouts/jython.py +++ /dev/null @@ -1,57 +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 sys - -from java.lang import Thread, Runnable - - -class Timeout(object): - - def __init__(self, timeout, error): - self._timeout = timeout - self._error = error - - def execute(self, runnable): - runner = Runner(runnable) - thread = Thread(runner, name='RobotFrameworkTimeoutThread') - thread.setDaemon(True) - thread.start() - thread.join(int(self._timeout * 1000)) - if thread.isAlive(): - thread.stop() - raise self._error - return runner.get_result() - - -class Runner(Runnable): - - def __init__(self, runnable): - self._runnable = runnable - self._result = None - self._error = None - - def run(self): - try: - self._result = self._runnable() - except: - self._error = sys.exc_info() - - def get_result(self): - if not self._error: - return self._result - # `exec` used to avoid errors with easy_install on Python 3: - # https://github.com/robotframework/robotframework/issues/2785 - exec('raise self._error[0], self._error[1], self._error[2]') diff --git a/src/robot/running/timeouts/nosupport.py b/src/robot/running/timeouts/nosupport.py new file mode 100644 index 00000000000..5943b216c05 --- /dev/null +++ b/src/robot/running/timeouts/nosupport.py @@ -0,0 +1,24 @@ +# 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 DataError + +from .runner import Runner + + +class NoSupportRunner(Runner): + + def _run(self, runnable): + raise DataError("Timeouts are not supported on this platform.") diff --git a/src/robot/running/timeouts/posix.py b/src/robot/running/timeouts/posix.py index 2017e8bd9f4..fcfbd0c5d19 100644 --- a/src/robot/running/timeouts/posix.py +++ b/src/robot/running/timeouts/posix.py @@ -13,16 +13,26 @@ # See the License for the specific language governing permissions and # limitations under the License. -from signal import setitimer, signal, SIGALRM, ITIMER_REAL +from signal import ITIMER_REAL, setitimer, SIG_DFL, SIGALRM, signal +from robot.errors import DataError, TimeoutExceeded -class Timeout(object): +from .runner import Runner - def __init__(self, timeout, error): - self._timeout = timeout - self._error = error - def execute(self, runnable): +class PosixRunner(Runner): + _started = 0 + + def __init__( + self, + timeout: float, + timeout_error: TimeoutExceeded, + data_error: "DataError | None" = None, + ): + super().__init__(timeout, timeout_error, data_error) + self._orig_alrm = None + + def _run(self, runnable): self._start_timer() try: return runnable() @@ -30,11 +40,18 @@ def execute(self, runnable): self._stop_timer() def _start_timer(self): - signal(SIGALRM, self._raise_timeout_error) - setitimer(ITIMER_REAL, self._timeout) + if not self._started: + self._orig_alrm = signal(SIGALRM, self._raise_timeout) + setitimer(ITIMER_REAL, self.timeout) + type(self)._started += 1 - def _raise_timeout_error(self, signum, frame): - raise self._error + def _raise_timeout(self, signum, frame): + self.exceeded = True + if not self.paused: + raise self.timeout_error def _stop_timer(self): - setitimer(ITIMER_REAL, 0) + type(self)._started -= 1 + if not self._started: + setitimer(ITIMER_REAL, 0) + signal(SIGALRM, self._orig_alrm or SIG_DFL) diff --git a/src/robot/running/timeouts/runner.py b/src/robot/running/timeouts/runner.py new file mode 100644 index 00000000000..cf4a203cac5 --- /dev/null +++ b/src/robot/running/timeouts/runner.py @@ -0,0 +1,89 @@ +# 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 collections.abc import Callable, Mapping, Sequence + +from robot.errors import DataError, TimeoutExceeded +from robot.utils import WINDOWS + + +class Runner: + runner_implementation: "type[Runner] | None" = None + + def __init__( + self, + timeout: float, + timeout_error: TimeoutExceeded, + data_error: "DataError | None" = None, + ): + self.timeout = round(timeout, 3) + self.timeout_error = timeout_error + self.data_error = data_error + self.exceeded = False + self.paused = 0 + + @classmethod + def for_platform( + cls, + timeout: float, + timeout_error: TimeoutExceeded, + data_error: "DataError | None" = None, + ) -> "Runner": + runner = cls.runner_implementation + if not runner: + runner = cls.runner_implementation = cls._get_runner_implementation() + return runner(timeout, timeout_error, data_error) + + @classmethod + def _get_runner_implementation(cls) -> "type[Runner]": + if WINDOWS: + from .windows import WindowsRunner + + return WindowsRunner + try: + from .posix import PosixRunner + + return PosixRunner + except ImportError: + from .nosupport import NoSupportRunner + + return NoSupportRunner + + def run( + self, + runnable: "Callable[..., object]", + args: "Sequence | None" = None, + kwargs: "Mapping | None" = None, + ) -> object: + if self.data_error: + raise self.data_error + if self.timeout <= 0: + raise self.timeout_error + try: + return self._run(lambda: runnable(*(args or ()), **(kwargs or {}))) + finally: + if self.exceeded and not self.paused: + raise self.timeout_error from None + + def _run(self, runnable: "Callable[[], object]") -> object: + raise NotImplementedError + + def pause(self): + self.paused += 1 + + def resume(self): + self.paused -= 1 + if self.exceeded and not self.paused: + raise self.timeout_error diff --git a/src/robot/running/timeouts/timeout.py b/src/robot/running/timeouts/timeout.py new file mode 100644 index 00000000000..465b484ce1f --- /dev/null +++ b/src/robot/running/timeouts/timeout.py @@ -0,0 +1,144 @@ +# 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 time +from collections.abc import Callable, Mapping, Sequence + +from robot.errors import DataError, TimeoutExceeded +from robot.utils import secs_to_timestr, Sortable, timestr_to_secs + +from .runner import Runner + + +class Timeout(Sortable): + kind: str + + def __init__( + self, + timeout: "float | str | None" = None, + variables=None, + start: bool = False, + ): + try: + self.timeout = self._parse(timeout, variables) + except (DataError, ValueError) as err: + self.timeout = 0.000001 # to make timeout active + self.string = str(timeout) + self.error = f"Setting {self.kind.lower()} timeout failed: {err}" + else: + self.string = secs_to_timestr(self.timeout) if self.timeout else "NONE" + self.error = None + if start: + self.start() + else: + self.start_time = -1 + + def _parse(self, timeout, variables) -> "float | None": + if not timeout: + return None + if variables: + timeout = variables.replace_string(timeout) + else: + timeout = str(timeout) + if timeout.upper() in ("NONE", ""): + return None + timeout = timestr_to_secs(timeout) + if timeout <= 0: + return None + return timeout + + def start(self): + if self.timeout is None: + raise ValueError("Cannot start inactive timeout.") + self.start_time = time.time() + + def time_left(self) -> float: + if self.start_time < 0: + raise ValueError("Timeout is not started.") + return self.timeout - (time.time() - self.start_time) + + def timed_out(self) -> bool: + return self.time_left() <= 0 + + def get_runner(self) -> Runner: + """Get a runner that can run code with a timeout.""" + timeout_error = TimeoutExceeded( + f"{self.kind.title()} timeout {self} exceeded.", + test_timeout=self.kind != "KEYWORD", + ) + data_error = DataError(self.error) if self.error else None + return Runner.for_platform(self.time_left(), timeout_error, data_error) + + def run( + self, + runnable: "Callable[..., object]", + args: "Sequence | None" = None, + kwargs: "Mapping | None" = None, + ) -> object: + """Convenience method to directly run code with a timeout.""" + return self.get_runner().run(runnable, args, kwargs) + + def get_message(self): + kind = self.kind.title() + if self.start_time < 0: + return f"{kind} timeout not active." + left = self.time_left() + if left > 0: + return f"{kind} timeout {self} active. {left:.3f} seconds left." + return f"{kind} timeout {self} exceeded." + + def __str__(self): + return self.string + + def __bool__(self): + return self.timeout is not None + + @property + def _sort_key(self): + if self.timeout is None: + raise ValueError("Cannot compare inactive timeout.") + return self.time_left() + + def __eq__(self, other): + return self is other + + def __hash__(self): + return id(self) + + +class TestTimeout(Timeout): + kind = "TEST" + _keyword_timeout_occurred = False + + def __init__( + self, + timeout: "float | str | None" = None, + variables=None, + start: bool = False, + rpa: bool = False, + ): + self.kind = "TASK" if rpa else self.kind + super().__init__(timeout, variables, start) + + def set_keyword_timeout(self, timeout_occurred): + if timeout_occurred: + self._keyword_timeout_occurred = True + + def any_timeout_occurred(self): + return self.timed_out() or self._keyword_timeout_occurred + + +class KeywordTimeout(Timeout): + kind = "KEYWORD" diff --git a/src/robot/running/timeouts/windows.py b/src/robot/running/timeouts/windows.py index 008b0e3da35..c60dac72649 100644 --- a/src/robot/running/timeouts/windows.py +++ b/src/robot/running/timeouts/windows.py @@ -15,57 +15,70 @@ import ctypes import time -from threading import current_thread, Lock, Timer +from threading import current_thread, Timer +from robot.errors import DataError, TimeoutExceeded -class Timeout(object): +from .runner import Runner - def __init__(self, timeout, error): + +class WindowsRunner(Runner): + + def __init__( + self, + timeout: float, + timeout_error: TimeoutExceeded, + data_error: "DataError | None" = None, + ): + super().__init__(timeout, timeout_error, data_error) self._runner_thread_id = current_thread().ident - self._timer = Timer(timeout, self._timed_out) - self._error = error - self._timeout_occurred = False - self._finished = False - self._lock = Lock() + self._timeout_pending = False - def execute(self, runnable): + def _run(self, runnable): + timer = Timer(self.timeout, self._timeout_exceeded) + timer.start() try: - self._start_timer() - try: - result = runnable() - finally: - self._cancel_timer() - self._wait_for_raised_timeout() - return result + result = runnable() + except TimeoutExceeded: + self._timeout_pending = False + raise finally: - if self._timeout_occurred: - raise self._error + timer.cancel() + self._wait_for_pending_timeout() + return result - def _start_timer(self): - self._timer.start() + def _timeout_exceeded(self): + self.exceeded = True + if not self.paused: + self._timeout_pending = True + self._raise_async_timeout() - def _cancel_timer(self): - with self._lock: - self._finished = True - self._timer.cancel() + def _raise_async_timeout(self): + # See the following for the original recipe and API docs. + # https://code.activestate.com/recipes/496960-thread2-killable-threads/ + # https://docs.python.org/3/c-api/init.html#c.PyThreadState_SetAsyncExc + tid = ctypes.c_ulong(self._runner_thread_id) + error = ctypes.py_object(type(self.timeout_error)) + modified = ctypes.pythonapi.PyThreadState_SetAsyncExc(tid, error) + # This should never happen. Better anyway to check the return value + # and report the very unlikely error than ignore it. + if modified != 1: + raise ValueError( + f"Expected 'PyThreadState_SetAsyncExc' to return 1, got {modified}." + ) - def _wait_for_raised_timeout(self): - if self._timeout_occurred: - while True: + def _wait_for_pending_timeout(self): + # Wait for asynchronously raised timeout that hasn't yet been received. + # This can happen if a timeout occurs at the same time when the executed + # function returns. If the execution ever gets here, the timeout should + # happen immediately. The while loop shouldn't need a limit, but better + # to have it to avoid a deadlock even if our code had a bug. + if self._timeout_pending: + self._timeout_pending = False + end = time.time() + 1 + while time.time() < end: time.sleep(0) - def _timed_out(self): - with self._lock: - if self._finished: - return - self._timeout_occurred = True - self._raise_timeout() - - def _raise_timeout(self): - # See, for example, http://tomerfiliba.com/recipes/Thread2/ - # for more information about using PyThreadState_SetAsyncExc - tid = ctypes.c_long(self._runner_thread_id) - error = ctypes.py_object(type(self._error)) - while ctypes.pythonapi.PyThreadState_SetAsyncExc(tid, error) > 1: - ctypes.pythonapi.PyThreadState_SetAsyncExc(tid, None) - time.sleep(0) # give time for other threads + def pause(self): + super().pause() + self._wait_for_pending_timeout() diff --git a/src/robot/running/usererrorhandler.py b/src/robot/running/usererrorhandler.py deleted file mode 100644 index 686fbac7ba6..00000000000 --- a/src/robot/running/usererrorhandler.py +++ /dev/null @@ -1,68 +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 Tags -from robot.result import Keyword as KeywordResult - -from .arguments import ArgumentSpec -from .statusreporter import StatusReporter - - -class UserErrorHandler(object): - """Created if creating handlers fail -- running 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 - is created and if it is ever run DataError is raised then. - """ - - def __init__(self, error, name, libname=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. - """ - self.name = name - self.libname = libname - self.error = error - self.arguments = ArgumentSpec() - self.timeout = '' - self.tags = Tags() - - @property - def longname(self): - return '%s.%s' % (self.libname, self.name) if self.libname else self.name - - @property - def doc(self): - return '*Creating keyword failed:* %s' % self.error - - @property - def shortdoc(self): - return self.doc.splitlines()[0] - - def create_runner(self, name): - return self - - def run(self, kw, context): - result = KeywordResult(kwname=self.name, - libname=self.libname, - args=kw.args, - assign=kw.assign, - type=kw.type) - with StatusReporter(context, result): - raise self.error - - dry_run = run diff --git a/src/robot/running/userkeyword.py b/src/robot/running/userkeyword.py deleted file mode 100644 index a250f9a6cc7..00000000000 --- a/src/robot/running/userkeyword.py +++ /dev/null @@ -1,114 +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 os - -from robot.errors import DataError -from robot.output import LOGGER -from robot.utils import getshortdoc, is_string, unic - -from .builder import ResourceFileBuilder -from .arguments import EmbeddedArguments, UserKeywordArgumentParser -from .handlerstore import HandlerStore -from .userkeywordrunner import UserKeywordRunner, EmbeddedArgumentsRunner -from .usererrorhandler import UserErrorHandler - - -class UserLibrary(object): - TEST_CASE_FILE_TYPE = HandlerStore.TEST_CASE_FILE_TYPE - RESOURCE_FILE_TYPE = HandlerStore.RESOURCE_FILE_TYPE - - def __init__(self, source, source_type=RESOURCE_FILE_TYPE): - if is_string(source): - resource = ResourceFileBuilder().build(source) - else: - resource = source - 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.doc = resource.doc - self.handlers = HandlerStore(basename, source_type) - self.source = source - self.source_type = source_type - for kw in resource.keywords: - try: - handler = self._create_handler(kw) - except DataError as error: - handler = UserErrorHandler(error, kw.name, self.name) - self._log_creating_failed(handler, error) - embedded = isinstance(handler, EmbeddedArgumentsHandler) - try: - self.handlers.add(handler, embedded) - except DataError as error: - self._log_creating_failed(handler, error) - - def _create_handler(self, kw): - embedded = EmbeddedArguments(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): - LOGGER.error("Error in %s '%s': Creating keyword '%s' failed: %s" - % (self.source_type.lower(), self.source, - handler.name, error.message)) - - -# TODO: Should be merged with running.model.UserKeyword - -class UserKeywordHandler(object): - - def __init__(self, keyword, libname): - self.name = keyword.name - self.libname = libname - self.doc = unic(keyword.doc) - self.tags = keyword.tags - self.arguments = UserKeywordArgumentParser().parse(tuple(keyword.args), - self.longname) - self._kw = keyword - self.timeout = keyword.timeout - self.keywords = keyword.keywords.normal - self.return_value = tuple(keyword.return_) - self.teardown = keyword.keywords.teardown - - @property - def longname(self): - return '%s.%s' % (self.libname, self.name) if self.libname else self.name - - @property - def shortdoc(self): - return getshortdoc(self.doc) - - def create_runner(self, name): - return UserKeywordRunner(self) - - -class EmbeddedArgumentsHandler(UserKeywordHandler): - - def __init__(self, keyword, libname, embedded): - UserKeywordHandler.__init__(self, keyword, libname) - self.keyword = keyword - self.embedded_name = embedded.name - self.embedded_args = embedded.args - - def matches(self, name): - return self.embedded_name.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 f357d05378b..f6c87d5fb92 100644 --- a/src/robot/running/userkeywordrunner.py +++ b/src/robot/running/userkeywordrunner.py @@ -13,236 +13,293 @@ # See the License for the specific language governing permissions and # limitations under the License. -from itertools import chain +from typing import TYPE_CHECKING -from robot.errors import (ExecutionFailed, ExecutionPassed, ExecutionStatus, - ExitForLoop, ContinueForLoop, DataError, - PassExecution, ReturnFromKeyword, - UserKeywordExecutionFailed, VariableError) +from robot.errors import ( + DataError, ExecutionFailed, 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.variables import is_list_var, VariableAssignment +from robot.utils import DotDict, getshortdoc, prepr +from robot.variables import is_list_variable, VariableAssignment -from .arguments import DefaultValue +from .arguments import ArgumentSpec, DefaultValue +from .bodyrunner import BodyRunner, KeywordRunner +from .model import Keyword as KeywordData from .statusreporter import StatusReporter -from .steprunner import StepRunner from .timeouts import KeywordTimeout +if TYPE_CHECKING: + from .resourcemodel import UserKeyword -class UserKeywordRunner(object): - def __init__(self, handler, name=None): - self._handler = handler - self.name = name or handler.name +class UserKeywordRunner: - @property - def longname(self): - libname = self._handler.libname - return '%s.%s' % (libname, self.name) if libname else self.name + def __init__(self, keyword: "UserKeyword", name: "str | None" = None): + self.keyword = keyword + self.name = name or keyword.name + self.pre_run_messages = () - @property - def libname(self): - return self._handler.libname - - @property - def arguments(self): - return self._handler.arguments - - def run(self, kw, context): - assignment = VariableAssignment(kw.assign) - result = self._get_result(kw, assignment, context.variables) - with StatusReporter(context, result): + def run(self, data: KeywordData, result: KeywordResult, context, run=True): + kw = self.keyword.bind(data) + assignment = VariableAssignment(data.assign) + self._config_result(result, data, kw, assignment, context.variables) + with StatusReporter(data, result, context, run, implementation=kw): + self._validate(kw) + if kw.private: + context.warn_on_invalid_private_call(kw) + if not run: + return None with assignment.assigner(context) as assigner: - return_value = self._run(context, kw.args, result) + return_value = self._run(data, kw, result, context) assigner.assign(return_value) return return_value - def _get_result(self, kw, assignment, variables): - handler = self._handler - 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, - doc=getshortdoc(doc), - args=kw.args, - assign=tuple(assignment), - tags=tags, - type=kw.type) - - def _run(self, context, args, result): + def _config_result( + self, + result: KeywordResult, + data: KeywordData, + kw: "UserKeyword", + assignment, + variables, + ): + args = tuple(data.args) + if data.named_args: + args += tuple(f"{n}={v}" for n, v in data.named_args.items()) + result.config( + name=self.name, + owner=kw.owner.name, + doc=getshortdoc(variables.replace_string(kw.doc, ignore_errors=True)), + args=args, + assign=tuple(assignment), + tags=variables.replace_list(kw.all_tags, ignore_errors=True), + type=data.type, + ) + + def _validate(self, kw: "UserKeyword"): + if kw.error: + raise DataError(kw.error) + if not kw.name: + raise DataError("User keyword name cannot be empty.") + if not kw.body: + raise DataError("User keyword cannot be empty.") + + def _run( + self, + data: KeywordData, + kw: "UserKeyword", + result: KeywordResult, + context, + ): + 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._set_arguments(args, context) - timeout = self._get_timeout(variables) - if timeout is not None: - result.timeout = str(timeout) - with context.timeout(timeout): - exception, return_ = self._execute(context) - if exception and not exception.can_continue(context.in_teardown): + positional, named = self._resolve_arguments(data, kw, variables) + with context.user_keyword(kw): + self._set_arguments(kw, positional, named, context) + if kw.timeout: + timeout = KeywordTimeout(kw.timeout, variables) + result.timeout = str(timeout) if timeout else None + else: + timeout = None + with context.keyword_timeout(timeout): + exception, return_value = self._execute(kw, result, context) + if exception and not exception.can_continue(context): + if context.in_teardown and exception.keyword_timeout: + # Allow execution to continue on teardowns after timeout. + # https://github.com/robotframework/robotframework/issues/3398 + exception.keyword_timeout = False raise exception - return_value = self._get_return_value(variables, return_) + return_value = self._handle_return_value(return_value, variables) if exception: exception.return_value = return_value raise exception return return_value - def _get_timeout(self, variables=None): - timeout = self._handler.timeout - if not timeout: - return None - timeout = KeywordTimeout(timeout.value, timeout.message, variables) - return timeout - - def _resolve_arguments(self, arguments, variables=None): - return self.arguments.resolve(arguments, variables) + def _resolve_arguments(self, data: KeywordData, kw: "UserKeyword", variables=None): + return kw.resolve_arguments(data.args, data.named_args, variables) - def _set_arguments(self, arguments, context): - positional, named = arguments + def _set_arguments(self, kw: "UserKeyword", positional, named, context): variables = context.variables - 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)) - - def _set_variables(self, positional, kwargs, variables): - spec = self.arguments - args, varargs = self._split_args_and_varargs(positional) - kwonly, kwargs = self._split_kwonly_and_kwargs(kwargs) - for name, value in chain(zip(spec.positional, args), kwonly): + positional, named = kw.args.map(positional, named, replace_defaults=False) + self._set_variables(kw.args, positional, named, variables) + context.output.trace( + lambda: self._trace_log_args_message(kw, variables), write_if_flat=False + ) + + def _set_variables(self, spec: ArgumentSpec, positional, named, variables): + positional, var_positional = self._separate_positional(spec, positional) + named_only, var_named = self._separate_named(spec, named) + for name, value in (*zip(spec.positional, positional), *named_only): if isinstance(value, DefaultValue): value = value.resolve(variables) - variables['${%s}' % name] = value - if spec.varargs: - variables['@{%s}' % spec.varargs] = varargs - if spec.kwargs: - variables['&{%s}' % spec.kwargs] = DotDict(kwargs) - - def _split_args_and_varargs(self, args): - if not self.arguments.varargs: - return args, [] - positional = len(self.arguments.positional) - return args[:positional], args[positional:] - - def _split_kwonly_and_kwargs(self, all_kwargs): - kwonly = [] - kwargs = [] - for name, value in all_kwargs: - target = kwonly if name in self.arguments.kwonlyargs else kwargs + info = spec.types.get(name) + if info: + value = info.convert(value, name, kind="Default value for argument") + variables[f"${{{name}}}"] = value + if spec.var_positional: + variables[f"@{{{spec.var_positional}}}"] = var_positional + if spec.var_named: + variables[f"&{{{spec.var_named}}}"] = DotDict(var_named) + + def _separate_positional(self, spec: ArgumentSpec, positional): + if not spec.var_positional: + return positional, [] + count = len(spec.positional) + return positional[:count], positional[count:] + + def _separate_named(self, spec: ArgumentSpec, named): + named_only = [] + var_named = [] + for name, value in named: + target = named_only if name in spec.named_only else var_named target.append((name, value)) - return kwonly, kwargs - - def _trace_log_args_message(self, variables): - args = ['${%s}' % arg for arg in self.arguments.positional] - if self.arguments.varargs: - args.append('@{%s}' % self.arguments.varargs) - if self.arguments.kwargs: - args.append('&{%s}' % self.arguments.kwargs) - return self._format_trace_log_args_message(args, variables) + return named_only, var_named + + def _trace_log_args_message(self, kw: "UserKeyword", variables): + return self._format_trace_log_args_message( + self._format_args_for_trace_logging(kw.args), variables + ) + + def _format_args_for_trace_logging(self, spec: ArgumentSpec): + args = [f"${{{arg}}}" for arg in spec.positional] + if spec.var_positional: + args.append(f"@{{{spec.var_positional}}}") + if spec.named_only: + args.extend(f"${{{arg}}}" for arg in spec.named_only) + if spec.var_named: + args.append(f"&{{{spec.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) - - def _execute(self, context): - handler = self._handler - if not (handler.keywords 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: + args = " | ".join(f"{name}={prepr(variables[name])}" for name in args) + return f"Arguments: [ {args} ]" + + def _execute(self, kw: "UserKeyword", result: KeywordResult, context): + if context.dry_run and kw.tags.robot("no-dry-run"): return None, None - error = return_ = pass_ = None + error = success = return_value = None + if kw.setup: + error = self._run_setup_or_teardown(kw.setup, result.setup, context) try: - StepRunner(context).run_steps(handler.keywords) + BodyRunner(context, run=not error).run(kw, result) except ReturnFromKeyword as exception: - return_ = exception + return_value = exception.return_value error = exception.earlier_failures - except (ExitForLoop, ContinueForLoop) as exception: - pass_ = exception except ExecutionPassed as exception: - pass_ = exception + success = exception error = exception.earlier_failures if error: error.continue_on_failure = False except ExecutionFailed as exception: error = exception - with context.keyword_teardown(error): - td_error = self._run_teardown(context) + if kw.teardown: + with context.keyword_teardown(error): + td_error = self._run_setup_or_teardown( + kw.teardown, result.teardown, context + ) + if context.timeouts and not (error or td_error): + td_error = self._handle_timeout_exceeded_during_teardown(context) + else: + td_error = None if error or td_error: error = UserKeywordExecutionFailed(error, td_error) - return error or pass_, return_ + return error or success, return_value - def _get_return_value(self, variables, return_): - ret = self._handler.return_value if not return_ else return_.return_value - if not ret: - return None - contains_list_var = any(is_list_var(item) for item in ret) - try: - ret = variables.replace_list(ret) - except DataError as err: - raise VariableError('Replacing variables from keyword return value ' - 'failed: %s' % err.message) - if len(ret) != 1 or contains_list_var: - return ret - return ret[0] - - def _run_teardown(self, context): - if not self._handler.teardown: + def _handle_timeout_exceeded_during_teardown(self, context): + timeout = min(context.timeouts) + if timeout.timed_out(): + return ExecutionFailed( + timeout.get_message(), + test_timeout=timeout.kind == "TEST", + keyword_timeout=timeout.kind == "KEYWORD", + ) + return None + + def _handle_return_value(self, return_value, variables): + if not return_value: return None + contains_list_var = any(is_list_variable(item) for item in return_value) try: - name = context.variables.replace_string(self._handler.teardown.name) + return_value = variables.replace_list(return_value) except DataError as err: - return ExecutionFailed(err.message, syntax=True) - if name.upper() in ('', 'NONE'): - return None + raise VariableError( + f"Replacing variables from keyword return value failed: {err}" + ) + if len(return_value) != 1 or contains_list_var: + return return_value + return return_value[0] + + def _run_setup_or_teardown(self, data: KeywordData, result: KeywordResult, context): try: - StepRunner(context).run_step(self._handler.teardown, name) + KeywordRunner(context).run(data, result, setup_or_teardown=True) except PassExecution: return None except ExecutionStatus as err: return err return None - def dry_run(self, kw, context): - assignment = VariableAssignment(kw.assign) - result = self._get_result(kw, assignment, context.variables) - with StatusReporter(context, result): + def dry_run(self, data: KeywordData, result: KeywordResult, context): + kw = self.keyword.bind(data) + assignment = VariableAssignment(data.assign) + self._config_result(result, data, kw, assignment, context.variables) + with StatusReporter(data, result, context, implementation=kw): + self._validate(kw) assignment.validate_assignment() - self._dry_run(context, kw.args, result) + self._dry_run(data, kw, result, context) - def _dry_run(self, context, args, result): - self._resolve_arguments(args) - with context.user_keyword: - timeout = self._get_timeout() - if timeout: + def _dry_run( + self, + data: KeywordData, + kw: "UserKeyword", + result: KeywordResult, + context, + ): + if self.pre_run_messages: + for message in self.pre_run_messages: + context.output.message(message) + self._resolve_arguments(data, kw) + with context.user_keyword(kw): + if kw.timeout: + timeout = KeywordTimeout(kw.timeout, context.variables) result.timeout = str(timeout) - error, _ = self._execute(context) + error, _ = self._execute(kw, result, context) if error: raise error 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())) - - 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] - - def _set_arguments(self, embedded_args, context): + def __init__(self, keyword: "UserKeyword", name: str): + super().__init__(keyword, name) + self.embedded_args = keyword.embedded.parse_args(name) + + def _resolve_arguments(self, data: KeywordData, kw: "UserKeyword", variables=None): + result = super()._resolve_arguments(data, kw, variables) + if variables: + embedded = [variables.replace_scalar(e) for e in self.embedded_args] + self.embedded_args = kw.embedded.map(embedded) + return result + + def _set_arguments(self, kw: "UserKeyword", positional, named, context): variables = context.variables - for name, value in embedded_args: - variables['${%s}' % name] = value - context.output.trace(lambda: self._trace_log_args_message(variables)) + for name, value in self.embedded_args: + variables[f"${{{name}}}"] = value + super()._set_arguments(kw, positional, named, context) - def _trace_log_args_message(self, variables): - args = ['${%s}' % arg for arg, _ in self.embedded_args] + def _trace_log_args_message(self, kw: "UserKeyword", variables): + args = [f"${{{arg}}}" for arg in kw.embedded.args] + args += self._format_args_for_trace_logging(kw.args) return self._format_trace_log_args_message(args, variables) + + def _config_result( + self, + result: KeywordResult, + data: KeywordData, + kw: "UserKeyword", + assignment, + variables, + ): + super()._config_result(result, data, kw, assignment, variables) + result.source_name = kw.name diff --git a/src/robot/testdoc.py b/src/robot/testdoc.py index ca91be94f1c..34daa76262e 100755 --- a/src/robot/testdoc.py +++ b/src/robot/testdoc.py @@ -29,29 +29,23 @@ 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 -if 'robot' not in sys.modules and __name__ == '__main__': - import pythonpathsetter +if __name__ == "__main__" and "robot" not in sys.modules: + from pythonpathsetter import set_pythonpath + + set_pythonpath() from robot.conf import RobotSettings -from robot.htmldata import HtmlFileWriter, ModelWriter, JsonWriter, TESTDOC -from robot.parsing import disable_curdir_processing +from robot.htmldata import HtmlFileWriter, JsonWriter, ModelWriter, TESTDOC +from robot.output import LOGGER from robot.running import TestSuiteBuilder -from robot.utils import (abspath, Application, file_writer, get_link_path, - html_escape, html_format, IRONPYTHON, is_string, - PY_VERSION, secs_to_timestr, seq2str2, - timestr_to_secs, unescape) - - -# http://ironpython.codeplex.com/workitem/31549 -if IRONPYTHON and PY_VERSION < (2, 7, 2): - int = long - +from robot.utils import ( + abspath, Application, file_writer, get_link_path, html_escape, html_format, + is_list_like, secs_to_timestr, seq2str2, timestr_to_secs, unescape +) USAGE = """robot.testdoc -- Robot Framework test data documentation tool @@ -64,6 +58,10 @@ metadata of each test suite and test case, as well as the top-level keywords and their arguments. +WARNING: Testdoc is deprecated and will be removed in Robot Framework 8.0. + Use the external Testdoc tool instead: + https://marvkler.github.io/robotframework-testdoc + Options ======= @@ -84,7 +82,7 @@ one per line. Contents do not need to be escaped but spaces in the beginning and end of lines are removed. Empty lines and lines starting with a hash character - (#) are ignored. New in Robot Framework 3.0.2. + (#) are ignored. Example file: | --name Example | # This is a comment line @@ -104,15 +102,14 @@ directories. In all these cases, the last argument must be the file where to write the output. The output is always created in HTML format. -Testdoc works with all interpreters supported by Robot Framework (Python, -Jython and IronPython). It can be executed as an installed module like +Testdoc works with all interpreters supported by Robot Framework. +It can be executed as an installed module like `python -m robot.testdoc` or as a script like `python path/robot/testdoc.py`. Examples: - python -m robot.testdoc my_test.html testdoc.html - jython -m robot.testdoc -N smoke_tests -i smoke path/to/my_tests smoke.html - ipy path/to/robot/testdoc.py first_suite.txt second_suite.txt output.html + python -m robot.testdoc my_test.robot testdoc.html + python path/to/robot/testdoc.py first_suite.txt second_suite.txt output.html For more information about Testdoc and other built-in tools, see http://robotframework.org/robotframework/#built-in-tools. @@ -128,20 +125,24 @@ def main(self, datasources, title=None, **options): outfile = abspath(datasources.pop()) suite = TestSuiteFactory(datasources, **options) self._write_test_doc(suite, outfile, title) + LOGGER.warn( + "The built-in Testdoc tool has been deprecated in favor of the new and " + "enhanced external Testdoc tool. Learn more about the new tool at " + "https://marvkler.github.io/robotframework-testdoc/." + ) self.console(outfile) def _write_test_doc(self, suite, outfile, title): - with file_writer(outfile) as output: + with file_writer(outfile, usage="Testdoc output") as output: model_writer = TestdocModelWriter(output, suite, title) HtmlFileWriter(output, model_writer).write(TESTDOC) -@disable_curdir_processing def TestSuiteFactory(datasources, **options): settings = RobotSettings(options) - if is_string(datasources): + if not is_list_like(datasources): datasources = [datasources] - suite = TestSuiteBuilder().build(*datasources) + suite = TestSuiteBuilder(process_curdir=False).build(*datasources) suite.configure(**settings.suite_config) return suite @@ -150,25 +151,25 @@ class TestdocModelWriter(ModelWriter): def __init__(self, output, suite, title=None): self._output = output - self._output_path = getattr(output, 'name', None) + self._output_path = getattr(output, "name", None) self._suite = suite - self._title = title.replace('_', ' ') if title else suite.name + self._title = title.replace("_", " ") if title else suite.name def write(self, line): self._output.write('\n') + self._output.write("\n") def write_data(self): model = { - 'suite': JsonConverter(self._output_path).convert(self._suite), - 'title': self._title, - 'generated': int(time.time() * 1000) + "suite": JsonConverter(self._output_path).convert(self._suite), + "title": self._title, + "generated": int(time.time() * 1000), } - JsonWriter(self._output).write_json('testdoc = ', model) + JsonWriter(self._output).write_json("testdoc = ", model) -class JsonConverter(object): +class JsonConverter: def __init__(self, output_path=None): self._output_path = output_path @@ -178,24 +179,26 @@ def convert(self, suite): def _convert_suite(self, suite): return { - 'source': suite.source or '', - 'relativeSource': self._get_relative_source(suite.source), - 'id': suite.id, - 'name': self._escape(suite.name), - 'fullName': self._escape(suite.longname), - 'doc': self._html(suite.doc), - 'metadata': [(self._escape(name), self._html(value)) - for name, value in suite.metadata.items()], - 'numberOfTests': suite.test_count , - 'suites': self._convert_suites(suite), - 'tests': self._convert_tests(suite), - 'keywords': list(self._convert_keywords(suite)) + "source": str(suite.source or ""), + "relativeSource": self._get_relative_source(suite.source), + "id": suite.id, + "name": self._escape(suite.name), + "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, + "suites": self._convert_suites(suite), + "tests": self._convert_tests(suite), + "keywords": list(self._convert_keywords((suite.setup, suite.teardown))), } 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 "" + return get_link_path(source, Path(self._output_path).parent) def _escape(self, item): return html_escape(item) @@ -210,59 +213,93 @@ def _convert_tests(self, suite): return [self._convert_test(t) for t in suite.tests] def _convert_test(self, test): + if test.setup: + test.body.insert(0, test.setup) + if test.teardown: + test.body.append(test.teardown) return { - 'name': self._escape(test.name), - 'fullName': self._escape(test.longname), - 'id': test.id, - 'doc': self._html(test.doc), - 'tags': [self._escape(t) for t in test.tags], - 'timeout': self._get_timeout(test.timeout), - 'keywords': list(self._convert_keywords(test)) + "name": self._escape(test.name), + "fullName": self._escape(test.full_name), + "id": test.id, + "doc": self._html(test.doc), + "tags": [self._escape(t) for t in test.tags], + "metadata": [ + (self._escape(name), self._html(value)) + for name, value in test.metadata.items() + ], + "timeout": self._get_timeout(test.timeout), + "keywords": list(self._convert_keywords(test.body)), } - def _convert_keywords(self, item): - for kw in getattr(item, 'keywords', []): - if kw.type == kw.SETUP_TYPE: - yield self._convert_keyword(kw, 'SETUP') - elif kw.type == kw.TEARDOWN_TYPE: - yield self._convert_keyword(kw, 'TEARDOWN') - elif kw.type == kw.FOR_LOOP_TYPE: - yield self._convert_for_loop(kw) + def _convert_keywords(self, keywords): + for kw in keywords: + if not kw: + continue + 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: + yield self._convert_while(kw) + elif kw.type == kw.IF_ELSE_ROOT: + yield from self._convert_if(kw) + elif kw.type == kw.TRY_EXCEPT_ROOT: + yield from self._convert_try(kw) + elif kw.type == kw.VAR: + yield self._convert_var(kw) + + def _convert_for(self, data): + name = f"{', '.join(data.assign)} {data.flavor} {seq2str2(data.values)}" + return {"type": "FOR", "name": self._escape(name), "arguments": ""} + + def _convert_while(self, data): + return {"type": "WHILE", "name": self._escape(data.condition), "arguments": ""} + + def _convert_if(self, data): + for branch in data.body: + yield { + "type": branch.type, + "name": self._escape(branch.condition or ""), + "arguments": "", + } + + def _convert_try(self, data): + for branch in data.body: + if branch.type == branch.EXCEPT: + patterns = ", ".join(branch.patterns) + as_var = f"AS {branch.assign}" if branch.assign else "" + name = f"{patterns} {as_var}".strip() else: - yield self._convert_keyword(kw, 'KEYWORD') + name = "" + yield {"type": branch.type, "name": name, "arguments": ""} - def _convert_for_loop(self, kw): - return { - 'name': self._escape(self._get_for_loop(kw)), - 'arguments': '', - 'type': 'FOR' - } + 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, kw_type): + def _convert_keyword(self, kw): return { - 'name': self._escape(self._get_kw_name(kw)), - 'arguments': self._escape(', '.join(kw.args)), - 'type': kw_type + "type": kw.type, + "name": self._escape(self._get_kw_name(kw)), + "arguments": self._escape(", ".join(kw.args)), } def _get_kw_name(self, kw): if kw.assign: - return '%s = %s' % (', '.join(a.rstrip('= ') for a in kw.assign), kw.name) + assign = ", ".join(a.rstrip("= ") for a in kw.assign) + return f"{assign} = {kw.name}" return kw.name - def _get_for_loop(self, kw): - joiner = ' %s ' % kw.flavor - return ', '.join(kw.variables) + joiner + seq2str2(kw.values) - def _get_timeout(self, timeout): if timeout is None: - return '' + return "" try: - tout = secs_to_timestr(timestr_to_secs(timeout.value)) + tout = secs_to_timestr(timestr_to_secs(timeout)) except ValueError: - tout = timeout.value - if timeout.message: - tout += ' :: ' + timeout.message + tout = timeout return tout @@ -299,5 +336,5 @@ def testdoc(*arguments, **options): TestDoc().execute(*arguments, **options) -if __name__ == '__main__': +if __name__ == "__main__": testdoc_cli(sys.argv[1:]) diff --git a/src/robot/tidy.py b/src/robot/tidy.py deleted file mode 100755 index 01a2fcb8c64..00000000000 --- a/src/robot/tidy.py +++ /dev/null @@ -1,335 +0,0 @@ -#!/usr/bin/env python - -# 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. - -"""Module implementing the command line entry point for the `Tidy` tool. - -This module can be executed from the command line using the following -approaches:: - - python -m robot.tidy - python path/to/robot/tidy.py - -Instead of ``python`` it is possible to use also other Python interpreters. - -This module also provides :class:`Tidy` class and :func:`tidy_cli` function -that can be used programmatically. Other code is for internal usage. -""" - -import os -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__': - import pythonpathsetter - -from robot.errors import DataError -from robot.parsing import (ResourceFile, TestDataDirectory, TestCaseFile, - disable_curdir_processing) -from robot.utils import Application, binary_file_writer, file_writer, PY2 - - -USAGE = """robot.tidy -- Robot Framework test data clean-up tool - -Version: - -Usage: python -m robot.tidy [options] inputfile - or: python -m robot.tidy [options] inputfile [outputfile] - or: python -m robot.tidy --inplace [options] inputfile [more input files] - or: python -m robot.tidy --recursive [options] directory - -Tidy tool can be used to clean up and change format of Robot Framework test -data files. The output is written into the standard output stream by default, -but an optional output file can be given as well. Files can also be modified -in-place using --inplace or --recursive options. - -Options -======= - - -i --inplace Tidy given file(s) so that original file(s) are overwritten - (or removed, if the format is changed). When this option is - used, it is possible to give multiple input files. - Examples: - python -m robot.tidy --inplace tests.robot - python -m robot.tidy --inplace --format robot *.html - -r --recursive Process given directory recursively. Files in the directory - are processed in-place similarly as when --inplace option - is used. - -f --format txt|html|tsv|robot - Output file format. If omitted, the format of the input - file is used. - -p --usepipes Use pipe ('|') as a cell separator in the plain text format. - -s --spacecount number - The number of spaces between cells in the plain text format. - Default is 4. - -l --lineseparator native|windows|unix - Line separator to use in outputs. The default is 'native'. - native: use operating system's native line separators - windows: use Windows line separators (CRLF) - unix: use Unix line separators (LF) - -h -? --help Show this help. - -Cleaning up the test data -========================= - -Test case files can be normalized using Tidy. Tidy always writes consistent -headers, consistent order for settings, and consistent amount of whitespace -between sections and cells. - -Examples: - python -m robot.tidy messed_up_tests.robot cleaned_up_tests.robot - python -m robot.tidy --inplace tests.robot - python -m robot.tidy --recursive path/to/tests - -Changing the test data format -============================= - -Robot Framework supports test data in various formats, but nowadays the -plain text format with the '.robot' extension is the most commonly used. -Tidy makes it easy to convert data from one format to another. This is -especially useful if there is a need to convert tests in deprecated HTML -format to other formats. - -Input format is always determined based on the extension of the input file. -If output file is given, the output format is got from its extension, and -when using --inplace or --recursive, it is possible to specify the desired -format using the --format option. - -Examples: - python -m robot.tidy tests.html tests.robot - python -m robot.tidy --format robot --inplace tests.html - python -m robot.tidy --format robot --recursive path/to/tests - -Output encoding -=============== - -All output files are written using UTF-8 encoding. Outputs written to the -console use the current console encoding. - -Alternative execution -===================== - -In the above examples Tidy is used only with Python, but it works also with -Jython and IronPython. Above it is executed as an installed module, but it -can also be run as a script like `python path/robot/tidy.py`. - -For more information about Tidy and other built-in tools, see -http://robotframework.org/robotframework/#built-in-tools. -""" - - -class Tidy(object): - """Programmatic API for the `Tidy` tool. - - Arguments accepted when creating an instance have same semantics as - Tidy command line options with same names. - """ - - def __init__(self, format='txt', use_pipes=False, - space_count=4, line_separator=os.linesep): - self._options = dict(format=format, - pipe_separated=use_pipes, - txt_separating_spaces=space_count, - line_separator=line_separator) - - def file(self, path, output=None): - """Tidy a file. - - :param path: Path of the input file. - :param output: Path of the output file. If not given, output is - returned. - - Use :func:`inplace` to tidy files in-place. - """ - data = self._parse_data(path) - with self._get_writer(path, output) as writer: - self._save_file(data, writer) - if not output: - return writer.getvalue().replace('\r\n', '\n') - - def _get_writer(self, inpath, outpath): - if PY2 and self._is_tsv(inpath): - return binary_file_writer(outpath) - return file_writer(outpath, newline=self._options['line_separator']) - - def _is_tsv(self, path): - format = self._options['format'] or os.path.splitext(path)[1][1:] - return format.upper() == 'TSV' - - def inplace(self, *paths): - """Tidy file(s) in-place. - - :param paths: Paths of the files to to process. - """ - for path in paths: - self._save_file(self._parse_data(path)) - - def directory(self, path): - """Tidy a directory. - - :param path: Path of the directory to process. - - All files in a directory, recursively, are processed in-place. - """ - self._save_directory(self._parse_data(path)) - - @disable_curdir_processing - def _parse_data(self, path): - if os.path.isdir(path): - return TestDataDirectory(source=path).populate() - if self._is_init_file(path): - path = os.path.dirname(path) - return TestDataDirectory(source=path).populate(recurse=False) - try: - return TestCaseFile(source=path).populate() - except DataError: - try: - return ResourceFile(source=path).populate() - except DataError: - raise DataError("Invalid data source '%s'." % path) - - def _is_init_file(self, path): - return os.path.splitext(os.path.basename(path))[0].lower() == '__init__' - - def _save_file(self, data, output=None): - source = data.initfile if self._is_directory(data) else data.source - if source and not output: - os.remove(source) - data.save(output=output, **self._options) - - def _save_directory(self, data): - if not self._is_directory(data): - self._save_file(data) - return - if data.initfile: - self._save_file(data) - for child in data.children: - self._save_directory(child) - - def _is_directory(self, data): - return hasattr(data, 'initfile') - - -class TidyCommandLine(Application): - """Command line interface for the `Tidy` tool. - - Typically :func:`tidy_cli` is a better suited for command line style - usage and :class:`Tidy` for other programmatic usage. - """ - - def __init__(self): - Application.__init__(self, USAGE, arg_limits=(1,)) - - def main(self, arguments, recursive=False, inplace=False, format='txt', - usepipes=False, spacecount=4, lineseparator=os.linesep): - tidy = Tidy(format=format, use_pipes=usepipes, - space_count=spacecount, line_separator=lineseparator) - if recursive: - tidy.directory(arguments[0]) - elif inplace: - tidy.inplace(*arguments) - else: - output = tidy.file(*arguments) - self.console(output) - - def validate(self, opts, args): - validator = ArgumentValidator() - opts['recursive'], opts['inplace'] \ - = validator.mode_and_arguments(args, **opts) - opts['format'] = validator.format(args, **opts) - opts['lineseparator'] = validator.line_sep(**opts) - if not opts['spacecount']: - opts.pop('spacecount') - else: - opts['spacecount'] = validator.spacecount(opts['spacecount']) - return opts, args - - -class ArgumentValidator(object): - - def mode_and_arguments(self, args, recursive, inplace, **others): - recursive, inplace = bool(recursive), bool(inplace) - validators = {(True, True): self._recursive_and_inplace_together, - (True, False): self._recursive_mode_arguments, - (False, True): self._inplace_mode_arguments, - (False, False): self._default_mode_arguments} - validator = validators[(recursive, inplace)] - validator(args) - return recursive, inplace - - def _recursive_and_inplace_together(self, args): - raise DataError('--recursive and --inplace can not be used together.') - - def _recursive_mode_arguments(self, args): - if len(args) != 1: - raise DataError('--recursive requires exactly one argument.') - if not os.path.isdir(args[0]): - raise DataError('--recursive requires input to be a directory.') - - def _inplace_mode_arguments(self, args): - if not all(os.path.isfile(path) for path in args): - raise DataError('--inplace requires inputs to be files.') - - def _default_mode_arguments(self, args): - if len(args) not in (1, 2): - raise DataError('Default mode requires 1 or 2 arguments.') - if not os.path.isfile(args[0]): - raise DataError('Default mode requires input to be a file.') - - def format(self, args, format, inplace, recursive, **others): - if not format: - if inplace or recursive or len(args) < 2: - return None - format = os.path.splitext(args[1])[1][1:] - format = format.upper() - if format not in ('TXT', 'TSV', 'HTML', 'ROBOT'): - raise DataError("Invalid format '%s'." % format) - return format - - def line_sep(self, lineseparator, **others): - values = {'native': os.linesep, 'windows': '\r\n', 'unix': '\n'} - try: - return values[(lineseparator or 'native').lower()] - except KeyError: - raise DataError("Invalid line separator '%s'." % lineseparator) - - def spacecount(self, spacecount): - try: - spacecount = int(spacecount) - if spacecount < 2: - raise ValueError - except ValueError: - raise DataError('--spacecount must be an integer greater than 1.') - return spacecount - - -def tidy_cli(arguments): - """Executes `Tidy` similarly as from the command line. - - :param arguments: Command line arguments as a list of strings. - - Example:: - - from robot.tidy import tidy_cli - - tidy_cli(['--format', 'txt', 'tests.html']) - """ - TidyCommandLine().execute_cli(arguments) - - -if __name__ == '__main__': - tidy_cli(sys.argv[1:]) diff --git a/src/robot/utils/__init__.py b/src/robot/utils/__init__.py index 3f2595025e9..d4034aa25dc 100644 --- a/src/robot/utils/__init__.py +++ b/src/robot/utils/__init__.py @@ -33,43 +33,233 @@ assert Matcher('H?llo').match('Hillo') """ -from .argumentparser import ArgumentParser, cmdline2list -from .application import Application -from .compat import isatty, py2to3, StringIO, with_metaclass -from .compress import compress_text -from .connectioncache import ConnectionCache -from .dotdict import DotDict -from .encoding import (CONSOLE_ENCODING, SYSTEM_ENCODING, console_decode, - console_encode, system_decode, system_encode) -from .error import (get_error_message, get_error_details, ErrorDetails) -from .escaping import escape, unescape, split_from_equals -from .etreewrapper import ET, ETSource -from .frange import frange -from .markuputils import html_format, html_escape, xml_escape, attribute_escape -from .markupwriters import HtmlWriter, XmlWriter, NullMarkupWriter -from .importer import Importer -from .match import eq, Matcher, MultiMatcher -from .misc import (plural_or_not, printable_name, roundup, seq2str, - seq2str2) -from .normalizing import lower, normalize, NormalizedDict -from .platform import (IRONPYTHON, JAVA_VERSION, JYTHON, PY_VERSION, - PY2, PY3, PYPY, UNIXY, WINDOWS, RERAISED_EXCEPTIONS) -from .recommendations import RecommendationFinder -from .robotenv import get_env_var, set_env_var, del_env_var, get_env_vars -from .robotinspect import is_java_init, is_java_method -from .robotio import binary_file_writer, file_writer -from .robotpath import abspath, find_file, get_link_path, normpath -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) -from .robottypes import (FALSE_STRINGS, TRUE_STRINGS, is_bytes, is_dict_like, - is_falsy, is_integer, is_list_like, is_number, - is_string, is_truthy, is_unicode, type_name, unicode) -from .setter import setter, SetterAwareType -from .sortable import Sortable -from .text import (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 .unic import prepr, unic -from .utf8reader import Utf8Reader +import warnings + +from .application import Application as Application +from .argumentparser import ( + ArgumentParser as ArgumentParser, + cmdline2list as cmdline2list, +) +from .compress import compress_text as compress_text +from .connectioncache import ConnectionCache as ConnectionCache +from .dotdict import DotDict as DotDict +from .encoding import ( + console_decode as console_decode, + console_encode as console_encode, + CONSOLE_ENCODING as CONSOLE_ENCODING, + system_decode as system_decode, + system_encode as system_encode, + SYSTEM_ENCODING as SYSTEM_ENCODING, +) +from .error import ( + ErrorDetails as ErrorDetails, + get_error_details as get_error_details, + get_error_message as get_error_message, +) +from .escaping import ( + escape as escape, + glob_escape as glob_escape, + split_from_equals as split_from_equals, + unescape as unescape, +) +from .etreewrapper import ETSource as ETSource +from .filereader import FileReader as FileReader, Source as Source +from .frange import frange as frange +from .importer import Importer as Importer +from .json import JsonDumper as JsonDumper, JsonLoader as JsonLoader +from .markuputils import ( + attribute_escape as attribute_escape, + html_escape as html_escape, + html_format as html_format, + xml_escape as xml_escape, +) +from .markupwriters import ( + HtmlWriter as HtmlWriter, + NullMarkupWriter as NullMarkupWriter, + XmlWriter as XmlWriter, +) +from .match import eq as eq, Matcher as Matcher, MultiMatcher as MultiMatcher +from .misc import ( + classproperty as classproperty, + isatty as isatty, + parse_re_flags as parse_re_flags, + plural_or_not as plural_or_not, + printable_name as printable_name, + seq2str as seq2str, + seq2str2 as seq2str2, + test_or_task as test_or_task, + validate_literal as validate_literal, +) +from .normalizing import ( + normalize as normalize, + normalize_whitespace as normalize_whitespace, + NormalizedDict as NormalizedDict, +) +from .notset import NOT_SET as NOT_SET, NotSet as NotSet +from .platform import ( + PY_VERSION as PY_VERSION, + PYPY as PYPY, + UNIXY as UNIXY, + WINDOWS as WINDOWS, +) +from .recommendations import RecommendationFinder as RecommendationFinder +from .robotenv import ( + del_env_var as del_env_var, + get_env_var as get_env_var, + get_env_vars as get_env_vars, + set_env_var as set_env_var, +) +from .robotinspect import is_init as is_init +from .robotio import ( + binary_file_writer as binary_file_writer, + create_destination_directory as create_destination_directory, + file_writer as file_writer, +) +from .robotpath import ( + abspath as abspath, + find_file as find_file, + get_link_path as get_link_path, + normpath as normpath, +) +from .robottime import ( + elapsed_time_to_string as elapsed_time_to_string, + format_time as format_time, + get_elapsed_time as get_elapsed_time, + get_time as get_time, + get_timestamp as get_timestamp, + parse_time as parse_time, + parse_timestamp as parse_timestamp, + secs_to_timestamp as secs_to_timestamp, + secs_to_timestr as secs_to_timestr, + timestamp_to_secs as timestamp_to_secs, + timestr_to_secs as timestr_to_secs, +) +from .robottypes import ( + has_args as has_args, + is_dict_like as is_dict_like, + is_falsy as is_falsy, + is_list_like as is_list_like, + is_truthy as is_truthy, + is_union as is_union, + type_name as type_name, + type_repr as type_repr, + typeddict_types as typeddict_types, +) +from .secret import Secret as Secret +from .setter import setter as setter, SetterAwareType as SetterAwareType +from .sortable import Sortable as Sortable +from .text import ( + cut_assign_value as cut_assign_value, + cut_long_message as cut_long_message, + expand_variables as expand_variables, + format_assign_message as format_assign_message, + get_console_length as get_console_length, + getdoc as getdoc, + getshortdoc as getshortdoc, + pad_console_length as pad_console_length, + split_args_from_name_or_path as split_args_from_name_or_path, +) +from .typehints import ( + copy_signature as copy_signature, + KnownAtRuntime as KnownAtRuntime, +) +from .unic import prepr as prepr, safe_str as safe_str + + +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 9.0.", + DeprecationWarning, + ) + return safe_str(item) + + +def __getattr__(name): + # Deprecated utils mostly related to the old Python 2/3 compatibility layer. + # See also 'unic' above and 'PY2' in 'platform.py'. + # https://github.com/robotframework/robotframework/issues/4501 + + from io import StringIO + from os import PathLike + from xml.etree import ElementTree as ET + + 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 + + def is_integer(item): + return isinstance(item, int) + + def is_number(item): + return isinstance(item, (int, float)) + + def is_bytes(item): + return isinstance(item, (bytes, bytearray)) + + def is_string(item): + return isinstance(item, str) + + def is_pathlike(item): + return isinstance(item, PathLike) + + def read_rest_data(rstfile): + from robot.running.builder.restreader import read_rest_data + + return read_rest_data(rstfile) + + def split_tags_from_doc(doc): + doc = doc.rstrip() + tags = [] + if not doc: + return doc, tags + lines = doc.splitlines() + if lines[-1].upper().strip().startswith("TAGS:"): + doc = "\n".join(lines[:-1]).rstrip() + tags = [tag.strip() for tag in lines[-1].split(":", 1)[1].split(",")] + return doc, tags + + deprecated = { + "RERAISED_EXCEPTIONS": (KeyboardInterrupt, SystemExit, MemoryError), + "FALSE_STRINGS": FALSE_STRINGS, + "TRUE_STRINGS": TRUE_STRINGS, + "ET": ET, + "StringIO": StringIO, + "PY3": True, + "PY2": False, + "JYTHON": False, + "IRONPYTHON": False, + "is_number": is_number, + "is_integer": is_integer, + "is_pathlike": is_pathlike, + "is_bytes": is_bytes, + "is_string": is_string, + "is_unicode": is_string, + "unicode": str, + "roundup": round, + "py2to3": py2to3, + "py3to2": py3to2, + "read_rest_data": read_rest_data, + "split_tags_from_doc": split_tags_from_doc, + } + + if name in deprecated: + # TODO: Change DeprecationWarning to more visible UserWarning in RF 8.0. + # https://github.com/robotframework/robotframework/issues/4501 + # Remember also 'unic' above '__getattr__' and 'PY2' in 'platform.py'. + warnings.warn( + f"'robot.utils.{name}' is deprecated and will be removed in " + f"Robot Framework 9.0.", + DeprecationWarning, + ) + return deprecated[name] + + raise AttributeError(f"'robot.utils' has no attribute '{name}'.") diff --git a/src/robot/utils/application.py b/src/robot/utils/application.py index f328a72a526..5f54a149499 100644 --- a/src/robot/utils/application.py +++ b/src/robot/utils/application.py @@ -13,24 +13,38 @@ # See the License for the specific language governing permissions and # limitations under the License. -from __future__ import print_function - import sys -from robot.errors import (INFO_PRINTED, DATA_ERROR, STOPPED_BY_USER, - FRAMEWORK_ERROR, Information, DataError) +from robot.errors import ( + DATA_ERROR, DataError, FRAMEWORK_ERROR, Information, STOPPED_BY_USER +) from .argumentparser import ArgumentParser from .encoding import console_encode from .error import get_error_details -class Application(object): - - def __init__(self, usage, name=None, version=None, arg_limits=None, - env_options=None, logger=None, **auto_options): - self._ap = ArgumentParser(usage, name, version, arg_limits, - self.validate, env_options, **auto_options) +class Application: + + def __init__( + self, + usage, + name=None, + version=None, + arg_limits=None, + env_options=None, + logger=None, + **auto_options, + ): + self._ap = ArgumentParser( + usage, + name, + version, + arg_limits, + self.validate, + env_options, + **auto_options, + ) self._logger = logger or DefaultLogger() def main(self, arguments, **options): @@ -41,7 +55,7 @@ def validate(self, options, arguments): def execute_cli(self, cli_arguments, exit=True): with self._logger: - self._logger.info('%s %s' % (self._ap.name, self._ap.version)) + self._logger.info(f"{self._ap.name} {self._ap.version}") options, arguments = self._parse_arguments(cli_arguments) rc = self._execute(arguments, options) if exit: @@ -55,12 +69,12 @@ def console(self, msg): def _parse_arguments(self, cli_args): try: options, arguments = self.parse_arguments(cli_args) - except Information as msg: - self._report_info(msg.message) + except Information as info: + self._report_info(info) except DataError as err: self._report_error(err.message, help=True, exit=True) else: - self._logger.info('Arguments: %s' % ','.join(arguments)) + self._logger.info(f"Arguments: {','.join(arguments)}") return options, arguments def parse_arguments(self, cli_args): @@ -75,7 +89,7 @@ def parse_arguments(self, cli_args): def execute(self, *arguments, **options): with self._logger: - self._logger.info('%s %s' % (self._ap.name, self._ap.version)) + self._logger.info(f"{self._ap.name} {self._ap.version}") return self._execute(list(arguments), options) def _execute(self, arguments, options): @@ -84,25 +98,31 @@ def _execute(self, arguments, options): except DataError as err: return self._report_error(err.message, help=True) except (KeyboardInterrupt, SystemExit): - return self._report_error('Execution stopped by user.', - rc=STOPPED_BY_USER) - except: + return self._report_error("Execution stopped by user.", rc=STOPPED_BY_USER) + except Exception: error, details = get_error_details(exclude_robot_traces=False) - return self._report_error('Unexpected error: %s' % error, - details, rc=FRAMEWORK_ERROR) + return self._report_error( + f"Unexpected error: {error}", details, rc=FRAMEWORK_ERROR + ) else: return rc or 0 - def _report_info(self, message): - self.console(message) - self._exit(INFO_PRINTED) - - def _report_error(self, message, details=None, help=False, rc=DATA_ERROR, - exit=False): + def _report_info(self, info): + self.console(info.message) + self._exit(info.rc) + + def _report_error( + self, + message, + details=None, + help=False, + rc=DATA_ERROR, + exit=False, + ): if help: - message += '\n\nTry --help for usage information.' + message += "\n\nTry --help for usage information." if details: - message += '\n' + details + message += "\n" + details self._logger.error(message) if exit: self._exit(rc) @@ -112,7 +132,7 @@ def _exit(self, rc): sys.exit(rc) -class DefaultLogger(object): +class DefaultLogger: def info(self, message): pass diff --git a/src/robot/utils/argumentparser.py b/src/robot/utils/argumentparser.py index 6fe519de003..1e6905a562b 100644 --- a/src/robot/utils/argumentparser.py +++ b/src/robot/utils/argumentparser.py @@ -13,84 +13,91 @@ # See the License for the specific language governing permissions and # limitations under the License. -import getopt # optparse was not supported by Jython 2.2 +import getopt +import glob import os import re import shlex import sys -import glob -import string -import textwrap +import warnings +from pathlib import Path +from string import ascii_letters -from robot.errors import DataError, Information, FrameworkError +from robot.errors import DataError, FrameworkError, Information from robot.version import get_full_version -from .misc import plural_or_not from .encoding import console_decode, system_decode -from .platform import PY2 -from .utf8reader import Utf8Reader -from .robottypes import is_falsy, is_integer, is_list_like, is_string, is_unicode - - -ESCAPES = dict( - space = ' ', apos = "'", quot = '"', lt = '<', gt = '>', - pipe = '|', star = '*', comma = ',', slash = '/', semic = ';', - colon = ':', quest = '?', hash = '#', amp = '&', dollar = '$', - percent = '%', at = '@', exclam = '!', paren1 = '(', paren2 = ')', - square1 = '[', square2 = ']', curly1 = '{', curly2 = '}', bslash = '\\' -) +from .filereader import FileReader +from .misc import plural_or_not as s +from .robottypes import is_truthy +from .text import expand_variables def cmdline2list(args, escaping=False): - if PY2 and is_unicode(args): - args = args.encode('UTF-8') - decode = lambda item: item.decode('UTF-8') - else: - decode = lambda item: item + if isinstance(args, Path): + return [str(args)] lexer = shlex.shlex(args, posix=True) - if is_falsy(escaping): - lexer.escape = '' - lexer.escapedquotes = '"\'' - lexer.commenters = '' + if not escaping: + lexer.escape = "" + lexer.escapedquotes = "\"'" + lexer.commenters = "" lexer.whitespace_split = True try: - return [decode(token) for token in lexer] + return list(lexer) except ValueError as err: - raise ValueError("Parsing '%s' failed: %s" % (args, err)) - - -class ArgumentParser(object): - _opt_line_re = re.compile(''' - ^\s{1,4} # 1-4 spaces in the beginning of the line - ((-\S\s)*) # all possible short options incl. spaces (group 1) - --(\S{2,}) # required long option (group 3) - (\s\S+)? # optional value (group 4) - (\s\*)? # optional '*' telling option allowed multiple times (group 5) - ''', re.VERBOSE) - - def __init__(self, usage, name=None, version=None, arg_limits=None, - validator=None, env_options=None, auto_help=True, - auto_version=True, auto_escape=True, auto_pythonpath=True, - auto_argumentfile=True): + raise ValueError(f"Parsing '{args}' failed: {err}") + + +class ArgumentParser: + _opt_line_re = re.compile( + r""" + ^\s{1,4} # 1-4 spaces in the beginning of the line + ((-\S\s)*) # all possible short options incl. spaces (group 1) + --(\S{2,}) # required long option (group 3) + (\s\S+)? # optional value (group 4) + (\s\*)? # optional '*' telling option allowed multiple times (group 5) + """, + re.VERBOSE, + ) + + def __init__( + self, + usage, + name=None, + version=None, + arg_limits=None, + validator=None, + env_options=None, + auto_help=True, + auto_version=True, + auto_pythonpath="DEPRECATED", + auto_argumentfile=True, + ): """Available options and tool name are read from the usage. Tool name is got from the first row of the usage. It is either the whole row or anything before first ' -- '. """ if not usage: - raise FrameworkError('Usage cannot be empty') - self.name = name or usage.splitlines()[0].split(' -- ')[0].strip() + raise FrameworkError("Usage cannot be empty") + self.name = name or usage.splitlines()[0].split(" -- ")[0].strip() self.version = version or get_full_version() self._usage = usage self._arg_limit_validator = ArgLimitValidator(arg_limits) self._validator = validator self._auto_help = auto_help self._auto_version = auto_version - self._auto_escape = auto_escape + if auto_pythonpath == "DEPRECATED": + auto_pythonpath = False + else: + warnings.warn( + "ArgumentParser option 'auto_pythonpath' is deprecated " + "since Robot Framework 5.0.", + ) self._auto_pythonpath = auto_pythonpath self._auto_argumentfile = auto_argumentfile self._env_options = env_options - self._short_opts = '' + self._short_opts = "" self._long_opts = [] self._multi_opts = [] self._flag_opts = [] @@ -123,9 +130,6 @@ def parse_args(self, args): Some options have a special meaning and are handled automatically if defined in the usage and given from the command line: - --escape option can be used to automatically unescape problematic - characters given in an escaped format. - --argumentfile can be used to automatically read arguments from a specified file. When --argumentfile is used, the parser always allows using it multiple times. Adding '*' to denote that is thus @@ -133,24 +137,25 @@ def parse_args(self, args): stdin instead of a file. --pythonpath can be used to add extra path(s) to sys.path. + This functionality was deprecated in Robot Framework 5.0. --help and --version automatically generate help and version messages. Version is generated based on the tool name and version -- see __init__ for information how to set them. Help contains the whole usage given to __init__. Possible text in the usage is replaced with the - given version. Possible <--ESCAPES--> is replaced with available - escapes so that they are wrapped to multiple lines but take the same - amount of horizontal space as <---ESCAPES--->. Both help and version - are wrapped to Information exception. + given version. Both help and version are wrapped to Information + exception. """ args = self._get_env_options() + list(args) args = [system_decode(a) for a in args] if self._auto_argumentfile: args = self._process_possible_argfile(args) opts, args = self._parse_args(args) - if self._auto_argumentfile and opts.get('argumentfile'): - raise DataError("Using '--argumentfile' option in shortened format " - "like '--argumentf' is not supported.") + if self._auto_argumentfile and opts.get("argumentfile"): + raise DataError( + "Using '--argumentfile' option in shortened format " + "like '--argumentf' is not supported." + ) opts, args = self._handle_special_options(opts, args) self._arg_limit_validator(args) if self._validator: @@ -165,86 +170,39 @@ def _get_env_options(self): return [] def _handle_special_options(self, opts, args): - if self._auto_escape and opts.get('escape'): - opts, args = self._unescape_opts_and_args(opts, args) - if self._auto_help and opts.get('help'): - self._raise_help() - if self._auto_version and opts.get('version'): - self._raise_version() - if self._auto_pythonpath and opts.get('pythonpath'): - sys.path = self._get_pythonpath(opts['pythonpath']) + sys.path - for auto, opt in [(self._auto_help, 'help'), - (self._auto_version, 'version'), - (self._auto_escape, 'escape'), - (self._auto_pythonpath, 'pythonpath'), - (self._auto_argumentfile, 'argumentfile')]: - if auto and opt in opts: - opts.pop(opt) + if self._auto_help and opts.pop("help", False): + self._raise_help(opts.get("statusrc")) + if self._auto_version and opts.pop("version", False): + self._raise_version(opts.get("statusrc")) + if self._auto_pythonpath: + sys.path = self._get_pythonpath(opts.pop("pythonpath", [])) + sys.path + if self._auto_argumentfile: + opts.pop("argumentfile", None) return opts, args def _parse_args(self, args): - 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): - if not opt.startswith('--'): + def _normalize_long_option(self, opt): + if not opt.startswith("--"): return opt - if '=' not in opt: - return opt.lower() - opt, value = opt.split('=', 1) - return '%s=%s' % (opt.lower(), value) - - def _unescape_opts_and_args(self, opts, args): - from robot.output import LOGGER - with LOGGER.cache_only: - LOGGER.warn("Option '--escape' is deprecated. Use console escape " - "mechanism instead.") - try: - escape_strings = opts['escape'] - except KeyError: - raise FrameworkError("No 'escape' in options") - escapes = self._get_escapes(escape_strings) - for name, value in opts.items(): - if name != 'escape': - opts[name] = self._unescape(value, escapes) - return opts, [self._unescape(arg, escapes) for arg in args] + if "=" not in opt: + return f"--{opt.lower().replace('-', '')}" + opt, value = opt.split("=", 1) + return f"--{opt.lower().replace('-', '')}={value}" def _process_possible_argfile(self, args): - options = ['--argumentfile'] + options = ["--argumentfile"] for short_opt, long_opt in self._short_to_long.items(): - if long_opt == 'argumentfile': - options.append('-'+short_opt) + if long_opt == "argumentfile": + options.append("-" + short_opt) return ArgFileParser(options).process(args) - def _get_escapes(self, escape_strings): - escapes = {} - for estr in escape_strings: - try: - name, value = estr.split(':', 1) - except ValueError: - raise DataError("Invalid escape string syntax '%s'. " - "Expected: what:with" % estr) - try: - escapes[value] = ESCAPES[name.lower()] - except KeyError: - raise DataError("Invalid escape '%s'. Available: %s" - % (name, self._get_available_escapes())) - return escapes - - def _unescape(self, value, escapes): - if value in [None, True, False]: - return value - if is_list_like(value): - return [self._unescape(item, escapes) for item in value] - for esc_name, esc_value in escapes.items(): - if esc_name in value: - value = value.replace(esc_name, esc_value) - return value - def _process_opts(self, opt_tuple): opts = self._get_default_opts() for name, value in opt_tuple: @@ -253,7 +211,7 @@ def _process_opts(self, opt_tuple): opts[name].append(value) elif name in self._flag_opts: opts[name] = True - elif name.startswith('no') and name[2:] in self._flag_opts: + elif name.startswith("no") and name[2:] in self._flag_opts: opts[name[2:]] = False else: opts[name] = value @@ -262,8 +220,8 @@ def _process_opts(self, opt_tuple): def _get_default_opts(self): defaults = {} for opt in self._long_opts: - opt = opt.rstrip('=') - if opt.startswith('no') and opt[2:] in self._flag_opts: + opt = opt.rstrip("=") + if opt.startswith("no") and opt[2:] in self._flag_opts: continue defaults[opt] = [] if opt in self._multi_opts else None return defaults @@ -279,7 +237,7 @@ def _glob_args(self, args): return temp def _get_name(self, name): - name = name.lstrip('-') + name = name.lstrip("-") try: return self._short_to_long[name] except KeyError: @@ -289,41 +247,43 @@ def _create_options(self, usage): for line in usage.splitlines(): 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(), - takes_arg=bool(res.group(4)), - is_multi=bool(res.group(5))) + self._create_option( + short_opts=[o[1] for o in res.group(1).split()], + long_opt=res.group(3).lower().replace("-", ""), + takes_arg=bool(res.group(4)), + is_multi=bool(res.group(5)), + ) def _create_option(self, short_opts, long_opt, takes_arg, is_multi): self._verify_long_not_already_used(long_opt, not takes_arg) for sopt in short_opts: if sopt in self._short_to_long: - self._raise_option_multiple_times_in_usage('-' + sopt) + self._raise_option_multiple_times_in_usage("-" + sopt) self._short_to_long[sopt] = long_opt if is_multi: self._multi_opts.append(long_opt) if takes_arg: - long_opt += '=' - short_opts = [sopt+':' for sopt in short_opts] + long_opt += "=" + short_opts = [sopt + ":" for sopt in short_opts] else: - if long_opt.startswith('no'): + if long_opt.startswith("no"): long_opt = long_opt[2:] - self._long_opts.append('no' + long_opt) + self._long_opts.append("no" + long_opt) self._flag_opts.append(long_opt) self._long_opts.append(long_opt) - self._short_opts += (''.join(short_opts)) + self._short_opts += "".join(short_opts) def _verify_long_not_already_used(self, opt, flag=False): if flag: - if opt.startswith('no'): + if opt.startswith("no"): opt = opt[2:] self._verify_long_not_already_used(opt) - self._verify_long_not_already_used('no' + opt) - elif opt in [o.rstrip('=') for o in self._long_opts]: - self._raise_option_multiple_times_in_usage('--' + opt) + self._verify_long_not_already_used("no" + opt) + elif opt in [o.rstrip("=") for o in self._long_opts]: + self._raise_option_multiple_times_in_usage("--" + opt) def _get_pythonpath(self, paths): - if is_string(paths): + if isinstance(paths, str): paths = [paths] temp = [] for path in self._split_pythonpath(paths): @@ -332,22 +292,22 @@ def _get_pythonpath(self, paths): def _split_pythonpath(self, paths): # paths may already contain ':' as separator - tokens = ':'.join(paths).split(':') - if os.sep == '/': + tokens = ":".join(paths).split(":") + if os.sep == "/": return tokens # Fix paths split like 'c:\temp' -> 'c', '\temp' ret = [] - drive = '' + drive = "" for item in tokens: - item = item.replace('/', '\\') - if drive and item.startswith('\\'): - ret.append('%s:%s' % (drive, item)) - drive = '' + item = item.replace("/", "\\") + if drive and item.startswith("\\"): + ret.append(f"{drive}:{item}") + drive = "" continue if drive: ret.append(drive) - drive = '' - if len(item) == 1 and item in string.ascii_letters: + drive = "" + if len(item) == 1 and item in ascii_letters: drive = item else: ret.append(item) @@ -355,30 +315,24 @@ def _split_pythonpath(self, paths): ret.append(drive) return ret - def _get_available_escapes(self): - names = sorted(ESCAPES.keys(), key=str.lower) - return ', '.join('%s (%s)' % (n, ESCAPES[n]) for n in names) - - def _raise_help(self): - msg = self._usage + def _raise_help(self, status_rc=True): + usage = self._usage if self.version: - msg = msg.replace('', self.version) - def replace_escapes(res): - escapes = 'Available escapes: ' + self._get_available_escapes() - lines = textwrap.wrap(escapes, width=len(res.group(2))) - indent = ' ' * len(res.group(1)) - return '\n'.join(indent + line for line in lines) - msg = re.sub('( *)(<-+ESCAPES-+>)', replace_escapes, msg) - raise Information(msg) - - def _raise_version(self): - raise Information('%s %s' % (self.name, self.version)) + usage = usage.replace("", self.version) + if status_rc is None: + status_rc = True + raise Information(usage, status_rc) + + def _raise_version(self, status_rc=True): + if status_rc is None: + status_rc = True + raise Information(f"{self.name} {self.version}", status_rc) def _raise_option_multiple_times_in_usage(self, opt): - raise FrameworkError("Option '%s' multiple times in usage" % opt) + raise FrameworkError(f"Option '{opt}' multiple times in usage") -class ArgLimitValidator(object): +class ArgLimitValidator: def __init__(self, arg_limits): self._min_args, self._max_args = self._parse_arg_limits(arg_limits) @@ -386,7 +340,7 @@ def __init__(self, arg_limits): def _parse_arg_limits(self, arg_limits): if arg_limits is None: return 0, sys.maxsize - if is_integer(arg_limits): + if isinstance(arg_limits, int): return arg_limits, arg_limits if len(arg_limits) == 1: return arg_limits[0], sys.maxsize @@ -397,20 +351,19 @@ def __call__(self, args): self._raise_invalid_args(self._min_args, self._max_args, len(args)) def _raise_invalid_args(self, min_args, max_args, arg_count): - min_end = plural_or_not(min_args) if min_args == max_args: - expectation = "%d argument%s" % (min_args, min_end) + expectation = f"Expected {min_args} argument{s(min_args)}" elif max_args != sys.maxsize: - expectation = "%d to %d arguments" % (min_args, max_args) + expectation = f"Expected {min_args} to {max_args} arguments" else: - expectation = "at least %d argument%s" % (min_args, min_end) - raise DataError("Expected %s, got %d." % (expectation, arg_count)) + expectation = f"Expected at least {min_args} argument{s(min_args)}" + raise DataError(f"{expectation}, got {arg_count}.") -class ArgFileParser(object): +class ArgFileParser: - def __init__(self, options): - self._options = options + def __init__(self, config): + self.config = config def process(self, args): while True: @@ -421,60 +374,71 @@ def process(self, args): return args def _get_index(self, args): - for opt in self._options: - start = opt + '=' if opt.startswith('--') else opt + for conf in self.config: + long = conf.startswith("--") for index, arg in enumerate(args): - normalized_arg = arg.lower() if opt.startswith('--') else arg + normalized = "--" + arg.lower().replace("-", "") if long 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) - # Handles `--argumentfile=foo` and `-Afoo` - if normalized_arg.startswith(start): - return arg[len(start):], slice(index, index+1) + if normalized == conf and index + 1 < len(args): + return args[index + 1], slice(index, index + 2) + # Handles `--argumentfile=foo` + elif long and normalized.startswith(conf + "="): + return arg[len(conf) + 1 :], slice(index, index + 1) + # Handles `-Afoo` + elif not long and normalized.startswith(conf): + return arg[len(conf) :], slice(index, index + 1) return None, -1 def _get_args(self, path): - if path.upper() != 'STDIN': - content = self._read_from_file(path) - else: + if path.upper() == "STDIN": content = self._read_from_stdin() - return self._process_file(content) + else: + content = self._read_from_file(path) + try: + return self._process_file(content) + except ValueError as err: + raise DataError(f"Processing argument file '{path}' failed: {err}") + + def _read_from_stdin(self): + return console_decode(sys.__stdin__.read()) def _read_from_file(self, path): try: - with Utf8Reader(path) as reader: + with FileReader(path) as reader: return reader.read() except (IOError, UnicodeError) as err: - raise DataError("Opening argument file '%s' failed: %s" - % (path, err)) - - def _read_from_stdin(self): - return console_decode(sys.__stdin__.read()) + raise DataError(f"Opening argument file '{path}' failed: {err}") def _process_file(self, content): args = [] + if self._parse_expandvars_pragma(content): + content = expand_variables(content) for line in content.splitlines(): line = line.strip() - if line.startswith('-'): + if line.startswith("-"): args.extend(self._split_option(line)) - elif line and not line.startswith('#'): + elif line and not line.startswith("#"): args.append(line) return args - def _split_option(self, line): - separator = self._get_option_separator(line) + def _parse_expandvars_pragma(self, content): + match = re.match(r"#\s*expandvars:\s*(.*)\s*\n", content, flags=re.IGNORECASE) + return is_truthy(match.group(1)) if match else False + + def _split_option(self, option): + separator = self._get_option_separator(option) if not separator: - return [line] - option, value = line.split(separator, 1) - if separator == ' ': + return [option] + name, value = option.split(separator, 1) + if separator == " ": value = value.strip() - return [option, value] + return [name, value] - def _get_option_separator(self, line): - if ' ' not in line and '=' not in line: + def _get_option_separator(self, option): + if " " not in option and "=" not in option: return None - if '=' not in line: - return ' ' - if ' ' not in line: - return '=' - return ' ' if line.index(' ') < line.index('=') else '=' + if "=" not in option: + return " " + if " " not in option: + return "=" + return " " if option.index(" ") < option.index("=") else "=" diff --git a/src/robot/utils/asserts.py b/src/robot/utils/asserts.py index cfa65da9f64..939e5416626 100644 --- a/src/robot/utils/asserts.py +++ b/src/robot/utils/asserts.py @@ -95,7 +95,7 @@ def test_new_style(self): """ from .robottypes import type_name -from .unic import unic +from .unic import safe_str def fail(msg=None): @@ -117,23 +117,23 @@ def assert_true(expr, msg=None): def assert_not_none(obj, msg=None, values=True): """Fail the test if given object is None.""" - _msg = 'is None' + _msg = "is None" if obj is None: if msg is None: msg = _msg elif values is True: - msg = '%s: %s' % (msg, _msg) + msg = f"{msg}: {_msg}" _report_failure(msg) def assert_none(obj, msg=None, values=True): """Fail the test if given object is not None.""" - _msg = '%r is not None' % obj + _msg = f"{obj!r} is not None" if obj is not None: if msg is None: msg = _msg elif values is True: - msg = '%s: %s' % (msg, _msg) + msg = f"{msg}: {_msg}" _report_failure(msg) @@ -153,39 +153,37 @@ def assert_raises(exc_class, callable_obj, *args, **kwargs): except exc_class as err: return err else: - if hasattr(exc_class,'__name__'): + if hasattr(exc_class, "__name__"): exc_name = exc_class.__name__ else: exc_name = str(exc_class) - _report_failure('%s not raised' % exc_name) + _report_failure(f"{exc_name} not raised") -def assert_raises_with_msg(exc_class, expected_msg, callable_obj, *args, - **kwargs): +def assert_raises_with_msg(exc_class, expected_msg, callable_obj, *args, **kwargs): """Similar to fail_unless_raises but also checks the exception message.""" try: callable_obj(*args, **kwargs) except exc_class as err: - assert_equal(expected_msg, unic(err), - 'Correct exception but wrong message') + assert_equal(expected_msg, str(err), "Correct exception but wrong message") else: - if hasattr(exc_class,'__name__'): + if hasattr(exc_class, "__name__"): exc_name = exc_class.__name__ else: exc_name = str(exc_class) - _report_failure('%s not raised' % exc_name) + _report_failure(f"{exc_name} not raised") -def assert_equal(first, second, msg=None, values=True, formatter=None): +def assert_equal(first, second, msg=None, values=True, formatter=safe_str): """Fail if given objects are unequal as determined by the '==' operator.""" - if not first == second: - _report_inequality(first, second, '!=', msg, values, formatter) + if not first == second: # noqa: SIM201 + _report_inequality(first, second, "!=", msg, values, formatter) -def assert_not_equal(first, second, msg=None, values=True, formatter=None): +def assert_not_equal(first, second, msg=None, values=True, formatter=safe_str): """Fail if given objects are equal as determined by the '==' operator.""" if first == second: - _report_inequality(first, second, '==', msg, values, formatter) + _report_inequality(first, second, "==", msg, values, formatter) def assert_almost_equal(first, second, places=7, msg=None, values=True): @@ -197,8 +195,8 @@ def assert_almost_equal(first, second, places=7, msg=None, values=True): significant digits (measured from the most significant digit). """ if round(second - first, places) != 0: - extra = 'within %r places' % places - _report_inequality(first, second, '!=', msg, values, extra=extra) + extra = f"within {places} places" + _report_inequality(first, second, "!=", msg, values, extra=extra) def assert_not_almost_equal(first, second, places=7, msg=None, values=True): @@ -209,33 +207,39 @@ def assert_not_almost_equal(first, second, places=7, msg=None, values=True): Note that decimal places (from zero) are usually not the same as significant digits (measured from the most significant digit). """ - if round(second-first, places) == 0: - extra = 'within %r places' % places - _report_inequality(first, second, '==', msg, values, extra=extra) + if round(second - first, places) == 0: + extra = f"within {places!r} places" + _report_inequality(first, second, "==", msg, values, extra=extra) def _report_failure(msg): if msg is None: - raise AssertionError() + raise AssertionError raise AssertionError(msg) -def _report_inequality(obj1, obj2, delim, msg=None, values=False, - formatter=None, extra=None): +def _report_inequality( + obj1, + obj2, + delim, + msg=None, + values=False, + formatter=safe_str, + extra=None, +): + _msg = _format_message(obj1, obj2, delim, formatter) if not msg: - msg = _format_message(obj1, obj2, delim, formatter) + msg = _msg elif values: - msg = '%s: %s' % (msg, _format_message(obj1, obj2, delim, formatter)) + msg = f"{msg}: {_msg}" if values and extra: - msg += ' ' + extra + msg += " " + extra raise AssertionError(msg) -def _format_message(obj1, obj2, delim, formatter=None): - formatter = formatter or unic +def _format_message(obj1, obj2, delim, formatter=safe_str): str1 = formatter(obj1) str2 = formatter(obj2) - if delim == '!=' and str1 == str2: - return '%s (%s) != %s (%s)' % (str1, type_name(obj1), - str2, type_name(obj2)) - return '%s %s %s' % (str1, delim, str2) + if delim == "!=" and str1 == str2: + return f"{str1} ({type_name(obj1)}) != {str2} ({type_name(obj2)})" + return f"{str1} {delim} {str2}" diff --git a/src/robot/utils/charwidth.py b/src/robot/utils/charwidth.py index d207b7b0ec2..76d486a2c72 100644 --- a/src/robot/utils/charwidth.py +++ b/src/robot/utils/charwidth.py @@ -18,122 +18,89 @@ Some East Asian characters have width of two on console, and combining characters themselves take no extra space. -See issue 604 [1] for more details about East Asian characters. The issue also -contains `generate_wild_chars.py` script that was originally used to create -`_EAST_ASIAN_WILD_CHARS` mapping. An updated version of the script is attached -to issue 1096. Big thanks for xieyanbo for the script and the original patch. - -Note that Python's `unicodedata` module is not used here because importing -it takes several seconds on Jython. - -[1] https://github.com/robotframework/robotframework/issues/604 -[2] https://github.com/robotframework/robotframework/issues/1096 +For more details about East Asian characters and the associated problems see: +https://github.com/robotframework/robotframework/issues/604 """ + def get_char_width(char): char = ord(char) - if _char_in_map(char, _COMBINING_CHARS): + if _char_in_map(char, COMBINING_CHARS): return 0 - if _char_in_map(char, _EAST_ASIAN_WILD_CHARS): + if _char_in_map(char, EAST_ASIAN_WILD_CHARS): return 2 return 1 + def _char_in_map(char, map): for begin, end in map: if char < begin: - break - if begin <= char <= end: + return False + if char <= end: return True return False -_COMBINING_CHARS = [(768, 879)] - -_EAST_ASIAN_WILD_CHARS = [ - (888, 889), (895, 899), (907, 907), (909, 909), (930, 930), - (1316, 1328), (1367, 1368), (1376, 1376), (1416, 1416), - (1419, 1424), (1480, 1487), (1515, 1519), (1525, 1535), - (1540, 1541), (1564, 1565), (1568, 1568), (1631, 1631), - (1806, 1806), (1867, 1868), (1970, 1983), (2043, 2304), - (2362, 2363), (2382, 2383), (2389, 2391), (2419, 2426), - (2432, 2432), (2436, 2436), (2445, 2446), (2449, 2450), - (2473, 2473), (2481, 2481), (2483, 2485), (2490, 2491), - (2501, 2502), (2505, 2506), (2511, 2518), (2520, 2523), - (2526, 2526), (2532, 2533), (2555, 2560), (2564, 2564), - (2571, 2574), (2577, 2578), (2601, 2601), (2609, 2609), - (2612, 2612), (2615, 2615), (2618, 2619), (2621, 2621), - (2627, 2630), (2633, 2634), (2638, 2640), (2642, 2648), - (2653, 2653), (2655, 2661), (2678, 2688), (2692, 2692), - (2702, 2702), (2706, 2706), (2729, 2729), (2737, 2737), - (2740, 2740), (2746, 2747), (2758, 2758), (2762, 2762), - (2766, 2767), (2769, 2783), (2788, 2789), (2800, 2800), - (2802, 2816), (2820, 2820), (2829, 2830), (2833, 2834), - (2857, 2857), (2865, 2865), (2868, 2868), (2874, 2875), - (2885, 2886), (2889, 2890), (2894, 2901), (2904, 2907), - (2910, 2910), (2916, 2917), (2930, 2945), (2948, 2948), - (2955, 2957), (2961, 2961), (2966, 2968), (2971, 2971), - (2973, 2973), (2976, 2978), (2981, 2983), (2987, 2989), - (3002, 3005), (3011, 3013), (3017, 3017), (3022, 3023), - (3025, 3030), (3032, 3045), (3067, 3072), (3076, 3076), - (3085, 3085), (3089, 3089), (3113, 3113), (3124, 3124), - (3130, 3132), (3141, 3141), (3145, 3145), (3150, 3156), - (3159, 3159), (3162, 3167), (3172, 3173), (3184, 3191), - (3200, 3201), (3204, 3204), (3213, 3213), (3217, 3217), - (3241, 3241), (3252, 3252), (3258, 3259), (3269, 3269), - (3273, 3273), (3278, 3284), (3287, 3293), (3295, 3295), - (3300, 3301), (3312, 3312), (3315, 3329), (3332, 3332), - (3341, 3341), (3345, 3345), (3369, 3369), (3386, 3388), - (3397, 3397), (3401, 3401), (3406, 3414), (3416, 3423), - (3428, 3429), (3446, 3448), (3456, 3457), (3460, 3460), - (3479, 3481), (3506, 3506), (3516, 3516), (3518, 3519), - (3527, 3529), (3531, 3534), (3541, 3541), (3543, 3543), - (3552, 3569), (3573, 3584), (3643, 3646), (3676, 3712), - (3715, 3715), (3717, 3718), (3721, 3721), (3723, 3724), - (3726, 3731), (3736, 3736), (3744, 3744), (3748, 3748), - (3750, 3750), (3752, 3753), (3756, 3756), (3770, 3770), - (3774, 3775), (3781, 3781), (3783, 3783), (3790, 3791), - (3802, 3803), (3806, 3839), (3912, 3912), (3949, 3952), - (3980, 3983), (3992, 3992), (4029, 4029), (4045, 4045), - (4053, 4095), (4250, 4253), (4294, 4303), (4349, 4447), - (4515, 4519), (4602, 4607), (4681, 4681), (4686, 4687), - (4695, 4695), (4697, 4697), (4702, 4703), (4745, 4745), - (4750, 4751), (4785, 4785), (4790, 4791), (4799, 4799), - (4801, 4801), (4806, 4807), (4823, 4823), (4881, 4881), - (4886, 4887), (4955, 4958), (4989, 4991), (5018, 5023), - (5109, 5120), (5751, 5759), (5789, 5791), (5873, 5887), - (5901, 5901), (5909, 5919), (5943, 5951), (5972, 5983), - (5997, 5997), (6001, 6001), (6004, 6015), (6110, 6111), - (6122, 6127), (6138, 6143), (6159, 6159), (6170, 6175), - (6264, 6271), (6315, 6399), (6429, 6431), (6444, 6447), - (6460, 6463), (6465, 6467), (6510, 6511), (6517, 6527), - (6570, 6575), (6602, 6607), (6618, 6621), (6684, 6685), - (6688, 6911), (6988, 6991), (7037, 7039), (7083, 7085), - (7098, 7167), (7224, 7226), (7242, 7244), (7296, 7423), - (7655, 7677), (7958, 7959), (7966, 7967), (8006, 8007), - (8014, 8015), (8024, 8024), (8026, 8026), (8028, 8028), - (8030, 8030), (8062, 8063), (8117, 8117), (8133, 8133), - (8148, 8149), (8156, 8156), (8176, 8177), (8181, 8181), - (8191, 8191), (8293, 8297), (8306, 8307), (8335, 8335), - (8341, 8351), (8374, 8399), (8433, 8447), (8528, 8530), - (8585, 8591), (9001, 9002), (9192, 9215), (9255, 9279), - (9291, 9311), (9886, 9887), (9917, 9919), (9924, 9984), - (9989, 9989), (9994, 9995), (10024, 10024), (10060, 10060), - (10062, 10062), (10067, 10069), (10071, 10071), (10079, 10080), - (10133, 10135), (10160, 10160), (10175, 10175), (10187, 10187), - (10189, 10191), (11085, 11087), (11093, 11263), (11311, 11311), - (11359, 11359), (11376, 11376), (11390, 11391), (11499, 11512), - (11558, 11567), (11622, 11630), (11632, 11647), (11671, 11679), - (11687, 11687), (11695, 11695), (11703, 11703), (11711, 11711), - (11719, 11719), (11727, 11727), (11735, 11735), (11743, 11743), - (11825, 12350), (12352, 19903), (19968, 42239), (42540, 42559), - (42592, 42593), (42612, 42619), (42648, 42751), (42893, 43002), - (43052, 43071), (43128, 43135), (43205, 43213), (43226, 43263), - (43348, 43358), (43360, 43519), (43575, 43583), (43598, 43599), - (43610, 43611), (43616, 55295), (63744, 64255), (64263, 64274), - (64280, 64284), (64311, 64311), (64317, 64317), (64319, 64319), - (64322, 64322), (64325, 64325), (64434, 64466), (64832, 64847), - (64912, 64913), (64968, 65007), (65022, 65023), (65040, 65055), - (65063, 65135), (65141, 65141), (65277, 65278), (65280, 65376), - (65471, 65473), (65480, 65481), (65488, 65489), (65496, 65497), - (65501, 65511), (65519, 65528), (65534, 65535), - ] +COMBINING_CHARS = [(768, 879)] +EAST_ASIAN_WILD_CHARS = [ + (888, 889), (895, 899), (907, 907), (909, 909), (930, 930), (1316, 1328), + (1367, 1368), (1376, 1376), (1416, 1416), (1419, 1424), (1480, 1487), (1515, 1519), + (1525, 1535), (1540, 1541), (1564, 1565), (1568, 1568), (1631, 1631), (1806, 1806), + (1867, 1868), (1970, 1983), (2043, 2304), (2362, 2363), (2382, 2383), (2389, 2391), + (2419, 2426), (2432, 2432), (2436, 2436), (2445, 2446), (2449, 2450), (2473, 2473), + (2481, 2481), (2483, 2485), (2490, 2491), (2501, 2502), (2505, 2506), (2511, 2518), + (2520, 2523), (2526, 2526), (2532, 2533), (2555, 2560), (2564, 2564), (2571, 2574), + (2577, 2578), (2601, 2601), (2609, 2609), (2612, 2612), (2615, 2615), (2618, 2619), + (2621, 2621), (2627, 2630), (2633, 2634), (2638, 2640), (2642, 2648), (2653, 2653), + (2655, 2661), (2678, 2688), (2692, 2692), (2702, 2702), (2706, 2706), (2729, 2729), + (2737, 2737), (2740, 2740), (2746, 2747), (2758, 2758), (2762, 2762), (2766, 2767), + (2769, 2783), (2788, 2789), (2800, 2800), (2802, 2816), (2820, 2820), (2829, 2830), + (2833, 2834), (2857, 2857), (2865, 2865), (2868, 2868), (2874, 2875), (2885, 2886), + (2889, 2890), (2894, 2901), (2904, 2907), (2910, 2910), (2916, 2917), (2930, 2945), + (2948, 2948), (2955, 2957), (2961, 2961), (2966, 2968), (2971, 2971), (2973, 2973), + (2976, 2978), (2981, 2983), (2987, 2989), (3002, 3005), (3011, 3013), (3017, 3017), + (3022, 3023), (3025, 3030), (3032, 3045), (3067, 3072), (3076, 3076), (3085, 3085), + (3089, 3089), (3113, 3113), (3124, 3124), (3130, 3132), (3141, 3141), (3145, 3145), + (3150, 3156), (3159, 3159), (3162, 3167), (3172, 3173), (3184, 3191), (3200, 3201), + (3204, 3204), (3213, 3213), (3217, 3217), (3241, 3241), (3252, 3252), (3258, 3259), + (3269, 3269), (3273, 3273), (3278, 3284), (3287, 3293), (3295, 3295), (3300, 3301), + (3312, 3312), (3315, 3329), (3332, 3332), (3341, 3341), (3345, 3345), (3369, 3369), + (3386, 3388), (3397, 3397), (3401, 3401), (3406, 3414), (3416, 3423), (3428, 3429), + (3446, 3448), (3456, 3457), (3460, 3460), (3479, 3481), (3506, 3506), (3516, 3516), + (3518, 3519), (3527, 3529), (3531, 3534), (3541, 3541), (3543, 3543), (3552, 3569), + (3573, 3584), (3643, 3646), (3676, 3712), (3715, 3715), (3717, 3718), (3721, 3721), + (3723, 3724), (3726, 3731), (3736, 3736), (3744, 3744), (3748, 3748), (3750, 3750), + (3752, 3753), (3756, 3756), (3770, 3770), (3774, 3775), (3781, 3781), (3783, 3783), + (3790, 3791), (3802, 3803), (3806, 3839), (3912, 3912), (3949, 3952), (3980, 3983), + (3992, 3992), (4029, 4029), (4045, 4045), (4053, 4095), (4250, 4253), (4294, 4303), + (4349, 4447), (4515, 4519), (4602, 4607), (4681, 4681), (4686, 4687), (4695, 4695), + (4697, 4697), (4702, 4703), (4745, 4745), (4750, 4751), (4785, 4785), (4790, 4791), + (4799, 4799), (4801, 4801), (4806, 4807), (4823, 4823), (4881, 4881), (4886, 4887), + (4955, 4958), (4989, 4991), (5018, 5023), (5109, 5120), (5751, 5759), (5789, 5791), + (5873, 5887), (5901, 5901), (5909, 5919), (5943, 5951), (5972, 5983), (5997, 5997), + (6001, 6001), (6004, 6015), (6110, 6111), (6122, 6127), (6138, 6143), (6159, 6159), + (6170, 6175), (6264, 6271), (6315, 6399), (6429, 6431), (6444, 6447), (6460, 6463), + (6465, 6467), (6510, 6511), (6517, 6527), (6570, 6575), (6602, 6607), (6618, 6621), + (6684, 6685), (6688, 6911), (6988, 6991), (7037, 7039), (7083, 7085), (7098, 7167), + (7224, 7226), (7242, 7244), (7296, 7423), (7655, 7677), (7958, 7959), (7966, 7967), + (8006, 8007), (8014, 8015), (8024, 8024), (8026, 8026), (8028, 8028), (8030, 8030), + (8062, 8063), (8117, 8117), (8133, 8133), (8148, 8149), (8156, 8156), (8176, 8177), + (8181, 8181), (8191, 8191), (8293, 8297), (8306, 8307), (8335, 8335), (8341, 8351), + (8374, 8399), (8433, 8447), (8528, 8530), (8585, 8591), (9001, 9002), (9192, 9215), + (9255, 9279), (9291, 9311), (9886, 9887), (9917, 9919), (9924, 9984), (9989, 9989), + (9994, 9995), (10024, 10024), (10060, 10060), (10062, 10062), (10067, 10069), + (10071, 10071), (10079, 10080), (10133, 10135), (10160, 10160), (10175, 10175), + (10187, 10187), (10189, 10191), (11085, 11087), (11093, 11263), (11311, 11311), + (11359, 11359), (11376, 11376), (11390, 11391), (11499, 11512), (11558, 11567), + (11622, 11630), (11632, 11647), (11671, 11679), (11687, 11687), (11695, 11695), + (11703, 11703), (11711, 11711), (11719, 11719), (11727, 11727), (11735, 11735), + (11743, 11743), (11825, 12350), (12352, 19903), (19968, 42239), (42540, 42559), + (42592, 42593), (42612, 42619), (42648, 42751), (42893, 43002), (43052, 43071), + (43128, 43135), (43205, 43213), (43226, 43263), (43348, 43358), (43360, 43519), + (43575, 43583), (43598, 43599), (43610, 43611), (43616, 55295), (63744, 64255), + (64263, 64274), (64280, 64284), (64311, 64311), (64317, 64317), (64319, 64319), + (64322, 64322), (64325, 64325), (64434, 64466), (64832, 64847), (64912, 64913), + (64968, 65007), (65022, 65023), (65040, 65055), (65063, 65135), (65141, 65141), + (65277, 65278), (65280, 65376), (65471, 65473), (65480, 65481), (65488, 65489), + (65496, 65497), (65501, 65511), (65519, 65528), (65534, 65535) +] # fmt: skip diff --git a/src/robot/utils/compat.py b/src/robot/utils/compat.py deleted file mode 100644 index 20af76181e5..00000000000 --- a/src/robot/utils/compat.py +++ /dev/null @@ -1,76 +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 sys - -from .platform import IRONPYTHON, PY2 - - -if PY2: - # io.StringIO only accepts u'foo' with Python 2. - from StringIO import StringIO - - - def py2to3(cls): - if hasattr(cls, '__unicode__'): - cls.__str__ = lambda self: unicode(self).encode('UTF-8') - return cls - -else: - from io import StringIO - - - 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 - - -# Copied from Jinja2, released under the BSD license. -# https://github.com/mitsuhiko/jinja2/blob/743598d788528921df825479d64f492ef60bef82/jinja2/_compat.py#L88 -def with_metaclass(meta, *bases): - """Create a base class with a metaclass.""" - # This requires a bit of explanation: the basic idea is to make a - # dummy metaclass for one level of class instantiation that replaces - # itself with the actual metaclass. - class metaclass(type): - def __new__(cls, name, this_bases, d): - return meta(name, bases, d) - return type.__new__(metaclass, 'temporary_class', (), {}) - - -# On IronPython sys.stdxxx.isatty() always returns True -if not IRONPYTHON: - - def isatty(stream): - # first check if buffer was detached - if hasattr(stream, 'buffer') and stream.buffer is None: - return False - return hasattr(stream, 'isatty') and stream.isatty() - -else: - - from ctypes import windll - - _HANDLE_IDS = {sys.__stdout__ : -11, sys.__stderr__ : -12} - _CONSOLE_TYPE = 2 - - def isatty(stream): - if stream not in _HANDLE_IDS: - return False - handle = windll.kernel32.GetStdHandle(_HANDLE_IDS[stream]) - return windll.kernel32.GetFileType(handle) == _CONSOLE_TYPE diff --git a/src/robot/utils/compress.py b/src/robot/utils/compress.py index ee6fb89a1d2..5544f5c0ccd 100644 --- a/src/robot/utils/compress.py +++ b/src/robot/utils/compress.py @@ -14,40 +14,9 @@ # limitations under the License. import base64 - -from .platform import JYTHON, PY2 +import zlib def compress_text(text): - result = base64.b64encode(_compress(text.encode('UTF-8'))) - return result if PY2 else result.decode('ASCII') - - -if not JYTHON: - - import zlib - - def _compress(text): - return zlib.compress(text, 9) - -else: - - # Custom compress implementation was originally used to avoid memory leak - # (http://bugs.jython.org/issue1775). Kept around still because it is a bit - # faster than Jython's standard zlib.compress. - - from java.util.zip import Deflater - import jarray - - _DEFLATOR = Deflater(9, False) - - def _compress(text): - _DEFLATOR.setInput(text) - _DEFLATOR.finish() - buf = jarray.zeros(1024, 'b') - compressed = [] - while not _DEFLATOR.finished(): - length = _DEFLATOR.deflate(buf, 0, 1024) - compressed.append(buf[:length].tostring()) - _DEFLATOR.reset() - return ''.join(compressed) + compressed = zlib.compress(text.encode("UTF-8"), 9) + return base64.b64encode(compressed).decode("ASCII") diff --git a/src/robot/utils/connectioncache.py b/src/robot/utils/connectioncache.py index 9785e6e9464..5056856b8c8 100644 --- a/src/robot/utils/connectioncache.py +++ b/src/robot/utils/connectioncache.py @@ -13,47 +13,48 @@ # See the License for the specific language governing permissions and # limitations under the License. -import warnings +from typing import Generic, Iterator, Literal, NoReturn, TypeVar -from .compat import py2to3 from .normalizing import NormalizedDict -from .robottypes import is_string +C = TypeVar("C") -@py2to3 -class ConnectionCache(object): - """Cache for test libs to use with concurrent connections, processes, etc. + +class ConnectionCache(Generic[C]): + """Cache for libraries to use with concurrent connections, processes, etc. The cache stores the registered connections (or other objects) and allows - switching between them using generated indices or user given aliases. - This is useful with any test library where there's need for multiple - concurrent connections, processes, etc. + switching between them using generated indices, user given aliases or + connection objects themselves. This is useful with any library having a need + for multiple concurrent connections, processes, etc. - This class can, and is, used also outside the core framework by SSHLibrary, - Selenium(2)Library, etc. Backwards compatibility is thus important when - doing changes. + This class is used also outside the core framework by SeleniumLibrary, + SSHLibrary, etc. Backwards compatibility is thus important when doing changes. """ - def __init__(self, no_current_msg='No open connection.'): + def __init__(self, no_current_msg: str = "No open connection."): self._no_current = NoConnection(no_current_msg) - self.current = self._no_current #: Current active connection. - self._connections = [] - self._aliases = NormalizedDict() + self.current: C | NoConnection = self._no_current + self._connections: list[C] = [] + self._aliases = NormalizedDict[int]() @property - def current_index(self): + def current_index(self) -> "int | None": if not self: return None for index, conn in enumerate(self): if conn is self.current: return index + 1 + return None @current_index.setter - def current_index(self, index): - self.current = self._connections[index - 1] \ - if index is not None else self._no_current + def current_index(self, index: "int | None"): + if index is None: + self.current = self._no_current + else: + self.current = self._connections[index - 1] - def register(self, connection, alias=None): + def register(self, connection: C, alias: "str | None" = None) -> int: """Registers given connection with optional alias and returns its index. Given connection is set to be the :attr:`current` connection. @@ -67,52 +68,77 @@ def register(self, connection, alias=None): self.current = connection self._connections.append(connection) index = len(self._connections) - if is_string(alias): + if alias: self._aliases[alias] = index return index - def switch(self, alias_or_index): - """Switches to the connection specified by the given alias or index. + def switch(self, identifier: "int | str | C") -> C: + """Switches to the connection specified using the ``identifier``. - Updates :attr:`current` and also returns its new value. + Identifier can be an index, an alias, or a registered connection. + Raises an error if no matching connection is found. - Alias is whatever was given to :meth:`register` method and indices - are returned by it. Index can be given either as an integer or - as a string that can be converted to an integer. Raises an error - if no connection with the given index or alias found. + Updates :attr:`current` and also returns its new value. """ - self.current = self.get_connection(alias_or_index) + self.current = self.get_connection(identifier) return self.current - def get_connection(self, alias_or_index=None): - """Get the connection specified by the given alias or index.. - - If ``alias_or_index`` is ``None``, returns the current connection - if it is active, or raises an error if it is not. + def get_connection( + self, + identifier: "int | str | C | None" = None, + ) -> C: + """Returns the connection specified using the ``identifier``. - Alias is whatever was given to :meth:`register` method and indices - are returned by it. Index can be given either as an integer or - as a string that can be converted to an integer. Raises an error - if no connection with the given index or alias found. + Identifier can be an index (integer or string), an alias, a registered + connection or ``None``. If the identifier is ``None``, returns the + current connection if it is active and raises an error if it is not. + Raises an error also if no matching connection is found. """ - if alias_or_index is None: + if identifier is None: if not self: self.current.raise_error() return self.current try: - index = self.resolve_alias_or_index(alias_or_index) + index = self.get_connection_index(identifier) except ValueError as err: raise RuntimeError(err.args[0]) - return self._connections[index-1] + return self._connections[index - 1] - __getitem__ = get_connection + def get_connection_index(self, identifier: "int | str | C") -> int: + """Returns the index of the connection specified using the ``identifier``. - def close_all(self, closer_method='close'): - """Closes connections using given closer method and empties cache. + Identifier can be an index (integer or string), an alias, or a registered + connection. + + New in Robot Framework 7.0. :meth:`resolve_alias_or_index` can be used + with earlier versions. + """ + if isinstance(identifier, str) and identifier in self._aliases: + return self._aliases[identifier] + if identifier in self._connections: + return self._connections.index(identifier) + 1 + try: + index = int(identifier) + except (ValueError, TypeError): + index = -1 + if 0 < index <= len(self._connections): + return index + raise ValueError(f"Non-existing index or alias '{identifier}'.") + + def resolve_alias_or_index(self, alias_or_index: "str | int") -> int: + """Deprecated in RF 7.0. Use :meth:`get_connection_index` instead.""" + # This was initially added for SeleniumLibrary in RF 3.1.2. + # https://github.com/robotframework/robotframework/issues/3125 + # The new method was added in RF 7.0. We can loudly deprecate this + # earliest in RF 8.0. + return self.get_connection_index(alias_or_index) + + def close_all(self, closer_method: str = "close"): + """Closes connections using the specified closer method and empties cache. If simply calling the closer method is not adequate for closing connections, clients should close connections themselves and use - :meth:`empty_cache` afterwards. + :meth:`empty_cache` afterward. """ for conn in self._connections: getattr(conn, closer_method)() @@ -128,59 +154,30 @@ def empty_cache(self): self._connections = [] self._aliases = NormalizedDict() - def __iter__(self): + __getitem__ = get_connection + + def __iter__(self) -> Iterator[C]: return iter(self._connections) - def __len__(self): + def __len__(self) -> int: return len(self._connections) - def __nonzero__(self): + def __bool__(self) -> bool: return self.current is not self._no_current - def resolve_alias_or_index(self, alias_or_index): - for resolver in self._resolve_alias, self._resolve_index: - try: - return resolver(alias_or_index) - except ValueError: - pass - raise ValueError("Non-existing index or alias '%s'." % alias_or_index) - - def _resolve_alias_or_index(self, alias_or_index): - # TODO: Change to more visible UserWarning in RF 3.2 and remove in 3.3. - # See https://github.com/robotframework/robotframework/issues/3125 - warnings.warn("'ConnectionCache._resolve_alias_or_index' is " - "deprecated. Use 'resolve_alias_or_index' instead.", - DeprecationWarning) - return self.resolve_alias_or_index(alias_or_index) - - def _resolve_alias(self, alias): - if is_string(alias) and alias in self._aliases: - return self._aliases[alias] - raise ValueError - - def _resolve_index(self, index): - try: - index = int(index) - except TypeError: - raise ValueError - if not 0 < index <= len(self._connections): - raise ValueError - return index - -@py2to3 -class NoConnection(object): +class NoConnection: - def __init__(self, message): + def __init__(self, message: str): self.message = message - def __getattr__(self, name): - if name.startswith('__') and name.endswith('__'): + def __getattr__(self, name: str) -> NoReturn: + if name.startswith("__") and name.endswith("__"): raise AttributeError self.raise_error() - def raise_error(self): + def raise_error(self) -> NoReturn: raise RuntimeError(self.message) - def __nonzero__(self): + def __bool__(self) -> Literal[False]: return False diff --git a/src/robot/utils/dotdict.py b/src/robot/utils/dotdict.py index cbb77005fd0..cf3b64ca5ce 100644 --- a/src/robot/utils/dotdict.py +++ b/src/robot/utils/dotdict.py @@ -23,12 +23,13 @@ class DotDict(OrderedDict): def __init__(self, *args, **kwds): args = [self._convert_nested_initial_dicts(a) for a in args] kwds = self._convert_nested_initial_dicts(kwds) - OrderedDict.__init__(self, *args, **kwds) + super().__init__(*args, **kwds) def _convert_nested_initial_dicts(self, value): items = value.items() if is_dict_like(value) else value - return OrderedDict((key, self._convert_nested_dicts(value)) - for key, value in items) + return OrderedDict( + (key, self._convert_nested_dicts(value)) for key, value in items + ) def _convert_nested_dicts(self, value): if isinstance(value, DotDict): @@ -46,7 +47,7 @@ def __getattr__(self, key): raise AttributeError(key) def __setattr__(self, key, value): - if not key.startswith('_OrderedDict__'): + if not key.startswith("_OrderedDict__"): self[key] = value else: OrderedDict.__setattr__(self, key, value) @@ -64,7 +65,8 @@ def __ne__(self, other): return not self == other 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}}}" # Must use original dict.__repr__ to allow customising PrettyPrinter. __repr__ = dict.__repr__ diff --git a/src/robot/utils/encoding.py b/src/robot/utils/encoding.py index 92a3efd1cd4..d8c52961cc3 100644 --- a/src/robot/utils/encoding.py +++ b/src/robot/utils/encoding.py @@ -17,87 +17,75 @@ import sys from .encodingsniffer import get_console_encoding, get_system_encoding -from .compat import isatty -from .platform import JYTHON, IRONPYTHON, PY3 -from .robottypes import is_unicode -from .unic import unic - +from .misc import isatty +from .unic import safe_str CONSOLE_ENCODING = get_console_encoding() SYSTEM_ENCODING = get_system_encoding() -# IronPython and Jython streams have wrong encoding if outputs are redirected. -# Jython gets it right if PYTHONIOENCODING is set, though. -NON_TTY_ENCODING_CAN_BE_TRUSTED = \ - not (IRONPYTHON or JYTHON and not os.getenv('PYTHONIOENCODING')) +CUSTOM_ENCODINGS = {"CONSOLE": CONSOLE_ENCODING, "SYSTEM": SYSTEM_ENCODING} +PYTHONIOENCODING = os.getenv("PYTHONIOENCODING") -def console_decode(string, encoding=CONSOLE_ENCODING, force=False): +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. - By default returns Unicode strings as-is. The `force` argument can be used - on IronPython where all strings are `unicode` and caller knows decoding - is needed. + If `string` is already Unicode, it is returned as-is. """ - if is_unicode(string) and not (IRONPYTHON and force): + if isinstance(string, str): return string - encoding = {'CONSOLE': CONSOLE_ENCODING, - 'SYSTEM': SYSTEM_ENCODING}.get(encoding.upper(), encoding) + encoding = CUSTOM_ENCODINGS.get(encoding.upper(), encoding) try: return string.decode(encoding) except UnicodeError: - return unic(string) + return safe_str(string) + +def console_encode( + string, + encoding=None, + errors="replace", + stream=sys.__stdout__, + force=False, +): + """Encodes the given string so that it can be used in the console. -def console_encode(string, errors='replace', stream=sys.__stdout__): - """Encodes Unicode to bytes in console or system encoding. + If encoding is not given, determines it based on the given stream and system + configuration. 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. - Determines the encoding to use based on the given stream and system - configuration. On Python 3 and IronPython returns Unicode, otherwise - returns bytes. + Decodes bytes back to Unicode by default, because Python 3 APIs in general + work with strings. Use `force=True` if that is not desired. """ - encoding = _get_console_encoding(stream) - if PY3 and encoding != 'UTF-8': - return string.encode(encoding, errors).decode(encoding) - if PY3 or IRONPYTHON: - return string - return string.encode(encoding, errors) + if not isinstance(string, str): + string = safe_str(string) + if encoding: + encoding = CUSTOM_ENCODINGS.get(encoding.upper(), encoding) + else: + encoding = _get_console_encoding(stream) + 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 def _get_console_encoding(stream): - encoding = getattr(stream, 'encoding', None) - if encoding and (NON_TTY_ENCODING_CAN_BE_TRUSTED or isatty(stream)): - return encoding - return CONSOLE_ENCODING if isatty(stream) else SYSTEM_ENCODING - - -# These interpreters handle communication with system APIs using Unicode. -if PY3 or JYTHON or IRONPYTHON: - - def system_decode(string): - return string if is_unicode(string) else unic(string) - - def system_encode(string, errors='replace'): - return string if is_unicode(string) else unic(string) + encoding = getattr(stream, "encoding", None) + if isatty(stream): + return encoding or CONSOLE_ENCODING + if PYTHONIOENCODING: + return PYTHONIOENCODING + return encoding or SYSTEM_ENCODING -else: - def system_decode(string): - """Decodes bytes from system (e.g. cli args or env vars) to Unicode.""" - try: - return string.decode(SYSTEM_ENCODING) - except UnicodeError: - return unic(string) +def system_decode(string): + return string if isinstance(string, str) else safe_str(string) - def system_encode(string, errors='replace'): - """Encodes Unicode to system encoding (e.g. cli args and env vars). - Non-Unicode values are first converted to Unicode. - """ - if not is_unicode(string): - string = unic(string) - return string.encode(SYSTEM_ENCODING, errors) +def system_encode(string): + return string if isinstance(string, str) else safe_str(string) diff --git a/src/robot/utils/encodingsniffer.py b/src/robot/utils/encodingsniffer.py index deaee233c4a..42d62bb1ccb 100644 --- a/src/robot/utils/encodingsniffer.py +++ b/src/robot/utils/encodingsniffer.py @@ -13,33 +13,36 @@ # See the License for the specific language governing permissions and # limitations under the License. +import locale import os import sys -import locale - -from .platform import JYTHON, PY2, PY3, UNIXY, WINDOWS +from .misc import isatty +from .platform import PY_VERSION, UNIXY, WINDOWS if UNIXY: - DEFAULT_CONSOLE_ENCODING = 'UTF-8' - DEFAULT_SYSTEM_ENCODING = 'UTF-8' + DEFAULT_CONSOLE_ENCODING = "UTF-8" + DEFAULT_SYSTEM_ENCODING = "UTF-8" else: - DEFAULT_CONSOLE_ENCODING = 'cp437' - DEFAULT_SYSTEM_ENCODING = 'cp1252' + DEFAULT_CONSOLE_ENCODING = "cp437" + DEFAULT_SYSTEM_ENCODING = "cp1252" def get_system_encoding(): - platform_getters = [(True, _get_python_system_encoding), - (JYTHON, _get_java_system_encoding), - (UNIXY, _get_unixy_encoding), - (WINDOWS, _get_windows_system_encoding)] + platform_getters = [ + (True, _get_python_system_encoding), + (UNIXY, _get_unixy_encoding), + (WINDOWS, _get_windows_system_encoding), + ] return _get_encoding(platform_getters, DEFAULT_SYSTEM_ENCODING) def get_console_encoding(): - platform_getters = [(True, _get_stream_output_encoding), - (UNIXY, _get_unixy_encoding), - (WINDOWS, _get_windows_output_encoding)] + platform_getters = [ + (True, _get_stream_output_encoding), + (UNIXY, _get_unixy_encoding), + (WINDOWS, _get_windows_console_encoding), + ] return _get_encoding(platform_getters, DEFAULT_CONSOLE_ENCODING) @@ -53,70 +56,64 @@ def _get_encoding(platform_getters, default): def _get_python_system_encoding(): - # `locale.getpreferredencoding(False)` returns exactly what we want, but - # it doesn't seem to work outside Windows on Python 2. Luckily on these - # platforms `sys.getfilesystemencoding()` seems to do the right thing. - if PY2 and not WINDOWS: - return sys.getfilesystemencoding() - return locale.getpreferredencoding(False) - - -def _get_java_system_encoding(): - from java.lang import System - return System.getProperty('file.encoding') + if sys.flags.utf8_mode: + return "UTF-8" + if PY_VERSION >= (3, 11): + return locale.getencoding() + # 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': + for name in "LC_ALL", "LC_CTYPE", "LANG", "LANGUAGE": if name in os.environ: # Encoding can be in format like `UTF-8` or `en_US.UTF-8` - encoding = os.environ[name].split('.')[-1] + encoding = os.environ[name].split(".")[-1] if _is_valid(encoding): return encoding return None def _get_stream_output_encoding(): - # Python < 3.6 on Windows returns different encoding depending on are - # outputs redirected or not, and Python >= 3.6 always use UTF-8. We - # want the real console encoding regardless the platform. - if WINDOWS and PY3: + # Python uses UTF-8 as encoding with output streams. + # We want the real console encoding regardless the platform. + if WINDOWS: return None - # Stream may not have encoding attribute if intercepted outside RF in - # Python. Encoding is None if process output is redirected and Python < 3. for stream in sys.__stdout__, sys.__stderr__, sys.__stdin__: - encoding = getattr(stream, 'encoding', None) - if _is_valid(encoding): - return encoding + if isatty(stream): + encoding = getattr(stream, "encoding", None) + if _is_valid(encoding): + return encoding return None def _get_windows_system_encoding(): - return _get_code_page('GetACP') + return _get_code_page("GetACP") -def _get_windows_output_encoding(): - return _get_code_page('GetOEMCP') +def _get_windows_console_encoding(): + return _get_code_page("GetConsoleOutputCP") def _get_code_page(method_name): from ctypes import cdll - try: - method = getattr(cdll.kernel32, method_name) - except TypeError: # Occurred few times with IronPython on CI. - return None - method.argtypes = () # Needed with Jython. - return 'cp%s' % method() + + method = getattr(cdll.kernel32, method_name) + return f"cp{method()}" def _is_valid(encoding): if not encoding: return False try: - 'test'.encode(encoding) + "test".encode(encoding) except LookupError: return False else: diff --git a/src/robot/utils/error.py b/src/robot/utils/error.py index f4af61154ce..e1eddd27d5a 100644 --- a/src/robot/utils/error.py +++ b/src/robot/utils/error.py @@ -14,22 +14,12 @@ # limitations under the License. import os -import re import sys import traceback -from robot.errors import RobotError +from robot.errors import RobotError, TimeoutExceeded -from .platform import JYTHON, RERAISED_EXCEPTIONS -from .unic import unic - - -EXCLUDE_ROBOT_TRACES = not os.getenv('ROBOT_INTERNAL_TRACES') -if JYTHON: - from java.io import StringWriter, PrintWriter - from java.lang import Throwable, OutOfMemoryError -else: - Throwable = () +EXCLUDE_ROBOT_TRACES = not os.getenv("ROBOT_INTERNAL_TRACES") def get_error_message(): @@ -42,37 +32,37 @@ def get_error_message(): return ErrorDetails().message -def get_error_details(exclude_robot_traces=EXCLUDE_ROBOT_TRACES): +def get_error_details(full_traceback=True, exclude_robot_traces=EXCLUDE_ROBOT_TRACES): """Returns error message and details of the last occurred exception.""" - details = ErrorDetails(exclude_robot_traces=exclude_robot_traces) + details = ErrorDetails( + full_traceback=full_traceback, + exclude_robot_traces=exclude_robot_traces, + ) return details.message, details.traceback -def ErrorDetails(exc_info=None, exclude_robot_traces=EXCLUDE_ROBOT_TRACES): - """This factory returns an object that wraps the last occurred exception +class ErrorDetails: + """Object wrapping the last occurred exception. - It has attributes `message`, `traceback` and `error`, where `message` - contains type and message of the original error, `traceback` contains the - traceback/stack trace and `error` contains the original error instance. + It has attributes `message`, `traceback`, and `error`, where `message` contains + the message with possible generic exception name removed, `traceback` contains + the traceback and `error` contains the original error instance. """ - exc_type, exc_value, exc_traceback = exc_info or sys.exc_info() - if exc_type in RERAISED_EXCEPTIONS: - raise exc_value - details = PythonErrorDetails \ - if not isinstance(exc_value, Throwable) else JavaErrorDetails - return details(exc_type, exc_value, exc_traceback, exclude_robot_traces) - - -class _ErrorDetails(object): - _generic_exception_names = ('AssertionError', 'AssertionFailedError', - 'Exception', 'Error', 'RuntimeError', - 'RuntimeException') - - def __init__(self, exc_type, exc_value, exc_traceback, - exclude_robot_traces=True): - self.error = exc_value - self._exc_type = exc_type - self._exc_traceback = exc_traceback + + _generic_names = frozenset(("AssertionError", "Error", "Exception", "RuntimeError")) + + def __init__( + self, + error=None, + full_traceback=True, + exclude_robot_traces=EXCLUDE_ROBOT_TRACES, + ): + if not error: + error = sys.exc_info()[1] + if isinstance(error, (KeyboardInterrupt, SystemExit, MemoryError)): + raise error + self.error = error + self._full_traceback = full_traceback self._exclude_robot_traces = exclude_robot_traces self._message = None self._traceback = None @@ -80,127 +70,65 @@ def __init__(self, exc_type, exc_value, exc_traceback, @property def message(self): if self._message is None: - self._message = self._get_message() + self._message = self._format_message(self.error) return self._message - def _get_message(self): - raise NotImplementedError - @property def traceback(self): if self._traceback is None: - self._traceback = self._get_details() + self._traceback = self._format_traceback(self.error) return self._traceback - def _get_details(self): - raise NotImplementedError - - def _get_name(self, exc_type): - try: - return exc_type.__name__ - except AttributeError: - return unic(exc_type) - - def _format_message(self, name, message): - message = unic(message or '') - message = self._clean_up_message(message, name) - name = name.split('.')[-1] # Use only last part of the name + def _format_traceback(self, error): + if isinstance(error, TimeoutExceeded): + return "" + if isinstance(error, RobotError): + return error.details + if self._exclude_robot_traces: + self._remove_robot_traces(error) + lines = self._get_traceback_lines(type(error), error, error.__traceback__) + return "".join(lines).rstrip() + + def _remove_robot_traces(self, error): + tb = error.__traceback__ + 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__") + return module and module.startswith("robot.") + + 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 or value.__context__ or value.__cause__: + return traceback.format_exception(etype, value, tb) + return empty_tb + traceback.format_exception_only(etype, value) + if tb: + return [prefix, *traceback.format_tb(tb)] + return empty_tb + + def _format_message(self, error): + name = type(error).__name__.split(".")[-1] # Use only the last part + message = str(error) if not message: return name - if self._is_generic_exception(name): + if self._suppress_name(name, error): return message - return '%s: %s' % (name, message) - - def _is_generic_exception(self, name): - return (name in self._generic_exception_names or - isinstance(self.error, RobotError) or - getattr(self.error, 'ROBOT_SUPPRESS_NAME', False)) - - def _clean_up_message(self, message, name): - return message - - -class PythonErrorDetails(_ErrorDetails): - - def _get_message(self): - name = self._get_name(self._exc_type) - return self._format_message(name, unic(self.error)) - - def _get_details(self): - if isinstance(self.error, RobotError): - return self.error.details - return 'Traceback (most recent call last):\n' + self._get_traceback() - - def _get_traceback(self): - tb = self._exc_traceback - while tb and self._is_excluded_traceback(tb): - tb = tb.tb_next - return ''.join(traceback.format_tb(tb)).rstrip() or ' None' - - def _is_excluded_traceback(self, traceback): - if not self._exclude_robot_traces: - return False - module = traceback.tb_frame.f_globals.get('__name__') - return module and module.startswith('robot.') - - -class JavaErrorDetails(_ErrorDetails): - _java_trace_re = re.compile('^\s+at (\w.+)') - _ignored_java_trace = ('org.python.', 'robot.running.', 'robot$py.', - 'sun.reflect.', 'java.lang.reflect.') - - def _get_message(self): - exc_name = self._get_name(self._exc_type) - # OOME.getMessage and even toString seem to throw NullPointerException - if not self._is_out_of_memory_error(self._exc_type): - exc_msg = self.error.getMessage() - else: - exc_msg = str(self.error) - return self._format_message(exc_name, exc_msg) - - def _is_out_of_memory_error(self, exc_type): - return exc_type is OutOfMemoryError - - def _get_details(self): - # OOME.printStackTrace seems to throw NullPointerException - if self._is_out_of_memory_error(self._exc_type): - return '' - output = StringWriter() - self.error.printStackTrace(PrintWriter(output)) - details = '\n'.join(line for line in output.toString().splitlines() - if not self._is_ignored_stack_trace_line(line)) - msg = unic(self.error.getMessage() or '') - if msg: - details = details.replace(msg, '', 1) - return details - - def _is_ignored_stack_trace_line(self, line): - if not line: - return True - res = self._java_trace_re.match(line) - if res is None: - return False - location = res.group(1) - for entry in self._ignored_java_trace: - if location.startswith(entry): - return True - return False - - def _clean_up_message(self, msg, name): - msg = self._remove_stack_trace_lines(msg) - return self._remove_exception_name(msg, name).strip() - - def _remove_stack_trace_lines(self, msg): - lines = msg.splitlines() - while lines: - if self._java_trace_re.match(lines[-1]): - lines.pop() - else: - break - return '\n'.join(lines) - - def _remove_exception_name(self, msg, name): - tokens = msg.split(':', 1) - if len(tokens) == 2 and tokens[0] == name: - msg = tokens[1] - return msg + if message.startswith("*HTML*"): + name = "*HTML* " + name + message = message.split("*", 2)[-1].lstrip() + return f"{name}: {message}" + + def _suppress_name(self, name, error): + return ( + name in self._generic_names + or isinstance(error, (RobotError, TimeoutExceeded)) + or getattr(error, "ROBOT_SUPPRESS_NAME", False) + ) diff --git a/src/robot/utils/escaping.py b/src/robot/utils/escaping.py index 944f92cfe4d..812936373f6 100644 --- a/src/robot/utils/escaping.py +++ b/src/robot/utils/escaping.py @@ -15,134 +15,121 @@ import re -from .platform import PY3 -from .robottypes import is_string - - -if PY3: - unichr = chr - -_CONTROL_WORDS = frozenset(('ELSE', 'ELSE IF', 'AND', 'WITH NAME')) -_SEQUENCES_TO_BE_ESCAPED = ('\\', '${', '@{', '%{', '&{', '*{', '=') +_CONTROL_WORDS = frozenset(("ELSE", "ELSE IF", "AND", "WITH NAME", "AS")) +_SEQUENCES_TO_BE_ESCAPED = ("\\", "${", "@{", "%{", "&{", "*{", "=") def escape(item): - if not is_string(item): + if not isinstance(item, str): return item if item in _CONTROL_WORDS: - return '\\' + item + return "\\" + item for seq in _SEQUENCES_TO_BE_ESCAPED: if seq in item: - item = item.replace(seq, '\\' + seq) + item = item.replace(seq, "\\" + seq) return item -def unescape(item): - if not (is_string(item) and '\\' in item): - return item - return Unescaper().unescape(item) - - -class Unescaper(object): - - def unescape(self, string): - return ''.join(self._yield_unescaped(string)) - - def _yield_unescaped(self, string): - while '\\' in string: - finder = EscapeFinder(string) - yield finder.before + finder.backslashes - if finder.escaped and finder.text: - yield self._unescape(finder.text) - else: - yield finder.text - string = finder.after - yield string - - def _unescape(self, text): - try: - escape = str(text[0]) - except UnicodeError: - return text - try: - unescaper = getattr(self, '_unescaper_for_' + escape) - except AttributeError: - return text - else: - return unescaper(text[1:]) - - def _unescaper_for_n(self, text): - # TODO: Deprecate ignoring space after newline in RF 3.2. - if text.startswith(' '): - text = text[1:] - return '\n' + text - - def _unescaper_for_r(self, text): - return '\r' + text - - def _unescaper_for_t(self, text): - return '\t' + text - - def _unescaper_for_x(self, text): - return self._unescape_character(text, 2, 'x') - - def _unescaper_for_u(self, text): - return self._unescape_character(text, 4, 'u') +def glob_escape(item): + # Python 3.4+ has `glob.escape()` but it has special handling for drives + # that we don't want. + for char in "[*?": + if char in item: + item = item.replace(char, f"[{char}]") + return item - def _unescaper_for_U(self, text): - return self._unescape_character(text, 8, 'U') - def _unescape_character(self, text, length, escape): - try: - char = self._get_character(text[:length], length) - except ValueError: - return escape + text - else: - return char + text[length:] - - def _get_character(self, text, length): - if len(text) < length or not text.isalnum(): - raise ValueError - ordinal = int(text, 16) +class Unescaper: + _escape_sequences = re.compile( + r""" + (\\+) # escapes + (n|r|t # n, r, or t + |x[0-9a-fA-F]{2} # x+HH + |u[0-9a-fA-F]{4} # u+HHHH + |U[0-9a-fA-F]{8} # U+HHHHHHHH + )? # optionally + """, + re.VERBOSE, + ) + + def __init__(self): + self._escape_handlers = { + "": lambda value: value, + "n": lambda value: "\n", + "r": lambda value: "\r", + "t": lambda value: "\t", + "x": self._hex_to_unichr, + "u": self._hex_to_unichr, + "U": self._hex_to_unichr, + } + + def _hex_to_unichr(self, value): + ordinal = int(value, 16) # No Unicode code points above 0x10FFFF if ordinal > 0x10FFFF: - raise ValueError - # unichr only supports ordinals up to 0xFFFF with narrow Python builds + return "U" + value + # `chr` only supports ordinals up to 0xFFFF on narrow Python builds. + # This may not be relevant anymore. if ordinal > 0xFFFF: - return eval("u'\\U%08x'" % ordinal) - return unichr(ordinal) - - -class EscapeFinder(object): - _escaped = re.compile(r'(\\+)([^\\]*)') - - def __init__(self, string): - res = self._escaped.search(string) - self.before = string[:res.start()] - escape_chars = len(res.group(1)) - self.backslashes = '\\' * (escape_chars // 2) - self.escaped = bool(escape_chars % 2) - self.text = res.group(2) - self.after = string[res.end():] - + return eval(rf"'\U{ordinal:08x}'") + return chr(ordinal) + + def unescape(self, item): + if not isinstance(item, str) or "\\" not in item: + return item + return self._escape_sequences.sub(self._handle_escapes, item) + + def _handle_escapes(self, match): + escapes, text = match.groups() + half, is_escaped = divmod(len(escapes), 2) + escapes = escapes[:half] + text = text or "" + if is_escaped: + marker, value = text[:1], text[1:] + text = self._escape_handlers[marker](value) + return escapes + text + + +unescape = Unescaper().unescape + + +def split_from_equals(value): + from robot.variables import VariableMatches + + if not isinstance(value, str) or "=" not in value: + return value, None + matches = VariableMatches(value, ignore_errors=True) + if not matches and "\\" not in value: + return tuple(value.split("=", 1)) + try: + index = _find_split_index(value, matches) + except ValueError: + return value, None + return value[:index], value[index + 1 :] + + +def _find_split_index(string, matches): + remaining = string + relative_index = 0 + for match in matches: + try: + return _find_split_index_from_part(match.before) + relative_index + except ValueError: + remaining = match.after + relative_index += match.end + return _find_split_index_from_part(remaining) + relative_index -def split_from_equals(string): - if not is_string(string) or '=' not in string: - return string, None - index = _get_split_index(string) - if index == -1: - return string, None - return string[:index], string[index+1:] -def _get_split_index(string): +def _find_split_index_from_part(string): index = 0 - while '=' in string[index:]: - index += string[index:].index('=') + while "=" in string[index:]: + index += string[index:].index("=") if _not_escaping(string[:index]): return index index += 1 - return -1 + raise ValueError + def _not_escaping(name): - backslashes = len(name) - len(name.rstrip('\\')) + backslashes = len(name) - len(name.rstrip("\\")) return backslashes % 2 == 0 diff --git a/src/robot/utils/etreewrapper.py b/src/robot/utils/etreewrapper.py index 0f2782adc6d..592a96fc1ca 100644 --- a/src/robot/utils/etreewrapper.py +++ b/src/robot/utils/etreewrapper.py @@ -13,69 +13,64 @@ # See the License for the specific language governing permissions and # limitations under the License. +import re from io import BytesIO +from os import fsdecode +from pathlib import Path +from typing import IO, Union -from .compat import py2to3 -from .platform import IRONPYTHON, PY_VERSION -from .robottypes import is_string +Source = Union[IO, Path, str, bytes, bytearray] -IRONPYTHON_WITH_BROKEN_ETREE = IRONPYTHON and PY_VERSION < (2, 7, 9) -NO_ETREE_ERROR = 'No valid ElementTree XML parser module found' +class ETSource: - -if not IRONPYTHON_WITH_BROKEN_ETREE: - try: - from xml.etree import cElementTree as ET - except ImportError: - try: - from xml.etree import ElementTree as ET - except ImportError: - raise ImportError(NO_ETREE_ERROR) -else: - # Standard ElementTree works only with IronPython 2.7.9+ - # https://github.com/IronLanguages/ironpython2/issues/370 - try: - from elementtree import ElementTree as ET - except ImportError: - raise ImportError(NO_ETREE_ERROR) - from StringIO import StringIO - - -# cElementTree.VERSION seems to always be 1.0.6. We want real API version. -if ET.VERSION < '1.3' and hasattr(ET, 'tostringlist'): - ET.VERSION = '1.3' - - -@py2to3 -class ETSource(object): - - def __init__(self, source): + def __init__(self, source: Source): self._source = source - self._opened = None + self._opened: BytesIO | None = None - def __enter__(self): - self._opened = self._open_source_if_necessary() + def __enter__(self) -> "IO | Path | str | bytes": + self._opened = self._open_if_necessary(self._source) return self._opened or self._source + def _open_if_necessary(self, source: Source) -> "BytesIO | None": + if self._is_path(source) or self._is_already_open(source): + return None + if isinstance(source, (bytes, bytearray)): + return BytesIO(source) + encoding = self._find_encoding(source) + return BytesIO(source.encode(encoding)) + + def _is_path(self, source: Source) -> bool: + if isinstance(source, Path): + return True + if isinstance(source, str): + return not source.lstrip().startswith("<") + if isinstance(source, bytes): + return not source.lstrip().startswith(b"<") + return False + + def _is_already_open(self, source: Source) -> bool: + return not isinstance(source, (str, bytes, bytearray)) + + def _find_encoding(self, source: str) -> str: + match = re.match(r"\s*<\?xml .*encoding=(['\"])(.*?)\1.*\?>", source) + return match.group(2) if match else "UTF-8" + def __exit__(self, exc_type, exc_value, exc_trace): if self._opened: self._opened.close() - def __unicode__(self): - if self._source_is_file_name(): - return self._source - if hasattr(self._source, 'name'): - return self._source.name - return '' - - def _source_is_file_name(self): - return is_string(self._source) \ - and not self._source.lstrip().startswith('<') - - def _open_source_if_necessary(self): - if self._source_is_file_name() or not is_string(self._source): - return None - if IRONPYTHON_WITH_BROKEN_ETREE: - return StringIO(self._source) - return BytesIO(self._source.encode('UTF-8')) + def __str__(self) -> str: + source = self._source + if self._is_path(source): + return self._path_to_string(source) + if hasattr(source, "name"): + return self._path_to_string(source.name) + return "" + + def _path_to_string(self, path: "Path | str | bytes") -> str: + if isinstance(path, Path): + return str(path) + if isinstance(path, bytes): + return fsdecode(path) + return path diff --git a/src/robot/utils/filereader.py b/src/robot/utils/filereader.py new file mode 100644 index 00000000000..1605d89a3d3 --- /dev/null +++ b/src/robot/utils/filereader.py @@ -0,0 +1,102 @@ +# 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 collections.abc import Iterator +from io import StringIO +from pathlib import Path +from typing import TextIO, Union + +Source = Union[Path, str, TextIO] + + +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. 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 + Unicode text or UTF-8 encoded bytes. + + - The third options is giving the source as Unicode text directly. + This requires setting ``accept_text=True`` when creating the reader. + + In all cases bytes are automatically decoded to Unicode and possible + BOM removed. + """ + + 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[TextIO, bool]": + path = self._get_path(source, accept_text) + if path: + file = open(path, "rb") + opened = True + elif isinstance(source, str): + file = StringIO(source) + opened = True + else: + file = source + opened = False + return file, opened + + def _get_path(self, source: Source, accept_text: bool): + if isinstance(source, Path): + return str(source) + if not isinstance(source, str): + return None + if not accept_text: + return source + if "\n" in source: + return None + path = Path(source) + try: + is_path = path.is_absolute() or path.exists() + except OSError: # Can happen on Windows w/ Python < 3.10. + is_path = False + return source if is_path else None + + @property + def name(self) -> str: + return getattr(self.file, "name", "") + + def __enter__(self): + return self + + def __exit__(self, *exc_info): + if self._opened: + self.file.close() + + def read(self) -> str: + return self._decode(self.file.read()) + + def readlines(self) -> "Iterator[str]": + first_line = True + for line in self.file: + yield self._decode(line, remove_bom=first_line) + first_line = False + + def _decode(self, content: "str | bytes", remove_bom: bool = True) -> str: + if isinstance(content, bytes): + content = content.decode("UTF-8") + if remove_bom and content.startswith("\ufeff"): + content = content[1:] + if "\r\n" in content: + content = content.replace("\r\n", "\n") + return content diff --git a/src/robot/utils/frange.py b/src/robot/utils/frange.py index 98f22f36883..162bff8cbaf 100644 --- a/src/robot/utils/frange.py +++ b/src/robot/utils/frange.py @@ -13,20 +13,16 @@ # See the License for the specific language governing permissions and # limitations under the License. -from .misc import roundup -from .robottypes import is_integer, is_string - def frange(*args): """Like ``range()`` but accepts float arguments.""" - if all(is_integer(arg) for arg in args): + if all(isinstance(arg, int) for arg in args): return list(range(*args)) start, stop, step = _get_start_stop_step(args) digits = max(_digits(start), _digits(stop), _digits(step)) factor = pow(10, digits) - return [x/float(factor) for x in range(roundup(start*factor), - roundup(stop*factor), - roundup(step*factor))] + scaled = range(round(start * factor), round(stop * factor), round(step * factor)) + return [x / factor for x in scaled] def _get_start_stop_step(args): @@ -36,28 +32,28 @@ def _get_start_stop_step(args): return args[0], args[1], 1 if len(args) == 3: return args - raise TypeError('frange expected 1-3 arguments, got %d.' % len(args)) + raise TypeError(f"frange expected 1-3 arguments, got {len(args)}.") def _digits(number): - if not is_string(number): + if not isinstance(number, str): number = repr(number) - if 'e' in number: + if "e" in number: return _digits_with_exponent(number) - if '.' in number: + if "." in number: return _digits_with_fractional(number) return 0 def _digits_with_exponent(number): - mantissa, exponent = number.split('e') + mantissa, exponent = number.split("e") mantissa_digits = _digits(mantissa) exponent_digits = int(exponent) * -1 return max(mantissa_digits + exponent_digits, 0) def _digits_with_fractional(number): - fractional = number.split('.')[1] - if fractional == '0': + fractional = number.split(".")[1] + if fractional == "0": return 0 return len(fractional) diff --git a/src/robot/utils/htmlformatters.py b/src/robot/utils/htmlformatters.py index 7c3821e84cd..2c95cc2af60 100644 --- a/src/robot/utils/htmlformatters.py +++ b/src/robot/utils/htmlformatters.py @@ -18,20 +18,23 @@ from itertools import cycle -class LinkFormatter(object): - _image_exts = ('.jpg', '.jpeg', '.png', '.gif', '.bmp') - _link = re.compile('\[(.+?\|.*?)\]') - _url = re.compile(''' -((^|\ ) ["'\(\[]*) # begin of line or space and opt. any char "'([ -([a-z][\w+-.]*://[^\s|]+?) # url -(?=[\]\)|"'.,!?:;]* ($|\ )) # opt. any char ])"'.,!?:; and end of line or space -''', re.VERBOSE|re.MULTILINE|re.IGNORECASE) +class LinkFormatter: + _image_exts = (".jpg", ".jpeg", ".png", ".gif", ".bmp", ".svg") + _link = re.compile(r"\[(.+?\|.*?)]") + _url = re.compile( + r""" + ((^|\ ) ["'(\[{]*) # begin of line or space and opt. any char "'([{ + ([a-z][\w+-.]*://[^\s|]+?) # url + (?=[)\]}"'.,!?:;|]* ($|\ )) # opt. any char )]}"'.,!?:;| and eol or space + """, + re.VERBOSE | re.MULTILINE | re.IGNORECASE, + ) def format_url(self, text): return self._format_url(text, format_as_image=False) def _format_url(self, text, format_as_image=True): - if '://' not in text: + if "://" not in text: return text return self._url.sub(partial(self._replace_url, format_as_image), text) @@ -43,23 +46,22 @@ def _replace_url(self, format_as_image, match): return pre + self._get_link(url) def _get_image(self, src, title=None): - return '' \ - % (self._quot(src), self._quot(title or src)) + return f'' def _get_link(self, href, content=None): - return '%s' % (self._quot(href), content or href) + return f'{content or href}' def _quot(self, attr): - return attr if '"' not in attr else attr.replace('"', '"') + return attr if '"' not in attr else attr.replace('"', """) def format_link(self, text): # 2nd, 4th, etc. token contains link, others surrounding content tokens = self._link.split(text) formatters = cycle((self._format_url, self._format_link)) - return ''.join(f(t) for f, t in zip(formatters, tokens)) + return "".join(f(t) for f, t in zip(formatters, tokens)) def _format_link(self, text): - link, content = [t.strip() for t in text.split('|', 1)] + link, content = [t.strip() for t in text.split("|", 1)] if self._is_image(content): content = self._get_image(content, link) elif self._is_image(link): @@ -67,45 +69,58 @@ def _format_link(self, text): return self._get_link(link, content) def _is_image(self, text): - return text.lower().endswith(self._image_exts) - - -class LineFormatter(object): - handles = lambda self, line: True - newline = '\n' - _bold = re.compile(''' -( # prefix (group 1) - (^|\ ) # begin of line or space - ["'(]* _? # optionally any char "'( and optional begin of italic -) # -\* # start of bold -([^\ ].*?) # no space and then anything (group 3) -\* # end of bold -(?= # start of postfix (non-capturing group) - _? ["').,!?:;]* # optional end of italic and any char "').,!?:; - ($|\ ) # end of line or space -) -''', re.VERBOSE) - _italic = re.compile(''' -( (^|\ ) ["'(]* ) # begin of line or space and opt. any char "'( -_ # start of italic -([^\ _].*?) # no space or underline and then anything -_ # end of italic -(?= ["').,!?:;]* ($|\ ) ) # opt. any char "').,!?:; and end of line or space -''', re.VERBOSE) - _code = re.compile(''' -( (^|\ ) ["'(]* ) # same as above with _ changed to `` -`` -([^\ `].*?) -`` -(?= ["').,!?:;]* ($|\ ) ) -''', re.VERBOSE) + return text.startswith("data:image/") or text.lower().endswith(self._image_exts) + + +class LineFormatter: + newline = "\n" + _bold = re.compile( + r""" + ( # prefix (group 1) + (^|\ ) # begin of line or space + ["'(]* _? # opt. any char "'( and opt. start of italics + ) # + \* # start of bold + ([^ ].*?) # no space and then anything (group 3) + \* # end of bold + (?= # start of postfix (non-capturing group) + _? ["').,!?:;]* # optional end of italic and any char "').,!?:; + ($|\ ) # end of line or space + ) + """, + re.VERBOSE, + ) + _italic = re.compile( + r""" + ((^|\ ) ["'(]*) # begin of line or space and opt. any char "'( + _ # start of italics + ([^ _].*?) # no space or underscore and then anything + _ # end of italics + (?= ["').,!?:;]* ($|\ )) # opt. any char "').,!?:; and end of line or space + """, + re.VERBOSE, + ) + _code = re.compile( + r""" + ((^|\ ) ["'(]*) # same as above with _ changed to `` + `` + ([^ `].*?) + `` + (?= ["').,!?:;]* ($|\ )) + """, + re.VERBOSE, + ) def __init__(self): - self._formatters = [('*', self._format_bold), - ('_', self._format_italic), - ('``', self._format_code), - ('', LinkFormatter().format_link)] + self._formatters = [ + ("*", self._format_bold), + ("_", self._format_italic), + ("``", self._format_code), + ("", LinkFormatter().format_link), + ] + + def handles(self, line): + return True def format(self, line): for marker, formatter in self._formatters: @@ -114,55 +129,58 @@ def format(self, line): return line def _format_bold(self, line): - return self._bold.sub('\\1\\3', line) + return self._bold.sub(r"\1\3", line) def _format_italic(self, line): - return self._italic.sub('\\1\\3', line) + return self._italic.sub(r"\1\3", line) def _format_code(self, line): - return self._code.sub('\\1\\3', line) + return self._code.sub(r"\1\3", line) -class HtmlFormatter(object): +class HtmlFormatter: def __init__(self): - self._results = [] - self._formatters = [TableFormatter(), - PreformattedFormatter(), - ListFormatter(), - HeaderFormatter(), - RulerFormatter()] + self._formatters: list[_Formatter] = [ + TableFormatter(), + PreformattedFormatter(), + ListFormatter(), + HeaderFormatter(), + RulerFormatter(), + ] self._formatters.append(ParagraphFormatter(self._formatters[:])) - self._current = None + self._current: _Formatter | None = None def format(self, text): + results = [] for line in text.splitlines(): - self._process_line(line) - self._end_current() - return '\n'.join(self._results) + self._process_line(line, results) + self._end_current(results) + return "\n".join(results) - def _process_line(self, line): + def _process_line(self, line, results): if not line.strip(): - self._end_current() + self._end_current(results) elif self._current and self._current.handles(line): self._current.add(line) else: - self._end_current() + self._end_current(results) self._current = self._find_formatter(line) self._current.add(line) - def _end_current(self): + def _end_current(self, results): if self._current: - self._results.append(self._current.end()) + results.append(self._current.end()) self._current = None def _find_formatter(self, line): for formatter in self._formatters: if formatter.handles(line): return formatter + return None -class _Formatter(object): +class _Formatter: _strip_lines = True def __init__(self): @@ -202,79 +220,78 @@ def format_line(self, line): class RulerFormatter(_SingleLineFormatter): - match = re.compile('^-{3,}$').match + match = re.compile("^-{3,}$").match def format_line(self, line): - return '
' + return "
" class HeaderFormatter(_SingleLineFormatter): - match = re.compile(r'^(={1,3})\s+(\S.*?)\s+\1$').match + match = re.compile(r"^(={1,3})\s+(\S.*?)\s+\1$").match def format_line(self, line): level, text = self.match(line).groups() level = len(level) + 1 - return '%s' % (level, text, level) + return f"{text}" class ParagraphFormatter(_Formatter): _format_line = LineFormatter().format def __init__(self, other_formatters): - _Formatter.__init__(self) + super().__init__() self._other_formatters = other_formatters def _handles(self, line): - return not any(other.handles(line) - for other in self._other_formatters) + return not any(other.handles(line) for other in self._other_formatters) def format(self, lines): - return '

%s

' % self._format_line(' '.join(lines)) + return f"

{self._format_line(' '.join(lines))}

" class TableFormatter(_Formatter): - _table_line = re.compile('^\| (.* |)\|$') - _line_splitter = re.compile(' \|(?= )') + _table_line = re.compile(r"^\| (.* |)\|$") + _line_splitter = re.compile(r" \|(?= )") _format_cell_content = LineFormatter().format def _handles(self, line): return self._table_line.match(line) is not None def format(self, lines): - return self._format_table([self._split_to_cells(l) for l in lines]) + return self._format_table([self._split_to_cells(li) for li in lines]) def _split_to_cells(self, line): return [cell.strip() for cell in self._line_splitter.split(line[1:-1])] def _format_table(self, rows): - maxlen = max(len(row) for row in rows) + row_len = max(len(row) for row in rows) table = [''] for row in rows: - row += [''] * (maxlen - len(row)) # fix ragged tables - table.append('') + row += [""] * (row_len - len(row)) # fix ragged tables + table.append("") table.extend(self._format_cell(cell) for cell in row) - table.append('') - table.append('
') - return '\n'.join(table) + table.append("") + table.append("") + return "\n".join(table) def _format_cell(self, content): - if content.startswith('=') and content.endswith('='): - tx = 'th' + if content.startswith("=") and content.endswith("="): + tx = "th" content = content[1:-1].strip() else: - tx = 'td' - return '<%s>%s' % (tx, self._format_cell_content(content), tx) + tx = "td" + return f"<{tx}>{self._format_cell_content(content)}" class PreformattedFormatter(_Formatter): _format_line = LineFormatter().format def _handles(self, line): - return line.startswith('| ') or line == '|' + return line.startswith("| ") or line == "|" def format(self, lines): lines = [self._format_line(line[2:]) for line in lines] - return '\n'.join(['
'] + lines + ['
']) + return "\n".join(["
", *lines, "
"]) class ListFormatter(_Formatter): @@ -282,22 +299,22 @@ class ListFormatter(_Formatter): _format_item = LineFormatter().format def _handles(self, line): - return line.strip().startswith('- ') or \ - line.startswith(' ') and self._lines + return line.strip().startswith("- ") or line.startswith(" ") and self._lines def format(self, lines): - items = ['
  • %s
  • ' % self._format_item(line) - for line in self._combine_lines(lines)] - return '\n'.join(['
      '] + items + ['
    ']) + items = [ + f"
  • {self._format_item(line)}
  • " for line in self._combine_lines(lines) + ] + return "\n".join(["
      ", *items, "
    "]) def _combine_lines(self, lines): current = [] for line in lines: line = line.strip() - if not line.startswith('- '): + if not line.startswith("- "): current.append(line) continue if current: - yield ' '.join(current) + yield " ".join(current) current = [line[2:].strip()] - yield ' '.join(current) + yield " ".join(current) diff --git a/src/robot/utils/importer.py b/src/robot/utils/importer.py index 1b0a47f6485..aee278bcfe5 100644 --- a/src/robot/utils/importer.py +++ b/src/robot/utils/importer.py @@ -13,81 +13,167 @@ # See the License for the specific language governing permissions and # limitations under the License. -import os -import sys +import importlib import inspect +import os.path +import sys +from collections.abc import Sequence +from pathlib import Path +from typing import NoReturn from robot.errors import DataError -from .encoding import system_decode from .error import get_error_details -from .platform import JYTHON, IRONPYTHON, PY3 +from .robotinspect import is_init from .robotpath import abspath, normpath -from .robottypes import type_name, is_unicode +from .robottypes import type_name -if PY3: - from importlib import invalidate_caches as invalidate_import_caches -else: - invalidate_import_caches = lambda: None -if JYTHON: - from java.lang.System import getProperty +class Importer: + """Utility that can import modules and classes based on names and paths. -class Importer(object): + Imported classes can optionally be instantiated automatically. + """ def __init__(self, type=None, logger=None): - if not logger: - from robot.output import LOGGER as logger - self._type = type or '' - self._logger = logger - self._importers = (ByPathImporter(logger), - NonDottedImporter(logger), - DottedImporter(logger)) + """ + :param type: + Type of the thing being imported. Used in error and log messages. + :param logger: + Logger to be notified about successful imports and other events. + Currently only needs the ``info`` method, but other level specific + methods may be needed in the future. If not given, logging is disabled. + """ + self.type = type or "" + self.logger = logger or NoLogger() + library_import = type and type.upper() == "LIBRARY" + self._importers = ( + ByPathImporter(self.logger, library_import), + NonDottedImporter(self.logger, library_import), + DottedImporter(self.logger, library_import), + ) self._by_path_importer = self._importers[0] - def import_class_or_module(self, name, instantiate_with_args=None, - return_source=False): - """Imports Python class/module or Java class with given name. + def import_class_or_module( + self, + name_or_path: "str | Path", + instantiate_with_args: "Sequence | None" = None, + return_source: bool = False, + ): + """Imports Python class or module based on the given name or path. + + :param name_or_path: + Name or path of the module or class to import. If a path is given as + a string, it must be absolute. Paths given as ``Path`` objects can be + relative starting from Robot Framework 7.3. + :param instantiate_with_args: + When arguments are given, imported classes are automatically initialized + using them. + :param return_source: + When true, returns a tuple containing the imported module or class + and a path to it. By default, returns only the imported module or class. + + The class or 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 classes and modules by path. + + Classes can be imported from the module search path using name like + ``modulename.ClassName``. If the class name and module name are same, using + just ``CommonName`` is enough. When importing a class by a path, the class + name and the module name must match. + + Optional arguments to use when creating an instance are given as a list. + Starting from Robot Framework 4.0, both positional and named arguments are + supported (e.g. ``['positional', 'name=value']``) and arguments are converted + automatically based on type hints and default values. + + If arguments needed when creating an instance are initially embedded into + 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(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: + self._raise_import_failed(name_or_path, err) + else: + return self._handle_return_values(imported, source, return_source) + + def import_module(self, name_or_path: "str | Path"): + """Imports Python module based on the given name or path. - Class can either live in a module/package or be standalone Java class. - In the former case the name is something like 'MyClass' and in the - latter it could be 'your.package.YourLibrary'. Python classes always - live in a module, but if the module name is exactly same as the class - name then simple 'MyLibrary' will import a class. + :param name_or_path: + Name or path of the module to import. If a path is given as a string, + it must be absolute. Paths given as ``Path`` objects can be relative + starting from Robot Framework 7.3. - Python modules can be imported both using format 'MyModule' and - 'mymodule.submodule'. + 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. - `name` can also be a path to the imported file/directory. In that case - importing is done using `import_class_or_module_by_path` method. + Use :meth:`import_class_or_module` if it is desired to get a class + from the imported module automatically. - If `instantiate_with_args` is not None, imported classes are - instantiated with the specified arguments automatically. + New in Robot Framework 6.0. """ try: - imported, source = self._import_class_or_module(name) - self._log_import_succeeded(imported, name, source) - imported = self._instantiate_if_needed(imported, instantiate_with_args) + 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, err) + self._raise_import_failed(name_or_path, err) else: - return (imported, source) if return_source else imported + return imported - def _import_class_or_module(self, name): + def _import(self, name, get_class=True): for importer in self._importers: if importer.handles(name): - return importer.import_(name) - - def import_class_or_module_by_path(self, path, instantiate_with_args=None): - """Import a Python module or Java class using a file system path. + return importer.import_(name, get_class) + assert False - When importing a Python file, the path must end with '.py' and the - actual file must also exist. When importing Java classes, the path - must end with '.java' or '.class'. The class file must exist in both - cases and in the former case also the source file must exist. - - If `instantiate_with_args` is not None, imported classes are - instantiated with the specified arguments automatically. + def _handle_return_values(self, imported, source, return_source=False): + if not return_source: + return imported + if source and os.path.exists(source): + source = self._sanitize_source(source) + return imported, source + + def _sanitize_source(self, source): + source = normpath(source) + if os.path.isdir(source): + candidate = os.path.join(source, "__init__.py") + elif source.endswith(".pyc"): + candidate = source[:-4] + ".py" + else: + return source + return candidate if os.path.exists(candidate) else source + + def import_class_or_module_by_path( + self, + path: "str | Path", + instantiate_with_args: "Sequence | None" = None, + ): + """Import a Python module or class using a file system path. + + :param path: + Path to the module or class to import. If a path is given as a string, + it must be absolute. Paths given as ``Path`` objects can be relative + starting from Robot Framework 7.3. + :param instantiate_with_args: + When arguments are given, imported classes are automatically initialized + using them. + + When importing a Python file, the path must end with :file:`.py` and the + actual file must also exist. + + Use :meth:`import_class_or_module` to support importing also using name, + not only path. See the documentation of that function for more information + about creating instances automatically. """ try: imported, source = self._by_path_importer.import_(path) @@ -96,24 +182,15 @@ def import_class_or_module_by_path(self, path, instantiate_with_args=None): except DataError as err: self._raise_import_failed(path, err) - def _raise_import_failed(self, name, error): - import_type = '%s ' % self._type if self._type else '' - msg = "Importing %s'%s' failed: %s" % (import_type, name, error.message) - if not error.details: - raise DataError(msg) - msg = [msg, error.details] - msg.extend(self._get_items_in('PYTHONPATH', sys.path)) - if JYTHON: - classpath = getProperty('java.class.path').split(os.path.pathsep) - msg.extend(self._get_items_in('CLASSPATH', classpath)) - raise DataError('\n'.join(msg)) - - def _get_items_in(self, type, items): - yield '%s:' % type - for item in items: - if item: - yield ' %s' % (item if is_unicode(item) - else system_decode(item)) + def _log_import_succeeded(self, item, name, source): + prefix = f"Imported {self.type.lower()}" if self.type else "Imported" + item_type = "module" if inspect.ismodule(item) else "class" + source = f"'{source}'" if source else "unknown location" + self.logger.info(f"{prefix} {item_type} '{name}' from {source}.") + + def _raise_import_failed(self, name, error) -> NoReturn: + prefix = f"Importing {self.type.lower()}" if self.type else "Importing" + raise DataError(f"{prefix} '{name}' failed: {error}") def _instantiate_if_needed(self, imported, args): if args is None: @@ -125,120 +202,145 @@ def _instantiate_if_needed(self, imported, args): return imported def _instantiate_class(self, imported, args): + spec = self._get_arg_spec(imported) + try: + positional, named = spec.resolve(args) + except ValueError as err: + raise DataError(err.args[0]) try: - return imported(*args) - except: - raise DataError('Creating instance failed: %s\n%s' % get_error_details()) + return imported(*positional, **dict(named)) + except Exception: + message, traceback = get_error_details() + raise DataError(f"Creating instance failed: {message}\n{traceback}") - def _log_import_succeeded(self, item, name, source): - import_type = '%s ' % self._type if self._type else '' - item_type = 'module' if inspect.ismodule(item) else 'class' - location = ("'%s'" % source) if source else 'unknown location' - self._logger.info("Imported %s%s '%s' from %s." - % (import_type, item_type, name, location)) + def _get_arg_spec(self, imported): + # Avoid cyclic import. Yuck. + from robot.running.arguments import ArgumentSpec, PythonArgumentParser + init = getattr(imported, "__init__", None) + name = imported.__name__ + if not is_init(init): + return ArgumentSpec(name, self.type) + return PythonArgumentParser(self.type).parse(init, name) -class _Importer(object): - def __init__(self, logger): - self._logger = logger +class _Importer: - def _import(self, name, fromlist=None, retry=True): + def __init__(self, logger, library_import=False): + self.logger = logger + self.library_import = library_import + + 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() + raise DataError( + "Cannot import custom module with same name as Python built-in module." + ) + importlib.invalidate_caches() try: - try: - return __import__(name, fromlist=fromlist) - except ImportError: - # Hack to support standalone Jython. For more information, see: - # https://github.com/robotframework/robotframework/issues/515 - # http://bugs.jython.org/issue1778514 - if JYTHON and fromlist and retry: - __import__('%s.%s' % (name, fromlist[0])) - return self._import(name, fromlist, retry=False) - # IronPython loses traceback when using plain raise. - # https://github.com/IronLanguages/main/issues/989 - if IRONPYTHON: - exec('raise sys.exc_type, sys.exc_value, sys.exc_traceback') - raise - except: - raise DataError(*get_error_details()) + return __import__(name, fromlist=fromlist) + except Exception: + message, traceback = get_error_details(full_traceback=False) + path = "\n".join(f" {p}" for p in sys.path) + raise DataError(f"{message}\n{traceback}\nPYTHONPATH:\n{path}") def _verify_type(self, imported): if inspect.isclass(imported) or inspect.ismodule(imported): return imported - raise DataError('Expected class or module, got %s.' - % type_name(imported)) + raise DataError(f"Expected class or module, got {type_name(imported)}.") + + def _get_possible_class(self, module, name=None): + cls = self._get_class_matching_module_name(module, name) + if not cls and self.library_import: + cls = self._get_decorated_library_class_in_imported_module(module) + return cls or module + + def _get_class_matching_module_name(self, module, name): + cls = getattr(module, name or module.__name__, None) + return cls if inspect.isclass(cls) else None - def _get_class_from_module(self, module, name=None): - klass = getattr(module, name or module.__name__, None) - return klass if inspect.isclass(klass) else None + def _get_decorated_library_class_in_imported_module(self, module): + def predicate(item): + return ( + inspect.isclass(item) + and hasattr(item, "ROBOT_AUTO_KEYWORDS") + and item.__module__ == module.__name__ + ) + + classes = [cls for _, cls in inspect.getmembers(module, predicate)] + return classes[0] if len(classes) == 1 else None def _get_source(self, imported): try: - return abspath(inspect.getfile(imported)) + source = inspect.getfile(imported) except TypeError: return None + return abspath(source) if source else None class ByPathImporter(_Importer): - _valid_import_extensions = ('.py', '.java', '.class', '') + _valid_import_extensions = (".py", "") def handles(self, path): - return os.path.isabs(path) + return os.path.isabs(path) or isinstance(path, Path) - def import_(self, path): - self._verify_import_path(path) + def import_(self, path, get_class=True): + path = 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_possible_class(imported) return self._verify_type(imported), path def _verify_import_path(self, path): if not os.path.exists(path): - raise DataError('File or directory does not exist.') + raise DataError("File or directory does not exist.") if not os.path.isabs(path): - raise DataError('Import path must be absolute.') - if not os.path.splitext(path)[1] in self._valid_import_extensions: - raise DataError('Not a valid file or directory to import.') + if isinstance(path, Path): + path = path.absolute() + else: + raise DataError("Import path must be absolute.") + if os.path.splitext(path)[1] not in self._valid_import_extensions: + raise DataError("Not a valid file or directory to import.") + return os.path.normpath(path) def _remove_wrong_module_from_sys_modules(self, path): importing_from, name = self._split_path_to_module(path) - importing_package = os.path.splitext(path)[1] == '' + importing_package = os.path.splitext(path)[1] == "" if self._wrong_module_imported(name, importing_from, importing_package): del sys.modules[name] - self._logger.info("Removed module '%s' from sys.modules to import " - "fresh module." % name) + self.logger.info( + f"Removed module '{name}' from sys.modules to import a fresh module." + ) def _split_path_to_module(self, path): module_dir, module_file = os.path.split(abspath(path)) module_name = os.path.splitext(module_file)[0] - if module_name.endswith('$py'): - module_name = module_name[:-3] return module_dir, module_name def _wrong_module_imported(self, name, importing_from, importing_package): if name not in sys.modules: return False - source = getattr(sys.modules[name], '__file__', None) - if not source: # play safe (occurs at least with java based modules) + source = getattr(sys.modules[name], "__file__", None) + if not source: # play safe return True imported_from, imported_package = self._get_import_information(source) - return (normpath(importing_from, case_normalize=True) != - normpath(imported_from, case_normalize=True) or - importing_package != imported_package) + return ( + normpath(importing_from, case_normalize=True) + != normpath(imported_from, case_normalize=True) + or importing_package != imported_package + ) def _get_import_information(self, source): imported_from, imported_file = self._split_path_to_module(source) - imported_package = imported_file == '__init__' + imported_package = imported_file == "__init__" if imported_package: imported_from = os.path.dirname(imported_from) return imported_from, imported_package def _import_by_path(self, path): module_dir, module_name = self._split_path_to_module(path) + if "." in module_name: + raise DataError("Module name cannot contain dots when importing by path.") sys.path.insert(0, module_dir) try: return self._import(module_name) @@ -249,26 +351,31 @@ def _import_by_path(self, path): class NonDottedImporter(_Importer): def handles(self, name): - return '.' not in 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_possible_class(imported) return self._verify_type(imported), self._get_source(imported) class DottedImporter(_Importer): def handles(self, name): - return '.' in name + return "." in name - def import_(self, name): - parent_name, lib_name = name.rsplit('.', 1) + def import_(self, name, get_class=True): + parent_name, lib_name = name.rsplit(".", 1) parent = self._import(parent_name, fromlist=[str(lib_name)]) try: imported = getattr(parent, lib_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 + raise DataError(f"Module '{parent_name}' does not contain '{lib_name}'.") + if get_class: + imported = self._get_possible_class(imported, lib_name) return self._verify_type(imported), self._get_source(imported) + + +class NoLogger: + error = warn = info = debug = trace = lambda self, *args, **kws: None diff --git a/src/robot/utils/json.py b/src/robot/utils/json.py new file mode 100644 index 00000000000..d68d9fb3457 --- /dev/null +++ b/src/robot/utils/json.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. + +import json +from pathlib import Path +from typing import Dict, overload, TextIO + +from .error import get_error_message +from .robottypes import type_name + +DataDict = Dict[str, object] + + +class JsonLoader: + """Generic JSON object loader. + + JSON source can be a string or bytes, a path or an open file object. + The top level JSON item must always be a dictionary. + + Supports the same configuration parameters as the underlying `json.load`__ + except for ``object_pairs_hook``. As a special feature, handles duplicate + items so that lists are merged. + + __ https://docs.python.org/3/library/json.html#json.load + """ + + def __init__(self, **config): + self.config = self._add_hook_to_merge_duplicate_lists(config) + + def _add_hook_to_merge_duplicate_lists(self, config): + object_hook = config.get("object_hook") + object_pairs_hook = config.get("object_pairs_hook") + if object_pairs_hook: + raise ValueError("'object_pairs_hook' is not supported.") + + def merge_duplicate_lists(items: "list[tuple[str, object]]") -> DataDict: + data = {} + for name, value in items: + if name in data and isinstance(value, list): + data[name].extend(value) + else: + data[name] = value + return object_hook(data) if object_hook else data + + config["object_pairs_hook"] = merge_duplicate_lists + return config + + def load(self, source: "str | bytes | TextIO | Path") -> DataDict: + try: + data = self._load(source) + except (json.JSONDecodeError, TypeError): + raise ValueError(f"Invalid JSON data: {get_error_message()}") + if not isinstance(data, dict): + raise TypeError(f"Expected dictionary, got {type_name(data)}.") + return data + + def _load(self, source: "str | bytes | TextIO | Path") -> object: + if self._is_path(source): + with open(source, encoding="UTF-8") as file: + return json.load(file, **self.config) + if hasattr(source, "read"): + return json.load(source, **self.config) + return json.loads(source, **self.config) + + def _is_path(self, source: "str | bytes | TextIO | Path") -> bool: + if isinstance(source, Path): + return True + return isinstance(source, str) and "{" not in source + + +class JsonDumper: + """Generic JSON dumper. + + JSON can be written to a file given as a path or as an open file object. + If no output is given, JSON is returned as a string. + + Supports the same configuration as the underlying `json.dump`__. + + __ https://docs.python.org/3/library/json.html#json.load + """ + + def __init__(self, **config): + self.config = config + + @overload + def dump(self, data: DataDict, output: None = None) -> str: ... + + @overload + def dump(self, data: DataDict, output: "TextIO | Path | str") -> None: ... + + 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)): + with open(output, "w", encoding="UTF-8") as file: + json.dump(data, file, **self.config) + return None + elif hasattr(output, "write"): + json.dump(data, output, **self.config) + return None + else: + raise TypeError( + f"Output should be None, path or open file, got {type_name(output)}." + ) diff --git a/src/robot/utils/markdown.py b/src/robot/utils/markdown.py new file mode 100644 index 00000000000..d9448bab224 --- /dev/null +++ b/src/robot/utils/markdown.py @@ -0,0 +1,74 @@ +# 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. + +"""Module containing Markdown related utilities.""" + +import re +from xml.etree import ElementTree as ET + +try: + from markdown import Markdown as Markdown + from markdown.extensions import Extension + from markdown.inlinepatterns import InlineProcessor +except ImportError: + from robot.errors import DataError + + def Markdown(*args, **kwargs): + raise DataError("Markdown format requires 'markdown' module to be installed.") + + Extension = InlineProcessor = object + + +__all__ = ["LinkifyExtension", "Markdown"] + + +class LinkifyExtension(Extension): + """Python-Markdown extension for automatically linkifying URLs. + + Supports only "normal" URLs like `http://example.com`, not mailto URLs like + `mailto:info@example.com`. If mailto URLs are needed, or if URL detection + does not work properly otherwise, URLs can be surrounded with angle brackets + like ``. That is standard Markdown syntax and handled + natively by Python-Markdown. + """ + + pattern = r""" + ( # URL group. + [a-z][\w+-.]* # Protocol. Supports also protocols like 'git+ssh'. + :// # Literal '://'. + \S+? # URL itself. Anything but whitespace. Non-greedy. + ) + ( # Tail group. + [])}"'.,!?:;]* # Possible closing braces, quotes, dots, etc. + (?=\s|$) # Whitespace or end of string. Non-capturing group. + ) + """ + + def extendMarkdown(self, md): + processor = LinkifyInlineProcessor(self.pattern, md) + md.inlinePatterns.register(processor, "linkify", 10) + + +class LinkifyInlineProcessor(InlineProcessor): + + def getCompiledRegExp(self): + return re.compile(self.pattern, re.DOTALL | re.IGNORECASE | re.VERBOSE) + + def handleMatch(self, match, data): + url, tail = match.groups() + el = ET.Element("a", href=url) + el.text = url + el.tail = tail + return el, match.start(0), match.end(0) diff --git a/src/robot/utils/markuputils.py b/src/robot/utils/markuputils.py index c503f246aec..5a579dc3059 100644 --- a/src/robot/utils/markuputils.py +++ b/src/robot/utils/markuputils.py @@ -15,34 +15,39 @@ import re -from .htmlformatters import LinkFormatter, HtmlFormatter - +from .htmlformatters import HtmlFormatter, LinkFormatter _format_url = LinkFormatter().format_url -_generic_escapes = (('&', '&'), ('<', '<'), ('>', '>')) -_attribute_escapes = _generic_escapes \ - + (('"', '"'), ('\n', ' '), ('\r', ' '), ('\t', ' ')) -_illegal_chars_in_xml = re.compile(u'[\x00-\x08\x0B\x0C\x0E-\x1F\uFFFE\uFFFF]') +_format_html = HtmlFormatter().format +_generic_escapes = (("&", "&"), ("<", "<"), (">", ">")) +_attribute_escapes = ( + *_generic_escapes, + ('"', """), + ("\n", " "), + ("\r", " "), + ("\t", " "), +) +_illegal_chars_in_xml = re.compile("[\x00-\x08\x0b\x0c\x0e-\x1f\ufffe\uffff]") def html_escape(text, linkify=True): text = _escape(text) - if linkify and '://' in text: + if linkify and "://" in text: text = _format_url(text) return text def xml_escape(text): - return _illegal_chars_in_xml.sub('', _escape(text)) + return _illegal_chars_in_xml.sub("", _escape(text)) def html_format(text): - return HtmlFormatter().format(_escape(text)) + return _format_html(_escape(text)) def attribute_escape(attr): attr = _escape(attr, _attribute_escapes) - return _illegal_chars_in_xml.sub('', attr) + return _illegal_chars_in_xml.sub("", attr) def _escape(text, escapes=_generic_escapes): diff --git a/src/robot/utils/markupwriters.py b/src/robot/utils/markupwriters.py index 82333f7c437..9710c354def 100644 --- a/src/robot/utils/markupwriters.py +++ b/src/robot/utils/markupwriters.py @@ -13,47 +13,51 @@ # See the License for the specific language governing permissions and # limitations under the License. +from os import PathLike + from .markuputils import attribute_escape, html_escape, xml_escape -from .robottypes import is_string from .robotio import file_writer -class _MarkupWriter(object): +class _MarkupWriter: - def __init__(self, output, write_empty=True): + def __init__(self, output, write_empty=True, usage=None, preamble=True): """ :param output: Either an opened, file like object, or a path to the desired output file. In the latter case, the file is created and clients should use :py:meth:`close` method to close it. :param write_empty: Whether to write empty elements and attributes. """ - if is_string(output): - output = file_writer(output) + if isinstance(output, (str, PathLike)): + output = file_writer(output, usage=usage) self.output = output self._write_empty = write_empty - self._preamble() + if preamble: + self._preamble() 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('<%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): + def _format_attrs(self, attrs, write_empty): if not attrs: - return '' - attrs = [(k, attribute_escape(attrs[k] or '')) - for k in self._order_attrs(attrs)] - write_empty = self._write_empty - return ' '.join('%s="%s"' % a for a in attrs if write_empty or a[1]) + return "" + 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 + ) def _order_attrs(self, attrs): - return attrs + return attrs.items() def content(self, content=None, escape=True, newline=False): if content: @@ -63,14 +67,23 @@ def _escape(self, content): raise NotImplementedError def end(self, name, newline=True): - self._write('' % name, newline) - - def element(self, name, content=None, attrs=None, escape=True, - newline=True, replace_newlines=False): - attrs = self._format_attrs(attrs) - if self._write_empty or content or attrs: + self._write(f"", newline) + + 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, replace_newlines) + self.content(content, escape) self.end(name, newline) def close(self): @@ -80,13 +93,13 @@ def close(self): def _write(self, text, newline=False): self.output.write(text) if newline: - self.output.write('\n') + self.output.write("\n") class HtmlWriter(_MarkupWriter): def _order_attrs(self, attrs): - return sorted(attrs) # eases testing + return sorted(attrs.items()) # eases testing def _escape(self, content): return html_escape(content) @@ -100,9 +113,29 @@ def _preamble(self): def _escape(self, text): return xml_escape(text) + def element( + self, + name, + content=None, + attrs=None, + escape=True, + newline=True, + write_empty=None, + ): + if content: + super().element(name, content, attrs, escape, newline, write_empty) + else: + self._self_closing_element(name, attrs, newline, write_empty) + + 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) + -class NullMarkupWriter(object): +class NullMarkupWriter: """Null implementation of the _MarkupWriter interface.""" - __init__ = start = content = element = end = close = \ - lambda *args, **kwargs: None + __init__ = start = content = element = end = close = lambda *args, **kwargs: None diff --git a/src/robot/utils/match.py b/src/robot/utils/match.py index 88ac3440bc9..07306166ade 100644 --- a/src/robot/utils/match.py +++ b/src/robot/utils/match.py @@ -13,78 +13,92 @@ # See the License for the specific language governing permissions and # limitations under the License. -import re import fnmatch -from functools import partial +import re +from typing import Iterable, Iterator, Sequence -from .compat import py2to3 from .normalizing import normalize -from .platform import IRONPYTHON, PY3 -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 -@py2to3 -class Matcher(object): +class Matcher: - def __init__(self, pattern, ignore=(), caseless=True, spaceless=True, - regexp=False): - if PY3 and isinstance(pattern, bytes): - raise TypeError('Matching bytes is not supported on Python 3.') + 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) + # Make normalizing a no-op if nothing needs to be normalized. + 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): if not regexp: pattern = fnmatch.translate(pattern) - # https://github.com/IronLanguages/ironpython2/issues/515 - if IRONPYTHON and "\\'" in pattern: - pattern = pattern.replace("\\'", "'") 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: Iterable[str]) -> bool: return any(self.match(s) for s in strings) - def __nonzero__(self): + def __bool__(self) -> bool: return bool(self._normalize(self.pattern)) -class MultiMatcher(object): - - 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 _ensure_list(self, patterns): +class MultiMatcher(Iterable[Matcher]): + + 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_iterable(patterns) + ] + self.match_if_no_patterns = match_if_no_patterns + + def _ensure_iterable(self, patterns): if patterns is None: - return [] - if is_string(patterns): - return [patterns] + return () + if isinstance(patterns, str): + 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: Iterable[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/src/robot/utils/misc.py b/src/robot/utils/misc.py index 7a236535f3a..2f45fe79fbb 100644 --- a/src/robot/utils/misc.py +++ b/src/robot/utils/misc.py @@ -13,43 +13,10 @@ # See the License for the specific language governing permissions and # limitations under the License. -from __future__ import division +import re +from typing import Any, Literal -from operator import add, sub - -from .platform import PY2 -from .robottypes import is_integer -from .unic import unic - - -def roundup(number, ndigits=0, return_type=None): - """Rounds number to the given number of digits. - - Numbers equally close to a certain precision are always rounded away from - zero. By default return value is float when ``ndigits`` is positive and - int otherwise, but that can be controlled with ``return_type``. - - With the built-in ``round()`` rounding equally close numbers as well as - the return type depends on the Python version. - """ - result = _roundup(number, ndigits) - if not return_type: - return_type = float if ndigits > 0 else int - return return_type(result) - - -# Python 2 rounds half away from zero (as taught in school) but Python 3 -# uses "bankers' rounding" that rounds half towards the even number. We want -# consistent rounding and expect Python 2 style to be more familiar for users. -if PY2: - _roundup = round -else: - def _roundup(number, ndigits): - precision = 10 ** (-1 * ndigits) - if number % (0.5 * precision) == 0 and number % precision != 0: - operator = add if number > 0 else sub - number = operator(number, 0.1 * precision) - return round(number, ndigits) +from .unic import safe_str def printable_name(string, code_style=False): @@ -71,27 +38,26 @@ def printable_name(string, code_style=False): 'miXed_CAPS_nAMe' -> 'MiXed CAPS NAMe' '' -> '' """ - if code_style and '_' in string: - string = string.replace('_', ' ') + if code_style and "_" in string: + string = string.replace("_", " ") parts = string.split() - if code_style and len(parts) == 1 \ - and not (string.isalpha() and string.islower()): + if code_style and len(parts) == 1 and not (string.isalpha() and string.islower()): parts = _split_camel_case(parts[0]) - return ' '.join(part[0].upper() + part[1:] for part in parts) + return " ".join(part[0].upper() + part[1:] for part in parts) def _split_camel_case(string): tokens = [] token = [] - for prev, char, next in zip(' ' + string, string, string[1:] + ' '): + for prev, char, next in zip(" " + string, string, string[1:] + " "): if _is_camel_case_boundary(prev, char, next): if token: - tokens.append(''.join(token)) + tokens.append("".join(token)) token = [char] else: token.append(char) if token: - tokens.append(''.join(token)) + tokens.append("".join(token)) return tokens @@ -104,15 +70,15 @@ def _is_camel_case_boundary(prev, char, next): def plural_or_not(item): - count = item if is_integer(item) else len(item) - return '' if count == 1 else 's' + count = item if isinstance(item, int) else len(item) + return "" if count in (1, -1) else "s" -def seq2str(sequence, quote="'", sep=', ', lastsep=' and '): +def seq2str(sequence, quote="'", sep=", ", lastsep=" and "): """Returns sequence in format `'item 1', 'item 2' and 'item 3'`.""" - sequence = [quote + unic(item) + quote for item in sequence] + sequence = [f"{quote}{safe_str(item)}{quote}" for item in sequence] if not sequence: - return '' + return "" if len(sequence) == 1: return sequence[0] last_two = lastsep.join(sequence[-2:]) @@ -122,5 +88,100 @@ def seq2str(sequence, quote="'", sep=', ', lastsep=' and '): def seq2str2(sequence): """Returns sequence in format `[ item 1 | item 2 | ... ]`.""" if not sequence: - return '[ ]' - return '[ %s ]' % ' | '.join(unic(item) for item in sequence) + return "[ ]" + items = " | ".join(safe_str(item) for item in sequence) + return f"[ {items} ]" + + +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, + 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)) + + if text.upper() == "TEST": + return replace(text) + return re.sub("{(test)}", lambda m: replace(m.group(1)), text, flags=re.IGNORECASE) + + +def isatty(stream): + # first check if buffer was detached + if hasattr(stream, "buffer") and stream.buffer is None: + return False + if not hasattr(stream, "isatty"): + return False + try: + return stream.isatty() + except ValueError: # Occurs if file is closed. + return False + + +def parse_re_flags(flags: "str | None" = None) -> int: + 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 + + +def validate_literal(value: Any, literal: "type[Literal]", kind: str = "value") -> Any: + upper = value.upper() if isinstance(value, str) else None + available = literal.__args__ + matches = [] + for item in available: + if item == value: + if type(item) is type(value): + return item + matches.append(item) + elif isinstance(item, str) and item.upper() == upper: + matches.append(item) + if len(matches) == 1: + return matches[0] + raise ValueError(f"Invalid {kind} '{value}'. Available: {seq2str(available)}.") + + +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/src/robot/utils/normalizing.py b/src/robot/utils/normalizing.py index d6bd6367239..567053d2403 100644 --- a/src/robot/utils/normalizing.py +++ b/src/robot/utils/normalizing.py @@ -13,101 +13,146 @@ # See the License for the specific language governing permissions and # limitations under the License. -from collections import MutableMapping +import re +from collections.abc import Iterable, Iterator, Mapping, Sequence +from typing import MutableMapping, TypeVar -from .platform import IRONPYTHON, PY_VERSION, PY3 -from .robottypes import is_dict_like, is_unicode +from .robottypes import type_name +V = TypeVar("V") +Self = TypeVar("Self", bound="NormalizedDict") -def normalize(string, ignore=(), caseless=True, spaceless=True): - """Normalizes given string according to given spec. - By default string is turned to lower case and all whitespace is removed. - Additional characters can be removed by giving them in ``ignore`` list. +def normalize( + string: str, + ignore: "Sequence[str]" = (), + caseless: bool = True, + spaceless: bool = True, +) -> str: + """Normalize the ``string`` according to the given spec. + + By default, string is turned to lower case (actually case-folded) and all + whitespace is removed. Additional characters can be removed by giving them + in ``ignore`` list. """ - empty = u'' if is_unicode(string) else b'' - if PY3 and isinstance(ignore, bytes): - # Iterating bytes in Python3 yields integers. - ignore = [bytes([i]) for i in ignore] if spaceless: - string = empty.join(string.split()) + string = "".join(string.split()) if caseless: - string = lower(string) - ignore = [lower(i) for i in ignore] + string = string.casefold() + ignore = [i.casefold() for i in ignore] # both if statements below enhance performance a little if ignore: for ign in ignore: if ign in string: - string = string.replace(ign, empty) + string = string.replace(ign, "") return string -# http://ironpython.codeplex.com/workitem/33133 -if IRONPYTHON and PY_VERSION < (2, 7, 5): - def lower(string): - return ('A' + string).lower()[1:] -else: - def lower(string): - return string.lower() +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: - self._add_initial(initial) - - def _add_initial(self, initial): - items = initial.items() if hasattr(initial, 'items') else initial - for key, value in items: - self[key] = value - - def __getitem__(self, key): + self.update(initial) + + @classmethod + def fromkeys( + cls, + keys: "Iterable[str]", + value: "V | None" = None, + /, + *, + ignore: "Sequence[str]" = (), + caseless: bool = True, + spaceless: bool = True, + ) -> "NormalizedDict[V | None]": + """Convenience method to create a NormalizedDict from keys. + + This matches ``dict.fromkeys`` but is not part of the Mapping API. + + New in Robot Framework 7.4.1. + """ + return cls( + dict.fromkeys(keys, value), + ignore=ignore, + caseless=caseless, + spaceless=spaceless, + ) + + @property + def normalized_keys(self) -> "tuple[str, ...]": + return tuple(self._keys) + + def __getitem__(self, key: str) -> V: + if not isinstance(key, str): + raise KeyError(key) return self._data[self._normalize(key)] - def __setitem__(self, key, value): + def __setitem__(self, key: str, value: V): + if not isinstance(key, str): + raise TypeError( + f"{type_name(self)} only accepts strings as keys, " + f"got {type_name(key)}." + ) 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): + if not isinstance(key, str): + raise KeyError(key) 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): - return '{%s}' % ', '.join('%r: %r' % (key, self[key]) for key in self) + def __str__(self) -> str: + items = ", ".join(f"{key!r}: {self[key]!r}" for key in self) + return f"{{{items}}}" + + def __repr__(self) -> str: + name = type(self).__name__ + params = str(self) if self else "" + return f"{name}({params})" - def __eq__(self, other): - if not is_dict_like(other): + def __eq__(self, other: object) -> bool: + try: + if not isinstance(other, Mapping): + return False + except AttributeError: return False if not isinstance(other, NormalizedDict): other = NormalizedDict(other) return self._data == other._data - def __ne__(self, other): - return not self == other - - 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 @@ -115,8 +160,8 @@ def copy(self): # Speed-ups. Following methods are faster than default implementations. - def __contains__(self, key): - return self._normalize(key) in self._data + def __contains__(self, key: str) -> bool: + return isinstance(key, str) and self._normalize(key) in self._data def clear(self): self._data.clear() diff --git a/src/robot/utils/notset.py b/src/robot/utils/notset.py new file mode 100644 index 00000000000..25c0070dfef --- /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. + + ``robot.utils.NOT_SET`` is an instance of this class, but it in same cases + it is better to create a separate instance. + + New in Robot Framework 7.0. + """ + + def __repr__(self): + return "" + + +NOT_SET = NotSet() diff --git a/src/robot/utils/platform.py b/src/robot/utils/platform.py index 711d60598fa..c561c1e5462 100644 --- a/src/robot/utils/platform.py +++ b/src/robot/utils/platform.py @@ -14,26 +14,39 @@ # limitations under the License. import os -import re import sys - -java_match = re.match(r'java(\d+)\.(\d+)\.(\d+)', sys.platform) -if java_match: - JYTHON = True - JAVA_VERSION = tuple(int(i) for i in java_match.groups()) -else: - JYTHON = False - JAVA_VERSION = (0, 0, 0) PY_VERSION = sys.version_info[:3] -PY2 = PY_VERSION[0] == 2 -PY3 = not PY2 -IRONPYTHON = sys.platform == 'cli' -PYPY = 'PyPy' in sys.version -UNIXY = os.sep == '/' +PYPY = "PyPy" in sys.version +UNIXY = os.sep == "/" WINDOWS = not UNIXY -RERAISED_EXCEPTIONS = (KeyboardInterrupt, SystemExit, MemoryError) -if JYTHON: - from java.lang import OutOfMemoryError - RERAISED_EXCEPTIONS += (OutOfMemoryError,) + +def isatty(stream): + # first check if buffer was detached + if hasattr(stream, "buffer") and stream.buffer is None: + return False + if not hasattr(stream, "isatty"): + return False + try: + 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 9.0.", + DeprecationWarning, + ) + return False + + raise AttributeError(f"'robot.utils.platform' has no attribute '{name}'.") diff --git a/src/robot/utils/recommendations.py b/src/robot/utils/recommendations.py index b7c76d5a67d..691d59a4d2d 100644 --- a/src/robot/utils/recommendations.py +++ b/src/robot/utils/recommendations.py @@ -14,62 +14,96 @@ # limitations under the License. import difflib +from collections.abc import Sequence +from typing import Callable +from robot.utils import seq2str -class RecommendationFinder(object): - def __init__(self, normalizer=None): +class RecommendationFinder: + + def __init__(self, normalizer: "Callable[[str], str] | None" = None): self.normalizer = normalizer or (lambda x: x) - def find_recommendations(self, name, candidates, max_matches=10): + def find_and_format( + self, + name: str, + candidates: "Sequence[str]", + message: str, + max_matches: int = 10, + check_missing_argument_separator: bool = False, + ) -> str: + recommendations = self.find(name, candidates, max_matches) + if recommendations: + return self.format(message, recommendations) + if check_missing_argument_separator and name: + recommendation = self._check_missing_argument_separator(name, candidates) + if recommendation: + return f"{message} {recommendation}" + return message + + def find( + self, + name: str, + candidates: "Sequence[str]", + max_matches: int = 10, + ) -> "list[str]": """Return a list of close matches to `name` from `candidates`.""" if not name or not candidates: return [] norm_name = self.normalizer(name) norm_candidates = self._get_normalized_candidates(candidates) cutoff = self._calculate_cutoff(norm_name) - norm_matches = difflib.get_close_matches(norm_name, - norm_candidates, - n=max_matches, - cutoff=cutoff) - return self._get_original_candidates(norm_candidates, norm_matches) + norm_matches = difflib.get_close_matches( + norm_name, norm_candidates, n=max_matches, cutoff=cutoff + ) + return self._get_original_candidates(norm_matches, norm_candidates) - @staticmethod - def format_recommendations(msg, recommendations): + def format(self, message: str, recommendations: "Sequence[str]") -> str: """Add recommendations to the given message. - The recommendation string looks like: - Did you mean: - - - + The recommendation string looks like:: + + Did you mean: + + + """ if recommendations: - msg += " Did you mean:" + message += " Did you mean:" for rec in recommendations: - msg += "\n %s" % rec - return msg + message += f"\n {rec}" + return message def _get_normalized_candidates(self, candidates): norm_candidates = {} - # sort before normalization for consistent Python/Jython ordering for cand in sorted(candidates): norm = self.normalizer(cand) norm_candidates.setdefault(norm, []).append(cand) return norm_candidates - def _get_original_candidates(self, norm_candidates, norm_matches): + def _get_original_candidates(self, norm_matches, norm_candidates): candidates = [] - for norm_match in norm_matches: - candidates.extend(norm_candidates[norm_match]) + for match in norm_matches: + candidates.extend(norm_candidates[match]) return candidates - def _calculate_cutoff(self, string, min_cutoff=.5, max_cutoff=.85, - step=.03): + def _calculate_cutoff(self, string, min_cutoff=0.5, max_cutoff=0.85, step=0.03): """Calculate a cutoff depending on string length. - Default values determined by manual tuning until the results - "look right". + Default values determined by manual tuning until the results "look right". """ cutoff = min_cutoff + len(string) * step return min(cutoff, max_cutoff) + + def _check_missing_argument_separator(self, name, candidates): + name = self.normalizer(name) + candidates = self._get_normalized_candidates(candidates) + matches = [c for c in candidates if name.startswith(c)] + if not matches: + return None + candidates = self._get_original_candidates(matches, candidates) + return ( + f"Did you try using keyword {seq2str(candidates, lastsep=' or ')} " + f"and forgot to use enough whitespace between keyword and arguments?" + ) diff --git a/src/robot/utils/robotenv.py b/src/robot/utils/robotenv.py index 3d0981f5b10..07270e7f53d 100644 --- a/src/robot/utils/robotenv.py +++ b/src/robot/utils/robotenv.py @@ -38,7 +38,9 @@ def del_env_var(name): return value -def get_env_vars(upper=os.sep != '/'): +def get_env_vars(upper=os.sep != "/"): # by default, name is upper-cased on Windows regardless interpreter - return dict((name if not upper else name.upper(), get_env_var(name)) - for name in (decode(name) for name in os.environ)) + return { + name.upper() if upper else name: get_env_var(name) + for name in (decode(name) for name in os.environ) + } diff --git a/src/robot/utils/robotinspect.py b/src/robot/utils/robotinspect.py index af5e961d5ab..673777ad5c1 100644 --- a/src/robot/utils/robotinspect.py +++ b/src/robot/utils/robotinspect.py @@ -13,24 +13,15 @@ # See the License for the specific language governing permissions and # limitations under the License. -from .platform import JYTHON +import inspect +from .platform import PYPY -if JYTHON: - from org.python.core import PyReflectedFunction, PyReflectedConstructor - - def is_java_init(init): - return isinstance(init, PyReflectedConstructor) - - def is_java_method(method): - func = method.im_func if hasattr(method, 'im_func') else method - return isinstance(func, PyReflectedFunction) - -else: - - def is_java_init(init): - return False - - def is_java_method(method): +def is_init(method): + if not method: return False + # https://foss.heptapod.net/pypy/pypy/-/issues/2462 + if PYPY: + return method is not object.__init__ + return inspect.isfunction(method) diff --git a/src/robot/utils/robotio.py b/src/robot/utils/robotio.py index e538db12ca8..d5ca3022daf 100644 --- a/src/robot/utils/robotio.py +++ b/src/robot/utils/robotio.py @@ -13,31 +13,47 @@ # See the License for the specific language governing permissions and # limitations under the License. -import io +import os.path +from io import BytesIO, StringIO +from pathlib import Path -from .platform import PY3 +from robot.errors import DataError +from .error import get_error_message -def file_writer(path=None, encoding='UTF-8', newline=None): - if path: - f = io.open(path, 'w', encoding=encoding, newline=newline) - else: - f = io.StringIO(newline=newline) - if PY3: - return f - # These streams require written text to be Unicode. We don't want to add - # `u` prefix to all our strings in Python 2, and cannot really use - # `unicode_literals` either because many other Python 2 APIs accept only - # byte strings. - write = f.write - f.write = lambda text: write(unicode(text)) - return f + +def file_writer(path=None, encoding="UTF-8", newline=None, usage=None): + if not path: + return StringIO(newline=newline) + if isinstance(path, Path): + path = str(path) + create_destination_directory(path, usage) + try: + return open(path, "w", encoding=encoding, newline=newline) + except EnvironmentError: + usage = f"{usage} file" if usage else "file" + raise DataError(f"Opening {usage} '{path}' failed: {get_error_message()}") def binary_file_writer(path=None): if path: - return io.open(path, 'wb') - f = io.BytesIO() - getvalue = f.getvalue - f.getvalue = lambda encoding='UTF-8': getvalue().decode(encoding) - return f + if isinstance(path, Path): + path = str(path) + return open(path, "wb") + writer = BytesIO() + getvalue = writer.getvalue + writer.getvalue = lambda encoding="UTF-8": getvalue().decode(encoding) + return writer + + +def create_destination_directory(path: "Path | str", usage=None): + if not isinstance(path, Path): + path = Path(path) + if not path.parent.exists(): + try: + os.makedirs(path.parent, exist_ok=True) + except EnvironmentError: + usage = f"{usage} directory" if usage else "directory" + raise DataError( + f"Creating {usage} '{path.parent}' failed: {get_error_message()}" + ) diff --git a/src/robot/utils/robotpath.py b/src/robot/utils/robotpath.py index 7c6897ffe11..90d8f95e552 100644 --- a/src/robot/utils/robotpath.py +++ b/src/robot/utils/robotpath.py @@ -16,40 +16,20 @@ import os import os.path import sys +from pathlib import Path +from urllib.request import pathname2url as path_to_url from robot.errors import DataError from .encoding import system_decode -from .platform import IRONPYTHON, PY_VERSION, PY2, WINDOWS -from .robottypes import is_unicode -from .unic import unic - - -if IRONPYTHON and PY_VERSION == (2, 7, 8): - # https://github.com/IronLanguages/ironpython2/issues/371 - def _abspath(path): - if os.path.isabs(path): - if not os.path.splitdrive(path)[0]: - drive = os.path.splitdrive(os.getcwd())[0] - return drive + path - return path - return os.path.abspath(path) -else: - _abspath = os.path.abspath - -if PY2: - from urllib import pathname2url - - def path_to_url(path): - return pathname2url(path.encode('UTF-8')) -else: - from urllib.request import pathname2url as path_to_url +from .platform import WINDOWS +from .unic import safe_str if WINDOWS: CASE_INSENSITIVE_FILESYSTEM = True else: try: - CASE_INSENSITIVE_FILESYSTEM = os.listdir('/tmp') == os.listdir('/TMP') + CASE_INSENSITIVE_FILESYSTEM = os.listdir("/tmp") == os.listdir("/TMP") except OSError: CASE_INSENSITIVE_FILESYSTEM = False @@ -63,14 +43,16 @@ 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:``. """ - if not is_unicode(path): + if isinstance(path, Path): + path = str(path) + elif not isinstance(path, str): path = system_decode(path) - path = unic(path) # Handles NFC normalization on OSX + path = safe_str(path) # Handles NFC normalization on OSX path = os.path.normpath(path) if case_normalize and CASE_INSENSITIVE_FILESYSTEM: path = path.lower() - if WINDOWS and len(path) == 2 and path[1] == ':': - return path + '\\' + if WINDOWS and len(path) == 2 and path[1] == ":": + return path + "\\" return path @@ -83,7 +65,7 @@ def abspath(path, case_normalize=False): 3. Turn ``c:`` into ``c:\\`` on Windows instead of ``c:\\current\\path``. """ path = normpath(path, case_normalize) - return normpath(_abspath(path), case_normalize) + return normpath(os.path.abspath(path), case_normalize) def get_link_path(target, base): @@ -98,16 +80,17 @@ def get_link_path(target, base): path = _get_link_path(target, base) url = path_to_url(path) if os.path.isabs(path): - url = 'file:' + url + url = "file:" + url return url + def _get_link_path(target, base): target = abspath(target) base = abspath(base) if os.path.isfile(base): base = os.path.dirname(base) if base == target: - return '.' + return "." base_drive, base_path = os.path.splitdrive(base) # Target and base on different drives if os.path.splitdrive(target)[0] != base_drive: @@ -118,9 +101,10 @@ def _get_link_path(target, base): if common_len == len(base_drive) + len(os.sep): common_len -= len(os.sep) dirs_up = os.sep.join([os.pardir] * base[common_len:].count(os.sep)) - path = os.path.join(dirs_up, target[common_len + len(os.sep):]) + path = os.path.join(dirs_up, target[common_len + len(os.sep) :]) return os.path.normpath(path) + def _common_path(p1, p2): """Returns the longest path common to p1 and p2. @@ -128,6 +112,12 @@ def _common_path(p1, p2): path separators as such, so it may return invalid paths: commonprefix(('/foo/bar/', '/foo/baz.txt')) -> '/foo/ba' (instead of /foo) """ + # os.path.dirname doesn't normalize leading double slash + # https://github.com/robotframework/robotframework/issues/3844 + if p1.startswith("//"): + p1 = "/" + p1.lstrip("/") + if p2.startswith("//"): + p2 = "/" + p2.lstrip("/") while p1 and p2: if p1 == p2: return p1 @@ -135,22 +125,18 @@ def _common_path(p1, p2): p1 = os.path.dirname(p1) else: p2 = os.path.dirname(p2) - return '' + return "" -def find_file(path, basedir='.', file_type=None): - path = os.path.normpath(path.replace('/', os.sep)) +def find_file(path, basedir=".", file_type=None): + path = os.path.normpath(path.replace("/", os.sep)) if os.path.isabs(path): ret = _find_absolute_path(path) else: ret = _find_relative_path(path, basedir) if ret: return ret - default = file_type or 'File' - file_type = {'Library': 'Test 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): @@ -160,10 +146,10 @@ def _find_absolute_path(path): def _find_relative_path(path, basedir): - for base in [basedir] + sys.path: + for base in [basedir, *sys.path]: if not (base and os.path.isdir(base)): continue - if not is_unicode(base): + if not isinstance(base, str): base = system_decode(base) ret = os.path.abspath(os.path.join(base, path)) if _is_valid_file(ret): @@ -172,5 +158,6 @@ def _find_relative_path(path, basedir): def _is_valid_file(path): - return os.path.isfile(path) or \ - (os.path.isdir(path) and os.path.isfile(os.path.join(path, '__init__.py'))) + return os.path.isfile(path) or ( + os.path.isdir(path) and os.path.isfile(os.path.join(path, "__init__.py")) + ) diff --git a/src/robot/utils/robottime.py b/src/robot/utils/robottime.py index 06432a4a689..68fed39bb48 100644 --- a/src/robot/utils/robottime.py +++ b/src/robot/utils/robottime.py @@ -13,16 +13,15 @@ # See the License for the specific language governing permissions and # limitations under the License. -import datetime -import time import re +import time +import warnings +from datetime import datetime, timedelta +from .misc import plural_or_not as s from .normalizing import normalize -from .misc import plural_or_not, roundup -from .robottypes import is_number, is_string - -_timer_re = re.compile(r'^([+-])?(\d+:)?(\d+):(\d+)(\.\d+)?$') +_timer_re = re.compile(r"^([+-])?(\d+:)?(\d+):(\d+)(\.\d+)?$") def _get_timetuple(epoch_secs=None): @@ -30,22 +29,37 @@ def _get_timetuple(epoch_secs=None): epoch_secs = time.time() secs, millis = _float_secs_to_secs_and_millis(epoch_secs) timetuple = time.localtime(secs)[:6] # from year to secs - return timetuple + (millis,) + return (*timetuple, millis) + def _float_secs_to_secs_and_millis(secs): isecs = int(secs) - millis = roundup((secs - isecs) * 1000) - return (isecs, millis) if millis < 1000 else (isecs+1, 0) + millis = round((secs - isecs) * 1000) + return (isecs, millis) if millis < 1000 else (isecs + 1, 0) -def timestr_to_secs(timestr, round_to=3): - """Parses time like '1h 10s', '01:00:10' or '42' and returns seconds.""" - if is_string(timestr) or is_number(timestr): - for converter in _number_to_secs, _timer_to_secs, _time_string_to_secs: +def timestr_to_secs( + timestr: "timedelta | int | float | str", + round_to: "int | None" = 3, +) -> float: + """Parses time strings like '1h 10s', '01:00:10' and '42' and returns seconds. + + Time can also be given as an integer or float or, starting from RF 6.0.1, + as a `timedelta` instance. + + The result is rounded according to the `round_to` argument. + Use `round_to=None` to disable rounding altogether. + """ + if isinstance(timestr, (str, int, float)): + 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 roundup(secs, round_to) - raise ValueError("Invalid time string '%s'." % timestr) + return secs if round_to is None else round(secs, round_to) + if isinstance(timestr, timedelta): + return timestr.total_seconds() + raise ValueError(f"Invalid time string '{timestr}'.") + def _number_to_secs(number): try: @@ -53,6 +67,7 @@ def _number_to_secs(number): except ValueError: return None + def _timer_to_secs(number): match = _timer_re.match(number) if not match: @@ -62,52 +77,91 @@ def _timer_to_secs(number): if hours: seconds += float(hours[:-1]) * 60 * 60 if millis: - seconds += float(millis[1:]) / 10**len(millis[1:]) - if prefix == '-': + seconds += float(millis[1:]) / 10 ** len(millis[1:]) + if prefix == "-": seconds *= -1 return seconds + def _time_string_to_secs(timestr): - timestr = _normalize_timestr(timestr) - if not timestr: + try: + timestr = _normalize_timestr(timestr) + except ValueError: return None - millis = secs = mins = hours = days = 0 - if timestr[0] == '-': + nanos = micros = millis = secs = mins = hours = days = weeks = 0 + if timestr[0] == "-": sign = -1 timestr = timestr[1:] else: sign = 1 temp = [] for c in timestr: - try: - if 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 = [] - elif c == 'd': days = float(''.join(temp)); temp = [] - else: temp.append(c) - except ValueError: - return None + if c in ("n", "u", "M", "s", "m", "h", "d", "w"): + try: + value = float("".join(temp)) + except ValueError: + return None + if c == "n": + nanos = value + elif c == "u": + micros = value + elif c == "M": + millis = value + elif c == "s": + secs = value + elif c == "m": + mins = value + elif c == "h": + hours = value + elif c == "d": + days = value + elif c == "w": + weeks = value + temp = [] + else: + temp.append(c) 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 / 1e3 + + secs + + mins * 60 + + hours * 60 * 60 + + days * 60 * 60 * 24 + + weeks * 60 * 60 * 24 * 7 + ) + def _normalize_timestr(timestr): timestr = normalize(timestr) - for specifier, aliases in [('x', ['millisecond', 'millisec', 'millis', - 'msec', 'ms']), - ('s', ['second', 'sec']), - ('m', ['minute', 'min']), - ('h', ['hour']), - ('d', ['day'])]: - plural_aliases = [a+'s' for a in aliases if not a.endswith('s')] + if not timestr: + raise ValueError + seen = [] + for specifier, aliases in [ + ("n", ["nanosecond", "ns"]), + ("u", ["microsecond", "us", "μs"]), + ("M", ["millisecond", "millisec", "millis", "msec", "ms"]), + ("s", ["second", "sec"]), + ("m", ["minute", "min"]), + ("h", ["hour"]), + ("d", ["day"]), + ("w", ["week"]), + ]: + plural_aliases = [a + "s" for a in aliases if not a.endswith("s")] for alias in plural_aliases + aliases: if alias in timestr: timestr = timestr.replace(alias, specifier) + if specifier in timestr: # There are false positives but that's fine. + seen.append(specifier) + for specifier in seen: + if timestr.count(specifier) > 1: + raise ValueError 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 @@ -115,12 +169,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() @@ -129,18 +185,17 @@ 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: - return self._sign + ' '.join(self._ret) - return '0s' if self._compact else '0 seconds' + return self._sign + " ".join(self._ret) + return "0s" if self._compact else "0 seconds" def _add_item(self, value, compact_suffix, long_suffix): if value == 0: @@ -148,15 +203,15 @@ def _add_item(self, value, compact_suffix, long_suffix): if self._compact: suffix = compact_suffix else: - suffix = ' %s%s' % (long_suffix, plural_or_not(value)) - self._ret.append('%d%s' % (value, suffix)) + suffix = f" {long_suffix}{s(value)}" + self._ret.append(f"{value}{suffix}") def _secs_to_components(self, float_secs): if float_secs < 0: - sign = '- ' + sign = "- " float_secs = abs(float_secs) else: - sign = '' + sign = "" int_secs, millis = _float_secs_to_secs_and_millis(float_secs) secs = int_secs % 60 mins = int_secs // 60 % 60 @@ -165,36 +220,38 @@ def _secs_to_components(self, float_secs): return sign, millis, secs, mins, hours, days -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. - """ - if is_number(timetuple_or_epochsecs): +def format_time( + timetuple_or_epochsecs, + daysep="", + daytimesep=" ", + timesep=":", + millissep=None, +): + """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 isinstance(timetuple_or_epochsecs, (int, float)): timetuple = _get_timetuple(timetuple_or_epochsecs) else: timetuple = timetuple_or_epochsecs - daytimeparts = ['%02d' % t for t in timetuple[:6]] - day = daysep.join(daytimeparts[:3]) - time_ = timesep.join(daytimeparts[3:6]) - millis = millissep and '%s%03d' % (millissep, timetuple[6]) or '' + parts = [f"{t:02}" for t in timetuple[:6]] + day = daysep.join(parts[:3]) + time_ = timesep.join(parts[3:6]) + millis = f"{millissep}{timetuple[6]:03}" if millissep else "" return day + daytimesep + time_ + millis -def get_time(format='timestamp', time_=None): +def get_time( + format: str = "timestamp", + time_: "float | None" = None, +) -> "int | str | list[str]": """Return the given or current time in requested format. If time is not given, current time is used. How time is returned is - is deternined based on the given 'format' string as follows. Note that all - checks are case insensitive. + determined based on the given 'format' string as follows. Note that all + checks are case-insensitive. - If 'format' contains word 'epoch' the time is returned in seconds after the unix epoch. @@ -206,24 +263,80 @@ 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: + 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: + if len(parts) == 1: return parts[0] - else: - return parts + return parts + + +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. + + Separators '-', '_', ' ', 'T', ':' and '.' between date and time components. + Separators can also be omitted altogether. + + Examples:: + + 2023-09-08T14:34:42.123456 + 2023-09-08 14:34:42.123 + 20230908 143442 + 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: + 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): @@ -241,13 +354,12 @@ def parse_time(timestr): Seconds are rounded down to avoid getting times in the future. """ - for method in [_parse_time_epoch, - _parse_time_timestamp, - _parse_time_now_and_utc]: + for method in [_parse_time_epoch, _parse_time_timestamp, _parse_time_now_and_utc]: seconds = method(timestr) if seconds is not None: return int(seconds) - raise ValueError("Invalid time format '%s'." % timestr) + raise ValueError(f"Invalid time format '{timestr}'.") + def _parse_time_epoch(timestr): try: @@ -255,116 +367,194 @@ def _parse_time_epoch(timestr): except ValueError: return None if ret < 0: - raise ValueError("Epoch time must be positive (got %s)." % timestr) + raise ValueError(f"Epoch time must be positive, got '{timestr}'.") return ret + def _parse_time_timestamp(timestr): try: - return timestamp_to_secs(timestr, (' ', ':', '-', '.')) + return parse_timestamp(timestr).timestamp() except ValueError: return None + def _parse_time_now_and_utc(timestr): - timestr = timestr.replace(' ', '').lower() + timestr = timestr.replace(" ", "").lower() base = _parse_time_now_and_utc_base(timestr[:3]) if base is not None: extra = _parse_time_now_and_utc_extra(timestr[3:]) if extra is not None: - return base + extra + return base + extra + _get_dst_difference(base, base + extra) return None + def _parse_time_now_and_utc_base(base): now = time.time() - if base == 'now': + if base == "now": return now - if base == 'utc': + if base == "utc": zone = time.altzone if time.localtime().tm_isdst else time.timezone return now + zone return None + def _parse_time_now_and_utc_extra(extra): if not extra: return 0 - if extra[0] not in ['+', '-']: + if extra[0] not in ["+", "-"]: return None - return (1 if extra[0] == '+' else -1) * timestr_to_secs(extra[1:]) + return (1 if extra[0] == "+" else -1) * timestr_to_secs(extra[1:]) -def get_timestamp(daysep='', daytimesep=' ', timesep=':', millissep='.'): - return TIMESTAMP_CACHE.get_timestamp(daysep, daytimesep, timesep, millissep) +def _get_dst_difference(time1, time2): + time1_is_dst = time.localtime(time1).tm_isdst + time2_is_dst = time.localtime(time2).tm_isdst + if time1_is_dst is time2_is_dst: + return 0 + difference = time.timezone - time.altzone + return difference if time1_is_dst else -difference + + +def 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: + # 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) 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): - raise ValueError("Invalid timestamp '%s'." % timestamp) + raise ValueError(f"Invalid timestamp '{timestamp}'.") else: - return roundup(secs, 3) + return round(secs, 3) 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) + seps = ("", " ", ":", "." if millis else None) ttuple = time.localtime(secs)[:6] if millis: millis = (secs - int(secs)) * 1000 - ttuple = ttuple + (roundup(millis),) + ttuple = (*ttuple, round(millis)) return format_time(ttuple, *seps) 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, include_millis=True): - """Converts elapsed time in milliseconds to format 'hh:mm:ss.mil'. +def elapsed_time_to_string( + elapsed: "int | float | timedelta", + include_millis: bool = True, + seconds: bool = False, +): + """Converts elapsed time to format 'hh:mm:ss.mil'. + + 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. """ - prefix = '' + # TODO: Change the default input to seconds in RF 8.0. + if isinstance(elapsed, timedelta): + 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 = '-' + 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(roundup(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 = roundup(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): if seps: timestamp = _normalize_timestamp(timestamp, seps) Y, M, D, h, m, s, millis = _split_timestamp(timestamp) - secs = time.mktime(datetime.datetime(Y, M, D, h, m, s).timetuple()) - return roundup(1000*secs + millis) + secs = time.mktime((Y, M, D, h, m, s, 0, 0, -1)) + return round(1000 * secs + millis) + def _normalize_timestamp(ts, seps): for sep in seps: if sep in ts: - ts = ts.replace(sep, '') - ts = ts.ljust(17, '0') - return '%s%s%s %s:%s:%s.%s' % (ts[:4], ts[4:6], ts[6:8], ts[8:10], - ts[10:12], ts[12:14], ts[14:17]) + ts = ts.replace(sep, "") + ts = ts.ljust(17, "0") + return f"{ts[:8]} {ts[8:10]}:{ts[10:12]}:{ts[12:14]}.{ts[14:17]}" + def _split_timestamp(timestamp): years = int(timestamp[:4]) @@ -375,42 +565,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(object): - - 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/src/robot/utils/robottypes.py b/src/robot/utils/robottypes.py index 536dedd5a59..c2e4ef74842 100644 --- a/src/robot/utils/robottypes.py +++ b/src/robot/utils/robottypes.py @@ -13,33 +13,144 @@ # See the License for the specific language governing permissions and # limitations under the License. -from .platform import PY2 +import sys +import warnings +from collections import UserString +from collections.abc import Iterable, Mapping +from io import IOBase +from typing import _SpecialForm, get_args, get_origin, TypedDict, Union +if sys.version_info < (3, 9): + try: + # get_args and get_origin handle at least Annotated wrong in Python 3.8. + from typing_extensions import get_args, get_origin + except ImportError: + pass +if sys.version_info >= (3, 10): + from types import UnionType # In Python 3.14+ this is same as typing.Union. +else: + UnionType = () -if PY2: - from .robottypes2 import (is_bytes, is_dict_like, is_integer, is_list_like, - is_number, is_string, is_unicode, type_name) - unicode = unicode +try: + from typing_extensions import TypedDict as ExtTypedDict +except ImportError: + ExtTypedDict = None + + +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_list_like(item): + try: + if isinstance(item, (str, bytes, bytearray, UserString, IOBase)): + return False + except AttributeError: + return False + return isinstance(item, Iterable) -else: - from .robottypes3 import (is_bytes, is_dict_like, is_integer, is_list_like, - is_number, is_string, is_unicode, type_name) - unicode = str +def is_dict_like(item): + try: + return isinstance(item, Mapping) + except AttributeError: + return False -TRUE_STRINGS = {'TRUE', 'YES', 'ON', '1'} -FALSE_STRINGS = {'FALSE', 'NO', 'OFF', '0', 'NONE', ''} + +def is_union(item): + return isinstance(item, UnionType) or get_origin(item) is Union + + +def type_name(item, capitalize=False): + """Return "non-technical" type name for objects and types. + + For example, 'integer' instead of 'int' and 'file' instead of 'TextIOWrapper'. + """ + if is_union(item): + return "Union" + item = get_origin(item) or item + if isinstance(item, _SpecialForm): + # Prior to Python 3.10, typing special forms (Any, Union, ...) didn't + # have `__name__` but instead they had `_name`. + name = item.__name__ if hasattr(item, "__name__") else item._name + else: + typ = item if isinstance(item, type) else type(item) + if issubclass(typ, IOBase): + name = "file" + else: + named_types = { + str: "string", + bool: "boolean", + int: "integer", + type(None): "None", + dict: "dictionary", + } + name = named_types.get(typ, typ.__name__.strip("_")) + return name.capitalize() if capitalize and name.islower() else name + + +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]' + instead of 'typing.List[typing.Any]'. + """ + if typ is type(None): + return "None" + if typ is Ellipsis: + return "..." + if is_union(typ): + return " | ".join(type_repr(a) for a in get_args(typ)) if nested else "Union" + name = _get_type_name(typ) + if nested: + # At least Literal and Annotated can have strings in args. + args = [repr(a) if isinstance(a, str) else type_repr(a) for a in get_args(typ)] + if args: + return f"{name}[{', '.join(args)}]" + return name + + +def _get_type_name(typ, try_origin=True): + try: + return typ.__name__ + except AttributeError: + # Prior to Python 3.10, all typing constructs (e.g. Any, Union, ...) didn't + # have `__name__`, but they had `_name` that we can use instead. + if getattr(typ, "__module__", None) in ("typing", "typing_extensions"): + name = getattr(typ, "_name", None) + if name: + return name + # Typing constructs may not name themselves, but their origin can still have it. + origin = get_origin(typ) + if origin and try_origin: + return _get_type_name(origin, try_origin=False) + return str(typ) + + +# TODO: Remove has_args in RF 8. +def has_args(type): + """Helper to check has type valid ``__args__``. + + Deprecated in Robot Framework 7.3 and will be removed in Robot Framework 8.0. + ``typing.get_args`` can be used instead. + """ + warnings.warn( + "'robot.utils.has_args' is deprecated and will be removed in " + "Robot Framework 8.0. Use 'typing.get_args' instead." + ) + return bool(get_args(type)) def is_truthy(item): - """Returns `True` or `False` depending is the item considered true or not. + """Returns `True` or `False` depending on is the item considered true or not. Validation rules: - If the value is a string, it is considered false if it is `'FALSE'`, `'NO'`, `'OFF'`, `'0'`, `'NONE'` or `''`, case-insensitively. - Considering `'NONE'` false is new in RF 3.0.3 and considering `'OFF'` - and `'0'` false is new in RF 3.1. - Other strings are considered true. - Other values are handled by using the standard `bool()` function. @@ -47,7 +158,7 @@ def is_truthy(item): Boolean values similarly as Robot Framework itself. See also :func:`is_falsy`. """ - if is_string(item): + if isinstance(item, str): return item.upper() not in FALSE_STRINGS return bool(item) diff --git a/src/robot/utils/robottypes2.py b/src/robot/utils/robottypes2.py deleted file mode 100644 index 8c3482e90f3..00000000000 --- a/src/robot/utils/robottypes2.py +++ /dev/null @@ -1,74 +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 collections import Mapping -from UserDict import UserDict -from UserString import UserString -from types import ClassType, NoneType - -try: - from java.lang import String -except ImportError: - String = () - -from .platform import RERAISED_EXCEPTIONS - - -def is_integer(item): - return isinstance(item, (int, long)) - - -def is_number(item): - return isinstance(item, (int, long, float)) - - -def is_bytes(item): - return isinstance(item, (bytes, bytearray)) - - -def is_string(item): - # Returns False with `b'bytes'` on IronPython on purpose. Results of - # `isinstance(item, basestring)` would depend on IronPython 2.7.x version. - return isinstance(item, (str, unicode)) - - -def is_unicode(item): - return isinstance(item, unicode) - - -def is_list_like(item): - if isinstance(item, (str, unicode, bytes, bytearray, UserString, String, - file)): - return False - try: - iter(item) - except RERAISED_EXCEPTIONS: - raise - except: - return False - else: - return True - - -def is_dict_like(item): - return isinstance(item, (Mapping, UserDict)) - - -def type_name(item): - cls = item.__class__ if hasattr(item, '__class__') else type(item) - named_types = {str: 'string', unicode: 'string', bool: 'boolean', - int: 'integer', long: 'integer', NoneType: 'None', - dict: 'dictionary', type: 'class', ClassType: 'class'} - return named_types.get(cls, cls.__name__) diff --git a/src/robot/utils/robottypes3.py b/src/robot/utils/robottypes3.py deleted file mode 100644 index f8676ea2c38..00000000000 --- a/src/robot/utils/robottypes3.py +++ /dev/null @@ -1,65 +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 collections import Mapping, UserString -from io import IOBase - -from .platform import RERAISED_EXCEPTIONS - - -def is_integer(item): - return isinstance(item, int) - - -def is_number(item): - return isinstance(item, (int, float)) - - -def is_bytes(item): - return isinstance(item, (bytes, bytearray)) - - -def is_string(item): - return isinstance(item, str) - - -def is_unicode(item): - return isinstance(item, str) - - -def is_list_like(item): - if isinstance(item, (str, bytes, bytearray, UserString, IOBase)): - return False - try: - iter(item) - except RERAISED_EXCEPTIONS: - raise - except: - return False - else: - return True - - -def is_dict_like(item): - return isinstance(item, Mapping) - - -def type_name(item): - if isinstance(item, IOBase): - return 'file' - cls = item.__class__ if hasattr(item, '__class__') else type(item) - named_types = {str: 'string', bool: 'boolean', int: 'integer', - type(None): 'None', dict: 'dictionary', type: 'class'} - return named_types.get(cls, cls.__name__) diff --git a/src/robot/utils/secret.py b/src/robot/utils/secret.py new file mode 100644 index 00000000000..5838d921bf9 --- /dev/null +++ b/src/robot/utils/secret.py @@ -0,0 +1,58 @@ +# 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 Secret: + """Encapsulates secrets to avoid them being shown in Robot Framework logs. + + The typical usage is using this class in `library keyword type hints`__ to + indicate that only :class:`Secret` values are accepted. How to create these + objects in the data and elsewhere is explained in the `User Guide`__. + + The encapsulated value is available in the :attr:`value` attribute, and it + is mainly meant to be accessed by library keywords. Values are not hidden + or encrypted, so they are available for all code that can access these + objects directly or indirectly via Robot Framework APIs. + + The string representation of these objects does not disclose encapsulated + values, so they are not visible in logs even if these objects themselves + are logged. Notice, though, that if a keyword passes the actual value + further, it may be logged or otherwise disclosed later. + + This class should be imported via the :mod:`robot.api.types` module. + + .. sourcecode:: python + + from robot.api.types import Secret + + + def example(username: str, password: Secret): + user = authenticate(username, password.value) + ... + + New in Robot Framework 7.4. + + __ http://robotframework.org/robotframework/latest/RobotFrameworkUserGuide.html#secret-type + __ http://robotframework.org/robotframework/latest/RobotFrameworkUserGuide.html#secret-variables + """ + + def __init__(self, value: str): + self.value = value + + def __str__(self) -> str: + return "" + + def __repr__(self): + return f"{type(self).__name__}(value=)" diff --git a/src/robot/utils/setter.py b/src/robot/utils/setter.py index 6de990c05af..5064653a49d 100644 --- a/src/robot/utils/setter.py +++ b/src/robot/utils/setter.py @@ -13,15 +13,59 @@ # See the License for the specific language governing permissions and # limitations under the License. +from typing import Callable, Generic, overload, Type, TypeVar, Union -class setter(object): +T = TypeVar("T") +V = TypeVar("V") +A = TypeVar("A") - def __init__(self, method): + +class setter(Generic[T, V, A]): + """Modify instance attributes only when they are set, not when they are get. + + 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.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]) -> A: ... + + def __get__(self, instance: Union[T, None], owner: Type[T]) -> Union[A, "setter"]: if instance is None: return self try: @@ -29,18 +73,19 @@ 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: 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): - 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/src/robot/utils/sortable.py b/src/robot/utils/sortable.py index aa1eb9454cd..1227d138fb9 100644 --- a/src/robot/utils/sortable.py +++ b/src/robot/utils/sortable.py @@ -13,12 +13,12 @@ # See the License for the specific language governing permissions and # limitations under the License. -from operator import eq, lt, le, gt, ge +from operator import eq, ge, gt, le, lt from .robottypes import type_name -class Sortable(object): +class Sortable: """Base class for sorting based self._sort_key""" _sort_key = NotImplemented @@ -28,15 +28,11 @@ def __test(self, operator, other, require_sortable=True): return operator(self._sort_key, other._sort_key) if not require_sortable: return False - raise TypeError("Cannot sort '%s' and '%s'." - % (type_name(self), type_name(other))) + raise TypeError(f"Cannot sort '{type_name(self)}' and '{type_name(other)}'.") def __eq__(self, other): return self.__test(eq, other, require_sortable=False) - def __ne__(self, other): - return not self == other - def __lt__(self, other): return self.__test(lt, other) diff --git a/src/robot/utils/text.py b/src/robot/utils/text.py index 74b80a8434e..269eeb291a1 100644 --- a/src/robot/utils/text.py +++ b/src/robot/utils/text.py @@ -13,34 +13,34 @@ # See the License for the specific language governing permissions and # limitations under the License. -from itertools import takewhile import inspect +import os import os.path -import re +from collections.abc import Mapping +from pathlib import Path +from string import Template from .charwidth import get_char_width from .misc import seq2str2 -from .robottypes import is_string, is_unicode -from .unic import unic - +from .unic import safe_str MAX_ERROR_LINES = 40 -_MAX_ASSIGN_LENGTH = 200 +MAX_ASSIGN_LENGTH = 200 _MAX_ERROR_LINE_LENGTH = 78 -_ERROR_CUT_EXPLN = ' [ Message content over the limit has been removed. ]' -_TAGS_RE = re.compile(r'\s*tags:(.*)', re.IGNORECASE) +_ERROR_CUT_EXPLN = " [ Message content over the limit has been removed. ]" 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) + return "\n".join([*start, _ERROR_CUT_EXPLN, *end]) + def _prune_excess_lines(lines, lengths, from_end=False): if from_end: @@ -59,38 +59,47 @@ 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 if len(line) > available_chars: if not from_end: - line = line[:available_chars] + '...' + line = line[:available_chars] + "..." else: - line = '...' + line[-available_chars:] + 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) return lines if not remainder else lines + 1 -def format_assign_message(variable, value, cut_long=True): - formatter = {'$': unic, '@': seq2str2, '&': _dict_to_str}[variable[0]] +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 and len(value) > _MAX_ASSIGN_LENGTH: - value = value[:_MAX_ASSIGN_LENGTH] + '...' - return '%s = %s' % (variable, value) + if cut_long: + value = cut_assign_value(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: - return '{ }' - return '{ %s }' % ' | '.join('%s=%s' % (unic(k), unic(v)) - for k, v in d.items()) + return "{ }" + items = " | ".join(f"{safe_str(k)}={safe_str(d[k])}" for k in d) + return f"{{ {items} }}" + + +def cut_assign_value(value): + if not isinstance(value, str): + value = safe_str(value) + if len(value) > MAX_ASSIGN_LENGTH: + value = value[:MAX_ASSIGN_LENGTH] + "..." + return value def get_console_length(text): @@ -102,12 +111,14 @@ def pad_console_length(text, width): width = 5 diff = get_console_length(text) - width if diff > 0: - text = _lose_width(text, diff+3) + '...' + 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 + return text + " " * more + def _lose_width(text, diff): lost = 0 @@ -118,12 +129,19 @@ def _lose_width(text, diff): def split_args_from_name_or_path(name): + """Split arguments embedded to name or path like ``Example:arg1:arg2``. + + The separator can be either colon ``:`` or semicolon ``;``. If both are used, + the first one is considered to be the separator. + """ 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, [] - args = name[index+1:].split(name[index]) + args = name[index + 1 :].split(name[index]) name = name[:index] if os.path.exists(name): name = os.path.abspath(name) @@ -131,11 +149,11 @@ def split_args_from_name_or_path(name): def _get_arg_separator_index_from_name_or_path(name): - colon_index = name.find(':') + colon_index = name.find(":") # Handle absolute Windows paths - if colon_index == 1 and name[2:3] in ('/', '\\'): - colon_index = name.find(':', colon_index+1) - semicolon_index = name.find(';') + if colon_index == 1 and name[0].isalpha() and name[2:3] in ("/", "\\"): + colon_index = name.find(":", colon_index + 1) + semicolon_index = name.find(";") if colon_index == -1: return semicolon_index if semicolon_index == -1: @@ -143,32 +161,71 @@ def _get_arg_separator_index_from_name_or_path(name): return min(colon_index, semicolon_index) -def split_tags_from_doc(doc): - doc = doc.rstrip() - tags = [] +def getdoc(item): + return inspect.getdoc(item) or "" + + +def getshortdoc(doc_or_item, linesep="\n"): + if not doc_or_item: + return "" + doc = doc_or_item if isinstance(doc_or_item, str) else getdoc(doc_or_item) if not doc: - return doc, tags - lines = doc.splitlines() - match = _TAGS_RE.match(lines[-1]) - if match: - doc = '\n'.join(lines[:-1]).rstrip() - tags = [tag.strip() for tag in match.group(1).split(',')] - return doc, tags + return "" + lines = [] + for line in doc.splitlines(): + if not line.strip(): + break + lines.append(line) + return linesep.join(lines) -def getdoc(item): - doc = inspect.getdoc(item) or u'' - if is_unicode(doc): - return doc +def expand_variables(string, mapping=None): + """Expands variables in the ``string`` based on the ``mapping``. + + If ``mapping`` is not given, defaults to using ``os.environ``. Variable names + are limited to ASCII letters, numbers and underscores, and they cannot start + with a number. + + This is similar to ``os.path.expandvars``, but there are certain differences: + + - Only ``$NAME`` and ``${NAME}`` styles are supported, ``%NAME%`` is not. + - Non-existing variables cause a ``ValueError``. + - Escaping is possible by doubling the dollar sign like ``$$XXX``. + - Default values are supported with syntax ``${NAME=default}``. + + New in Robot Framework 7.4. + """ try: - return doc.decode('UTF-8') - except UnicodeDecodeError: - return unic(doc) + return TemplateWithDefaults(string).substitute(mapping or os.environ) + except KeyError as err: + raise ValueError(f"Variable '{err.args[0]}' does not exist.") -def getshortdoc(doc_or_item, linesep='\n'): - if not doc_or_item: - return u'' - doc = doc_or_item if is_string(doc_or_item) else getdoc(doc_or_item) - lines = takewhile(lambda line: line.strip(), doc.splitlines()) - return linesep.join(lines) +class TemplateWithDefaults(Template): + braceidpattern = r"(?a:[_a-z][_a-z0-9]*(=[^}]*)?)" + + def substitute(self, mapping=None, /, **kwds): + ns = NamespaceWithDefaults(mapping, **kwds) + return super().substitute(ns) + + def safe_substitute(self, mapping=None, /, **kwds): + ns = NamespaceWithDefaults(mapping, **kwds) + return super().safe_substitute(ns) + + +class NamespaceWithDefaults(Mapping): + + def __init__(self, data=None, /, **extra): + self.data = {**(data or {}), **extra} + + def __getitem__(self, key): + if "=" in key: + key, default = key.split("=", 1) + return self.data.get(key, default) + return self.data[key] + + def __iter__(self): + return iter(self.data) + + def __len__(self): + return len(self.data) diff --git a/src/robot/utils/typehints.py b/src/robot/utils/typehints.py new file mode 100644 index 00000000000..513def5967f --- /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 diff --git a/src/robot/utils/unic.py b/src/robot/utils/unic.py index 063b0fec362..7d123ca9a00 100644 --- a/src/robot/utils/unic.py +++ b/src/robot/utils/unic.py @@ -17,87 +17,50 @@ from pprint import PrettyPrinter from unicodedata import normalize -from .platform import PY2, PY3 -from .robottypes import is_bytes, is_unicode, unicode +def safe_str(item): + return normalize("NFC", _safe_str(item)) -def unic(item): - item = _unic(item) - try: - return normalize('NFC', item) - except ValueError: - # https://github.com/IronLanguages/ironpython2/issues/628 - return item - - -if PY2: - - def _unic(item): - if isinstance(item, unicode): - return item - if isinstance(item, (bytes, bytearray)): - try: - return item.decode('ASCII') - except UnicodeError: - return u''.join(chr(b) if b < 128 else '\\x%x' % b - for b in bytearray(item)) - try: - try: - return unicode(item) - except UnicodeError: - return unic(str(item)) - except: - return _unrepresentable_object(item) -else: - - def _unic(item): - if isinstance(item, str): - return item - if isinstance(item, (bytes, bytearray)): - try: - return item.decode('ASCII') - except UnicodeError: - return ''.join(chr(b) if b < 128 else '\\x%x' % b - for b in item) - try: - return str(item) - except: - return _unrepresentable_object(item) +def _safe_str(item): + if isinstance(item, str): + return item + if isinstance(item, (bytes, bytearray)): + # Map each byte to Unicode code point with same ordinal. + return item.decode("latin-1") + try: + return str(item) + except Exception: + return _unrepresentable_object(item) -def prepr(item, width=80): - return unic(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): def format(self, object, context, maxlevels, level): try: - if is_unicode(object): - return repr(object).lstrip('u'), True, False - if is_bytes(object): - return 'b' + repr(object).lstrip('b'), True, False return PrettyPrinter.format(self, object, context, maxlevels, level) - except: + except Exception: return _unrepresentable_object(object), True, False - if PY3: - - # Don't split strings: https://stackoverflow.com/questions/31485402 - def _format(self, object, *args, **kwargs): - if isinstance(object, (str, bytes, bytearray)): - width = self._width - self._width = sys.maxsize - try: - super()._format(object, *args, **kwargs) - finally: - self._width = width - else: + # Don't split strings: https://stackoverflow.com/questions/31485402 + def _format(self, object, *args, **kwargs): + if isinstance(object, (str, bytes, bytearray)): + width = self._width + self._width = sys.maxsize + try: super()._format(object, *args, **kwargs) + finally: + self._width = width + else: + super()._format(object, *args, **kwargs) def _unrepresentable_object(item): from .error import get_error_message - return u"" \ - % (item.__class__.__name__, get_error_message()) + + error = get_error_message() + return f"" diff --git a/src/robot/utils/utf8reader.py b/src/robot/utils/utf8reader.py deleted file mode 100644 index 9e8ee975aeb..00000000000 --- a/src/robot/utils/utf8reader.py +++ /dev/null @@ -1,52 +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 codecs import BOM_UTF8 - -from .robottypes import is_string - - -class Utf8Reader(object): - - def __init__(self, path_or_file): - if is_string(path_or_file): - self._file = open(path_or_file, 'rb') - self._close = True - else: - self._file = path_or_file - self._close = False - # IronPython handles BOM incorrectly if file not opened in binary mode: - # https://ironpython.codeplex.com/workitem/34655 - if hasattr(self._file, 'mode') and self._file.mode != 'rb': - raise ValueError('Only files in binary mode accepted.') - - def __enter__(self): - return self - - def __exit__(self, *exc_info): - if self._close: - self._file.close() - - def read(self): - return self._decode(self._file.read()) - - def readlines(self): - for index, line in enumerate(self._file.readlines()): - yield self._decode(line, remove_bom=index == 0) - - def _decode(self, content, remove_bom=True): - if remove_bom and content.startswith(BOM_UTF8): - content = content[len(BOM_UTF8):] - return content.decode('UTF-8') diff --git a/src/robot/variables/__init__.py b/src/robot/variables/__init__.py index eb622e46f60..b036ece09bd 100644 --- a/src/robot/variables/__init__.py +++ b/src/robot/variables/__init__.py @@ -15,13 +15,30 @@ """Implements storing and resolving variables. -This package is mainly for internal usage. +This package is mainly for internal usage, but utilities for finding +variables can be used externally as well. """ -from .assigner import VariableAssignment -from .isvar import contains_var, is_var, is_scalar_var, is_list_var, is_dict_var -from .notfound import variable_not_found -from .scopes import VariableScopes -from .splitter import VariableSplitter, VariableIterator -from .tablesetter import VariableTableValue, DictVariableTableValue -from .variables import Variables +from .assigner import VariableAssignment as VariableAssignment +from .evaluation import evaluate_expression as evaluate_expression +from .notfound import variable_not_found as variable_not_found +from .scopes import VariableScopes as VariableScopes +from .search import ( + contains_variable as contains_variable, + is_assign as is_assign, + is_dict_assign as is_dict_assign, + is_dict_variable as is_dict_variable, + is_list_assign as is_list_assign, + is_list_variable as is_list_variable, + is_scalar_assign as is_scalar_assign, + is_scalar_variable as is_scalar_variable, + is_variable as is_variable, + search_variable as search_variable, + VariableMatch as VariableMatch, + VariableMatches as VariableMatches, +) +from .tablesetter import ( + DictVariableResolver as DictVariableResolver, + VariableResolver as VariableResolver, +) +from .variables import Variables as Variables diff --git a/src/robot/variables/assigner.py b/src/robot/variables/assigner.py index 3b2c15d4cf2..80d242562fe 100644 --- a/src/robot/variables/assigner.py +++ b/src/robot/variables/assigner.py @@ -14,23 +14,25 @@ # 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, - is_number, is_string, prepr, type_name) +from robot.errors import ( + DataError, ExecutionStatus, HandlerExecutionFailed, VariableError +) +from robot.utils import ( + DotDict, ErrorDetails, format_assign_message, get_error_message, is_dict_like, + is_list_like, prepr, type_name +) +from .search import search_variable -class VariableAssignment(object): + +class VariableAssignment: def __init__(self, assignment): validator = AssignmentValidator() - try: - self.assignment = [validator.validate(var) for var in assignment] - self.error = None - except DataError as err: - self.assignment = assignment - self.error = err + self.assignment = validator.validate(assignment) + self.errors = tuple(dict.fromkeys(validator.errors)) # remove duplicates def __iter__(self): return iter(self.assignment) @@ -39,50 +41,61 @@ def __len__(self): return len(self.assignment) def validate_assignment(self): - if self.error: - raise self.error + if self.errors: + if len(self.errors) == 1: + error = self.errors[0] + else: + error = "\n- ".join(["Multiple errors:", *self.errors]) + raise DataError(error, syntax=True) def assigner(self, context): self.validate_assignment() return VariableAssigner(self.assignment, context) -class AssignmentValidator(object): +class AssignmentValidator: def __init__(self): - self._seen_list = False - self._seen_dict = False - self._seen_any_var = False - self._seen_assign_mark = False + self.seen_list = False + self.seen_dict = False + self.seen_any = False + self.seen_mark = False + self.errors = [] - def validate(self, variable): + def validate(self, assignment): + return [self._validate(var) for var in assignment] + + def _validate(self, variable): variable = self._validate_assign_mark(variable) - self._validate_state(is_list=variable[0] == '@', - is_dict=variable[0] == '&') + self._validate_state(is_list=variable[0] == "@", is_dict=variable[0] == "&") return variable def _validate_assign_mark(self, variable): - if self._seen_assign_mark: - raise DataError("Assign mark '=' can be used only with the last " - "variable.") - if variable.endswith('='): - self._seen_assign_mark = True + if self.seen_mark: + self.errors.append( + "Assign mark '=' can be used only with the last variable.", + ) + if variable[-1] == "=": + self.seen_mark = True return variable[:-1].rstrip() return 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.') - if self._seen_dict or is_dict and self._seen_any_var: - raise DataError('Dictionary variable cannot be assigned with ' - 'other variables.') - self._seen_list += is_list - self._seen_dict += is_dict - self._seen_any_var = True - - -class VariableAssigner(object): - _valid_extended_attr = re.compile('^[_a-zA-Z]\w*$') + if is_list and self.seen_list: + self.errors.append( + "Assignment can contain only one list variable.", + ) + if self.seen_dict or is_dict and self.seen_any: + self.errors.append( + "Dictionary variable cannot be assigned with other variables.", + ) + self.seen_list += is_list + self.seen_dict += is_dict + self.seen_any = True + + +class VariableAssigner: + _valid_extended_attr = re.compile(r"^[_a-zA-Z]\w*$") def __init__(self, assignment, context): self._assignment = assignment @@ -91,95 +104,174 @@ def __init__(self, assignment, context): def __enter__(self): return self - def __exit__(self, exc_type, exc_val, exc_tb): - if exc_val is None: + def __exit__(self, etype, error, tb): + if error is None: return - failure = self._get_failure(exc_type, exc_val, exc_tb) - if failure.can_continue(self._context.in_teardown): - self.assign(failure.return_value) - - def _get_failure(self, exc_type, exc_val, exc_tb): - if isinstance(exc_val, ExecutionStatus): - return exc_val - exc_info = (exc_type, exc_val, exc_tb) - return HandlerExecutionFailed(ErrorDetails(exc_info)) + if not isinstance(error, ExecutionStatus): + error = HandlerExecutionFailed(ErrorDetails(error)) + if error.can_continue(self._context): + self.assign(error.return_value) def assign(self, return_value): context = self._context - context.trace(lambda: 'Return: %s' % prepr(return_value)) - resolver = ReturnValueResolver(self._assignment) - for name, value in resolver.resolve(return_value): - if not self._extended_assign(name, value, context.variables): + context.output.trace( + lambda: f"Return: {prepr(return_value)}", write_if_flat=False + ) + resolver = ReturnValueResolver.from_assignment(self._assignment) + 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: + if "." not in name or name in variables: return False - base, attr = self._split_extended_assign(name) + base, attr = [token.strip() for token in name[2:-1].rsplit(".", 1)] try: - var = variables[base] - except DataError: + var = variables.replace_scalar(f"${{{base}}}") + except VariableError: return False - if not (self._variable_supports_extended_assign(var) and - self._is_valid_extended_attribute(attr)): + if not ( + self._variable_supports_extended_assign(var) + and self._is_valid_extended_attribute(attr) + ): return False try: - setattr(var, attr, value) - except: - raise VariableError("Setting attribute '%s' to variable '%s' " - "failed: %s" % (attr, base, get_error_message())) + setattr(var, attr, self._handle_list_and_dict(value, name[0])) + except Exception: + raise VariableError(f"Setting '{name}' failed: {get_error_message()}") return True - def _split_extended_assign(self, name): - base, attr = name.rsplit('.', 1) - return base.strip() + '}', attr[:-1].strip() - def _variable_supports_extended_assign(self, var): - return not (is_string(var) or is_number(var)) + return not isinstance(var, (str, int, float)) 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 isinstance(index, str): + 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 _handle_list_and_dict(self, value, identifier): + if identifier == "@": + if not is_list_like(value): + self._raise_cannot_set_type(value, "list") + value = list(value) + if identifier == "&": + 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): + raise VariableError( + f"Variable '{name}{decorated_nested_items}' is {type_name(var)} " + 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: + var[selector] = self._handle_list_and_dict(value, name[0]) + except (IndexError, TypeError, Exception): + raise VariableError( + f"Setting value to {type_name(var)} variable " + f"'{name}{decorated_nested_items}' at index [{item}] failed: " + f"{get_error_message()}" + ) + 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] + return value if name[0] == "$" else variables[name] + + +class ReturnValueResolver: + + @classmethod + def from_assignment(cls, assignment): + if not assignment: + return NoReturnValueResolver() + if len(assignment) == 1: + return OneReturnValueResolver(assignment[0]) + if any(a[0] == "@" for a in assignment): + return ScalarsAndListReturnValueResolver(assignment) + return ScalarsOnlyReturnValueResolver(assignment) + def resolve(self, return_value): + raise NotImplementedError + + def _split_assignment(self, assignment): + from robot.running import TypeInfo -def ReturnValueResolver(assignment): - if not assignment: - return NoReturnValueResolver() - if len(assignment) == 1: - return OneReturnValueResolver(assignment[0]) - if any(a[0] == '@' for a in assignment): - return ScalarsAndListReturnValueResolver(assignment) - return ScalarsOnlyReturnValueResolver(assignment) + match = search_variable(assignment, parse_type=True) + info = TypeInfo.from_variable(match) if match.type else None + return match.name, info, match.items + def _convert(self, return_value, type_info): + if not type_info: + return return_value + return type_info.convert(return_value, kind="Return value") -class NoReturnValueResolver(object): + +class NoReturnValueResolver(ReturnValueResolver): def resolve(self, return_value): return [] -class OneReturnValueResolver(object): +class OneReturnValueResolver(ReturnValueResolver): - def __init__(self, variable): - self._variable = variable + def __init__(self, assignment): + self._name, self._type, self._items = self._split_assignment(assignment) def resolve(self, return_value): if return_value is None: - identifier = self._variable[0] - return_value = {'$': None, '@': [], '&': {}}[identifier] - return [(self._variable, return_value)] + identifier = self._name[0] + return_value = {"$": None, "@": [], "&": {}}[identifier] + return_value = self._convert(return_value, self._type) + return [(self._name, self._items, return_value)] -class _MultiReturnValueResolver(object): +class MultiReturnValueResolver(ReturnValueResolver): - def __init__(self, variables): - self._variables = variables - self._min_count = len(variables) + def __init__(self, assignments): + self._names = [] + self._types = [] + self._items = [] + for assign in assignments: + name, type_, items = self._split_assignment(assign) + self._names.append(name) + self._types.append(type_) + self._items.append(items) + self._minimum = len(assignments) def resolve(self, return_value): return_value = self._convert_to_list(return_value) @@ -188,8 +280,8 @@ def resolve(self, return_value): def _convert_to_list(self, return_value): if return_value is None: - return [None] * self._min_count - if is_string(return_value): + return [None] * self._minimum + if isinstance(return_value, str): self._raise_expected_list(return_value) try: return list(return_value) @@ -197,10 +289,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 @@ -209,46 +301,51 @@ def _resolve(self, return_value): raise NotImplementedError -class ScalarsOnlyReturnValueResolver(_MultiReturnValueResolver): +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)) + if return_count != self._minimum: + self._raise(f"Expected {self._minimum} return values, got {return_count}.") def _resolve(self, return_value): - return list(zip(self._variables, return_value)) + return_value = [ + self._convert(rv, t) for rv, t in zip(return_value, self._types) + ] + return list(zip(self._names, self._items, return_value)) -class ScalarsAndListReturnValueResolver(_MultiReturnValueResolver): +class ScalarsAndListReturnValueResolver(MultiReturnValueResolver): - def __init__(self, variables): - _MultiReturnValueResolver.__init__(self, variables) - self._min_count -= 1 + def __init__(self, assignments): + super().__init__(assignments) + self._minimum -= 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)) + if return_count < self._minimum: + self._raise( + f"Expected {self._minimum} or more return values, got {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] for a in self._names].index("@") + list_len = len(return_value) - len(self._names) + 1 + items_before_list = zip( + self._names[:list_index], + self._items[:list_index], + return_value[:list_index], + ) + list_items = ( + self._names[list_index], + self._items[list_index], + return_value[list_index : list_index + list_len], + ) + items_after_list = zip( + self._names[list_index + 1 :], + self._items[list_index + 1 :], + return_value[list_index + list_len :], + ) + all_items = [*items_before_list, list_items, *items_after_list] + return [ + (name, items, self._convert(value, info)) + for (name, items, value), info in zip(all_items, self._types) + ] diff --git a/src/robot/variables/evaluation.py b/src/robot/variables/evaluation.py new file mode 100644 index 00000000000..df2191edddf --- /dev/null +++ b/src/robot/variables/evaluation.py @@ -0,0 +1,190 @@ +# 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 builtins +import re +import token +from collections.abc import MutableMapping +from io import StringIO +from tokenize import generate_tokens, untokenize + +from robot.errors import DataError +from robot.utils import get_error_message, type_name + +from .notfound import variable_not_found +from .search import VariableMatches + + +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, 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}' is used in a scope " + f"where it cannot be seen." + ) + else: + variable_recommendation = _recommend_special_variables(original) + raise DataError( + f"Evaluating expression {expression!r} failed: {error}\n\n" + f"{variable_recommendation}".strip() + ) + + +def _evaluate(expression, variable_store, modules=None, namespace=None): + if "$" in expression: + expression = _decorate_variables(expression, variable_store) + # Given namespace must be included in our custom local namespace to make + # it possible to detect which names are not found and should be imported + # 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 or ()) + if modules: + namespace.update(_import_modules(modules)) + local_ns = EvaluationNamespace(variable_store, namespace) + return eval(expression, namespace, local_ns) + + +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( + f"${tokval}", + variable_store.as_dict(decoration=False), + deco_braces=False, + ) + tokval = "RF_VAR_" + tokval + variable_found = True + else: + tokens.append((prev_toknum, "$")) + variable_started = False + if tokval == "$": + variable_started = True + prev_toknum = toknum + else: + tokens.append((toknum, tokval)) + return untokenize(tokens).strip() if variable_found else expression + + +def _import_modules(module_names): + modules = {} + for name in module_names.replace(" ", "").split(","): + if not name: + continue + modules[name] = __import__(name) + # If we just import module 'root.sub', module 'root' is not found. + while "." in name: + name, _ = name.rsplit(".", 1) + modules[name] = __import__(name) + return modules + + +def _recommend_special_variables(expression): + matches = VariableMatches(expression) + if not matches: + return "" + example = [] + for match in matches: + example[-1:] = [match.before, match.identifier + match.base, match.after] + example = "".join(_remove_possible_quoting(example)) + return ( + f"Variables in the original expression {expression!r} were resolved before " + f"the expression was evaluated. Try using {example!r} syntax to avoid that. " + f"See Evaluating Expressions appendix in Robot Framework User Guide for more " + f"details." + ) + + +def _remove_possible_quoting(example_tokens): + before = var = after = None + for index, item in enumerate(example_tokens): + if index == 0: + before = item + elif index % 2 == 1: + var = item + else: + after = item + if before[-3:] in ('"""', "'''") and after[:3] == before[-3:]: + before, after = before[:-3], after[3:] + elif before[-1:] in ('"', "'") and after[:1] == before[-1:]: + before, after = before[:-1], after[1:] + yield before + yield var + before = after + yield after + + +class EvaluationNamespace(MutableMapping): + + def __init__(self, variable_store, namespace): + self.namespace = namespace + self.variables = variable_store + + def __getitem__(self, key): + if key.startswith("RF_VAR_"): + return self.variables[key[7:]] + if key in self.namespace: + 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 hasattr(builtins, name): + raise KeyError + try: + return __import__(name) + except ImportError: + raise NameError(f"name '{name}' is not defined nor importable as module") + + def __iter__(self): + yield from self.variables + yield from self.namespace + + def __len__(self): + return len(self.variables) + len(self.namespace) diff --git a/src/robot/variables/filesetter.py b/src/robot/variables/filesetter.py index d114c4a891a..5f2e5984df5 100644 --- a/src/robot/variables/filesetter.py +++ b/src/robot/variables/filesetter.py @@ -14,7 +14,8 @@ # limitations under the License. import inspect -import io +import json + try: import yaml except ImportError: @@ -22,14 +23,17 @@ 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 +) + +from .store import VariableStore -class VariableFileSetter(object): +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) @@ -37,89 +41,66 @@ 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("Importing variable file '%s' with args %s" - % (path_or_variables, args)) - if path_or_variables.lower().endswith('.yaml'): + 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"): + importer = JsonImporter() else: 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 {args} " if args else "" + msg = get_error_message() + raise DataError( + f"Processing variable file '{path_or_variables}' {args}failed: {msg}" + ) 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 YamlImporter(object): +class PythonImporter: 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): - value = DotDict((n, self._dot_dict(v)) for n, v in value.items()) - return value - - -class PythonImporter(object): - - def import_variables(self, path, args=None): - importer = Importer('variable file').import_class_or_module_by_path + importer = Importer("variable file", LOGGER).import_class_or_module var_file = importer(path, instantiate_with_args=()) 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) - else: + if hasattr(var_file, "get_variables"): + variables = self._get_dynamic(var_file.get_variables, args) + elif hasattr(var_file, "getVariables"): + variables = self._get_dynamic(var_file.getVariables, args) + 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 _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')) - variables = get_variables(*args) + def _get_dynamic(self, 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("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 _resolve_arguments(self, get_variables, args): + 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__'): + names = [attr for attr in dir(var_file) if not attr.startswith("_")] + if hasattr(var_file, "__all__"): names = [name for name in names if name in var_file.__all__] variables = [(name, getattr(var_file, name)) for name in names] if not inspect.ismodule(var_file): @@ -128,21 +109,80 @@ 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 list-like value, " + f"got {type_name(value)}." + ) + name = name[6:] + value = list(value) + elif name.startswith("DICT__"): + if not is_dict_like(value): + raise DataError( + f"Invalid variable '{name}': Expected a dictionary-like value, " + f"got {type_name(value)}." + ) + name = name[6:] + value = DotDict(value) 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 [(name, self._dot_dict(value)) for name, value in variables] + + def _import(self, path): + with open(path, encoding="UTF-8") as stream: + variables = json.load(stream) + if not is_dict_like(variables): + raise DataError( + f"JSON variable file must be a mapping, got {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 + + +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 [(name, self._dot_dict(value)) for name, value in variables] + + def _import(self, path): + with 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, 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 diff --git a/src/robot/variables/finders.py b/src/robot/variables/finders.py index 165c438d4a2..31844492a32 100644 --- a/src/robot/variables/finders.py +++ b/src/robot/variables/finders.py @@ -15,139 +15,140 @@ import re -try: - from java.lang.System import (getProperty as get_java_property, - getProperties as get_java_properties) -except ImportError: - get_java_property = lambda name: None - get_java_properties = lambda: {} - from robot.errors import DataError, VariableError -from robot.utils import (get_env_var, get_env_vars, get_error_message, - is_dict_like, is_list_like, normalize, DotDict, - NormalizedDict) +from robot.utils import ( + get_env_var, get_env_vars, get_error_message, normalize, NormalizedDict +) -from .isvar import validate_var +from .evaluation import evaluate_expression from .notfound import variable_not_found +from .search import search_variable, VariableMatch + +NOT_FOUND = object() -class VariableFinder(object): +class VariableFinder: - def __init__(self, variable_store): - self._finders = (StoredFinder(variable_store), - NumberFinder(), - EmptyFinder(), - EnvironmentFinder(), - ExtendedFinder(self)) - self._store = variable_store + def __init__(self, variables): + self._finders = ( + StoredFinder(variables.store), + NumberFinder(), + EmptyFinder(), + InlinePythonFinder(variables), + EnvironmentFinder(), + ExtendedFinder(self), + ) + self._store = variables.store - def find(self, name): - validate_var(name, '$@&%') - identifier = name[0] + def find(self, variable): + match = self._get_match(variable) for finder in self._finders: - if identifier in finder.identifiers: - try: - value = finder.find(name) - except (KeyError, ValueError): - continue - try: - return self._validate_value(value, identifier, name) - except VariableError: - raise - except: - raise VariableError("Resolving variable '%s' failed: %s" - % (name, get_error_message())) - variable_not_found(name, self._store.data) - - def _validate_value(self, value, identifier, name): - if identifier == '@': - if not is_list_like(value): - raise VariableError("Value of variable '%s' is not list or " - "list-like." % name) - # TODO: Is converting to list needed or would checking be enough? - # TODO: Check this and DotDict usage below in RF 3.1. - return list(value) - if identifier == '&': - if not is_dict_like(value): - raise VariableError("Value of variable '%s' is not dictionary " - "or dictionary-like." % name) - # TODO: Is converting to DotDict needed? Check in RF 3.1. - return DotDict(value) - return value - - -class StoredFinder(object): - identifiers = '$@&' + if match.identifier in finder.identifiers: + result = finder.find(match) + if result is not NOT_FOUND: + return result + variable_not_found(match.name, self._store.data) + + def _get_match(self, variable): + if isinstance(variable, VariableMatch): + return variable + match = search_variable(variable) + if match.is_variable() and not match.items: + return match + raise DataError(f"Invalid variable name '{variable}'.") + + +class StoredFinder: + identifiers = "$@&" def __init__(self, store): self._store = store - def find(self, name): - return self._store[name[2:-1]] + def find(self, variable: VariableMatch): + return self._store.get(variable.base, NOT_FOUND, decorated=False) -class NumberFinder(object): - identifiers = '$' +class NumberFinder: + identifiers = "$" - def find(self, name): - number = normalize(name)[2:-1] - try: - return self._get_int(number) - except ValueError: - return float(number) + def find(self, variable: VariableMatch): + number = normalize(variable.base) + for converter in self._get_int, float: + try: + return converter(number) + except ValueError: + pass + return NOT_FOUND def _get_int(self, number): - bases = {'0b': 2, '0o': 8, '0x': 16} + bases = {"0b": 2, "0o": 8, "0x": 16} if number.startswith(tuple(bases)): return int(number[2:], bases[number[:2]]) return int(number) -class EmptyFinder(object): - identifiers = '$@&' - find = NormalizedDict({'${EMPTY}': u'', '@{EMPTY}': (), '&{EMPTY}': {}}, - ignore='_').__getitem__ +class EmptyFinder: + identifiers = "$@&" + empty = NormalizedDict({"${EMPTY}": "", "@{EMPTY}": (), "&{EMPTY}": {}}, ignore="_") + + def find(self, variable: VariableMatch): + return self.empty.get(variable.name, NOT_FOUND) + + +class InlinePythonFinder: + identifiers = "$@&" + + def __init__(self, variables): + self._variables = variables + + def find(self, variable: VariableMatch): + base = variable.base + if not base or base[0] != "{" or base[-1] != "}": + return NOT_FOUND + try: + return evaluate_expression(base[1:-1].strip(), self._variables) + except DataError as err: + raise VariableError(f"Resolving variable '{variable.name}' failed: {err}") -class ExtendedFinder(object): - identifiers = '$@&' - _match_extended = re.compile(r''' +class ExtendedFinder: + identifiers = "$@&" + _match_extended = re.compile( + r""" (.+?) # base name (group 1) ([^\s\w].+) # extended part (group 2) - ''', re.UNICODE|re.VERBOSE).match + """, + re.UNICODE | re.VERBOSE, + ).match def __init__(self, finder): self._find_variable = finder.find - def find(self, name): - match = self._match_extended(name[2:-1]) + def find(self, variable: VariableMatch): + match = self._match_extended(variable.base) if match is None: - raise ValueError + return NOT_FOUND base_name, extended = match.groups() try: - variable = self._find_variable('${%s}' % base_name) + base_var = self._find_variable(f"${{{base_name}}}") except DataError as err: - raise VariableError("Resolving variable '%s' failed: %s" - % (name, err.message)) + raise VariableError(f"Resolving variable '{variable.name}' failed: {err}") try: - return eval('_BASE_VAR_' + extended, {'_BASE_VAR_': variable}) - except: - raise VariableError("Resolving variable '%s' failed: %s" - % (name, get_error_message())) - - -class EnvironmentFinder(object): - identifiers = '%' - - def find(self, name): - for getter in get_env_var, get_java_property: - value = getter(name[2:-1]) - if value is not None: - return value - variable_not_found(name, self._get_candidates(), - "Environment variable '%s' not found." % name) - - def _get_candidates(self): - candidates = dict(get_java_properties()) - candidates.update(get_env_vars()) - return candidates + return eval("_BASE_VAR_" + extended, {"_BASE_VAR_": base_var}) + except Exception: + msg = get_error_message() + raise VariableError(f"Resolving variable '{variable.name}' failed: {msg}") + + +class EnvironmentFinder: + identifiers = "%" + + def find(self, variable: VariableMatch): + var_name, has_default, default_value = variable.base.partition("=") + value = get_env_var(var_name) + if value is not None: + return value + if has_default: + return default_value + error = f"Environment variable '{variable.name}' not found." + variable_not_found(variable.name, get_env_vars(), error) diff --git a/src/robot/variables/isvar.py b/src/robot/variables/isvar.py deleted file mode 100644 index 439b94fad12..00000000000 --- a/src/robot/variables/isvar.py +++ /dev/null @@ -1,52 +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 DataError -from robot.utils import is_string - -from .splitter import VariableIterator - - -def is_var(string, identifiers='$@&'): - if not string or not is_string(string) or len(string) < 4: - return False - if string[0] not in identifiers or string[1] != '{' or string[-1] != '}': - return False - body = string[2:-1] - return '{' not in body and '}' not in body - - -def is_scalar_var(string): - return is_var(string, identifiers='$') - - -def is_list_var(string): - return is_var(string, identifiers='@') - - -def is_dict_var(string): - return is_var(string, identifiers='&') - - -def contains_var(string, identifiers='$@&'): - return (is_string(string) and - any(i in string for i in identifiers) and - '{' in string and '}' in string and - bool(VariableIterator(string, identifiers))) - - -def validate_var(string, identifiers='$@&'): - if not is_var(string, identifiers): - raise DataError("Invalid variable name '%s'." % string) diff --git a/src/robot/variables/notfound.py b/src/robot/variables/notfound.py index 46e462e4bf4..a37d15a2920 100644 --- a/src/robot/variables/notfound.py +++ b/src/robot/variables/notfound.py @@ -13,34 +13,55 @@ # See the License for the specific language governing permissions and # limitations under the License. +from collections.abc import Mapping, Sequence from functools import partial +from typing import NoReturn from robot.errors import VariableError -from robot.utils import (is_dict_like, is_list_like, normalize, - RecommendationFinder) +from robot.utils import is_dict_like, is_list_like, normalize, RecommendationFinder -def variable_not_found(name, candidates, msg=None, deco_braces=True): - """Raise DataError for missing variable name. +def variable_not_found( + name: str, + candidates: "Mapping[str, object]", + message: "str | None" = None, + deco_braces: bool = True, +) -> NoReturn: + """Raise `VariableError` for missing variable name. - Return recommendations for similar variable names if any are found. + Error message includes recommendations for similar variable names if any are + found. Finding recommendations has a performance effect, so this function + should only been called when such information is actually useful. """ - if msg is None: - msg = "Variable '%s' not found." % name - candidates = _decorate_candidates(name[0], candidates, deco_braces) - normalizer = partial(normalize, ignore='$@%&*{}_', caseless=True, - spaceless=True) - finder = RecommendationFinder(normalizer) - recommendations = finder.find_recommendations(name, candidates) - msg = finder.format_recommendations(msg, recommendations) - raise VariableError(msg) + recommender = RecommendationFinder(normalizer=partial(normalize, ignore="$@&%{}_")) + recommendations = _decorate_recommendations( + name[0], + recommender.find(name, candidates), + candidates, + deco_braces, + ) + message = recommender.format( + message or f"Variable '{name}' not found.", + recommendations, + ) + raise VariableError(message) -def _decorate_candidates(identifier, candidates, deco_braces=True): - template = '%s{%s}' if deco_braces else '%s%s' - is_included = {'$': lambda value: True, - '@': is_list_like, - '&': is_dict_like, - '%': lambda value: True}[identifier] - return [template % (identifier, name) - for name in candidates if is_included(candidates[name])] +def _decorate_recommendations( + identifier: str, + recommendations: "Sequence[str]", + candidates: "Mapping[str, object]", + deco_braces: bool = True, +) -> "list[str]": + template = "%s{%s}" if deco_braces else "%s%s" + is_included = { + "$": lambda value: True, + "@": is_list_like, + "&": is_dict_like, + "%": lambda value: True, + }[identifier] + return [ + template % (identifier, name) + for name in recommendations + if is_included(candidates[name]) + ] diff --git a/src/robot/variables/replacer.py b/src/robot/variables/replacer.py index a31f10b0aba..6d6df4aa859 100644 --- a/src/robot/variables/replacer.py +++ b/src/robot/variables/replacer.py @@ -14,17 +14,20 @@ # limitations under the License. from robot.errors import DataError, VariableError -from robot.output import LOGGER -from robot.utils import (escape, is_dict_like, is_list_like, is_string, - type_name, unescape, unic) +from robot.output import librarylogger as logger +from robot.utils import ( + DotDict, escape, get_error_message, is_dict_like, is_list_like, safe_str, type_name, + unescape +) -from .splitter import VariableSplitter +from .finders import VariableFinder +from .search import search_variable, VariableMatch -class VariableReplacer(object): +class VariableReplacer: def __init__(self, variables): - self._variables = variables + self._finder = VariableFinder(variables) def replace_list(self, items, replace_until=None, ignore_errors=False): """Replaces variables from a list of items. @@ -35,154 +38,174 @@ 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 []) 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. + # 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: - if self._cannot_have_variables(item): - yield unescape(item) + match = search_variable(item, ignore_errors=ignore_errors) + value = self._replace(match, ignore_errors) + if match.is_list_variable() and is_list_like(value): + result.extend(value) else: - for value in self._replace_list_item(item, ignore_errors): - yield value - - def _replace_list_item(self, item, ignore_errors): - splitter = VariableSplitter(item) - try: - value = self._replace_scalar(item, splitter) - except DataError: - if ignore_errors: - return [item] - raise - if splitter.is_list_variable(): - return value - return [value] + result.append(value) + return result 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 ${scalar} - variable its value is returned. Otherwise variables are replaced with + 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 'replace_string'. Result may be any object. """ - if self._cannot_have_variables(item): - return unescape(item) - return self._replace_scalar(item, ignore_errors=ignore_errors) - - def _replace_scalar(self, item, splitter=None, ignore_errors=False): - if not splitter: - splitter = VariableSplitter(item) - if not splitter.identifier: - return unescape(item) - if not splitter.is_variable(): - return self._replace_string(item, splitter, ignore_errors) + if isinstance(item, VariableMatch): + match = item + else: + match = search_variable(item, ignore_errors=ignore_errors) + return self._replace(match, ignore_errors) + + def replace_string(self, item, custom_unescaper=None, ignore_errors=False): + """Replaces variables from a string. Result is always a string. + + Input can also be an already found VariableMatch. + """ + if isinstance(item, VariableMatch): + match = item + else: + match = search_variable(item, ignore_errors=ignore_errors) + result = self._replace(match, ignore_errors, custom_unescaper or unescape) + return safe_str(result) + + def _replace(self, match, ignore_errors, unescaper=unescape): + if not match: + return unescaper(match.string) + if match.is_variable(): + return self._get_variable_value(match, ignore_errors) + parts = [] + while match: + if match.before: + parts.append(unescaper(match.before)) + parts.append(self._get_variable_value(match, ignore_errors)) + match = search_variable(match.after, ignore_errors=ignore_errors) + if match.string: + parts.append(unescaper(match.string)) + if all(isinstance(p, (bytes, bytearray)) for p in parts): + return b"".join(parts) + return "".join(safe_str(p) for p in parts) + + 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( + rf"Syntax '{match}' is reserved for future use. " + rf"Please escape it like '\{match}'." + ) + return str(match) try: - return self._get_variable(splitter) + value = self._finder.find(match) + if match.items: + value = self._get_variable_item(match, value) + try: + 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 ignore_errors: - return item + return unescape(match.match) raise - def _cannot_have_variables(self, item): - return not (is_string(item) and '{' in item) - - def replace_string(self, string, ignore_errors=False): - """Replaces variables from a string. Result is always a string.""" - if not is_string(string): - return unic(string) - if self._cannot_have_variables(string): - return unescape(string) - return self._replace_string(string, ignore_errors=ignore_errors) - - def _replace_string(self, string, splitter=None, ignore_errors=False): - if not splitter: - splitter = VariableSplitter(string) - return ''.join(self._yield_replaced(string, splitter, ignore_errors)) - - def _yield_replaced(self, string, splitter, ignore_errors=False): - while splitter.identifier: - yield unescape(string[:splitter.start]) - try: - value = self._get_variable(splitter) - except DataError: - if not ignore_errors: - raise - value = string[splitter.start:splitter.end] - yield unic(value) - string = string[splitter.end:] - splitter = VariableSplitter(string) - yield unescape(string) - - def _get_variable(self, splitter): - if splitter.identifier not in '$@&%': - return self._get_reserved_variable(splitter) - name = splitter.get_replaced_variable(self) - variable = self._variables[name] - for item in splitter.items: - variable = self._get_variable_item(name, variable, item) - name = '%s[%s]' % (name, item) - return variable - - def _get_variable_item(self, name, variable, item): - if is_dict_like(variable): - return self._get_dict_variable_item(name, variable, item) - if is_list_like(variable): - return self._get_list_variable_item(name, variable, item) - raise VariableError("Variable '%s' is %s, not list or dictionary, " - "and thus accessing item '%s' from it is not " - "possible." - % (name, type_name(variable), item)) - - def _get_reserved_variable(self, splitter): - value = splitter.get_replaced_variable(self) - LOGGER.warn("Syntax '%s' is reserved for future use. Please " - "escape it like '\\%s'." % (value, value)) + def _get_variable_item(self, match, value): + name = match.name + for item in match.items: + if is_dict_like(value): + value = self._get_dict_variable_item(name, value, item) + elif hasattr(value, "__getitem__"): + value = self._get_sequence_variable_item(name, value, item) + else: + raise VariableError( + 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 = f"{name}[{item}]" return value - def _get_list_variable_item(self, name, variable, index): - index = self.replace_string(index) + def _get_sequence_variable_item(self, name, variable, index): + index = self.replace_scalar(index) try: - index = self._parse_list_variable_index(index, name[0] == '$') + index = self._parse_sequence_variable_index(index) except ValueError: - raise VariableError("List '%s' used with invalid index '%s'." - % (name, index)) + try: + return variable[index] + except TypeError: + 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("List '%s' has no item in index %d." - % (name, index)) + var_type = type_name(variable, capitalize=True) + raise VariableError(f"{var_type} '{name}' has no item in index {index}.") - def _parse_list_variable_index(self, index, support_slice=True): - if ':' not in index: + def _parse_sequence_variable_index(self, index): + if isinstance(index, (int, slice)): + return index + if not isinstance(index, str): + raise ValueError + if ":" not in index: return int(index) - if index.count(':') > 2 or not support_slice: + if index.count(":") > 2: raise ValueError - return slice(*[int(i) if i else None for i in index.split(':')]) + return slice(*[int(i) if i else None for i in index.split(":")]) def _get_dict_variable_item(self, name, variable, key): key = self.replace_scalar(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( + f"Value of variable '{match}' is not list or list-like." + ) + return list(value) + if match.identifier == "&": + if not is_dict_like(value): + raise VariableError( + f"Value of variable '{match}' is not dictionary or dictionary-like." + ) + return DotDict(value) + return value diff --git a/src/robot/variables/resolvable.py b/src/robot/variables/resolvable.py new file mode 100644 index 00000000000..78062ff4017 --- /dev/null +++ b/src/robot/variables/resolvable.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 robot.errors import DataError + + +class Resolvable: + + def resolve(self, variables): + raise NotImplementedError + + def report_error(self, error): + raise DataError(error) + + +class GlobalVariableValue(Resolvable): + + def __init__(self, value): + self.value = value + + def resolve(self, variables): + return self.value diff --git a/src/robot/variables/scopes.py b/src/robot/variables/scopes.py index c171f52fab0..711c3779498 100644 --- a/src/robot/variables/scopes.py +++ b/src/robot/variables/scopes.py @@ -14,21 +14,25 @@ # limitations under the License. import os +import re import tempfile from robot.errors import DataError +from robot.model import Tags from robot.output import LOGGER -from robot.utils import abspath, find_file, get_error_details, NormalizedDict +from robot.utils import abspath, DotDict, find_file, get_error_details, NormalizedDict +from .resolvable import GlobalVariableValue from .variables import Variables -class VariableScopes(object): +class VariableScopes: def __init__(self, settings): self._global = GlobalVariables(settings) self._suite = None self._test = None + self._suite_locals = [] self._scopes = [self._global] self._variables_set = SetVariables() @@ -57,16 +61,18 @@ def _scopes_until_test(self): def start_suite(self): self._suite = self._global.copy() self._scopes.append(self._suite) + self._suite_locals.append(NormalizedDict(ignore="_")) self._variables_set.start_suite() self._variables_set.update(self._suite) def end_suite(self): self._scopes.pop() + self._suite_locals.pop() self._suite = self._scopes[-1] if len(self._scopes) > 1 else None self._variables_set.end_suite() def start_test(self): - self._test = self._suite.copy() + self._test = self._suite.copy(update=self._suite_locals[-1]) self._scopes.append(self._test) self._variables_set.start_test() @@ -76,7 +82,8 @@ def end_test(self): self._variables_set.end_test() def start_keyword(self): - kw = self._suite.copy() + update = self._suite_locals[-1] if self._test else None + kw = self._suite.copy(update) self._variables_set.start_keyword() self._variables_set.update(kw) self._scopes.append(kw) @@ -100,8 +107,8 @@ def replace_list(self, items, replace_until=None, ignore_errors=False): def replace_scalar(self, items, ignore_errors=False): return self.current.replace_scalar(items, ignore_errors) - def replace_string(self, string, ignore_errors=False): - return self.current.replace_string(string, ignore_errors=ignore_errors) + def replace_string(self, string, custom_unescaper=None, ignore_errors=False): + return self.current.replace_string(string, custom_unescaper, ignore_errors) def set_from_file(self, path, args, overwrite=False): variables = None @@ -111,9 +118,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: @@ -127,8 +134,8 @@ def set_global(self, name, value): def _set_global_suite_or_test(self, scope, name, value): scope[name] = value # Avoid creating new list/dict objects in different scopes. - if name[0] != '$': - name = '$' + name[1:] + if name[0] != "$": + name = "$" + name[1:] value = scope[name] return name, value @@ -138,71 +145,115 @@ def set_suite(self, name, value, top=False, children=False): return for scope in self._scopes_until_suite: name, value = self._set_global_suite_or_test(scope, name, value) - if children: - self._variables_set.set_suite(name, value) + self._variables_set.set_suite(name, value, children) + # Override possible "suite local variables" (i.e. test variables set on + # suite level) if real suite level variable is set. + if name in self._suite_locals[-1]: + self._suite_locals[-1].pop(name) def set_test(self, name, value): - if self._test is None: - raise DataError('Cannot set test variable when no test is started.') - for scope in self._scopes_until_test: - name, value = self._set_global_suite_or_test(scope, name, value) - self._variables_set.set_test(name, value) + if self._test: + for scope in self._scopes_until_test: + name, value = self._set_global_suite_or_test(scope, name, value) + self._variables_set.set_test(name, value) + else: + # Set test scope variable on suite level. Keep track on added and + # overridden variables to allow updating variables when test starts. + prev = self._suite.get(name) + self.set_suite(name, value) + self._suite_locals[-1][name] = prev def set_keyword(self, name, value): self.current[name] = value self._variables_set.set_keyword(name, value) + def set_local(self, name, value): + self.current[name] = value + def as_dict(self, decoration=True): return self.current.as_dict(decoration=decoration) class GlobalVariables(Variables): + _import_by_path_ends = (".py", "/", os.sep, ".yaml", ".yml", ".json") def __init__(self, settings): - Variables.__init__(self) - self._set_cli_variables(settings) + super().__init__() self._set_built_in_variables(settings) + self._set_cli_variables(settings) def _set_cli_variables(self, settings): - for path, args in settings.variable_files: + for name, args in settings.variable_files: try: - path = find_file(path, file_type='Variable file') - self.set_from_file(path, args) - except: + if name.lower().endswith(self._import_by_path_ends): + name = find_file(name, file_type="Variable file") + self.set_from_file(name, args) + except Exception: msg, details = get_error_details() LOGGER.error(msg) LOGGER.info(details) for varstr in settings.variables: - try: - name, value = varstr.split(':', 1) - except ValueError: - name, value = varstr, '' - self['${%s}' % name] = value + match = re.fullmatch("([^:]+): ([^:]+):(.*)", varstr) + if match: + name, typ, value = match.groups() + value = self._convert_cli_variable(name, typ, value) + elif ":" in varstr: + name, value = varstr.split(":", 1) + else: + name, value = varstr, "" + self[f"${{{name}}}"] = value + + def _convert_cli_variable(self, name, typ, value): + from robot.api.types import Secret + from robot.running import TypeInfo + + var = f"${{{name}: {typ}}}" + try: + info = TypeInfo.from_variable(var) + except DataError as err: + raise DataError(f"Invalid command line variable '{var}': {err}") + if info.type is Secret: + return Secret(value) + try: + return info.convert(value, var, kind="Command line variable") + except ValueError as err: + raise DataError(str(err)) def _set_built_in_variables(self, settings): - for name, value in [('${TEMPDIR}', abspath(tempfile.gettempdir())), - ('${EXECDIR}', abspath('.')), - ('${/}', os.sep), - ('${:}', os.pathsep), - ('${\\n}', os.linesep), - ('${SPACE}', ' '), - ('${True}', True), - ('${False}', False), - ('${None}', None), - ('${null}', None), - ('${OUTPUT_DIR}', settings.output_directory), - ('${OUTPUT_FILE}', settings.output or 'NONE'), - ('${REPORT_FILE}', settings.report or 'NONE'), - ('${LOG_FILE}', settings.log or 'NONE'), - ('${DEBUG_FILE}', settings.debug_file or 'NONE'), - ('${LOG_LEVEL}', settings.log_level), - ('${PREV_TEST_NAME}', ''), - ('${PREV_TEST_STATUS}', ''), - ('${PREV_TEST_MESSAGE}', '')]: - self[name] = value - - -class SetVariables(object): + options = DotDict( + rpa=settings.rpa, + include=Tags(settings.include), + exclude=Tags(settings.exclude), + skip=Tags(settings.skip), + skip_on_failure=Tags(settings.skip_on_failure), + console_width=settings.console_width, + ) + for name, value in [ + ("${TEMPDIR}", abspath(tempfile.gettempdir())), + ("${EXECDIR}", abspath(".")), + ("${OPTIONS}", options), + ("${/}", os.sep), + ("${:}", os.pathsep), + ("${\\n}", os.linesep), + ("${SPACE}", " "), + ("${True}", True), + ("${False}", False), + ("${None}", None), + ("${null}", None), + ("${OUTPUT_DIR}", str(settings.output_directory)), + ("${OUTPUT_FILE}", str(settings.output or "NONE")), + ("${REPORT_FILE}", str(settings.report or "NONE")), + ("${LOG_FILE}", str(settings.log or "NONE")), + ("${DEBUG_FILE}", str(settings.debug_file or "NONE")), + ("${LOG_LEVEL}", settings.log_level), + ("${PREV_TEST_NAME}", ""), + ("${PREV_TEST_STATUS}", ""), + ("${PREV_TEST_MESSAGE}", ""), + ]: + self[name] = GlobalVariableValue(value) + + +class SetVariables: def __init__(self): self._suite = None @@ -211,7 +262,7 @@ def __init__(self): def start_suite(self): if not self._scopes: - self._suite = NormalizedDict(ignore='_') + self._suite = NormalizedDict(ignore="_") else: self._suite = self._scopes[-1].copy() self._scopes.append(self._suite) @@ -239,8 +290,14 @@ def set_global(self, name, value): if name in scope: scope.pop(name) - def set_suite(self, name, value): - self._suite[name] = value + def set_suite(self, name, value, children=False): + for scope in reversed(self._scopes): + if children: + scope[name] = value + elif name in scope: + scope.pop(name) + if scope is self._suite: + break def set_test(self, name, value): for scope in reversed(self._scopes): diff --git a/src/robot/variables/search.py b/src/robot/variables/search.py new file mode 100644 index 00000000000..eac80f0daac --- /dev/null +++ b/src/robot/variables/search.py @@ -0,0 +1,333 @@ +# 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 re +from functools import partial +from typing import Iterator, Sequence + +from robot.errors import VariableError + + +def search_variable( + string: str, + identifiers: Sequence[str] = "$@&%*", + parse_type: bool = False, + ignore_errors: bool = False, +) -> "VariableMatch": + if not (isinstance(string, str) and "{" in string): + return VariableMatch(string) + return _search_variable(string, identifiers, parse_type, ignore_errors) + + +def contains_variable(string: str, identifiers: Sequence[str] = "$@&") -> bool: + match = search_variable(string, identifiers, ignore_errors=True) + return bool(match) + + +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: str) -> bool: + return is_variable(string, "$") + + +def is_list_variable(string: str) -> bool: + return is_variable(string, "@") + + +def is_dict_variable(string: str) -> bool: + return is_variable(string, "&") + + +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: 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: 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: 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: str, + identifier: "str | None" = None, + base: "str | None" = None, + type: "str | None" = None, + items: "tuple[str, ...]" = (), + start: int = -1, + end: int = -1, + ): + self.string = string + self.identifier = identifier + self.base = base + self.type = type + self.items = items + self.start = start + self.end = end + + def resolve_base(self, variables, ignore_errors=False): + if self.identifier: + internal = search_variable(self.base) + self.base = variables.replace_string( + internal, + custom_unescaper=unescape_variable_syntax, + ignore_errors=ignore_errors, + ) + + @property + def name(self) -> "str | None": + return f"{self.identifier}{{{self.base}}}" if self.identifier else None + + @property + def before(self) -> str: + return self.string[: self.start] if self.identifier else self.string + + @property + def match(self) -> "str | None": + return self.string[self.start : self.end] if self.identifier else None + + @property + def after(self) -> str: + return self.string[self.end :] if self.identifier else "" + + 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) -> bool: + return self.identifier == "$" and self.is_variable() + + def is_list_variable(self) -> bool: + return self.identifier == "@" and self.is_variable() + + def is_dict_variable(self) -> bool: + return self.identifier == "&" and self.is_variable() + + 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) + return ( + self.is_variable() + and self.identifier in "$@&" + and (allow_items or not self.items) + and (allow_nested or not search_variable(self.base)) + ) + + 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: 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: bool = False, + allow_nested: bool = False, + ) -> bool: + return self.identifier == "&" and self.is_assign( + allow_assign_mark, allow_nested + ) + + def __bool__(self) -> bool: + return self.identifier is not None + + def __str__(self) -> str: + if not self: + return "" + type = f": {self.type}" if self.type else "" + items = "".join([f"[{i}]" for i in self.items]) if self.items else "" + return f"{self.identifier}{{{self.base}{type}}}{items}" + + +def _search_variable( + string: str, + identifiers: Sequence[str], + parse_type: bool = False, + ignore_errors: bool = False, +) -> VariableMatch: + start = _find_variable_start(string, identifiers) + if start < 0: + return VariableMatch(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 == right_brace and not escaped: + open_braces -= 1 + if open_braces == 0: + _, next_char = next(indices_and_chars, (-1, None)) + # Parsing name. + if left_brace == "{": + match.base = string[start + 2 : index] + if next_char != "[" or match.identifier not in "$@&": + match.end = index + 1 + break + left_brace, right_brace = "[", "]" + # Parsing items. + else: + items.append(string[start + 1 : index]) + if next_char != "[": + match.end = index + 1 + match.items = tuple(items) + break + start = index + 1 # Start of the next item. + open_braces = 1 + elif char == left_brace and not escaped: + open_braces += 1 + else: + escaped = False if char != "\\" else not escaped + + if open_braces: + if ignore_errors: + return VariableMatch(string) + 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.") + + if parse_type and ": " in match.base: + match.base, match.type = match.base.rsplit(": ", 1) + + return 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): + + def handle_escapes(match): + escapes, text = match.groups() + if len(escapes) % 2 == 1 and starts_with_variable_or_curly(text): + return escapes[1:] + return escapes + + def starts_with_variable_or_curly(text): + if text[0] in "{}": + return True + match = search_variable(text, ignore_errors=True) + return match and match.start == 0 + + return re.sub(r"(\\+)(?=(.+))", handle_escapes, item) + + +class VariableMatches: + + def __init__( + self, + string: str, + identifiers: Sequence[str] = "$@&%", + parse_type: bool = False, + ignore_errors: bool = False, + ): + self.string = string + self.search_variable = partial( + search_variable, + identifiers=identifiers, + parse_type=parse_type, + ignore_errors=ignore_errors, + ) + + def __iter__(self) -> Iterator[VariableMatch]: + remaining = self.string + while True: + match = self.search_variable(remaining) + if not match: + break + remaining = match.after + yield match + + def __len__(self) -> int: + return sum(1 for _ in self) + + def __bool__(self) -> bool: + return bool(self.search_variable(self.string)) diff --git a/src/robot/variables/splitter.py b/src/robot/variables/splitter.py deleted file mode 100644 index dd3e2f40539..00000000000 --- a/src/robot/variables/splitter.py +++ /dev/null @@ -1,188 +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 is_string, py2to3 - - -class VariableSplitter(object): - - def __init__(self, string, identifiers='$@%&*'): - self.identifier = None - self.base = None - self.items = [] - self.start = -1 - self.end = -1 - self._identifiers = identifiers - self._may_have_internal_variables = False - if not is_string(string): - self._max_end = -1 - return - self._max_end = len(string) - if self._split(string): - self._finalize() - - def get_replaced_variable(self, replacer): - if self._may_have_internal_variables: - base = replacer.replace_string(self.base) - else: - base = self.base - # This omits possible variable items. - return '%s{%s}' % (self.identifier, base) - - def is_variable(self): - return bool(self.identifier and self.base and - self.start == 0 and self.end == self._max_end) - - def is_list_variable(self): - return bool(self.identifier == '@' and self.base and - self.start == 0 and self.end == self._max_end and - not self.items) - - def is_dict_variable(self): - return bool(self.identifier == '&' and self.base and - self.start == 0 and self.end == self._max_end and - not self.items) - - def _finalize(self): - self.identifier = self._variable_chars[0] - self.base = ''.join(self._variable_chars[2:-1]) - self.end = self.start + len(self._variable_chars) - if self.items: - self.end += len(''.join(self.items)) + 2 * len(self.items) - - def _split(self, string): - start_index, max_index = self._find_variable(string) - if start_index == -1: - return False - self.start = start_index - self._open_curly = 1 - self._state = self._variable_state - self._variable_chars = [string[start_index], '{'] - self._item_chars = [] - self._string = string - start_index += 2 - for index, char in enumerate(string[start_index:], start=start_index): - try: - self._state(char, index) - except StopIteration: - break - if index == max_index and not self._scanning_item(): - break - return True - - def _scanning_item(self): - return self._state in (self._waiting_item_state, self._item_state) - - def _find_variable(self, string): - max_end_index = string.rfind('}') - if max_end_index == -1: - return -1, -1 - if self._is_escaped(string, max_end_index): - return self._find_variable(string[:max_end_index]) - start_index = self._find_start_index(string, 1, max_end_index) - if start_index == -1: - return -1, -1 - return start_index, max_end_index - - def _find_start_index(self, string, start, end): - while True: - index = string.find('{', start, end) - 1 - if index < 0: - return -1 - if self._start_index_is_ok(string, index): - return index - start = index + 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, index): - self._variable_chars.append(char) - if char == '}' and not self._is_escaped(self._string, index): - self._open_curly -= 1 - if self._open_curly == 0: - if not self._can_have_item(): - raise StopIteration - self._state = self._waiting_item_state - elif char in self._identifiers: - self._state = self._internal_variable_start_state - - def _can_have_item(self): - return self._variable_chars[0] in '$@&' - - def _internal_variable_start_state(self, char, index): - self._state = self._variable_state - if char == '{': - self._variable_chars.append(char) - self._open_curly += 1 - self._may_have_internal_variables = True - else: - self._variable_state(char, index) - - def _waiting_item_state(self, char, index): - if char != '[': - raise StopIteration - self._state = self._item_state - - def _item_state(self, char, index): - if char != ']': - self._item_chars.append(char) - return - self.items.append(''.join(self._item_chars)) - self._item_chars = [] - # Don't support nested item access with olf @ and & syntax. - # In RF 3.2 old syntax is to be deprecated and in RF 3.3 it - # will be reassigned to mean using variable in list/dict context. - if self._variable_chars[0] in '@&': - raise StopIteration - self._state = self._waiting_item_state - - -@py2to3 -class VariableIterator(object): - - def __init__(self, string, identifiers='$@%&*'): - self._string = string - self._identifiers = identifiers - - def __iter__(self): - string = self._string - while True: - var = VariableSplitter(string, self._identifiers) - if var.identifier is None: - break - before = string[:var.start] - variable = '%s{%s}' % (var.identifier, var.base) - string = string[var.end:] - yield before, variable, string - - def __len__(self): - return sum(1 for _ in self) - - def __nonzero__(self): - try: - next(iter(self)) - except StopIteration: - return False - else: - return True diff --git a/src/robot/variables/store.py b/src/robot/variables/store.py index 0cc3d18efcf..fd86893c750 100644 --- a/src/robot/variables/store.py +++ b/src/robot/variables/store.py @@ -13,19 +13,20 @@ # See the License for the specific language governing permissions and # 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.errors import DataError +from robot.utils import ( + DotDict, is_dict_like, is_list_like, NormalizedDict, NOT_SET, type_name +) -from .isvar import validate_var from .notfound import variable_not_found -from .tablesetter import VariableTableValueBase +from .resolvable import GlobalVariableValue, Resolvable +from .search import search_variable -class VariableStore(object): +class VariableStore: def __init__(self, variables): - self.data = NormalizedDict(ignore='_') + self.data = NormalizedDict(ignore="_") self._variables = variables def resolve_delayed(self): @@ -34,29 +35,45 @@ def resolve_delayed(self): self._resolve_delayed(name, value) except DataError: pass + return None def _resolve_delayed(self, name, value): - if not self._is_resolvable(value): + try: + if not isinstance(value, Resolvable): + return value + except Exception: return value try: - self.data[name] = value.resolve(self._variables) + resolved = value.resolve(self._variables) except DataError as err: # Recursive resolving may have already removed variable. - if name in self: - self.remove(name) - value.report_error(err) - variable_not_found('${%s}' % name, self.data, - "Variable '${%s}' not found." % name) - return self.data[name] - - def _is_resolvable(self, value): - try: # isinstance can throw an exception in ironpython and jython - return isinstance(value, VariableTableValueBase) - except Exception: - return False + if name in self.data: + self.data.pop(name) + value.report_error(str(err)) + raise + self.data[name] = resolved + return resolved def __getitem__(self, name): - return self._resolve_delayed(name, self.data[name]) + return self._get(name) + + def get(self, name, default=NOT_SET, decorated=True): + if decorated: + name = self._undecorate(name) + return self._get(name, default) + + def _get(self, name, default=NOT_SET): + try: + return self._resolve_delayed(name, self.data[name]) + except (KeyError, DataError): + if default is NOT_SET: + variable_not_found(f"${{{name}}}", self.data) + return default + + def pop(self, name, decorated=True): + if decorated: + name = self._undecorate(name) + return self.data.pop(name) def update(self, store): self.data.update(store.data) @@ -66,29 +83,36 @@ def clear(self): def add(self, name, value, overwrite=True, decorated=True): if decorated: - name, value = self._undecorate(name, value) - if overwrite or name not in self.data: + name, value = self._undecorate_and_validate(name, value) + if ( + overwrite + or name not in self.data + or isinstance(self.data[name], GlobalVariableValue) + ): self.data[name] = value - def _undecorate(self, name, value): - validate_var(name) - if name[0] == '@': + def _undecorate(self, name): + match = search_variable(name, parse_type=True) + if not match.is_assign(allow_nested=True): + raise DataError(f"Invalid variable name '{name}'.") + match.resolve_base(self._variables) + return str(match)[2:-1] + + 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 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 name[2:-1], 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 remove(self, name): - if name in self.data: - self.data.pop(name) + return undecorated, value def __len__(self): return len(self.data) @@ -104,13 +128,13 @@ def as_dict(self, decoration=True): variables = (self._decorate(name, self[name]) for name in self) else: variables = self.data - return NormalizedDict(variables, ignore='_') + return NormalizedDict(variables, ignore="_") def _decorate(self, name, value): if is_dict_like(value): - name = '&{%s}' % name + name = f"&{{{name}}}" elif is_list_like(value): - name = '@{%s}' % name + name = f"@{{{name}}}" else: - name = '${%s}' % name + name = f"${{{name}}}" return name, value diff --git a/src/robot/variables/tablesetter.py b/src/robot/variables/tablesetter.py index 1512b4e77cf..238d12b9d83 100644 --- a/src/robot/variables/tablesetter.py +++ b/src/robot/variables/tablesetter.py @@ -13,143 +13,252 @@ # See the License for the specific language governing permissions and # limitations under the License. -from contextlib import contextmanager +from typing import Any, Callable, Sequence, TYPE_CHECKING from robot.errors import DataError -from robot.utils import DotDict, is_string, split_from_equals, unic +from robot.utils import DotDict, safe_str, Secret, split_from_equals, unescape -from .isvar import validate_var -from .splitter import VariableSplitter +from .resolvable import Resolvable +from .search import is_dict_variable, is_list_variable, search_variable +if TYPE_CHECKING: + from robot.running import Var, Variable -class VariableTableSetter(object): + from .store import VariableStore - def __init__(self, store): - self._store = store - def set(self, variables, overwrite=False): - for name, value in VariableTableReader().read(variables): - self._store.add(name, value, overwrite, decorated=False) +class VariableTableSetter: + def __init__(self, store: "VariableStore"): + self.store = store -class VariableTableReader(object): - - def read(self, variables): + def set(self, variables: "Sequence[Variable]", overwrite: bool = False): for var in variables: - if not var: - continue try: - yield self._get_name_and_value(var.name, var.value, - var.report_invalid_syntax) + resolver = VariableResolver.from_variable(var) + self.store.add(resolver.name, resolver, overwrite) except DataError as err: - var.report_invalid_syntax(err) - - def _get_name_and_value(self, name, value, error_reporter): - return name[2:-1], VariableTableValue(value, name, error_reporter) - - -def VariableTableValue(value, name, error_reporter=None): - validate_var(name) - VariableTableValue = {'$': ScalarVariableTableValue, - '@': ListVariableTableValue, - '&': DictVariableTableValue}[name[0]] - return VariableTableValue(value, error_reporter) - - -class VariableTableValueBase(object): - - def __init__(self, values, error_reporter=None): - self._values = self._format_values(values) - self._error_reporter = error_reporter - self._resolving = False - - def _format_values(self, values): - return values - - def resolve(self, variables): - with self._avoid_recursion: - return self._replace_variables(self._values, variables) + var.report_error(str(err)) + + +class VariableResolver(Resolvable): + + def __init__( + self, + value: Sequence[str], + name: "str | None" = None, + type: "str | None" = None, + error_reporter: "Callable[[str], None] | None" = None, + ): + self.value = tuple(value) + self.name = name + self.type = type + self.error_reporter = error_reporter + self.resolving = False + self.resolved = False + + @classmethod + def from_name_and_value( + cls, + name: str, + value: "str | Sequence[str]", + separator: "str | None" = None, + error_reporter: "Callable[[str], None] | None" = None, + ) -> "VariableResolver": + match = search_variable(name, parse_type=True) + if not match.is_assign(allow_nested=True): + raise DataError(f"Invalid variable name '{name}'.") + if match.identifier == "$": + return ScalarVariableResolver( + value, + separator, + match.name, + match.type, + error_reporter, + ) + if separator is not None: + raise DataError("Only scalar variables support separators.") + klass = {"@": ListVariableResolver, "&": DictVariableResolver}[match.identifier] + return klass(value, match.name, match.type, error_reporter) + + @classmethod + 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, + getattr(var, "report_error", None), + ) + + def resolve(self, variables) -> Any: + if self.resolving: + raise DataError("Recursive variable definition.") + if not self.resolved: + self.resolving = True + try: + value = self._replace_variables(variables) + finally: + self.resolving = False + self.value = self._convert(value, self.type) if self.type else value + if self.name: + base = variables.replace_string(self.name[2:-1]) + self.name = self.name[:2] + base + "}" + self.resolved = True + return self.value + + def _replace_variables(self, variables) -> Any: + raise NotImplementedError - @property - @contextmanager - def _avoid_recursion(self): - if self._resolving: - raise DataError('Recursive variable definition.') - self._resolving = True + def _handle_secrets(self, value, replace_scalar): + match = search_variable(value, identifiers="$%") + if match.is_variable(): + value = replace_scalar(match.match) + return Secret(value) if match.identifier == "%" else value + return self._handle_embedded_secrets(match, replace_scalar) + + def _handle_embedded_secrets(self, match, replace_scalar): + parts = [] + secret_seen = False + while match: + value = replace_scalar(match.match) + if match.identifier == "%": + secret_seen = True + elif isinstance(value, Secret): + value = value.value + secret_seen = True + parts.extend([unescape(match.before), value]) + match = search_variable(match.after, identifiers="$%") + parts.append(unescape(match.string)) + value = "".join(safe_str(p) for p in parts) + return Secret(value) if secret_seen else value + + def _convert(self, value, type_): + from robot.running import TypeInfo + + info = TypeInfo.from_type_hint(type_) try: - yield - finally: - self._resolving = False - - def _replace_variables(self, value, variables): - raise NotImplementedError + return info.convert(value, kind="Value") + except (ValueError, TypeError) as err: + raise DataError(str(err)) def report_error(self, error): - if self._error_reporter: - self._error_reporter(unic(error)) - - -class ScalarVariableTableValue(VariableTableValueBase): - - def _format_values(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]) + if self.error_reporter: + self.error_reporter(error) + else: + raise DataError(f"Error reporter not set. Reported error was: {error}") + + def _is_secret_type(self, typ=None) -> bool: + typ = typ or self.type + return bool(typ and typ.title() == "Secret") + + +class ScalarVariableResolver(VariableResolver): + + def __init__( + self, + value: "str | Sequence[str]", + separator: "str | None" = None, + name=None, + type=None, + error_reporter=None, + ): + value, separator = self._get_value_and_separator(value, separator) + super().__init__(value, name, type, 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): + if self._is_secret_type(): + return self._handle_secrets(value[0], variables.replace_scalar) + return variables.replace_scalar(value[0]) if separator is None: - separator = ' ' - separator = variables.replace_string(separator) - values = variables.replace_list(values) - return separator.join(unic(item) for item in values) + separator = " " + 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, value, separator): + return separator is None and len(value) == 1 and not is_list_variable(value[0]) - def _is_single_value(self, separator, values): - return (separator is None and len(values) == 1 and - not VariableSplitter(values[0]).is_list_variable()) +class ListVariableResolver(VariableResolver): -class ListVariableTableValue(VariableTableValueBase): + def _replace_variables(self, variables): + if not self._is_secret_type(): + return variables.replace_list(self.value) + secrets = [] + for value in self.value: + if is_list_variable(value): + secrets.extend(variables.replace_scalar(value)) + else: + secrets.append(self._handle_secrets(value, variables.replace_scalar)) + return secrets - def _replace_variables(self, values, variables): - return variables.replace_list(values) + def _convert(self, value, type_): + return super()._convert(value, f"list[{type_}]") -class DictVariableTableValue(VariableTableValueBase): +class DictVariableResolver(VariableResolver): - def _format_values(self, values): - return list(self._yield_formatted(values)) + def __init__(self, value: Sequence[str], name=None, type=None, error_reporter=None): + super().__init__(tuple(self._yield_items(value)), name, type, error_reporter) - def _yield_formatted(self, values): + def _yield_items(self, values): for item in values: - if VariableSplitter(item).is_dict_variable(): + if is_dict_variable(item): yield item else: name, value = split_from_equals(item) if value is None: - raise DataError("Dictionary item '%s' does not contain " - "'=' separator." % item) + raise DataError( + 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): + 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('Creating dictionary failed: %s' % err) + raise DataError(f"Creating dictionary variable failed: {err}") def _yield_replaced(self, values, replace_scalar): + if not self.type: + secret_key = secret_value = False + elif "=" not in self.type: + secret_key = False + secret_value = self._is_secret_type(self.type) + else: + kt, vt = self.type.split("=", 1) + secret_key = self._is_secret_type(kt) + secret_value = self._is_secret_type(vt) for item in values: if isinstance(item, tuple): - key, values = item - yield replace_scalar(key), replace_scalar(values) + key, value = item + if secret_key: + key = self._handle_secrets(key, replace_scalar) + else: + key = replace_scalar(key) + if secret_value: + value = self._handle_secrets(value, replace_scalar) + else: + value = replace_scalar(value) + yield key, value else: - for key, values in replace_scalar(item).items(): - yield key, values + yield from replace_scalar(item).items() + + def _convert(self, value, type_): + k_type, v_type = type_.split("=", 1) if "=" in type_ else ("Any", type_) + return super()._convert(value, f"dict[{k_type}, {v_type}]") diff --git a/src/robot/variables/variables.py b/src/robot/variables/variables.py index 262f02696c9..b79f203e697 100644 --- a/src/robot/variables/variables.py +++ b/src/robot/variables/variables.py @@ -13,16 +13,15 @@ # See the License for the specific language governing permissions and # limitations under the License. -from robot.utils import is_list_like +from robot.utils import is_list_like, type_name from .filesetter import VariableFileSetter -from .finders import VariableFinder from .replacer import VariableReplacer from .store import VariableStore from .tablesetter import VariableTableSetter -class Variables(object): +class Variables: """Represents a set of variables. Contains methods for replacing variables from list, scalars, and strings. @@ -33,45 +32,58 @@ class Variables(object): def __init__(self): self.store = VariableStore(self) self._replacer = VariableReplacer(self) - self._finder = VariableFinder(self.store) def __setitem__(self, name, value): self.store.add(name, value) def __getitem__(self, name): - return self._finder.find(name) + return self.store.get(name) + + def __delitem__(self, name): + self.store.pop(name) def __contains__(self, name): return name in self.store + def get(self, name, default=None): + return self.store.get(name, default) + def resolve_delayed(self): self.store.resolve_delayed() def replace_list(self, items, replace_until=None, ignore_errors=False): if not is_list_like(items): - raise ValueError("'replace_list' requires list-like input.") + raise ValueError( + f"'replace_list' requires list-like input, got {type_name(items)}." + ) return self._replacer.replace_list(items, replace_until, ignore_errors) def replace_scalar(self, item, ignore_errors=False): return self._replacer.replace_scalar(item, ignore_errors) - def replace_string(self, item, ignore_errors=False): - return self._replacer.replace_string(item, ignore_errors) + def replace_string(self, item, custom_unescaper=None, ignore_errors=False): + return self._replacer.replace_string(item, custom_unescaper, ignore_errors) 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) def clear(self): self.store.clear() - def copy(self): + def copy(self, update=None): variables = Variables() variables.store.data = self.store.data.copy() + if update: + for name, value in update.items(): + if value is not None: + variables[name] = value + else: + del variables[name] return variables def update(self, variables): diff --git a/src/robot/version.py b/src/robot/version.py index 378a585a2a5..dc726749fe3 100644 --- a/src/robot/version.py +++ b/src/robot/version.py @@ -18,29 +18,22 @@ # Version number typically updated by running `invoke set-version `. # Run `invoke --help set-version` or see tasks.py for details. -VERSION = '3.1.3.dev1' +VERSION = "7.5b2.dev1" def get_version(naked=False): if naked: - return re.split('(a|b|rc|.dev)', VERSION)[0] + return re.split("(a|b|rc|.dev)", VERSION)[0] return VERSION def get_full_version(program=None, naked=False): - version = '%s %s (%s %s on %s)' % (program or '', - get_version(naked), - get_interpreter(), - sys.version.split()[0], - sys.platform) - return version.strip() + program = f"{program or ''} {get_version(naked)}".strip() + interpreter = f"{get_interpreter()} {sys.version.split()[0]}" + return f"{program} ({interpreter} on {sys.platform})" def get_interpreter(): - if sys.platform.startswith('java'): - return 'Jython' - if sys.platform == 'cli': - return 'IronPython' - if 'PyPy' in sys.version: - return 'PyPy' - return 'Python' + if "PyPy" in sys.version: + return "PyPy" + return "Python" diff --git a/src/robot/writer/__init__.py b/src/robot/writer/__init__.py deleted file mode 100644 index b8c6db3d0a6..00000000000 --- a/src/robot/writer/__init__.py +++ /dev/null @@ -1,26 +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. - -"""Implements writing of parsed, and possibly edited, test data back to files. - -This functionality is used by :meth:`robot.parsing.model.TestCaseFile.save` -and indirectly by :mod:`robot.tidy`. External tools should not need to use -this package directly. - -This package is considered stable, although the planned changes to -:mod:`robot.parsing` may affect also this package. -""" - -from .datafilewriter import DataFileWriter diff --git a/src/robot/writer/aligners.py b/src/robot/writer/aligners.py deleted file mode 100644 index c4280f17810..00000000000 --- a/src/robot/writer/aligners.py +++ /dev/null @@ -1,64 +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 .dataextractor import DataExtractor - - -class _Aligner(object): - - def __init__(self, widths=None): - self._widths = widths or [] - - def align_rows(self, rows): - return [self.align_row(r) for r in rows] - - def align_row(self, row): - for index, col in enumerate(row): - if len(self._widths) <= index: - break - row[index] = row[index].ljust(self._widths[index]) - return row - - -class FirstColumnAligner(_Aligner): - - def __init__(self, first_column_width): - _Aligner.__init__(self, [first_column_width]) - - -class ColumnAligner(_Aligner): - - def __init__(self, first_column_width, table): - _Aligner.__init__(self, self._count_widths(first_column_width, table)) - - def _count_widths(self, first_column_width, table): - result = [first_column_width] + [len(h) for h in table.header[1:]] - for row in DataExtractor().rows_from_table(table): - for index, col in enumerate(row[1:]): - index += 1 - if len(result) <= index: - result.append(len(col)) - else: - result[index] = max(len(col), result[index]) - return result - - -class NullAligner(_Aligner): - - def align_rows(self, rows): - return rows - - def align_row(self, row): - return row diff --git a/src/robot/writer/dataextractor.py b/src/robot/writer/dataextractor.py deleted file mode 100644 index 7cfc72f5f11..00000000000 --- a/src/robot/writer/dataextractor.py +++ /dev/null @@ -1,58 +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. - - -class DataExtractor(object): - """Transforms table of a parsed test data file into a list of rows.""" - - def __init__(self, want_name_on_first_row=None): - self._want_name_on_first_row = want_name_on_first_row or \ - (lambda t,n: False) - - def rows_from_table(self, table): - if table.type in ['setting', 'variable']: - return self._rows_from_item(table) - return self._rows_from_indented_table(table) - - def _rows_from_indented_table(self, table): - items = list(table) - for index, item in enumerate(items): - for row in self._rows_from_test_or_keyword(item, table): - yield row - if not self._last(items, index): - yield [] - - def _rows_from_test_or_keyword(self, test_or_keyword, table): - rows = list(self._rows_from_item(test_or_keyword, 1)) - for r in self._add_name(test_or_keyword.name, rows, table): - yield r - - def _add_name(self, name, rows, table): - if rows and self._want_name_on_first_row(table, name): - rows[0][0] = name - return rows - return [[name]] + rows - - def _rows_from_item(self, item, indent=0): - for child in item: - if child.is_set(): - yield [''] * indent + child.as_list() - if child.is_for_loop(): - for row in self._rows_from_item(child, indent+1): - yield row - yield ['', 'END'] - - def _last(self, items, index): - return index >= len(items) -1 diff --git a/src/robot/writer/datafilewriter.py b/src/robot/writer/datafilewriter.py deleted file mode 100644 index 242e61635b4..00000000000 --- a/src/robot/writer/datafilewriter.py +++ /dev/null @@ -1,126 +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 os - -from robot.errors import DataError -from robot.utils import binary_file_writer, file_writer, PY2 - -from .filewriters import FileWriter - - -class DataFileWriter(object): - """Object to write parsed test data file objects back to disk.""" - - def __init__(self, **options): - """ - :param `**options`: A :class:`.WritingContext` is created based on these. - """ - self._options = options - - def write(self, datafile): - """Writes given `datafile` using `**options`. - - :param datafile: The parsed test data object to be written - :type datafile: :py:class:`~robot.parsing.model.TestCaseFile`, - :py:class:`~robot.parsing.model.ResourceFile`, - :py:class:`~robot.parsing.model.TestDataDirectory` - """ - with WritingContext(datafile, **self._options) as ctx: - FileWriter(ctx).write(datafile) - - -class WritingContext(object): - """Contains configuration used in writing a test data file to disk.""" - txt_format = 'txt' - html_format = 'html' - tsv_format = 'tsv' - robot_format = 'robot' - txt_column_count = 8 - html_column_count = 5 - tsv_column_count = 8 - _formats = [txt_format, html_format, tsv_format, robot_format] - - def __init__(self, datafile, format='', output=None, pipe_separated=False, - txt_separating_spaces=4, line_separator='\n'): - """ - :param datafile: The datafile to be written. - :type datafile: :py:class:`~robot.parsing.model.TestCaseFile`, - :py:class:`~robot.parsing.model.ResourceFile`, - :py:class:`~robot.parsing.model.TestDataDirectory` - :param str format: Output file format. If omitted, read from the - extension of the `source` attribute of the given `datafile`. - :param output: An open, file-like object used in writing. If - omitted, value of `source` attribute of the given `datafile` is - used to construct a new file object. - :param bool pipe_separated: Whether to use pipes as separator when - output file format is txt. - :param int txt_separating_spaces: Number of separating spaces between - cells in space separated format. - :param str line_separator: Line separator used in output files. - - If `output` is not given, an output file is created based on the source - of the given datafile and value of `format`. Examples: - - Write output in a StringIO instance using format of `datafile.source`:: - - WriteConfiguration(datafile, output=StringIO) - - Output file is created from `datafile.source` by stripping extension - and replacing it with `html`:: - - WriteConfiguration(datafile, format='html') - """ - self.datafile = datafile - self.pipe_separated = pipe_separated - self.line_separator = line_separator - self._given_output = output - self.format = self._validate_format(format) or self._format_from_file() - self.txt_separating_spaces = txt_separating_spaces - self.output = output - - def __enter__(self): - if not self.output: - path = self._output_path() - if PY2 and self.format == self.tsv_format: - self.output = binary_file_writer(path) - else: - self.output = file_writer(path, newline=self.line_separator) - return self - - def __exit__(self, *exc_info): - if self._given_output is None: - self.output.close() - - def _validate_format(self, format): - format = format.lower() if format else '' - if format and format not in self._formats: - raise DataError('Invalid format: %s' % format) - return format - - def _format_from_file(self): - return self._format_from_extension(self._source_from_file()) - - def _format_from_extension(self, path): - return os.path.splitext(path)[1][1:].lower() - - def _output_path(self): - return '%s.%s' % (self._base_name(), self.format) - - def _base_name(self): - return os.path.splitext(self._source_from_file())[0] - - def _source_from_file(self): - return getattr(self.datafile, 'initfile', self.datafile.source) diff --git a/src/robot/writer/filewriters.py b/src/robot/writer/filewriters.py deleted file mode 100644 index 9849e0dd950..00000000000 --- a/src/robot/writer/filewriters.py +++ /dev/null @@ -1,149 +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. - -try: - import csv -except ImportError: - # csv module is missing from IronPython < 2.7.1 - csv = None - -from robot.utils import HtmlWriter, PY2 - -from .formatters import TsvFormatter, TxtFormatter, PipeFormatter -from .htmlformatter import HtmlFormatter -from .htmltemplate import TEMPLATE_START, TEMPLATE_END - - -def FileWriter(context): - """Creates and returns a ``FileWriter`` object. - - :param context: The type of the returned ``FileWriter`` is determined based - on ``context.format``. ``context`` is also passed to created writer. - :type context: :class:`~robot.writer.datafilewriter.WritingContext` - """ - if context.format == context.html_format: - return HtmlFileWriter(context) - if context.format == context.tsv_format: - return TsvFileWriter(context) - if context.pipe_separated: - return PipeSeparatedTxtWriter(context) - return SpaceSeparatedTxtWriter(context) - - -class _DataFileWriter(object): - - def __init__(self, formatter, configuration): - self._formatter = formatter - self._output = configuration.output - - def write(self, datafile): - tables = [table for table in datafile if table] - for table in tables: - self._write_table(table, is_last=table is tables[-1]) - - def _write_table(self, table, is_last): - self._write_header(table) - self._write_rows(self._formatter.format_table(table)) - if not is_last: - self._write_empty_row(table) - - def _write_header(self, table): - self._write_row(self._formatter.format_header(table)) - - def _write_rows(self, rows): - for row in rows: - self._write_row(row) - - def _write_empty_row(self, table): - self._write_row(self._formatter.empty_row_after(table)) - - def _write_row(self, row): - raise NotImplementedError - - -class SpaceSeparatedTxtWriter(_DataFileWriter): - - def __init__(self, configuration): - formatter = TxtFormatter(configuration.txt_column_count) - self._separator = ' ' * configuration.txt_separating_spaces - _DataFileWriter.__init__(self, formatter, configuration) - - def _write_row(self, row): - line = self._separator.join(row).rstrip() + '\n' - self._output.write(line) - - -class PipeSeparatedTxtWriter(_DataFileWriter): - _separator = ' | ' - - def __init__(self, configuration): - formatter = PipeFormatter(configuration.txt_column_count) - _DataFileWriter.__init__(self, formatter, configuration) - - def _write_row(self, row): - row = self._separator.join(row) - if row: - row = '| ' + row + ' |' - self._output.write(row + '\n') - - -class TsvFileWriter(_DataFileWriter): - - def __init__(self, configuration): - if not csv: - raise RuntimeError('No csv module found. ' - 'Writing tab separated format is not possible.') - formatter = TsvFormatter(configuration.tsv_column_count) - _DataFileWriter.__init__(self, formatter, configuration) - self._writer = self._get_writer(configuration) - - def _get_writer(self, configuration): - # Custom dialect needed as a workaround for - # http://ironpython.codeplex.com/workitem/33627 - dialect = csv.excel_tab() - dialect.lineterminator = configuration.line_separator if PY2 else '\n' - return csv.writer(configuration.output, dialect=dialect) - - def _write_row(self, row): - if PY2: - row = [c.encode('UTF-8') for c in row] - self._writer.writerow(row) - - -class HtmlFileWriter(_DataFileWriter): - - def __init__(self, configuration): - formatter = HtmlFormatter(configuration.html_column_count) - _DataFileWriter.__init__(self, formatter, configuration) - self._name = configuration.datafile.name - self._writer = HtmlWriter(configuration.output) - - def write(self, datafile): - self._writer.content(TEMPLATE_START % {'NAME': self._name}, escape=False) - _DataFileWriter.write(self, datafile) - self._writer.content(TEMPLATE_END, escape=False) - - def _write_table(self, table, is_last): - self._writer.start('table', {'id': table.type.replace(' ', ''), - 'border': '1'}) - _DataFileWriter._write_table(self, table, is_last) - self._writer.end('table') - - def _write_row(self, row): - self._writer.start('tr') - for cell in row: - self._writer.element(cell.tag, cell.content, cell.attributes, - escape=False) - self._writer.end('tr') diff --git a/src/robot/writer/formatters.py b/src/robot/writer/formatters.py deleted file mode 100644 index 40b6af76d1b..00000000000 --- a/src/robot/writer/formatters.py +++ /dev/null @@ -1,153 +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 re - -from .aligners import FirstColumnAligner, ColumnAligner, NullAligner -from .dataextractor import DataExtractor -from .rowsplitter import RowSplitter - - -class _DataFileFormatter(object): - _whitespace = re.compile('\s{2,}') - _split_multiline_doc = True - - def __init__(self, column_count): - self._splitter = RowSplitter(column_count, self._split_multiline_doc) - self._column_count = column_count - self._extractor = DataExtractor(self._want_names_on_first_content_row) - - def _want_names_on_first_content_row(self, table, name): - return True - - def empty_row_after(self, table): - return self._format_row([], table) - - def format_header(self, table): - header = self._format_row(table.header) - return self._format_header(header, table) - - def format_table(self, table): - rows = self._extractor.rows_from_table(table) - if self._should_split_rows(table): - rows = self._split_rows(rows, table) - return (self._format_row(r, table) for r in rows) - - def _should_split_rows(self, table): - return not self._should_align_columns(table) - - def _split_rows(self, original_rows, table): - for original in original_rows: - for split in self._splitter.split(original, table.type): - yield split - - def _should_align_columns(self, table): - return self._is_indented_table(table) and bool(table.header[1:]) - - def _is_indented_table(self, table): - return table is not None and table.type in ['test case', 'keyword'] - - def _escape_consecutive_whitespace(self, row): - return [self._whitespace.sub(self._whitespace_escaper, - cell.replace('\n', ' ')) for cell in row] - - def _whitespace_escaper(self, match): - return '\\'.join(match.group(0)) - - def _format_row(self, row, table=None): - raise NotImplementedError - - def _format_header(self, header, table): - raise NotImplementedError - - -class TsvFormatter(_DataFileFormatter): - - def _format_header(self, header, table): - return [self._format_header_cell(cell) for cell in header] - - def _format_header_cell(self, cell): - return '*%s*' % cell if cell else '' - - def _format_row(self, row, table=None): - return self._pad(self._escape(row)) - - def _escape(self, row): - return self._escape_consecutive_whitespace(self._escape_tabs(row)) - - def _escape_tabs(self, row): - return [c.replace('\t', '\\t') for c in row] - - def _pad(self, row): - row = [cell.replace('\n', ' ') for cell in row] - return row + [''] * (self._column_count - len(row)) - - -class TxtFormatter(_DataFileFormatter): - _test_or_keyword_name_width = 18 - _setting_and_variable_name_width = 14 - - def _format_row(self, row, table=None): - row = self._escape(row) - aligner = self._aligner_for(table) - return aligner.align_row(row) - - def _aligner_for(self, table): - if table and table.type in ['setting', 'variable']: - return FirstColumnAligner(self._setting_and_variable_name_width) - if self._should_align_columns(table): - return ColumnAligner(self._test_or_keyword_name_width, table) - return NullAligner() - - def _format_header(self, header, table): - header = ['*** %s ***' % header[0]] + header[1:] - aligner = self._aligner_for(table) - return aligner.align_row(header) - - def _want_names_on_first_content_row(self, table, name): - return self._should_align_columns(table) and \ - len(name) <= self._test_or_keyword_name_width - - def _escape(self, row): - if not row: - return row - return list(self._escape_cells(self._escape_consecutive_whitespace(row))) - - def _escape_cells(self, row): - escape = False - for cell in row: - if cell: - escape = True - elif escape: - cell = '\\' - yield cell - - -class PipeFormatter(TxtFormatter): - - def _escape_cells(self, row): - return [self._escape_empty(self._escape_pipes(cell)) for cell in row] - - def _escape_empty(self, cell): - return cell or ' ' - - def _escape_pipes(self, cell): - if ' | ' in cell: - cell = cell.replace(' | ', ' \\| ') - if cell.startswith('| '): - cell = '\\' + cell - if cell.endswith(' |'): - cell = cell[:-1] + '\\|' - return cell diff --git a/src/robot/writer/htmlformatter.py b/src/robot/writer/htmlformatter.py deleted file mode 100644 index b94f3c90856..00000000000 --- a/src/robot/writer/htmlformatter.py +++ /dev/null @@ -1,131 +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 re - -from robot.utils import attribute_escape, html_escape - -from .formatters import _DataFileFormatter - - -class HtmlFormatter(_DataFileFormatter): - _split_multiline_doc = False - - def _format_row(self, row, table=None): - row = self._pad(self._escape_consecutive_whitespace(row), table) - if self._is_documentation_row(row): - return self._create_documentation_row(row) - first_cell = self._create_first_cell(row[0], table) - if self._is_indented_documentation_row(row[1:], table): - return self._create_indented_documentation_row(first_cell, row[1:]) - return [first_cell] + [HtmlCell(c) for c in row[1:]] - - def _is_documentation_row(self, row): - return row[0] == 'Documentation' - - def _create_documentation_row(self, row): - return [NameCell(row[0]), DocumentationCell(row[1], span=self._column_count-1)] - - def _is_indented_documentation_row(self, cells, table): - return self._is_indented_table(table) and cells and \ - cells[0] == '[Documentation]' - - def _create_indented_documentation_row(self, first_cell, cells): - start = [first_cell, HtmlCell(cells[0])] - if any(c.startswith('#') for c in cells): - return start + [HtmlCell(c) for c in cells[1:]] - return start + [DocumentationCell(cells[1], self._column_count-2)] - - def _create_first_cell(self, cell, table): - if self._is_indented_table(table) and cell: - return AnchorNameCell(cell, 'keyword' if table.type == 'keyword' - else 'test') - return NameCell(cell) - - def format_header(self, table): - if not self._should_align_columns(table) or len(table.header) == 1: - return [HeaderCell(table.header[0], self._column_count)] - headers = self._pad_header(table) - return [HeaderCell(hdr) for hdr in headers] - - def _pad_header(self, table): - header = table.header - return header + [''] * (self._get_column_count(table) - len(header)) - - def _pad(self, row, table): - return row + [''] * (self._get_column_count(table) - len(row)) - - def _get_column_count(self, table): - if table is None or len(table.header) == 1 \ - or not self._is_indented_table(table): - return self._column_count - return max(self._max_column_count(table), len(table.header)) - - def _max_column_count(self, table): - count = 0 - for item in table: - for child in item: - count = max(count, len(child.as_list()) + 1) - return count - - -class HtmlCell(object): - _backslash_matcher = re.compile(r'(\\+)n ?') - - def __init__(self, content='', attributes=None, tag='td', escape=True): - if escape: - content = html_escape(content) - self.content = self._replace_newlines(content) - self.attributes = attributes or {} - self.tag = tag - - def _replace_newlines(self, content): - def replacer(match): - backslash_count = len(match.group(1)) - if backslash_count % 2 == 1: - return '%sn
    \n' % match.group(1) - return match.group() - return self._backslash_matcher.sub(replacer, content) - - -class NameCell(HtmlCell): - - def __init__(self, name='', attributes=None): - HtmlCell.__init__(self, name, {'class': 'name'}) - - -class AnchorNameCell(HtmlCell): - - def __init__(self, name, type_): - HtmlCell.__init__(self, self._link_from_name(name, type_), - {'class': 'name'}, escape=False) - - def _link_from_name(self, name, type_): - return '%s' % (type_, attribute_escape(name), - html_escape(name)) - - -class DocumentationCell(HtmlCell): - - def __init__(self, content, span): - HtmlCell.__init__(self, content, {'class': 'colspan%d' % span, - 'colspan': '%d' % span}) - - -class HeaderCell(HtmlCell): - - def __init__(self, name, span=1): - HtmlCell.__init__(self, name, {'class': 'name', 'colspan': '%d' % span}, - tag='th') diff --git a/src/robot/writer/htmltemplate.py b/src/robot/writer/htmltemplate.py deleted file mode 100644 index 248accfa8a3..00000000000 --- a/src/robot/writer/htmltemplate.py +++ /dev/null @@ -1,71 +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. - -TEMPLATE_START = """\ - - - - - -%(NAME)s - - -

    %(NAME)s

    -""" -TEMPLATE_END = """ - -""" diff --git a/src/robot/writer/rowsplitter.py b/src/robot/writer/rowsplitter.py deleted file mode 100644 index 5a3dd8b10ed..00000000000 --- a/src/robot/writer/rowsplitter.py +++ /dev/null @@ -1,108 +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 itertools - - -class RowSplitter(object): - _comment_mark = '#' - _empty_cell_escape = '${EMPTY}' - _line_continuation = '...' - _setting_table = 'setting' - _indented_tables = ('test case', 'keyword') - _split_from = ('ELSE', 'ELSE IF', 'AND') - - def __init__(self, cols=8, split_multiline_doc=True): - self._cols = cols - self._split_multiline_doc = split_multiline_doc - - def split(self, row, table_type): - if not row: - return self._split_empty_row() - indent = self._get_indent(row, table_type) - if self._split_multiline_doc and self._is_doc_row(row, table_type): - return self._split_doc_row(row, indent) - return self._split_row(row, indent) - - def _split_empty_row(self): - yield [] - - def _get_indent(self, row, table_type): - indent = self._get_first_non_empty_index(row) - min_indent = 1 if table_type in self._indented_tables else 0 - return max(indent, min_indent) - - def _get_first_non_empty_index(self, row, indented=False): - ignore = ['', '...'] if indented else [''] - return len(list(itertools.takewhile(lambda x: x in ignore, row))) - - def _is_doc_row(self, row, table_type): - if table_type == self._setting_table: - return len(row) > 1 and row[0] == 'Documentation' - if table_type in self._indented_tables: - return len(row) > 2 and row[1] == '[Documentation]' - return False - - def _split_doc_row(self, row, indent): - first, rest = self._split_doc(row[indent+1]) - yield row[:indent+1] + [first] + row[indent+2:] - while rest: - current, rest = self._split_doc(rest) - row = [current] if current else [] - yield self._continue_row(row, indent) - - def _split_doc(self, doc): - if '\\n' not in doc: - return doc, '' - if '\\n ' in doc: - doc = doc.replace('\\n ', '\\n') - return doc.split('\\n', 1) - - def _split_row(self, row, indent): - while row: - current, row = self._split(row) - yield self._escape_last_cell_if_empty(current) - if row: - row = self._continue_row(row, indent) - - def _split(self, data): - index = min(self._get_possible_split_indices(data)) - current, rest = data[:index], data[index:] - rest = self._comment_rest_if_needed(current, rest) - return current, rest - - def _get_possible_split_indices(self, data): - min_index = self._get_first_non_empty_index(data, indented=True) + 1 - for marker in self._split_from: - if marker in data[min_index:]: - yield data[min_index:].index(marker) + min_index - yield self._cols - - def _comment_rest_if_needed(self, current, rest): - if rest and any(c.startswith(self._comment_mark) for c in current) \ - and not rest[0].startswith(self._comment_mark): - rest = [self._comment_mark + ' ' + rest[0]] + rest[1:] - return rest - - def _escape_last_cell_if_empty(self, row): - if row and not row[-1].strip(): - row = row[:-1] + [self._empty_cell_escape] - return row - - def _continue_row(self, row, indent): - row = [self._line_continuation] + row - if indent + 1 < self._cols: - row = [''] * indent + row - return row diff --git a/src/web/.htmlnanorc b/src/web/.htmlnanorc new file mode 100644 index 00000000000..d59c951ec5a --- /dev/null +++ b/src/web/.htmlnanorc @@ -0,0 +1,4 @@ +{ + "removeComments": false, + "collapseWhitespace": false +} diff --git a/src/web/.parcelrc b/src/web/.parcelrc new file mode 100644 index 00000000000..08df838b937 --- /dev/null +++ b/src/web/.parcelrc @@ -0,0 +1,7 @@ +{ + "extends": "@parcel/config-default", + "transformers": { + "*.x-handlebars-template": ["parcel-transformer-plaintext"], + "*.html": ["./html-cleaner.mjs", "..."] + } +} diff --git a/src/web/.prettierignore b/src/web/.prettierignore new file mode 100644 index 00000000000..1a33f1f2992 --- /dev/null +++ b/src/web/.prettierignore @@ -0,0 +1,2 @@ +dist +docs diff --git a/src/web/.prettierrc b/src/web/.prettierrc new file mode 100644 index 00000000000..0967ef424bc --- /dev/null +++ b/src/web/.prettierrc @@ -0,0 +1 @@ +{} diff --git a/src/web/README.md b/src/web/README.md new file mode 100644 index 00000000000..7cd4b5a6c56 --- /dev/null +++ b/src/web/README.md @@ -0,0 +1,44 @@ +# Robot Framework web projects + +This directory contains the Robot Framework HTML frontend for libdoc. Eventually, also log and report will be moved to the same tech stack. + +## Tech + +This prototype uses following technologies: + +- [Parcel](https://parceljs.org) is used to create development and (minified) shipping bundles. It offers a very low-configuration way of creating standalone HTML files, which contain all the code and styles inlined. +- [Typescript](https://www.typescriptlang.org) is used to write the business logic. It offers better development ergomonics than plain Javascript. +- [Handlebars](https://handlebarsjs.com) is used for templating. Using either HTML `