diff --git a/.distro/python-scikit-build-core.rpmlintrc b/.distro/python-scikit-build-core.rpmlintrc new file mode 100644 index 00000000..5a161f29 --- /dev/null +++ b/.distro/python-scikit-build-core.rpmlintrc @@ -0,0 +1 @@ +addFilter("files-duplicate .*/__init__.py") diff --git a/scikit-build-core.spec b/.distro/scikit-build-core.spec similarity index 61% rename from scikit-build-core.spec rename to .distro/scikit-build-core.spec index 8ddf7f77..d6a7e169 100644 --- a/scikit-build-core.spec +++ b/.distro/scikit-build-core.spec @@ -1,11 +1,14 @@ +%global pypi_name scikit_build_core + Name: python-scikit-build-core -Version: 0.2.2 +Version: 0.0.0 Release: %{autorelease} Summary: Build backend for CMake based projects License: Apache-2.0 URL: https://github.com/scikit-build/scikit-build-core -Source0: https://github.com/scikit-build/scikit-build-core/archive/refs/tags/v%{version}.tar.gz +Source0: %{pypi_source %{pypi_name}} +Source1: %{name}.rpmlintrc BuildArch: noarch BuildRequires: python3-devel @@ -13,12 +16,6 @@ BuildRequires: cmake BuildRequires: ninja-build BuildRequires: gcc BuildRequires: gcc-c++ -Requires: cmake -Recommends: (ninja-build or make) -Recommends: python3dist(pyproject-metadata) -Recommends: python3dist(pathspec) -Suggests: ninja-build -Suggests: gcc %global _description %{expand: A next generation Python CMake adaptor and Python API for plugins} @@ -27,19 +24,16 @@ A next generation Python CMake adaptor and Python API for plugins} %package -n python3-scikit-build-core Summary: %{summary} +Requires: cmake +Recommends: (ninja-build or make) +Recommends: python3dist(pyproject-metadata) +Recommends: python3dist(pathspec) +Suggests: ninja-build +Suggests: gcc %description -n python3-scikit-build-core %_description %prep -# This assumes the source is not retrieved from tar ball, but built in place -# This makes it possible to build with `tito build --test` -# Change to `%%autosetup -n %%{pypi_name}-%%{version}` for release -# TODO: There should be a format to satisfy both -%setup -q -# TODO: Remove when tito upstream issue is fixed -# https://github.com/rpm-software-management/tito/issues/444 -if grep -q "describe-name: \$Format" .git_archival.txt; then - sed -i "s/describe-name:.*/describe-name: v%{version}/g" .git_archival.txt -fi +%autosetup -n %{pypi_name}-%{version} %generate_buildrequires %pyproject_buildrequires -x test @@ -50,7 +44,7 @@ fi %install %pyproject_install -%pyproject_save_files scikit_build_core +%pyproject_save_files %{pypi_name} %check diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 7a28d1c4..2cf6fcba 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -287,3 +287,51 @@ cmake_extensions=[CMakeExtension("cmake_example")], ``` Which should eventually support multiple extensions. + +# Downstream packaging + +## Fedora packaging + +We are using [`packit`](https://packit.dev/) to keep maintain the +[Fedora package](https://src.fedoraproject.org/rpms/python-scikit-build-core/). +There are two `packit` jobs one needs to keep in mind here: + +- `copr_build`: Submits copr builds to: + - `@scikit-build/nightly` if there is a commit to `main`. This is intended for + users to test the current development build + - `@scikit-build/scikit-build-core` if there is a PR request. This is for CI + purposes to confirm that the build is successful + - `@scikit-build/release` whenever a new release is published on GitHub. Users + can use this to get the latest release before they land on Fedora. This is + also used for other copr projects to check the future release +- `propose_downstream`: Submits a PR to `src.fedoraproject.org` once a release + is published + +To interact with `packit`, you can use +[`/packit command`](https://packit.dev/docs/guide/#how-to-re-trigger-packit-actions-in-your-pull-request) +in PRs and commit messages or [`packit` CLI](https://packit.dev/docs/cli/). +These interactions are primarily intended for controlling the CI managed on +`scikit-build`. + +To debug and build locally or on your own copr project you may use +`packit build` commands, e.g. to build locally using `mock` for fedora rawhide: + +```console +$ packit build in-mock -r /etc/mock/fedora-rawhide-x86_64.cfg +``` + +or for copr project `copr_user/scikit-build-core`: + +```console +$ copr-cli edit-permissions --builder=packit copr_user/scikit-build-core +$ packit build in-copr --owner copr_user --project scikit-build-core +``` + +(Here we are making sure `packit` has the appropriate permission for +`copr_user/scikit-build-core` via the `copr-cli` command. You may need to +configure [`~/.config/copr`](https://packit.dev/docs/cli/build/copr/)) first + +Both of these methods automatically edit the `Version` in the +[spec file](../.dist/scikit-build-core.spec), therefore it is intentionally +marked as `0.0.0` there to avoid manually updating. Make sure to not push these +changes in a PR. diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index 47157f04..8ca8eb0b 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -24,7 +24,7 @@ jobs: with: path: dist/* - - uses: pypa/gh-action-pypi-publish@v1.8.4 + - uses: pypa/gh-action-pypi-publish@v1.8.5 if: github.event_name == 'release' && github.event.action == 'published' with: password: ${{ secrets.pypi_password }} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 28ad3d17..79e45fbb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -85,6 +85,12 @@ jobs: pip install .[test,cov] cmake ninja rich hatch-fancy-pypi-readme setuptools-scm + - name: Add NumPy + if: > + (matrix.runs-on == 'ubuntu-latest' || matrix.python-version != + 'pypy-3.8') && matrix.python-version != '3.12-dev' + run: "pip install --only-binary=:all: numpy" + - name: Test package run: pytest -ra --showlocals --cov=scikit_build_core @@ -105,7 +111,9 @@ jobs: with: cmake-version: "${{ matrix.cmake-version }}" + # Skipped on pypy to keep the tests fast - name: Test min package + if: matrix.python-version != 'pypy-3.8' run: pytest -ra --showlocals cygwin: @@ -121,8 +129,7 @@ jobs: with: platform: x86_64 packages: - cmake ninja git make gcc-g++ gcc-fortran python39 python39-devel - python39-pip + cmake ninja git make gcc-g++ python39 python39-devel python39-pip - name: Install run: python3.9 -m pip install .[test] @@ -131,7 +138,7 @@ jobs: run: python3.9 -m pytest -ra --showlocals -m "not virtualenv" msys: - name: Tests on 🐍 3 • msys + name: Tests on 🐍 3 • msys UCRT runs-on: windows-latest defaults: @@ -141,13 +148,13 @@ jobs: steps: - uses: msys2/setup-msys2@v2 with: - msystem: msys + msystem: UCRT64 path-type: minimal update: true install: >- base-devel git pacboy: >- - python python-pip gcc cmake + python:p python-pip:p gcc:p cmake:p - uses: actions/checkout@v3 with: @@ -157,7 +164,7 @@ jobs: run: python -m pip install .[test] - name: Test package - run: python -m pytest -ra --showlocals + run: python -m pytest -ra --showlocals -m "not broken_on_urct" mingw64: name: Tests on 🐍 3 • mingw64 diff --git a/.gitignore b/.gitignore index 8cc0dc84..aabfd9a0 100644 --- a/.gitignore +++ b/.gitignore @@ -153,7 +153,9 @@ tests/**/build/ *.swp # RPM spec file -!/scikit-build-core.spec +!/.distro/scikit-build-core.spec +/.distro/*.tar.gz +*.rpm # ruff .ruff_cache/ diff --git a/.packit.yaml b/.packit.yaml new file mode 100644 index 00000000..786d6823 --- /dev/null +++ b/.packit.yaml @@ -0,0 +1,57 @@ +# See the documentation for more information: +# https://packit.dev/docs/configuration/ + +specfile_path: .distro/scikit-build-core.spec + +files_to_sync: + - src: .distro/scikit-build-core.spec + dest: python-scikit-build-core.spec + - .packit.yaml + - src: .distro/python-scikit-build-core.rpmlintrc + dest: python-scikit-build-core.rpmlintrc +upstream_package_name: scikit_build_core +downstream_package_name: python-scikit-build-core +update_release: false +upstream_tag_template: v{version} + +jobs: + - job: copr_build + trigger: pull_request + owner: "@scikit-build" + project: scikit-build-core + update_release: true + release_suffix: "{PACKIT_RPMSPEC_RELEASE}" + targets: + - fedora-development + - job: copr_build + trigger: commit + branch: main + owner: "@scikit-build" + project: nightly + targets: + - fedora-development-x86_64 + - fedora-latest-x86_64 + - fedora-development-aarch64 + - fedora-latest-aarch64 + - job: copr_build + trigger: release + owner: "@scikit-build" + project: release + targets: + - fedora-development-x86_64 + - fedora-latest-x86_64 + - fedora-development-aarch64 + - fedora-latest-aarch64 + - job: propose_downstream + trigger: release + dist_git_branches: + - fedora-development + - fedora-latest + - job: koji_build + trigger: commit + dist_git_branches: + - fedora-all + - job: bodhi_update + trigger: commit + dist_git_branches: + - fedora-branched diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index addb1cd2..b5aa6c13 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -54,7 +54,7 @@ repos: exclude: "^tests" - repo: https://github.com/charliermarsh/ruff-pre-commit - rev: v0.0.259 + rev: v0.0.260 hooks: - id: ruff args: ["--fix", "--show-fixes"] diff --git a/.tito/packages/.readme b/.tito/packages/.readme deleted file mode 100644 index b9411e2d..00000000 --- a/.tito/packages/.readme +++ /dev/null @@ -1,3 +0,0 @@ -the .tito/packages directory contains metadata files -named after their packages. Each file has the latest tagged -version and the project's relative directory. diff --git a/.tito/tito.props b/.tito/tito.props deleted file mode 100644 index eab3f190..00000000 --- a/.tito/tito.props +++ /dev/null @@ -1,5 +0,0 @@ -[buildconfig] -builder = tito.builder.Builder -tagger = tito.tagger.VersionTagger -changelog_do_not_remove_cherrypick = 0 -changelog_format = %s (%ae) diff --git a/docs/configuration.md b/docs/configuration.md index e59d4e04..cddc243e 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -372,8 +372,11 @@ CMAKE_ARGS: -DSOME_DEFINE=ON;-DOTHER=OFF ## Dynamic metadata (WIP) -Scikit-build-core 0.3.0 will support dynamic metadata. This is experimental -until a release. The public interface for making a plugin is very experimental. +Scikit-build-core 0.3.0 will support dynamic metadata. This is not ready for +plugin development outside of scikit-build-core; +`tool.scikit-build.expiremental=true` is required to use external plugins, since +the interface is provisional. Nested arbitrary dicts (as seen here) are not +supported in config-settings or environment variables. There currently are two built-in plugins for dynamic metadata. @@ -388,7 +391,7 @@ name = "mypackage" dynamic = ["version"] [tool.scikit-build] -metadata.version = "scikit_build_core.metadata.setuptools_scm" +metadata.version.provider = "scikit_build_core.metadata.setuptools_scm" ``` This sets the python project version according to @@ -425,7 +428,7 @@ name = "mypackage" dynamic = ["readme"] [tool.scikit-build] -metadata.readme = "scikit_build_core.metadata.fancy_pypi_readme" +metadata.readme.provider = "scikit_build_core.metadata.fancy_pypi_readme" ``` ::: diff --git a/noxfile.py b/noxfile.py index 7c0f26b6..eee0acd9 100644 --- a/noxfile.py +++ b/noxfile.py @@ -46,6 +46,8 @@ def tests(session: nox.Session) -> None: extra.append("cmake") if shutil.which("ninja") is None: extra.append("ninja") + if (3, 8) <= sys.version_info < (3, 12): + extra.append("numpy") session.install("-e.[test]", *extra) session.run("pytest", *session.posargs, env=env) diff --git a/pyproject.toml b/pyproject.toml index 9e14d128..09519adf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -55,7 +55,6 @@ test = [ "cattrs >=22.2.0", "gitpython", "importlib_metadata; python_version<'3.8'", - "numpy; python_version>='3.8' and python_version<'3.12'", "pathspec >=0.10.1", "pybind11", "pyproject-metadata >=0.5", @@ -108,13 +107,14 @@ filterwarnings = [ log_cli_level = "info" testpaths = ["tests"] markers = [ + "broken_on_urct: Broken for now due to lib not found", "compile: Compiles code", "configure: Configures CMake code", "fortran: Fortran code", "integration: Full package build", + "isolated: Needs an isolated virtualenv", "setuptools: Tests setuptools integration", "virtualenv: Needs a virtualenv", - "isolated: Needs an isolated virtualenv", ] diff --git a/src/scikit_build_core/build/wheel.py b/src/scikit_build_core/build/wheel.py index 31619e3a..90d140e1 100644 --- a/src/scikit_build_core/build/wheel.py +++ b/src/scikit_build_core/build/wheel.py @@ -73,7 +73,7 @@ def _build_wheel_impl( settings_reader.validate_may_exit() - metadata = get_standard_metadata(pyproject, settings, config_settings) + metadata = get_standard_metadata(pyproject, settings) if metadata.version is None: msg = "project.version is not statically specified, must be present currently" diff --git a/src/scikit_build_core/builder/get_requires.py b/src/scikit_build_core/builder/get_requires.py index c1995085..1d8c25bc 100644 --- a/src/scikit_build_core/builder/get_requires.py +++ b/src/scikit_build_core/builder/get_requires.py @@ -2,7 +2,6 @@ import dataclasses import functools -import importlib import os import sys from collections.abc import Generator, Mapping @@ -20,6 +19,7 @@ get_ninja_programs, ) from ..resources import resources +from ..settings._load_provider import load_provider from ..settings.skbuild_read_settings import SettingsReader __all__ = ["GetRequires"] @@ -40,15 +40,6 @@ def is_known_platform(platforms: frozenset[str]) -> bool: return any(tag.platform in platforms for tag in sys_tags()) -def _load_get_requires_hook( - mod_name: str, - config_settings: Mapping[str, list[str] | str] | None = None, -) -> list[str]: - module = importlib.import_module(mod_name) - hook = getattr(module, "get_requires_for_dynamic_metadata", None) - return [] if hook is None else hook(config_settings) # type: ignore[no-any-return] - - @dataclasses.dataclass class GetRequires: config_settings: Mapping[str, list[str] | str] | None = None @@ -103,5 +94,12 @@ def ninja(self) -> Generator[str, None, None]: yield f"ninja>={ninja_min}" def dynamic_metadata(self) -> Generator[str, None, None]: - for plugins in self._settings.metadata.values(): - yield from _load_get_requires_hook(plugins, self.config_settings) + for dynamic_metadata in self._settings.metadata.values(): + if "provider" in dynamic_metadata: + config = dynamic_metadata.copy() + provider = config.pop("provider") + provider_path = config.pop("provider-path", None) + module = load_provider(provider, provider_path) + yield from getattr( + module, "get_requires_for_dynamic_metadata", lambda _: [] + )(config) diff --git a/src/scikit_build_core/builder/sysconfig.py b/src/scikit_build_core/builder/sysconfig.py index add693f8..862564d9 100644 --- a/src/scikit_build_core/builder/sysconfig.py +++ b/src/scikit_build_core/builder/sysconfig.py @@ -65,6 +65,9 @@ def get_python_library(env: Mapping[str, str], *, abi3: bool = False) -> Path | libpath = libdir / ldlibrary if Path(os.path.expandvars(libpath)).is_file(): return libpath + logger.warning("libdir/ldlibrary: {} is not a real file!", libpath) + else: + logger.warning("libdir: {} is not a directory", libdir) framework_prefix = sysconfig.get_config_var("PYTHONFRAMEWORKPREFIX") if framework_prefix and Path(framework_prefix).is_dir() and ldlibrary: diff --git a/src/scikit_build_core/metadata/fancy_pypi_readme.py b/src/scikit_build_core/metadata/fancy_pypi_readme.py index b0ba5d02..7b2953bc 100644 --- a/src/scikit_build_core/metadata/fancy_pypi_readme.py +++ b/src/scikit_build_core/metadata/fancy_pypi_readme.py @@ -1,6 +1,8 @@ from __future__ import annotations -from typing import Any +from pathlib import Path + +from .._compat import tomllib __all__ = ["dynamic_metadata"] @@ -10,12 +12,23 @@ def __dir__() -> list[str]: def dynamic_metadata( - pyproject_dict: dict[str, Any], - _config_settings: dict[str, list[str] | str] | None = None, + fields: frozenset[str], + settings: dict[str, list[str] | str] | None = None, ) -> dict[str, str | dict[str, str | None]]: from hatch_fancy_pypi_readme._builder import build_text from hatch_fancy_pypi_readme._config import load_and_validate_config + if fields != {"readme"}: + msg = "Only the 'readme' field is supported" + raise ValueError(msg) + + if settings: + msg = "No inline configuration is supported" + raise ValueError(msg) + + with Path("pyproject.toml").open("rb") as f: + pyproject_dict = tomllib.load(f) + config = load_and_validate_config( pyproject_dict["tool"]["hatch"]["metadata"]["hooks"]["fancy-pypi-readme"] ) @@ -29,6 +42,6 @@ def dynamic_metadata( def get_requires_for_dynamic_metadata( - _config_settings: dict[str, list[str] | str] | None = None, + _settings: dict[str, object] | None = None, ) -> list[str]: return ["hatch-fancy-pypi-readme"] diff --git a/src/scikit_build_core/metadata/setuptools_scm.py b/src/scikit_build_core/metadata/setuptools_scm.py index 1988c83e..2f902ef8 100644 --- a/src/scikit_build_core/metadata/setuptools_scm.py +++ b/src/scikit_build_core/metadata/setuptools_scm.py @@ -8,11 +8,20 @@ def __dir__() -> list[str]: def dynamic_metadata( - pyproject_dict: dict[str, object], # noqa: ARG001 - _config_settings: dict[str, list[str] | str] | None = None, + fields: frozenset[str], + settings: dict[str, object] | None = None, ) -> dict[str, str | dict[str, str | None]]: # this is a classic implementation, waiting for the release of # vcs-versioning and an improved public interface + + if fields != {"version"}: + msg = "Only the 'version' field is supported" + raise ValueError(msg) + + if settings: + msg = "No inline configuration is supported" + raise ValueError(msg) + from setuptools_scm import Configuration, _get_version config = Configuration.from_file("pyproject.toml") @@ -22,6 +31,6 @@ def dynamic_metadata( def get_requires_for_dynamic_metadata( - _config_settings: dict[str, list[str] | str] | None = None, + _settings: dict[str, object] | None = None, ) -> list[str]: return ["setuptools-scm"] diff --git a/src/scikit_build_core/settings/_load_provider.py b/src/scikit_build_core/settings/_load_provider.py new file mode 100644 index 00000000..b735a2b2 --- /dev/null +++ b/src/scikit_build_core/settings/_load_provider.py @@ -0,0 +1,45 @@ +from __future__ import annotations + +import importlib +import sys +from collections.abc import Iterable +from pathlib import Path +from typing import Any + +from .._compat.typing import Protocol + +__all__ = ["load_provider"] + + +def __dir__() -> list[str]: + return __all__ + + +class DynamicMetadataProtocol(Protocol): + def dynamic_metadata( + self, fields: Iterable[str], settings: dict[str, Any] + ) -> dict[str, Any]: + ... + + +class DynamicMetadataRequirementsProtocol(DynamicMetadataProtocol, Protocol): + def get_requires_for_dynamic_metadata(self, settings: dict[str, Any]) -> list[str]: + ... + + +def load_provider( + provider: str, + provider_path: str | None = None, +) -> DynamicMetadataProtocol | DynamicMetadataRequirementsProtocol: + if provider_path is None: + return importlib.import_module(provider) + + if not Path(provider_path).is_dir(): + msg = "provider-path must be an existing directory" + raise AssertionError(msg) + + try: + sys.path.insert(0, provider_path) + return importlib.import_module(provider) + finally: + sys.path.pop(0) diff --git a/src/scikit_build_core/settings/metadata.py b/src/scikit_build_core/settings/metadata.py index 184da928..f0462ca7 100644 --- a/src/scikit_build_core/settings/metadata.py +++ b/src/scikit_build_core/settings/metadata.py @@ -1,11 +1,11 @@ from __future__ import annotations -import importlib from typing import Any from pyproject_metadata import StandardMetadata from ..settings.skbuild_model import ScikitBuildSettings +from ._load_provider import load_provider __all__ = ["get_standard_metadata"] @@ -14,37 +14,34 @@ def __dir__() -> list[str]: return __all__ -def _load( - mod_name: str, - pyproject_dict: dict[str, Any], - config_settings: dict[str, list[str] | str] | None = None, -) -> dict[str, Any]: - return importlib.import_module(mod_name).dynamic_metadata(pyproject_dict, config_settings) # type: ignore[no-any-return] - - # If pyproject-metadata eventually supports updates, this can be simplified def get_standard_metadata( pyproject_dict: dict[str, Any], settings: ScikitBuildSettings, - config_settings: dict[str, list[str] | str] | None = None, ) -> StandardMetadata: # Handle any dynamic metadata - for field in settings.metadata: + calls: dict[frozenset[tuple[str, Any]], set[str]] = {} + for field, raw_settings in settings.metadata.items(): if field not in pyproject_dict.get("project", {}).get("dynamic", []): msg = f"{field} is not in project.dynamic" raise KeyError(msg) - - plugins = set(settings.metadata.values()) - cached_plugins = { - key: _load(key, pyproject_dict, config_settings) for key in plugins - } - - for field, mod_name in settings.metadata.items(): - if field not in cached_plugins[mod_name]: - msg = f"{field} is not provided by plugin {mod_name}" + if "provider" not in raw_settings: + msg = f"{field} is missing provider" raise KeyError(msg) - - pyproject_dict["project"][field] = cached_plugins[mod_name][field] - pyproject_dict["project"]["dynamic"].remove(field) + calls.setdefault(frozenset(raw_settings.items()), set()).add(field) + + for call, fields in calls.items(): + args = dict(call) + provider = args.pop("provider") + provider_path = args.pop("provider-path", None) + computed = load_provider(provider, provider_path).dynamic_metadata( + frozenset(fields), args + ) + if set(computed) != fields: + msg = f"{provider} did not return requested fields" + raise KeyError(msg) + pyproject_dict["project"].update(computed) + for field in fields: + pyproject_dict["project"]["dynamic"].remove(field) return StandardMetadata.from_pyproject(pyproject_dict) diff --git a/src/scikit_build_core/settings/skbuild_model.py b/src/scikit_build_core/settings/skbuild_model.py index bc98f964..613d20d1 100644 --- a/src/scikit_build_core/settings/skbuild_model.py +++ b/src/scikit_build_core/settings/skbuild_model.py @@ -1,5 +1,5 @@ import dataclasses -from typing import Dict, List, Optional +from typing import Any, Dict, List, Optional __all__ = [ "ScikitBuildSettings", @@ -110,7 +110,7 @@ class ScikitBuildSettings: sdist: SDistSettings = dataclasses.field(default_factory=SDistSettings) wheel: WheelSettings = dataclasses.field(default_factory=WheelSettings) backport: BackportSettings = dataclasses.field(default_factory=BackportSettings) - metadata: Dict[str, str] = dataclasses.field(default_factory=dict) + metadata: Dict[str, Dict[str, Any]] = dataclasses.field(default_factory=dict) #: Strictly check all config options. If False, warnings will be #: printed for unknown options. If True, an error will be raised. diff --git a/src/scikit_build_core/settings/skbuild_read_settings.py b/src/scikit_build_core/settings/skbuild_read_settings.py index 1a65fe86..bb04053f 100644 --- a/src/scikit_build_core/settings/skbuild_read_settings.py +++ b/src/scikit_build_core/settings/skbuild_read_settings.py @@ -35,6 +35,7 @@ def __init__( EnvSource("SKBUILD"), ConfSource(settings=config_settings, verify=verify_conf), TOMLSource("tool", "scikit-build", settings=pyproject), + prefixes=["tool", "scikit-build"], ) self.settings = self.sources.convert_target(ScikitBuildSettings) @@ -86,6 +87,23 @@ def validate_may_exit(self) -> None: raise SystemExit(7) logger.warning("Unrecognized options: {}", ", ".join(unrecognized)) + for key, value in self.settings.metadata.items(): + if "provider" not in value: + sys.stdout.flush() + rich_print( + f"[red][bold]ERROR:[/bold] provider= must be provided in {key!r}:" + ) + raise SystemExit(7) + if not self.settings.experimental and ( + "provider-path" in value + or not value["provider"].startswith("scikit_build_core.") + ): + sys.stdout.flush() + rich_print( + "[red][bold]ERROR:[/bold] experimental must be enabled currently to use plugins not provided by scikit-build-core" + ) + raise SystemExit(7) + @classmethod def from_file( cls, diff --git a/src/scikit_build_core/settings/sources.py b/src/scikit_build_core/settings/sources.py index 7d7b385a..61eaa041 100644 --- a/src/scikit_build_core/settings/sources.py +++ b/src/scikit_build_core/settings/sources.py @@ -121,7 +121,7 @@ def _nested_dataclass_to_names(target: type[Any], *inner: str) -> Iterator[list[ class Source(Protocol): def has_item(self, *fields: str, is_dict: bool) -> bool: """ - Check if the source contains a chain of fields. For example, feilds = + Check if the source contains a chain of fields. For example, fields = [Field(name="a"), Field(name="b")] will check if the source contains the key "a.b". """ @@ -276,16 +276,18 @@ def convert( if raw_target == list: if isinstance(item, list): return [cls.convert(i, _get_inner_type(target)) for i in item] - assert not isinstance(item, dict) + if isinstance(item, dict): + msg = f"Expected {target}, got {type(item).__name__}" + raise TypeError(msg) return [ cls.convert(i.strip(), _get_inner_type(target)) for i in item.split(";") ] if raw_target == dict: assert not isinstance(item, (str, list)) return {k: cls.convert(v, _get_inner_type(target)) for k, v in item.items()} - assert not isinstance( - item, (list, dict) - ), "Can't convert list or dict to non-list/dict" + if isinstance(item, (list, dict)): + msg = f"Expected {target}, got {type(item).__name__}" + raise TypeError(msg) if raw_target is bool: result = item.strip().lower() not in {"0", "false", "off", "no", ""} return result @@ -347,9 +349,17 @@ def get_item(self, *fields: str, is_dict: bool) -> Any: # noqa: ARG002 def convert(cls, item: Any, target: type[Any]) -> object: raw_target = _get_target_raw_type(target) if raw_target == list: + if not isinstance(item, list): + msg = f"Expected {target}, got {type(item).__name__}" + raise TypeError(msg) return [cls.convert(it, _get_inner_type(target)) for it in item] if raw_target == dict: + if not isinstance(item, dict): + msg = f"Expected {target}, got {type(item).__name__}" + raise TypeError(msg) return {k: cls.convert(v, _get_inner_type(target)) for k, v in item.items()} + if raw_target == Any: + return item if callable(raw_target): return raw_target(item) msg = f"Can't convert target {target}" @@ -365,8 +375,14 @@ def all_option_names(self, target: type[Any]) -> Iterator[str]: class SourceChain: - def __init__(self, *sources: Source) -> None: + def __init__(self, *sources: Source, prefixes: Sequence[str] = ()) -> None: + """ + Combine a collection of sources into a single object that can run + ``convert_target(dataclass)``. An optional list of prefixes can be + given that will be prepended (dot separated) to error messages. + """ self.sources = sources + self.prefixes = prefixes def __getitem__(self, index: int) -> Source: return self.sources[index] @@ -387,6 +403,11 @@ def convert(cls, item: Any, target: type[T]) -> T: # noqa: ARG003 raise NotImplementedError(msg) def convert_target(self, target: type[T], *prefixes: str) -> T: + """ + Given a dataclass type, create an object of that dataclass filled + with the values in the sources. + """ + errors = [] prep: dict[str, Any] = {} for field in dataclasses.fields(target): # type: ignore[arg-type] @@ -396,6 +417,8 @@ def convert_target(self, target: type[T], *prefixes: str) -> T: field.type, *prefixes, field.name ) except Exception as e: + name = ".".join([*self.prefixes, *prefixes, field.name]) + e.__notes__ = [*getattr(e, "__notes__", []), f"Field: {name}"] # type: ignore[attr-defined] errors.append(e) continue @@ -407,6 +430,8 @@ def convert_target(self, target: type[T], *prefixes: str) -> T: try: tmp = source.convert(simple, field.type) except Exception as e: + name = ".".join([*self.prefixes, *prefixes, field.name]) + e.__notes__ = [*getattr(e, "__notes__", []), f"Field {name}"] # type: ignore[attr-defined] errors.append(e) prep[field.name] = None break @@ -432,7 +457,7 @@ def convert_target(self, target: type[T], *prefixes: str) -> T: errors.append(ValueError(f"Missing value for {field.name!r}")) if errors: - prefix_str = ".".join(prefixes) + prefix_str = ".".join([*self.prefixes, *prefixes]) msg = f"Failed converting {prefix_str}" raise ExceptionGroup(msg, errors) diff --git a/tests/packages/dynamic_metadata/dual_project.toml b/tests/packages/dynamic_metadata/dual_project.toml index f977a0af..7cd743cf 100644 --- a/tests/packages/dynamic_metadata/dual_project.toml +++ b/tests/packages/dynamic_metadata/dual_project.toml @@ -6,6 +6,7 @@ build-backend = "scikit_build_core.build" name = "fancy" dynamic = ["version", "license"] -[tool.scikit-build.metadata] -version = "test_dual" -license = "test_dual" +[tool.scikit-build] +experimental = true +metadata.version.provider = "test_dual" +metadata.license.provider = "test_dual" diff --git a/tests/packages/dynamic_metadata/faulty_dual_project.toml b/tests/packages/dynamic_metadata/faulty_dual_project.toml index 87797e39..fa0ee1b9 100644 --- a/tests/packages/dynamic_metadata/faulty_dual_project.toml +++ b/tests/packages/dynamic_metadata/faulty_dual_project.toml @@ -6,7 +6,8 @@ build-backend = "scikit_build_core.build" name = "fancy" dynamic = ["version", "readme", "license"] -[tool.scikit-build.metadata] -version = "test_dual" -license = "test_dual" -readme = "test_dual" +[tool.scikit-build] +experimental = true +metadata.version.provider = "test_dual" +metadata.license.provider = "test_dual" +metadata.readme.provider = "test_dual" diff --git a/tests/packages/dynamic_metadata/faulty_project.toml b/tests/packages/dynamic_metadata/faulty_project.toml index dff81f04..7a029082 100644 --- a/tests/packages/dynamic_metadata/faulty_project.toml +++ b/tests/packages/dynamic_metadata/faulty_project.toml @@ -7,4 +7,4 @@ name = "fancy" version = "0.0.1" [tool.scikit-build.metadata] -readme = "scikit_build_core.metadata.fancy_pypi_readme" +readme.provider = "scikit_build_core.metadata.fancy_pypi_readme" diff --git a/tests/packages/dynamic_metadata/local_pyproject.toml b/tests/packages/dynamic_metadata/local_pyproject.toml new file mode 100644 index 00000000..5ddbf263 --- /dev/null +++ b/tests/packages/dynamic_metadata/local_pyproject.toml @@ -0,0 +1,14 @@ +[build-system] +requires = ["scikit-build-core"] +build-backend = "scikit_build_core.build" + +[project] +name = "dynamic" +dynamic = ["version"] + +[tool.scikit-build] +experimental = true + +[tool.scikit-build.metadata.version] +provider = "version.nested" +provider-path = "plugins/local" diff --git a/tests/packages/dynamic_metadata/plugin_project.toml b/tests/packages/dynamic_metadata/plugin_project.toml index 25039084..d460feba 100644 --- a/tests/packages/dynamic_metadata/plugin_project.toml +++ b/tests/packages/dynamic_metadata/plugin_project.toml @@ -7,8 +7,8 @@ name = "fancy" dynamic = ["readme", "version"] [tool.scikit-build.metadata] -version = "scikit_build_core.metadata.setuptools_scm" -readme = "scikit_build_core.metadata.fancy_pypi_readme" +version.provider = "scikit_build_core.metadata.setuptools_scm" +readme.provider = "scikit_build_core.metadata.fancy_pypi_readme" [tool.setuptools_scm] diff --git a/tests/packages/dynamic_metadata/plugins/local/version/__init__.py b/tests/packages/dynamic_metadata/plugins/local/version/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/packages/dynamic_metadata/plugins/local/version/nested/__init__.py b/tests/packages/dynamic_metadata/plugins/local/version/nested/__init__.py new file mode 100644 index 00000000..f68ad031 --- /dev/null +++ b/tests/packages/dynamic_metadata/plugins/local/version/nested/__init__.py @@ -0,0 +1,22 @@ +from __future__ import annotations + +__all__ = ["dynamic_metadata"] + + +def __dir__() -> list[str]: + return __all__ + + +def dynamic_metadata( + fields: frozenset[str], + settings: dict[str, object] | None = None, +) -> dict[str, str | dict[str, str | None]]: + if fields != {"version"}: + msg = "Only the 'version' field is supported" + raise ValueError(msg) + + if settings: + msg = "No inline configuration is supported" + raise ValueError(msg) + + return {"version": "3.2.1"} diff --git a/tests/packages/dynamic_metadata/pyproject.toml b/tests/packages/dynamic_metadata/pyproject.toml index 4cf16540..a59cb33b 100644 --- a/tests/packages/dynamic_metadata/pyproject.toml +++ b/tests/packages/dynamic_metadata/pyproject.toml @@ -6,7 +6,8 @@ build-backend = "scikit_build_core.build" name = "dynamic" dynamic = ["version", "readme", "license"] -[tool.scikit-build.metadata] -version = "test_version" -readme = "test_readme" -license = "test_license" +[tool.scikit-build] +experimental = true +metadata.version.provider = "test_version" +metadata.readme.provider = "test_readme" +metadata.license.provider = "test_license" diff --git a/tests/packages/dynamic_metadata/warn_project.toml b/tests/packages/dynamic_metadata/warn_project.toml index 4074cccc..4b8f04ad 100644 --- a/tests/packages/dynamic_metadata/warn_project.toml +++ b/tests/packages/dynamic_metadata/warn_project.toml @@ -7,5 +7,6 @@ name = "fancy" version = "0.0.1" dynamic = ["readme"] -[tool.scikit-build.metadata] -readme = "non_existent" +[tool.scikit-build] +experimental = true +metadata.readme.provider = "non_existent" diff --git a/tests/test_dynamic_metadata.py b/tests/test_dynamic_metadata.py index 197d0628..a408997d 100644 --- a/tests/test_dynamic_metadata.py +++ b/tests/test_dynamic_metadata.py @@ -11,6 +11,7 @@ import git import pyproject_metadata import pytest +from packaging.version import Version from scikit_build_core._compat import tomllib from scikit_build_core.build import build_wheel @@ -26,16 +27,18 @@ # it turns out to be easier to create EntryPoint objects pointing to real # functions than to mock them. def ep_version( - _pyproject_dict: dict[str, Any], - _config_settings: dict[str, list[str] | str] | None = None, + fields: frozenset[str], + _settings: dict[str, object] | None = None, ) -> dict[str, str | dict[str, str | None]]: + assert fields == {"version"} return {"version": "0.0.2"} def ep_readme( - _pyproject_dict: dict[str, Any], - _config_settings: dict[str, list[str] | str] | None = None, + fields: frozenset[str], + _settings: dict[str, object] | None = None, ) -> dict[str, str | dict[str, str | None]]: + assert fields == {"readme"} return { "readme": { "content-type": "text/x-rst", @@ -45,16 +48,18 @@ def ep_readme( def ep_license( - _pyproject_dict: dict[str, Any], - _config_settings: dict[str, list[str] | str] | None = None, + fields: frozenset[str], + _settings: dict[str, object] | None = None, ) -> dict[str, str | dict[str, str | None]]: + assert fields == {"license"} return {"license": {"text": "MIT License"}} def ep_dual( - _pyproject_dict: dict[str, Any], - _config_settings: dict[str, list[str] | str] | None = None, + _fields: list[str], + _settings: dict[str, object] | None = None, ) -> dict[str, str | dict[str, str | None]]: + # Fields intentionally not checked to verify backend error thrown return { "version": "0.3", "license": {"text": "BSD License"}, @@ -162,6 +167,20 @@ def test_faulty_metadata(monkeypatch): get_standard_metadata(pyproject, settings) +def test_local_plugin_metadata(monkeypatch): + monkeypatch.chdir(DYNAMIC) + + with Path("local_pyproject.toml").open("rb") as ft: + pyproject = tomllib.load(ft) + settings_reader = SettingsReader(pyproject, {}) + settings = settings_reader.settings + + settings_reader.validate_may_exit() + + metadata = get_standard_metadata(pyproject, settings) + assert metadata.version == Version("3.2.1") + + def test_warn_metadata(monkeypatch): monkeypatch.chdir(DYNAMIC) with Path("warn_project.toml").open("rb") as ft: @@ -175,6 +194,18 @@ def test_warn_metadata(monkeypatch): get_standard_metadata(pyproject, settings) +def test_fail_experimental_metadata(monkeypatch): + monkeypatch.chdir(DYNAMIC) + with Path("warn_project.toml").open("rb") as ft: + pyproject = tomllib.load(ft) + settings_reader = SettingsReader(pyproject, {"experimental": "false"}) + + with pytest.raises(SystemExit) as exc: + settings_reader.validate_may_exit() + (value,) = exc.value.args + assert value == 7 + + @pytest.mark.usefixtures("mock_entry_points") def test_dual_metadata(monkeypatch): monkeypatch.chdir(DYNAMIC) diff --git a/tests/test_settings.py b/tests/test_settings.py index 6c9475ed..179b8ce0 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -1,6 +1,6 @@ import dataclasses from pathlib import Path -from typing import Dict, List, Optional, Union +from typing import Any, Dict, List, Optional, Union import pytest @@ -24,6 +24,8 @@ class SettingChecker: seven: Union[int, None] = None eight: Dict[str, str] = dataclasses.field(default_factory=dict) nine: Dict[str, int] = dataclasses.field(default_factory=dict) + # TOML only + ten: Dict[str, Any] = dataclasses.field(default_factory=dict) def test_empty(monkeypatch): @@ -128,6 +130,7 @@ def test_toml(): "seven": 7, "eight": {"one": "one", "two": "two"}, "nine": {"thing": 8}, + "ten": {"a": {"b": 3}}, } sources = SourceChain( @@ -147,6 +150,7 @@ def test_toml(): assert settings.seven == 7 assert settings.eight == {"one": "one", "two": "two"} assert settings.nine == {"thing": 8} + assert settings.ten == {"a": {"b": 3}} def test_all_names(): diff --git a/tests/test_setuptools_pep517.py b/tests/test_setuptools_pep517.py index b1126197..a38d9711 100644 --- a/tests/test_setuptools_pep517.py +++ b/tests/test_setuptools_pep517.py @@ -75,6 +75,7 @@ def test_pep517_sdist(tmp_path, monkeypatch): @pytest.mark.compile() @pytest.mark.configure() +@pytest.mark.broken_on_urct() @pytest.mark.skipif( sys.platform.startswith("cygwin"), reason="Cygwin fails here with ld errors" ) diff --git a/tests/test_setuptools_pep518.py b/tests/test_setuptools_pep518.py index 30740da9..74c59668 100644 --- a/tests/test_setuptools_pep518.py +++ b/tests/test_setuptools_pep518.py @@ -15,6 +15,7 @@ @pytest.mark.compile() @pytest.mark.configure() @pytest.mark.integration() +@pytest.mark.broken_on_urct() @pytest.mark.skipif( sys.platform.startswith("cygwin"), reason="Cygwin fails here with ld errors" ) @@ -56,6 +57,7 @@ def test_pep518_wheel(tmp_path, monkeypatch, isolated): @pytest.mark.compile() @pytest.mark.configure() @pytest.mark.integration() +@pytest.mark.broken_on_urct() @pytest.mark.skipif( sys.platform.startswith("cygwin"), reason="Cygwin fails here with ld errors" ) diff --git a/tests/test_skbuild_settings.py b/tests/test_skbuild_settings.py index b9726274..093d6c33 100644 --- a/tests/test_skbuild_settings.py +++ b/tests/test_skbuild_settings.py @@ -68,7 +68,6 @@ def test_skbuild_settings_envvar(tmp_path, monkeypatch): monkeypatch.setenv("SKBUILD_MINIMUM_VERSION", "0.1") monkeypatch.setenv("SKBUILD_CMAKE_VERBOSE", "TRUE") monkeypatch.setenv("SKBUILD_BUILD_DIR", "a/b/c") - monkeypatch.setenv("SKBUILD_METADATA", "a=b;c=d") pyproject_toml = tmp_path / "pyproject.toml" pyproject_toml.write_text("", encoding="utf-8") @@ -98,7 +97,7 @@ def test_skbuild_settings_envvar(tmp_path, monkeypatch): assert settings.experimental assert settings.minimum_version == "0.1" assert settings.build_dir == "a/b/c" - assert settings.metadata == {"a": "b", "c": "d"} + assert settings.metadata == {} def test_skbuild_settings_config_settings(tmp_path, monkeypatch): @@ -130,8 +129,6 @@ def test_skbuild_settings_config_settings(tmp_path, monkeypatch): "experimental": "1", "minimum-version": "0.1", "build-dir": "a/b/c", - "metadata.a": "b", - "metadata.c": "d", } settings_reader = SettingsReader.from_file(pyproject_toml, config_settings) @@ -157,7 +154,7 @@ def test_skbuild_settings_config_settings(tmp_path, monkeypatch): assert settings.experimental assert settings.minimum_version == "0.1" assert settings.build_dir == "a/b/c" - assert settings.metadata == {"a": "b", "c": "d"} + assert settings.metadata == {} def test_skbuild_settings_pyproject_toml(tmp_path, monkeypatch): @@ -188,7 +185,7 @@ def test_skbuild_settings_pyproject_toml(tmp_path, monkeypatch): experimental = true minimum-version = "0.1" build-dir = "a/b/c" - metadata = {a="b", c="d"} + metadata.version.provider = "a" """ ), encoding="utf-8", @@ -219,7 +216,7 @@ def test_skbuild_settings_pyproject_toml(tmp_path, monkeypatch): assert settings.experimental assert settings.minimum_version == "0.1" assert settings.build_dir == "a/b/c" - assert settings.metadata == {"a": "b", "c": "d"} + assert settings.metadata == {"version": {"provider": "a"}} def test_skbuild_settings_pyproject_toml_broken(tmp_path, capsys):