Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,9 @@ sdist.reproducible = true
# If set to True, CMake will be run before building the SDist.
sdist.cmake = false

# Resolve symlinks in the SDist, copying file contents instead of storing symlinks.
sdist.resolve-symlinks = true

# A list of packages to auto-copy into the wheel.
wheel.packages = ["src/<package>", "python/<package>", "<package>"]

Expand Down
13 changes: 13 additions & 0 deletions docs/reference/configs.md
Original file line number Diff line number Diff line change
Expand Up @@ -493,6 +493,19 @@ print(mk_skbuild_docs())
``SOURCE_DATE_EPOCH`` will be used for timestamps, or a fixed value if not set.
```

```{eval-rst}
.. confval:: sdist.resolve-symlinks
:type: ``bool``
:default: true

Resolve symlinks in the SDist, copying file contents instead of storing symlinks.

If not set, it will be ``true`` unless you set the minimum version below 0.13,
in which case it will be ``false`` to preserve backward compatibility.

.. versionadded: 0.13
```

## search

```{eval-rst}
Expand Down
7 changes: 6 additions & 1 deletion src/scikit_build_core/build/sdist.py
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,12 @@ def build_sdist(
)
)
tar = stack.enter_context(
tarfile.TarFile(fileobj=gzip_container, mode="w", format=tarfile.PAX_FORMAT)
tarfile.TarFile(
fileobj=gzip_container,
mode="w",
format=tarfile.PAX_FORMAT,
dereference=settings.sdist.resolve_symlinks,
)
Comment thread
henryiii marked this conversation as resolved.
)
assert settings.sdist.inclusion_mode is not None
paths = sorted(
Expand Down
4 changes: 4 additions & 0 deletions src/scikit_build_core/resources/scikit-build.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,10 @@
"type": "boolean",
"default": false,
"description": "If set to True, CMake will be run before building the SDist."
},
"resolve-symlinks": {
"type": "boolean",
"description": "Resolve symlinks in the SDist, copying file contents instead of storing symlinks."
}
}
},
Expand Down
13 changes: 13 additions & 0 deletions src/scikit_build_core/settings/skbuild_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,19 @@ class SDistSettings:
If set to True, CMake will be run before building the SDist.
"""

resolve_symlinks: Optional[bool] = dataclasses.field(
default=None,
metadata=SettingsFieldMetadata(display_default="true"),
)
"""
Resolve symlinks in the SDist, copying file contents instead of storing symlinks.

If not set, it will be ``true`` unless you set the minimum version below 0.13,
in which case it will be ``false`` to preserve backward compatibility.

