Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
520bfc2054 | ||
|
|
816e3a59d0 |
@@ -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
|
|
||||||
try:
|
|
||||||
# build essential instances
|
# build essential instances
|
||||||
self.__extractor = MetadataExtractor(self.__opts.manifest)
|
self.__extractor = MetadataExtractor(self.__opts.manifest)
|
||||||
self.__ctx = ArtifactContext(self.__extractor)
|
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)
|
||||||
|
|
||||||
|
# initialize packer and run
|
||||||
|
try:
|
||||||
app = App(opts)
|
app = App(opts)
|
||||||
|
except Exception as e:
|
||||||
|
logging.error("fail to initialize packer: %s", e)
|
||||||
|
sys.exit(1)
|
||||||
|
try:
|
||||||
app.run()
|
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()
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user