Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 41 additions & 19 deletions hookman/hookman_generator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand All @@ -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

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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.
Expand All @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -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
"""
Expand Down Expand Up @@ -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}'
Expand All @@ -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:
Expand Down
84 changes: 45 additions & 39 deletions hookman/plugin_config.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -21,60 +22,62 @@
"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")
)

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():
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down
1 change: 0 additions & 1 deletion tests/plugins/acme/simple_plugin/assets/plugin.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,4 @@ version: '1.0.0'

author: 'simple_plugin_author'
email: 'simple_plugin_author@simple_plugin_author.com'

id: 'simple_plugin'
10 changes: 7 additions & 3 deletions tests/test_hookman_generator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)

Expand Down Expand Up @@ -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'
"""
)

Expand Down Expand Up @@ -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'
"""
)
Expand Down Expand Up @@ -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)
4 changes: 3 additions & 1 deletion tests/test_hooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"])
Expand Down