diff --git a/packer/pyproject.toml b/packer/pyproject.toml index f1282b1..3facf5e 100644 --- a/packer/pyproject.toml +++ b/packer/pyproject.toml @@ -9,6 +9,7 @@ authors = [ requires-python = ">=3.13" dependencies = [ "jinja2==3.1.6", + "semver>=3.0.4", ] [project.scripts] diff --git a/packer/src/sarasacw_omrf_packer/renders/__init__.py b/packer/src/sarasacw_omrf_packer/renders/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/packer/src/sarasacw_omrf_packer/renders/cmake.py b/packer/src/sarasacw_omrf_packer/renders/cmake.py index e359313..484c698 100644 --- a/packer/src/sarasacw_omrf_packer/renders/cmake.py +++ b/packer/src/sarasacw_omrf_packer/renders/cmake.py @@ -1,24 +1,114 @@ +"""Renderer for CMake package-configuration files. + +Generates the ``Config.cmake`` and ``ConfigVersion.cmake`` files that +allow a downstream CMake project to ``find_package`` the distributed Rust FFI +library. The data fed to the templates is carried by +:class:`CMakeProperties`, and :class:`CMakeRender` performs the rendering on +top of :class:`renders.common.BaseRender`. +""" + from dataclasses import dataclass -from pathlib import Path +from typing import Any +from re import Pattern, compile from . import common +from semver import Version + + +_CMAKE_NAME_PATTERN: Pattern = compile(r"[a-zA-Z0-9_.+-]+") @dataclass(frozen=True) class CMakeProperties: - name: str - """The identifier of project""" + """Inputs required to render the CMake package-configuration files. + + A frozen dataclass validated in :meth:`__post_init__`; once constructed, an + instance is guaranteed to hold only values that are safe to substitute into + the CMake templates. + """ + + namespace_name: str + """Namespace component used when generating the CMake scripts.""" + target_name: str + """Target-name component used when generating the CMake scripts.""" + + artifact_dll: str + """File name of the dynamic-loadable artifact, including its platform-dependent suffix. + + Typically ``example.dll`` on Windows, ``example.so`` on Linux, and + ``example.dylib`` on macOS. + """ + artifact_lib: str + """File name of the import-library artifact, including its platform-dependent suffix. + + Typically the dynamic-loadable file name with its suffix replaced by + ``.lib``. This field is unused on UNIX-like systems, but it must still be + provided or rendering will fail. + """ + + version: Version + """Version of the library. Must not carry a prerelease or build component.""" + + def __post_init__(self) -> None: + """Validate the fields after construction. + + :raises ValueError: if the namespace or target name contains characters + outside the CMake name pattern, an artifact file name is empty, or + the version carries a prerelease or build component (unsupported by + CMake config-version files). + """ + if _CMAKE_NAME_PATTERN.fullmatch(self.namespace_name) is None: + raise ValueError( + "bad namespace component in CMake. consider manually specifying it" + ) + if _CMAKE_NAME_PATTERN.fullmatch(self.target_name) is None: + raise ValueError( + "bad target name component in CMake. consider manually specifying it" + ) + if self.artifact_dll == "": + raise ValueError("bad artifact dll file name") + if self.artifact_lib == "": + raise ValueError("bad artifact lib file name") + if self.version.prerelease is not None or self.version.build is not None: + raise ValueError( + "unsupported semantic version components (prerelease or build) in CMake" + ) class CMakeRender: + """Renderer that turns :class:`CMakeProperties` into CMake config files. + + Wraps a :class:`renders.common.BaseRender` and exposes one method per + generated file (:meth:`render_config`, :meth:`render_config_version`). + """ + __render: common.BaseRender __properties: CMakeProperties def __init__(self, properties: CMakeProperties) -> None: + """Create the renderer with its Jinja2 backend and bound properties. + + :param properties: Validated inputs reused by every render method. + """ self.__render = common.BaseRender() self.__properties = properties - def render_config(self) -> None: - pass + def render_config(self) -> str: + """Render the ``Config.cmake`` file. - def render_config_version(self) -> None: - pass + :returns: The rendered ``Config.cmake`` content as a string. + """ + payload: dict[str, Any] = { + "namespace": self.__properties.namespace_name, + "target": self.__properties.target_name, + "artifact_dll": self.__properties.artifact_dll, + "artifact_lib": self.__properties.artifact_lib, + } + return self.__render.render("XXXConfig.cmake.jinja", payload) + + def render_config_version(self) -> str: + """Render the ``ConfigVersion.cmake`` file. + + :returns: The rendered ``ConfigVersion.cmake`` content as a string. + """ + payload: dict[str, Any] = {"version": str(self.__properties.version)} + return self.__render.render("XXXConfigVersion.cmake.jinja", payload) diff --git a/packer/src/sarasacw_omrf_packer/renders/common.py b/packer/src/sarasacw_omrf_packer/renders/common.py index 5198185..86510ad 100644 --- a/packer/src/sarasacw_omrf_packer/renders/common.py +++ b/packer/src/sarasacw_omrf_packer/renders/common.py @@ -1,22 +1,47 @@ -from pathlib import Path +"""Shared infrastructure for the template-based renderers. + +Concrete renderers such as :class:`renders.cmake.CMakeRender` only decide which +template to render and with which payload; the actual Jinja2 plumbing -- the +loader, the environment and the template lookup -- is centralized here in +:class:`BaseRender`. +""" + from typing import Any import jinja2 from .. import utils class BaseRender: + """Jinja2-backed renderer shared by every concrete renderer. + + A single Jinja2 :class:`~jinja2.Environment` is created at construction + time, backed by a :class:`~jinja2.FileSystemLoader` rooted at the + package's template directory. The environment is reused across all + :meth:`render` calls. + """ + __loader: jinja2.BaseLoader __environment: jinja2.Environment def __init__(self) -> None: + """Create the Jinja2 loader and environment. + + The environment is configured to load templates from the directory + returned by :func:`utils.get_templates_dir`. + """ self.__loader = jinja2.FileSystemLoader(utils.get_templates_dir()) self.__environment = jinja2.Environment(loader=self.__loader) - def render( - self, template_filename: str, dest_filepath: Path, payload: dict[str, Any] - ) -> None: + def render(self, template_filename: str, payload: dict[str, Any]) -> str: + """Render a named template with the given payload. + + :param template_filename: Name of the template file, relative to the + package template directory. + :param payload: Mapping of variable names to their values, forwarded + to the template as its context. + :returns: The rendered template as a string. + """ # fetch template template = self.__environment.get_template(template_filename) - # render template and save - with open(dest_filepath, "w", encoding="utf-8") as f: - f.write(template.render(**payload)) + # render template and return + return template.render(**payload) diff --git a/packer/src/sarasacw_omrf_packer/renders/pkgconfig.py b/packer/src/sarasacw_omrf_packer/renders/pkgconfig.py index 5d68421..3fa466a 100644 --- a/packer/src/sarasacw_omrf_packer/renders/pkgconfig.py +++ b/packer/src/sarasacw_omrf_packer/renders/pkgconfig.py @@ -1,5 +1,5 @@ from dataclasses import dataclass -from pathlib import Path +from typing import Any from . import common @dataclass(frozen=True) @@ -16,5 +16,6 @@ class PkgConfigRender: self.__render = common.BaseRender() self.__properties = properties - def render(self) -> None: - pass + def render(self) -> str: + payload: dict[str, Any] = {} + return self.__render.render("XXX.pc.jinja", payload) diff --git a/packer/src/sarasacw_omrf_packer/templates/XXXConfig.cmake.jinja b/packer/src/sarasacw_omrf_packer/templates/XXXConfig.cmake.jinja index 360ee8e..f01da55 100644 --- a/packer/src/sarasacw_omrf_packer/templates/XXXConfig.cmake.jinja +++ b/packer/src/sarasacw_omrf_packer/templates/XXXConfig.cmake.jinja @@ -1,23 +1,36 @@ -{# Get the path to directory where current XXXConfig.cmake file is (i.e. /path/to/installation/lib/cmake/XXX) -#} -get_filename_component(PACKAGE_CMAKE_CONFIG_PATH "${CMAKE_CURRENT_LIST_FILE}" PATH) +{# Check whether this target is already exported -#} +if(TARGET {{ namespace }}::{{ target }}) + message(STATUS "Target {{ namespace }}::{{ target }} is already defined.") + message(STATUS "We will reuse it. Please check their version for compatibility.") + return() +endif() -{# Compute installation root directory (back to parent 3 times, i.e. /path/to/installation) -#} -get_filename_component(PACKAGE_PREFIX_DIR "${PACKAGE_CMAKE_CONFIG_PATH}/../../../" ABSOLUTE) +{# +Get the path to directory where current XXXConfig.cmake file is (i.e. /path/to/installation/lib/cmake/XXX) +And compute installation root directory (back to parent 3 times, i.e. /path/to/installation) +-#} +get_filename_component(_IMPORT_PREFIX "${CMAKE_CURRENT_LIST_FILE}/../../../" ABSOLUTE) {# Setup library and header file paths -#} -set(MyRust_LIBRARY "${PACKAGE_PREFIX_DIR}/lib/ {{- artifact_filename -}} }") -set(MyRust_INCLUDE_DIR "${PACKAGE_PREFIX_DIR}/include") +set(MyRust_LIBRARY "${_IMPORT_PREFIX}/lib/{{ artifact_dll }}") +set(MyRust_INCLUDE_DIR "${_IMPORT_PREFIX}/include") {# Create modern CMake target (IMPORTED target) -#} -add_library(MyRust::MyRust SHARED IMPORTED) -set_target_properties(MyRust::MyRust PROPERTIES - IMPORTED_LOCATION "${MyRust_LIBRARY}" - INTERFACE_INCLUDE_DIRECTORIES "${MyRust_INCLUDE_DIR}" +add_library({{ namespace }}::{{ target }} SHARED IMPORTED) +set_target_properties({{ namespace }}::{{ target }} PROPERTIES + INTERFACE_INCLUDE_DIRECTORIES "${_IMPORT_PREFIX}/include" ) - -{# Handle the Windows-specific case that .dll is seperated with .lib -#} +{# Handle Windows and UNIX-like respectively -#} if(WIN32) - set_target_properties(MyRust::MyRust PROPERTIES - IMPORTED_IMPLIB "${PACKAGE_PREFIX_DIR}/lib/myrust.lib" - ) + set_target_properties({{ namespace }}::{{ target }} PROPERTIES + IMPORTED_LOCATION "${_IMPORT_PREFIX}/bin/{{ artifact_dll }}" + IMPORTED_IMPLIB "${_IMPORT_PREFIX}/lib/{{ artifact_lib }}" + ) +else() + set_target_properties({{ namespace }}::{{ target }} PROPERTIES + IMPORTED_LOCATION "${_IMPORT_PREFIX}/lib/{{ artifact_dll }}" + ) endif() + +{# Cleanup temporary variables -#} +set(_IMPORT_PREFIX) diff --git a/packer/src/sarasacw_omrf_packer/templates/XXXConfigVersion.cmake.jinja b/packer/src/sarasacw_omrf_packer/templates/XXXConfigVersion.cmake.jinja index eed0fa0..d03f987 100644 --- a/packer/src/sarasacw_omrf_packer/templates/XXXConfigVersion.cmake.jinja +++ b/packer/src/sarasacw_omrf_packer/templates/XXXConfigVersion.cmake.jinja @@ -1,14 +1,30 @@ -{# Setup package version -#} -set(PACKAGE_VERSION "1.2.3") # TODO: replace with jinja argument +# This is a basic version file for the Config-mode of find_package(). +# It is created by sarasacw-omrf-packer and should not be changed manually. +# +# This file sets PACKAGE_VERSION_EXACT if the current version string and +# the requested version string are exactly the same and it sets +# PACKAGE_VERSION_COMPATIBLE if the current version is >= requested version. -{# Check whether user request EXACT match -#} -if(PACKAGE_FIND_VERSION_EXACT) - if(NOT "${PACKAGE_VERSION}" VERSION_EQUAL "${PACKAGE_FIND_VERSION}") - set(PACKAGE_VERSION_UNSUPPORTED TRUE) - endif() +{# Setup package version -#} +set(PACKAGE_VERSION "{{- version -}}") + +{# Check package version (code are copied from CMake generation) -#} +if (PACKAGE_FIND_VERSION_RANGE) + # Package version must be in the requested version range + if ((PACKAGE_FIND_VERSION_RANGE_MIN STREQUAL "INCLUDE" AND PACKAGE_VERSION VERSION_LESS PACKAGE_FIND_VERSION_MIN) + OR ((PACKAGE_FIND_VERSION_RANGE_MAX STREQUAL "INCLUDE" AND PACKAGE_VERSION VERSION_GREATER PACKAGE_FIND_VERSION_MAX) + OR (PACKAGE_FIND_VERSION_RANGE_MAX STREQUAL "EXCLUDE" AND PACKAGE_VERSION VERSION_GREATER_EQUAL PACKAGE_FIND_VERSION_MAX))) + set(PACKAGE_VERSION_COMPATIBLE FALSE) + else() + set(PACKAGE_VERSION_COMPATIBLE TRUE) + endif() else() - {# Check whether user requested version is <= current version (compatible with new version) -#} - if("${PACKAGE_VERSION}" VERSION_LESS "${PACKAGE_FIND_VERSION}") - set(PACKAGE_VERSION_UNSUPPORTED TRUE) + if(PACKAGE_VERSION VERSION_LESS PACKAGE_FIND_VERSION) + set(PACKAGE_VERSION_COMPATIBLE FALSE) + else() + set(PACKAGE_VERSION_COMPATIBLE TRUE) + if(PACKAGE_FIND_VERSION STREQUAL PACKAGE_VERSION) + set(PACKAGE_VERSION_EXACT TRUE) endif() -endif() \ No newline at end of file + endif() +endif() diff --git a/packer/uv.lock b/packer/uv.lock index add9c67..d780305 100644 --- a/packer/uv.lock +++ b/packer/uv.lock @@ -72,7 +72,20 @@ version = "0.1.0" source = { editable = "." } dependencies = [ { name = "jinja2" }, + { name = "semver" }, ] [package.metadata] -requires-dist = [{ name = "jinja2", specifier = "==3.1.6" }] +requires-dist = [ + { name = "jinja2", specifier = "==3.1.6" }, + { name = "semver", specifier = ">=3.0.4" }, +] + +[[package]] +name = "semver" +version = "3.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/d1/d3159231aec234a59dd7d601e9dd9fe96f3afff15efd33c1070019b26132/semver-3.0.4.tar.gz", hash = "sha256:afc7d8c584a5ed0a11033af086e8af226a9c0b206f313e0301f8dd7b6b589602", size = 269730, upload-time = "2025-01-24T13:19:27.617Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a6/24/4d91e05817e92e3a61c8a21e08fd0f390f5301f1c448b137c57c4bc6e543/semver-3.0.4-py3-none-any.whl", hash = "sha256:9c824d87ba7f7ab4a1890799cec8596f15c1241cb473404ea1cb0c55e4b04746", size = 17912, upload-time = "2025-01-24T13:19:24.949Z" }, +]