Skip to content

Commit

Permalink
feat: auto cmake version (#804)
Browse files Browse the repository at this point in the history
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]>
  • Loading branch information
henryiii authored Jul 15, 2024
1 parent 054e0cc commit c299548
Show file tree
Hide file tree
Showing 17 changed files with 647 additions and 14 deletions.
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -154,8 +154,10 @@ print("```\n")
[tool.scikit-build]
# 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.
cmake.version = ">=3.15"
# 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.
cmake.version = ""

# A list of args to pass to CMake when configuring the project. Setting this in
# config or envvar will override toml. See also ``cmake.define``.
Expand Down
26 changes: 26 additions & 0 deletions docs/api/scikit_build_core.ast.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
scikit\_build\_core.ast package
===============================

.. automodule:: scikit_build_core.ast
:members:
:undoc-members:
:show-inheritance:

Submodules
----------

scikit\_build\_core.ast.ast module
----------------------------------

.. automodule:: scikit_build_core.ast.ast
:members:
:undoc-members:
:show-inheritance:

scikit\_build\_core.ast.tokenizer module
----------------------------------------

.. automodule:: scikit_build_core.ast.tokenizer
:members:
:undoc-members:
:show-inheritance:
1 change: 1 addition & 0 deletions docs/api/scikit_build_core.rst
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ Subpackages
.. toctree::
:maxdepth: 4

scikit_build_core.ast
scikit_build_core.build
scikit_build_core.builder
scikit_build_core.file_api
Expand Down
8 changes: 8 additions & 0 deletions docs/api/scikit_build_core.settings.rst
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,14 @@ scikit\_build\_core.settings package
Submodules
----------

scikit\_build\_core.settings.auto\_cmake\_version module
--------------------------------------------------------

.. automodule:: scikit_build_core.settings.auto_cmake_version
:members:
:undoc-members:
:show-inheritance:

scikit\_build\_core.settings.auto\_requires module
--------------------------------------------------

Expand Down
6 changes: 6 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,12 @@ cmake.version = ">=3.26.1"
ninja.version = ">=1.11"
```

You can try to read the version from your CMakeLists.txt with the special
string `"CMakeLists.txt"`. This is an error if the minimum version was not
statically detectable in the file. If your `minimum-version` setting is unset
or set to "0.10" or higher, scikit-build-core will still try to read this if
possible, and will fall back on ">=3.15" if it can't read it.

You can also enforce ninja to be required even if make is present on Unix:

```toml
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,7 @@ report.exclude_lines = [
'if typing.TYPE_CHECKING:',
'if TYPE_CHECKING:',
'def __repr__',
'if __name__ == "main":',
]


Expand Down
3 changes: 3 additions & 0 deletions src/scikit_build_core/ast/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from __future__ import annotations

__all__: list[str] = []
95 changes: 95 additions & 0 deletions src/scikit_build_core/ast/ast.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
from __future__ import annotations

import dataclasses
import sys
from pathlib import Path
from typing import TYPE_CHECKING

from .._logging import rich_print
from .tokenizer import Token, TokenType, tokenize

if TYPE_CHECKING:
from collections.abc import Generator

__all__ = ["Node", "Block", "parse"]


def __dir__() -> list[str]:
return __all__


@dataclasses.dataclass(frozen=True)
class Node:
__slots__ = ("name", "value", "start", "stop")

name: str
value: str
start: int
stop: int

def __str__(self) -> str:
return f"{self.name}({self.value})"


@dataclasses.dataclass(frozen=True)
class Block(Node):
__slots__ = ("contents",)

contents: list[Node]

def __str__(self) -> str:
return f"{super().__str__()} ... {len(self.contents)} children"


def parse(
tokens: Generator[Token, None, None], stop: str = ""
) -> Generator[Node, None, None]:
"""
Generate a stream of nodes from a stream of tokens. This currently bundles all block-like functions
into a single `Block` node, but this could be changed to be more specific eventually if needed.
"""
try:
while True:
token = next(tokens)
if token.type != TokenType.UNQUOTED:
continue
name = token.value.lower()
start = token.start
token = next(tokens)
if token.type == TokenType.WHITESPACE:
token = next(tokens)
if token.type != TokenType.OPEN_PAREN:
msg = f"Expected open paren after {name!r}, got {token!r}"
raise AssertionError(msg)
count = 1
value = ""
while True:
token = next(tokens)
if token.type == TokenType.OPEN_PAREN:
count += 1
elif token.type == TokenType.CLOSE_PAREN:
count -= 1
if count == 0:
break
value += token.value

