Skip to content

Commit

Permalink
feat: if failed (retry)
Browse files Browse the repository at this point in the history
Signed-off-by: Henry Schreiner <[email protected]>
  • Loading branch information
henryiii committed Jul 19, 2024
1 parent 9bc0d97 commit 5291db7
Show file tree
Hide file tree
Showing 12 changed files with 252 additions and 14 deletions.
13 changes: 13 additions & 0 deletions docs/overrides.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,19 @@ SDist.
This will be true if the `PKG-INFO` file exists, that is, if this is coming
from an SDist. Takes a bool.

### `failed` (bool)

This override is a bit special. If a build fails, scikit-build-core will check
to see if there'a a matching `failed = true` override. If there is, the the build will
be retried once with the new settings. This can be used to build a pure-Python fallback
if a build fails, for example:

```toml
[[tool.scikit-build.overrides]]
if.failed = true
wheel.cmake = false
```

## Any matching condition

If you use `if.any` instead of `if`, then the override is true if any one of the
Expand Down
2 changes: 1 addition & 1 deletion src/scikit_build_core/build/_init.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,9 @@ def __dir__() -> list[str]:
@functools.lru_cache(1)
def setup_logging(log_level: str) -> None:
level_value = LEVEL_VALUE[log_level]
logger.setLevel(level_value)

