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 logging
import sys import sys
from pathlib import Path from pathlib import Path
from .utils import VERSION
from .cli import Cli, parse as parse_cli from .cli import Cli, parse as parse_cli
from .artifact import ( from .artifact import (
ArtifactContext, ArtifactContext,
@@ -15,30 +22,34 @@ from .metadata import MetadataExtractor
class App: 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 __opts: Cli
__extractor: MetadataExtractor __extractor: MetadataExtractor
__ctx: ArtifactContext __ctx: ArtifactContext
def __init__(self, opts: Cli) -> None: def __init__(self, opts: Cli) -> None:
"""Build the extractor and artifact context from CLI options.
:param opts: Parsed command-line options.
"""
# assign cli options # assign cli options
self.__opts = opts self.__opts = opts
# initialize packer # build essential instances
try: self.__extractor = MetadataExtractor(self.__opts.manifest)
# build essential instances self.__ctx = ArtifactContext(self.__extractor)
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)
def run(self) -> None: 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 # create distribution
with Archiver(self.__opts.dist_dir, self.__opts.dist_zip) as archiver: with Archiver(self.__opts.dist_dir, self.__opts.dist_zip) as archiver:
# create basic directory # create basic directory
@@ -66,10 +77,23 @@ class App:
def main() -> None: 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 # parse command line arguments
opts = parse_cli() 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)
app = App(opts) # initialize packer and run
app.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 zipfile
import shutil import shutil
from pathlib import Path from pathlib import Path
@@ -43,6 +49,11 @@ class Archiver:
self.__dist_zip = None self.__dist_zip = None
def push_dir(self, arcname: Path) -> 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: if self.__dist_dir is not None:
target_filepath = self.__dist_dir / arcname target_filepath = self.__dist_dir / arcname
target_filepath.parent.mkdir(parents=True, exist_ok=True) 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 sys
import logging import logging
from typing import Iterator from typing import Iterator
@@ -16,15 +23,21 @@ class ArtifactContext:
__metadata: Metadata __metadata: Metadata
def __init__(self, extractor: MetadataExtractor) -> None: 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.__extractor = extractor
self.__metadata = self.__extractor.get_metadata() self.__metadata = self.__extractor.get_metadata()
@property @property
def extractor(self) -> MetadataExtractor: def extractor(self) -> MetadataExtractor:
"""The wrapped :class:`MetadataExtractor`."""
return self.__extractor return self.__extractor
@property @property
def metadata(self) -> Metadata: def metadata(self) -> Metadata:
"""The cached :class:`Metadata` read from the extractor."""
return self.__metadata return self.__metadata
@@ -61,6 +74,8 @@ def _extract_common_prefix(pattern: str) -> tuple[str, str]:
@dataclass(frozen=True) @dataclass(frozen=True)
class FileCopyInfo: class FileCopyInfo:
"""A single on-disk file to copy into the distribution."""
from_path: Path from_path: Path
"""The absolute path pointing to source file without any wildcard""" """The absolute path pointing to source file without any wildcard"""
to_path: Path to_path: Path
@@ -68,6 +83,11 @@ class FileCopyInfo:
def resolve_include_copy(ctx: ArtifactContext) -> Iterator[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 extractor = ctx.extractor
metadata = ctx.metadata metadata = ctx.metadata
project_root = Path(extractor.get_project_dir()) 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: def _generate_dll_artifact_name(name: str) -> str:
"""Return the platform-appropriate dynamic-loadable file name for ``name``."""
match sys.platform: match sys.platform:
case "win32" | "cygwin": case "win32" | "cygwin":
return f"{name}.dll" return f"{name}.dll"
@@ -105,11 +126,17 @@ def _generate_dll_artifact_name(name: str) -> str:
def _generate_lib_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 # only Windows has this feature, so we simply return it
return f"{name}.dll.lib" return f"{name}.dll.lib"
def resolve_lib_copy(ctx: ArtifactContext) -> Iterator[FileCopyInfo]: 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 extractor = ctx.extractor
target_directory = extractor.get_target_directory() / "release" target_directory = extractor.get_target_directory() / "release"
target_name = extractor.get_target_name() target_name = extractor.get_target_name()
@@ -131,6 +158,8 @@ def resolve_lib_copy(ctx: ArtifactContext) -> Iterator[FileCopyInfo]:
@dataclass(frozen=True) @dataclass(frozen=True)
class TextCopyInfo: class TextCopyInfo:
"""A piece of generated text to write into the distribution."""
text: str text: str
"""The content of source file""" """The content of source file"""
to_path: Path to_path: Path
@@ -141,6 +170,10 @@ _BAD_NAME_PATTERN: Pattern = compile(r"[^a-zA-Z0-9_+-]")
def _sanitize_name(name: str) -> str: 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) new_name = _BAD_NAME_PATTERN.sub("", name)
if new_name == "": if new_name == "":
raise ValueError("name is blank after sanitizing") 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]: def resolve_cmake_copy(ctx: ArtifactContext) -> Iterator[TextCopyInfo]:
"""Yield :class:`TextCopyInfo` for the generated CMake ``Config`` and ``ConfigVersion`` files."""
extractor = ctx.extractor extractor = ctx.extractor
metadata = ctx.metadata metadata = ctx.metadata
target_name = extractor.get_target_name() 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]: def resolve_pkgconfig_copy(ctx: ArtifactContext) -> Iterator[TextCopyInfo]:
"""Yield :class:`TextCopyInfo` for the generated pkg-config ``.pc`` file."""
extractor = ctx.extractor extractor = ctx.extractor
metadata = ctx.metadata metadata = ctx.metadata
target_name = extractor.get_target_name() 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 import argparse
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path 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 json
import os import os
import subprocess import subprocess
@@ -7,20 +17,30 @@ from pathlib import Path
from typing import Any, Callable from typing import Any, Callable
from semver import Version from semver import Version
from . import utils 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) @dataclass(frozen=True)
class MetadataHeader: 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 from_path: str
"""Source path relative to the project root; may be a UNIX-style glob."""
to_path: str to_path: str
"""Destination path relative to the ``include`` directory (``""`` means the root)."""
def __post_init__(self) -> None: def __post_init__(self) -> None:
"""Validate that ``from_path`` is non-empty."""
if self.from_path == "": if self.from_path == "":
raise ValueError("header 'from' must not be empty") raise ValueError("header 'from' must not be empty")
@staticmethod @staticmethod
def from_dict(d: dict[str, Any]) -> "MetadataHeader": def from_dict(d: dict[str, Any]) -> "MetadataHeader":
"""Build a :class:`MetadataHeader` from its raw TOML table."""
return MetadataHeader( return MetadataHeader(
from_path=dict_typed_get_required(d, "from", str), from_path=dict_typed_get_required(d, "from", str),
to_path=dict_typed_get(d, "to", str, ""), to_path=dict_typed_get(d, "to", str, ""),
@@ -29,11 +49,28 @@ class MetadataHeader:
@dataclass(frozen=True) @dataclass(frozen=True)
class MetadataCMake: class MetadataCMake:
"""Optional ``[package.metadata.omrf.cmake]`` section."""
namespace_name: str | None namespace_name: str | None
"""CMake target namespace, or ``None`` to fall back to the project name."""
target_name: str | None 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 @staticmethod
def from_dict(d: dict[str, Any]) -> "MetadataCMake": def from_dict(d: dict[str, Any]) -> "MetadataCMake":
"""Build a :class:`MetadataCMake` from its raw TOML table."""
return MetadataCMake( return MetadataCMake(
namespace_name=dict_typed_get(d, "namespace_name", str), namespace_name=dict_typed_get(d, "namespace_name", str),
target_name=dict_typed_get(d, "target_name", str), target_name=dict_typed_get(d, "target_name", str),
@@ -42,12 +79,33 @@ class MetadataCMake:
@dataclass(frozen=True) @dataclass(frozen=True)
class MetadataPkgConfig: class MetadataPkgConfig:
"""Optional ``[package.metadata.omrf.pkgconfig]`` section."""
id: str | None id: str | None
"""pkg-config package id (the ``.pc`` file stem), or ``None`` to fall back to the project name."""
name: str | None name: str | None
"""Human-readable package name, or ``None`` to fall back to the project name."""
description: str | None 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 @staticmethod
def from_dict(d: dict[str, Any]) -> "MetadataPkgConfig": def from_dict(d: dict[str, Any]) -> "MetadataPkgConfig":
"""Build a :class:`MetadataPkgConfig` from its raw TOML table."""
return MetadataPkgConfig( return MetadataPkgConfig(
id=dict_typed_get(d, "id", str), id=dict_typed_get(d, "id", str),
name=dict_typed_get(d, "name", str), name=dict_typed_get(d, "name", str),
@@ -57,13 +115,33 @@ class MetadataPkgConfig:
@dataclass(frozen=True) @dataclass(frozen=True)
class Metadata: class Metadata:
"""Typed view of the whole ``[package.metadata.omrf]`` table."""
min_version: Version | None min_version: Version | None
"""Minimum packer version required by the project, or ``None`` for no constraint."""
headers: tuple[MetadataHeader, ...] headers: tuple[MetadataHeader, ...]
"""Header assets to distribute (required, possibly empty)."""
cmake: MetadataCMake | None cmake: MetadataCMake | None
"""CMake override section, or ``None`` when absent."""
pkgconfig: MetadataPkgConfig | None 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 @staticmethod
def from_dict(d: dict[str, Any]) -> "Metadata": 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) raw_min_version = dict_typed_get(d, "min_version", str)
if raw_min_version is not None: if raw_min_version is not None:
min_version = utils.parse_version(raw_min_version) 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]: 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) @wraps(func)
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R: def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
try: try:
@@ -108,6 +193,15 @@ def wrap_metadata_errors[**P, R](func: Callable[P, R]) -> Callable[P, R]:
class MetadataExtractor: 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] __metadata: dict[str, Any]
"""The direct output of ``cargo metadata``""" """The direct output of ``cargo metadata``"""
__metadata_package: dict[str, Any] __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""" """The target item in package item's targets list pointing to the main target"""
def __init__(self, cargo_toml_path: Path) -> None: 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 = MetadataExtractor.__extract_metadata(cargo_toml_path)
self.__metadata_package = MetadataExtractor.__extract_metadata_package( self.__metadata_package = MetadataExtractor.__extract_metadata_package(
self.__metadata, cargo_toml_path self.__metadata, cargo_toml_path
@@ -127,6 +225,12 @@ class MetadataExtractor:
@staticmethod @staticmethod
@wrap_metadata_errors @wrap_metadata_errors
def __extract_metadata(cargo_toml_path: Path) -> dict[str, Any]: 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") cargo_bin = os.getenv("OMRF_PACKER_CARGO", "cargo")
cmd = [ cmd = [
cargo_bin, cargo_bin,
@@ -157,6 +261,13 @@ class MetadataExtractor:
def __extract_metadata_package( def __extract_metadata_package(
cargo_metadata: dict[str, Any], cargo_toml_path: Path cargo_metadata: dict[str, Any], cargo_toml_path: Path
) -> dict[str, Any]: ) -> 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) packages: list[Any] = dict_typed_get_required(cargo_metadata, "packages", list)
for i, package in enumerate(packages): for i, package in enumerate(packages):
if not isinstance(package, dict): if not isinstance(package, dict):
@@ -173,6 +284,13 @@ class MetadataExtractor:
def __extract_metadata_target( def __extract_metadata_target(
cargo_package: dict[str, Any], cargo_toml_path: Path cargo_package: dict[str, Any], cargo_toml_path: Path
) -> dict[str, Any]: ) -> 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 # build the path to lib.rs for comparing
librs = cargo_toml_path.parent / "src" / "lib.rs" librs = cargo_toml_path.parent / "src" / "lib.rs"
# start checking # start checking
@@ -192,19 +310,24 @@ class MetadataExtractor:
@wrap_metadata_errors @wrap_metadata_errors
def get_target_directory(self) -> Path: def get_target_directory(self) -> Path:
"""Get the absolute path to directory where Rust target directory is""" """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) return Path(raw_target_directory)
@wrap_metadata_errors @wrap_metadata_errors
def get_name(self) -> str: def get_name(self) -> str:
"""Return the package name as reported by cargo."""
return dict_typed_get_required(self.__metadata_package, "name", str) return dict_typed_get_required(self.__metadata_package, "name", str)
@wrap_metadata_errors @wrap_metadata_errors
def get_description(self) -> str | None: def get_description(self) -> str | None:
"""Return the package description, or ``None`` if unset."""
return dict_typed_get(self.__metadata_package, "description", str) return dict_typed_get(self.__metadata_package, "description", str)
@wrap_metadata_errors @wrap_metadata_errors
def get_version(self) -> Version: 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) raw_version = dict_typed_get_required(self.__metadata_package, "version", str)
return utils.parse_version(raw_version) return utils.parse_version(raw_version)
@@ -218,9 +341,11 @@ class MetadataExtractor:
@wrap_metadata_errors @wrap_metadata_errors
def get_metadata(self) -> Metadata: def get_metadata(self) -> Metadata:
"""Return the parsed :class:`Metadata` model from the OMRF table."""
omrf = dict_chain_get(self.__metadata_package, "metadata", "omrf") omrf = dict_chain_get(self.__metadata_package, "metadata", "omrf")
return Metadata.from_dict(omrf) return Metadata.from_dict(omrf)
@wrap_metadata_errors @wrap_metadata_errors
def get_target_name(self) -> str: def get_target_name(self) -> str:
"""Return the name of the main library target."""
return dict_typed_get_required(self.__metadata_target, "name", str) 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 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 .. 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.search(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.
@@ -64,9 +50,9 @@ 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 == "" 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") 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") raise ValueError("bad description (blank or has EOL chars) of pkg-config")
if not utils.is_good_name(self.artifact): if not utils.is_good_name(self.artifact):
raise ValueError("bad artifact name in pkg-config") raise ValueError("bad artifact name in pkg-config")
+64 -3
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 pathlib import Path
from re import Pattern, compile from re import Pattern, compile
from typing import Any, overload from typing import Any, overload
@@ -15,6 +21,7 @@ def parse_version(vs: str) -> Version:
else: else:
return v return v
def is_good_version(v: Version) -> bool: def is_good_version(v: Version) -> bool:
"""Check whether given version has ``prerelease`` or ``build`` components which is not allowed in this package. """Check whether given version has ``prerelease`` or ``build`` components which is not allowed in this package.
@@ -22,19 +29,39 @@ def is_good_version(v: Version) -> bool:
""" """
return v.prerelease is None and v.build is None return v.prerelease is None and v.build is None
def get_root_dir() -> Path: def get_root_dir() -> Path:
"""Return the resolved directory that contains this package."""
return Path(__file__).resolve().parent return Path(__file__).resolve().parent
def get_templates_dir() -> Path: def get_templates_dir() -> Path:
"""Return the directory holding the Jinja2 templates (``<root>/templates``)."""
return get_root_dir() / "templates" return get_root_dir() / "templates"
@overload @overload
def dict_typed_get[T](d: dict[str, Any], key: str, ty: type[T]) -> T | None: ... def dict_typed_get[T](d: dict[str, Any], key: str, ty: type[T]) -> T | None: ...
@overload @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](
def dict_typed_get[T](d: dict[str, Any], key: str, ty: type[T], default: Any = None) -> Any: 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) tmp = d.get(key, None)
if tmp is None: if tmp is None:
return default 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: 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) result = dict_typed_get(d, key, ty)
if result is None: if result is None:
raise ValueError(f'can not find key "{key}" in given dictionary') raise ValueError(f'can not find key "{key}" in given dictionary')
@@ -52,14 +91,23 @@ 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]: 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: 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_+-]+") _NAME_PATTERN: Pattern = compile(r"[a-zA-Z0-9_+-]+")
def is_good_name(name: str) -> bool: def is_good_name(name: str) -> bool:
"""This package specific name checker """This package specific name checker
@@ -68,3 +116,16 @@ def is_good_name(name: str) -> bool:
:returns: True if given name is legal, otherwise false. :returns: True if given name is legal, otherwise false.
""" """
return _NAME_PATTERN.fullmatch(name) is not None 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