Compare commits

..
2 Commits
Author SHA1 Message Date
yyc12345 520bfc2054 doc: add docstring and do some refactor 2026-08-05 15:53:52 +08:00
yyc12345 816e3a59d0 refactor: move version checker to metadata 2026-08-05 15:33:02 +08:00
7 changed files with 291 additions and 41 deletions
+42 -18
View File
@@ -1,7 +1,14 @@
"""Command-line entry point of sarasacw-omrf-packer.
Wires the pipeline together: parse CLI options, build a
:class:`MetadataExtractor` wrapped in an :class:`ArtifactContext`, then drive
an :class:`Archiver` to copy headers/libraries and emit the CMake/pkg-config
files.
"""
import logging
import sys
from pathlib import Path
from .utils import VERSION
from .cli import Cli, parse as parse_cli
from .artifact import (
ArtifactContext,
@@ -15,30 +22,34 @@ from .metadata import MetadataExtractor
class App:
"""High-level orchestrator of a single packaging run.
Holds the shared :class:`MetadataExtractor`/:class:`ArtifactContext` built
from the CLI options and exposes :meth:`run` to produce the distribution.
"""
__opts: Cli
__extractor: MetadataExtractor
__ctx: ArtifactContext
def __init__(self, opts: Cli) -> None:
"""Build the extractor and artifact context from CLI options.
:param opts: Parsed command-line options.
"""
# assign cli options
self.__opts = opts
# initialize packer
try:
# build essential instances
self.__extractor = MetadataExtractor(self.__opts.manifest)
self.__ctx = ArtifactContext(self.__extractor)
# check version
metadata = self.__ctx.metadata
if metadata.min_version is not None:
if metadata.min_version > VERSION:
raise RuntimeError(
f"requested minimum version is not fulfilled. {metadata.min_version} required got {VERSION}"
)
except Exception as e:
logging.error(f"fail to initialize packer: %s", e)
sys.exit(1)
# build essential instances
self.__extractor = MetadataExtractor(self.__opts.manifest)
self.__ctx = ArtifactContext(self.__extractor)
def run(self) -> None:
"""Produce the distribution.
Opens an :class:`Archiver` over the requested sinks, creates the basic
``bin/``/``include/``/``lib/`` layout, then copies headers, libraries
and generated package-manager files into it.
"""
# create distribution
with Archiver(self.__opts.dist_dir, self.__opts.dist_zip) as archiver:
# create basic directory
@@ -66,10 +77,23 @@ class App:
def main() -> None:
"""Entry point: parse arguments, configure logging and run :class:`App`.
Logs and exits with status ``1`` on initialization or runtime errors.
"""
# parse command line arguments
opts = parse_cli()
# setup logging
logging.basicConfig(format="[%(levelname)s] %(message)s", level=logging.INFO)
app = App(opts)
app.run()
# initialize packer and run
try:
app = App(opts)
except Exception as e:
logging.error("fail to initialize packer: %s", e)
sys.exit(1)
try:
app.run()
except Exception as e:
logging.error("packer runtime error: %s", e)
sys.exit(1)
@@ -1,3 +1,9 @@
"""Dual-sink archiver that materializes a distribution tree.
The :class:`Archiver` mirrors the same logical entries into an on-disk
directory and/or a zip archive, and is usable as a context manager.
"""
import zipfile
import shutil
from pathlib import Path
@@ -43,6 +49,11 @@ class Archiver:
self.__dist_zip = None
def push_dir(self, arcname: Path) -> None:
"""Create an empty directory entry in both sinks.
:param arcname: Directory path relative to the distribution root.
Parent directories are created as needed for the directory sink.
"""
if self.__dist_dir is not None:
target_filepath = self.__dist_dir / arcname
target_filepath.parent.mkdir(parents=True, exist_ok=True)
@@ -1,3 +1,10 @@
"""Resolution of the files and generated texts that make up a distribution.
Given an :class:`ArtifactContext`, the ``resolve_*_copy`` generators yield the
header copies, library copies and generated CMake/pkg-config texts that the
archiver then materializes.
"""
import sys
import logging
from typing import Iterator
@@ -16,15 +23,21 @@ class ArtifactContext:
__metadata: Metadata
def __init__(self, extractor: MetadataExtractor) -> None:
"""Wrap an extractor and eagerly fetch its :class:`Metadata`.
:param extractor: The extractor to wrap and read from.
"""
self.__extractor = extractor
self.__metadata = self.__extractor.get_metadata()
@property
def extractor(self) -> MetadataExtractor:
"""The wrapped :class:`MetadataExtractor`."""
return self.__extractor
@property
def metadata(self) -> Metadata:
"""The cached :class:`Metadata` read from the extractor."""
return self.__metadata
@@ -61,6 +74,8 @@ def _extract_common_prefix(pattern: str) -> tuple[str, str]:
@dataclass(frozen=True)
class FileCopyInfo:
"""A single on-disk file to copy into the distribution."""
from_path: Path
"""The absolute path pointing to source file without any wildcard"""
to_path: Path
@@ -68,6 +83,11 @@ class FileCopyInfo:
def resolve_include_copy(ctx: ArtifactContext) -> Iterator[FileCopyInfo]:
"""Yield :class:`FileCopyInfo` for every header declared in the metadata.
Literal ``from`` paths produce a single entry; glob ``from`` paths are
expanded relative to their immutable leading prefix.
"""
extractor = ctx.extractor
metadata = ctx.metadata
project_root = Path(extractor.get_project_dir())
@@ -93,6 +113,7 @@ def resolve_include_copy(ctx: ArtifactContext) -> Iterator[FileCopyInfo]:
def _generate_dll_artifact_name(name: str) -> str:
"""Return the platform-appropriate dynamic-loadable file name for ``name``."""
match sys.platform:
case "win32" | "cygwin":
return f"{name}.dll"
@@ -105,11 +126,17 @@ def _generate_dll_artifact_name(name: str) -> str:
def _generate_lib_artifact_name(name: str) -> str:
"""Return the Windows import-library file name for ``name``.
Only meaningful on Windows; returned regardless of platform for use by the
CMake properties.
"""
# only Windows has this feature, so we simply return it
return f"{name}.dll.lib"
def resolve_lib_copy(ctx: ArtifactContext) -> Iterator[FileCopyInfo]:
"""Yield :class:`FileCopyInfo` for the built dynamic library (and, on Windows, the import library)."""
extractor = ctx.extractor
target_directory = extractor.get_target_directory() / "release"
target_name = extractor.get_target_name()
@@ -131,6 +158,8 @@ def resolve_lib_copy(ctx: ArtifactContext) -> Iterator[FileCopyInfo]:
@dataclass(frozen=True)
class TextCopyInfo:
"""A piece of generated text to write into the distribution."""
text: str
"""The content of source file"""
to_path: Path
@@ -141,6 +170,10 @@ _BAD_NAME_PATTERN: Pattern = compile(r"[^a-zA-Z0-9_+-]")
def _sanitize_name(name: str) -> str:
"""Strip characters disallowed in generated identifiers from ``name``.
:raises ValueError: if nothing remains after stripping.
"""
new_name = _BAD_NAME_PATTERN.sub("", name)
if new_name == "":
raise ValueError("name is blank after sanitizing")
@@ -149,6 +182,7 @@ def _sanitize_name(name: str) -> str:
def resolve_cmake_copy(ctx: ArtifactContext) -> Iterator[TextCopyInfo]:
"""Yield :class:`TextCopyInfo` for the generated CMake ``Config`` and ``ConfigVersion`` files."""
extractor = ctx.extractor
metadata = ctx.metadata
target_name = extractor.get_target_name()
@@ -195,6 +229,7 @@ def resolve_cmake_copy(ctx: ArtifactContext) -> Iterator[TextCopyInfo]:
def resolve_pkgconfig_copy(ctx: ArtifactContext) -> Iterator[TextCopyInfo]:
"""Yield :class:`TextCopyInfo` for the generated pkg-config ``.pc`` file."""
extractor = ctx.extractor
metadata = ctx.metadata
target_name = extractor.get_target_name()
+8
View File
@@ -1,3 +1,11 @@
"""Command-line interface for sarasacw-omrf-packer.
Defines :class:`Cli`, the immutable container of captured options, and
:func:`parse`, which interprets ``sys.argv`` via :mod:`argparse`. Downstream
stages consume a :class:`Cli` instance rather than reading ``sys.argv``
themselves, so the accepted options and their semantics live in one place.
"""
import argparse
from dataclasses import dataclass
from pathlib import Path
+127 -2
View File
@@ -1,3 +1,13 @@
"""Extraction and typed representation of cargo/OMRF metadata.
Defines the frozen :class:`MetadataHeader`/:class:`MetadataCMake`/
:class:`MetadataPkgConfig`/:class:`Metadata` models built from the
``[package.metadata.omrf]`` table, and :class:`MetadataExtractor`, which runs
``cargo metadata`` and exposes the relevant fields. The
:func:`wrap_metadata_errors` decorator gives every raised exception a uniform
``error occurs when fetching metadata`` context.
"""
import json
import os
import subprocess
@@ -7,20 +17,30 @@ from pathlib import Path
from typing import Any, Callable
from semver import Version
from . import utils
from .utils import dict_chain_get, dict_typed_get, dict_typed_get_required
from .utils import dict_chain_get, dict_typed_get, dict_typed_get_required, VERSION
@dataclass(frozen=True)
class MetadataHeader:
"""One ``headers`` asset entry from the OMRF metadata.
Describes a single header file (or, when ``from_path`` is a glob, a set of
files) to install into the ``include`` tree.
"""
from_path: str
"""Source path relative to the project root; may be a UNIX-style glob."""
to_path: str
"""Destination path relative to the ``include`` directory (``""`` means the root)."""
def __post_init__(self) -> None:
"""Validate that ``from_path`` is non-empty."""
if self.from_path == "":
raise ValueError("header 'from' must not be empty")
@staticmethod
def from_dict(d: dict[str, Any]) -> "MetadataHeader":
"""Build a :class:`MetadataHeader` from its raw TOML table."""
return MetadataHeader(
from_path=dict_typed_get_required(d, "from", str),
to_path=dict_typed_get(d, "to", str, ""),
@@ -29,11 +49,28 @@ class MetadataHeader:
@dataclass(frozen=True)
class MetadataCMake:
"""Optional ``[package.metadata.omrf.cmake]`` section."""
namespace_name: str | None
"""CMake target namespace, or ``None`` to fall back to the project name."""
target_name: str | None
"""CMake target name, or ``None`` to fall back to the project name."""
def __post_init__(self) -> None:
"""Validate the namespace and target names when specified.
:raises ValueError: if a specified name is not a legal name.
"""
if self.namespace_name is not None and not utils.is_good_name(
self.namespace_name
):
raise ValueError("bad cmake namespace name")
if self.target_name is not None and not utils.is_good_name(self.target_name):
raise ValueError("bad cmake target name")
@staticmethod
def from_dict(d: dict[str, Any]) -> "MetadataCMake":
"""Build a :class:`MetadataCMake` from its raw TOML table."""
return MetadataCMake(
namespace_name=dict_typed_get(d, "namespace_name", str),
target_name=dict_typed_get(d, "target_name", str),
@@ -42,12 +79,33 @@ class MetadataCMake:
@dataclass(frozen=True)
class MetadataPkgConfig:
"""Optional ``[package.metadata.omrf.pkgconfig]`` section."""
id: str | None
"""pkg-config package id (the ``.pc`` file stem), or ``None`` to fall back to the project name."""
name: str | None
"""Human-readable package name, or ``None`` to fall back to the project name."""
description: str | None
"""Brief package description, or ``None`` to fall back to the project description."""
def __post_init__(self) -> None:
"""Validate the id, name and description when specified.
:raises ValueError: if a specified id is not a legal name, or a
specified name/description contains EOL characters.
"""
if self.id is not None and not utils.is_good_name(self.id):
raise ValueError("bad pkg-config id")
if self.name is not None and not utils.is_good_sentence(self.name):
raise ValueError("bad pkg-config name (has EOL chars)")
if self.description is not None and not utils.is_good_sentence(
self.description
):
raise ValueError("bad pkg-config description (has EOL chars)")
@staticmethod
def from_dict(d: dict[str, Any]) -> "MetadataPkgConfig":
"""Build a :class:`MetadataPkgConfig` from its raw TOML table."""
return MetadataPkgConfig(
id=dict_typed_get(d, "id", str),
name=dict_typed_get(d, "name", str),
@@ -57,13 +115,33 @@ class MetadataPkgConfig:
@dataclass(frozen=True)
class Metadata:
"""Typed view of the whole ``[package.metadata.omrf]`` table."""
min_version: Version | None
"""Minimum packer version required by the project, or ``None`` for no constraint."""
headers: tuple[MetadataHeader, ...]
"""Header assets to distribute (required, possibly empty)."""
cmake: MetadataCMake | None
"""CMake override section, or ``None`` when absent."""
pkgconfig: MetadataPkgConfig | None
"""pkg-config override section, or ``None`` when absent."""
def __post_init__(self) -> None:
"""Enforce ``min_version`` against the running packer version.
:raises RuntimeError: if ``min_version`` is set and newer than this packer.
"""
# check version restriction
this_version = self.min_version
if this_version is not None:
if this_version > VERSION:
raise RuntimeError(
f"requested minimum version is not fulfilled. {this_version} required got {VERSION}"
)
@staticmethod
def from_dict(d: dict[str, Any]) -> "Metadata":
"""Build a :class:`Metadata` from the raw ``[package.metadata.omrf]`` table."""
raw_min_version = dict_typed_get(d, "min_version", str)
if raw_min_version is not None:
min_version = utils.parse_version(raw_min_version)
@@ -97,6 +175,13 @@ class Metadata:
def wrap_metadata_errors[**P, R](func: Callable[P, R]) -> Callable[P, R]:
"""Decorator that wraps a function's exceptions in a uniform metadata context.
Any :class:`Exception` raised by the wrapped function is re-raised as
``RuntimeError("error occurs when fetching metadata: ...")`` chained to the
original cause.
"""
@wraps(func)
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
try:
@@ -108,6 +193,15 @@ def wrap_metadata_errors[**P, R](func: Callable[P, R]) -> Callable[P, R]:
class MetadataExtractor:
"""Access layer over ``cargo metadata`` and the OMRF metadata table.
On construction it runs ``cargo metadata``, locates the user-requested
package and its main library target, and caches the raw dicts. The getter
methods expose typed views of individual fields; :meth:`get_metadata`
returns the parsed :class:`Metadata` model. Every getter is wrapped by
:func:`wrap_metadata_errors`.
"""
__metadata: dict[str, Any]
"""The direct output of ``cargo metadata``"""
__metadata_package: dict[str, Any]
@@ -116,6 +210,10 @@ class MetadataExtractor:
"""The target item in package item's targets list pointing to the main target"""
def __init__(self, cargo_toml_path: Path) -> None:
"""Run ``cargo metadata`` and locate the package and main target.
:param cargo_toml_path: Path to the ``Cargo.toml`` of the project to pack.
"""
self.__metadata = MetadataExtractor.__extract_metadata(cargo_toml_path)
self.__metadata_package = MetadataExtractor.__extract_metadata_package(
self.__metadata, cargo_toml_path
@@ -127,6 +225,12 @@ class MetadataExtractor:
@staticmethod
@wrap_metadata_errors
def __extract_metadata(cargo_toml_path: Path) -> dict[str, Any]:
"""Invoke ``cargo metadata`` and return its parsed JSON output.
:param cargo_toml_path: Path to the manifest passed via ``--manifest-path``.
:returns: Parsed ``cargo metadata`` output.
:raises RuntimeError: if cargo times out or exits with a non-zero status.
"""
cargo_bin = os.getenv("OMRF_PACKER_CARGO", "cargo")
cmd = [
cargo_bin,
@@ -157,6 +261,13 @@ class MetadataExtractor:
def __extract_metadata_package(
cargo_metadata: dict[str, Any], cargo_toml_path: Path
) -> dict[str, Any]:
"""Find the package whose ``manifest_path`` matches the given manifest.
:param cargo_metadata: Parsed ``cargo metadata`` output.
:param cargo_toml_path: Manifest path of the user-requested package.
:returns: The matching package item.
:raises RuntimeError: if no matching package is found.
"""
packages: list[Any] = dict_typed_get_required(cargo_metadata, "packages", list)
for i, package in enumerate(packages):
if not isinstance(package, dict):
@@ -173,6 +284,13 @@ class MetadataExtractor:
def __extract_metadata_target(
cargo_package: dict[str, Any], cargo_toml_path: Path
) -> dict[str, Any]:
"""Find the library target (the one with ``src/lib.rs``) of a package.
:param cargo_package: A package item from ``cargo metadata``.
:param cargo_toml_path: Manifest path used to derive the ``src/lib.rs`` location.
:returns: The matching target item.
:raises RuntimeError: if no library target is found.
"""
# build the path to lib.rs for comparing
librs = cargo_toml_path.parent / "src" / "lib.rs"
# start checking
@@ -192,19 +310,24 @@ class MetadataExtractor:
@wrap_metadata_errors
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)
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:
"""Return the package name as reported by cargo."""
return dict_typed_get_required(self.__metadata_package, "name", str)
@wrap_metadata_errors
def get_description(self) -> str | None:
"""Return the package description, or ``None`` if unset."""
return dict_typed_get(self.__metadata_package, "description", str)
@wrap_metadata_errors
def get_version(self) -> Version:
"""Return the package version as a validated :class:`semver.Version`."""
raw_version = dict_typed_get_required(self.__metadata_package, "version", str)
return utils.parse_version(raw_version)
@@ -218,9 +341,11 @@ class MetadataExtractor:
@wrap_metadata_errors
def get_metadata(self) -> Metadata:
"""Return the parsed :class:`Metadata` model from the OMRF table."""
omrf = dict_chain_get(self.__metadata_package, "metadata", "omrf")
return Metadata.from_dict(omrf)
@wrap_metadata_errors
def get_target_name(self) -> str:
"""Return the name of the main library target."""
return dict_typed_get_required(self.__metadata_target, "name", str)
@@ -9,25 +9,11 @@ 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.search(s) is None
@dataclass(frozen=True)
class PkgConfigProperties:
"""Inputs required to render the pkg-config ``.pc`` file.
@@ -64,9 +50,9 @@ class PkgConfigProperties:
version carries a prerelease or build component (unsupported in
pkg-config).
"""
if self.name == "" or not _is_good_sentence(self.name):
if self.name == "" or not utils.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):
if self.description == "" or not utils.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")
+66 -5
View File
@@ -1,3 +1,9 @@
"""Shared helpers used across sarasacw-omrf-packer.
Provides version parsing/checking, typed dictionary access helpers, name
validation and template-directory resolution.
"""
from pathlib import Path
from re import Pattern, compile
from typing import Any, overload
@@ -15,26 +21,47 @@ 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 the resolved directory that contains this package."""
return Path(__file__).resolve().parent
def get_templates_dir() -> Path:
"""Return the directory holding the Jinja2 templates (``<root>/templates``)."""
return get_root_dir() / "templates"
@overload
def dict_typed_get[T](d: dict[str, Any], key: str, ty: type[T]) -> T | None: ...
@overload
def dict_typed_get[T, D](d: dict[str, Any], key: str, ty: type[T], default: D) -> T | D: ...
def dict_typed_get[T](d: dict[str, Any], key: str, ty: type[T], default: Any = None) -> Any:
def dict_typed_get[T, D](
d: dict[str, Any], key: str, ty: type[T], default: D
) -> T | D: ...
def dict_typed_get[T](
d: dict[str, Any], key: str, ty: type[T], default: Any = None
) -> Any:
"""Fetch ``key`` from ``d`` with an optional default, checking its type.
When the key is missing (or maps to ``None``) ``default`` is returned. When
present, the value must be an instance of ``ty`` or a :class:`TypeError` is
raised.
:param d: Dictionary to read from.
:param key: Key to look up.
:param ty: Expected type of the value; used for the ``isinstance`` check.
:param default: Value returned when the key is absent (defaults to ``None``).
:returns: The stored value (typed ``ty``) or ``default``.
:raises TypeError: if the stored value is not an instance of ``ty``.
"""
tmp = d.get(key, None)
if tmp is None:
return default
@@ -45,6 +72,18 @@ def dict_typed_get[T](d: dict[str, Any], key: str, ty: type[T], default: Any = N
def dict_typed_get_required[T](d: dict[str, Any], key: str, ty: type[T]) -> T:
"""Fetch ``key`` from ``d``, requiring it to be present and correctly typed.
Like :func:`dict_typed_get` but raises :class:`ValueError` when the key is
missing (or maps to ``None``) instead of returning a default.
:param d: Dictionary to read from.
:param key: Key to look up.
:param ty: Expected type of the value.
:returns: The stored value, typed ``ty``.
:raises ValueError: if the key is absent.
:raises TypeError: if the stored value is not an instance of ``ty``.
"""
result = dict_typed_get(d, key, ty)
if result is None:
raise ValueError(f'can not find key "{key}" in given dictionary')
@@ -52,19 +91,41 @@ def dict_typed_get_required[T](d: dict[str, Any], key: str, ty: type[T]) -> T:
def dict_chain_get(d: dict[str, Any], *args: str) -> dict[str, Any]:
"""Descend through a chain of keys, each required to be a ``dict``.
Equivalent to calling :func:`dict_typed_get_required` with ``dict`` on each
key in turn.
:param d: Dictionary to start from.
:param args: Keys to traverse, in order.
:returns: The nested dictionary reached after following every key.
"""
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
_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.search(s) is None