diff --git a/hookman/hookman_generator.py b/hookman/hookman_generator.py index a5978f5..80524da 100644 --- a/hookman/hookman_generator.py +++ b/hookman/hookman_generator.py @@ -2,6 +2,7 @@ import inspect import re import sys +from collections.abc import Mapping from pathlib import Path from textwrap import dedent from typing import Any @@ -144,11 +145,12 @@ def generate_plugin_template( author_email: str, author_name: str, dst_path: Path, - extra_includes: Optional[List[str]] = None, - extra_body_lines: Optional[List[str]] = None, - exclude_hooks: Optional[List[str]] = None, - extras: Optional[Dict[str, str]] = None, - ): + extra_includes: list[str] | None = None, + extra_body_lines: list[str] | None = None, + exclude_hooks: list[str] | None = None, + extras: Mapping[str, str] | None = None, + requirements: Mapping[str, str] | None = None, + ) -> None: """ Generate a template with the necessary files and structure to create a plugin @@ -170,6 +172,9 @@ def generate_plugin_template( :param extra_includes: Extras include to be added on {plugin_id}.cpp as "default", as an example is the includes for a SDK. + :param requirements: + The requirements needed to create the plugin template. + :param extra_body_lines: Extras lines to be added on {plugin_id}.cpp on the body, used for default implementations of hooks @@ -203,7 +208,9 @@ def generate_plugin_template( self._plugin_cmake_file_content(plugin_id) ) Path(assets_folder / "plugin.yaml").write_text( - self._plugin_config_file_content(caption, plugin_id, author_email, author_name, extras) + self._plugin_config_file_content( + caption, plugin_id, author_email, author_name, extras, requirements + ) ) Path(assets_folder / "README.md").write_text( self._readme_content(caption, author_email, author_name) @@ -268,10 +275,11 @@ def generate_project_files(self, dst_path: Union[Path, str]): def generate_plugin_package( self, package_name: str, - plugin_dir: Union[Path, str], + plugin_dir: Path | str, dst_path: Path = None, - extras_defaults: Optional[Dict[str, str]] = None, - package_name_suffix: Optional[str] = None, + extras_defaults: Mapping[str, str] | None = None, + requirements: Mapping[str, str] | None = None, + package_name_suffix: str | None = None, ): """ Creates a .hmplugin file using the name provided on package_name argument. @@ -289,9 +297,14 @@ def generate_plugin_package( :param Dict[str,str] extras_defaults: (key, value) entries to be added to "extras" if not defined by the original input yaml. + :param requirements: + The requirements necessary to generate the plugin. + :param Optional[str] package_name_suffix: If not `None` this string is inserted after the plugin version in the filename. """ + import strictyaml + plugin_dir = Path(plugin_dir) if dst_path is None: dst_path = plugin_dir @@ -306,14 +319,17 @@ def generate_plugin_package( contents = (assets_dir / "plugin.yaml").read_text() if extras_defaults is not None: - import strictyaml - contents_dict = strictyaml.load(contents, PLUGIN_CONFIG_SCHEMA) extras = extras_defaults.copy() extras.update(contents_dict.data.get("extras", {})) contents_dict["extras"] = dict(sorted(extras.items())) contents = contents_dict.as_yaml() + if requirements is not None: + contents_dict = strictyaml.load(contents, PLUGIN_CONFIG_SCHEMA) + contents_dict["requirements"] = dict(sorted(requirements.items())) + contents = contents_dict.as_yaml() + hmplugin_base_name_components = [package_name, plugin_info.version] if package_name_suffix is not None: hmplugin_base_name_components.append(package_name_suffix) @@ -382,21 +398,21 @@ def _validate_package_folder(self, artifacts_dir, assets_dir): if not assets_dir.joinpath("CHANGELOG.rst").is_file(): raise FileNotFoundError(f"Unable to locate the file CHANGELOG.rst in {assets_dir}") - def _validate_plugin_config_file(self, plugin_config_file: Path): + def _validate_plugin_config_file(self, plugin_config_file: Path) -> None: """ Check if the given plugin_file is valid, by creating a instance of PluginInfo. All checks are made in the __init__ """ plugin_file_content = PluginInfo(plugin_config_file, hooks_available=None) - semantic_version_re = re.compile(r"^(\d+)\.(\d+)\.(\d+)") # Ex.: 1.0.0 + semantic_version_re = re.compile(r"^(\d+)\.(\d+)\.(\d+)") # Ex.: 1.0.0 or 2025.1.0 version = semantic_version_re.match(plugin_file_content.version) if not version: raise ValueError( - f"Version attribute does not follow semantic version, got {plugin_file_content.version!r}" + f"Version attribute does not follow the calendar or semantic versioning, got {plugin_file_content.version!r}" ) - def _hook_specs_header_content(self, plugin_id) -> str: + def _hook_specs_header_content(self, plugin_id: str) -> str: """ Create a C header file with the content informed on the hook_specs """ @@ -636,11 +652,14 @@ def _plugin_config_file_content( plugin_id: str, author_email: str, author_name: str, - extras: dict, + extras: dict[str, str] | None, + requirements: dict[str, str] | None, ) -> str: """ Return a string that represent the content of a valid configuration for a plugin """ + import strictyaml + file_content = dedent( f"""\ author: '{author_name}' @@ -650,11 +669,14 @@ def _plugin_config_file_content( version: '1.0.0' """ ) - if extras: - import strictyaml - + if extras is not None: extras_dict = {"extras": extras} file_content += strictyaml.as_document(extras_dict).as_yaml() + + if requirements is not None: + requirement_dict = {"requirements": dict(sorted(requirements.items()))} + file_content += strictyaml.as_document(requirement_dict).as_yaml() + return file_content def _readme_content(self, caption: str, author_email: str, author_name: str) -> str: diff --git a/hookman/plugin_config.py b/hookman/plugin_config.py index 2218ab2..01d833e 100644 --- a/hookman/plugin_config.py +++ b/hookman/plugin_config.py @@ -1,15 +1,16 @@ import ctypes import sys +from collections.abc import Sequence from pathlib import Path -from typing import List from zipfile import ZipFile -import attr -from attr import attrib +from attr import define +from attr import field from strictyaml import Map from strictyaml import MapPattern from strictyaml import Optional from strictyaml import Str +from strictyaml import YAML from hookman.exceptions import SharedLibraryNotFoundError from hookman.hookman_utils import load_shared_lib @@ -21,31 +22,34 @@ "author": Str(), "email": Str(), "id": Str(), + Optional("requirements"): MapPattern(Str(), Str()), Optional("extras"): MapPattern(Str(), Str()), } ) -@attr.s -class PluginInfo(object): +@define +class PluginInfo: """ Class that holds all information related to the plugin with some auxiliary methods """ - yaml_location = attrib(type=Path) - hooks_available = attrib(validator=attr.validators.optional(attr.validators.instance_of(dict))) - - author = attrib(type=str, init=False) - description = attrib(type=str, default="Could not find a description", init=False) - email = attrib(type=str, init=False) - hooks_implemented = attrib(type=list, init=False) - caption = attrib(type=str, init=False) - shared_lib_name = attrib(type=str, init=False) - shared_lib_path = attrib(type=Path, init=False) - version = attrib(type=str, init=False) - extras = attrib(attr.Factory(dict), init=False) - - def __attrs_post_init__(self): + yaml_location: Path + hooks_available: dict | None = None + + description: str = field(init=False) + author: str = field(init=False) + email: str = field(init=False) + hooks_implemented: Sequence[str] = field(init=False) + caption: str = field(init=False) + shared_lib_name: str = field(init=False) + shared_lib_path: Path = field(init=False) + version: str = field(init=False) + requirements: dict[str, str] = field(init=False) + extras: dict = field(init=False) + id: str = field(init=False) + + def __attrs_post_init__(self) -> None: plugin_config_file_content = self._load_yaml_file( self.yaml_location.read_text(encoding="utf-8") ) @@ -53,28 +57,27 @@ def __attrs_post_init__(self): name = plugin_config_file_content["id"] shared_lib_name = f"{name}.dll" if sys.platform == "win32" else f"lib{name}.so" - object.__setattr__(self, "shared_lib_name", shared_lib_name) - object.__setattr__( - self, "shared_lib_path", self.yaml_location.parents[1] / "artifacts" / shared_lib_name - ) - - object.__setattr__(self, "author", plugin_config_file_content["author"]) - object.__setattr__(self, "caption", plugin_config_file_content["caption"]) - object.__setattr__(self, "email", plugin_config_file_content["email"]) - object.__setattr__(self, "version", plugin_config_file_content["version"]) - object.__setattr__(self, "extras", plugin_config_file_content.get("extras", {})) + self.shared_lib_name = shared_lib_name + self.shared_lib_path = self.yaml_location.parents[1] / "artifacts" / shared_lib_name + self.author = plugin_config_file_content["author"] + self.caption = plugin_config_file_content["caption"] + self.email = plugin_config_file_content["email"] + self.version = plugin_config_file_content["version"] + self.requirements = plugin_config_file_content.get("requirements", {}) + self.extras = plugin_config_file_content.get("extras", {}) # The id bellow guarantee to me that the plugin_id to be used in the application was not changed by a config file. - object.__setattr__( - self, "id", self._get_plugin_id_from_dll(plugin_config_file_content["id"]) + self.id = self._get_plugin_id_from_dll(plugin_config_file_content["id"]) + + readme_file = self.yaml_location.parent / "README.md" + self.description = ( + readme_file.read_text(encoding="utf-8") + if readme_file.is_file() + else "Could not find a description" ) if not self.hooks_available is None: - object.__setattr__(self, "hooks_implemented", self._get_hooks_implemented()) - - readme_file = self.yaml_location.parent / "README.md" - if readme_file.exists(): - object.__setattr__(self, "description", readme_file.read_text()) + self.hooks_implemented = self._get_hooks_implemented() def _check_if_shared_lib_exists(self): if not self.shared_lib_path.is_file(): @@ -95,11 +98,14 @@ def _get_plugin_id_from_dll(self, plugin_id_from_plugin_yaml: str) -> str: raise RuntimeError(msg) return plugin_id_from_shared_lib - def _get_hooks_implemented(self) -> List[str]: + def _get_hooks_implemented(self) -> Sequence[str]: """ Return a list of which hooks from "hooks_available" the shared library implements """ self._check_if_shared_lib_exists() + if self.hooks_available is None: + return [] + with load_shared_lib(str(self.shared_lib_path)) as plugin_dll: hooks_implemented = [ hook_name @@ -124,7 +130,7 @@ def is_implemented_on_plugin(cls, plugin_dll: ctypes.CDLL, hook_name: str) -> bo return True @classmethod - def _load_yaml_file(cls, yaml_content): + def _load_yaml_file(cls, yaml_content: str) -> YAML: import strictyaml plugin_config_file_content = strictyaml.load(yaml_content, PLUGIN_CONFIG_SCHEMA).data @@ -139,7 +145,7 @@ def _load_yaml_file(cls, yaml_content): return plugin_config_file_content @classmethod - def validate_plugin_file(cls, plugin_file_zip: ZipFile): + def validate_plugin_file(cls, plugin_file_zip: ZipFile) -> None: """ Check if the given plugin_file is valid, currently the only check that this method do is to verify if the id is available diff --git a/tests/plugins/acme/simple_plugin/assets/plugin.yaml b/tests/plugins/acme/simple_plugin/assets/plugin.yaml index d71c6c7..624b91e 100644 --- a/tests/plugins/acme/simple_plugin/assets/plugin.yaml +++ b/tests/plugins/acme/simple_plugin/assets/plugin.yaml @@ -3,5 +3,4 @@ version: '1.0.0' author: 'simple_plugin_author' email: 'simple_plugin_author@simple_plugin_author.com' - id: 'simple_plugin' diff --git a/tests/test_hookman_generator.py b/tests/test_hookman_generator.py index 4588f4f..0cbc9ff 100644 --- a/tests/test_hookman_generator.py +++ b/tests/test_hookman_generator.py @@ -236,6 +236,7 @@ def test_generate_plugin_package( package_name="acme", plugin_dir=plugin_dir, extras_defaults={"key": "default", "key3": "default"}, + requirements={"C": ">=3.10", "A": ">=1.1.0;<=1.3.0", "B": ">=2.0"}, package_name_suffix=package_name_extra, ) @@ -281,6 +282,10 @@ def test_generate_plugin_package( key: override key2: value2 key3: default + requirements: + A: '>=1.1.0;<=1.3.0' + B: '>=2.0' + C: '>=3.10' """ ) @@ -336,10 +341,8 @@ def test_generate_plugin_package_with_missing_folders(acme_hook_specs_file, tmpd f"""\ caption: 'ACME' version: '1.0.0' - author: 'acme_author' email: 'acme_email' - id: 'acme' """ ) @@ -398,6 +401,7 @@ def test_generate_plugin_package_invalid_version( ) with pytest.raises( - ValueError, match="Version attribute does not follow semantic version, got '1'" + ValueError, + match="Version attribute does not follow the calendar or semantic versioning, got '1'", ): hg.generate_plugin_package(plugin_id, plugin_dir=tmp_path / plugin_id) diff --git a/tests/test_hooks.py b/tests/test_hooks.py index 40beef2..0e08d81 100644 --- a/tests/test_hooks.py +++ b/tests/test_hooks.py @@ -112,15 +112,17 @@ def test_plugins_available_plain(simple_plugin, simple_plugin_2): assert list(attr.asdict(plugins[0]).keys()) == [ "yaml_location", "hooks_available", - "author", "description", + "author", "email", "hooks_implemented", "caption", "shared_lib_name", "shared_lib_path", "version", + "requirements", "extras", + "id", ] plugins = hm.get_plugins_available(ignored_plugins=["simple_plugin_2"])