From c57dd2e7c405445f969e8b6849ab87129021fad6 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 5 Sep 2023 07:26:58 +0000 Subject: [PATCH 01/51] Bump actions/checkout from 3 to 4 Bumps [actions/checkout](https://github.com/actions/checkout) from 3 to 4. - [Release notes](https://github.com/actions/checkout/releases) - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/checkout/compare/v3...v4) --- updated-dependencies: - dependency-name: actions/checkout dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/CI.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index 5dfa9d7..73e58c4 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -18,7 +18,7 @@ jobs: rf-version: [5.0.1, 6.1.0] 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 with: From c57fc10441b07eb49c3c60137f0b554634e17762 Mon Sep 17 00:00:00 2001 From: Tatu Aalto Date: Wed, 15 Nov 2023 22:40:11 +0200 Subject: [PATCH 02/51] Merge dev and build reg files --- requirements-build.txt | 7 ------- requirements-dev.txt | 3 +++ 2 files changed, 3 insertions(+), 7 deletions(-) delete mode 100644 requirements-build.txt 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..2f4358f 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -8,3 +8,6 @@ robotframework-tidy invoke >= 2.2.0 twine wheel +rellu >= 0.7 +twine +wheel From a42bd4f22a2099bd5ae2f694b7036a770e6b0a25 Mon Sep 17 00:00:00 2001 From: Tatu Aalto Date: Wed, 15 Nov 2023 22:43:48 +0200 Subject: [PATCH 03/51] Ignore more lint outputs --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 4f4a815..b74338b 100644 --- a/.gitignore +++ b/.gitignore @@ -46,6 +46,9 @@ htmlcov/ .coverage .coverage.* .cache +.ruff_cache +.mypy_cache +.pytest_cache nosetests.xml coverage.xml *,cover From 4bf4f90b1d38ea73eeaae7029d163aa493d11ef0 Mon Sep 17 00:00:00 2001 From: Tatu Aalto Date: Thu, 16 Nov 2023 20:42:31 +0200 Subject: [PATCH 04/51] Improve testing for TypedDict --- atest/lib_future_annotation.py | 22 ++++++++++++++++++++++ requirements-dev.txt | 1 + src/robotlibcore.py | 18 +++++++++++++----- utest/test_get_keyword_types.py | 12 ++++++++++++ 4 files changed, 48 insertions(+), 5 deletions(-) create mode 100644 atest/lib_future_annotation.py 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/requirements-dev.txt b/requirements-dev.txt index 2f4358f..aff0549 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -11,3 +11,4 @@ wheel rellu >= 0.7 twine wheel +typing-extensions >= 4.5.0 diff --git a/src/robotlibcore.py b/src/robotlibcore.py index c87c5c8..0d95b29 100644 --- a/src/robotlibcore.py +++ b/src/robotlibcore.py @@ -21,7 +21,7 @@ import inspect import os from dataclasses import dataclass -from typing import Any, Callable, List, Optional, Union, get_type_hints +from typing import Any, Callable, List, Optional, Union, get_type_hints, ForwardRef from robot.api.deco import keyword # noqa: F401 from robot.errors import DataError @@ -223,6 +223,17 @@ def _get_arguments(cls, function): 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__ + for arg, hint in hints.items(): + if isinstance(hint, ForwardRef): + hint = hint.__forward_arg__ + return hints + @classmethod def _get_args(cls, arg_spec: inspect.FullArgSpec, function: Callable) -> list: args = cls._drop_self_from_args(function, arg_spec) @@ -279,10 +290,7 @@ def _get_types(cls, 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__ + 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): diff --git a/utest/test_get_keyword_types.py b/utest/test_get_keyword_types.py index 7a0dba2..7e407f3 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 lib_future_annotation, Location @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} @@ -204,3 +210,9 @@ def test_kw_with_many_named_arguments_with_default(lib_types: DynamicTypesAnnota 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 = {"arg1": Location} + assert types == expected From 65147195457dd4fd48c03d8a406a81f47ef8afbe Mon Sep 17 00:00:00 2001 From: Tatu Aalto Date: Thu, 16 Nov 2023 20:44:55 +0200 Subject: [PATCH 05/51] remove debug code --- src/robotlibcore.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/robotlibcore.py b/src/robotlibcore.py index 0d95b29..0c4d9a7 100644 --- a/src/robotlibcore.py +++ b/src/robotlibcore.py @@ -229,9 +229,6 @@ def _get_type_hint(cls, function: Callable): hints = get_type_hints(function) except Exception: # noqa: BLE001 hints = function.__annotations__ - for arg, hint in hints.items(): - if isinstance(hint, ForwardRef): - hint = hint.__forward_arg__ return hints @classmethod From ba28e4ebab77e08b590c9852b9c971adfc3f16f3 Mon Sep 17 00:00:00 2001 From: Tatu Aalto Date: Thu, 16 Nov 2023 20:48:01 +0200 Subject: [PATCH 06/51] Lint fixes --- src/robotlibcore.py | 4 ++-- tasks.py | 2 -- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/src/robotlibcore.py b/src/robotlibcore.py index 0c4d9a7..4e314c1 100644 --- a/src/robotlibcore.py +++ b/src/robotlibcore.py @@ -21,7 +21,7 @@ import inspect import os from dataclasses import dataclass -from typing import Any, Callable, List, Optional, Union, get_type_hints, ForwardRef +from typing import Any, Callable, List, Optional, Union, get_type_hints from robot.api.deco import keyword # noqa: F401 from robot.errors import DataError @@ -226,7 +226,7 @@ def _get_arg_spec(cls, function: Callable) -> inspect.FullArgSpec: @classmethod def _get_type_hint(cls, function: Callable): try: - hints = get_type_hints(function) + hints = get_type_hints(function) except Exception: # noqa: BLE001 hints = function.__annotations__ return hints diff --git a/tasks.py b/tasks.py index b2b07d3..2260aee 100644 --- a/tasks.py +++ b/tasks.py @@ -140,8 +140,6 @@ def lint(ctx): command = [ "robotidy", "--transform", - "RenameKeywords", - "--transform", "RenameTestCases", "-c", "RenameTestCases:capitalize_each_word=True", From b8c9340ff4cf6670e6243371f88c3cacb3aa38b4 Mon Sep 17 00:00:00 2001 From: Tatu Aalto Date: Thu, 16 Nov 2023 20:49:56 +0200 Subject: [PATCH 07/51] Fix utest --- utest/test_get_keyword_types.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/utest/test_get_keyword_types.py b/utest/test_get_keyword_types.py index 7e407f3..92029a2 100644 --- a/utest/test_get_keyword_types.py +++ b/utest/test_get_keyword_types.py @@ -214,5 +214,5 @@ def test_kw_with_many_named_arguments_with_default(lib_types: DynamicTypesAnnota def test_lib_annotations(lib_annotation: lib_future_annotation): types = lib_annotation.get_keyword_types("future_annotations") - expected = {"arg1": Location} + expected = {"arg": Location} assert types == expected From 4628ad558e84af2b7878431724e72b555f32dbc0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rene=CC=81?= Date: Sat, 18 Nov 2023 22:36:52 +0100 Subject: [PATCH 08/51] added RF7 compatibility (Return Types) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: René --- atest/DynamicTypesAnnotationsLibrary.py | 2 +- src/robotlibcore.py | 4 ++-- utest/test_get_keyword_types.py | 8 ++++---- utest/test_keyword_builder.py | 2 +- 4 files changed, 8 insertions(+), 8 deletions(-) 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/src/robotlibcore.py b/src/robotlibcore.py index 4e314c1..5672fb0 100644 --- a/src/robotlibcore.py +++ b/src/robotlibcore.py @@ -291,8 +291,8 @@ def _get_typing_hints(cls, 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 return and self statements - if arg_with_hint not in all_args: + # remove self statements + if arg_with_hint not in [*all_args, "return"]: hints.pop(arg_with_hint) return hints diff --git a/utest/test_get_keyword_types.py b/utest/test_get_keyword_types.py index 92029a2..925ebe3 100644 --- a/utest/test_get_keyword_types.py +++ b/utest/test_get_keyword_types.py @@ -80,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): @@ -105,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): @@ -125,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): @@ -140,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): diff --git a/utest/test_keyword_builder.py b/utest/test_keyword_builder.py index 54ad082..093c20f 100644 --- a/utest/test_keyword_builder.py +++ b/utest/test_keyword_builder.py @@ -70,7 +70,7 @@ def test_types_disabled_in_keyword_deco(lib): def test_types_(lib): spec = KeywordBuilder.build(lib.args_with_type_hints) - assert spec.argument_types == {"arg3": str, "arg4": type(None)} + assert spec.argument_types == {"arg3": str, "arg4": type(None), "return": bool} def test_types(lib): From 068baf48e1e759a4d30f9673f8cea06b718e38d3 Mon Sep 17 00:00:00 2001 From: Tatu Aalto Date: Sun, 19 Nov 2023 15:23:29 +0200 Subject: [PATCH 09/51] Release notes for 4.3.0 --- docs/PythonLibCore-4.3.0.rst | 66 ++++++++++++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) create mode 100644 docs/PythonLibCore-4.3.0.rst 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 From d577b91ff40cd835f4d02f77dd71293219cc8586 Mon Sep 17 00:00:00 2001 From: Tatu Aalto Date: Sun, 19 Nov 2023 15:24:46 +0200 Subject: [PATCH 10/51] Updated version to 4.3.0 --- src/robotlibcore.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/robotlibcore.py b/src/robotlibcore.py index 5672fb0..251a20d 100644 --- a/src/robotlibcore.py +++ b/src/robotlibcore.py @@ -27,7 +27,7 @@ from robot.errors import DataError from robot.utils import Importer -__version__ = "4.2.0" +__version__ = "4.3.0" class PythonLibCoreException(Exception): # noqa: N818 From c742ccf47d2906ec116e39db898e37833d9f949f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 7 Dec 2023 07:27:06 +0000 Subject: [PATCH 11/51] Bump actions/setup-python from 4 to 5 Bumps [actions/setup-python](https://github.com/actions/setup-python) from 4 to 5. - [Release notes](https://github.com/actions/setup-python/releases) - [Commits](https://github.com/actions/setup-python/compare/v4...v5) --- updated-dependencies: - dependency-name: actions/setup-python dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/CI.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index 73e58c4..3e9fa37 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -20,7 +20,7 @@ jobs: steps: - 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 From 927995b44d896bc0b4cc78fae5a50e0c4e9292c0 Mon Sep 17 00:00:00 2001 From: Tatu Aalto Date: Sat, 16 Dec 2023 00:18:45 +0200 Subject: [PATCH 12/51] Use upload-artifact v4 --- .github/workflows/CI.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index 3e9fa37..f0c5860 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -45,8 +45,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 From 6c0319df50168c46b8d7de5e469a212688e3c3b5 Mon Sep 17 00:00:00 2001 From: Tatu Aalto Date: Sat, 16 Mar 2024 01:38:05 +0200 Subject: [PATCH 13/51] Add VS Code setting to ignore --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index b74338b..13badd1 100644 --- a/.gitignore +++ b/.gitignore @@ -100,6 +100,9 @@ ENV/ # PyCharm project settings .idea +# VSCode project settings +.vscode + # Robot Ouput files log.html output.xml From 8e36e69e660d63ac70ff78f95b244b5db2c8e5bd Mon Sep 17 00:00:00 2001 From: Tatu Aalto Date: Sat, 16 Mar 2024 01:38:30 +0200 Subject: [PATCH 14/51] For VSCode and pytest support --- pytest.ini | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 pytest.ini 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 From adae90e06374f29b4086845762915b14e3701ec7 Mon Sep 17 00:00:00 2001 From: Tatu Aalto Date: Sat, 16 Mar 2024 01:49:06 +0200 Subject: [PATCH 15/51] Support for translations --- atest/SmallLibrary.py | 36 +++++++ atest/tests_types.robot | 6 ++ atest/translation.json | 11 ++ requirements-dev.txt | 1 + src/robotlibcore.py | 51 +++++++-- utest/test_keyword_builder.py | 24 ++--- utest/test_plugin_api.py | 22 ++-- utest/test_robotlibcore.py | 101 ++++-------------- ...robotlibcore.test_dir_dyn_lib.approved.txt | 41 +++++++ ...otlibcore.test_dir_hubrid_lib.approved.txt | 32 ++++++ ...botlibcore.test_keyword_names.approved.txt | 0 ...botlibcore.test_keyword_names.received.txt | 3 + ...re.test_keyword_names_dynamic.approved.txt | 16 +++ ...ore.test_keyword_names_hybrid.approved.txt | 16 +++ utest/test_translations.py | 26 +++++ 15 files changed, 271 insertions(+), 115 deletions(-) create mode 100644 atest/SmallLibrary.py create mode 100644 atest/translation.json create mode 100644 utest/test_robotlibcore.test_dir_dyn_lib.approved.txt create mode 100644 utest/test_robotlibcore.test_dir_hubrid_lib.approved.txt create mode 100644 utest/test_robotlibcore.test_keyword_names.approved.txt create mode 100644 utest/test_robotlibcore.test_keyword_names.received.txt create mode 100644 utest/test_robotlibcore.test_keyword_names_dynamic.approved.txt create mode 100644 utest/test_robotlibcore.test_keyword_names_hybrid.approved.txt create mode 100644 utest/test_translations.py diff --git a/atest/SmallLibrary.py b/atest/SmallLibrary.py new file mode 100644 index 0000000..ce0c4b0 --- /dev/null +++ b/atest/SmallLibrary.py @@ -0,0 +1,36 @@ +from pathlib import Path +from typing import Optional + +from robot.api import logger +from robotlibcore import DynamicCore, keyword + +class SmallLibrary(DynamicCore): + """Library documentation.""" + + def __init__(self, translation: Optional[Path] = None): + """__init__ documentation.""" + logger.warn(translation.absolute()) + logger.warn(type(translation)) + + 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 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..af5efd1 --- /dev/null +++ b/atest/translation.json @@ -0,0 +1,11 @@ +{ + "normal_keyword": { + "name": "other_name", + "doc": "This is new doc" + }, + "name_changed": { + "name": "name_changed_again", + "doc": "This is also replaced.\n\nnew line." + } + +} \ No newline at end of file diff --git a/requirements-dev.txt b/requirements-dev.txt index aff0549..7d36f77 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -12,3 +12,4 @@ rellu >= 0.7 twine wheel typing-extensions >= 4.5.0 +approvaltests >= 11.1.1 diff --git a/src/robotlibcore.py b/src/robotlibcore.py index 251a20d..6d41922 100644 --- a/src/robotlibcore.py +++ b/src/robotlibcore.py @@ -19,10 +19,13 @@ https://github.com/robotframework/PythonLibCore """ import inspect +import json import os from dataclasses import dataclass +from pathlib import Path from typing import Any, Callable, List, Optional, Union, get_type_hints +from robot.api import logger from robot.api.deco import keyword # noqa: F401 from robot.errors import DataError from robot.utils import Importer @@ -42,28 +45,47 @@ class NoKeywordFound(PythonLibCoreException): pass +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 find file: {translation}") + return {} + else: + return {} + + class HybridCore: - def __init__(self, library_components: List) -> None: + def __init__(self, library_components: List, translation: Optional[Path] = None) -> None: self.keywords = {} self.keywords_spec = {} self.attributes = {} - self.add_library_components(library_components) - self.add_library_components([self]) + translation_data = _translation(translation) + self.add_library_components(library_components, translation_data) + self.add_library_components([self], translation_data) self.__set_library_listeners(library_components) - def add_library_components(self, library_components: List): - self.keywords_spec["__init__"] = KeywordBuilder.build(self.__init__) # type: ignore + def add_library_components(self, library_components: List, translation: Optional[dict] = None): + translation = translation if translation else {} + self.keywords_spec["__init__"] = KeywordBuilder.build(self.__init__, translation) # 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 + kw_name = self.__get_keyword_name(func, name, translation) self.keywords[kw_name] = kw - self.keywords_spec[kw_name] = KeywordBuilder.build(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): + if name in translation: + return translation[name]["name"] + return func.robot_name or name + def __set_library_listeners(self, library_components: list): listeners = self.__get_manually_registered_listeners() listeners.extend(self.__get_component_listeners([self, *library_components])) @@ -198,13 +220,24 @@ def __get_keyword_path(self, method): class KeywordBuilder: @classmethod - def build(cls, function): + def build(cls, function, translation: Optional[dict] = None): + translation = translation if translation else {} return KeywordSpecification( argument_specification=cls._get_arguments(function), - documentation=inspect.getdoc(function) or "", + 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): + 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) diff --git a/utest/test_keyword_builder.py b/utest/test_keyword_builder.py index 093c20f..4222aea 100644 --- a/utest/test_keyword_builder.py +++ b/utest/test_keyword_builder.py @@ -17,59 +17,59 @@ 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) + spec = KeywordBuilder.build(lib.args_with_type_hints, {}) assert spec.argument_types == {"arg3": str, "arg4": type(None), "return": bool} diff --git a/utest/test_plugin_api.py b/utest/test_plugin_api.py index b51dce2..6464ede 100644 --- a/utest/test_plugin_api.py +++ b/utest/test_plugin_api.py @@ -1,4 +1,4 @@ -import my_plugin_test +from helpers import my_plugin_test import pytest from robotlibcore import Module, PluginError, PluginParser @@ -37,22 +37,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 +61,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" @@ -83,7 +83,7 @@ class PythonObject: 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 diff --git a/utest/test_robotlibcore.py b/utest/test_robotlibcore.py index cc4a779..b4b0a96 100644 --- a/utest/test_robotlibcore.py +++ b/utest/test_robotlibcore.py @@ -1,8 +1,10 @@ +import json import pytest from DynamicLibrary import DynamicLibrary from DynamicTypesAnnotationsLibrary import DynamicTypesAnnotationsLibrary from HybridLibrary import HybridLibrary from robotlibcore import HybridCore, NoKeywordFound +from approvaltests.approvals import verify, verify_all @pytest.fixture(scope="module") @@ -10,89 +12,22 @@ 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..9a8ef25 --- /dev/null +++ b/utest/test_robotlibcore.test_dir_dyn_lib.approved.txt @@ -0,0 +1,41 @@ +[ + "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__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..8579980 --- /dev/null +++ b/utest/test_robotlibcore.test_dir_hubrid_lib.approved.txt @@ -0,0 +1,32 @@ +[ + "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__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..c462a77 --- /dev/null +++ b/utest/test_translations.py @@ -0,0 +1,26 @@ +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." \ No newline at end of file From a798346b0b446ff25916d041f7d4058b94c51194 Mon Sep 17 00:00:00 2001 From: Tatu Aalto Date: Sat, 16 Mar 2024 01:49:32 +0200 Subject: [PATCH 16/51] Ruff config fixes --- pyproject.toml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 6fa99a2..76b9f02 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,8 @@ select = [ "RUF" ] -[tool.ruff.mccabe] +[tool.ruff.lint.mccabe] max-complexity = 9 -[tool.ruff.flake8-quotes] +[tool.ruff.lint.flake8-quotes] docstring-quotes = "double" From 9705679c36a0e74e8602d077644cd0b2f54e28d7 Mon Sep 17 00:00:00 2001 From: Tatu Aalto Date: Sat, 16 Mar 2024 02:14:39 +0200 Subject: [PATCH 17/51] Lint fixes --- pyproject.toml | 8 ++++++++ tasks.py | 3 ++- utest/run.py | 16 ++++++---------- utest/test_get_keyword_source.py | 25 ++++++++++++++----------- utest/test_get_keyword_types.py | 8 ++++---- utest/test_plugin_api.py | 12 ++++++------ utest/test_robotlibcore.py | 9 ++++++--- utest/test_translations.py | 7 +++++-- 8 files changed, 51 insertions(+), 37 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 76b9f02..16759fb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -46,6 +46,14 @@ lint.select = [ "RUF" ] +[tool.ruff.lint.extend-per-file-ignores] +"utest/*" = [ + "S", + "SLF", + "PLR", + "B018" +] + [tool.ruff.lint.mccabe] max-complexity = 9 diff --git a/tasks.py b/tasks.py index 2260aee..3e98212 100644 --- a/tasks.py +++ b/tasks.py @@ -132,9 +132,10 @@ 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 = [ 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 925ebe3..e72803b 100644 --- a/utest/test_get_keyword_types.py +++ b/utest/test_get_keyword_types.py @@ -4,7 +4,7 @@ import pytest from DynamicTypesAnnotationsLibrary import CustomObject, DynamicTypesAnnotationsLibrary from DynamicTypesLibrary import DynamicTypesLibrary -from lib_future_annotation import lib_future_annotation, Location +from lib_future_annotation import Location, lib_future_annotation @pytest.fixture(scope="module") @@ -80,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, 'return': Union[List[str], str]} + assert types == {"arg": str, "return": Union[List[str], str]} def test_keyword_forward_references(lib_types): @@ -105,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], 'return': str} + assert types == {"arg1": int, "arg2": Union[bool, str], "return": str} def test_keyword_with_robot_types_and_annotations(lib_types): @@ -205,7 +205,7 @@ 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") diff --git a/utest/test_plugin_api.py b/utest/test_plugin_api.py index 6464ede..67226d6 100644 --- a/utest/test_plugin_api.py +++ b/utest/test_plugin_api.py @@ -1,5 +1,5 @@ -from helpers import my_plugin_test import pytest +from helpers import my_plugin_test from robotlibcore import Module, PluginError, PluginParser @@ -19,17 +19,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 == [ @@ -52,7 +52,7 @@ def test_parse_plugins_as_list(plugin_parser): assert len(plugins) == 1 assert isinstance(plugins[0], my_plugin_test.TestClass) plugins = plugin_parser.parse_plugins( - ["helpers.my_plugin_test.TestClass", "helpers.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) @@ -81,6 +81,7 @@ 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("helpers.my_plugin_test.TestPluginWithPythonArgs;4") @@ -88,4 +89,3 @@ class PythonObject: 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 b4b0a96..52689ad 100644 --- a/utest/test_robotlibcore.py +++ b/utest/test_robotlibcore.py @@ -1,10 +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 -from approvaltests.approvals import verify, verify_all @pytest.fixture(scope="module") @@ -17,15 +18,17 @@ def test_keyword_names_hybrid(): def test_keyword_names_dynamic(): - verify(json.dumps(DynamicLibrary().get_keyword_names(), indent=4)) + 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 = [a for a in dir(HybridLibrary()) if a[:2] != "__"] result = json.dumps(result, indent=4) verify(result) diff --git a/utest/test_translations.py b/utest/test_translations.py index c462a77..20c3dc6 100644 --- a/utest/test_translations.py +++ b/utest/test_translations.py @@ -1,6 +1,6 @@ from pathlib import Path -import pytest +import pytest from SmallLibrary import SmallLibrary @@ -9,18 +9,21 @@ 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." \ No newline at end of file + assert kw.documentation == "This is also replaced.\n\nnew line." From 68eb98559a03cd99215409ee1f2db5b69e1c2756 Mon Sep 17 00:00:00 2001 From: Tatu Aalto Date: Sat, 16 Mar 2024 02:23:10 +0200 Subject: [PATCH 18/51] Fix for RF 5 --- atest/SmallLibrary.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/atest/SmallLibrary.py b/atest/SmallLibrary.py index ce0c4b0..55a9540 100644 --- a/atest/SmallLibrary.py +++ b/atest/SmallLibrary.py @@ -9,6 +9,9 @@ class SmallLibrary(DynamicCore): def __init__(self, translation: Optional[Path] = None): """__init__ documentation.""" + if not isinstance(translation, Path): + logger.warn("Convert to Path") + translation = Path(translation) logger.warn(translation.absolute()) logger.warn(type(translation)) From d41993435a4f91722d38522585a7f430b6a0986b Mon Sep 17 00:00:00 2001 From: Tatu Aalto Date: Sat, 16 Mar 2024 02:29:15 +0200 Subject: [PATCH 19/51] Better error --- src/robotlibcore.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/robotlibcore.py b/src/robotlibcore.py index 6d41922..8fa8f56 100644 --- a/src/robotlibcore.py +++ b/src/robotlibcore.py @@ -51,7 +51,7 @@ def _translation(translation: Optional[Path] = None): try: return json.load(file) except json.decoder.JSONDecodeError: - logger.warn(f"Could not find file: {translation}") + logger.warn(f"Could not convert json file {translation} to dictionary.") return {} else: return {} From b1503dcc80d3bac96fca07a89150923637bcce82 Mon Sep 17 00:00:00 2001 From: Tatu Aalto Date: Tue, 19 Mar 2024 22:23:21 +0200 Subject: [PATCH 20/51] Fix intro doc replace --- atest/translation.json | 11 +++++++++-- src/robotlibcore.py | 5 +++++ utest/test_robotlibcore.test_dir_dyn_lib.approved.txt | 1 + ...test_robotlibcore.test_dir_hubrid_lib.approved.txt | 1 + utest/test_translations.py | 7 +++++++ 5 files changed, 23 insertions(+), 2 deletions(-) diff --git a/atest/translation.json b/atest/translation.json index af5efd1..36795c5 100644 --- a/atest/translation.json +++ b/atest/translation.json @@ -6,6 +6,13 @@ "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." } - -} \ No newline at end of file +} diff --git a/src/robotlibcore.py b/src/robotlibcore.py index 8fa8f56..b42f8e6 100644 --- a/src/robotlibcore.py +++ b/src/robotlibcore.py @@ -70,6 +70,7 @@ def __init__(self, library_components: List, translation: Optional[Path] = None) def add_library_components(self, library_components: List, translation: Optional[dict] = None): translation = translation if translation 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"): @@ -86,6 +87,10 @@ def __get_keyword_name(self, func: Callable, name: str, translation: dict): return translation[name]["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])) diff --git a/utest/test_robotlibcore.test_dir_dyn_lib.approved.txt b/utest/test_robotlibcore.test_dir_dyn_lib.approved.txt index 9a8ef25..d4bb728 100644 --- a/utest/test_robotlibcore.test_dir_dyn_lib.approved.txt +++ b/utest/test_robotlibcore.test_dir_dyn_lib.approved.txt @@ -9,6 +9,7 @@ "_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", diff --git a/utest/test_robotlibcore.test_dir_hubrid_lib.approved.txt b/utest/test_robotlibcore.test_dir_hubrid_lib.approved.txt index 8579980..4de4be5 100644 --- a/utest/test_robotlibcore.test_dir_hubrid_lib.approved.txt +++ b/utest/test_robotlibcore.test_dir_hubrid_lib.approved.txt @@ -6,6 +6,7 @@ "_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", diff --git a/utest/test_translations.py b/utest/test_translations.py index 20c3dc6..a482a52 100644 --- a/utest/test_translations.py +++ b/utest/test_translations.py @@ -27,3 +27,10 @@ def test_translations_docs(lib: SmallLibrary): 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." From 504d2106ed3c62e87680c49b0e794ea94b123e82 Mon Sep 17 00:00:00 2001 From: Tatu Aalto Date: Tue, 19 Mar 2024 22:44:29 +0200 Subject: [PATCH 21/51] Use markdown as readme --- README.md | 155 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 155 insertions(+) create mode 100644 README.md diff --git a/README.md b/README.md new file mode 100644 index 0000000..f9ab3ad --- /dev/null +++ b/README.md @@ -0,0 +1,155 @@ +--- +title: 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 version 1.0 is already used by +[SeleniumLibrary](https://github.com/robotframework/SeleniumLibrary/) +and +[WhiteLibrary](https://pypi.org/project/robotframework-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)](https://github.com/robotframework/PythonLibCore) + +# Usage + +There are two ways to use PythonLibCore, either by +[HybridCore]{.title-ref} or by using [DynamicCore]{.title-ref}. +[HybridCore]{.title-ref} provides support for the hybrid library API and +[DynamicCore]{.title-ref} 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]{.title-ref} or + [DynamicCore]{.title-ref}. +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]{.title-ref} argument in the + [HybridCore]{.title-ref} or [DynamicCore]{.title-ref} + [\_\_init\_\_]{.title-ref}. + +It is also possible implement keywords in the library main class, by +marking method with [\@keyword]{.title-ref} as keywords. It is not +requires pass main library instance in the +[library_components]{.title-ref} 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 + +``` 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: + +``` bash +Library ${CURDIR}/PluginLib.py plugins=${CURDIR}/MyPlugin.py +``` From 568d5e9f3818ca4e3f1a80319180014aa5bda5b3 Mon Sep 17 00:00:00 2001 From: Tatu Aalto Date: Tue, 19 Mar 2024 22:56:50 +0200 Subject: [PATCH 22/51] Fix README --- README.md | 27 ++++++++++----------------- 1 file changed, 10 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index f9ab3ad..a645dca 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,4 @@ ---- -title: Python Library Core ---- +# Python Library Core Tools to ease creating larger test libraries for [Robot Framework](http://robotframework.org) using Python. The Robot Framework @@ -21,32 +19,27 @@ The version 2.0 support changes in the Robot Framework 3.2. [![image](https://github.com/robotframework/PythonLibCore/workflows/CI/badge.svg?branch=master)](https://github.com/robotframework/PythonLibCore) -# Usage +## Usage There are two ways to use PythonLibCore, either by -[HybridCore]{.title-ref} or by using [DynamicCore]{.title-ref}. -[HybridCore]{.title-ref} provides support for the hybrid library API and -[DynamicCore]{.title-ref} provides support for dynamic library API. +`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]{.title-ref} or - [DynamicCore]{.title-ref}. +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]{.title-ref} argument in the - [HybridCore]{.title-ref} or [DynamicCore]{.title-ref} - [\_\_init\_\_]{.title-ref}. + `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]{.title-ref} as keywords. It is not -requires pass main library instance in the -[library_components]{.title-ref} argument. +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 @@ -150,6 +143,6 @@ class MyPlugi: Then Library can be imported in Robot Framework side like this: -``` bash +``` robotframework Library ${CURDIR}/PluginLib.py plugins=${CURDIR}/MyPlugin.py ``` From 169b4a69c72a52e6e2219de169f72144b0dfd421 Mon Sep 17 00:00:00 2001 From: Tatu Aalto Date: Tue, 19 Mar 2024 23:21:55 +0200 Subject: [PATCH 23/51] Fix badge --- README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index a645dca..4b98d9c 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,9 @@ and [WhiteLibrary](https://pypi.org/project/robotframework-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)](https://github.com/robotframework/PythonLibCore) +[![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 From 21d242480a0b35eb4a3aa9c23a294b120a605c8a Mon Sep 17 00:00:00 2001 From: Tatu Aalto Date: Tue, 19 Mar 2024 23:24:47 +0200 Subject: [PATCH 24/51] Remove rst version of README Also fix wording in README --- README.md | 6 +-- README.rst | 149 ----------------------------------------------------- 2 files changed, 3 insertions(+), 152 deletions(-) delete mode 100644 README.rst diff --git a/README.md b/README.md index 4b98d9c..8e0c52f 100644 --- a/README.md +++ b/README.md @@ -11,11 +11,11 @@ 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 +Code is stable and is already used by [SeleniumLibrary](https://github.com/robotframework/SeleniumLibrary/) and -[WhiteLibrary](https://pypi.org/project/robotframework-whitelibrary/). -The version 2.0 support changes in the Robot Framework 3.2. +[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) 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 From 081bc84301d25eb9e0736ac2e2a129496a07fd2f Mon Sep 17 00:00:00 2001 From: Tatu Aalto Date: Tue, 19 Mar 2024 23:33:54 +0200 Subject: [PATCH 25/51] Convert BUILD to markdown --- BUILD.md | 227 +++++++++++++++++++++++++++++++++++++++++++++++++++ BUILD.rst | 238 ------------------------------------------------------ 2 files changed, 227 insertions(+), 238 deletions(-) create mode 100644 BUILD.md delete mode 100644 BUILD.rst diff --git a/BUILD.md b/BUILD.md new file mode 100644 index 0000000..3939625 --- /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-build.txt](requirements-build.txt) file: + + pip install -r requirements-build.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.py](src/robotlibcore.py): + + 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](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.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. From 877f34b2f06e3fa49d7477dfae271d0b46147a05 Mon Sep 17 00:00:00 2001 From: Tatu Aalto Date: Thu, 21 Mar 2024 21:42:02 +0200 Subject: [PATCH 26/51] Make transltaiton more flaxible #139 --- atest/SmallLibrary.py | 26 ++++++++++++++++++++++---- atest/translation.json | 7 +++++++ src/robotlibcore.py | 6 ++++-- utest/test_translations.py | 22 ++++++++++++++++++++++ 4 files changed, 55 insertions(+), 6 deletions(-) diff --git a/atest/SmallLibrary.py b/atest/SmallLibrary.py index 55a9540..e576368 100644 --- a/atest/SmallLibrary.py +++ b/atest/SmallLibrary.py @@ -14,26 +14,44 @@ def __init__(self, translation: Optional[Path] = None): translation = Path(translation) logger.warn(translation.absolute()) logger.warn(type(translation)) - + 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 + + @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/translation.json b/atest/translation.json index 36795c5..dbdab73 100644 --- a/atest/translation.json +++ b/atest/translation.json @@ -14,5 +14,12 @@ "__intro__": { "name": "__intro__", "doc": "New __intro__ documentation is here." + }, + "doc_not_translated": { + "name": "this_is_replaced" + } + , + "kw_not_translated": { + "doc": "Here is new doc" } } diff --git a/src/robotlibcore.py b/src/robotlibcore.py index b42f8e6..6eec23d 100644 --- a/src/robotlibcore.py +++ b/src/robotlibcore.py @@ -84,7 +84,8 @@ def add_library_components(self, library_components: List, translation: Optional def __get_keyword_name(self, func: Callable, name: str, translation: dict): if name in translation: - return translation[name]["name"] + if new_name := translation[name].get("name"): + return new_name return func.robot_name or name def __replace_intro_doc(self, translation: dict): @@ -236,7 +237,8 @@ def build(cls, function, translation: Optional[dict] = None): @classmethod def get_doc(cls, function, translation: dict): if kw := cls._get_kw_transtation(function, translation): - return kw["doc"] + if "doc" in kw: + return kw["doc"] return inspect.getdoc(function) or "" @classmethod diff --git a/utest/test_translations.py b/utest/test_translations.py index a482a52..cff47c6 100644 --- a/utest/test_translations.py +++ b/utest/test_translations.py @@ -34,3 +34,25 @@ def test_init_and_lib_docs(lib: SmallLibrary): 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" From ebad43f8367cd683fee04399683615f003ed8226 Mon Sep 17 00:00:00 2001 From: Tatu Aalto Date: Thu, 21 Mar 2024 21:46:46 +0200 Subject: [PATCH 27/51] Lint fixes --- src/robotlibcore.py | 4 ++-- utest/test_translations.py | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/robotlibcore.py b/src/robotlibcore.py index 6eec23d..f0fe0b3 100644 --- a/src/robotlibcore.py +++ b/src/robotlibcore.py @@ -83,7 +83,7 @@ def add_library_components(self, library_components: List, translation: Optional self.attributes[name] = self.attributes[kw_name] = kw def __get_keyword_name(self, func: Callable, name: str, translation: dict): - if name in translation: + if name in translation: # noqa: SIM102 if new_name := translation[name].get("name"): return new_name return func.robot_name or name @@ -236,7 +236,7 @@ def build(cls, function, translation: Optional[dict] = None): @classmethod def get_doc(cls, function, translation: dict): - if kw := cls._get_kw_transtation(function, translation): + if kw := cls._get_kw_transtation(function, translation): # noqa: SIM102 if "doc" in kw: return kw["doc"] return inspect.getdoc(function) or "" diff --git a/utest/test_translations.py b/utest/test_translations.py index cff47c6..2d009b0 100644 --- a/utest/test_translations.py +++ b/utest/test_translations.py @@ -28,6 +28,7 @@ def test_translations_docs(lib: SmallLibrary): 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__"] From 5bfd92b41795ec1999dfc89b140c20c4e109a222 Mon Sep 17 00:00:00 2001 From: Tatu Aalto Date: Thu, 21 Mar 2024 22:00:28 +0200 Subject: [PATCH 28/51] Update docs #139 --- README.md | 84 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 84 insertions(+) diff --git a/README.md b/README.md index 8e0c52f..a88b802 100644 --- a/README.md +++ b/README.md @@ -148,3 +148,87 @@ 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.` From 1708927f9dfc61df8817308be30534e5b420d590 Mon Sep 17 00:00:00 2001 From: Tatu Aalto Date: Thu, 21 Mar 2024 22:02:11 +0200 Subject: [PATCH 29/51] Use RF 7 in CI --- .github/workflows/CI.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index f0c5860..2577265 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -15,7 +15,7 @@ jobs: strategy: matrix: python-version: [3.8, 3.11.1] - rf-version: [5.0.1, 6.1.0] + rf-version: [5.0.1, 7.0.0] steps: - uses: actions/checkout@v4 From b02ea0aac0334725a87d90acfe88521f17f746e6 Mon Sep 17 00:00:00 2001 From: Tatu Aalto Date: Fri, 22 Mar 2024 22:07:23 +0200 Subject: [PATCH 30/51] Release notes for 4.4.0 --- docs/PythonLibCore-4.4.0.rst | 74 ++++++++++++++++++++++++++++++++++++ 1 file changed, 74 insertions(+) create mode 100644 docs/PythonLibCore-4.4.0.rst 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 From 16a2a2a4dc218c485df0e14a39a5fb57775be722 Mon Sep 17 00:00:00 2001 From: Tatu Aalto Date: Fri, 22 Mar 2024 22:08:31 +0200 Subject: [PATCH 31/51] Updated version to 4.4.0 --- src/robotlibcore.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/robotlibcore.py b/src/robotlibcore.py index f0fe0b3..47668bd 100644 --- a/src/robotlibcore.py +++ b/src/robotlibcore.py @@ -30,7 +30,7 @@ from robot.errors import DataError from robot.utils import Importer -__version__ = "4.3.0" +__version__ = "4.4.0" class PythonLibCoreException(Exception): # noqa: N818 From 8b756a4bd119d660109437023789bfada21bdc78 Mon Sep 17 00:00:00 2001 From: Tatu Aalto Date: Fri, 22 Mar 2024 22:13:45 +0200 Subject: [PATCH 32/51] Fix setup.py because of README format change --- setup.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/setup.py b/setup.py index d0fb2d4..c92d9e4 100644 --- a/setup.py +++ b/setup.py @@ -23,7 +23,7 @@ """.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: +with open(join(CURDIR, 'README.md')) as f: LONG_DESCRIPTION = f.read() DESCRIPTION = ('Tools to ease creating larger test libraries for ' @@ -37,6 +37,7 @@ 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, From b7be0d94ef28772c90e477740720e2b134fd96c4 Mon Sep 17 00:00:00 2001 From: Tatu Aalto Date: Sat, 6 Apr 2024 01:09:46 +0300 Subject: [PATCH 33/51] fix leaking keywords names Fixes #146 --- atest/SmallLibrary.py | 14 +++++++++----- atest/translation.json | 4 ++++ src/robotlibcore.py | 28 ++++++++++++++++++++-------- utest/test_translations.py | 8 ++++++++ 4 files changed, 41 insertions(+), 13 deletions(-) diff --git a/atest/SmallLibrary.py b/atest/SmallLibrary.py index e576368..3a93661 100644 --- a/atest/SmallLibrary.py +++ b/atest/SmallLibrary.py @@ -4,6 +4,13 @@ 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.""" @@ -12,10 +19,7 @@ def __init__(self, translation: Optional[Path] = None): if not isinstance(translation, Path): logger.warn("Convert to Path") translation = Path(translation) - logger.warn(translation.absolute()) - logger.warn(type(translation)) - - DynamicCore.__init__(self, [], translation.absolute()) + DynamicCore.__init__(self, [KeywordClass()], translation.absolute()) @keyword(tags=["tag1", "tag2"]) def normal_keyword(self, arg: int, other: str) -> str: @@ -32,7 +36,7 @@ def not_keyword(self, data: str) -> str: print(data) return data - @keyword(name="This Is New Name", tags=["tag1", "tag2"]) + @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)}") diff --git a/atest/translation.json b/atest/translation.json index dbdab73..a3b2585 100644 --- a/atest/translation.json +++ b/atest/translation.json @@ -21,5 +21,9 @@ , "kw_not_translated": { "doc": "Here is new doc" + }, + "execute_something": { + "name": "tee_jotain", + "doc": "Uusi kirja." } } diff --git a/src/robotlibcore.py b/src/robotlibcore.py index 47668bd..e652daf 100644 --- a/src/robotlibcore.py +++ b/src/robotlibcore.py @@ -57,35 +57,47 @@ def _translation(translation: Optional[Path] = None): return {} +def _translated_keywords(translation_data: dict) -> list: + return [item.get("name") for item in translation_data.values() if item.get("name")] + + class HybridCore: def __init__(self, library_components: List, translation: Optional[Path] = None) -> None: self.keywords = {} self.keywords_spec = {} self.attributes = {} translation_data = _translation(translation) - self.add_library_components(library_components, translation_data) - self.add_library_components([self], translation_data) + 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): + 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) + 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): - if name in translation: # noqa: SIM102 - if new_name := translation[name].get("name"): - return new_name + 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): diff --git a/utest/test_translations.py b/utest/test_translations.py index 2d009b0..b9b9e3b 100644 --- a/utest/test_translations.py +++ b/utest/test_translations.py @@ -57,3 +57,11 @@ def test_kw_not_translated_but_doc_is(lib: SmallLibrary): 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}" From 134ca05105f7e610365d6ea82f7ed918aa8e99e1 Mon Sep 17 00:00:00 2001 From: Tatu Aalto Date: Sat, 6 Apr 2024 01:25:16 +0300 Subject: [PATCH 34/51] Release notes for 4.4.1 --- docs/PythonLibCore-4.4.1.rst | 70 ++++++++++++++++++++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100644 docs/PythonLibCore-4.4.1.rst 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 From b1fb3d67ad934263e7bd1ec8acc3ec06127add7d Mon Sep 17 00:00:00 2001 From: Tatu Aalto Date: Sat, 6 Apr 2024 01:26:32 +0300 Subject: [PATCH 35/51] Updated version to 4.4.1 --- src/robotlibcore.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/robotlibcore.py b/src/robotlibcore.py index e652daf..0c9cab1 100644 --- a/src/robotlibcore.py +++ b/src/robotlibcore.py @@ -30,7 +30,7 @@ from robot.errors import DataError from robot.utils import Importer -__version__ = "4.4.0" +__version__ = "4.4.1" class PythonLibCoreException(Exception): # noqa: N818 From d5123c73c1c8d34f87226a99f66e1faee9d99f91 Mon Sep 17 00:00:00 2001 From: Jani Mikkonen Date: Thu, 16 May 2024 00:12:21 +0300 Subject: [PATCH 36/51] Fix documentation for building the docs BUILD.md mentioned requirements-build.txt only req file was called requirements-dev.txt --- BUILD.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/BUILD.md b/BUILD.md index 3939625..e94bc9e 100644 --- a/BUILD.md +++ b/BUILD.md @@ -27,9 +27,9 @@ 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-build.txt](requirements-build.txt) file: +[requirements-dev.txt](requirements-dev.txt) file: - pip install -r requirements-build.txt + pip install -r requirements-dev.txt ## Using Invoke From c035a37c05de8e3e7c25e9fd415b439a8b1828ca Mon Sep 17 00:00:00 2001 From: Jani Mikkonen Date: Thu, 16 May 2024 00:21:44 +0300 Subject: [PATCH 37/51] Restructure robotlibcore into smalle chunks pr splits robotlibcore.py into smaller source files to rework packaging to be placed into directory inside site-packages instead of single file into root of site-packages Fixes #149 --- BUILD.md | 6 +- setup.py | 17 +- src/robotlibcore.py | 429 --------------------- src/robotlibcore/__init__.py | 42 ++ src/robotlibcore/core/__init__.py | 19 + src/robotlibcore/core/dynamic.py | 88 +++++ src/robotlibcore/core/hybrid.py | 121 ++++++ src/robotlibcore/keywords/__init__.py | 19 + src/robotlibcore/keywords/builder.py | 149 +++++++ src/robotlibcore/keywords/specification.py | 25 ++ src/robotlibcore/plugin/__init__.py | 17 + src/robotlibcore/plugin/parser.py | 73 ++++ src/robotlibcore/utils/__init__.py | 28 ++ src/robotlibcore/utils/exceptions.py | 25 ++ src/robotlibcore/utils/translations.py | 36 ++ tasks.py | 2 +- 16 files changed, 655 insertions(+), 441 deletions(-) delete mode 100644 src/robotlibcore.py create mode 100644 src/robotlibcore/__init__.py create mode 100644 src/robotlibcore/core/__init__.py create mode 100644 src/robotlibcore/core/dynamic.py create mode 100644 src/robotlibcore/core/hybrid.py create mode 100644 src/robotlibcore/keywords/__init__.py create mode 100644 src/robotlibcore/keywords/builder.py create mode 100644 src/robotlibcore/keywords/specification.py create mode 100644 src/robotlibcore/plugin/__init__.py create mode 100644 src/robotlibcore/plugin/parser.py create mode 100644 src/robotlibcore/utils/__init__.py create mode 100644 src/robotlibcore/utils/exceptions.py create mode 100644 src/robotlibcore/utils/translations.py diff --git a/BUILD.md b/BUILD.md index e94bc9e..89daf8c 100644 --- a/BUILD.md +++ b/BUILD.md @@ -130,13 +130,13 @@ respectively. # Set version 1. Set version information in - [src/robotlibcore.py](src/robotlibcore.py): + [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.py + git commit -m "Updated version to $VERSION" src/robotlibcore/__init__.py git push # Tagging @@ -192,7 +192,7 @@ respectively. 2. Set dev version based on the previous version: invoke set-version dev - git commit -m "Back to dev version" src/robotlibcore.py + 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 diff --git a/setup.py b/setup.py index c92d9e4..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.md')) 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.') @@ -43,6 +45,5 @@ 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 0c9cab1..0000000 --- a/src/robotlibcore.py +++ /dev/null @@ -1,429 +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 json -import os -from dataclasses import dataclass -from pathlib import Path -from typing import Any, Callable, List, Optional, Union, get_type_hints - -from robot.api import logger -from robot.api.deco import keyword # noqa: F401 -from robot.errors import DataError -from robot.utils import Importer - -__version__ = "4.4.1" - - -class PythonLibCoreException(Exception): # noqa: N818 - pass - - -class PluginError(PythonLibCoreException): - pass - - -class NoKeywordFound(PythonLibCoreException): - pass - - -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")] - - -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__: - 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, 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) - - -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..f70f659 --- /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__: + 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 3e98212..8d85add 100644 --- a/tasks.py +++ b/tasks.py @@ -10,7 +10,7 @@ REPOSITORY = "robotframework/PythonLibCore" -VERSION_PATH = Path("src/robotlibcore.py") +VERSION_PATH = Path("src/robotlibcore/__init__.py") RELEASE_NOTES_PATH = Path("docs/PythonLibCore-{version}.rst") RELEASE_NOTES_TITLE = "Python Library Core {version}" RELEASE_NOTES_INTRO = """ From b2c4e72c4dbcfee0eab8cfe4a64a71f2db0eb295 Mon Sep 17 00:00:00 2001 From: Jani Mikkonen Date: Fri, 17 May 2024 10:52:15 +0300 Subject: [PATCH 38/51] Fix set-version task with correct quotes used code Version pattern now uses double quote instead of single quote. Fixes #152 --- tasks.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tasks.py b/tasks.py index 8d85add..0db4d90 100644 --- a/tasks.py +++ b/tasks.py @@ -11,6 +11,7 @@ REPOSITORY = "robotframework/PythonLibCore" 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) From f5f07fde5a050413639ab45d09f523fd2b758be4 Mon Sep 17 00:00:00 2001 From: Tatu Aalto Date: Fri, 2 Aug 2024 11:55:25 +0300 Subject: [PATCH 39/51] Ruff update and lint fixes --- requirements-dev.txt | 2 +- src/robotlibcore/core/hybrid.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index 7d36f77..4858cc4 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -3,7 +3,7 @@ pytest-cov pytest-mockito robotstatuschecker black >= 23.7.0 -ruff >= 0.0.286 +ruff >= 0.5.5 robotframework-tidy invoke >= 2.2.0 twine diff --git a/src/robotlibcore/core/hybrid.py b/src/robotlibcore/core/hybrid.py index f70f659..cb0cc6c 100644 --- a/src/robotlibcore/core/hybrid.py +++ b/src/robotlibcore/core/hybrid.py @@ -88,7 +88,7 @@ def __get_members(self, component): raise TypeError( msg, ) - if type(component) != component.__class__: + if type(component) is component.__class__: msg = ( "Libraries must be modules or new-style class instances, " f"got old-style class {component.__class__.__name__} instead." From bbba333e30159b5bc9f2a08020e725616067b27e Mon Sep 17 00:00:00 2001 From: Tatu Aalto Date: Fri, 2 Aug 2024 11:52:29 +0300 Subject: [PATCH 40/51] Use uv to install deps --- .github/workflows/CI.yml | 9 +++++---- requirements-dev.txt | 1 + 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index 2577265..11f54d7 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -14,8 +14,8 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: [3.8, 3.11.1] - rf-version: [5.0.1, 7.0.0] + python-version: [3.8, 3.12] + rf-version: [5.0.1, 7.0.1] steps: - uses: actions/checkout@v4 @@ -26,10 +26,11 @@ jobs: - 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 diff --git a/requirements-dev.txt b/requirements-dev.txt index 4858cc4..fe02ea1 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,3 +1,4 @@ +uv pytest pytest-cov pytest-mockito From fbe96f137e5787f4e39406d7c897c7168ecd2883 Mon Sep 17 00:00:00 2001 From: Tatu Aalto Date: Fri, 2 Aug 2024 11:59:01 +0300 Subject: [PATCH 41/51] Add ignore --- src/robotlibcore/core/hybrid.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/robotlibcore/core/hybrid.py b/src/robotlibcore/core/hybrid.py index cb0cc6c..2caa8b2 100644 --- a/src/robotlibcore/core/hybrid.py +++ b/src/robotlibcore/core/hybrid.py @@ -88,7 +88,7 @@ def __get_members(self, component): raise TypeError( msg, ) - if type(component) is component.__class__: + 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." From 6ba85665b302d6bc5e361009c70eec2a9e92eea2 Mon Sep 17 00:00:00 2001 From: Tatu Aalto Date: Fri, 2 Aug 2024 12:03:00 +0300 Subject: [PATCH 42/51] Fix atest --- atest/tests.robot | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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.* From ac375752c80a95b28d13e4c744a381f4dfbb4862 Mon Sep 17 00:00:00 2001 From: Tatu Aalto Date: Sat, 7 Sep 2024 00:54:40 +0300 Subject: [PATCH 43/51] Liting with ruff --- tasks.py | 2 +- utest/test_keyword_builder.py | 1 + utest/test_plugin_api.py | 1 + utest/test_robotlibcore.py | 1 + 4 files changed, 4 insertions(+), 1 deletion(-) diff --git a/tasks.py b/tasks.py index 0db4d90..90ebdf3 100644 --- a/tasks.py +++ b/tasks.py @@ -166,5 +166,5 @@ def utest(ctx): @task(utest, atest) -def test(ctx): # noqa: ARG001 +def test(ctx): pass diff --git a/utest/test_keyword_builder.py b/utest/test_keyword_builder.py index 4222aea..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 diff --git a/utest/test_plugin_api.py b/utest/test_plugin_api.py index 67226d6..9209d8b 100644 --- a/utest/test_plugin_api.py +++ b/utest/test_plugin_api.py @@ -1,5 +1,6 @@ import pytest from helpers import my_plugin_test + from robotlibcore import Module, PluginError, PluginParser diff --git a/utest/test_robotlibcore.py b/utest/test_robotlibcore.py index 52689ad..b2497aa 100644 --- a/utest/test_robotlibcore.py +++ b/utest/test_robotlibcore.py @@ -5,6 +5,7 @@ from DynamicLibrary import DynamicLibrary from DynamicTypesAnnotationsLibrary import DynamicTypesAnnotationsLibrary from HybridLibrary import HybridLibrary + from robotlibcore import HybridCore, NoKeywordFound From 90a62a07cc03c6760b68a5c797c2572c13a757b9 Mon Sep 17 00:00:00 2001 From: Alpha_Centauri Date: Thu, 6 Mar 2025 11:45:32 +0100 Subject: [PATCH 44/51] add pip install command to readme file --- README.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/README.md b/README.md index a88b802..af276a7 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,13 @@ 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 From baa6bef13e64f64bec2ca1523570f3475312cf89 Mon Sep 17 00:00:00 2001 From: joerendleman Date: Fri, 2 Jan 2026 16:20:59 -0600 Subject: [PATCH 45/51] add a safety check to __getattr__ to avoid unwanted recursion --- src/robotlibcore/core/hybrid.py | 2 ++ utest/test_robotlibcore.py | 10 ++++++++++ 2 files changed, 12 insertions(+) diff --git a/src/robotlibcore/core/hybrid.py b/src/robotlibcore/core/hybrid.py index 2caa8b2..15e8dad 100644 --- a/src/robotlibcore/core/hybrid.py +++ b/src/robotlibcore/core/hybrid.py @@ -106,6 +106,8 @@ def __get_members_from_instance(self, instance): yield name, getattr(owner, name) def __getattr__(self, name): + if name == "attributes": + return super().__getattribute__(name) if name in self.attributes: return self.attributes[name] msg = "{!r} object has no attribute {!r}".format(type(self).__name__, name) diff --git a/utest/test_robotlibcore.py b/utest/test_robotlibcore.py index b2497aa..b81d982 100644 --- a/utest/test_robotlibcore.py +++ b/utest/test_robotlibcore.py @@ -104,3 +104,13 @@ def test_library_cannot_be_class(): with pytest.raises(TypeError) as exc_info: HybridCore([HybridLibrary]) assert str(exc_info.value) == "Libraries must be modules or instances, got class 'HybridLibrary' instead." + +def test_get_library_attr(): + class TestClass(HybridCore): + def __init__(self): + self.a = self.b *2 + super().__init__() + + with pytest.raises(AttributeError): + TestClass() + From eff0f2215ce0e726f4c8125fc626e182201f95e0 Mon Sep 17 00:00:00 2001 From: Tatu Aalto Date: Fri, 16 Jan 2026 15:23:29 +0200 Subject: [PATCH 46/51] feat: lint fixes --- src/robotlibcore/__init__.py | 6 +++--- src/robotlibcore/utils/__init__.py | 2 +- utest/test_robotlibcore.py | 5 ++--- 3 files changed, 6 insertions(+), 7 deletions(-) diff --git a/src/robotlibcore/__init__.py b/src/robotlibcore/__init__.py index 3286c2d..c0b88a0 100644 --- a/src/robotlibcore/__init__.py +++ b/src/robotlibcore/__init__.py @@ -33,10 +33,10 @@ "HybridCore", "KeywordBuilder", "KeywordSpecification", - "PluginParser", - "keyword", + "Module", "NoKeywordFound", "PluginError", + "PluginParser", "PythonLibCoreException", - "Module", + "keyword", ] diff --git a/src/robotlibcore/utils/__init__.py b/src/robotlibcore/utils/__init__.py index 609b6b4..697e8a4 100644 --- a/src/robotlibcore/utils/__init__.py +++ b/src/robotlibcore/utils/__init__.py @@ -25,4 +25,4 @@ class Module: kw_args: dict -__all__ = ["Module", "NoKeywordFound", "PluginError", "PythonLibCoreException", "_translation", "_translated_keywords"] +__all__ = ["Module", "NoKeywordFound", "PluginError", "PythonLibCoreException", "_translated_keywords", "_translation"] diff --git a/utest/test_robotlibcore.py b/utest/test_robotlibcore.py index b81d982..769f3be 100644 --- a/utest/test_robotlibcore.py +++ b/utest/test_robotlibcore.py @@ -105,12 +105,11 @@ def test_library_cannot_be_class(): HybridCore([HybridLibrary]) assert str(exc_info.value) == "Libraries must be modules or instances, got class 'HybridLibrary' instead." + def test_get_library_attr(): class TestClass(HybridCore): def __init__(self): - self.a = self.b *2 - super().__init__() + self.a = self.b * 2 with pytest.raises(AttributeError): TestClass() - From ba598abb4192677bf9f81ba12ecc3044be03c5da Mon Sep 17 00:00:00 2001 From: Tatu Aalto Date: Fri, 16 Jan 2026 15:28:16 +0200 Subject: [PATCH 47/51] ci: robotstatuschecker api change fix --- atest/run.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/atest/run.py b/atest/run.py index 8491ca2..feb05f6 100755 --- a/atest/run.py +++ b/atest/run.py @@ -35,7 +35,7 @@ ) if rc > 250: sys.exit(rc) - process_output(output, verbose=False) + process_output(output) output = join( outdir, "lib-DynamicTypesLibrary-python-{}-robot-{}.xml".format(python_version, RF_VERSION), @@ -52,12 +52,12 @@ ) if rc > 250: sys.exit(rc) -process_output(output, verbose=False) +process_output(output) output = join(outdir, "lib-PluginApi-python-{}-robot-{}.xml".format(python_version, RF_VERSION)) rc = run(plugin_api, name="Plugin", output=output, report=None, log=None, loglevel="debug") if rc > 250: sys.exit(rc) -process_output(output, verbose=False) +process_output(output) print("\nCombining results.") library_variants.append("DynamicTypesLibrary") xml_files = [str(xml_file) for xml_file in Path(outdir).glob("*.xml")] From c537a241cda57e7abfeb4074810338e005b659cf Mon Sep 17 00:00:00 2001 From: Tatu Aalto Date: Fri, 16 Jan 2026 15:29:45 +0200 Subject: [PATCH 48/51] ci: bump Python and RF versions --- .github/workflows/CI.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index 11f54d7..72b56e4 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -14,8 +14,8 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: [3.8, 3.12] - rf-version: [5.0.1, 7.0.1] + python-version: [3.10.19, 3.14] + rf-version: [6.1.1, 7.4.1] steps: - uses: actions/checkout@v4 From 42a30c101a5111c5d2c3e32f9d444eea03781ed4 Mon Sep 17 00:00:00 2001 From: Tatu Aalto Date: Fri, 16 Jan 2026 15:41:28 +0200 Subject: [PATCH 49/51] doc: fixed build command --- BUILD.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/BUILD.md b/BUILD.md index 89daf8c..24dd14e 100644 --- a/BUILD.md +++ b/BUILD.md @@ -166,7 +166,7 @@ respectively. 3. Create source distribution and universal (i.e. Python 2 and 3 compatible) [wheel](http://pythonwheels.com): - python setup.py sdist bdist_wheel --universal + uv build ls -l dist Distributions can be tested locally if needed. From e457248e5be5f92a7ce9ee363d5db8d9cf5b4f62 Mon Sep 17 00:00:00 2001 From: Tatu Aalto Date: Fri, 16 Jan 2026 16:39:34 +0200 Subject: [PATCH 50/51] Release notes for 4.5.0 --- docs/PythonLibCore-4.5.0.rst | 65 ++++++++++++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100644 docs/PythonLibCore-4.5.0.rst diff --git a/docs/PythonLibCore-4.5.0.rst b/docs/PythonLibCore-4.5.0.rst new file mode 100644 index 0000000..f20bb7b --- /dev/null +++ b/docs/PythonLibCore-4.5.0.rst @@ -0,0 +1,65 @@ +# Python Library Core 4.5.0 + + +`Python Library Core`_ is a generic component making it easier to create +bigger `Robot Framework`_ test libraries. Python Library Core 4.5.0 is +a new release with to refactor internal structure and bug fix to avoid +maximum recursion depth exceeded error on getattr. + +All issues targeted for Python Library Core v4.5.0 can be found +from the `issue tracker`_. + +If you have pip_ installed, just run + +:: + + pip install --upgrade robotframework-pythonlibcore + +to install the latest available release or use + +:: + + pip install pip install robotframework-pythonlibcore==4.5.0 + +to install exactly this version. Alternatively you can download the source +distribution from PyPI_ and install it manually. + +Python Library Core 4.5.0 was released on Friday January 16, 2026. +It support Python versions 3.10+ and Robot Framework versions 6.1 and newer. + +.. _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.5.0 + + +## Most important enhancements + +Maximum Recursion Depth Exceeded on getattr ([#158](https://github.com/robotframework/PythonLibCore/issues/158)) +---------------------------------------------------------------------------------------------------------------- +If one attempts to get any attribute on a child class of HybridCore or DynamicCore before the initializer is called +(so the attributes object has yet to be initialized) the custom getattr implementation on the class causes an +infinite recursion. getattr is called on the attribute of the class then HybridCore attempts to evaluate if name +in self.attributes: but attributes is undef because since attributes is undef, python calls getattr on self.attributes +then HybridCore attempts to evaluate if name in self.attributes: but attributes is undef and so on. + +HybridCore should fall back to the standard implementation of getattr if self.attributes is undefined which would avoid this issue. + +Many thanks to Joe Rendleman for reporting this issue and providing a PR with a fix. + +robotlibcore.py placed right into root of site-packages/ ([#149](https://github.com/robotframework/PythonLibCore/issues/149)) +------------------------------------------------------------------------------------------------------------------------------ +To improve compatibility with various tools and IDEs, robotlibcore.py is now in own folder and package is refactored +logical modules. This change should be transparent to end users as the package structure is unchanged. + +many thanks to Jani Mikkonen to reporting this issue and providing a PR with a fix. + +## Full list of fixes and enhancements + +| ID | Type | Priority | Summary | +|---|---|---|---| +| [#158](https://github.com/robotframework/PythonLibCore/issues/158) | bug | high | Maximum Recursion Depth Exceeded on getattr | +| [#149](https://github.com/robotframework/PythonLibCore/issues/149) | enhancement | high | robotlibcore.py placed right into root of site-packages/ | + +Altogether 2 issues. View on the [issue tracker](https://github.com/robotframework/PythonLibCore/issues?q=milestone%3Av4.5.0). From 6bda05de1d1c1dd96e9abd6ff1dd39522d310334 Mon Sep 17 00:00:00 2001 From: Tatu Aalto Date: Fri, 16 Jan 2026 16:39:50 +0200 Subject: [PATCH 51/51] Updated version to 4.5.0 --- src/robotlibcore/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/robotlibcore/__init__.py b/src/robotlibcore/__init__.py index c0b88a0..a800bfa 100644 --- a/src/robotlibcore/__init__.py +++ b/src/robotlibcore/__init__.py @@ -26,7 +26,7 @@ from robotlibcore.plugin import PluginParser from robotlibcore.utils import Module, NoKeywordFound, PluginError, PythonLibCoreException -__version__ = "4.4.1" +__version__ = "4.5.0" __all__ = [ "DynamicCore",