.. versionadded: 0.13
"""


@dataclasses.dataclass
class WheelSettings:
Expand Down
16 changes: 16 additions & 0 deletions src/scikit_build_core/settings/skbuild_read_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -380,6 +380,22 @@ def __init__(
else:
self.settings.sdist.inclusion_mode = "default"

if self.settings.sdist.resolve_symlinks is not None:
if (
self.settings.minimum_version is not None
and self.settings.minimum_version < Version("0.13")
):
rich_error(
"minimum-version can't be less than 0.13 to use sdist.resolve-symlinks"
)
elif (
self.settings.minimum_version is not None
and self.settings.minimum_version < Version("0.13")
):
self.settings.sdist.resolve_symlinks = False
else:
self.settings.sdist.resolve_symlinks = True

def unrecognized_options(self) -> Generator[str, None, None]:
return self.sources.unrecognized_options(ScikitBuildSettings)

Expand Down
50 changes: 50 additions & 0 deletions tests/test_pep517_sdist_symlink.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
from __future__ import annotations

import tarfile
from pathlib import Path

import pytest

from scikit_build_core.build import build_sdist


@pytest.fixture
def can_symlink() -> None:
"""Skip the test if symlinks are not supported on this OS."""
try:
Path("_symlink_check").symlink_to("_symlink_check_target")
except OSError:
pytest.skip(
"Creating symlinks is not supported/allowed on this OS without privileges"
)
else:
Path("_symlink_check").unlink(missing_ok=True)
Comment on lines +12 to +21
Copy link

Copilot AI Apr 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can_symlink creates and deletes a fixed-name path in the current working directory (_symlink_check). Since CI runs tests with pytest -n auto (xdist), this can race with other workers if they share the same CWD at fixture setup time. Consider creating the check symlink in a unique temp directory (e.g., via tmp_path_factory.mktemp(...)) to avoid cross-test interference.

Suggested change
def can_symlink() -> None:
"""Skip the test if symlinks are not supported on this OS."""
try:
Path("_symlink_check").symlink_to("_symlink_check_target")
except OSError:
pytest.skip(
"Creating symlinks is not supported/allowed on this OS without privileges"
)
else:
Path("_symlink_check").unlink(missing_ok=True)
def can_symlink(tmp_path_factory) -> None:
"""Skip the test if symlinks are not supported on this OS."""
check_dir = tmp_path_factory.mktemp("symlink_check")
check_link = check_dir / "_symlink_check"
try:
check_link.symlink_to("_symlink_check_target")
except OSError:
pytest.skip(
"Creating symlinks is not supported/allowed on this OS without privileges"
)
else:
check_link.unlink(missing_ok=True)

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this also could be cached, vs. being computed each time.



@pytest.mark.usefixtures("package_simple_pyproject_ext", "can_symlink")
@pytest.mark.parametrize(
("config_settings", "expected_type"),
[
pytest.param({}, "reg", id="dereference_default"),
pytest.param({"sdist.resolve-symlinks": "false"}, "sym", id="no_dereference"),
],
)
def test_pep517_sdist_symlink(
tmp_path: Path,
config_settings: dict[str, list[str] | str],
expected_type: str,
) -> None:
Path("CMakeLists_link.txt").symlink_to("CMakeLists.txt")

out = build_sdist(str(tmp_path), config_settings=config_settings or None)

with tarfile.open(tmp_path / out, "r:gz") as tar:
link_member = tar.getmember("cmake_example-0.0.1/CMakeLists_link.txt")
if expected_type == "reg":
assert link_member.isreg(), (
"The symlink should have been stored as a regular file"
)
else:
assert link_member.issym(), (
"The symlink should have been stored as a symlink"
)
35 changes: 35 additions & 0 deletions tests/test_skbuild_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ def test_skbuild_settings_default(tmp_path: Path):
assert settings.sdist.inclusion_mode == "default"
assert settings.sdist.reproducible
assert not settings.sdist.cmake
assert settings.sdist.resolve_symlinks
Comment thread
henryiii marked this conversation as resolved.
assert settings.wheel.packages is None
assert settings.wheel.py_api == ""
assert not settings.wheel.expand_macos_universal_tags
Expand Down Expand Up @@ -947,3 +948,37 @@ def test_backcompat_sdist_inclusion_mode(

settings_reader = SettingsReader.from_file(pyproject_toml, {})
assert settings_reader.settings.sdist.inclusion_mode == "classic"


def test_backcompat_sdist_resolve_symlinks(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
):
monkeypatch.setattr(
scikit_build_core.settings.skbuild_read_settings, "__version__", "0.13.0"
)
pyproject_toml = tmp_path / "pyproject.toml"
pyproject_toml.write_text(
textwrap.dedent(
"""\
[tool.scikit-build]
minimum-version = "0.12"
"""
),
encoding="utf-8",
)

settings_reader = SettingsReader.from_file(pyproject_toml, {})
assert not settings_reader.settings.sdist.resolve_symlinks
Copy link

Copilot AI Apr 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

assert not ...resolve_symlinks will also pass if the value is None, so this test wouldn’t catch a regression where the setting isn’t resolved. Please assert the explicit boolean value here (e.g., is False) so the backcompat behavior is actually verified.

Suggested change
assert not settings_reader.settings.sdist.resolve_symlinks
assert settings_reader.settings.sdist.resolve_symlinks is False

Copilot uses AI. Check for mistakes.

pyproject_toml.write_text(
textwrap.dedent(
"""\
[tool.scikit-build]
minimum-version = "0.13"
"""
),
encoding="utf-8",
)

settings_reader = SettingsReader.from_file(pyproject_toml, {})
assert settings_reader.settings.sdist.resolve_symlinks
Loading