Skip to content

Commit 18f40d0

Browse files
feat: add support to use inferred build tools and to extract tool-specific build dependency information (#1256)
Adds support to use inferred build tools and to extract tool-specific build dependency information for buildspec generation. Signed-off-by: Abhinav Pradeep <[email protected]>
1 parent 84493c4 commit 18f40d0

File tree

13 files changed

+361
-7
lines changed

13 files changed

+361
-7
lines changed

src/macaron/build_spec_generator/common_spec/pypi_spec.py

Lines changed: 41 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import logging
77
import os
88
import re
9+
from typing import Any
910

1011
import tomli
1112
from packageurl import PackageURL
@@ -67,15 +68,17 @@ def get_default_build_commands(
6768

6869
match build_tool_name:
6970
case "pip":
70-
default_build_commands.append("python -m build".split())
71+
default_build_commands.append("python -m build --wheel -n".split())
7172
case "poetry":
7273
default_build_commands.append("poetry build".split())
7374
case "flit":
75+
# We might also want to deal with existence flit.ini, we can do so via
76+
# "python -m flit.tomlify"
7477
default_build_commands.append("flit build".split())
7578
case "hatch":
7679
default_build_commands.append("hatch build".split())
7780
case "conda":
78-
default_build_commands.append("conda build".split())
81+
default_build_commands.append('echo("Not supported")'.split())
7982
case _:
8083
pass
8184

@@ -156,6 +159,7 @@ def resolve_fields(self, purl: PackageURL) -> None:
156159
try:
157160
with pypi_package_json.sourcecode():
158161
try:
162+
# Get the build time requirements from ["build-system", "requires"]
159163
pyproject_content = pypi_package_json.get_sourcecode_file_contents("pyproject.toml")
160164
content = tomli.loads(pyproject_content.decode("utf-8"))
161165
requires = json_extract(content, ["build-system", "requires"], list)
@@ -164,10 +168,10 @@ def resolve_fields(self, purl: PackageURL) -> None:
164168
backend = json_extract(content, ["build-system", "build-backend"], str)
165169
if backend:
166170
build_backends_set.add(backend.replace(" ", ""))
167-
168171
python_version_constraint = json_extract(content, ["project", "requires-python"], str)
169172
if python_version_constraint:
170173
python_version_set.add(python_version_constraint.replace(" ", ""))
174+
self.apply_tool_specific_inferences(build_requires_set, python_version_set, content)
171175
logger.debug(
172176
"After analyzing pyproject.toml from the sdist: build-requires: %s, build_backend: %s",
173177
build_requires_set,
@@ -239,6 +243,40 @@ def resolve_fields(self, purl: PackageURL) -> None:
239243

240244
self.data["build_commands"] = patched_build_commands
241245

246+
def apply_tool_specific_inferences(
247+
self, build_requires_set: set[str], python_version_set: set[str], pyproject_contents: dict[str, Any]
248+
) -> None:
249+
"""
250+
Based on build tools inferred, look into the pyproject.toml for related additional dependencies.
251+
252+
Parameters
253+
----------
254+
build_requires_set: set[str]
255+
Set of build requirements to populate.
256+
python_version_set: set[str]
257+
Set of compatible interpreter versions to populate.
258+
pyproject_contents: dict[str, Any]
259+
Parsed contents of the pyproject.toml file.
260+
"""
261+
# If we have hatch as a build_tool, we will examine [tool.hatch.build.hooks.*] to
262+
# look for any additional build dependencies declared there.
263+
if "hatch" in self.data["build_tools"]:
264+
# Look for [tool.hatch.build.hooks.*]
265+
hatch_build_hooks = json_extract(pyproject_contents, ["tool", "hatch", "build", "hooks"], dict)
266+
if hatch_build_hooks:
267+
for _, section in hatch_build_hooks.items():
268+
dependencies = section.get("dependencies")
269+
if dependencies:
270+
build_requires_set.update(elem.replace(" ", "") for elem in dependencies)
271+
# If we have flit as a build_tool, we will check if the legacy header [tool.flit.metadata] exists,
272+
# and if so, check to see if we can use its "requires-python".
273+
if "flit" in self.data["build_tools"]:
274+
flit_python_version_constraint = json_extract(
275+
pyproject_contents, ["tool", "flit", "metadata", "requires-python"], str
276+
)
277+
if flit_python_version_constraint:
278+
python_version_set.add(flit_python_version_constraint.replace(" ", ""))
279+
242280
def read_directory(self, wheel_path: str, purl: PackageURL) -> tuple[str, str]:
243281
"""
244282
Read in the WHEEL and METADATA file from the .dist_info directory.

src/macaron/build_spec_generator/dockerfile/pypi_dockerfile_output.py

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,17 @@ def gen_dockerfile(buildspec: BaseBuildSpecDict) -> str:
3838
logger.debug("Could not derive a specific interpreter version.")
3939
raise GenerateBuildSpecError("Could not derive specific interpreter version.")
4040
backend_install_commands: str = " && ".join(build_backend_commands(buildspec))
41+
build_tool_install: str = ""
42+
if (
43+
buildspec["build_tools"][0] != "pip"
44+
and buildspec["build_tools"][0] != "conda"
45+
and buildspec["build_tools"][0] != "flit"
46+
):
47+
build_tool_install = f"pip install {buildspec['build_tools'][0]} && "
48+
elif buildspec["build_tools"][0] == "flit":
49+
build_tool_install = (
50+
f"pip install {buildspec['build_tools'][0]} && if test -f \"flit.ini\"; then python -m flit.tomlify; fi && "
51+
)
4152
dockerfile_content = f"""
4253
#syntax=docker/dockerfile:1.10
4354
FROM oraclelinux:9
@@ -87,7 +98,7 @@ def gen_dockerfile(buildspec: BaseBuildSpecDict) -> str:
8798
EOF
8899
89100
# Run the build
90-
RUN /deps/bin/python -m build --wheel -n
101+
RUN {"source /deps/bin/activate && " + build_tool_install + " ".join(x for x in buildspec["build_commands"][0])}
91102
"""
92103

93104
return dedent(dockerfile_content)
@@ -148,4 +159,6 @@ def build_backend_commands(buildspec: BaseBuildSpecDict) -> list[str]:
148159
commands: list[str] = []
149160
for backend, version_constraint in buildspec["build_requires"].items():
150161
commands.append(f'/deps/bin/pip install "{backend}{version_constraint}"')
162+
# For a stable order on the install commands
163+
commands.sort()
151164
return commands

tests/build_spec_generator/dockerfile/__snapshots__/test_pypi_dockerfile_output.ambr

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@
5050
EOF
5151

5252
# Run the build
53-
RUN /deps/bin/python -m build --wheel -n
53+
RUN source /deps/bin/activate && python -m build
5454

5555
'''
5656
# ---
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
# Copyright (c) 2025 - 2025, Oracle and/or its affiliates. All rights reserved.
2+
# Licensed under the Universal Permissive License v 1.0 as shown at https://oss.oracle.com/licenses/upl/.
3+
4+
"""Script to compare a generated dockerfile buildspec."""
5+
6+
import argparse
7+
import logging
8+
from collections.abc import Callable
9+
10+
logger = logging.getLogger(__name__)
11+
logger.setLevel(logging.DEBUG)
12+
logging.basicConfig(format="[%(filename)s:%(lineno)s %(tag)s] %(message)s")
13+
14+
15+
def log_with_tag(tag: str) -> Callable[[str], None]:
16+
"""Generate a log function that prints the name of the file and a tag at the beginning of each line."""
17+
18+
def log_fn(msg: str) -> None:
19+
logger.info(msg, extra={"tag": tag})
20+
21+
return log_fn
22+
23+
24+
log_info = log_with_tag("INFO")
25+
log_err = log_with_tag("ERROR")
26+
log_passed = log_with_tag("PASSED")
27+
log_failed = log_with_tag("FAILED")
28+
29+
30+
def log_diff(result: str, expected: str) -> None:
31+
"""Pretty-print the diff of two strings."""
32+
output = [
33+
*("---- Result ---", result),
34+
*("---- Expected ---", expected),
35+
"-----------------",
36+
]
37+
log_info("\n".join(output))
38+
39+
40+
def main() -> int:
41+
"""Compare a Macaron generated dockerfile buildspec.
42+
43+
Returns
44+
-------
45+
int
46+
0 if the generated dockerfile matches the expected output, or non-zero otherwise.
47+
"""
48+
parser = argparse.ArgumentParser()
49+
parser.add_argument("result_dockerfile", help="the result dockerfile buildspec")
50+
parser.add_argument("expected_dockerfile_buildspec", help="the expected buildspec dockerfile")
51+
args = parser.parse_args()
52+
53+
# Load both files
54+
with open(args.result_dockerfile, encoding="utf-8") as file:
55+
buildspec = normalize(file.read())
56+
57+
with open(args.expected_dockerfile_buildspec, encoding="utf-8") as file:
58+
expected_buildspec = normalize(file.read())
59+
60+
log_info(
61+
f"Comparing the dockerfile buildspec {args.result_dockerfile} with the expected "
62+
+ "output dockerfile {args.expected_dockerfile_buildspec}"
63+
)
64+
65+
# Compare the files
66+
return compare(buildspec, expected_buildspec)
67+
68+
69+
def normalize(contents: str) -> list[str]:
70+
"""Convert string of file contents to list of its non-empty lines"""
71+
return [line.strip() for line in contents.splitlines() if line.strip()]
72+
73+
74+
def compare(buildspec: list[str], expected_buildspec: list[str]) -> int:
75+
"""Compare the lines in the two files directly.
76+
77+
Early return when an unexpected difference is found. If the lengths
78+
mismatch, but the first safe_index_max lines are the same, print
79+
the missing/extra lines.
80+
81+
Returns
82+
-------
83+
int
84+
0 if the generated dockerfile matches the expected output, or non-zero otherwise.
85+
"""
86+
safe_index_max = min(len(buildspec), len(expected_buildspec))
87+
for index in range(safe_index_max):
88+
if buildspec[index] != expected_buildspec[index]:
89+
# Log error
90+
log_err("Mismatch found:")
91+
# Log diff
92+
log_diff(buildspec[index], expected_buildspec[index])
93+
return 1
94+
if safe_index_max < len(expected_buildspec):
95+
log_err("Mismatch found: result is missing trailing lines")
96+
log_diff("", "\n".join(expected_buildspec[safe_index_max:]))
97+
return 1
98+
if safe_index_max < len(buildspec):
99+
log_err("Mismatch found: result has extra trailing lines")
100+
log_diff("\n".join(buildspec[safe_index_max:]), "")
101+
return 1
102+
return 0
103+
104+
105+
if __name__ == "__main__":
106+
raise SystemExit(main())

tests/integration/cases/pypi_cachetools/expected_default.buildspec

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,9 @@
1919
[
2020
"python",
2121
"-m",
22-
"build"
22+
"build",
23+
"--wheel",
24+
"-n"
2325
]
2426
],
2527
"build_requires": {
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
2+
#syntax=docker/dockerfile:1.10
3+
FROM oraclelinux:9
4+
5+
# Install core tools
6+
RUN dnf -y install which wget tar git
7+
8+
# Install compiler and make
9+
RUN dnf -y install gcc make
10+
11+
# Download and unzip interpreter
12+
RUN <<EOF
13+
wget https://www.python.org/ftp/python/3.14.0/Python-3.14.0.tgz
14+
tar -xf Python-3.14.0.tgz
15+
EOF
16+
17+
# Install necessary libraries to build the interpreter
18+
# From: https://devguide.python.org/getting-started/setup-building/
19+
RUN dnf install \
20+
gcc-c++ gdb lzma glibc-devel libstdc++-devel openssl-devel \
21+
readline-devel zlib-devel libzstd-devel libffi-devel bzip2-devel \
22+
xz-devel sqlite sqlite-devel sqlite-libs libuuid-devel gdbm-libs \
23+
perf expat expat-devel mpdecimal python3-pip
24+
25+
# Build interpreter and create venv
26+
RUN <<EOF
27+
cd Python-3.14.0
28+
./configure --with-pydebug
29+
make -s -j $(nproc)
30+
./python -m venv /deps
31+
EOF
32+
33+
# Clone code to rebuild
34+
RUN <<EOF
35+
mkdir src
36+
cd src
37+
git clone https://github.com/tkem/cachetools .
38+
git checkout --force ca7508fd56103a1b6d6f17c8e93e36c60b44ca25
39+
EOF
40+
41+
WORKDIR /src
42+
43+
# Install build and the build backends
44+
RUN <<EOF
45+
/deps/bin/pip install "setuptools==80.9.0" && /deps/bin/pip install "wheel"
46+
/deps/bin/pip install build
47+
EOF
48+
49+
# Run the build
50+
RUN source /deps/bin/activate && python -m build --wheel -n

tests/integration/cases/pypi_cachetools/test.yaml

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,3 +30,17 @@ steps:
3030
kind: default_build_spec
3131
result: output/buildspec/pypi/cachetools/macaron.buildspec
3232
expected: expected_default.buildspec
33+
- name: Generate the buildspec
34+
kind: gen-build-spec
35+
options:
36+
command_args:
37+
- -purl
38+
- pkg:pypi/[email protected]
39+
- --output-format
40+
- dockerfile
41+
- name: Compare Dockerfile.
42+
kind: compare
43+
options:
44+
kind: dockerfile_build_spec
45+
result: output/buildspec/pypi/cachetools/dockerfile.buildspec
46+
expected: expected_dockerfile.buildspec
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
2+
#syntax=docker/dockerfile:1.10
3+
FROM oraclelinux:9
4+
5+
# Install core tools
6+
RUN dnf -y install which wget tar git
7+
8+
# Install compiler and make
9+
RUN dnf -y install gcc make
10+
11+
# Download and unzip interpreter
12+
RUN <<EOF
13+
wget https://www.python.org/ftp/python/3.14.0/Python-3.14.0.tgz
14+
tar -xf Python-3.14.0.tgz
15+
EOF
16+
17+
# Install necessary libraries to build the interpreter
18+
# From: https://devguide.python.org/getting-started/setup-building/
19+
RUN dnf install \
20+
gcc-c++ gdb lzma glibc-devel libstdc++-devel openssl-devel \
21+
readline-devel zlib-devel libzstd-devel libffi-devel bzip2-devel \
22+
xz-devel sqlite sqlite-devel sqlite-libs libuuid-devel gdbm-libs \
23+
perf expat expat-devel mpdecimal python3-pip
24+
25+
# Build interpreter and create venv
26+
RUN <<EOF
27+
cd Python-3.14.0
28+
./configure --with-pydebug
29+
make -s -j $(nproc)
30+
./python -m venv /deps
31+
EOF
32+
33+
# Clone code to rebuild
34+
RUN <<EOF
35+
mkdir src
36+
cd src
37+
git clone https://github.com/executablebooks/markdown-it-py .
38+
git checkout --force c62983f1554124391b47170180e6c62df4d476ca
39+
EOF
40+
41+
WORKDIR /src
42+
43+
# Install build and the build backends
44+
RUN <<EOF
45+
/deps/bin/pip install "flit==3.12.0" && /deps/bin/pip install "flit_core<4,>=3.4"
46+
/deps/bin/pip install build
47+
EOF
48+
49+
# Run the build
50+
RUN source /deps/bin/activate && pip install flit && if test -f "flit.ini"; then python -m flit.tomlify; fi && flit build

tests/integration/cases/pypi_markdown-it-py/test.yaml

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,3 +27,17 @@ steps:
2727
kind: default_build_spec
2828
result: output/buildspec/pypi/markdown-it-py/macaron.buildspec
2929
expected: expected_default.buildspec
30+
- name: Generate the buildspec
31+
kind: gen-build-spec
32+
options:
33+
command_args:
34+
- -purl
35+
- pkg:pypi/[email protected]
36+
- --output-format
37+
- dockerfile
38+
- name: Compare Dockerfile
39+
kind: compare
40+
options:
41+
kind: dockerfile_build_spec
42+
result: output/buildspec/pypi/markdown-it-py/dockerfile.buildspec
43+
expected: expected_dockerfile.buildspec

tests/integration/cases/pypi_toga/expected_default.buildspec

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,9 @@
1919
[
2020
"python",
2121
"-m",
22-
"build"
22+
"build",
23+
"--wheel",
24+
"-n"
2325
]
2426
],
2527
"build_requires": {

0 commit comments

Comments
 (0)