diff --git a/packer/src/sarasacw_omrf_packer/__init__.py b/packer/src/sarasacw_omrf_packer/__init__.py index 5acf234..b612f77 100644 --- a/packer/src/sarasacw_omrf_packer/__init__.py +++ b/packer/src/sarasacw_omrf_packer/__init__.py @@ -1,49 +1,80 @@ import logging +import sys from pathlib import Path from semver import Version -from . import cli +from .cli import Cli, parse as parse_cli +from .artifact import resolve_include_copy, resolve_lib_copy, FileCopyInfo from .archiver import Archiver from .metadata import MetadataExtractor from .renders.cmake import CMakeProperties, CMakeRender from .renders.pkgconfig import PkgConfigProperties, PkgConfigRender -def build_cmake_render(extractor: MetadataExtractor) -> CMakeRender: - properties = CMakeProperties( - "wfassoc", "wfassoc", "wfassoc.dll", "wfassoc.lib", Version(1, 0, 0) - ) - return CMakeRender(properties) +class App: + __opts: Cli + __extractor: MetadataExtractor + def __init__(self, opts: Cli) -> None: + # assign cli options + self.__opts = opts + # initialize packer + try: + self.__extractor = MetadataExtractor(self.__opts.manifest) + _ = self.__extractor.get_metadata + except Exception as e: + logging.error(f"fail to initialize packer: {e}") + sys.exit(1) -def build_pkgconfig_render(extractor: MetadataExtractor) -> PkgConfigRender: - properties = PkgConfigProperties( - "wfassoc", "wfassoc C/C++ FFI", "wfassoc", Version(1, 0, 0) - ) - return PkgConfigRender(properties) + def run(self) -> None: + + # create renders and their properties from metadata + cmake_render = self.__build_cmake_render() + pkgconfig_render = self.__build_pkgconfig_render() + + # create distribution + with Archiver(self.__opts.dist_dir, self.__opts.dist_zip) as archiver: + # create basic directory + archiver.push_dir(Path("bin")) + archiver.push_dir(Path("include")) + archiver.push_dir(Path("lib")) + # copy header files + include_dir = Path("include") + for header_copy in self.__build_header_copy(): + archiver.push_file(header_copy.from_path, include_dir / header_copy.to_path) + # create package infos + archiver.push_text( + cmake_render.render_config(), + Path("lib", "cmake", "wfassoc", "wfassocConfig.cmake"), + ) + archiver.push_text( + cmake_render.render_config_version(), + Path("lib", "cmake", "wfassoc", "wfassocConfigVersion.cmake"), + ) + archiver.push_text( + pkgconfig_render.render(), Path("lib", "pkgconfig", "wfassoc.pc") + ) + + def __build_header_copy(self) -> tuple[IncludeCopyInfo, ...]: + return resolve_header_copy(self.__extractor) + + def __build_cmake_render(self) -> CMakeRender: + properties = CMakeProperties( + "wfassoc", "wfassoc", "wfassoc.dll", "wfassoc.lib", Version(1, 0, 0) + ) + return CMakeRender(properties) + + def __build_pkgconfig_render(self) -> PkgConfigRender: + properties = PkgConfigProperties( + "wfassoc", "wfassoc C/C++ FFI", "wfassoc", Version(1, 0, 0) + ) + return PkgConfigRender(properties) def main() -> None: # parse command line arguments - opts = cli.parse() + opts = parse_cli() # setup logging - logging.basicConfig(format='[%(levelname)s] %(message)s', level=logging.INFO) + logging.basicConfig(format="[%(levelname)s] %(message)s", level=logging.INFO) - # build metadata extractor - extractor = MetadataExtractor(opts.manifest) - # extract omrf metadata - metadata = extractor.get_metadata() - - - - # create renders and their properties from metadata - cmake_render = extractor - - - # create distribution - with Archiver(opts.dist_dir, opts.dist_zip) as archiver: - archiver.push_dir(Path("bin")) - archiver.push_dir(Path("include")) - archiver.push_dir(Path("lib")) - archiver.push_text(cmake_render.render_config(), Path("lib", "cmake", "wfassoc", "wfassocConfig.cmake")) - archiver.push_text(cmake_render.render_config_version(), Path("lib", "cmake", "wfassoc", "wfassocConfigVersion.cmake")) - archiver.push_text(pkgconfig_render.render(), Path("lib", "pkgconfig", "wfassoc.pc")) + app = App(opts) + app.run() diff --git a/packer/src/sarasacw_omrf_packer/artifact.py b/packer/src/sarasacw_omrf_packer/artifact.py new file mode 100644 index 0000000..66d3aee --- /dev/null +++ b/packer/src/sarasacw_omrf_packer/artifact.py @@ -0,0 +1,154 @@ +import fnmatch +import sys +import logging +from pathlib import Path +from dataclasses import dataclass +from re import Pattern, compile +from .metadata import MetadataExtractor, Metadata +from .renders.cmake import CMakeProperties, CMakeRender +from .renders.pkgconfig import PkgConfigProperties, PkgConfigRender + +class ArtifactMetadata: + """A wrapper of metadata with proper fallback""" + + __extractor: MetadataExtractor + __metadata: Metadata + + def __init__(self, extractor: MetadataExtractor) -> None: + self.__extractor = extractor + self.__metadata = self.__extractor.get_metadata() + + + + + + + +def _is_glob_pattern(pattern: str) -> bool: + """Check whether given string is a valid UNIX-style glob (pathname) pattern + + :returns: True if it is, otherwise false. + """ + pass + +def _extract_common_prefix(pattern: str) -> (str, str): + """Extract the immutable common prefix of given UNIX-style glob (pathname) pattern. + + For example, give ``some/path/**/*.h``, this function return ``some/path`` + :returns: A tuple with 2 items. + First item is the immutable common prefix of given UNIX-style glob (pathname) pattern, + and the last item is the residue. + """ + pass + +@dataclass(frozen=True) +class FileCopyInfo: + + from_path: Path + """The absolute path pointing to source file without any wildcard""" + to_path: Path + """The path of destination file relative to the install directory""" + + +def resolve_include_copy(extractor: MetadataExtractor) -> tuple[FileCopyInfo, ...]: + metadata = extractor.get_metadata + project_root = Path(extractor.get_project_dir()) + rv: list[FileCopyInfo] = [] + + for header in metadata.headers: + to_path = Path(header.to_path) + if _is_glob_pattern(header.from_path): + (common_prefix, glob_residue) = _extract_common_prefix(header.from_path) + new_project_root = project_root / Path(common_prefix) + + for subpath in new_project_root.glob(glob_residue): + rv.append(FileCopyInfo(new_project_root / subpath, to_path / subpath)) + else: + rv.append(FileCopyInfo(project_root / header.from_path, to_path / header.from_path)) + + return tuple(rv) + + + +def _generate_dll_artifact_name(name: str) -> str: + match sys.platform: + case "win32" | "cygwin": + return f"{name}.dll" + case "linux" | "android" | "freebsd": + return f"lib{name}.so" + case "darwin" | "ios": + return f"lib{name}.dylib" + case _: + raise RuntimeError("not supported system") + + +def _generate_lib_artifact_name(name: str) -> str: + # only Windows has this feature, so we simply return it + return f"{name}.dll.lib" + + +def resolve_lib_copy(extractor: MetadataExtractor) -> tuple[FileCopyInfo, ...]: + target_directory = extractor.get_target_directory() / "release" + target_name = extractor.get_target_name() + rv: list[FileCopyInfo] = [] + + # copy artifact for redist + dll_artifact_filename = _generate_dll_artifact_name(target_name) + rv.append(FileCopyInfo(target_directory / dll_artifact_filename, Path("bin" if sys.platform == "win32" else "lib") / dll_artifact_filename )) + # copy artifact for linking only on windows + if sys.platform == "win32": + lib_artifact_filename = _generate_lib_artifact_name(target_name) + rv.append(FileCopyInfo(target_directory / lib_artifact_filename, Path("lib") / lib_artifact_filename)) + + return tuple(rv) + +@dataclass(frozen=True) +class TextCopyInfo: + + text: str + """The content of source file""" + to_path: Path + """The path of destination file relative to the install directory""" + +_BAD_NAME_PATTERN: Pattern = compile(r'[^a-zA-Z0-9_+-]') + +def _sanitize_name(name: str) -> str: + new_name = _BAD_NAME_PATTERN.sub('', name) + if new_name == '': + raise ValueError('name is blank after sanitizing') + else: + return new_name + + +def resolve_cmake_copy(extractor: MetadataExtractor) -> tuple[TextCopyInfo, ...]: + target_name = extractor.get_target_name() + name = _sanitize_name(target_name) + + properties = CMakeProperties( + name, name, _generate_dll_artifact_name(target_name), _generate_lib_artifact_name(target_name), extractor.get_version() + ) + render = CMakeRender(properties) + return ( + TextCopyInfo(render.render_config(), + Path("lib", "cmake", name, f"{name}Config.cmake")), + TextCopyInfo( + render.render_config_version(), + Path("lib", "cmake", name, f"{name}ConfigVersion.cmake") + ) + ) + + +def resolve_pkgconfig_copy(extractor: MetadataExtractor) -> tuple[TextCopyInfo, ...]: + target_name = extractor.get_target_name() + name = _sanitize_name(target_name) + + properties = PkgConfigProperties( + name, "wfassoc C/C++ FFI", target_name, extractor.get_version() + ) + render = PkgConfigRender(properties) + return ( + TextCopyInfo(render.render(), Path("lib", "pkgconfig", f"{name}.pc")), + ) + + + diff --git a/packer/src/sarasacw_omrf_packer/metadata.py b/packer/src/sarasacw_omrf_packer/metadata.py index ef854f5..dc16a62 100644 --- a/packer/src/sarasacw_omrf_packer/metadata.py +++ b/packer/src/sarasacw_omrf_packer/metadata.py @@ -101,6 +101,7 @@ def wrap_metadata_errors[**P, R](func: Callable[P, R]) -> Callable[P, R]: return func(*args, **kwargs) except Exception as e: raise RuntimeError(f"error occurs when fetching metadata: {e}") from e + return wrapper @@ -187,8 +188,10 @@ class MetadataExtractor: ) @wrap_metadata_errors - def get_target_directory(self) -> str: - return dict_typed_get_required(self.__metadata, "target_directory", str) + def get_target_directory(self) -> Path: + """Get the absolute path to directory where Rust target directory is""" + raw_target_directory = dict_typed_get_required(self.__metadata, "target_directory", str) + return Path(raw_target_directory) @wrap_metadata_errors def get_name(self) -> str: @@ -203,6 +206,14 @@ class MetadataExtractor: raw_version = dict_typed_get_required(self.__metadata_package, "version", str) return utils.parse_version(raw_version) + @wrap_metadata_errors + def get_project_dir(self) -> Path: + """Get the absolute path to directory where exported Cargo.toml is""" + raw_manifest_path = dict_typed_get_required( + self.__metadata_package, "manifest_path", str + ) + return Path(raw_manifest_path).parent + @wrap_metadata_errors def get_metadata(self) -> Metadata: omrf = dict_chain_get(self.__metadata_package, "metadata", "omrf") diff --git a/packer/src/sarasacw_omrf_packer/renders/cmake.py b/packer/src/sarasacw_omrf_packer/renders/cmake.py index 4f7d315..e35c3db 100644 --- a/packer/src/sarasacw_omrf_packer/renders/cmake.py +++ b/packer/src/sarasacw_omrf_packer/renders/cmake.py @@ -9,12 +9,9 @@ top of :class:`renders.common.BaseRender`. from dataclasses import dataclass from typing import Any -from re import Pattern, compile -from . import common from semver import Version - - -_CMAKE_NAME_PATTERN: Pattern = compile(r"[a-zA-Z0-9_.+-]+") +from . import common +from .. import utils @dataclass(frozen=True) @@ -56,11 +53,11 @@ class CMakeProperties: the version carries a prerelease or build component (unsupported by CMake config-version files). """ - if _CMAKE_NAME_PATTERN.fullmatch(self.namespace_name) is None: + if not utils.is_good_name(self.namespace_name): raise ValueError( "bad namespace component in CMake. consider manually specifying it" ) - if _CMAKE_NAME_PATTERN.fullmatch(self.target_name) is None: + if not utils.is_good_name(self.target_name) is None: raise ValueError( "bad target name component in CMake. consider manually specifying it" ) @@ -68,7 +65,7 @@ class CMakeProperties: raise ValueError("bad artifact dll file name in CMake") if self.artifact_lib == "": raise ValueError("bad artifact lib file name in CMake") - if self.version.prerelease is not None or self.version.build is not None: + if not utils.is_good_version(self.version): raise ValueError( "unsupported semantic version components (prerelease or build) in CMake" ) diff --git a/packer/src/sarasacw_omrf_packer/renders/pkgconfig.py b/packer/src/sarasacw_omrf_packer/renders/pkgconfig.py index c530344..f172f95 100644 --- a/packer/src/sarasacw_omrf_packer/renders/pkgconfig.py +++ b/packer/src/sarasacw_omrf_packer/renders/pkgconfig.py @@ -9,10 +9,25 @@ rendering on top of :class:`renders.common.BaseRender`. from dataclasses import dataclass from typing import Any +from re import Pattern, compile from . import common +from .. import utils from semver import Version +_EOL_PATTERN: Pattern = compile(r"[\r\n]") + + +def _is_good_sentence(s: str) -> bool: + """Check whether given string has EOL chars. + + This function is used for checking human-readable name and description, + because EOL chars are not allowed in these properties. + :returns: True if given string don't contain any EOL chars, otherwise false. + """ + return _EOL_PATTERN.match(s) is None + + @dataclass(frozen=True) class PkgConfigProperties: """Inputs required to render the pkg-config ``.pc`` file. @@ -49,11 +64,13 @@ class PkgConfigProperties: version carries a prerelease or build component (unsupported in pkg-config). """ - if self.name == "": - raise ValueError("unexpected blank name of pkg-config") - if self.description == "": - raise ValueError("unexpected blank description of pkg-config") - if self.version.prerelease is not None or self.version.build is not None: + if self.name == "" or not _is_good_sentence(self.name): + raise ValueError("bad name (blank or has EOL chars) of pkg-config") + if self.description == "" or not _is_good_sentence(self.description): + raise ValueError("bad description (blank or has EOL chars) of pkg-config") + if not utils.is_good_name(self.artifact): + raise ValueError("bad artifact name in pkg-config") + if not utils.is_good_version(self.version): raise ValueError( "unsupported semantic version components (prerelease or build) in pkg-config" ) diff --git a/packer/src/sarasacw_omrf_packer/utils.py b/packer/src/sarasacw_omrf_packer/utils.py index 99a294a..a46ddf0 100644 --- a/packer/src/sarasacw_omrf_packer/utils.py +++ b/packer/src/sarasacw_omrf_packer/utils.py @@ -1,5 +1,6 @@ from pathlib import Path -from typing import Any, cast, overload +from re import Pattern, compile +from typing import Any, overload from semver import Version VERSION: Version = Version(1, 0, 0) @@ -14,6 +15,12 @@ def parse_version(vs: str) -> Version: else: return v +def is_good_version(v: Version) -> bool: + """Check whether given version has ``prerelease`` or ``build`` components which is not allowed in this package. + + :returns: True if given string do not have these components, otherwise false. + """ + return v.prerelease is None and v.build is None def get_root_dir() -> Path: return Path(__file__).resolve().parent @@ -48,3 +55,16 @@ def dict_chain_get(d: dict[str, Any], *args: str) -> dict[str, Any]: for arg in args: d = dict_typed_get_required(d, arg, dict) return d + + + +_NAME_PATTERN: Pattern = compile(r"[a-zA-Z0-9_+-]+") + +def is_good_name(name: str) -> bool: + """This package specific name checker + + These allowed chars are picked as the subset of allowed chars in all generated contents, + including file name restrictions, CMake name restrictions and etc. + :returns: True if given name is legal, otherwise false. + """ + return _NAME_PATTERN.fullmatch(name) is not None