From 520bfc205482a27a8a326238ad7a767bbf280c81 Mon Sep 17 00:00:00 2001 From: yyc12345 Date: Wed, 5 Aug 2026 15:53:52 +0800 Subject: [PATCH] doc: add docstring and do some refactor --- packer/src/sarasacw_omrf_packer/__init__.py | 28 +++++ packer/src/sarasacw_omrf_packer/archiver.py | 11 ++ packer/src/sarasacw_omrf_packer/artifact.py | 35 ++++++ packer/src/sarasacw_omrf_packer/cli.py | 8 ++ packer/src/sarasacw_omrf_packer/metadata.py | 114 ++++++++++++++++++ .../sarasacw_omrf_packer/renders/pkgconfig.py | 18 +-- packer/src/sarasacw_omrf_packer/utils.py | 71 ++++++++++- 7 files changed, 264 insertions(+), 21 deletions(-) diff --git a/packer/src/sarasacw_omrf_packer/__init__.py b/packer/src/sarasacw_omrf_packer/__init__.py index cc649e3..1c2a8a7 100644 --- a/packer/src/sarasacw_omrf_packer/__init__.py +++ b/packer/src/sarasacw_omrf_packer/__init__.py @@ -1,3 +1,11 @@ +"""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 @@ -14,11 +22,21 @@ 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 # build essential instances @@ -26,6 +44,12 @@ class App: 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 @@ -53,6 +77,10 @@ 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 diff --git a/packer/src/sarasacw_omrf_packer/archiver.py b/packer/src/sarasacw_omrf_packer/archiver.py index 8245811..2b94b37 100644 --- a/packer/src/sarasacw_omrf_packer/archiver.py +++ b/packer/src/sarasacw_omrf_packer/archiver.py @@ -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) diff --git a/packer/src/sarasacw_omrf_packer/artifact.py b/packer/src/sarasacw_omrf_packer/artifact.py index 15d9a47..544fa30 100644 --- a/packer/src/sarasacw_omrf_packer/artifact.py +++ b/packer/src/sarasacw_omrf_packer/artifact.py @@ -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() diff --git a/packer/src/sarasacw_omrf_packer/cli.py b/packer/src/sarasacw_omrf_packer/cli.py index c7ada17..245818b 100644 --- a/packer/src/sarasacw_omrf_packer/cli.py +++ b/packer/src/sarasacw_omrf_packer/cli.py @@ -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 diff --git a/packer/src/sarasacw_omrf_packer/metadata.py b/packer/src/sarasacw_omrf_packer/metadata.py index 2366ce6..74e3ae8 100644 --- a/packer/src/sarasacw_omrf_packer/metadata.py +++ b/packer/src/sarasacw_omrf_packer/metadata.py @@ -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 @@ -12,15 +22,25 @@ from .utils import dict_chain_get, dict_typed_get, dict_typed_get_required, VERS @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,12 +115,22 @@ 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: @@ -73,6 +141,7 @@ class Metadata: @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) @@ -106,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: @@ -117,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] @@ -125,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 @@ -136,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, @@ -166,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): @@ -182,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 @@ -208,14 +317,17 @@ class MetadataExtractor: @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) @@ -229,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) diff --git a/packer/src/sarasacw_omrf_packer/renders/pkgconfig.py b/packer/src/sarasacw_omrf_packer/renders/pkgconfig.py index e0e7eab..b82925c 100644 --- a/packer/src/sarasacw_omrf_packer/renders/pkgconfig.py +++ b/packer/src/sarasacw_omrf_packer/renders/pkgconfig.py @@ -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") diff --git a/packer/src/sarasacw_omrf_packer/utils.py b/packer/src/sarasacw_omrf_packer/utils.py index a46ddf0..7995b7b 100644 --- a/packer/src/sarasacw_omrf_packer/utils.py +++ b/packer/src/sarasacw_omrf_packer/utils.py @@ -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 (``/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