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
+63 -32
View File
@@ -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()
+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)
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")
@@ -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"
)
@@ -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"
)
+21 -1
View File
@@ -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