diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index 5dfa9d7..11f54d7 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -14,22 +14,23 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: [3.8, 3.11.1] - rf-version: [5.0.1, 6.1.0] + python-version: [3.8, 3.12] + rf-version: [5.0.1, 7.0.1] steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} with Robot Framework ${{ matrix.rf-version }} - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - name: Install dependencies run: | python -m pip install --upgrade pip - pip install -r requirements-dev.txt + pip install uv + uv pip install -r requirements-dev.txt --python ${{ matrix.python-version }} --system - name: Install RF ${{ matrix.rf-version }} run: | - pip install -U --pre robotframework==${{ matrix.rf-version }} + uv pip install -U robotframework==${{ matrix.rf-version }} --python ${{ matrix.python-version }} --system - name: Run ruff run: | ruff check ./src tasks.py @@ -45,8 +46,8 @@ jobs: - name: Run acceptance tests run: | python atest/run.py - - uses: actions/upload-artifact@v3 + - uses: actions/upload-artifact@v4 if: ${{ always() }} with: - name: atest_results + name: atest_results-${{ matrix.python-version }}-${{ matrix.rf-version }} path: atest/results diff --git a/.gitignore b/.gitignore index 4f4a815..13badd1 100644 --- a/.gitignore +++ b/.gitignore @@ -46,6 +46,9 @@ htmlcov/ .coverage .coverage.* .cache +.ruff_cache +.mypy_cache +.pytest_cache nosetests.xml coverage.xml *,cover @@ -97,6 +100,9 @@ ENV/ # PyCharm project settings .idea +# VSCode project settings +.vscode + # Robot Ouput files log.html output.xml diff --git a/BUILD.md b/BUILD.md new file mode 100644 index 0000000..89daf8c --- /dev/null +++ b/BUILD.md @@ -0,0 +1,227 @@ +# Creating PythonLibCore releases + +These instructions cover steps needed to create new releases of +PythonLibCore. Many individual steps are automated, but we don\'t want +to automate the whole procedure because it would be hard to react if +something goes terribly wrong. When applicable, the steps are listed as +commands that can be copied and executed on the command line. + +# Preconditions + +## Operating system and Python requirements + +Generating releases has only been tested on Linux, but it ought to work +the same way also on OSX and other unixes. Generating releases on +Windows may work but is not tested, supported, or recommended. + +Creating releases is only supported with Python 3.6 or newer. + +The `pip` and `invoke` commands below are also expected to run on Python +3.6+. Alternatively, it\'s possible to use the `python3.6 -m pip` +approach to run these commands. + +## Python dependencies + +Many steps are automated using the generic [Invoke](http://pyinvoke.org) +tool with a help by our [rellu](https://github.com/robotframework/rellu) +utilities, but also other tools and modules are needed. A pre-condition +is installing all these, and that\'s easiest done using +[pip](http://pip-installer.org) and the provided +[requirements-dev.txt](requirements-dev.txt) file: + + pip install -r requirements-dev.txt + +## Using Invoke + +Invoke tasks are defined in the [tasks.py](tasks.py) file and they are +executed from the command line like: + + inv[oke] task [options] + +Run `invoke` without arguments for help. All tasks can be listed using +`invoke --list` and each task\'s usage with `invoke --help task`. + +## Different Git workflows + +Git commands used below always expect that `origin` is the project main +repository. If that\'s not the case, and instead `origin` is your +personal fork, you probably still want to push to the main repository. +In that case you need to add `upstream` or similar to `git push` +commands before running them. + +# Testing + +Make sure that adequate unit and acceptance tests are executed using +supported interpreters and operating systems before releases are +created. Unit and acceptance tests can be executed by running +[utest/run.py](utest/run.py) and [atest/run.py](atest/run.py) scripts, +respectively. + +# Preparation + +1. Check that you are on the master branch and have nothing left to + commit, pull, or push: + + git branch + git status + git pull --rebase + git push + +2. Clean up: + + invoke clean + +3. 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`. + +# Release notes + +1. Set GitHub user information into shell variables to ease + copy-pasting the following command: + + GITHUB_USERNAME= + GITHUB_PASSWORD= + + Alternatively, supply the credentials when running that command. + +2. Generate a template for the release notes: + + invoke release-notes -w -v $VERSION -u $GITHUB_USERNAME -p $GITHUB_PASSWORD + + The `-v $VERSION` option can be omitted if [version is already + set](#set-version). 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`, + 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`). + +3. Fill the missing details in the generated release notes template. + +4. 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 the release notes. + - Issue priorities should be consistent. + - Issue titles should be informative. Consistency is good here + too, but no need to overdo it. + + If information needs to be added or edited, its better to edit it in + the issue tracker than in the generated release notes. This allows + re-generating the list of issues later if more issues are added. + +5. Add, commit and push: + + git add docs/PythonLibCore-$VERSION.rst + git commit -m "Release notes for $VERSION" docs/PythonLibCore-$VERSION.rst + git push + +6. 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. + +# Set version + +1. Set version information in + [src/robotlibcore/__init__.py](src/robotlibcore/__init__.py): + + invoke set-version $VERSION + +2. Commit and push changes: + + git commit -m "Updated version to $VERSION" src/robotlibcore/__init__.py + git push + +# Tagging + +1. Create an annotated tag and push it: + + git tag -a v$VERSION -m "Release $VERSION" + git push --tags + +2. Add short release notes to GitHub\'s [releases + page](https://github.com/robotframework/PythonLibCore/releases) with + a link to the full release notes. + +# Creating distributions + +1. Checkout the earlier created tag if necessary: + + git checkout v$VERSION + + This isn\'t necessary if continuing right after [tagging](#tagging). + +2. Cleanup (again). This removes temporary files as well as `build` and + `dist` directories: + + invoke clean + +3. Create source distribution and universal (i.e. Python 2 and 3 + compatible) [wheel](http://pythonwheels.com): + + python setup.py sdist bdist_wheel --universal + ls -l dist + + Distributions can be tested locally if needed. + +4. Upload distributions to PyPI: + + twine upload dist/* + +5. Verify that project the page at + [PyPI](https://pypi.org/project/robotframework-pythonlibcore/) looks + good. + +6. Test installation (add `--pre` with pre-releases): + + pip install --upgrade robotframework-pythonlibcore + +# Post actions + +1. Back to master if needed: + + git checkout master + +2. Set dev version based on the previous version: + + invoke set-version dev + git commit -m "Back to dev version" src/robotlibcore/__init__.py + git push + + For example, `1.2.3` is changed to `1.2.4.dev1` and `2.0.1a1` to + `2.0.1a2.dev1`. + +3. Close the [issue tracker + milestone](https://github.com/robotframework/PythonLibCore/milestones). + Create also new milestone for the next release unless one exists + already. + +# Announcements + +1. [robotframework-users](https://groups.google.com/group/robotframework-users) + and + [robotframework-announce](https://groups.google.com/group/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](http://twitter.com/robotframework), + or send the message directly as [\@robotframework]{.title-ref}. This + makes the note appear also at . + + Should include a link to more information. Possibly a link to the + full release notes or an email to the aforementioned mailing lists. + +3. Slack community. The `#general` channel is probably best. + +4. Possibly also [Robot Framework + LinkedIn](https://www.linkedin.com/groups/Robot-Framework-3710899) + group. diff --git a/BUILD.rst b/BUILD.rst deleted file mode 100644 index da04047..0000000 --- a/BUILD.rst +++ /dev/null @@ -1,238 +0,0 @@ -Creating PythonLibCore releases -=============================== - -These instructions cover steps needed to create new releases of PythonLibCore. -Many individual steps are automated, but we don't want to automate -the whole procedure because it would be hard to react if something goes -terribly wrong. When applicable, the steps are listed as commands that can -be copied and executed on the command line. - -.. contents:: - :depth: 1 - -Preconditions -------------- - -Operating system and Python requirements -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -Generating releases has only been tested on Linux, but it ought to work the -same way also on OSX and other unixes. Generating releases on Windows may -work but is not tested, supported, or recommended. - -Creating releases is only supported with Python 3.6 or newer. - -The ``pip`` and ``invoke`` commands below are also expected to run on Python -3.6+. Alternatively, it's possible to use the ``python3.6 -m pip`` approach -to run these commands. - -Python dependencies -~~~~~~~~~~~~~~~~~~~ - -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:: - - pip install -r requirements-build.txt - -Using Invoke -~~~~~~~~~~~~ - -Invoke tasks are defined in the ``_ file and they are executed from -the command line like:: - - inv[oke] task [options] - -Run ``invoke`` without arguments for help. All tasks can be listed using -``invoke --list`` and each task's usage with ``invoke --help task``. - -Different Git workflows -~~~~~~~~~~~~~~~~~~~~~~~ - -Git commands used below always expect that ``origin`` is the project main -repository. If that's not the case, and instead ``origin`` is your personal -fork, you probably still want to push to the main repository. In that case -you need to add ``upstream`` or similar to ``git push`` commands before -running them. - -Testing -------- - -Make sure that adequate unit and acceptance tests are executed using -supported interpreters and operating systems before releases are created. -Unit and acceptance tests can be executed by running ``_ and -``_ scripts, respectively. - -Preparation ------------ - -1. Check that you are on the master branch and have nothing left to commit, - pull, or push:: - - git branch - git status - git pull --rebase - git push - -2. Clean up:: - - invoke clean - -3. 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``. - -Release notes -------------- - -1. Set GitHub user information into shell variables to ease copy-pasting the - following command:: - - GITHUB_USERNAME= - GITHUB_PASSWORD= - - Alternatively, supply the credentials when running that command. - -2. Generate a template for the release notes:: - - invoke release-notes -w -v $VERSION -u $GITHUB_USERNAME -p $GITHUB_PASSWORD - - 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``, - 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. - -3. 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 - the release notes. - - Issue priorities should be consistent. - - Issue titles should be informative. Consistency is good here too, but - no need to overdo it. - - If information needs to be added or edited, its better to edit it in the - 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:: - - git add docs/PythonLibCore-$VERSION.rst - git commit -m "Release notes for $VERSION" docs/PythonLibCore-$VERSION.rst - git push - -5. 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. - -Set version ------------ - -1. Set version information in ``_:: - - invoke set-version $VERSION - -2. Commit and push changes:: - - git commit -m "Updated version to $VERSION" src/robotlibcore.py - git push - -Tagging -------- - -1. Create an annotated tag and push it:: - - git tag -a v$VERSION -m "Release $VERSION" - git push --tags - -2. Add short release notes to GitHub's `releases page - `_ - with a link to the full release notes. - -Creating distributions ----------------------- - -1. Checkout the earlier created tag if necessary:: - - git checkout v$VERSION - - This isn't necessary if continuing right after tagging_. - -2. Cleanup (again). This removes temporary files as well as ``build`` and - ``dist`` directories:: - - invoke clean - -3. Create source distribution and universal (i.e. Python 2 and 3 compatible) - `wheel `_:: - - python setup.py sdist bdist_wheel --universal - ls -l dist - - Distributions can be tested locally if needed. - -4. Upload distributions to PyPI:: - - twine upload dist/* - -5. Verify that project the page at `PyPI - `_ - looks good. - -6. Test installation (add ``--pre`` with pre-releases):: - - pip install --upgrade robotframework-pythonlibcore - -Post actions ------------- - -1. Back to master if needed:: - - git checkout master - -2. Set dev version based on the previous version:: - - invoke set-version dev - git commit -m "Back to dev version" src/robotlibcore.py - git push - - For example, ``1.2.3`` is changed to ``1.2.4.dev1`` and ``2.0.1a1`` - to ``2.0.1a2.dev1``. - -3. Close the `issue tracker milestone - `_. - Create also new milestone for the next release unless one exists already. - -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. Slack community. The ``#general`` channel is probably best. - -4. Possibly also `Robot Framework LinkedIn - `_ group. diff --git a/README.md b/README.md new file mode 100644 index 0000000..af276a7 --- /dev/null +++ b/README.md @@ -0,0 +1,241 @@ +# Python Library Core + +Tools to ease creating larger test libraries for [Robot +Framework](http://robotframework.org) using Python. The Robot Framework +[hybrid](https://robotframework.org/robotframework/latest/RobotFrameworkUserGuide.html#hybrid-library-api) +and [dynamic library +API](https://robotframework.org/robotframework/latest/RobotFrameworkUserGuide.html#dynamic-library-api) +gives more flexibility for library than the static library API, but they +also sets requirements for libraries which needs to be implemented in +the library side. PythonLibCore eases the problem by providing simpler +interface and handling all the requirements towards the Robot Framework +library APIs. + +Code is stable and is already used by +[SeleniumLibrary](https://github.com/robotframework/SeleniumLibrary/) +and +[Browser library](https://github.com/MarketSquare/robotframework-browser/). +Project supports two latest version of Robot Framework. + +[![Version](https://img.shields.io/pypi/v/robotframework-pythonlibcore.svg)](https://pypi.python.org/pypi/robotframework-pythonlibcore/) +[![Actions Status](https://github.com/robotframework/PythonLibCore/workflows/CI/badge.svg)](https://github.com/robotframework/PythonLibCore/actions) +[![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://opensource.org/licenses/Apache-2.0) + +## Usage + +There are two ways to use PythonLibCore, either by +`HybridCore` or by using `DynamicCore`. `HybridCore` provides support for +the hybrid library API and `DynamicCore` provides support for dynamic library API. +Consult the Robot Framework [User +Guide](https://robotframework.org/robotframework/latest/RobotFrameworkUserGuide.html#creating-test-libraries), +for choosing the correct API for library. + +Regardless which library API is chosen, both have similar requirements. + +1) Library must inherit either the `HybridCore` or `DynamicCore`. +2) Library keywords must be decorated with Robot Framework + [\@keyword](https://github.com/robotframework/robotframework/blob/master/src/robot/api/deco.py) + decorator. +3) Provide a list of class instances implementing keywords to + `library_components` argument in the `HybridCore` or `DynamicCore` `__init__`. + +It is also possible implement keywords in the library main class, by marking method with +`@keyword` as keywords. It is not required pass main library instance in the +`library_components` argument. + +All keyword, also keywords implemented in the classes outside of the +main library are available in the library instance as methods. This +automatically publish library keywords in as methods in the Python +public API. + +The example in below demonstrates how the PythonLibCore can be used with +a library. + +## Installation +To install this library, run the following command in your terminal: +``` bash +pip install robotframework-pythonlibcore +``` +This command installs the latest version of `robotframework-pythonlibcore`, ensuring you have all the current features and updates. + +# Example + +``` python +"""Main library.""" + +from robotlibcore import DynamicCore + +from mystuff import Library1, Library2 + + +class MyLibrary(DynamicCore): + """General library documentation.""" + + def __init__(self): + libraries = [Library1(), Library2()] + DynamicCore.__init__(self, libraries) + + @keyword + def keyword_in_main(self): + pass +``` + +``` python +"""Library components.""" + +from robotlibcore import keyword + + +class Library1(object): + + @keyword + def example(self): + """Keyword documentation.""" + pass + + @keyword + def another_example(self, arg1, arg2='default'): + pass + + def not_keyword(self): + pass + + +class Library2(object): + + @keyword('Custom name') + def this_name_is_not_used(self): + pass + + @keyword(tags=['tag', 'another']) + def tags(self): + pass +``` + +# Plugin API + +It is possible to create plugin API to a library by using PythonLibCore. +This allows extending library with external Python classes. Plugins can +be imported during library import time, example by defining argumet in +library [\_\_init\_\_]{.title-ref} which allows defining the plugins. It +is possible to define multiple plugins, by seperating plugins with with +comma. Also it is possible to provide arguments to plugin by seperating +arguments with semicolon. + +``` python +from robot.api.deco import keyword # noqa F401 + +from robotlibcore import DynamicCore, PluginParser + +from mystuff import Library1, Library2 + + +class PluginLib(DynamicCore): + + def __init__(self, plugins): + plugin_parser = PluginParser() + libraries = [Library1(), Library2()] + parsed_plugins = plugin_parser.parse_plugins(plugins) + libraries.extend(parsed_plugins) + DynamicCore.__init__(self, libraries) +``` + +When plugin class can look like this: + +``` python +class MyPlugi: + + @keyword + def plugin_keyword(self): + return 123 +``` + +Then Library can be imported in Robot Framework side like this: + +``` robotframework +Library ${CURDIR}/PluginLib.py plugins=${CURDIR}/MyPlugin.py +``` + +# Translation + +PLC supports translation of keywords names and documentation, but arguments names, tags and types +can not be currently translated. Translation is provided as a file containing +[Json](https://www.json.org/json-en.html) and as a +[Path](https://docs.python.org/3/library/pathlib.html) object. Translation is provided in +`translation` argument in the `HybridCore` or `DynamicCore` `__init__`. Providing translation +file is optional, also it is not mandatory to provide translation to all keyword. + +The keys of json are the methods names, not the keyword names, which implements keyword. Value +of key is json object which contains two keys: `name` and `doc`. `name` key contains the keyword +translated name and `doc` contains keyword translated documentation. Providing +`doc` and `name` is optional, example translation json file can only provide translations only +to keyword names or only to documentatin. But it is always recomended to provide translation to +both `name` and `doc`. + +Library class documentation and instance documetation has special keys, `__init__` key will +replace instance documentation and `__intro__` will replace libary class documentation. + +## Example + +If there is library like this: +```python +from pathlib import Path + +from robotlibcore import DynamicCore, keyword + +class SmallLibrary(DynamicCore): + """Library documentation.""" + + def __init__(self, translation: Path): + """__init__ documentation.""" + DynamicCore.__init__(self, [], translation.absolute()) + + @keyword(tags=["tag1", "tag2"]) + def normal_keyword(self, arg: int, other: str) -> str: + """I have doc + + Multiple lines. + Other line. + """ + data = f"{arg} {other}" + print(data) + return data + + def not_keyword(self, data: str) -> str: + print(data) + return data + + @keyword(name="This Is New Name", tags=["tag1", "tag2"]) + def name_changed(self, some: int, other: int) -> int: + """This one too""" + print(f"{some} {type(some)}, {other} {type(other)}") + return some + other +``` + +And when there is translation file like: +```json +{ + "normal_keyword": { + "name": "other_name", + "doc": "This is new doc" + }, + "name_changed": { + "name": "name_changed_again", + "doc": "This is also replaced.\n\nnew line." + }, + "__init__": { + "name": "__init__", + "doc": "Replaces init docs with this one." + }, + "__intro__": { + "name": "__intro__", + "doc": "New __intro__ documentation is here." + }, +} +``` +Then `normal_keyword` is translated to `other_name`. Also this keyword documentions is +translted to `This is new doc`. The keyword is `name_changed` is translted to +`name_changed_again` keyword and keyword documentation is translted to +`This is also replaced.\n\nnew line.`. The library class documentation is translated +to `Replaces init docs with this one.` and class documentation is translted to +`New __intro__ documentation is here.` diff --git a/README.rst b/README.rst deleted file mode 100644 index 5166682..0000000 --- a/README.rst +++ /dev/null @@ -1,149 +0,0 @@ -Python Library Core -=================== - -Tools to ease creating larger test libraries for `Robot Framework`_ using -Python. The Robot Framework `hybrid`_ and `dynamic library API`_ gives more -flexibility for library than the static library API, but they also sets requirements -for libraries which needs to be implemented in the library side. PythonLibCore -eases the problem by providing simpler interface and handling all the requirements -towards the Robot Framework library APIs. - -Code is stable and version 1.0 is already used by SeleniumLibrary_ and -WhiteLibrary_. The version 2.0 support changes in the Robot Framework -3.2. - -.. image:: https://github.com/robotframework/PythonLibCore/workflows/CI/badge.svg?branch=master - :target: https://github.com/robotframework/PythonLibCore - -Usage ------ -There are two ways to use PythonLibCore, either by `HybridCore` or by using `DynamicCore`. -`HybridCore` provides support for the hybrid library API and `DynamicCore` provides support -for dynamic library API. Consult the Robot Framework `User Guide`_, for choosing the -correct API for library. - -Regardless which library API is chosen, both have similar requirements. - -1) Library must inherit either the `HybridCore` or `DynamicCore`. -2) Library keywords must be decorated with Robot Framework `@keyword`_ decorator. -3) Provide a list of class instances implementing keywords to `library_components` argument in the `HybridCore` or `DynamicCore` `__init__`. - -It is also possible implement keywords in the library main class, by marking method with -`@keyword` as keywords. It is not requires pass main library instance in the -`library_components` argument. - -All keyword, also keywords implemented in the classes outside of the main library are -available in the library instance as methods. This automatically publish library keywords -in as methods in the Python public API. - -The example in below demonstrates how the PythonLibCore can be used with a library. - -Example -------- - -.. sourcecode:: python - - """Main library.""" - - from robotlibcore import DynamicCore - - from mystuff import Library1, Library2 - - - class MyLibrary(DynamicCore): - """General library documentation.""" - - def __init__(self): - libraries = [Library1(), Library2()] - DynamicCore.__init__(self, libraries) - - @keyword - def keyword_in_main(self): - pass - -.. sourcecode:: python - - """Library components.""" - - from robotlibcore import keyword - - - class Library1(object): - - @keyword - def example(self): - """Keyword documentation.""" - pass - - @keyword - def another_example(self, arg1, arg2='default'): - pass - - def not_keyword(self): - pass - - - class Library2(object): - - @keyword('Custom name') - def this_name_is_not_used(self): - pass - - @keyword(tags=['tag', 'another']) - def tags(self): - pass - - -Plugin API ----------- -It is possible to create plugin API to a library by using PythonLibCore. This allows extending library -with external Python classes. Plugins can be imported during library import time, example by defining argumet -in library `__init__` which allows defining the plugins. It is possible to define multiple plugins, by seperating -plugins with with comma. Also it is possible to provide arguments to plugin by seperating arguments with -semicolon. - - -.. sourcecode:: python - - from robot.api.deco import keyword # noqa F401 - - from robotlibcore import DynamicCore, PluginParser - - from mystuff import Library1, Library2 - - - class PluginLib(DynamicCore): - - def __init__(self, plugins): - plugin_parser = PluginParser() - libraries = [Library1(), Library2()] - parsed_plugins = plugin_parser.parse_plugins(plugins) - libraries.extend(parsed_plugins) - DynamicCore.__init__(self, libraries) - - -When plugin class can look like this: - -.. sourcecode:: python - - class MyPlugi: - - @keyword - def plugin_keyword(self): - return 123 - -Then Library can be imported in Robot Framework side like this: - -.. sourcecode:: bash - - Library ${CURDIR}/PluginLib.py plugins=${CURDIR}/MyPlugin.py - - - -.. _Robot Framework: http://robotframework.org -.. _SeleniumLibrary: https://github.com/robotframework/SeleniumLibrary/ -.. _WhiteLibrary: https://pypi.org/project/robotframework-whitelibrary/ -.. _hybrid: https://robotframework.org/robotframework/latest/RobotFrameworkUserGuide.html#hybrid-library-api -.. _dynamic library API: https://robotframework.org/robotframework/latest/RobotFrameworkUserGuide.html#dynamic-library-api -.. _User Guide: https://robotframework.org/robotframework/latest/RobotFrameworkUserGuide.html#creating-test-libraries -.. _@keyword: https://github.com/robotframework/robotframework/blob/master/src/robot/api/deco.py diff --git a/atest/DynamicTypesAnnotationsLibrary.py b/atest/DynamicTypesAnnotationsLibrary.py index fa47ed5..551a591 100644 --- a/atest/DynamicTypesAnnotationsLibrary.py +++ b/atest/DynamicTypesAnnotationsLibrary.py @@ -56,7 +56,7 @@ def keyword_new_type(self, arg: UserId): return arg @keyword - def keyword_define_return_type(self, arg: str) -> None: + def keyword_define_return_type(self, arg: str) -> Union[List[str], str]: logger.info(arg) return None diff --git a/atest/SmallLibrary.py b/atest/SmallLibrary.py new file mode 100644 index 0000000..3a93661 --- /dev/null +++ b/atest/SmallLibrary.py @@ -0,0 +1,61 @@ +from pathlib import Path +from typing import Optional + +from robot.api import logger +from robotlibcore import DynamicCore, keyword + +class KeywordClass: + + @keyword(name="Execute SomeThing") + def execute_something(self): + """This is old""" + print("Name is here") + +class SmallLibrary(DynamicCore): + """Library documentation.""" + + def __init__(self, translation: Optional[Path] = None): + """__init__ documentation.""" + if not isinstance(translation, Path): + logger.warn("Convert to Path") + translation = Path(translation) + DynamicCore.__init__(self, [KeywordClass()], translation.absolute()) + + @keyword(tags=["tag1", "tag2"]) + def normal_keyword(self, arg: int, other: str) -> str: + """I have doc + + Multiple lines. + Other line. + """ + data = f"{arg} {other}" + print(data) + return data + + def not_keyword(self, data: str) -> str: + print(data) + return data + + @keyword(name="Name ChanGed", tags=["tag1", "tag2"]) + def name_changed(self, some: int, other: int) -> int: + """This one too""" + print(f"{some} {type(some)}, {other} {type(other)}") + return some + other + + @keyword + def not_translated(seld, a: int) -> int: + """This is not replaced.""" + print(f"{a} {type(a)}") + return a + 1 + + @keyword + def doc_not_translated(seld, a: int) -> int: + """This is not replaced also.""" + print(f"{a} {type(a)}") + return a + 1 + + @keyword + def kw_not_translated(seld, a: int) -> int: + """This is replaced too but name is not.""" + print(f"{a} {type(a)}") + return a + 1 diff --git a/atest/lib_future_annotation.py b/atest/lib_future_annotation.py new file mode 100644 index 0000000..1fd6576 --- /dev/null +++ b/atest/lib_future_annotation.py @@ -0,0 +1,22 @@ +from __future__ import annotations + +from typing_extensions import TypedDict + +from robotlibcore import DynamicCore, keyword + + +class Location(TypedDict): + longitude: float + latitude: float + + +class lib_future_annotation(DynamicCore): + + def __init__(self) -> None: + DynamicCore.__init__(self, []) + + @keyword + def future_annotations(self, arg: Location): + longitude = arg["longitude"] + latitude = arg["latitude"] + return f'{longitude} type({type(longitude)}), {latitude} {type(latitude)}' diff --git a/atest/tests.robot b/atest/tests.robot index 3c66808..a12b35f 100644 --- a/atest/tests.robot +++ b/atest/tests.robot @@ -14,7 +14,7 @@ Keyword Names Method Custom Name Cust Omna Me - IF $LIBRARY == "ExtendExistingLibrary" Keyword In Extending Library + IF "$LIBRARY" == "ExtendExistingLibrary" Keyword In Extending Library Method Without @keyword Are Not Keyowrds [Documentation] FAIL GLOB: No keyword with name 'Not Keyword' found.* diff --git a/atest/tests_types.robot b/atest/tests_types.robot index 23a20fb..2388942 100644 --- a/atest/tests_types.robot +++ b/atest/tests_types.robot @@ -1,6 +1,7 @@ *** Settings *** Library DynamicTypesLibrary.py Library DynamicTypesAnnotationsLibrary.py xxx +Library SmallLibrary.py ${CURDIR}/translation.json *** Variables *** @@ -115,6 +116,11 @@ Python 3.10 New Type Hints Keyword With Named Only Arguments Kw With Named Arguments arg=1 +SmallLibray With New Name + ${data} = SmallLibrary.Other Name 123 abc + Should Be Equal ${data} 123 abc + ${data} = SmallLibrary.name_changed_again 1 2 + Should Be Equal As Integers ${data} 3 *** Keywords *** Import DynamicTypesAnnotationsLibrary In Python 3.10 Only diff --git a/atest/translation.json b/atest/translation.json new file mode 100644 index 0000000..a3b2585 --- /dev/null +++ b/atest/translation.json @@ -0,0 +1,29 @@ +{ + "normal_keyword": { + "name": "other_name", + "doc": "This is new doc" + }, + "name_changed": { + "name": "name_changed_again", + "doc": "This is also replaced.\n\nnew line." + }, + "__init__": { + "name": "__init__", + "doc": "Replaces init docs with this one." + }, + "__intro__": { + "name": "__intro__", + "doc": "New __intro__ documentation is here." + }, + "doc_not_translated": { + "name": "this_is_replaced" + } + , + "kw_not_translated": { + "doc": "Here is new doc" + }, + "execute_something": { + "name": "tee_jotain", + "doc": "Uusi kirja." + } +} diff --git a/docs/PythonLibCore-4.3.0.rst b/docs/PythonLibCore-4.3.0.rst new file mode 100644 index 0000000..da43c7e --- /dev/null +++ b/docs/PythonLibCore-4.3.0.rst @@ -0,0 +1,66 @@ +========================= +Python Library Core 4.3.0 +========================= + + +.. default-role:: code + + +`Python Library Core`_ is a generic component making it easier to create +bigger `Robot Framework`_ test libraries. Python Library Core 4.3.0 is +a new release with support of Robot Framework 7.0 and return type hints. + +All issues targeted for Python Library Core v4.3.0 can be found +from the `issue tracker`_. + +:: + + pip install --upgrade pip install robotframework-pythonlibcore + +to install the latest available release or use + +:: + + pip install pip install robotframework-pythonlibcore==4.3.0 + +to install exactly this version. Alternatively you can download the source +distribution from PyPI_ and install it manually. + +Python Library Core 4.3.0 was released on Sunday November 19, 2023. + +.. _PythonLibCore: https://github.com/robotframework/PythonLibCore +.. _Robot Framework: http://robotframework.org +.. _pip: http://pip-installer.org +.. _PyPI: https://pypi.python.org/pypi/robotframework-robotlibcore +.. _issue tracker: https://github.com/robotframework/PythonLibCore/issues?q=milestone%3Av4.3.0 + + +.. contents:: + :depth: 2 + :local: + +Most important enhancements +=========================== + +Support RF 7.0 (`#135`_) +------------------------ +THis release supports FF 7 return type hints. + +Full list of fixes and enhancements +=================================== + +.. list-table:: + :header-rows: 1 + + * - ID + - Type + - Priority + - Summary + * - `#135`_ + - enhancement + - high + - Support RF 7.0 + +Altogether 1 issue. View on the `issue tracker `__. + +.. _#135: https://github.com/robotframework/PythonLibCore/issues/135 diff --git a/docs/PythonLibCore-4.4.0.rst b/docs/PythonLibCore-4.4.0.rst new file mode 100644 index 0000000..ff5a7b1 --- /dev/null +++ b/docs/PythonLibCore-4.4.0.rst @@ -0,0 +1,74 @@ +========================= +Python Library Core 4.4.0 +========================= + + +.. default-role:: code + + +`Python Library Core`_ is a generic component making it easier to create +bigger `Robot Framework`_ test libraries. Python Library Core 4.4.0 is +a new release with enhancement to support keyword translation. Python Library +Core can translate keyword names and keyword documentation. It is also +possible to translate library init and class documentation. + +All issues targeted for Python Library Core v4.4.0 can be found +from the `issue tracker`_. + +If you have pip_ installed, just run + +:: + + pip install --upgrade pip install robotframework-pythonlibcore + +to install the latest available release or use + +:: + + pip install pip install robotframework-pythonlibcore==4.4.0 + +to install exactly this version. Alternatively you can download the source +distribution from PyPI_ and install it manually. + +Python Library Core supports Robot Framework 5.0.1 or older and Python +3.8+. Python Library Core 4.4.0 was released on Friday March 22, 2024. + +.. _PythonLibCore: https://github.com/robotframework/PythonLibCore +.. _Robot Framework: http://robotframework.org +.. _pip: http://pip-installer.org +.. _PyPI: https://pypi.python.org/pypi/robotframework-robotlibcore +.. _issue tracker: https://github.com/robotframework/PythonLibCore/issues?q=milestone%3Av4.4.0 + + +.. contents:: + :depth: 2 + :local: + +Most important enhancements +=========================== + +Add translation for for keywords in PLC (`#139`_) +------------------------------------------------- +Robot Framework core has supported translations since release 6.0. Now also Python Lib Core +provides support to translate library keyword and documentation. Also it is possible to +translate library init and class level documentation. Keyword or library init argument names, argument +types and argument default values are not translated. + +Full list of fixes and enhancements +=================================== + +.. list-table:: + :header-rows: 1 + + * - ID + - Type + - Priority + - Summary + * - `#139`_ + - enhancement + - critical + - Add translation for for keywords in PLC + +Altogether 1 issue. View on the `issue tracker `__. + +.. _#139: https://github.com/robotframework/PythonLibCore/issues/139 diff --git a/docs/PythonLibCore-4.4.1.rst b/docs/PythonLibCore-4.4.1.rst new file mode 100644 index 0000000..2f34057 --- /dev/null +++ b/docs/PythonLibCore-4.4.1.rst @@ -0,0 +1,70 @@ +========================= +Python Library Core 4.4.1 +========================= + + +.. default-role:: code + + +`Python Library Core`_ is a generic component making it easier to create +bigger `Robot Framework`_ test libraries. Python Library Core 4.4.1 is +a new release with a bug fix to not leak keywords names if @keyword +decorator defines custom name. + +All issues targeted for Python Library Core v4.4.1 can be found +from the `issue tracker`_. + +If you have pip_ installed, just run + +:: + + pip install --pre --upgrade pip install robotframework-pythonlibcore + +to install the latest available release or use + +:: + + pip install pip install robotframework-pythonlibcore==4.4.1 + +to install exactly this version. Alternatively you can download the source +distribution from PyPI_ and install it manually. + +Python Library Core 4.4.1 was released on Saturday April 6, 2024. + +.. _PythonLibCore: https://github.com/robotframework/PythonLibCore +.. _Robot Framework: http://robotframework.org +.. _pip: http://pip-installer.org +.. _PyPI: https://pypi.python.org/pypi/robotframework-robotlibcore +.. _issue tracker: https://github.com/robotframework/PythonLibCore/issues?q=milestone%3Av4.4.1 + + +.. contents:: + :depth: 2 + :local: + +Most important enhancements +=========================== + +If @keyword deco has custom name, original name leaks to keywords (`#146`_) +--------------------------------------------------------------------------- +If @keyword deco has custom name, then original and not translated method name +leaks to keywords. This issue is now fixed. + +Full list of fixes and enhancements +=================================== + +.. list-table:: + :header-rows: 1 + + * - ID + - Type + - Priority + - Summary + * - `#146`_ + - bug + - critical + - If @keyword deco has custom name, original name leaks to keywords + +Altogether 1 issue. View on the `issue tracker `__. + +.. _#146: https://github.com/robotframework/PythonLibCore/issues/146 diff --git a/pyproject.toml b/pyproject.toml index 6fa99a2..16759fb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,9 +4,9 @@ line-length = 120 [tool.ruff] line-length = 120 -fixable = ["ALL"] +lint.fixable = ["ALL"] target-version = "py38" -select = [ +lint.select = [ "F", "E", "W", @@ -46,8 +46,16 @@ select = [ "RUF" ] -[tool.ruff.mccabe] +[tool.ruff.lint.extend-per-file-ignores] +"utest/*" = [ + "S", + "SLF", + "PLR", + "B018" +] + +[tool.ruff.lint.mccabe] max-complexity = 9 -[tool.ruff.flake8-quotes] +[tool.ruff.lint.flake8-quotes] docstring-quotes = "double" diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..c796d95 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,2 @@ +[pytest] +pythonpath = ./atest ./src ./utest diff --git a/requirements-build.txt b/requirements-build.txt deleted file mode 100644 index 9c49708..0000000 --- a/requirements-build.txt +++ /dev/null @@ -1,7 +0,0 @@ -# Requirements needed when generating releases. See BUILD.rst for details. -rellu >= 0.7 -twine -wheel - -# Include other dev dependencies from requirements-dev.txt. --r requirements-dev.txt diff --git a/requirements-dev.txt b/requirements-dev.txt index 7438901..fe02ea1 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,10 +1,16 @@ +uv pytest pytest-cov pytest-mockito robotstatuschecker black >= 23.7.0 -ruff >= 0.0.286 +ruff >= 0.5.5 robotframework-tidy invoke >= 2.2.0 twine wheel +rellu >= 0.7 +twine +wheel +typing-extensions >= 4.5.0 +approvaltests >= 11.1.1 diff --git a/setup.py b/setup.py index d0fb2d4..44f2e79 100644 --- a/setup.py +++ b/setup.py @@ -1,10 +1,11 @@ #!/usr/bin/env python import re -from os.path import abspath, dirname, join +from pathlib import Path +from os.path import join from setuptools import find_packages, setup -CURDIR = dirname(abspath(__file__)) +CURDIR = Path(__file__).parent CLASSIFIERS = """ Development Status :: 5 - Production/Stable @@ -21,10 +22,11 @@ Topic :: Software Development :: Testing Framework :: Robot Framework """.strip().splitlines() -with open(join(CURDIR, 'src', 'robotlibcore.py')) as f: - VERSION = re.search('\n__version__ = "(.*)"', f.read()).group(1) -with open(join(CURDIR, 'README.rst')) as f: - LONG_DESCRIPTION = f.read() + +version_file = Path(CURDIR / 'src' / 'robotlibcore' / '__init__.py') +VERSION = re.search('\n__version__ = "(.*)"', version_file.read_text()).group(1) + +LONG_DESCRIPTION = Path(CURDIR / 'README.md').read_text() DESCRIPTION = ('Tools to ease creating larger test libraries for ' 'Robot Framework using Python.') @@ -37,11 +39,11 @@ license = 'Apache License 2.0', description = DESCRIPTION, long_description = LONG_DESCRIPTION, + long_description_content_type = "text/markdown", keywords = 'robotframework testing testautomation library development', platforms = 'any', classifiers = CLASSIFIERS, python_requires = '>=3.8, <4', package_dir = {'': 'src'}, - packages = find_packages('src'), - py_modules = ['robotlibcore'], + packages = ["robotlibcore","robotlibcore.core", "robotlibcore.keywords", "robotlibcore.plugin", "robotlibcore.utils"] ) diff --git a/src/robotlibcore.py b/src/robotlibcore.py deleted file mode 100644 index c87c5c8..0000000 --- a/src/robotlibcore.py +++ /dev/null @@ -1,372 +0,0 @@ -# Copyright 2017- 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. - -"""Generic test library core for Robot Framework. - -Main usage is easing creating larger test libraries. For more information and -examples see the project pages at -https://github.com/robotframework/PythonLibCore -""" -import inspect -import os -from dataclasses import dataclass -from typing import Any, Callable, List, Optional, Union, get_type_hints - -from robot.api.deco import keyword # noqa: F401 -from robot.errors import DataError -from robot.utils import Importer - -__version__ = "4.2.0" - - -class PythonLibCoreException(Exception): # noqa: N818 - pass - - -class PluginError(PythonLibCoreException): - pass - - -class NoKeywordFound(PythonLibCoreException): - pass - - -class HybridCore: - def __init__(self, library_components: List) -> None: - self.keywords = {} - self.keywords_spec = {} - self.attributes = {} - self.add_library_components(library_components) - self.add_library_components([self]) - self.__set_library_listeners(library_components) - - def add_library_components(self, library_components: List): - self.keywords_spec["__init__"] = KeywordBuilder.build(self.__init__) # type: ignore - for component in library_components: - for name, func in self.__get_members(component): - if callable(func) and hasattr(func, "robot_name"): - kw = getattr(component, name) - kw_name = func.robot_name or name - self.keywords[kw_name] = kw - self.keywords_spec[kw_name] = KeywordBuilder.build(kw) - # Expose keywords as attributes both using original - # method names as well as possible custom names. - self.attributes[name] = self.attributes[kw_name] = kw - - def __set_library_listeners(self, library_components: list): - listeners = self.__get_manually_registered_listeners() - listeners.extend(self.__get_component_listeners([self, *library_components])) - if listeners: - self.ROBOT_LIBRARY_LISTENER = list(dict.fromkeys(listeners)) - - def __get_manually_registered_listeners(self) -> list: - manually_registered_listener = getattr(self, "ROBOT_LIBRARY_LISTENER", []) - try: - return [*manually_registered_listener] - except TypeError: - return [manually_registered_listener] - - def __get_component_listeners(self, library_listeners: list) -> list: - return [component for component in library_listeners if hasattr(component, "ROBOT_LISTENER_API_VERSION")] - - def __get_members(self, component): - if inspect.ismodule(component): - return inspect.getmembers(component) - if inspect.isclass(component): - msg = f"Libraries must be modules or instances, got class '{component.__name__}' instead." - raise TypeError( - msg, - ) - if type(component) != component.__class__: - msg = ( - "Libraries must be modules or new-style class instances, " - f"got old-style class {component.__class__.__name__} instead." - ) - raise TypeError( - msg, - ) - return self.__get_members_from_instance(component) - - def __get_members_from_instance(self, instance): - # Avoid calling properties by getting members from class, not instance. - cls = type(instance) - for name in dir(instance): - owner = cls if hasattr(cls, name) else instance - yield name, getattr(owner, name) - - def __getattr__(self, name): - if name in self.attributes: - return self.attributes[name] - msg = "{!r} object has no attribute {!r}".format(type(self).__name__, name) - raise AttributeError( - msg, - ) - - def __dir__(self): - my_attrs = super().__dir__() - return sorted(set(my_attrs) | set(self.attributes)) - - def get_keyword_names(self): - return sorted(self.keywords) - - -@dataclass -class Module: - module: str - args: list - kw_args: dict - - -class DynamicCore(HybridCore): - def run_keyword(self, name, args, kwargs=None): - return self.keywords[name](*args, **(kwargs or {})) - - def get_keyword_arguments(self, name): - spec = self.keywords_spec.get(name) - if not spec: - msg = f"Could not find keyword: {name}" - raise NoKeywordFound(msg) - return spec.argument_specification - - def get_keyword_tags(self, name): - return self.keywords[name].robot_tags - - def get_keyword_documentation(self, name): - if name == "__intro__": - return inspect.getdoc(self) or "" - spec = self.keywords_spec.get(name) - if not spec: - msg = f"Could not find keyword: {name}" - raise NoKeywordFound(msg) - return spec.documentation - - def get_keyword_types(self, name): - spec = self.keywords_spec.get(name) - if spec is None: - raise ValueError('Keyword "%s" not found.' % name) - return spec.argument_types - - def __get_keyword(self, keyword_name): - if keyword_name == "__init__": - return self.__init__ # type: ignore - if keyword_name.startswith("__") and keyword_name.endswith("__"): - return None - method = self.keywords.get(keyword_name) - if not method: - raise ValueError('Keyword "%s" not found.' % keyword_name) - return method - - def get_keyword_source(self, keyword_name): - method = self.__get_keyword(keyword_name) - path = self.__get_keyword_path(method) - line_number = self.__get_keyword_line(method) - if path and line_number: - return "{}:{}".format(path, line_number) - if path: - return path - if line_number: - return ":%s" % line_number - return None - - def __get_keyword_line(self, method): - try: - lines, line_number = inspect.getsourcelines(method) - except (OSError, TypeError): - return None - for increment, line in enumerate(lines): - if line.strip().startswith("def "): - return line_number + increment - return line_number - - def __get_keyword_path(self, method): - try: - return os.path.normpath(inspect.getfile(inspect.unwrap(method))) - except TypeError: - return None - - -class KeywordBuilder: - @classmethod - def build(cls, function): - return KeywordSpecification( - argument_specification=cls._get_arguments(function), - documentation=inspect.getdoc(function) or "", - argument_types=cls._get_types(function), - ) - - @classmethod - def unwrap(cls, function): - return inspect.unwrap(function) - - @classmethod - def _get_arguments(cls, function): - unwrap_function = cls.unwrap(function) - arg_spec = cls._get_arg_spec(unwrap_function) - argument_specification = cls._get_args(arg_spec, function) - argument_specification.extend(cls._get_varargs(arg_spec)) - argument_specification.extend(cls._get_named_only_args(arg_spec)) - argument_specification.extend(cls._get_kwargs(arg_spec)) - return argument_specification - - @classmethod - def _get_arg_spec(cls, function: Callable) -> inspect.FullArgSpec: - return inspect.getfullargspec(function) - - @classmethod - def _get_args(cls, arg_spec: inspect.FullArgSpec, function: Callable) -> list: - args = cls._drop_self_from_args(function, arg_spec) - args.reverse() - defaults = list(arg_spec.defaults) if arg_spec.defaults else [] - formated_args = [] - for arg in args: - if defaults: - formated_args.append((arg, defaults.pop())) - else: - formated_args.append(arg) - formated_args.reverse() - return formated_args - - @classmethod - def _drop_self_from_args( - cls, - function: Callable, - arg_spec: inspect.FullArgSpec, - ) -> list: - return arg_spec.args[1:] if inspect.ismethod(function) else arg_spec.args - - @classmethod - def _get_varargs(cls, arg_spec: inspect.FullArgSpec) -> list: - return [f"*{arg_spec.varargs}"] if arg_spec.varargs else [] - - @classmethod - def _get_kwargs(cls, arg_spec: inspect.FullArgSpec) -> list: - return [f"**{arg_spec.varkw}"] if arg_spec.varkw else [] - - @classmethod - def _get_named_only_args(cls, arg_spec: inspect.FullArgSpec) -> list: - rf_spec: list = [] - kw_only_args = arg_spec.kwonlyargs if arg_spec.kwonlyargs else [] - if not arg_spec.varargs and kw_only_args: - rf_spec.append("*") - kw_only_defaults = arg_spec.kwonlydefaults if arg_spec.kwonlydefaults else {} - for kw_only_arg in kw_only_args: - if kw_only_arg in kw_only_defaults: - rf_spec.append((kw_only_arg, kw_only_defaults[kw_only_arg])) - else: - rf_spec.append(kw_only_arg) - return rf_spec - - @classmethod - def _get_types(cls, function): - if function is None: - return function - types = getattr(function, "robot_types", ()) - if types is None or types: - return types - return cls._get_typing_hints(function) - - @classmethod - def _get_typing_hints(cls, function): - function = cls.unwrap(function) - try: - hints = get_type_hints(function) - except Exception: # noqa: BLE001 - hints = function.__annotations__ - arg_spec = cls._get_arg_spec(function) - all_args = cls._args_as_list(function, arg_spec) - for arg_with_hint in list(hints): - # remove return and self statements - if arg_with_hint not in all_args: - hints.pop(arg_with_hint) - return hints - - @classmethod - def _args_as_list(cls, function, arg_spec) -> list: - function_args = cls._drop_self_from_args(function, arg_spec) - if arg_spec.varargs: - function_args.append(arg_spec.varargs) - function_args.extend(arg_spec.kwonlyargs or []) - if arg_spec.varkw: - function_args.append(arg_spec.varkw) - return function_args - - @classmethod - def _get_defaults(cls, arg_spec): - if not arg_spec.defaults: - return {} - names = arg_spec.args[-len(arg_spec.defaults) :] - return zip(names, arg_spec.defaults) - - -class KeywordSpecification: - def __init__( - self, - argument_specification=None, - documentation=None, - argument_types=None, - ) -> None: - self.argument_specification = argument_specification - self.documentation = documentation - self.argument_types = argument_types - - -class PluginParser: - def __init__(self, base_class: Optional[Any] = None, python_object=None) -> None: - self._base_class = base_class - self._python_object = python_object if python_object else [] - - def parse_plugins(self, plugins: Union[str, List[str]]) -> List: - imported_plugins = [] - importer = Importer("test library") - for parsed_plugin in self._string_to_modules(plugins): - plugin = importer.import_class_or_module(parsed_plugin.module) - if not inspect.isclass(plugin): - message = f"Importing test library: '{parsed_plugin.module}' failed." - raise DataError(message) - args = self._python_object + parsed_plugin.args - plugin = plugin(*args, **parsed_plugin.kw_args) - if self._base_class and not isinstance(plugin, self._base_class): - message = f"Plugin does not inherit {self._base_class}" - raise PluginError(message) - imported_plugins.append(plugin) - return imported_plugins - - def get_plugin_keywords(self, plugins: List): - return DynamicCore(plugins).get_keyword_names() - - def _string_to_modules(self, modules: Union[str, List[str]]): - parsed_modules: list = [] - if not modules: - return parsed_modules - for module in self._modules_splitter(modules): - module_and_args = module.strip().split(";") - module_name = module_and_args.pop(0) - kw_args = {} - args = [] - for argument in module_and_args: - if "=" in argument: - key, value = argument.split("=") - kw_args[key] = value - else: - args.append(argument) - parsed_modules.append(Module(module=module_name, args=args, kw_args=kw_args)) - return parsed_modules - - def _modules_splitter(self, modules: Union[str, List[str]]): - if isinstance(modules, str): - for module in modules.split(","): - yield module - else: - for module in modules: - yield module diff --git a/src/robotlibcore/__init__.py b/src/robotlibcore/__init__.py new file mode 100644 index 0000000..3286c2d --- /dev/null +++ b/src/robotlibcore/__init__.py @@ -0,0 +1,42 @@ +# Copyright 2017- 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. + +"""Generic test library core for Robot Framework. + +Main usage is easing creating larger test libraries. For more information and +examples see the project pages at +https://github.com/robotframework/PythonLibCore +""" + +from robot.api.deco import keyword + +from robotlibcore.core import DynamicCore, HybridCore +from robotlibcore.keywords import KeywordBuilder, KeywordSpecification +from robotlibcore.plugin import PluginParser +from robotlibcore.utils import Module, NoKeywordFound, PluginError, PythonLibCoreException + +__version__ = "4.4.1" + +__all__ = [ + "DynamicCore", + "HybridCore", + "KeywordBuilder", + "KeywordSpecification", + "PluginParser", + "keyword", + "NoKeywordFound", + "PluginError", + "PythonLibCoreException", + "Module", +] diff --git a/src/robotlibcore/core/__init__.py b/src/robotlibcore/core/__init__.py new file mode 100644 index 0000000..7072136 --- /dev/null +++ b/src/robotlibcore/core/__init__.py @@ -0,0 +1,19 @@ +# Copyright 2017- 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 .dynamic import DynamicCore +from .hybrid import HybridCore + +__all__ = ["DynamicCore", "HybridCore"] diff --git a/src/robotlibcore/core/dynamic.py b/src/robotlibcore/core/dynamic.py new file mode 100644 index 0000000..9e02005 --- /dev/null +++ b/src/robotlibcore/core/dynamic.py @@ -0,0 +1,88 @@ +# Copyright 2017- 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 +import os + +from robotlibcore.utils import NoKeywordFound + +from .hybrid import HybridCore + + +class DynamicCore(HybridCore): + def run_keyword(self, name, args, kwargs=None): + return self.keywords[name](*args, **(kwargs or {})) + + def get_keyword_arguments(self, name): + spec = self.keywords_spec.get(name) + if not spec: + msg = f"Could not find keyword: {name}" + raise NoKeywordFound(msg) + return spec.argument_specification + + def get_keyword_tags(self, name): + return self.keywords[name].robot_tags + + def get_keyword_documentation(self, name): + if name == "__intro__": + return inspect.getdoc(self) or "" + spec = self.keywords_spec.get(name) + if not spec: + msg = f"Could not find keyword: {name}" + raise NoKeywordFound(msg) + return spec.documentation + + def get_keyword_types(self, name): + spec = self.keywords_spec.get(name) + if spec is None: + raise ValueError('Keyword "%s" not found.' % name) + return spec.argument_types + + def __get_keyword(self, keyword_name): + if keyword_name == "__init__": + return self.__init__ # type: ignore + if keyword_name.startswith("__") and keyword_name.endswith("__"): + return None + method = self.keywords.get(keyword_name) + if not method: + raise ValueError('Keyword "%s" not found.' % keyword_name) + return method + + def get_keyword_source(self, keyword_name): + method = self.__get_keyword(keyword_name) + path = self.__get_keyword_path(method) + line_number = self.__get_keyword_line(method) + if path and line_number: + return "{}:{}".format(path, line_number) + if path: + return path + if line_number: + return ":%s" % line_number + return None + + def __get_keyword_line(self, method): + try: + lines, line_number = inspect.getsourcelines(method) + except (OSError, TypeError): + return None + for increment, line in enumerate(lines): + if line.strip().startswith("def "): + return line_number + increment + return line_number + + def __get_keyword_path(self, method): + try: + return os.path.normpath(inspect.getfile(inspect.unwrap(method))) + except TypeError: + return None diff --git a/src/robotlibcore/core/hybrid.py b/src/robotlibcore/core/hybrid.py new file mode 100644 index 0000000..2caa8b2 --- /dev/null +++ b/src/robotlibcore/core/hybrid.py @@ -0,0 +1,121 @@ +# Copyright 2017- 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 pathlib import Path +from typing import Callable, List, Optional + +from robotlibcore.keywords import KeywordBuilder +from robotlibcore.utils import _translated_keywords, _translation + + +class HybridCore: + def __init__(self, library_components: List, translation: Optional[Path] = None) -> None: + self.keywords = {} + self.keywords_spec = {} + self.attributes = {} + translation_data = _translation(translation) + translated_kw_names = _translated_keywords(translation_data) + self.add_library_components(library_components, translation_data, translated_kw_names) + self.add_library_components([self], translation_data, translated_kw_names) + self.__set_library_listeners(library_components) + + def add_library_components( + self, + library_components: List, + translation: Optional[dict] = None, + translated_kw_names: Optional[list] = None, + ): + translation = translation if translation else {} + translated_kw_names = translated_kw_names if translated_kw_names else [] + self.keywords_spec["__init__"] = KeywordBuilder.build(self.__init__, translation) # type: ignore + self.__replace_intro_doc(translation) + for component in library_components: + for name, func in self.__get_members(component): + if callable(func) and hasattr(func, "robot_name"): + kw = getattr(component, name) + kw_name = self.__get_keyword_name(func, name, translation, translated_kw_names) + self.keywords[kw_name] = kw + self.keywords_spec[kw_name] = KeywordBuilder.build(kw, translation) + # Expose keywords as attributes both using original + # method names as well as possible custom names. + self.attributes[name] = self.attributes[kw_name] = kw + + def __get_keyword_name(self, func: Callable, name: str, translation: dict, translated_kw_names: list): + if name in translated_kw_names: + return name + if name in translation and translation[name].get("name"): + return translation[name].get("name") + return func.robot_name or name + + def __replace_intro_doc(self, translation: dict): + if "__intro__" in translation: + self.__doc__ = translation["__intro__"].get("doc", "") + + def __set_library_listeners(self, library_components: list): + listeners = self.__get_manually_registered_listeners() + listeners.extend(self.__get_component_listeners([self, *library_components])) + if listeners: + self.ROBOT_LIBRARY_LISTENER = list(dict.fromkeys(listeners)) + + def __get_manually_registered_listeners(self) -> list: + manually_registered_listener = getattr(self, "ROBOT_LIBRARY_LISTENER", []) + try: + return [*manually_registered_listener] + except TypeError: + return [manually_registered_listener] + + def __get_component_listeners(self, library_listeners: list) -> list: + return [component for component in library_listeners if hasattr(component, "ROBOT_LISTENER_API_VERSION")] + + def __get_members(self, component): + if inspect.ismodule(component): + return inspect.getmembers(component) + if inspect.isclass(component): + msg = f"Libraries must be modules or instances, got class '{component.__name__}' instead." + raise TypeError( + msg, + ) + if type(component) != component.__class__: # noqa: E721 + msg = ( + "Libraries must be modules or new-style class instances, " + f"got old-style class {component.__class__.__name__} instead." + ) + raise TypeError( + msg, + ) + return self.__get_members_from_instance(component) + + def __get_members_from_instance(self, instance): + # Avoid calling properties by getting members from class, not instance. + cls = type(instance) + for name in dir(instance): + owner = cls if hasattr(cls, name) else instance + yield name, getattr(owner, name) + + def __getattr__(self, name): + if name in self.attributes: + return self.attributes[name] + msg = "{!r} object has no attribute {!r}".format(type(self).__name__, name) + raise AttributeError( + msg, + ) + + def __dir__(self): + my_attrs = super().__dir__() + return sorted(set(my_attrs) | set(self.attributes)) + + def get_keyword_names(self): + return sorted(self.keywords) diff --git a/src/robotlibcore/keywords/__init__.py b/src/robotlibcore/keywords/__init__.py new file mode 100644 index 0000000..6febe2c --- /dev/null +++ b/src/robotlibcore/keywords/__init__.py @@ -0,0 +1,19 @@ +# Copyright 2017- 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 .builder import KeywordBuilder +from .specification import KeywordSpecification + +__all__ = ["KeywordBuilder", "KeywordSpecification"] diff --git a/src/robotlibcore/keywords/builder.py b/src/robotlibcore/keywords/builder.py new file mode 100644 index 0000000..d81c677 --- /dev/null +++ b/src/robotlibcore/keywords/builder.py @@ -0,0 +1,149 @@ +# Copyright 2017- 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 typing import Callable, Optional, get_type_hints + +from .specification import KeywordSpecification + + +class KeywordBuilder: + @classmethod + def build(cls, function, translation: Optional[dict] = None): + translation = translation if translation else {} + return KeywordSpecification( + argument_specification=cls._get_arguments(function), + documentation=cls.get_doc(function, translation), + argument_types=cls._get_types(function), + ) + + @classmethod + def get_doc(cls, function, translation: dict): + if kw := cls._get_kw_transtation(function, translation): # noqa: SIM102 + if "doc" in kw: + return kw["doc"] + return inspect.getdoc(function) or "" + + @classmethod + def _get_kw_transtation(cls, function, translation: dict): + return translation.get(function.__name__, {}) + + @classmethod + def unwrap(cls, function): + return inspect.unwrap(function) + + @classmethod + def _get_arguments(cls, function): + unwrap_function = cls.unwrap(function) + arg_spec = cls._get_arg_spec(unwrap_function) + argument_specification = cls._get_args(arg_spec, function) + argument_specification.extend(cls._get_varargs(arg_spec)) + argument_specification.extend(cls._get_named_only_args(arg_spec)) + argument_specification.extend(cls._get_kwargs(arg_spec)) + return argument_specification + + @classmethod + def _get_arg_spec(cls, function: Callable) -> inspect.FullArgSpec: + return inspect.getfullargspec(function) + + @classmethod + def _get_type_hint(cls, function: Callable): + try: + hints = get_type_hints(function) + except Exception: # noqa: BLE001 + hints = function.__annotations__ + return hints + + @classmethod + def _get_args(cls, arg_spec: inspect.FullArgSpec, function: Callable) -> list: + args = cls._drop_self_from_args(function, arg_spec) + args.reverse() + defaults = list(arg_spec.defaults) if arg_spec.defaults else [] + formated_args = [] + for arg in args: + if defaults: + formated_args.append((arg, defaults.pop())) + else: + formated_args.append(arg) + formated_args.reverse() + return formated_args + + @classmethod + def _drop_self_from_args( + cls, + function: Callable, + arg_spec: inspect.FullArgSpec, + ) -> list: + return arg_spec.args[1:] if inspect.ismethod(function) else arg_spec.args + + @classmethod + def _get_varargs(cls, arg_spec: inspect.FullArgSpec) -> list: + return [f"*{arg_spec.varargs}"] if arg_spec.varargs else [] + + @classmethod + def _get_kwargs(cls, arg_spec: inspect.FullArgSpec) -> list: + return [f"**{arg_spec.varkw}"] if arg_spec.varkw else [] + + @classmethod + def _get_named_only_args(cls, arg_spec: inspect.FullArgSpec) -> list: + rf_spec: list = [] + kw_only_args = arg_spec.kwonlyargs if arg_spec.kwonlyargs else [] + if not arg_spec.varargs and kw_only_args: + rf_spec.append("*") + kw_only_defaults = arg_spec.kwonlydefaults if arg_spec.kwonlydefaults else {} + for kw_only_arg in kw_only_args: + if kw_only_arg in kw_only_defaults: + rf_spec.append((kw_only_arg, kw_only_defaults[kw_only_arg])) + else: + rf_spec.append(kw_only_arg) + return rf_spec + + @classmethod + def _get_types(cls, function): + if function is None: + return function + types = getattr(function, "robot_types", ()) + if types is None or types: + return types + return cls._get_typing_hints(function) + + @classmethod + def _get_typing_hints(cls, function): + function = cls.unwrap(function) + hints = cls._get_type_hint(function) + arg_spec = cls._get_arg_spec(function) + all_args = cls._args_as_list(function, arg_spec) + for arg_with_hint in list(hints): + # remove self statements + if arg_with_hint not in [*all_args, "return"]: + hints.pop(arg_with_hint) + return hints + + @classmethod + def _args_as_list(cls, function, arg_spec) -> list: + function_args = cls._drop_self_from_args(function, arg_spec) + if arg_spec.varargs: + function_args.append(arg_spec.varargs) + function_args.extend(arg_spec.kwonlyargs or []) + if arg_spec.varkw: + function_args.append(arg_spec.varkw) + return function_args + + @classmethod + def _get_defaults(cls, arg_spec): + if not arg_spec.defaults: + return {} + names = arg_spec.args[-len(arg_spec.defaults) :] + return zip(names, arg_spec.defaults) diff --git a/src/robotlibcore/keywords/specification.py b/src/robotlibcore/keywords/specification.py new file mode 100644 index 0000000..5a85365 --- /dev/null +++ b/src/robotlibcore/keywords/specification.py @@ -0,0 +1,25 @@ +# Copyright 2017- 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 KeywordSpecification: + def __init__( + self, + argument_specification=None, + documentation=None, + argument_types=None, + ) -> None: + self.argument_specification = argument_specification + self.documentation = documentation + self.argument_types = argument_types diff --git a/src/robotlibcore/plugin/__init__.py b/src/robotlibcore/plugin/__init__.py new file mode 100644 index 0000000..7e92ab7 --- /dev/null +++ b/src/robotlibcore/plugin/__init__.py @@ -0,0 +1,17 @@ +# Copyright 2017- 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 PluginParser + +__all__ = ["PluginParser"] diff --git a/src/robotlibcore/plugin/parser.py b/src/robotlibcore/plugin/parser.py new file mode 100644 index 0000000..6233d0f --- /dev/null +++ b/src/robotlibcore/plugin/parser.py @@ -0,0 +1,73 @@ +# Copyright 2017- 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 typing import Any, List, Optional, Union + +from robot.errors import DataError +from robot.utils import Importer + +from robotlibcore.core import DynamicCore +from robotlibcore.utils import Module, PluginError + + +class PluginParser: + def __init__(self, base_class: Optional[Any] = None, python_object=None) -> None: + self._base_class = base_class + self._python_object = python_object if python_object else [] + + def parse_plugins(self, plugins: Union[str, List[str]]) -> List: + imported_plugins = [] + importer = Importer("test library") + for parsed_plugin in self._string_to_modules(plugins): + plugin = importer.import_class_or_module(parsed_plugin.module) + if not inspect.isclass(plugin): + message = f"Importing test library: '{parsed_plugin.module}' failed." + raise DataError(message) + args = self._python_object + parsed_plugin.args + plugin = plugin(*args, **parsed_plugin.kw_args) + if self._base_class and not isinstance(plugin, self._base_class): + message = f"Plugin does not inherit {self._base_class}" + raise PluginError(message) + imported_plugins.append(plugin) + return imported_plugins + + def get_plugin_keywords(self, plugins: List): + return DynamicCore(plugins).get_keyword_names() + + def _string_to_modules(self, modules: Union[str, List[str]]): + parsed_modules: list = [] + if not modules: + return parsed_modules + for module in self._modules_splitter(modules): + module_and_args = module.strip().split(";") + module_name = module_and_args.pop(0) + kw_args = {} + args = [] + for argument in module_and_args: + if "=" in argument: + key, value = argument.split("=") + kw_args[key] = value + else: + args.append(argument) + parsed_modules.append(Module(module=module_name, args=args, kw_args=kw_args)) + return parsed_modules + + def _modules_splitter(self, modules: Union[str, List[str]]): + if isinstance(modules, str): + for module in modules.split(","): + yield module + else: + for module in modules: + yield module diff --git a/src/robotlibcore/utils/__init__.py b/src/robotlibcore/utils/__init__.py new file mode 100644 index 0000000..609b6b4 --- /dev/null +++ b/src/robotlibcore/utils/__init__.py @@ -0,0 +1,28 @@ +# Copyright 2017- 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 dataclasses import dataclass + +from .exceptions import NoKeywordFound, PluginError, PythonLibCoreException +from .translations import _translated_keywords, _translation + + +@dataclass +class Module: + module: str + args: list + kw_args: dict + + +__all__ = ["Module", "NoKeywordFound", "PluginError", "PythonLibCoreException", "_translation", "_translated_keywords"] diff --git a/src/robotlibcore/utils/exceptions.py b/src/robotlibcore/utils/exceptions.py new file mode 100644 index 0000000..c832387 --- /dev/null +++ b/src/robotlibcore/utils/exceptions.py @@ -0,0 +1,25 @@ +# Copyright 2017- 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 PythonLibCoreException(Exception): # noqa: N818 + pass + + +class PluginError(PythonLibCoreException): + pass + + +class NoKeywordFound(PythonLibCoreException): + pass diff --git a/src/robotlibcore/utils/translations.py b/src/robotlibcore/utils/translations.py new file mode 100644 index 0000000..35c32f6 --- /dev/null +++ b/src/robotlibcore/utils/translations.py @@ -0,0 +1,36 @@ +# Copyright 2017- 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 Optional + +from robot.api import logger + + +def _translation(translation: Optional[Path] = None): + if translation and isinstance(translation, Path) and translation.is_file(): + with translation.open("r") as file: + try: + return json.load(file) + except json.decoder.JSONDecodeError: + logger.warn(f"Could not convert json file {translation} to dictionary.") + return {} + else: + return {} + + +def _translated_keywords(translation_data: dict) -> list: + return [item.get("name") for item in translation_data.values() if item.get("name")] diff --git a/tasks.py b/tasks.py index b2b07d3..90ebdf3 100644 --- a/tasks.py +++ b/tasks.py @@ -10,7 +10,8 @@ REPOSITORY = "robotframework/PythonLibCore" -VERSION_PATH = Path("src/robotlibcore.py") +VERSION_PATH = Path("src/robotlibcore/__init__.py") +VERSION_PATTERN = '__version__ = "(.*)"' RELEASE_NOTES_PATH = Path("docs/PythonLibCore-{version}.rst") RELEASE_NOTES_TITLE = "Python Library Core {version}" RELEASE_NOTES_INTRO = """ @@ -67,7 +68,7 @@ def set_version(ctx, version): # noqa: ARG001 to the next suitable development version. For example, 3.0 -> 3.0.1.dev1, 3.1.1 -> 3.1.2.dev1, 3.2a1 -> 3.2a2.dev1, 3.2.dev1 -> 3.2.dev2. """ - version = Version(version, VERSION_PATH) + version = Version(version, VERSION_PATH, VERSION_PATTERN) version.write() print(version) @@ -132,16 +133,15 @@ def lint(ctx): ruff_cmd.append("--fix") ruff_cmd.append("./src") ruff_cmd.append("./tasks.py") + ruff_cmd.append("./utest") ctx.run(" ".join(ruff_cmd)) print("Run black") - ctx.run("black src/ tasks.py utest/run.py atest/run.py") + ctx.run("black src/ tasks.py utest atest/run.py") print("Run tidy") print(f"Lint Robot files {'in ci' if in_ci else ''}") command = [ "robotidy", "--transform", - "RenameKeywords", - "--transform", "RenameTestCases", "-c", "RenameTestCases:capitalize_each_word=True", @@ -166,5 +166,5 @@ def utest(ctx): @task(utest, atest) -def test(ctx): # noqa: ARG001 +def test(ctx): pass diff --git a/utest/run.py b/utest/run.py index 1da6cd3..7400196 100755 --- a/utest/run.py +++ b/utest/run.py @@ -2,23 +2,19 @@ import argparse import platform import sys -from os.path import abspath, dirname, join +from pathlib import Path import pytest from robot.version import VERSION as RF_VERSION -curdir = dirname(abspath(__file__)) -atest_dir = join(curdir, "..", "atest") +curdir = Path(__file__).parent +atest_dir = curdir / ".." / "atest" python_version = platform.python_version() -xunit_report = join( - atest_dir, - "results", - "xunit-python-{}-robot{}.xml".format(python_version, RF_VERSION), -) -src = join(curdir, "..", "src") +xunit_report = atest_dir / "results" / f"xunit-python-{python_version}-robot{RF_VERSION}.xml" +src = curdir / ".." / "src" sys.path.insert(0, src) sys.path.insert(0, atest_dir) -helpers = join(curdir, "helpers") +helpers = curdir / "helpers" sys.path.append(helpers) parser = argparse.ArgumentParser() diff --git a/utest/test_get_keyword_source.py b/utest/test_get_keyword_source.py index 11828e4..f9bcc76 100644 --- a/utest/test_get_keyword_source.py +++ b/utest/test_get_keyword_source.py @@ -1,5 +1,5 @@ import inspect -from os import path +from pathlib import Path import pytest from DynamicLibrary import DynamicLibrary @@ -18,23 +18,26 @@ def lib_types(): @pytest.fixture(scope="module") -def cur_dir(): - return path.dirname(__file__) +def cur_dir() -> Path: + return Path(__file__).parent @pytest.fixture(scope="module") -def lib_path(cur_dir): - return path.normpath(path.join(cur_dir, "..", "atest", "DynamicLibrary.py")) +def lib_path(cur_dir) -> Path: + path = cur_dir / ".." / "atest" / "DynamicLibrary.py" + return path.resolve() @pytest.fixture(scope="module") -def lib_path_components(cur_dir): - return path.normpath(path.join(cur_dir, "..", "atest", "librarycomponents.py")) +def lib_path_components(cur_dir) -> Path: + path = cur_dir / ".." / "atest" / "librarycomponents.py" + return path.resolve() @pytest.fixture(scope="module") -def lib_path_types(cur_dir): - return path.normpath(path.join(cur_dir, "..", "atest", "DynamicTypesLibrary.py")) +def lib_path_types(cur_dir) -> Path: + path = cur_dir / ".." / "atest" / "DynamicTypesLibrary.py" + return path.resolve() def test_location_in_main(lib, lib_path): @@ -60,7 +63,7 @@ def test_location_in_class_custom_keyword_name(lib, lib_path_components): def test_no_line_number(lib, lib_path, when): when(lib)._DynamicCore__get_keyword_line(Any()).thenReturn(None) source = lib.get_keyword_source("keyword_in_main") - assert source == lib_path + assert Path(source) == lib_path def test_no_path(lib, when): @@ -90,4 +93,4 @@ def test_error_in_getfile(lib, when): def test_error_in_line_number(lib, when, lib_path): when(inspect).getsourcelines(Any()).thenRaise(IOError("Some message")) source = lib.get_keyword_source("keyword_in_main") - assert source == lib_path + assert Path(source) == lib_path diff --git a/utest/test_get_keyword_types.py b/utest/test_get_keyword_types.py index 7a0dba2..e72803b 100644 --- a/utest/test_get_keyword_types.py +++ b/utest/test_get_keyword_types.py @@ -4,6 +4,7 @@ import pytest from DynamicTypesAnnotationsLibrary import CustomObject, DynamicTypesAnnotationsLibrary from DynamicTypesLibrary import DynamicTypesLibrary +from lib_future_annotation import Location, lib_future_annotation @pytest.fixture(scope="module") @@ -16,6 +17,11 @@ def lib_types(): return DynamicTypesAnnotationsLibrary("aaa") +@pytest.fixture(scope="module") +def lib_annotation(): + return lib_future_annotation() + + def test_using_keyword_types(lib): types = lib.get_keyword_types("keyword_with_types") assert types == {"arg1": str} @@ -74,7 +80,7 @@ def test_keyword_new_type(lib_types): def test_keyword_return_type(lib_types): types = lib_types.get_keyword_types("keyword_define_return_type") - assert types == {"arg": str} + assert types == {"arg": str, "return": Union[List[str], str]} def test_keyword_forward_references(lib_types): @@ -99,7 +105,7 @@ def test_keyword_with_annotation_external_class(lib_types): def test_keyword_with_annotation_and_default_part2(lib_types): types = lib_types.get_keyword_types("keyword_default_and_annotation") - assert types == {"arg1": int, "arg2": Union[bool, str]} + assert types == {"arg1": int, "arg2": Union[bool, str], "return": str} def test_keyword_with_robot_types_and_annotations(lib_types): @@ -119,7 +125,7 @@ def test_keyword_with_robot_types_and_bool_annotations(lib_types): def test_init_args(lib_types): types = lib_types.get_keyword_types("__init__") - assert types == {"arg": str} + assert types == {"arg": str, "return": type(None)} def test_dummy_magic_method(lib): @@ -134,7 +140,7 @@ def test_varargs(lib): def test_init_args_with_annotation(lib_types): types = lib_types.get_keyword_types("__init__") - assert types == {"arg": str} + assert types == {"arg": str, "return": type(None)} def test_exception_in_annotations(lib_types): @@ -199,8 +205,14 @@ def test_kw_with_named_arguments(lib_types: DynamicTypesAnnotationsLibrary): def test_kw_with_many_named_arguments_with_default(lib_types: DynamicTypesAnnotationsLibrary): types = lib_types.get_keyword_types("kw_with_many_named_arguments_with_default") - assert types == {'arg2': int} + assert types == {"arg2": int} types = lib_types.get_keyword_types("kw_with_positional_and_named_arguments_with_defaults") assert types == {"arg1": int, "arg2": str} types = lib_types.get_keyword_types("kw_with_positional_and_named_arguments") assert types == {"arg2": int} + + +def test_lib_annotations(lib_annotation: lib_future_annotation): + types = lib_annotation.get_keyword_types("future_annotations") + expected = {"arg": Location} + assert types == expected diff --git a/utest/test_keyword_builder.py b/utest/test_keyword_builder.py index 54ad082..9943c1c 100644 --- a/utest/test_keyword_builder.py +++ b/utest/test_keyword_builder.py @@ -3,6 +3,7 @@ import pytest from DynamicTypesAnnotationsLibrary import DynamicTypesAnnotationsLibrary from moc_library import MockLibrary + from robotlibcore import KeywordBuilder @@ -17,60 +18,60 @@ def dyn_types(): def test_documentation(lib): - spec = KeywordBuilder.build(lib.positional_args) + spec = KeywordBuilder.build(lib.positional_args, {}) assert spec.documentation == "Some documentation\n\nMulti line docs" - spec = KeywordBuilder.build(lib.positional_and_default) + spec = KeywordBuilder.build(lib.positional_and_default, {}) assert spec.documentation == "" def test_no_args(lib): - spec = KeywordBuilder.build(lib.no_args) + spec = KeywordBuilder.build(lib.no_args, {}) assert spec.argument_specification == [] def test_positional_args(lib): - spec = KeywordBuilder.build(lib.positional_args) + spec = KeywordBuilder.build(lib.positional_args, {}) assert spec.argument_specification == ["arg1", "arg2"] def test_positional_and_named(lib): - spec = KeywordBuilder.build(lib.positional_and_default) + spec = KeywordBuilder.build(lib.positional_and_default, {}) assert spec.argument_specification == ["arg1", "arg2", ("named1", "string1"), ("named2", 123)] def test_named_only_default_only(lib): - spec = KeywordBuilder.build(lib.default_only) + spec = KeywordBuilder.build(lib.default_only, {}) assert spec.argument_specification == [("named1", "string1"), ("named2", 123)] def test_varargs_and_kwargs(lib): - spec = KeywordBuilder.build(lib.varargs_kwargs) + spec = KeywordBuilder.build(lib.varargs_kwargs, {}) assert spec.argument_specification == ["*vargs", "**kwargs"] def test_named_only_part2(lib): - spec = KeywordBuilder.build(lib.named_only) + spec = KeywordBuilder.build(lib.named_only, {}) assert spec.argument_specification == ["*varargs", "key1", "key2"] def test_named_only(lib): - spec = KeywordBuilder.build(lib.named_only_with_defaults) + spec = KeywordBuilder.build(lib.named_only_with_defaults, {}) assert spec.argument_specification == ["*varargs", "key1", "key2", ("key3", "default1"), ("key4", True)] def test_types_in_keyword_deco(lib): - spec = KeywordBuilder.build(lib.positional_args) + spec = KeywordBuilder.build(lib.positional_args, {}) assert spec.argument_types == {"arg1": str, "arg2": int} def test_types_disabled_in_keyword_deco(lib): - spec = KeywordBuilder.build(lib.types_disabled) + spec = KeywordBuilder.build(lib.types_disabled, {}) assert spec.argument_types is None def test_types_(lib): - spec = KeywordBuilder.build(lib.args_with_type_hints) - assert spec.argument_types == {"arg3": str, "arg4": type(None)} + spec = KeywordBuilder.build(lib.args_with_type_hints, {}) + assert spec.argument_types == {"arg3": str, "arg4": type(None), "return": bool} def test_types(lib): diff --git a/utest/test_plugin_api.py b/utest/test_plugin_api.py index b51dce2..9209d8b 100644 --- a/utest/test_plugin_api.py +++ b/utest/test_plugin_api.py @@ -1,5 +1,6 @@ -import my_plugin_test import pytest +from helpers import my_plugin_test + from robotlibcore import Module, PluginError, PluginParser @@ -19,17 +20,17 @@ def test_plugins_string_to_modules(plugin_parser): result = plugin_parser._string_to_modules("path.to.MyLibrary,path.to.OtherLibrary") assert result == [ Module("path.to.MyLibrary", [], {}), - Module("path.to.OtherLibrary", [], {}) + Module("path.to.OtherLibrary", [], {}), ] result = plugin_parser._string_to_modules("path.to.MyLibrary , path.to.OtherLibrary") assert result == [ Module("path.to.MyLibrary", [], {}), - Module("path.to.OtherLibrary", [], {}) + Module("path.to.OtherLibrary", [], {}), ] result = plugin_parser._string_to_modules("path.to.MyLibrary;foo;bar , path.to.OtherLibrary;1") assert result == [ Module("path.to.MyLibrary", ["foo", "bar"], {}), - Module("path.to.OtherLibrary", ["1"], {}) + Module("path.to.OtherLibrary", ["1"], {}), ] result = plugin_parser._string_to_modules("PluginWithKwArgs.py;kw1=Text1;kw2=Text2") assert result == [ @@ -37,22 +38,22 @@ def test_plugins_string_to_modules(plugin_parser): ] -def test_parse_plugins(plugin_parser): - plugins = plugin_parser.parse_plugins("my_plugin_test.TestClass") +def test_parse_plugins(plugin_parser: PluginParser): + plugins = plugin_parser.parse_plugins("helpers.my_plugin_test.TestClass") assert len(plugins) == 1 assert isinstance(plugins[0], my_plugin_test.TestClass) - plugins = plugin_parser.parse_plugins("my_plugin_test.TestClass,my_plugin_test.TestClassWithBase") + plugins = plugin_parser.parse_plugins("helpers.my_plugin_test.TestClass,helpers.my_plugin_test.TestClassWithBase") assert len(plugins) == 2 assert isinstance(plugins[0], my_plugin_test.TestClass) assert isinstance(plugins[1], my_plugin_test.TestClassWithBase) def test_parse_plugins_as_list(plugin_parser): - plugins = plugin_parser.parse_plugins(["my_plugin_test.TestClass"]) + plugins = plugin_parser.parse_plugins(["helpers.my_plugin_test.TestClass"]) assert len(plugins) == 1 assert isinstance(plugins[0], my_plugin_test.TestClass) plugins = plugin_parser.parse_plugins( - ["my_plugin_test.TestClass", "my_plugin_test.TestClassWithBase"] + ["helpers.my_plugin_test.TestClass", "helpers.my_plugin_test.TestClassWithBase"], ) assert len(plugins) == 2 assert isinstance(plugins[0], my_plugin_test.TestClass) @@ -61,16 +62,16 @@ def test_parse_plugins_as_list(plugin_parser): def test_parse_plugins_with_base(): parser = PluginParser(my_plugin_test.LibraryBase) - plugins = parser.parse_plugins("my_plugin_test.TestClassWithBase") + plugins = parser.parse_plugins("helpers.my_plugin_test.TestClassWithBase") assert len(plugins) == 1 assert isinstance(plugins[0], my_plugin_test.TestClassWithBase) with pytest.raises(PluginError) as excinfo: - parser.parse_plugins("my_plugin_test.TestClass") - assert "Plugin does not inherit " in str(excinfo.value) + parser.parse_plugins("helpers.my_plugin_test.TestClass") + assert "Plugin does not inherit " in str(excinfo.value) def test_plugin_keywords(plugin_parser): - plugins = plugin_parser.parse_plugins("my_plugin_test.TestClass,my_plugin_test.TestClassWithBase") + plugins = plugin_parser.parse_plugins("helpers.my_plugin_test.TestClass,helpers.my_plugin_test.TestClassWithBase") keywords = plugin_parser.get_plugin_keywords(plugins) assert len(keywords) == 2 assert keywords[0] == "another_keyword" @@ -81,11 +82,11 @@ def test_plugin_python_objects(): class PythonObject: x = 1 y = 2 + python_object = PythonObject() parser = PluginParser(my_plugin_test.LibraryBase, [python_object]) - plugins = parser.parse_plugins("my_plugin_test.TestPluginWithPythonArgs;4") + plugins = parser.parse_plugins("helpers.my_plugin_test.TestPluginWithPythonArgs;4") assert len(plugins) == 1 plugin = plugins[0] assert plugin.python_class.x == 1 assert plugin.python_class.y == 2 - diff --git a/utest/test_robotlibcore.py b/utest/test_robotlibcore.py index cc4a779..b2497aa 100644 --- a/utest/test_robotlibcore.py +++ b/utest/test_robotlibcore.py @@ -1,7 +1,11 @@ +import json + import pytest +from approvaltests.approvals import verify from DynamicLibrary import DynamicLibrary from DynamicTypesAnnotationsLibrary import DynamicTypesAnnotationsLibrary from HybridLibrary import HybridLibrary + from robotlibcore import HybridCore, NoKeywordFound @@ -10,89 +14,24 @@ def dyn_lib(): return DynamicLibrary() -def test_keyword_names(): - expected = [ - "Custom name", - 'Embedded arguments "${here}"', - "all_arguments", - "defaults", - "doc_and_tags", - "function", - "keyword_in_main", - "kwargs_only", - "mandatory", - "method", - "multi_line_doc", - "one_line_doc", - "tags", - "varargs_and_kwargs", - ] - assert HybridLibrary().get_keyword_names() == expected - assert DynamicLibrary().get_keyword_names() == expected - - -def test_dir(): - expected = [ - "Custom name", - 'Embedded arguments "${here}"', - "_DynamicCore__get_keyword", - "_DynamicCore__get_keyword_line", - "_DynamicCore__get_keyword_path", - "_HybridCore__get_component_listeners", - "_HybridCore__get_manually_registered_listeners", - "_HybridCore__get_members", - "_HybridCore__get_members_from_instance", - "_HybridCore__set_library_listeners", - "_other_name_here", - "add_library_components", - "all_arguments", - "attributes", - "class_attribute", - "defaults", - "doc_and_tags", - "embedded", - "function", - "get_keyword_arguments", - "get_keyword_documentation", - "get_keyword_names", - "get_keyword_source", - "get_keyword_tags", - "get_keyword_types", - "instance_attribute", - "keyword_in_main", - "keywords", - "keywords_spec", - "kwargs_only", - "mandatory", - "method", - "multi_line_doc", - "not_keyword_in_main", - "one_line_doc", - "run_keyword", - "tags", - "varargs_and_kwargs", - ] - assert [a for a in dir(DynamicLibrary()) if a[:2] != "__"] == expected - expected = [ - e - for e in expected - if e - not in ( - "_DynamicCore__get_typing_hints", - "_DynamicCore__get_keyword", - "_DynamicCore__get_keyword_line", - "_DynamicCore__get_keyword_path", - "_DynamicCore__join_defaults_with_types", - "get_keyword_arguments", - "get_keyword_documentation", - "get_keyword_source", - "get_keyword_tags", - "parse_plugins", - "run_keyword", - "get_keyword_types", - ) - ] - assert [a for a in dir(HybridLibrary()) if a[:2] != "__"] == expected +def test_keyword_names_hybrid(): + verify(json.dumps(HybridLibrary().get_keyword_names(), indent=4)) + + +def test_keyword_names_dynamic(): + verify(json.dumps(DynamicLibrary().get_keyword_names(), indent=4)) + + +def test_dir_dyn_lib(): + result = [a for a in dir(DynamicLibrary()) if a[:2] != "__"] + result = json.dumps(result, indent=4) + verify(result) + + +def test_dir_hubrid_lib(): + result = [a for a in dir(HybridLibrary()) if a[:2] != "__"] + result = json.dumps(result, indent=4) + verify(result) def test_getattr(): diff --git a/utest/test_robotlibcore.test_dir_dyn_lib.approved.txt b/utest/test_robotlibcore.test_dir_dyn_lib.approved.txt new file mode 100644 index 0000000..d4bb728 --- /dev/null +++ b/utest/test_robotlibcore.test_dir_dyn_lib.approved.txt @@ -0,0 +1,42 @@ +[ + "Custom name", + "Embedded arguments \"${here}\"", + "_DynamicCore__get_keyword", + "_DynamicCore__get_keyword_line", + "_DynamicCore__get_keyword_path", + "_HybridCore__get_component_listeners", + "_HybridCore__get_keyword_name", + "_HybridCore__get_manually_registered_listeners", + "_HybridCore__get_members", + "_HybridCore__get_members_from_instance", + "_HybridCore__replace_intro_doc", + "_HybridCore__set_library_listeners", + "_other_name_here", + "add_library_components", + "all_arguments", + "attributes", + "class_attribute", + "defaults", + "doc_and_tags", + "embedded", + "function", + "get_keyword_arguments", + "get_keyword_documentation", + "get_keyword_names", + "get_keyword_source", + "get_keyword_tags", + "get_keyword_types", + "instance_attribute", + "keyword_in_main", + "keywords", + "keywords_spec", + "kwargs_only", + "mandatory", + "method", + "multi_line_doc", + "not_keyword_in_main", + "one_line_doc", + "run_keyword", + "tags", + "varargs_and_kwargs" +] diff --git a/utest/test_robotlibcore.test_dir_hubrid_lib.approved.txt b/utest/test_robotlibcore.test_dir_hubrid_lib.approved.txt new file mode 100644 index 0000000..4de4be5 --- /dev/null +++ b/utest/test_robotlibcore.test_dir_hubrid_lib.approved.txt @@ -0,0 +1,33 @@ +[ + "Custom name", + "Embedded arguments \"${here}\"", + "_HybridCore__get_component_listeners", + "_HybridCore__get_keyword_name", + "_HybridCore__get_manually_registered_listeners", + "_HybridCore__get_members", + "_HybridCore__get_members_from_instance", + "_HybridCore__replace_intro_doc", + "_HybridCore__set_library_listeners", + "_other_name_here", + "add_library_components", + "all_arguments", + "attributes", + "class_attribute", + "defaults", + "doc_and_tags", + "embedded", + "function", + "get_keyword_names", + "instance_attribute", + "keyword_in_main", + "keywords", + "keywords_spec", + "kwargs_only", + "mandatory", + "method", + "multi_line_doc", + "not_keyword_in_main", + "one_line_doc", + "tags", + "varargs_and_kwargs" +] diff --git a/utest/test_robotlibcore.test_keyword_names.approved.txt b/utest/test_robotlibcore.test_keyword_names.approved.txt new file mode 100644 index 0000000..e69de29 diff --git a/utest/test_robotlibcore.test_keyword_names.received.txt b/utest/test_robotlibcore.test_keyword_names.received.txt new file mode 100644 index 0000000..4d1dd17 --- /dev/null +++ b/utest/test_robotlibcore.test_keyword_names.received.txt @@ -0,0 +1,3 @@ +Keywords + +0) ['Custom name', 'Embedded arguments "${here}"', 'all_arguments', 'defaults', 'doc_and_tags', 'function', 'keyword_in_main', 'kwargs_only', 'mandatory', 'method', 'multi_line_doc', 'one_line_doc', 'tags', 'varargs_and_kwargs', 'Custom name', 'Embedded arguments "${here}"', 'all_arguments', 'defaults', 'doc_and_tags', 'function', 'keyword_in_main', 'kwargs_only', 'mandatory', 'method', 'multi_line_doc', 'one_line_doc', 'tags', 'varargs_and_kwargs'] diff --git a/utest/test_robotlibcore.test_keyword_names_dynamic.approved.txt b/utest/test_robotlibcore.test_keyword_names_dynamic.approved.txt new file mode 100644 index 0000000..d5882b3 --- /dev/null +++ b/utest/test_robotlibcore.test_keyword_names_dynamic.approved.txt @@ -0,0 +1,16 @@ +[ + "Custom name", + "Embedded arguments \"${here}\"", + "all_arguments", + "defaults", + "doc_and_tags", + "function", + "keyword_in_main", + "kwargs_only", + "mandatory", + "method", + "multi_line_doc", + "one_line_doc", + "tags", + "varargs_and_kwargs" +] diff --git a/utest/test_robotlibcore.test_keyword_names_hybrid.approved.txt b/utest/test_robotlibcore.test_keyword_names_hybrid.approved.txt new file mode 100644 index 0000000..d5882b3 --- /dev/null +++ b/utest/test_robotlibcore.test_keyword_names_hybrid.approved.txt @@ -0,0 +1,16 @@ +[ + "Custom name", + "Embedded arguments \"${here}\"", + "all_arguments", + "defaults", + "doc_and_tags", + "function", + "keyword_in_main", + "kwargs_only", + "mandatory", + "method", + "multi_line_doc", + "one_line_doc", + "tags", + "varargs_and_kwargs" +] diff --git a/utest/test_translations.py b/utest/test_translations.py new file mode 100644 index 0000000..b9b9e3b --- /dev/null +++ b/utest/test_translations.py @@ -0,0 +1,67 @@ +from pathlib import Path + +import pytest +from SmallLibrary import SmallLibrary + + +@pytest.fixture(scope="module") +def lib(): + translation = Path(__file__).parent.parent / "atest" / "translation.json" + return SmallLibrary(translation=translation) + + +def test_invalid_translation(): + translation = Path(__file__) + assert SmallLibrary(translation=translation) + + +def test_translations_names(lib: SmallLibrary): + keywords = lib.keywords_spec + assert "other_name" in keywords + assert "name_changed_again" in keywords + + +def test_translations_docs(lib: SmallLibrary): + keywords = lib.keywords_spec + kw = keywords["other_name"] + assert kw.documentation == "This is new doc" + kw = keywords["name_changed_again"] + assert kw.documentation == "This is also replaced.\n\nnew line." + + +def test_init_and_lib_docs(lib: SmallLibrary): + keywords = lib.keywords_spec + init = keywords["__init__"] + assert init.documentation == "Replaces init docs with this one." + doc = lib.get_keyword_documentation("__intro__") + assert doc == "New __intro__ documentation is here." + + +def test_not_translated(lib: SmallLibrary): + keywords = lib.keywords_spec + assert "not_translated" in keywords + doc = lib.get_keyword_documentation("not_translated") + assert doc == "This is not replaced." + + +def test_doc_not_translated(lib: SmallLibrary): + keywords = lib.keywords_spec + assert "doc_not_translated" not in keywords + assert "this_is_replaced" in keywords + doc = lib.get_keyword_documentation("this_is_replaced") + assert doc == "This is not replaced also." + + +def test_kw_not_translated_but_doc_is(lib: SmallLibrary): + keywords = lib.keywords_spec + assert "kw_not_translated" in keywords + doc = lib.get_keyword_documentation("kw_not_translated") + assert doc == "Here is new doc" + + +def test_rf_name_not_in_keywords(): + translation = Path(__file__).parent.parent / "atest" / "translation.json" + lib = SmallLibrary(translation=translation) + kw = lib.keywords + assert "Execute SomeThing" not in kw, f"Execute SomeThing should not be present: {kw}" + assert len(kw) == 6, f"Too many keywords: {kw}"