if name in {"if", "foreach", "while", "macro", "function", "block"}:
contents = list(parse(tokens, f"end{name}"))
yield Block(name, value, start, contents[-1].stop, contents)
else:
yield Node(name, value, start, token.stop)
if stop and name == stop:
break
except StopIteration:
pass


if __name__ == "__main__":
with Path(sys.argv[1]).open(encoding="utf-8-sig") as f:
for node in parse(tokenize(f.read())):
cnode = dataclasses.replace(
node,
name=f"[bold blue]{node.name}[/bold /blue]",
value=f"[green]{node.value}[/green]",
)
rich_print(cnode)
81 changes: 81 additions & 0 deletions src/scikit_build_core/ast/tokenizer.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
from __future__ import annotations

import dataclasses
import enum
import re
import sys
from pathlib import Path
from typing import TYPE_CHECKING

from .._logging import rich_print

if TYPE_CHECKING:
from collections.abc import Generator

__all__ = ["Token", "TokenType", "tokenize"]


def __dir__() -> list[str]:
return __all__


TOKEN_EXPRS = {
"BRACKET_COMMENT": r"\s*#\[(?P<bc1>=*)\[(?s:.)*?\](?P=bc1)\]",
"COMMENT": r"#.*$",
"QUOTED": r'"(?:\\(?s:.)|[^"\\])*?"',
"BRACKET_QUOTE": r"\[(?P<bq1>=*)\[(?s:.)*?\](?P=bq1)\]",
"OPEN_PAREN": r"\(",
"CLOSE_PAREN": r"\)",
"LEGACY": r'\b\w+=[^\s"()$\\]*(?:"[^"\\]*"[^\s"()$\\]*)*|"(?:[^"\\]*(?:\\.[^"\\]*)*)*"',
"UNQUOTED": r"(?:\\.|[^\s()#\"\\])+",
}


class TokenType(enum.Enum):
BRACKET_COMMENT = enum.auto()
COMMENT = enum.auto()
UNQUOTED = enum.auto()
QUOTED = enum.auto()
BRACKET_QUOTE = enum.auto()
LEGACY = enum.auto()
OPEN_PAREN = enum.auto()
CLOSE_PAREN = enum.auto()
WHITESPACE = enum.auto()


@dataclasses.dataclass(frozen=True)
class Token:
__slots__ = ("type", "start", "stop", "value")

type: TokenType
start: int
stop: int
value: str

def __str__(self) -> str:
return f"{self.type.name}({self.value!r})"


def tokenize(contents: str) -> Generator[Token, None, None]:
tok_regex = "|".join(f"(?P<{n}>{v})" for n, v in TOKEN_EXPRS.items())
last = 0
for match in re.finditer(tok_regex, contents, re.MULTILINE):
for typ, value in match.groupdict().items():
if typ in TOKEN_EXPRS and value is not None:
if match.start() != last:
yield Token(
TokenType.WHITESPACE,
last,
match.start(),
contents[last : match.start()],
)
last = match.end()
yield Token(TokenType[typ], match.start(), match.end(), value)


if __name__ == "__main__":
with Path(sys.argv[1]).open(encoding="utf-8-sig") as f:
for token in tokenize(f.read()):
rich_print(
f"[green]{token.type.name}[/green][red]([/red]{token.value}[red])[/red]"
)
3 changes: 1 addition & 2 deletions src/scikit_build_core/resources/scikit-build.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,7 @@
},
"version": {
"type": "string",
"default": ">=3.15",
"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."
"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."
},
"args": {
"type": "array",
Expand Down
27 changes: 27 additions & 0 deletions src/scikit_build_core/settings/auto_cmake_version.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
from __future__ import annotations

from ..ast.ast import parse
from ..ast.tokenizer import tokenize

__all__ = ["find_min_cmake_version"]


def __dir__() -> list[str]:
return __all__


def find_min_cmake_version(cmake_content: str) -> str | None:
"""
Locate the minimum required version. Return None if not found.
"""
for node in parse(tokenize(cmake_content)):
if node.name == "cmake_minimum_required":
return (
node.value.replace("VERSION", "")
.replace("FATAL_ERROR", "")
.split("...")[0]
.strip()
.strip("\"'[]=")
)

return None
10 changes: 6 additions & 4 deletions src/scikit_build_core/settings/skbuild_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,11 +33,13 @@ class CMakeSettings:
DEPRECATED in 0.8; use version instead.
"""

version: SpecifierSet = SpecifierSet(">=3.15")
version: Optional[SpecifierSet] = None
"""
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 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.
"""

args: List[str] = dataclasses.field(default_factory=list)
Expand Down
Loading

0 comments on commit c299548

Please sign in to comment.