feat: partially write artifact module

This commit is contained in:
2026-08-04 16:46:58 +08:00
parent 10a33f8921
commit 26ce893920
6 changed files with 278 additions and 48 deletions
+56 -25
View File
@@ -1,21 +1,69 @@
import logging import logging
import sys
from pathlib import Path from pathlib import Path
from semver import Version 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 .archiver import Archiver
from .metadata import MetadataExtractor from .metadata import MetadataExtractor
from .renders.cmake import CMakeProperties, CMakeRender from .renders.cmake import CMakeProperties, CMakeRender
from .renders.pkgconfig import PkgConfigProperties, PkgConfigRender from .renders.pkgconfig import PkgConfigProperties, PkgConfigRender
def build_cmake_render(extractor: MetadataExtractor) -> CMakeRender: 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 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( properties = CMakeProperties(
"wfassoc", "wfassoc", "wfassoc.dll", "wfassoc.lib", Version(1, 0, 0) "wfassoc", "wfassoc", "wfassoc.dll", "wfassoc.lib", Version(1, 0, 0)
) )
return CMakeRender(properties) return CMakeRender(properties)
def __build_pkgconfig_render(self) -> PkgConfigRender:
def build_pkgconfig_render(extractor: MetadataExtractor) -> PkgConfigRender:
properties = PkgConfigProperties( properties = PkgConfigProperties(
"wfassoc", "wfassoc C/C++ FFI", "wfassoc", Version(1, 0, 0) "wfassoc", "wfassoc C/C++ FFI", "wfassoc", Version(1, 0, 0)
) )
@@ -24,26 +72,9 @@ def build_pkgconfig_render(extractor: MetadataExtractor) -> PkgConfigRender:
def main() -> None: def main() -> None:
# parse command line arguments # parse command line arguments
opts = cli.parse() opts = parse_cli()
# setup logging # 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 app = App(opts)
extractor = MetadataExtractor(opts.manifest) app.run()
# 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"))
+154
View File
@@ -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")),
)
+13 -2
View File
@@ -101,6 +101,7 @@ def wrap_metadata_errors[**P, R](func: Callable[P, R]) -> Callable[P, R]:
return func(*args, **kwargs) return func(*args, **kwargs)
except Exception as e: except Exception as e:
raise RuntimeError(f"error occurs when fetching metadata: {e}") from e raise RuntimeError(f"error occurs when fetching metadata: {e}") from e
return wrapper return wrapper
@@ -187,8 +188,10 @@ class MetadataExtractor:
) )
@wrap_metadata_errors @wrap_metadata_errors
def get_target_directory(self) -> str: def get_target_directory(self) -> Path:
return dict_typed_get_required(self.__metadata, "target_directory", str) """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 @wrap_metadata_errors
def get_name(self) -> str: def get_name(self) -> str:
@@ -203,6 +206,14 @@ class MetadataExtractor:
raw_version = dict_typed_get_required(self.__metadata_package, "version", str) raw_version = dict_typed_get_required(self.__metadata_package, "version", str)
return utils.parse_version(raw_version) 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 @wrap_metadata_errors
def get_metadata(self) -> Metadata: def get_metadata(self) -> Metadata:
omrf = dict_chain_get(self.__metadata_package, "metadata", "omrf") omrf = dict_chain_get(self.__metadata_package, "metadata", "omrf")
@@ -9,12 +9,9 @@ top of :class:`renders.common.BaseRender`.
from dataclasses import dataclass from dataclasses import dataclass
from typing import Any from typing import Any
from re import Pattern, compile
from . import common
from semver import Version from semver import Version
from . import common
from .. import utils
_CMAKE_NAME_PATTERN: Pattern = compile(r"[a-zA-Z0-9_.+-]+")
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -56,11 +53,11 @@ class CMakeProperties:
the version carries a prerelease or build component (unsupported by the version carries a prerelease or build component (unsupported by
CMake config-version files). 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( raise ValueError(
"bad namespace component in CMake. consider manually specifying it" "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( raise ValueError(
"bad target name component in CMake. consider manually specifying it" "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") raise ValueError("bad artifact dll file name in CMake")
if self.artifact_lib == "": if self.artifact_lib == "":
raise ValueError("bad artifact lib file name in CMake") 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( raise ValueError(
"unsupported semantic version components (prerelease or build) in CMake" "unsupported semantic version components (prerelease or build) in CMake"
) )
@@ -9,10 +9,25 @@ rendering on top of :class:`renders.common.BaseRender`.
from dataclasses import dataclass from dataclasses import dataclass
from typing import Any from typing import Any
from re import Pattern, compile
from . import common from . import common
from .. import utils
from semver import Version 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) @dataclass(frozen=True)
class PkgConfigProperties: class PkgConfigProperties:
"""Inputs required to render the pkg-config ``.pc`` file. """Inputs required to render the pkg-config ``.pc`` file.
@@ -49,11 +64,13 @@ class PkgConfigProperties:
version carries a prerelease or build component (unsupported in version carries a prerelease or build component (unsupported in
pkg-config). pkg-config).
""" """
if self.name == "": if self.name == "" or not _is_good_sentence(self.name):
raise ValueError("unexpected blank name of pkg-config") raise ValueError("bad name (blank or has EOL chars) of pkg-config")
if self.description == "": if self.description == "" or not _is_good_sentence(self.description):
raise ValueError("unexpected blank description of pkg-config") raise ValueError("bad description (blank or has EOL chars) of pkg-config")
if self.version.prerelease is not None or self.version.build is not None: 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( raise ValueError(
"unsupported semantic version components (prerelease or build) in pkg-config" "unsupported semantic version components (prerelease or build) in pkg-config"
) )
+21 -1
View File
@@ -1,5 +1,6 @@
from pathlib import Path from pathlib import Path
from typing import Any, cast, overload from re import Pattern, compile
from typing import Any, overload
from semver import Version from semver import Version
VERSION: Version = Version(1, 0, 0) VERSION: Version = Version(1, 0, 0)
@@ -14,6 +15,12 @@ def parse_version(vs: str) -> Version:
else: else:
return v 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: def get_root_dir() -> Path:
return Path(__file__).resolve().parent 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: for arg in args:
d = dict_typed_get_required(d, arg, dict) d = dict_typed_get_required(d, arg, dict)
return d 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