Skip to content

Commit c299548

Browse files
authored
feat: auto cmake version (#804)
Close #777. This is on by default for 0.10+. Setting "CMakeLists.txt" explicitly though will force it to be found, while the default will fallback on 3.15+. --------- Signed-off-by: Henry Schreiner <[email protected]>
1 parent 054e0cc commit c299548

17 files changed

+647
-14
lines changed

README.md

+4-2
Original file line numberDiff line numberDiff line change
@@ -154,8 +154,10 @@ print("```\n")
154154
[tool.scikit-build]
155155
# The versions of CMake to allow. If CMake is not present on the system or does
156156
# not pass this specifier, it will be downloaded via PyPI if possible. An empty
157-
# string will disable this check.
158-
cmake.version = ">=3.15"
157+
# string will disable this check. The default on 0.10+ is "CMakeLists.txt",
158+
# which will read it from the project's CMakeLists.txt file, or ">=3.15" if
159+
# unreadable or <0.10.
160+
cmake.version = ""
159161

160162
# A list of args to pass to CMake when configuring the project. Setting this in
161163
# config or envvar will override toml. See also ``cmake.define``.

docs/api/scikit_build_core.ast.rst

+26
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
scikit\_build\_core.ast package
2+
===============================
3+
4+
.. automodule:: scikit_build_core.ast
5+
:members:
6+
:undoc-members:
7+
:show-inheritance:
8+
9+
Submodules
10+
----------
11+
12+
scikit\_build\_core.ast.ast module
13+
----------------------------------
14+
15+
.. automodule:: scikit_build_core.ast.ast
16+
:members:
17+
:undoc-members:
18+
:show-inheritance:
19+
20+
scikit\_build\_core.ast.tokenizer module
21+
----------------------------------------
22+
23+
.. automodule:: scikit_build_core.ast.tokenizer
24+
:members:
25+
:undoc-members:
26+
:show-inheritance:

docs/api/scikit_build_core.rst

+1
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ Subpackages
1212
.. toctree::
1313
:maxdepth: 4
1414

15+
scikit_build_core.ast
1516
scikit_build_core.build
1617
scikit_build_core.builder
1718
scikit_build_core.file_api

docs/api/scikit_build_core.settings.rst

+8
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,14 @@ scikit\_build\_core.settings package
99
Submodules
1010
----------
1111

12+
scikit\_build\_core.settings.auto\_cmake\_version module
13+
--------------------------------------------------------
14+
15+
.. automodule:: scikit_build_core.settings.auto_cmake_version
16+
:members:
17+
:undoc-members:
18+
:show-inheritance:
19+
1220
scikit\_build\_core.settings.auto\_requires module
1321
--------------------------------------------------
1422

docs/configuration.md

+6
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,12 @@ cmake.version = ">=3.26.1"
142142
ninja.version = ">=1.11"
143143
```
144144

145+
You can try to read the version from your CMakeLists.txt with the special
146+
string `"CMakeLists.txt"`. This is an error if the minimum version was not
147+
statically detectable in the file. If your `minimum-version` setting is unset
148+
or set to "0.10" or higher, scikit-build-core will still try to read this if
149+
possible, and will fall back on ">=3.15" if it can't read it.
150+
145151
You can also enforce ninja to be required even if make is present on Unix:
146152

147153
```toml

pyproject.toml

+1
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,7 @@ report.exclude_lines = [
205205
'if typing.TYPE_CHECKING:',
206206
'if TYPE_CHECKING:',
207207
'def __repr__',
208+
'if __name__ == "main":',
208209
]
209210

210211

src/scikit_build_core/ast/__init__.py

+3
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
from __future__ import annotations
2+
3+
__all__: list[str] = []

src/scikit_build_core/ast/ast.py

+95
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
from __future__ import annotations
2+
3+
import dataclasses
4+
import sys
5+
from pathlib import Path
6+
from typing import TYPE_CHECKING
7+
8+
from .._logging import rich_print
9+
from .tokenizer import Token, TokenType, tokenize
10+
11+
if TYPE_CHECKING:
12+
from collections.abc import Generator
13+
14+
__all__ = ["Node", "Block", "parse"]
15+
16+
17+
def __dir__() -> list[str]:
18+
return __all__
19+
20+
21+
@dataclasses.dataclass(frozen=True)
22+
class Node:
23+
__slots__ = ("name", "value", "start", "stop")
24+
25+
name: str
26+
value: str
27+
start: int
28+
stop: int
29+
30+
def __str__(self) -> str:
31+
return f"{self.name}({self.value})"
32+
33+
34+
@dataclasses.dataclass(frozen=True)
35+
class Block(Node):
36+
__slots__ = ("contents",)
37+
38+
contents: list[Node]
39+
40+
def __str__(self) -> str:
41+
return f"{super().__str__()} ... {len(self.contents)} children"
42+
43+
44+
def parse(
45+
tokens: Generator[Token, None, None], stop: str = ""
46+
) -> Generator[Node, None, None]:
47+
"""
48+
Generate a stream of nodes from a stream of tokens. This currently bundles all block-like functions
49+
into a single `Block` node, but this could be changed to be more specific eventually if needed.
50+
"""
51+
try:
52+
while True:
53+
token = next(tokens)
54+
if token.type != TokenType.UNQUOTED:
55+
continue
56+
name = token.value.lower()
57+
start = token.start
58+
token = next(tokens)
59+
if token.type == TokenType.WHITESPACE:
60+
token = next(tokens)
61+
if token.type != TokenType.OPEN_PAREN:
62+
msg = f"Expected open paren after {name!r}, got {token!r}"
63+
raise AssertionError(msg)
64+
count = 1
65+
value = ""
66+
while True:
67+
token = next(tokens)
68+
if token.type == TokenType.OPEN_PAREN:
69+
count += 1
70+
elif token.type == TokenType.CLOSE_PAREN:
71+
count -= 1
72+
if count == 0:
73+
break
74+
value += token.value
75+
76+
if name in {"if", "foreach", "while", "macro", "function", "block"}:
77+
contents = list(parse(tokens, f"end{name}"))
78+
yield Block(name, value, start, contents[-1].stop, contents)
79+
else:
80+
yield Node(name, value, start, token.stop)
81+
if stop and name == stop:
82+
break
83+
except StopIteration:
84+
pass
85+
86+
87+
if __name__ == "__main__":
88+
with Path(sys.argv[1]).open(encoding="utf-8-sig") as f:
89+
for node in parse(tokenize(f.read())):
90+
cnode = dataclasses.replace(
91+
node,
92+
name=f"[bold blue]{node.name}[/bold /blue]",
93+
value=f"[green]{node.value}[/green]",
94+
)
95+
rich_print(cnode)
+81
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
from __future__ import annotations
2+
3+
import dataclasses
4+
import enum
5+
import re
6+
import sys
7+
from pathlib import Path
8+
from typing import TYPE_CHECKING
9+
10+
from .._logging import rich_print
11+
12+
if TYPE_CHECKING:
13+
from collections.abc import Generator
14+
15+
__all__ = ["Token", "TokenType", "tokenize"]
16+
17+
18+
def __dir__() -> list[str]:
19+
return __all__
20+
21+
22+
TOKEN_EXPRS = {
23+
"BRACKET_COMMENT": r"\s*#\[(?P<bc1>=*)\[(?s:.)*?\](?P=bc1)\]",
24+
"COMMENT": r"#.*$",
25+
"QUOTED": r'"(?:\\(?s:.)|[^"\\])*?"',
26+
"BRACKET_QUOTE": r"\[(?P<bq1>=*)\[(?s:.)*?\](?P=bq1)\]",
27+
"OPEN_PAREN": r"\(",
28+
"CLOSE_PAREN": r"\)",
29+
"LEGACY": r'\b\w+=[^\s"()$\\]*(?:"[^"\\]*"[^\s"()$\\]*)*|"(?:[^"\\]*(?:\\.[^"\\]*)*)*"',
30+
"UNQUOTED": r"(?:\\.|[^\s()#\"\\])+",
31+
}
32+
33+
34+
class TokenType(enum.Enum):
35+
BRACKET_COMMENT = enum.auto()
36+
COMMENT = enum.auto()
37+
UNQUOTED = enum.auto()
38+
QUOTED = enum.auto()
39+
BRACKET_QUOTE = enum.auto()
40+
LEGACY = enum.auto()
41+
OPEN_PAREN = enum.auto()
42+
CLOSE_PAREN = enum.auto()
43+
WHITESPACE = enum.auto()
44+
45+
46+
@dataclasses.dataclass(frozen=True)
47+
class Token:
48+
__slots__ = ("type", "start", "stop", "value")
49+
50+
type: TokenType
51+
start: int
52+
stop: int
53+
value: str
54+
55+
def __str__(self) -> str:
56+
return f"{self.type.name}({self.value!r})"
57+
58+
59+
def tokenize(contents: str) -> Generator[Token, None, None]:
60+
tok_regex = "|".join(f"(?P<{n}>{v})" for n, v in TOKEN_EXPRS.items())
61+
last = 0
62+
for match in re.finditer(tok_regex, contents, re.MULTILINE):
63+
for typ, value in match.groupdict().items():
64+
if typ in TOKEN_EXPRS and value is not None:
65+
if match.start() != last:
66+
yield Token(
67+
TokenType.WHITESPACE,
68+
last,
69+
match.start(),
70+
contents[last : match.start()],
71+
)
72+
last = match.end()
73+
yield Token(TokenType[typ], match.start(), match.end(), value)
74+
75+
76+
if __name__ == "__main__":
77+
with Path(sys.argv[1]).open(encoding="utf-8-sig") as f:
78+
for token in tokenize(f.read()):
79+
rich_print(
80+
f"[green]{token.type.name}[/green][red]([/red]{token.value}[red])[/red]"
81+
)

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

+1-2
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,7 @@
1616
},
1717
"version": {
1818
"type": "string",
19-
"default": ">=3.15",
20-
"description": "The versions of CMake to allow. If CMake is not present on the system or does not pass this specifier, it will be downloaded via PyPI if possible. An empty string will disable this check."
19+
"description": "The versions of CMake to allow. If CMake is not present on the system or does not pass this specifier, it will be downloaded via PyPI if possible. An empty string will disable this check. The default on 0.10+ is \"CMakeLists.txt\", which will read it from the project's CMakeLists.txt file, or \">=3.15\" if unreadable or <0.10."
2120
},
2221
"args": {
2322
"type": "array",
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
from __future__ import annotations
2+
3+
from ..ast.ast import parse
4+
from ..ast.tokenizer import tokenize
5+
6+
__all__ = ["find_min_cmake_version"]
7+
8+
9+
def __dir__() -> list[str]:
10+
return __all__
11+
12+
13+
def find_min_cmake_version(cmake_content: str) -> str | None:
14+
"""
15+
Locate the minimum required version. Return None if not found.
16+
"""
17+
for node in parse(tokenize(cmake_content)):
18+
if node.name == "cmake_minimum_required":
19+
return (
20+
node.value.replace("VERSION", "")
21+
.replace("FATAL_ERROR", "")
22+
.split("...")[0]
23+
.strip()
24+
.strip("\"'[]=")
25+
)
26+
27+
return None

src/scikit_build_core/settings/skbuild_model.py

+6-4
Original file line numberDiff line numberDiff line change
@@ -33,11 +33,13 @@ class CMakeSettings:
3333
DEPRECATED in 0.8; use version instead.
3434
"""
3535

36-
version: SpecifierSet = SpecifierSet(">=3.15")
36+
version: Optional[SpecifierSet] = None
3737
"""
38-
The versions of CMake to allow. If CMake is not present on the system or does
39-
not pass this specifier, it will be downloaded via PyPI if possible. An empty
40-
string will disable this check.
38+
The versions of CMake to allow. If CMake is not present on the system or
39+
does not pass this specifier, it will be downloaded via PyPI if possible. An
40+
empty string will disable this check. The default on 0.10+ is
41+
"CMakeLists.txt", which will read it from the project's CMakeLists.txt file,
42+
or ">=3.15" if unreadable or <0.10.
4143
"""
4244

4345
args: List[str] = dataclasses.field(default_factory=list)

0 commit comments

Comments
 (0)