From c255732b25dca70a82b04741d9fcc005a5b0e899 Mon Sep 17 00:00:00 2001 From: Henry Schreiner Date: Fri, 11 Aug 2023 13:23:51 -0400 Subject: [PATCH] feat: write metadata to file Signed-off-by: Henry Schreiner --- .pre-commit-config.yaml | 2 +- README.md | 16 ++++ docs/api/scikit_build_core.build.rst | 8 ++ docs/configuration.md | 26 ++++++- src/scikit_build_core/build/generate.py | 32 ++++++++ src/scikit_build_core/build/sdist.py | 30 +++++-- src/scikit_build_core/build/wheel.py | 21 +++++ src/scikit_build_core/builder/builder.py | 2 +- .../resources/scikit-build.schema.json | 59 ++++++++++++++ .../settings/documentation.py | 12 ++- src/scikit_build_core/settings/json_schema.py | 40 ++++++++-- .../settings/skbuild_model.py | 30 +++++++ .../settings/skbuild_read_settings.py | 14 ++++ .../settings/skbuild_schema.py | 24 +++++- src/scikit_build_core/settings/sources.py | 17 +++- tests/packages/sdist_config/.gitignore | 1 + tests/packages/sdist_config/CMakeLists.txt | 16 +++- tests/packages/sdist_config/input.cmake | 1 + tests/packages/sdist_config/pyproject.toml | 18 +++++ tests/test_json_schema.py | 25 +++--- tests/test_pyproject_pep518.py | 29 ++++++- tests/test_schema.py | 35 ++++++++- tests/test_settings.py | 78 +++++++++++++++++++ tests/test_skbuild_settings.py | 17 +++- 24 files changed, 516 insertions(+), 37 deletions(-) create mode 100644 src/scikit_build_core/build/generate.py create mode 100644 tests/packages/sdist_config/input.cmake diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index f4554a2c3..4b53cb4e9 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -51,7 +51,7 @@ repos: - id: prettier types_or: [yaml, markdown, html, css, scss, javascript, json] args: [--prose-wrap=always] - exclude: "^tests" + exclude: "^tests|src/scikit_build_core/resources/scikit-build.schema.json" - repo: https://github.com/astral-sh/ruff-pre-commit rev: v0.0.282 diff --git a/README.md b/README.md index ae2ee1eb6..06a042ee3 100644 --- a/README.md +++ b/README.md @@ -228,6 +228,22 @@ install.components = [] # Whether to strip the binaries. True for scikit-build-core 0.5+. install.strip = false +# The path (relative to platlib) for the file to generate. +generate[].path = "" + +# The template to use for the file. This includes string.Template style +# placeholders for all the metadata. If empty, a template-path must be set. +generate[].template = "" + +# The path to the template file. If empty, a template must be set. +generate[].template-path = "" + +# The place to put the generated file. The "build" directory is useful for CMake +# files, and the "install" directory is useful for Python files, usually. You +# can also write directly to the "source" directory, will overwrite existing +# files & remember to gitignore the file. +generate[].location = "install" + # List dynamic metadata fields and hook locations in this table. metadata = {} diff --git a/docs/api/scikit_build_core.build.rst b/docs/api/scikit_build_core.build.rst index 2c5844a92..80df73427 100644 --- a/docs/api/scikit_build_core.build.rst +++ b/docs/api/scikit_build_core.build.rst @@ -9,6 +9,14 @@ scikit\_build\_core.build package Submodules ---------- +scikit\_build\_core.build.generate module +----------------------------------------- + +.. automodule:: scikit_build_core.build.generate + :members: + :undoc-members: + :show-inheritance: + scikit\_build\_core.build.sdist module -------------------------------------- diff --git a/docs/configuration.md b/docs/configuration.md index 81be30fcb..18fe0e850 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -376,7 +376,7 @@ default targets): ## Dynamic metadata -Scikit-build-core 0.3.0 supports dynamic metadata with two built-in plugins. +Scikit-build-core 0.3.0+ supports dynamic metadata with two built-in plugins. :::{warning} @@ -445,6 +445,30 @@ metadata.readme.provider = "scikit_build_core.metadata.fancy_pypi_readme" ::: +### Writing metadata + +You can write out metadata to file(s) as well. Other info might become available +here in the future, but currently it supports anything available as strings in +metadata. (Note that arrays like this are only supported in TOML configuration.) + +```toml +[[tool.scikit-build.generate]] +path = "package/_version.py" +template = ''' +version = "${version}" +''' +``` + +`template` or `template-file` is required; this uses {class}`string.Template` +formatting. There are three options for output location; `location = "install"` +(the default) will go to the wheel, `location = "build"` will go to the CMake +build directory, and `location = "source"` will write out to the source +directory (be sure to .gitignore this file. It will automatically be added to +your SDist includes. It will overwrite existing files). + +The path is generally relative to the base of the wheel / build dir / source +dir, depending on which location you pick. + ## Editable installs Experimental support for editable installs is provided, with some caveats and diff --git a/src/scikit_build_core/build/generate.py b/src/scikit_build_core/build/generate.py new file mode 100644 index 000000000..9456f1145 --- /dev/null +++ b/src/scikit_build_core/build/generate.py @@ -0,0 +1,32 @@ +from __future__ import annotations + +__all__ = ["generate_file_contents"] + +import dataclasses +import string + +from pyproject_metadata import StandardMetadata + +from ..settings.skbuild_model import GenerateSettings + + +def __dir__() -> list[str]: + return __all__ + + +def generate_file_contents(gen: GenerateSettings, metadata: StandardMetadata) -> str: + """ + Generate a file contents from a template. Input GeneratorSettings and + metadata. Metadata is available inside the template. + """ + + assert ( + gen.template_path or gen.template + ), f"One of template or template-path must be set for {gen.path}" + + if gen.template_path: + template = gen.template_path.read_text(encoding="utf-8") + else: + template = gen.template + + return string.Template(template).substitute(dataclasses.asdict(metadata)) diff --git a/src/scikit_build_core/build/sdist.py b/src/scikit_build_core/build/sdist.py index 45e42747d..de5d8e373 100644 --- a/src/scikit_build_core/build/sdist.py +++ b/src/scikit_build_core/build/sdist.py @@ -18,6 +18,7 @@ from ..settings.skbuild_read_settings import SettingsReader from ._file_processor import each_unignored_file from ._init import setup_logging +from .generate import generate_file_contents from .wheel import _build_wheel_impl __all__: list[str] = ["build_sdist"] @@ -70,6 +71,22 @@ def normalize_tar_info(tar_info: tarfile.TarInfo) -> tarfile.TarInfo: return tar_info +def add_bytes_to_tar( + tar: tarfile.TarFile, data: bytes, name: str, normalize: bool +) -> None: + """ + Write ``data`` bytes to ``name`` in a tarfile ``tar``. Normalize the info if + ``normalize`` is true. + """ + + tarinfo = tarfile.TarInfo(name) + if normalize: + tarinfo = normalize_tar_info(tarinfo) + with io.BytesIO(data) as bio: + tarinfo.size = bio.getbuffer().nbytes + tar.addfile(tarinfo, bio) + + def build_sdist( sdist_directory: str, config_settings: dict[str, list[str] | str] | None = None, @@ -116,6 +133,12 @@ def build_sdist( None, config_settings, None, exit_after_config=True, editable=False ) + for gen in settings.generate: + if gen.location == "source": + contents = generate_file_contents(gen, metadata) + gen.path.write_text(contents) + settings.sdist.include.append(str(gen.path)) + sdist_dir.mkdir(parents=True, exist_ok=True) with contextlib.ExitStack() as stack: gzip_container = stack.enter_context( @@ -138,11 +161,6 @@ def build_sdist( filter=normalize_tar_info if reproducible else lambda x: x, ) - tarinfo = tarfile.TarInfo(name=f"{srcdirname}/PKG-INFO") - tarinfo.size = len(pkg_info) - if reproducible: - tarinfo = normalize_tar_info(tarinfo) - with io.BytesIO(pkg_info) as fileobj: - tar.addfile(tarinfo, fileobj) + add_bytes_to_tar(tar, pkg_info, f"{srcdirname}/PKG-INFO", reproducible) return filename diff --git a/src/scikit_build_core/build/wheel.py b/src/scikit_build_core/build/wheel.py index 94b7a1838..13abc1582 100644 --- a/src/scikit_build_core/build/wheel.py +++ b/src/scikit_build_core/build/wheel.py @@ -28,6 +28,7 @@ ) from ._scripts import process_script_dir from ._wheelfile import WheelWriter +from .generate import generate_file_contents __all__: list[str] = ["_build_wheel_impl"] @@ -167,6 +168,12 @@ def _build_wheel_impl( "No license files found, set wheel.license-files to [] to suppress this warning" ) + for gen in settings.generate: + if gen.location == "source": + contents = generate_file_contents(gen, metadata) + gen.path.write_text(contents) + settings.sdist.include.append(str(gen.path)) + config = CMaker( cmake, source_dir=settings.cmake.source_dir, @@ -199,6 +206,20 @@ def _build_wheel_impl( path.write_bytes(data) return WheelImplReturn(wheel_filename=dist_info.name) + for gen in settings.generate: + contents = generate_file_contents(gen, metadata) + if gen.location == "source": + continue + if gen.location == "build": + path = build_dir / gen.path + elif gen.location == "install": + path = install_dir / gen.path + else: + msg = f"Invalid location {gen.location!r}, must be 'build' or 'install'" + raise AssertionError(msg) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(contents, encoding="utf-8") + rich_print("[green]***[/green] [bold]Configuring CMake...") defines: dict[str, str] = {} cache_entries: dict[str, str | Path] = { diff --git a/src/scikit_build_core/builder/builder.py b/src/scikit_build_core/builder/builder.py index f6368a39c..f9bce4a16 100644 --- a/src/scikit_build_core/builder/builder.py +++ b/src/scikit_build_core/builder/builder.py @@ -23,7 +23,7 @@ get_soabi, ) -__all__: list[str] = ["Builder", "get_archs", "archs_to_tags"] +__all__ = ["Builder", "get_archs", "archs_to_tags"] DIR = Path(__file__).parent.resolve() diff --git a/src/scikit_build_core/resources/scikit-build.schema.json b/src/scikit_build_core/resources/scikit-build.schema.json index e639e886a..156037c48 100644 --- a/src/scikit_build_core/resources/scikit-build.schema.json +++ b/src/scikit_build_core/resources/scikit-build.schema.json @@ -202,6 +202,65 @@ } } }, + "generate": { + "type": "array", + "items": { + "oneOf": [ + { + "type": "object", + "additionalProperties": false, + "required": [ + "path", + "template" + ], + "properties": { + "path": { + "type": "string", + "description": "The path (relative to platlib) for the file to generate.", + "minLength": 1 + }, + "template": { + "type": "string", + "description": "The template to use for the file. This includes string.Template style placeholders for all the metadata. If empty, a template-path must be set.", + "minLength": 1 + }, + "location": { + "type": "string", + "default": "install", + "description": "The place to put the generated file. The \"build\" directory is useful for CMake files, and the \"install\" directory is useful for Python files, usually. You can also write directly to the \"source\" directory, will overwrite existing files & remember to gitignore the file.", + "minLength": 1 + } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": [ + "path", + "template-path" + ], + "properties": { + "path": { + "type": "string", + "description": "The path (relative to platlib) for the file to generate.", + "minLength": 1 + }, + "template-path": { + "type": "string", + "description": "The path to the template file. If empty, a template must be set.", + "minLength": 1 + }, + "location": { + "type": "string", + "default": "install", + "description": "The place to put the generated file. The \"build\" directory is useful for CMake files, and the \"install\" directory is useful for Python files, usually. You can also write directly to the \"source\" directory, will overwrite existing files & remember to gitignore the file.", + "minLength": 1 + } + } + } + ] + } + }, "metadata": { "type": "object", "patternProperties": { diff --git a/src/scikit_build_core/settings/documentation.py b/src/scikit_build_core/settings/documentation.py index 4ff0f5ad6..2b81e77bd 100644 --- a/src/scikit_build_core/settings/documentation.py +++ b/src/scikit_build_core/settings/documentation.py @@ -10,6 +10,8 @@ from packaging.version import Version +from .._compat.typing import get_args, get_origin + __all__ = ["pull_docs"] @@ -64,7 +66,13 @@ def mk_docs(dc: type[object], prefix: str = "") -> Generator[DCDoc, None, None]: yield from mk_docs(field.type, prefix=f"{prefix}{field.name}.") continue - if field.default is not dataclasses.MISSING: + if get_origin(field.type) is list: + field_type = get_args(field.type)[0] + if dataclasses.is_dataclass(field_type): + yield from mk_docs(field_type, prefix=f"{prefix}{field.name}[].") + continue + + if field.default is not dataclasses.MISSING and field.default is not None: default = repr( str(field.default) if isinstance(field.default, (Path, Version)) @@ -73,7 +81,7 @@ def mk_docs(dc: type[object], prefix: str = "") -> Generator[DCDoc, None, None]: elif field.default_factory is not dataclasses.MISSING: default = repr(field.default_factory()) else: - default = "" + default = '""' yield DCDoc( f"{prefix}{field.name}".replace("_", "-"), diff --git a/src/scikit_build_core/settings/json_schema.py b/src/scikit_build_core/settings/json_schema.py index 995940576..67b158541 100644 --- a/src/scikit_build_core/settings/json_schema.py +++ b/src/scikit_build_core/settings/json_schema.py @@ -26,6 +26,7 @@ def to_json_schema(dclass: type[Any], *, normalize_keys: bool) -> dict[str, Any] assert dataclasses.is_dataclass(dclass) props = {} errs = [] + required = [] for field in dataclasses.fields(dclass): if dataclasses.is_dataclass(field.type): props[field.name] = to_json_schema( @@ -34,7 +35,7 @@ def to_json_schema(dclass: type[Any], *, normalize_keys: bool) -> dict[str, Any] continue try: - props[field.name] = convert_type(field.type) + props[field.name] = convert_type(field.type, normalize_keys=normalize_keys) except FailedConversion as err: if sys.version_info < (3, 11): notes = "__notes__" # set so linter's won't try to be clever @@ -51,6 +52,12 @@ def to_json_schema(dclass: type[Any], *, normalize_keys: bool) -> dict[str, Any] else field.default ) + if ( + field.default_factory is dataclasses.MISSING + and field.default is dataclasses.MISSING + ): + required.append(field.name) + if errs: msg = f"Failed Conversion to JSON Schema on {dclass.__name__}" raise ExceptionGroup(msg, errs) @@ -62,10 +69,20 @@ def to_json_schema(dclass: type[Any], *, normalize_keys: bool) -> dict[str, Any] if normalize_keys: props = {k.replace("_", "-"): v for k, v in props.items()} + if required: + return { + "type": "object", + "additionalProperties": False, + "required": required, + "properties": props, + } + return {"type": "object", "additionalProperties": False, "properties": props} -def convert_type(t: Any) -> dict[str, Any]: +def convert_type(t: Any, *, normalize_keys: bool) -> dict[str, Any]: + if dataclasses.is_dataclass(t): + return to_json_schema(t, normalize_keys=normalize_keys) if t is str or t is Path or t is Version: return {"type": "string"} if t is bool: @@ -74,18 +91,29 @@ def convert_type(t: Any) -> dict[str, Any]: args = get_args(t) if origin is list: assert len(args) == 1 - return {"type": "array", "items": convert_type(args[0])} + return { + "type": "array", + "items": convert_type(args[0], normalize_keys=normalize_keys), + } if origin is dict: assert len(args) == 2 assert args[0] is str if args[1] is Any: return {"type": "object"} - return {"type": "object", "patternProperties": {".+": convert_type(args[1])}} + return { + "type": "object", + "patternProperties": { + ".+": convert_type(args[1], normalize_keys=normalize_keys) + }, + } if origin is Union: # Ignore optional if len(args) == 2 and any(a is type(None) for a in args): - return convert_type(next(iter(a for a in args if a is not type(None)))) - return {"oneOf": [convert_type(a) for a in args]} + return convert_type( + next(iter(a for a in args if a is not type(None))), + normalize_keys=normalize_keys, + ) + return {"oneOf": [convert_type(a, normalize_keys=normalize_keys) for a in args]} msg = f"Cannot convert type {t} to JSON Schema" raise FailedConversion(msg) diff --git a/src/scikit_build_core/settings/skbuild_model.py b/src/scikit_build_core/settings/skbuild_model.py index 933527596..cd82a7a31 100644 --- a/src/scikit_build_core/settings/skbuild_model.py +++ b/src/scikit_build_core/settings/skbuild_model.py @@ -13,6 +13,7 @@ "NinjaSettings", "SDistSettings", "ScikitBuildSettings", + "GenerateSettings", "WheelSettings", ] @@ -202,6 +203,33 @@ class InstallSettings: """ +@dataclasses.dataclass +class GenerateSettings: + path: Path + """ + The path (relative to platlib) for the file to generate. + """ + + template: str = "" + """ + The template to use for the file. This includes string.Template style + placeholders for all the metadata. If empty, a template-path must be set. + """ + + template_path: Optional[Path] = None + """ + The path to the template file. If empty, a template must be set. + """ + + location: str = "install" + """ + The place to put the generated file. The "build" directory is useful for + CMake files, and the "install" directory is useful for Python files, + usually. You can also write directly to the "source" directory, will + overwrite existing files & remember to gitignore the file. + """ + + @dataclasses.dataclass class ScikitBuildSettings: cmake: CMakeSettings = dataclasses.field(default_factory=CMakeSettings) @@ -212,6 +240,8 @@ class ScikitBuildSettings: backport: BackportSettings = dataclasses.field(default_factory=BackportSettings) editable: EditableSettings = dataclasses.field(default_factory=EditableSettings) install: InstallSettings = dataclasses.field(default_factory=InstallSettings) + generate: List[GenerateSettings] = dataclasses.field(default_factory=list) + metadata: Dict[str, Dict[str, Any]] = dataclasses.field(default_factory=dict) """ List dynamic metadata fields and hook locations in this table. diff --git a/src/scikit_build_core/settings/skbuild_read_settings.py b/src/scikit_build_core/settings/skbuild_read_settings.py index d46167d84..74a6a187b 100644 --- a/src/scikit_build_core/settings/skbuild_read_settings.py +++ b/src/scikit_build_core/settings/skbuild_read_settings.py @@ -123,6 +123,20 @@ def validate_may_exit(self) -> None: ) raise SystemExit(7) + for gen in self.settings.generate: + if not gen.template and not gen.template_path: + sys.stdout.flush() + rich_print( + "[red][bold]ERROR:[/bold] template= or template-path= must be provided in generate" + ) + raise SystemExit(7) + if gen.location not in {"install", "build", "source"}: + sys.stdout.flush() + rich_print( + f"[red][bold]ERROR:[/bold] location={gen.location!r} must be 'install', 'build', or 'source'" + ) + raise SystemExit(7) + @classmethod def from_file( cls, diff --git a/src/scikit_build_core/settings/skbuild_schema.py b/src/scikit_build_core/settings/skbuild_schema.py index fe8d68686..9b2567918 100644 --- a/src/scikit_build_core/settings/skbuild_schema.py +++ b/src/scikit_build_core/settings/skbuild_schema.py @@ -1,5 +1,6 @@ from __future__ import annotations +import copy import json from typing import Any @@ -19,13 +20,34 @@ def generate_skbuild_schema(tool_name: str = "scikit-build") -> dict[str, Any]: from .json_schema import to_json_schema from .skbuild_model import ScikitBuildSettings - return { + schema = { "$schema": "http://json-schema.org/draft-07/schema", "$id": "https://github.com/scikit-build/scikit-build-core/blob/main/src/scikit_build_core/resources/scikit-build.schema.json", "description": "Scikit-build-core's settings.", **to_json_schema(ScikitBuildSettings, normalize_keys=True), } + # Manipulate a bit to get better validation + # This is making the generate's template or template-path required + generate = schema["properties"]["generate"]["items"] + for prop in generate["properties"].values(): + prop["minLength"] = 1 + generate_tmpl = copy.deepcopy(generate) + generate_path = copy.deepcopy(generate) + + generate_tmpl["required"] = ["path", "template"] + del generate_tmpl["properties"]["template-path"] + del generate_tmpl["properties"]["template"]["default"] + + generate_path["required"] = ["path", "template-path"] + del generate_path["properties"]["template"] + + schema["properties"]["generate"]["items"] = { + "oneOf": [generate_tmpl, generate_path] + } + + return schema + def get_skbuild_schema(tool_name: str = "scikit-build") -> dict[str, Any]: "Get the stored complete schema for scikit-build settings." diff --git a/src/scikit_build_core/settings/sources.py b/src/scikit_build_core/settings/sources.py index e663a0228..9fa26ab06 100644 --- a/src/scikit_build_core/settings/sources.py +++ b/src/scikit_build_core/settings/sources.py @@ -34,7 +34,7 @@ - Any callable (`Path`, `Version`): Passed the string input. - ``Optional[T]``: Treated like T. Default should be None, since no input format supports None's. - ``Union[str, ...]``: Supports other input types in TOML form (bool currently). Otherwise a string. -- ``List[T]``: A list of items. `;` separated supported in EnvVar/config forms. +- ``List[T]``: A list of items. `;` separated supported in EnvVar/config forms. T can be a dataclass (TOML only). - ``Dict[str, T]``: A table of items. TOML supports a layer of nesting. Any is supported as an item type. These are supported for JSON schema generation for the TOML, as well. @@ -211,6 +211,9 @@ def get_item( @classmethod def convert(cls, item: str, target: type[Any]) -> object: raw_target = _get_target_raw_type(target) + if dataclasses.is_dataclass(raw_target): + msg = f"Array of dataclasses are not supported in configuration settings ({raw_target})" + raise TypeError(msg) if raw_target == list: return [ cls.convert(i.strip(), _get_inner_type(target)) for i in item.split(";") @@ -316,6 +319,9 @@ def convert( cls, item: str | list[str] | dict[str, str], target: type[Any] ) -> object: raw_target = _get_target_raw_type(target) + if dataclasses.is_dataclass(raw_target): + msg = f"Array of dataclasses are not supported in configuration settings ({raw_target})" + raise TypeError(msg) if raw_target == list: if isinstance(item, list): return [cls.convert(i, _get_inner_type(target)) for i in item] @@ -392,6 +398,15 @@ def get_item(self, *fields: str, is_dict: bool) -> Any: # noqa: ARG002 @classmethod def convert(cls, item: Any, target: type[Any]) -> object: raw_target = _get_target_raw_type(target) + if dataclasses.is_dataclass(raw_target): + fields = dataclasses.fields(raw_target) + values = ((k.replace("-", "_"), v) for k, v in item.items()) + return raw_target( + **{ + k: cls.convert(v, *[f.type for f in fields if f.name == k]) + for k, v in values + } + ) if raw_target is list: if not isinstance(item, list): msg = f"Expected {target}, got {type(item).__name__}" diff --git a/tests/packages/sdist_config/.gitignore b/tests/packages/sdist_config/.gitignore index 70533e312..7abdf7de1 100644 --- a/tests/packages/sdist_config/.gitignore +++ b/tests/packages/sdist_config/.gitignore @@ -1 +1,2 @@ /pybind11 +overwrite.cmake diff --git a/tests/packages/sdist_config/CMakeLists.txt b/tests/packages/sdist_config/CMakeLists.txt index 1a596707e..9a4511381 100644 --- a/tests/packages/sdist_config/CMakeLists.txt +++ b/tests/packages/sdist_config/CMakeLists.txt @@ -1,5 +1,8 @@ cmake_minimum_required(VERSION 3.15...3.27) -project(sdist_config LANGUAGES CXX) +project( + sdist_config + LANGUAGES CXX + VERSION ${SKBUILD_PROJECT_VERSION}) include(FetchContent) @@ -20,3 +23,14 @@ FetchContent_MakeAvailable(pybind11) pybind11_add_module(sdist_config main.cpp) install(TARGETS sdist_config DESTINATION .) + +# Generation test +include("${CMAKE_CURRENT_BINARY_DIR}/output.cmake") +if(NOT "${MY_VERSION}" STREQUAL "${PROJECT_VERSION}") + message(FATAL_ERROR "Version mismatch: ${MY_VERSION} != ${PROJECT_VERSION}") +endif() + +include("${CMAKE_CURRENT_SOURCE_DIR}/overwrite.cmake") +if(NOT "${MY_NAME}" STREQUAL "${SKBUILD_PROJECT_NAME}") + message(FATAL_ERROR "Name mismatch: ${MY_NAME} != ${SKBUILD_PROJECT_NAME}") +endif() diff --git a/tests/packages/sdist_config/input.cmake b/tests/packages/sdist_config/input.cmake new file mode 100644 index 000000000..d80edc255 --- /dev/null +++ b/tests/packages/sdist_config/input.cmake @@ -0,0 +1 @@ +set(MY_VERSION "${version}") diff --git a/tests/packages/sdist_config/pyproject.toml b/tests/packages/sdist_config/pyproject.toml index f3ae48bf7..6e3037a5a 100644 --- a/tests/packages/sdist_config/pyproject.toml +++ b/tests/packages/sdist_config/pyproject.toml @@ -16,3 +16,21 @@ sdist.include = [ wheel.license-files = [] wheel.packages = [] cmake.define.FETCHCONTENT_QUIET = false + +[[tool.scikit-build.generate]] +path = "output.cmake" +location = "build" +template-path = "input.cmake" + +[[tool.scikit-build.generate]] +path = "output.py" +template = """ +version = "${version}" +""" + +[[tool.scikit-build.generate]] +path = "overwrite.cmake" +location = "source" +template = """ +set(MY_NAME "${name}") +""" diff --git a/tests/test_json_schema.py b/tests/test_json_schema.py index 7ef6771a2..154116159 100644 --- a/tests/test_json_schema.py +++ b/tests/test_json_schema.py @@ -8,51 +8,54 @@ def test_convert_str(): - assert convert_type(str) == {"type": "string"} + assert convert_type(str, normalize_keys=False) == {"type": "string"} def test_convert_str_or_bool(): - assert convert_type(Union[str, bool]) == { + assert convert_type(Union[str, bool], normalize_keys=False) == { "oneOf": [{"type": "string"}, {"type": "boolean"}] } def test_convert_optional_str(): - assert convert_type(Optional[str]) == {"type": "string"} + assert convert_type(Optional[str], normalize_keys=False) == {"type": "string"} def test_convert_path(): - assert convert_type(Path) == {"type": "string"} + assert convert_type(Path, normalize_keys=False) == {"type": "string"} def test_convert_version(): - assert convert_type(Version) == {"type": "string"} + assert convert_type(Version, normalize_keys=False) == {"type": "string"} def test_convert_list(): - assert convert_type(List[str]) == {"type": "array", "items": {"type": "string"}} - assert convert_type(List[Union[str, bool]]) == { + assert convert_type(List[str], normalize_keys=False) == { + "type": "array", + "items": {"type": "string"}, + } + assert convert_type(List[Union[str, bool]], normalize_keys=False) == { "type": "array", "items": {"oneOf": [{"type": "string"}, {"type": "boolean"}]}, } def test_convert_dict(): - assert convert_type(Dict[str, str]) == { + assert convert_type(Dict[str, str], normalize_keys=False) == { "type": "object", "patternProperties": {".+": {"type": "string"}}, } - assert convert_type(Dict[str, Dict[str, str]]) == { + assert convert_type(Dict[str, Dict[str, str]], normalize_keys=False) == { "type": "object", "patternProperties": { ".+": {"type": "object", "patternProperties": {".+": {"type": "string"}}} }, } - assert convert_type(Dict[str, Any]) == { + assert convert_type(Dict[str, Any], normalize_keys=False) == { "type": "object", } def test_convert_invalid(): with pytest.raises(FailedConversion): - convert_type(object) + convert_type(object, normalize_keys=False) diff --git a/tests/test_pyproject_pep518.py b/tests/test_pyproject_pep518.py index d44fe9ca9..2c9e85337 100644 --- a/tests/test_pyproject_pep518.py +++ b/tests/test_pyproject_pep518.py @@ -9,6 +9,14 @@ import pytest +@pytest.fixture() +def cleanup_overwrite(): + overwrite = Path("overwrite.cmake") + yield overwrite + if overwrite.exists(): + overwrite.unlink() + + @pytest.mark.network() @pytest.mark.integration() def test_pep518_sdist(isolated, package_simple_pyproject_ext): @@ -54,7 +62,9 @@ def test_pep518_sdist(isolated, package_simple_pyproject_ext): @pytest.mark.configure() @pytest.mark.integration() @pytest.mark.usefixtures("package_sdist_config") -def test_pep518_sdist_with_cmake_config(isolated): +def test_pep518_sdist_with_cmake_config(isolated, cleanup_overwrite): + cleanup_overwrite.write_text("set(MY_VERSION fiddlesticks)") + correct_metadata = textwrap.dedent( """\ Metadata-Version: 2.1 @@ -77,6 +87,8 @@ def test_pep518_sdist_with_cmake_config(isolated): "pyproject.toml", "main.cpp", "PKG-INFO", + "overwrite.cmake", + ".gitignore", ) } assert sum("pybind11" in x for x in file_names) >= 10 @@ -85,6 +97,8 @@ def test_pep518_sdist_with_cmake_config(isolated): pkg_info_contents = pkg_info.read().decode() assert correct_metadata == pkg_info_contents + assert cleanup_overwrite.is_file() + @pytest.mark.network() @pytest.mark.compile() @@ -94,7 +108,9 @@ def test_pep518_sdist_with_cmake_config(isolated): @pytest.mark.parametrize( "build_args", [(), ("--wheel",)], ids=["sdist_to_wheel", "wheel_directly"] ) -def test_pep518_wheel_sdist_with_cmake_config(isolated, build_args, capfd): +def test_pep518_wheel_sdist_with_cmake_config( + isolated, build_args, capfd, cleanup_overwrite +): isolated.install("build[virtualenv]") isolated.module( "build", @@ -116,9 +132,9 @@ def test_pep518_wheel_sdist_with_cmake_config(isolated, build_args, capfd): p = zipfile.Path(f) file_names = [p.name for p in p.iterdir()] - assert len(file_names) == 2 - assert "sdist_config-0.1.0.dist-info" in file_names + assert len(file_names) == 3 file_names.remove("sdist_config-0.1.0.dist-info") + file_names.remove("output.py") (so_file,) = file_names assert so_file.startswith("sdist_config") @@ -129,6 +145,11 @@ def test_pep518_wheel_sdist_with_cmake_config(isolated, build_args, capfd): life = isolated.execute("import sdist_config; print(sdist_config.life())") assert life == "42" + version = isolated.execute("import output; print(output.version)") + assert version == "0.1.0" + + assert cleanup_overwrite.is_file() + @pytest.mark.compile() @pytest.mark.configure() diff --git a/tests/test_schema.py b/tests/test_schema.py index 68d9d8f4f..5cd23cf3b 100644 --- a/tests/test_schema.py +++ b/tests/test_schema.py @@ -40,7 +40,14 @@ def test_valid_schemas_files(filepath: Path) -> None: @pytest.mark.parametrize( - "addition", [{"minimum-version": 0.3}, {"random": "not valid"}] + "addition", + [ + {"minimum-version": 0.3}, + {"random": "not valid"}, + {"generate": [{"path": "CMakeLists.txt"}]}, + {"generate": [{"path": "me.py", "template": "hi", "template-path": "hello"}]}, + {"generate": [{"path": "me.py", "template": ""}]}, + ], ) def test_invalid_schemas(addition: dict[str, Any]) -> None: fastjsonschema = pytest.importorskip("fastjsonschema") @@ -61,3 +68,29 @@ def test_invalid_schemas(addition: dict[str, Any]) -> None: validator = api.Validator() with pytest.raises(fastjsonschema.JsonSchemaValueException): validator(example) + + +@pytest.mark.parametrize( + "addition", + [ + {"generate": [{"path": "CMakeLists.txt", "template": "hi"}]}, + {"generate": [{"path": "me.py", "template-path": "hello"}]}, + ], +) +def test_valid_schemas(addition: dict[str, Any]) -> None: + api = pytest.importorskip("validate_pyproject.api") + + example_toml = """\ + [project] + name = "myproj" + version = "0" + + [tool.scikit-build] + minimum-version = "0.3" + """ + + example = tomllib.loads(example_toml) + example["tool"]["scikit-build"].update(**addition) + + validator = api.Validator() + validator(example) diff --git a/tests/test_settings.py b/tests/test_settings.py index f225137e4..e4e437d59 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -5,6 +5,7 @@ import pytest from packaging.version import Version +from scikit_build_core._compat.builtins import ExceptionGroup from scikit_build_core.settings.sources import ( ConfSource, EnvSource, @@ -512,3 +513,80 @@ def test_versions(monkeypatch): assert settings.third_opt == Version("3.5") assert settings.fourth_req == Version("4.5") assert settings.fourth_opt == Version("4.6") + + +@dataclasses.dataclass +class ArraySetting: + required: str + optional: Optional[str] = None + + +@dataclasses.dataclass +class ArraySettings: + array: List[ArraySetting] = dataclasses.field(default_factory=list) + + +def test_empty_array(): + sources = SourceChain( + EnvSource("SKBUILD"), + ConfSource(settings={}), + TOMLSource(settings={}), + ) + + settings = sources.convert_target(ArraySettings) + + assert settings.array == [] + + +def test_toml_required(): + sources = SourceChain( + EnvSource("SKBUILD"), + ConfSource(settings={}), + TOMLSource(settings={"array": [{"optional": "2"}]}), + ) + + with pytest.raises(ExceptionGroup): + sources.convert_target(ArraySettings) + + +def test_toml_array(): + sources = SourceChain( + EnvSource("SKBUILD"), + ConfSource(settings={}), + TOMLSource( + settings={ + "array": [{"required": "one"}, {"required": "two", "optional": "2"}] + } + ), + ) + + settings = sources.convert_target(ArraySettings) + + assert settings.array == [ArraySetting("one"), ArraySetting("two", "2")] + + +def test_env_array_error(monkeypatch: pytest.MonkeyPatch): + monkeypatch.setenv("SKBUILD_ARRAY", "one") + sources = SourceChain( + EnvSource("SKBUILD"), + ConfSource(settings={}), + TOMLSource(settings={}), + ) + + with pytest.raises(ExceptionGroup): + sources.convert_target(ArraySettings) + + +def test_config_array_error(): + sources = SourceChain( + EnvSource("SKBUILD"), + ConfSource( + settings={ + "array": [{"required": "one"}, {"required": "two", "optional": "2"}] # type: ignore[list-item] + } + ), + TOMLSource(settings={}), + ) + + with pytest.raises(ExceptionGroup): + sources.convert_target(ArraySettings) diff --git a/tests/test_skbuild_settings.py b/tests/test_skbuild_settings.py index 52b7ea588..68c18e095 100644 --- a/tests/test_skbuild_settings.py +++ b/tests/test_skbuild_settings.py @@ -11,6 +11,7 @@ from packaging.version import Version import scikit_build_core.settings.skbuild_read_settings +from scikit_build_core.settings.skbuild_model import GenerateSettings from scikit_build_core.settings.skbuild_read_settings import SettingsReader @@ -58,6 +59,7 @@ def test_skbuild_settings_default(tmp_path: Path): assert settings.editable.verbose assert settings.install.components == [] assert settings.install.strip + assert settings.generate == [] def test_skbuild_settings_envvar(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): @@ -250,6 +252,13 @@ def test_skbuild_settings_pyproject_toml( editable.verbose = false install.components = ["a", "b", "c"] install.strip = true + [[tool.scikit-build.generate]] + path = "a/b/c" + template = "hello" + [[tool.scikit-build.generate]] + path = "d/e/f" + template-path = "g/h/i" + location = "build" """ ), encoding="utf-8", @@ -290,6 +299,12 @@ def test_skbuild_settings_pyproject_toml( assert not settings.editable.verbose assert settings.install.components == ["a", "b", "c"] assert settings.install.strip + assert settings.generate == [ + GenerateSettings(path=Path("a/b/c"), template="hello", location="install"), + GenerateSettings( + path=Path("d/e/f"), template_path=Path("g/h/i"), location="build" + ), + ] def test_skbuild_settings_pyproject_toml_broken( @@ -327,7 +342,7 @@ def test_skbuild_settings_pyproject_toml_broken( == """\ ERROR: Unrecognized options in pyproject.toml: tool.scikit-build.cmake.minimum-verison -> Did you mean: tool.scikit-build.cmake.minimum-version, tool.scikit-build.minimum-version, tool.scikit-build.ninja.minimum-version? - tool.scikit-build.logger -> Did you mean: tool.scikit-build.logging, tool.scikit-build.wheel, tool.scikit-build.cmake? + tool.scikit-build.logger -> Did you mean: tool.scikit-build.logging, tool.scikit-build.generate, tool.scikit-build.wheel? """.split() )