ch = logging.StreamHandler()
ch.setLevel(level_value)
# create formatter and add it to the handlers
formatter = logging.Formatter(
"%(asctime)s - %(name)s - %(levelname)s - %(message)s"
Expand Down
63 changes: 58 additions & 5 deletions src/scikit_build_core/build/wheel.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,19 +7,20 @@
import sysconfig
import tempfile
from pathlib import Path
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, Any

from packaging.requirements import Requirement
from packaging.utils import canonicalize_name

from .. import __version__
from .._compat import tomllib
from .._compat.typing import Literal, assert_never
from .._logging import logger, rich_print
from .._logging import LEVEL_VALUE, logger, rich_print
from .._shutil import fix_win_37_all_permissions
from ..builder.builder import Builder, archs_to_tags, get_archs
from ..builder.wheel_tag import WheelTag
from ..cmake import CMake, CMaker
from ..errors import FailedLiveProcessError
from ..settings.skbuild_read_settings import SettingsReader
from ._editable import editable_redirect, libdir_to_installed, mapping_to_modules
from ._init import setup_logging
Expand Down Expand Up @@ -123,6 +124,7 @@ def _build_wheel_impl(
) -> WheelImplReturn:
"""
Build a wheel or just prepare metadata (if wheel dir is None). Can be editable.
Handles one retry attempt if "failed" override present.
"""
state: Literal["sdist", "wheel", "editable", "metadata_wheel", "metadata_editable"]
if exit_after_config:
Expand All @@ -136,9 +138,10 @@ def _build_wheel_impl(
with pyproject_path.open("rb") as ft:
pyproject = tomllib.load(ft)

settings_reader = SettingsReader(pyproject, config_settings or {}, state=state)
settings = settings_reader.settings
setup_logging(settings.logging.level)
settings_reader = SettingsReader(
pyproject, config_settings or {}, state=state, retry=False
)
setup_logging(settings_reader.settings.logging.level)

settings_reader.validate_may_exit()

Expand All @@ -156,6 +159,56 @@ def _build_wheel_impl(
"ninja should not be in build-system.requires - scikit-build-core will inject it as needed"
)

try:
return _build_wheel_impl_impl(
wheel_directory,
metadata_directory,
exit_after_config=exit_after_config,
editable=editable,
state=state,
settings=settings_reader.settings,
pyproject=pyproject,
)
except FailedLiveProcessError as err:
settings_reader = SettingsReader(
pyproject, config_settings or {}, state=state, retry=True
)
if "failed" not in settings_reader.overrides:
raise

rich_print(
f"\n[yellow bold]*** {' '.join(err.args)} - retrying due to override..."
)

logger.setLevel(LEVEL_VALUE[settings_reader.settings.logging.level])

settings_reader.validate_may_exit()

return _build_wheel_impl_impl(
wheel_directory,
metadata_directory,
exit_after_config=exit_after_config,
editable=editable,
state=state,
settings=settings_reader.settings,
pyproject=pyproject,
)


def _build_wheel_impl_impl(
wheel_directory: str | None,
metadata_directory: str | None,
*,
exit_after_config: bool = False,
editable: bool,
state: Literal["sdist", "wheel", "editable", "metadata_wheel", "metadata_editable"],
settings: ScikitBuildSettings,
pyproject: dict[str, Any],
) -> WheelImplReturn:
"""
Build a wheel or just prepare metadata (if wheel dir is None). Can be editable.
"""

metadata = get_standard_metadata(pyproject, settings)

if metadata.version is None:
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 @@ -628,6 +628,10 @@
"type": "boolean",
"description": "Whether the build is from an sdist."
},
"failed": {
"type": "boolean",
"description": "Matches if the build fails. A build will be retried if there is at least one matching override with this set to true."
},
"env": {
"type": "object",
"patternProperties": {
Expand Down
73 changes: 65 additions & 8 deletions src/scikit_build_core/settings/skbuild_read_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ def override_match(
"sdist", "wheel", "editable", "metadata_wheel", "metadata_editable"
],
has_dist_info: bool,
retry: bool,
python_version: str | None = None,
implementation_name: str | None = None,
implementation_version: str | None = None,
Expand All @@ -81,25 +82,35 @@ def override_match(
env: dict[str, str] | None = None,
state: str | None = None,
from_sdist: bool | None = None,
) -> bool:
failed: bool | None = None,
) -> set[str]:
"""
Check if the current environment matches the overrides. Returns the set of matched keys.
"""
# This makes a matches list. A match is a string; if it's an empty string,
# then it did not match. If it's not empty, it did match, and the string
# will be printed in the logs - it's the match reason. If match_all is True,
# then all must match, otherwise any match will count.
matches = []

passed_keys = set()

if current_env is None:
current_env = os.environ

if python_version is not None:
current_python_version = ".".join(str(x) for x in sys.version_info[:2])
match_msg = version_match(current_python_version, python_version, "Python")
matches.append(match_msg)
if match_msg:
passed_keys.add("python-version")

if implementation_name is not None:
current_impementation_name = sys.implementation.name
match_msg = regex_match(current_impementation_name, implementation_name)
matches.append(match_msg)
if match_msg:
passed_keys.add("implementation-name")

if implementation_version is not None:
info = sys.implementation.version
Expand All @@ -111,25 +122,45 @@ def override_match(
version, implementation_version, "Python implementation"
)
matches.append(match_msg)
if match_msg:
passed_keys.add("implementation-version")

if platform_system is not None:
current_platform_system = sys.platform
match_msg = regex_match(current_platform_system, platform_system)
matches.append(match_msg)
if match_msg:
passed_keys.add("platform-system")

if platform_machine is not None:
current_platform_machine = platform.machine()
match_msg = regex_match(current_platform_machine, platform_machine)
matches.append(match_msg)
if match_msg:
passed_keys.add("platform-machine")

if platform_node is not None:
current_platform_node = platform.node()
match_msg = regex_match(current_platform_node, platform_node)
matches.append(match_msg)
if match_msg:
passed_keys.add("platform-node")

if state is not None:
match_msg = regex_match(current_state, state)
matches.append(match_msg)
if match_msg:
passed_keys.add("state")

if failed is not None:
if failed and retry:
matches.append("Previous run failed")
passed_keys.add("failed")
elif not failed and not retry:
matches.append("Running on a fresh run")
passed_keys.add("failed")
else:
matches.append("")

if from_sdist is not None:
if has_dist_info:
Expand All @@ -140,17 +171,21 @@ def override_match(
if env:
for key, value in env.items():
if isinstance(value, bool):
matches.append(
match_msg = (
f"env {key} is {value}"
if strtobool(current_env.get(key, "")) == value
else ""
)
matches.append(match_msg)
passed_keys.add(f"env.{key}")
elif key not in current_env:
matches.append("")
else:
current_value = current_env.get(key, "")
match_msg = regex_match(current_value, value)
matches.append(match_msg and f"env {key}: {match_msg}")
if match_msg:
passed_keys.add(f"env.{key}")

if not matches:
msg = "At least one override must be provided"
Expand All @@ -164,7 +199,8 @@ def override_match(
matched = any(matches)
if matched:
logger.info("Overrides {}", " or ".join([m for m in matches if m]))
return matched

return passed_keys if matched else set()


def _handle_minimum_version(
Expand Down Expand Up @@ -273,17 +309,20 @@ def inherit_join(

def process_overides(
tool_skb: dict[str, Any],
*,
state: Literal["sdist", "wheel", "editable", "metadata_wheel", "metadata_editable"],
retry: bool,
env: Mapping[str, str] | None = None,
) -> None:
) -> set[str]:
"""
Process overrides into the main dictionary if they match. Modifies the input
dictionary. Must be run from the package directory.
"""
has_dist_info = Path("PKG-INFO").is_file()

global_matched: set[str] = set()
for override in tool_skb.pop("overrides", []):
matched = True
matched: set[str] | None = None
if_override = override.pop("if", None)
if not if_override:
msg = "At least one 'if' override must be provided"
Expand All @@ -299,6 +338,7 @@ def process_overides(
current_env=env,
current_state=state,
has_dist_info=has_dist_info,
retry=retry,
**select,
)

Expand All @@ -309,13 +349,23 @@ def process_overides(

select = {k.replace("-", "_"): v for k, v in if_override.items()}
if select:
matched = matched and override_match(
each_matched = override_match(
match_all=True,
current_env=env,
current_state=state,
has_dist_info=has_dist_info,
retry=retry,
**select,
)
if matched is None:
matched = each_matched
elif matched and each_matched:
matched |= each_matched
else:
matched = set()
if matched is None:
matched = set()
global_matched |= matched
if matched:
for key, value in override.items():
inherit1 = inherit_override.get(key, {})
Expand All @@ -334,6 +384,7 @@ def process_overides(
tool_skb[key] = inherit_join(
value, tool_skb.get(key), inherit_override_tmp
)
return global_matched


class SettingsReader:
Expand All @@ -348,12 +399,18 @@ def __init__(
extra_settings: Mapping[str, Any] | None = None,
verify_conf: bool = True,
env: Mapping[str, str] | None = None,
retry: bool = False,
) -> None:
self.state = state

# Handle overrides
pyproject = copy.deepcopy(pyproject)
process_overides(pyproject.get("tool", {}).get("scikit-build", {}), state, env)
self.overrides = process_overides(
pyproject.get("tool", {}).get("scikit-build", {}),
state=state,
env=env,
retry=retry,
)

# Support for minimum-version='build-system.requires'
tmp_min_v = (
Expand Down Expand Up @@ -385,7 +442,7 @@ def __init__(

if extra_settings is not None:
extra_skb = copy.deepcopy(dict(extra_settings))
process_overides(extra_skb, state, env)
process_overides(extra_skb, state=state, env=env, retry=retry)
toml_srcs.insert(0, TOMLSource(settings=extra_skb))

prefixed = {
Expand Down
4 changes: 4 additions & 0 deletions src/scikit_build_core/settings/skbuild_schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,10 @@ def generate_skbuild_schema(tool_name: str = "scikit-build") -> dict[str, Any]:
"type": "boolean",
"description": "Whether the build is from an sdist.",
},
"failed": {
"type": "boolean",
"description": "Matches if the build fails. A build will be retried if there is at least one matching override with this set to true.",
},
"env": {
"type": "object",
"patternProperties": {
Expand Down
9 changes: 9 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -302,6 +302,15 @@ def navigate_editable(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Packag
return package


@pytest.fixture()
def broken_fallback(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> PackageInfo:
package = PackageInfo(
"broken_fallback",
)
process_package(package, tmp_path, monkeypatch)
return package


@pytest.fixture()
def package_sdist_config(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
Expand Down
Loading

0 comments on commit 5291db7

Please sign in to comment.