feat: finish cmake generation

This commit is contained in:
2026-08-03 11:10:44 +08:00
parent 3261a4cd4b
commit 02526784a2
8 changed files with 203 additions and 44 deletions
@@ -1,24 +1,114 @@
"""Renderer for CMake package-configuration files.
Generates the ``<pkg>Config.cmake`` and ``<pkg>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 ``<pkg>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 ``<pkg>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)
@@ -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)
@@ -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)