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/BUILD.md b/BUILD.md index 3939625..89daf8c 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 @@ -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/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 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/tests.robot b/atest/tests.robot index 3c66808..a12b35f 100644 --- a/atest/tests.robot +++ b/atest/tests.robot @@ -14,7 +14,7 @@ Keyword Names Method Custom Name Cust Omna Me - IF $LIBRARY == "ExtendExistingLibrary" Keyword In Extending Library + IF "$LIBRARY" == "ExtendExistingLibrary" Keyword In Extending Library Method Without @keyword Are Not Keyowrds [Documentation] FAIL GLOB: No keyword with name 'Not Keyword' found.* diff --git a/atest/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/docs/PythonLibCore-4.4.1.rst b/docs/PythonLibCore-4.4.1.rst new file mode 100644 index 0000000..2f34057 --- /dev/null +++ b/docs/PythonLibCore-4.4.1.rst @@ -0,0 +1,70 @@ +========================= +Python Library Core 4.4.1 +========================= + + +.. default-role:: code + + +`Python Library Core`_ is a generic component making it easier to create +bigger `Robot Framework`_ test libraries. Python Library Core 4.4.1 is +a new release with a bug fix to not leak keywords names if @keyword +decorator defines custom name. + +All issues targeted for Python Library Core v4.4.1 can be found +from the `issue tracker`_. + +If you have pip_ installed, just run + +:: + + pip install --pre --upgrade pip install robotframework-pythonlibcore + +to install the latest available release or use + +:: + + pip install pip install robotframework-pythonlibcore==4.4.1 + +to install exactly this version. Alternatively you can download the source +distribution from PyPI_ and install it manually. + +Python Library Core 4.4.1 was released on Saturday April 6, 2024. + +.. _PythonLibCore: https://github.com/robotframework/PythonLibCore +.. _Robot Framework: http://robotframework.org +.. _pip: http://pip-installer.org +.. _PyPI: https://pypi.python.org/pypi/robotframework-robotlibcore +.. _issue tracker: https://github.com/robotframework/PythonLibCore/issues?q=milestone%3Av4.4.1 + + +.. contents:: + :depth: 2 + :local: + +Most important enhancements +=========================== + +If @keyword deco has custom name, original name leaks to keywords (`#146`_) +--------------------------------------------------------------------------- +If @keyword deco has custom name, then original and not translated method name +leaks to keywords. This issue is now fixed. + +Full list of fixes and enhancements +=================================== + +.. list-table:: + :header-rows: 1 + + * - ID + - Type + - Priority + - Summary + * - `#146`_ + - bug + - critical + - If @keyword deco has custom name, original name leaks to keywords + +Altogether 1 issue. View on the `issue tracker `__. + +.. _#146: https://github.com/robotframework/PythonLibCore/issues/146 diff --git a/requirements-dev.txt b/requirements-dev.txt index 7d36f77..fe02ea1 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,9 +1,10 @@ +uv pytest pytest-cov pytest-mockito robotstatuschecker black >= 23.7.0 -ruff >= 0.0.286 +ruff >= 0.5.5 robotframework-tidy invoke >= 2.2.0 twine diff --git a/setup.py b/setup.py index d0fb2d4..44f2e79 100644 --- a/setup.py +++ b/setup.py @@ -1,10 +1,11 @@ #!/usr/bin/env python import re -from os.path import abspath, dirname, join +from pathlib import Path +from os.path import join from setuptools import find_packages, setup -CURDIR = dirname(abspath(__file__)) +CURDIR = Path(__file__).parent CLASSIFIERS = """ Development Status :: 5 - Production/Stable @@ -21,10 +22,11 @@ Topic :: Software Development :: Testing Framework :: Robot Framework """.strip().splitlines() -with open(join(CURDIR, 'src', 'robotlibcore.py')) as f: - VERSION = re.search('\n__version__ = "(.*)"', f.read()).group(1) -with open(join(CURDIR, 'README.rst')) as f: - LONG_DESCRIPTION = f.read() + +version_file = Path(CURDIR / 'src' / 'robotlibcore' / '__init__.py') +VERSION = re.search('\n__version__ = "(.*)"', version_file.read_text()).group(1) + +LONG_DESCRIPTION = Path(CURDIR / 'README.md').read_text() DESCRIPTION = ('Tools to ease creating larger test libraries for ' 'Robot Framework using Python.') @@ -37,11 +39,11 @@ license = 'Apache License 2.0', description = DESCRIPTION, long_description = LONG_DESCRIPTION, + long_description_content_type = "text/markdown", keywords = 'robotframework testing testautomation library development', platforms = 'any', classifiers = CLASSIFIERS, python_requires = '>=3.8, <4', package_dir = {'': 'src'}, - packages = find_packages('src'), - py_modules = ['robotlibcore'], + packages = ["robotlibcore","robotlibcore.core", "robotlibcore.keywords", "robotlibcore.plugin", "robotlibcore.utils"] ) diff --git a/src/robotlibcore.py b/src/robotlibcore.py deleted file mode 100644 index 47668bd..0000000 --- a/src/robotlibcore.py +++ /dev/null @@ -1,417 +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.0" - - -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 {} - - -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) - self.__set_library_listeners(library_components) - - 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"): - kw = getattr(component, name) - kw_name = self.__get_keyword_name(func, name, translation) - 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 - 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..2caa8b2 --- /dev/null +++ b/src/robotlibcore/core/hybrid.py @@ -0,0 +1,121 @@ +# Copyright 2017- Robot Framework Foundation +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +import inspect +from pathlib import Path +from typing import Callable, List, Optional + +from robotlibcore.keywords import KeywordBuilder +from robotlibcore.utils import _translated_keywords, _translation + + +class HybridCore: + def __init__(self, library_components: List, translation: Optional[Path] = None) -> None: + self.keywords = {} + self.keywords_spec = {} + self.attributes = {} + translation_data = _translation(translation) + translated_kw_names = _translated_keywords(translation_data) + self.add_library_components(library_components, translation_data, translated_kw_names) + self.add_library_components([self], translation_data, translated_kw_names) + self.__set_library_listeners(library_components) + + def add_library_components( + self, + library_components: List, + translation: Optional[dict] = None, + translated_kw_names: Optional[list] = None, + ): + translation = translation if translation else {} + translated_kw_names = translated_kw_names if translated_kw_names else [] + self.keywords_spec["__init__"] = KeywordBuilder.build(self.__init__, translation) # type: ignore + self.__replace_intro_doc(translation) + for component in library_components: + for name, func in self.__get_members(component): + if callable(func) and hasattr(func, "robot_name"): + kw = getattr(component, name) + kw_name = self.__get_keyword_name(func, name, translation, translated_kw_names) + self.keywords[kw_name] = kw + self.keywords_spec[kw_name] = KeywordBuilder.build(kw, translation) + # Expose keywords as attributes both using original + # method names as well as possible custom names. + self.attributes[name] = self.attributes[kw_name] = kw + + def __get_keyword_name(self, func: Callable, name: str, translation: dict, translated_kw_names: list): + if name in translated_kw_names: + return name + if name in translation and translation[name].get("name"): + return translation[name].get("name") + return func.robot_name or name + + def __replace_intro_doc(self, translation: dict): + if "__intro__" in translation: + self.__doc__ = translation["__intro__"].get("doc", "") + + def __set_library_listeners(self, library_components: list): + listeners = self.__get_manually_registered_listeners() + listeners.extend(self.__get_component_listeners([self, *library_components])) + if listeners: + self.ROBOT_LIBRARY_LISTENER = list(dict.fromkeys(listeners)) + + def __get_manually_registered_listeners(self) -> list: + manually_registered_listener = getattr(self, "ROBOT_LIBRARY_LISTENER", []) + try: + return [*manually_registered_listener] + except TypeError: + return [manually_registered_listener] + + def __get_component_listeners(self, library_listeners: list) -> list: + return [component for component in library_listeners if hasattr(component, "ROBOT_LISTENER_API_VERSION")] + + def __get_members(self, component): + if inspect.ismodule(component): + return inspect.getmembers(component) + if inspect.isclass(component): + msg = f"Libraries must be modules or instances, got class '{component.__name__}' instead." + raise TypeError( + msg, + ) + if type(component) != component.__class__: # noqa: E721 + msg = ( + "Libraries must be modules or new-style class instances, " + f"got old-style class {component.__class__.__name__} instead." + ) + raise TypeError( + msg, + ) + return self.__get_members_from_instance(component) + + def __get_members_from_instance(self, instance): + # Avoid calling properties by getting members from class, not instance. + cls = type(instance) + for name in dir(instance): + owner = cls if hasattr(cls, name) else instance + yield name, getattr(owner, name) + + def __getattr__(self, name): + if name in self.attributes: + return self.attributes[name] + msg = "{!r} object has no attribute {!r}".format(type(self).__name__, name) + raise AttributeError( + msg, + ) + + def __dir__(self): + my_attrs = super().__dir__() + return sorted(set(my_attrs) | set(self.attributes)) + + def get_keyword_names(self): + return sorted(self.keywords) diff --git a/src/robotlibcore/keywords/__init__.py b/src/robotlibcore/keywords/__init__.py new file mode 100644 index 0000000..6febe2c --- /dev/null +++ b/src/robotlibcore/keywords/__init__.py @@ -0,0 +1,19 @@ +# Copyright 2017- Robot Framework Foundation +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +from .builder import KeywordBuilder +from .specification import KeywordSpecification + +__all__ = ["KeywordBuilder", "KeywordSpecification"] diff --git a/src/robotlibcore/keywords/builder.py b/src/robotlibcore/keywords/builder.py new file mode 100644 index 0000000..d81c677 --- /dev/null +++ b/src/robotlibcore/keywords/builder.py @@ -0,0 +1,149 @@ +# Copyright 2017- Robot Framework Foundation +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +import inspect +from typing import Callable, Optional, get_type_hints + +from .specification import KeywordSpecification + + +class KeywordBuilder: + @classmethod + def build(cls, function, translation: Optional[dict] = None): + translation = translation if translation else {} + return KeywordSpecification( + argument_specification=cls._get_arguments(function), + documentation=cls.get_doc(function, translation), + argument_types=cls._get_types(function), + ) + + @classmethod + def get_doc(cls, function, translation: dict): + if kw := cls._get_kw_transtation(function, translation): # noqa: SIM102 + if "doc" in kw: + return kw["doc"] + return inspect.getdoc(function) or "" + + @classmethod + def _get_kw_transtation(cls, function, translation: dict): + return translation.get(function.__name__, {}) + + @classmethod + def unwrap(cls, function): + return inspect.unwrap(function) + + @classmethod + def _get_arguments(cls, function): + unwrap_function = cls.unwrap(function) + arg_spec = cls._get_arg_spec(unwrap_function) + argument_specification = cls._get_args(arg_spec, function) + argument_specification.extend(cls._get_varargs(arg_spec)) + argument_specification.extend(cls._get_named_only_args(arg_spec)) + argument_specification.extend(cls._get_kwargs(arg_spec)) + return argument_specification + + @classmethod + def _get_arg_spec(cls, function: Callable) -> inspect.FullArgSpec: + return inspect.getfullargspec(function) + + @classmethod + def _get_type_hint(cls, function: Callable): + try: + hints = get_type_hints(function) + except Exception: # noqa: BLE001 + hints = function.__annotations__ + return hints + + @classmethod + def _get_args(cls, arg_spec: inspect.FullArgSpec, function: Callable) -> list: + args = cls._drop_self_from_args(function, arg_spec) + args.reverse() + defaults = list(arg_spec.defaults) if arg_spec.defaults else [] + formated_args = [] + for arg in args: + if defaults: + formated_args.append((arg, defaults.pop())) + else: + formated_args.append(arg) + formated_args.reverse() + return formated_args + + @classmethod + def _drop_self_from_args( + cls, + function: Callable, + arg_spec: inspect.FullArgSpec, + ) -> list: + return arg_spec.args[1:] if inspect.ismethod(function) else arg_spec.args + + @classmethod + def _get_varargs(cls, arg_spec: inspect.FullArgSpec) -> list: + return [f"*{arg_spec.varargs}"] if arg_spec.varargs else [] + + @classmethod + def _get_kwargs(cls, arg_spec: inspect.FullArgSpec) -> list: + return [f"**{arg_spec.varkw}"] if arg_spec.varkw else [] + + @classmethod + def _get_named_only_args(cls, arg_spec: inspect.FullArgSpec) -> list: + rf_spec: list = [] + kw_only_args = arg_spec.kwonlyargs if arg_spec.kwonlyargs else [] + if not arg_spec.varargs and kw_only_args: + rf_spec.append("*") + kw_only_defaults = arg_spec.kwonlydefaults if arg_spec.kwonlydefaults else {} + for kw_only_arg in kw_only_args: + if kw_only_arg in kw_only_defaults: + rf_spec.append((kw_only_arg, kw_only_defaults[kw_only_arg])) + else: + rf_spec.append(kw_only_arg) + return rf_spec + + @classmethod + def _get_types(cls, function): + if function is None: + return function + types = getattr(function, "robot_types", ()) + if types is None or types: + return types + return cls._get_typing_hints(function) + + @classmethod + def _get_typing_hints(cls, function): + function = cls.unwrap(function) + hints = cls._get_type_hint(function) + arg_spec = cls._get_arg_spec(function) + all_args = cls._args_as_list(function, arg_spec) + for arg_with_hint in list(hints): + # remove self statements + if arg_with_hint not in [*all_args, "return"]: + hints.pop(arg_with_hint) + return hints + + @classmethod + def _args_as_list(cls, function, arg_spec) -> list: + function_args = cls._drop_self_from_args(function, arg_spec) + if arg_spec.varargs: + function_args.append(arg_spec.varargs) + function_args.extend(arg_spec.kwonlyargs or []) + if arg_spec.varkw: + function_args.append(arg_spec.varkw) + return function_args + + @classmethod + def _get_defaults(cls, arg_spec): + if not arg_spec.defaults: + return {} + names = arg_spec.args[-len(arg_spec.defaults) :] + return zip(names, arg_spec.defaults) diff --git a/src/robotlibcore/keywords/specification.py b/src/robotlibcore/keywords/specification.py new file mode 100644 index 0000000..5a85365 --- /dev/null +++ b/src/robotlibcore/keywords/specification.py @@ -0,0 +1,25 @@ +# Copyright 2017- Robot Framework Foundation +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +class KeywordSpecification: + def __init__( + self, + argument_specification=None, + documentation=None, + argument_types=None, + ) -> None: + self.argument_specification = argument_specification + self.documentation = documentation + self.argument_types = argument_types diff --git a/src/robotlibcore/plugin/__init__.py b/src/robotlibcore/plugin/__init__.py new file mode 100644 index 0000000..7e92ab7 --- /dev/null +++ b/src/robotlibcore/plugin/__init__.py @@ -0,0 +1,17 @@ +# Copyright 2017- Robot Framework Foundation +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from .parser import PluginParser + +__all__ = ["PluginParser"] diff --git a/src/robotlibcore/plugin/parser.py b/src/robotlibcore/plugin/parser.py new file mode 100644 index 0000000..6233d0f --- /dev/null +++ b/src/robotlibcore/plugin/parser.py @@ -0,0 +1,73 @@ +# Copyright 2017- Robot Framework Foundation +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import inspect +from typing import Any, List, Optional, Union + +from robot.errors import DataError +from robot.utils import Importer + +from robotlibcore.core import DynamicCore +from robotlibcore.utils import Module, PluginError + + +class PluginParser: + def __init__(self, base_class: Optional[Any] = None, python_object=None) -> None: + self._base_class = base_class + self._python_object = python_object if python_object else [] + + def parse_plugins(self, plugins: Union[str, List[str]]) -> List: + imported_plugins = [] + importer = Importer("test library") + for parsed_plugin in self._string_to_modules(plugins): + plugin = importer.import_class_or_module(parsed_plugin.module) + if not inspect.isclass(plugin): + message = f"Importing test library: '{parsed_plugin.module}' failed." + raise DataError(message) + args = self._python_object + parsed_plugin.args + plugin = plugin(*args, **parsed_plugin.kw_args) + if self._base_class and not isinstance(plugin, self._base_class): + message = f"Plugin does not inherit {self._base_class}" + raise PluginError(message) + imported_plugins.append(plugin) + return imported_plugins + + def get_plugin_keywords(self, plugins: List): + return DynamicCore(plugins).get_keyword_names() + + def _string_to_modules(self, modules: Union[str, List[str]]): + parsed_modules: list = [] + if not modules: + return parsed_modules + for module in self._modules_splitter(modules): + module_and_args = module.strip().split(";") + module_name = module_and_args.pop(0) + kw_args = {} + args = [] + for argument in module_and_args: + if "=" in argument: + key, value = argument.split("=") + kw_args[key] = value + else: + args.append(argument) + parsed_modules.append(Module(module=module_name, args=args, kw_args=kw_args)) + return parsed_modules + + def _modules_splitter(self, modules: Union[str, List[str]]): + if isinstance(modules, str): + for module in modules.split(","): + yield module + else: + for module in modules: + yield module diff --git a/src/robotlibcore/utils/__init__.py b/src/robotlibcore/utils/__init__.py new file mode 100644 index 0000000..609b6b4 --- /dev/null +++ b/src/robotlibcore/utils/__init__.py @@ -0,0 +1,28 @@ +# Copyright 2017- Robot Framework Foundation +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from dataclasses import dataclass + +from .exceptions import NoKeywordFound, PluginError, PythonLibCoreException +from .translations import _translated_keywords, _translation + + +@dataclass +class Module: + module: str + args: list + kw_args: dict + + +__all__ = ["Module", "NoKeywordFound", "PluginError", "PythonLibCoreException", "_translation", "_translated_keywords"] diff --git a/src/robotlibcore/utils/exceptions.py b/src/robotlibcore/utils/exceptions.py new file mode 100644 index 0000000..c832387 --- /dev/null +++ b/src/robotlibcore/utils/exceptions.py @@ -0,0 +1,25 @@ +# Copyright 2017- Robot Framework Foundation +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +class PythonLibCoreException(Exception): # noqa: N818 + pass + + +class PluginError(PythonLibCoreException): + pass + + +class NoKeywordFound(PythonLibCoreException): + pass diff --git a/src/robotlibcore/utils/translations.py b/src/robotlibcore/utils/translations.py new file mode 100644 index 0000000..35c32f6 --- /dev/null +++ b/src/robotlibcore/utils/translations.py @@ -0,0 +1,36 @@ +# Copyright 2017- Robot Framework Foundation +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +import json +from pathlib import Path +from typing import Optional + +from robot.api import logger + + +def _translation(translation: Optional[Path] = None): + if translation and isinstance(translation, Path) and translation.is_file(): + with translation.open("r") as file: + try: + return json.load(file) + except json.decoder.JSONDecodeError: + logger.warn(f"Could not convert json file {translation} to dictionary.") + return {} + else: + return {} + + +def _translated_keywords(translation_data: dict) -> list: + return [item.get("name") for item in translation_data.values() if item.get("name")] diff --git a/tasks.py b/tasks.py index 3e98212..90ebdf3 100644 --- a/tasks.py +++ b/tasks.py @@ -10,7 +10,8 @@ REPOSITORY = "robotframework/PythonLibCore" -VERSION_PATH = Path("src/robotlibcore.py") +VERSION_PATH = Path("src/robotlibcore/__init__.py") +VERSION_PATTERN = '__version__ = "(.*)"' RELEASE_NOTES_PATH = Path("docs/PythonLibCore-{version}.rst") RELEASE_NOTES_TITLE = "Python Library Core {version}" RELEASE_NOTES_INTRO = """ @@ -67,7 +68,7 @@ def set_version(ctx, version): # noqa: ARG001 to the next suitable development version. For example, 3.0 -> 3.0.1.dev1, 3.1.1 -> 3.1.2.dev1, 3.2a1 -> 3.2a2.dev1, 3.2.dev1 -> 3.2.dev2. """ - version = Version(version, VERSION_PATH) + version = Version(version, VERSION_PATH, VERSION_PATTERN) version.write() print(version) @@ -165,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 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}"