Skip to content

Commit 2910c15

Browse files
LecrisUThenryiii
andauthored
feat: add a setting to disallow hard-coding some setting keys in the pyproject.toml (#1078)
This complements the discussion in #1050 that we should not allow some fields to be hard-coded. We should allow them in the context of overrides, so here is a rather convoluted way of adding that check. This will also make it possible to extend it further if we want more specific checks for specific overrides matchers as well. E.g. we can add a restriction that `build.requires` cannot have `@` in an `sdist` --------- Signed-off-by: Cristian Le <git@lecris.dev> Signed-off-by: Henry Schreiner <henryschreineriii@gmail.com> Signed-off-by: Henry Schreiner <henryfs@princeton.edu> Co-authored-by: Henry Schreiner <henryschreineriii@gmail.com> Co-authored-by: Henry Schreiner <henryfs@princeton.edu>
1 parent 5f78d8f commit 2910c15

File tree

12 files changed

+271
-17
lines changed

12 files changed

+271
-17
lines changed

README.md

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -286,9 +286,6 @@ minimum-version = "0.11" # current version
286286
# The CMake build directory. Defaults to a unique temporary directory.
287287
build-dir = ""
288288

289-
# Immediately fail the build. This is only useful in overrides.
290-
fail = false
291-
292289
```
293290

294291
<!-- [[[end]]] -->

docs/reference/configs.md

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,6 @@ print(mk_skbuild_docs())
7878
```{eval-rst}
7979
.. confval:: fail
8080
:type: ``bool``
81-
:default: false
8281
8382
Immediately fail the build. This is only useful in overrides.
8483
```

src/scikit_build_core/resources/scikit-build.schema.json

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -489,11 +489,6 @@
489489
"default": "",
490490
"description": "The CMake build directory. Defaults to a unique temporary directory."
491491
},
492-
"fail": {
493-
"type": "boolean",
494-
"default": false,
495-
"description": "Immediately fail the build. This is only useful in overrides."
496-
},
497492
"overrides": {
498493
"type": "array",
499494
"description": "A list of overrides to apply to the settings, based on the `if` selector.",
@@ -648,7 +643,8 @@
648643
"$ref": "#/properties/build-dir"
649644
},
650645
"fail": {
651-
"$ref": "#/properties/fail"
646+
"type": "boolean",
647+
"description": "Immediately fail the build. This is only useful in overrides."
652648
}
653649
}
654650
}

src/scikit_build_core/settings/documentation.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ class DCDoc:
5656
docs: str
5757
field: dataclasses.Field[typing.Any]
5858
deprecated: bool = False
59+
override_only: bool = False
5960

6061

6162
def sanitize_default_field(text: str) -> str:
@@ -134,4 +135,5 @@ def mk_docs(dc: type[object], prefix: str = "") -> Generator[DCDoc, None, None]:
134135
docs=docs[field.name],
135136
field=field,
136137
deprecated=field.metadata.get("deprecated", False),
138+
override_only=field.metadata.get("override_only", False),
137139
)

src/scikit_build_core/settings/json_schema.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,8 @@ def to_json_schema(dclass: type[Any], *, normalize_keys: bool) -> dict[str, Any]
9797
props[field.name]["description"] = docs[field.name].split("\n", maxsplit=1)[0]
9898
if field.metadata.get("deprecated"):
9999
props[field.name]["deprecated"] = True
100+
if field.metadata.get("override_only"):
101+
props[field.name]["scikit-build:override-only"] = True
100102

101103
if normalize_keys:
102104
props = {k.replace("_", "-"): v for k, v in props.items()}

src/scikit_build_core/settings/skbuild_docs_readme.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,11 @@ def mk_skbuild_docs() -> str:
4646
Makes documentation for the skbuild model.
4747
"""
4848
doc = Document(
49-
[Item(item) for item in mk_docs(ScikitBuildSettings) if not item.deprecated]
49+
[
50+
Item(item)
51+
for item in mk_docs(ScikitBuildSettings)
52+
if not item.deprecated and not item.override_only
53+
]
5054
)
5155
return doc.format()
5256

src/scikit_build_core/settings/skbuild_model.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@ def __dir__() -> List[str]:
3333
class SettingsFieldMetadata(TypedDict, total=False):
3434
display_default: Optional[str]
3535
deprecated: bool
36+
override_only: bool
37+
"""Do not allow the field to be a top-level table."""
3638

3739

3840
class CMakeSettingsDefine(str):
@@ -507,7 +509,10 @@ class ScikitBuildSettings:
507509
This can be set to reuse the build directory from previous runs.
508510
"""
509511

510-
fail: bool = False
512+
fail: Optional[bool] = dataclasses.field(
513+
default=None,
514+
metadata=SettingsFieldMetadata(override_only=True),
515+
)
511516
"""
512517
Immediately fail the build. This is only useful in overrides.
513518
"""

src/scikit_build_core/settings/skbuild_overrides.py

Lines changed: 100 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from __future__ import annotations
22

3+
import dataclasses
34
import os
45
import platform
56
import re
@@ -18,7 +19,7 @@
1819
from ..errors import CMakeNotFoundError
1920
from ..resources import resources
2021

21-
__all__ = ["process_overrides", "regex_match"]
22+
__all__ = ["OverrideRecord", "process_overrides", "regex_match"]
2223

2324

2425
def __dir__() -> list[str]:
@@ -29,6 +30,34 @@ def __dir__() -> list[str]:
2930
from collections.abc import Mapping
3031

3132

33+
@dataclasses.dataclass
34+
class OverrideRecord:
35+
"""
36+
Record of the override action.
37+
38+
Saves the original and final values, and the override reasons.
39+
"""
40+
41+
key: str
42+
"""Settings key that is overridden."""
43+
44+
original_value: Any | None
45+
"""
46+
Original value in the pyproject table.
47+
48+
If the pyproject table did not have the key, this is a ``None``.
49+
"""
50+
51+
value: Any
52+
"""Final value."""
53+
54+
passed_all: dict[str, str] | None
55+
"""All if statements that passed (except the effective ``match_any``)."""
56+
57+
passed_any: dict[str, str] | None
58+
"""All if.any statements that passed."""
59+
60+
3261
def strtobool(value: str) -> bool:
3362
"""
3463
Converts a environment variable string into a boolean value.
@@ -257,20 +286,72 @@ def inherit_join(
257286
raise TypeError(msg)
258287

259288

289+
def record_override(
290+
*keys: str,
291+
value: Any,
292+
tool_skb: dict[str, Any],
293+
overriden_items: dict[str, OverrideRecord],
294+
passed_all: dict[str, str] | None,
295+
passed_any: dict[str, str] | None,
296+
) -> None:
297+
full_key = ".".join(keys)
298+
# Get the original_value to construct the record
299+
if full_key in overriden_items:
300+
# We found the original value from a previous override record
301+
original_value = overriden_items[full_key].original_value
302+
else:
303+
# Otherwise navigate the original pyproject table until we resolved all keys
304+
_dict_or_value = tool_skb
305+
keys_list = [*keys]
306+
while keys_list:
307+
k = keys_list.pop(0)
308+
if k not in _dict_or_value:
309+
# We hit a dead end so we imply the original_value was not set (`None`)
310+
original_value = None
311+
break
312+
_dict_or_value = _dict_or_value[k]
313+
if isinstance(_dict_or_value, dict):
314+
# If the value is a dict it is either the final value or we continue
315+
# to navigate it
316+
continue
317+
# Otherwise it should be the final value
318+
original_value = _dict_or_value
319+
if keys_list:
320+
msg = f"Could not navigate to the key {full_key} because {k} is a {type(_dict_or_value)}"
321+
raise TypeError(msg)
322+
break
323+
else:
324+
# We exhausted all keys so the current value should be the table key we are
325+
# interested in
326+
original_value = _dict_or_value
327+
# Now save the override record
328+
overriden_items[full_key] = OverrideRecord(
329+
key=keys[-1],
330+
original_value=original_value,
331+
value=value,
332+
passed_any=passed_any,
333+
passed_all=passed_all,
334+
)
335+
336+
260337
def process_overrides(
261338
tool_skb: dict[str, Any],
262339
*,
263340
state: Literal["sdist", "wheel", "editable", "metadata_wheel", "metadata_editable"],
264341
retry: bool,
265342
env: Mapping[str, str] | None = None,
266-
) -> set[str]:
343+
) -> tuple[set[str], dict[str, OverrideRecord]]:
267344
"""
268345
Process overrides into the main dictionary if they match. Modifies the input
269346
dictionary. Must be run from the package directory.
347+
348+
:return: A tuple of the set of matching overrides and a dict of changed keys and
349+
override record
270350
"""
271351
has_dist_info = Path("PKG-INFO").is_file()
272352

273353
global_matched: set[str] = set()
354+
overriden_items: dict[str, OverrideRecord] = {}
274355
for override in tool_skb.pop("overrides", []):
275356
passed_any: dict[str, str] | None = None
276357
passed_all: dict[str, str] | None = None
@@ -354,17 +435,33 @@ def process_overrides(
354435
inherit1 = inherit_override.get(key, {})
355436
if isinstance(value, dict):
356437
for key2, value2 in value.items():
438+
record_override(
439+
*[key, key2],
440+
value=value2,
441+
tool_skb=tool_skb,
442+
overriden_items=overriden_items,
443+
passed_all=passed_all,
444+
passed_any=passed_any,
445+
)
357446
inherit2 = inherit1.get(key2, "none")
358447
inner = tool_skb.get(key, {})
359448
inner[key2] = inherit_join(
360449
value2, inner.get(key2, None), inherit2
361450
)
362451
tool_skb[key] = inner
363452
else:
453+
record_override(
454+
key,
455+
value=value,
456+
tool_skb=tool_skb,
457+
overriden_items=overriden_items,
458+
passed_all=passed_all,
459+
passed_any=passed_any,
460+
)
364461
inherit_override_tmp = inherit_override or "none"
365462
if isinstance(inherit_override_tmp, dict):
366463
assert not inherit_override_tmp
367464
tool_skb[key] = inherit_join(
368465
value, tool_skb.get(key), inherit_override_tmp
369466
)
370-
return global_matched
467+
return global_matched, overriden_items

src/scikit_build_core/settings/skbuild_read_settings.py

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@
2424
import os
2525
from collections.abc import Generator, Mapping
2626

27+
from .skbuild_overrides import OverrideRecord
28+
2729

2830
__all__ = ["SettingsReader"]
2931

@@ -133,6 +135,57 @@ def _handle_move(
133135
return before
134136

135137

138+
def _validate_overrides(
139+
settings: ScikitBuildSettings,
140+
overrides: dict[str, OverrideRecord],
141+
) -> None:
142+
"""Validate all fields with any override information."""
143+
144+
def validate_field(
145+
field: dataclasses.Field[Any],
146+
value: Any,
147+
prefix: str = "",
148+
record: OverrideRecord | None = None,
149+
) -> None:
150+
"""Do the actual validation."""
151+
# Check if we had a hard-coded value in the record
152+
conf_key = field.name.replace("_", "-")
153+
if field.metadata.get("override_only", False):
154+
original_value = record.original_value if record else value
155+
if original_value is not None:
156+
msg = f"{prefix}{conf_key} is not allowed to be hard-coded in the pyproject.toml file"
157+
if settings.strict_config:
158+
sys.stdout.flush()
159+
rich_print(f"{{bold.red}}ERROR:{{normal}} {msg}")
160+
raise SystemExit(7)
161+
logger.warning(msg)
162+
163+
def validate_field_recursive(
164+
obj: Any,
165+
record: OverrideRecord | None = None,
166+
prefix: str = "",
167+
) -> None:
168+
"""Navigate through all the keys and validate each field."""
169+
for field in dataclasses.fields(obj):
170+
conf_key = field.name.replace("_", "-")
171+
closest_record = overrides.get(f"{prefix}{conf_key}", record)
172+
value = getattr(obj, field.name)
173+
# Do the validation of the current field
174+
validate_field(
175+
field=field,
176+
value=value,
177+
prefix=prefix,
178+
record=closest_record,
179+
)
180+
if dataclasses.is_dataclass(value):
181+
validate_field_recursive(
182+
obj=value, record=closest_record, prefix=f"{prefix}{conf_key}."
183+
)
184+
185+
# Navigate all fields starting from the top-level
186+
validate_field_recursive(obj=settings)
187+
188+
136189
class SettingsReader:
137190
def __init__(
138191
self,
@@ -151,7 +204,7 @@ def __init__(
151204

152205
# Handle overrides
153206
pyproject = copy.deepcopy(pyproject)
154-
self.overrides = process_overrides(
207+
self.overrides, self.overriden_items = process_overrides(
155208
pyproject.get("tool", {}).get("scikit-build", {}),
156209
state=state,
157210
env=env,
@@ -352,6 +405,7 @@ def validate_may_exit(self) -> None:
352405
self.print_suggestions()
353406
raise SystemExit(7)
354407
logger.warning("Unrecognized options: {}", ", ".join(unrecognized))
408+
_validate_overrides(self.settings, self.overriden_items)
355409

356410
for key, value in self.settings.metadata.items():
357411
if "provider" not in value:

src/scikit_build_core/settings/skbuild_schema.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,21 @@ def generate_skbuild_schema(tool_name: str = "scikit-build") -> dict[str, Any]:
8282
m: {"$ref": "#/$defs/metadata"} for m in METADATA
8383
}
8484

85+
inherit_only_props = {
86+
k: v
87+
for k, v in schema["properties"].items()
88+
if v.get("scikit-build:override-only")
89+
}
90+
schema["properties"] = {
91+
k: v
92+
for k, v in schema["properties"].items()
93+
if not v.get("scikit-build:override-only")
94+
}
95+
for v in inherit_only_props.values():
96+
del v["scikit-build:override-only"]
8597
props = {k: {"$ref": f"#/properties/{k}"} for k in schema["properties"]}
98+
props.update(inherit_only_props)
99+
86100
schema["$defs"]["if_overrides"] = {
87101
"type": "object",
88102
"minProperties": 1,

0 commit comments

Comments
 (0)