Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 19 additions & 1 deletion src/macaron/build_spec_generator/common_spec/base_spec.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,24 @@
from packageurl import PackageURL


class SpecBuildRequirementDict(TypedDict, total=False):
"""
Initialize build requirement section of the build specification.

It contains information about tools/packages that must be available before
running a build command.
"""

#: Build requirement name, e.g., "maturin", "rust", "rustup", "pkg-config".
name: Required[str]

#: Build requirement version or version constraint, e.g., "1.75.0", ">=1.4,<2".
version: NotRequired[str]

#: Build requirement installer, e.g., "pip", "rustup", "system", or "bootstrap".
installer: Required[str]


class SpecBuildCommandDict(TypedDict, total=False):
"""
Initialize build command section of the build specification.
Expand Down Expand Up @@ -100,7 +118,7 @@ class BaseBuildSpecDict(TypedDict, total=False):
entry_point: NotRequired[str | None]

#: The build_requires is the required packages that need to be available in the build environment.
build_requires: NotRequired[dict[str, str]]
build_requires: NotRequired[list[SpecBuildRequirementDict]]

#: A "back end" is tool that a "front end" (such as pip/build) would call to
#: package the source distribution into the wheel format. build_backends would
Expand Down
354 changes: 313 additions & 41 deletions src/macaron/build_spec_generator/common_spec/pypi_spec.py

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,8 @@ def gen_dockerfile(buildspec: BaseBuildSpecDict) -> str:
GenerateBuildSpecError
Raised if dockerfile cannot be generated.
"""
if buildspec["has_binaries"]:
maturin_build = is_maturin_build(buildspec)
if buildspec["has_binaries"] and not maturin_build:
raise GenerateBuildSpecError("We currently do not support generating a dockerfile for non-pure Python packages")
language_version: str | None = pick_specific_version(buildspec["language_version"])
if language_version is None:
Expand All @@ -48,6 +49,7 @@ def gen_dockerfile(buildspec: BaseBuildSpecDict) -> str:
raise GenerateBuildSpecError("Derived interpreter version could not be parsed") from error

backend_install_commands = " && ".join(build_backend_commands(buildspec))
rustup_install_commands = build_rustup_install_commands() if maturin_build else ""

modern_build_command = "python -m build --wheel -n"

Expand All @@ -72,7 +74,7 @@ def gen_dockerfile(buildspec: BaseBuildSpecDict) -> str:

# Install core tools
RUN dnf -y install which wget tar unzip git

{rustup_install_commands}
# Install compiler and make
RUN dnf -y install gcc make

Expand Down Expand Up @@ -156,6 +158,40 @@ def gen_dockerfile(buildspec: BaseBuildSpecDict) -> str:
return dedent(dockerfile_content)


def is_maturin_build(buildspec: BaseBuildSpecDict) -> bool:
"""Check whether the buildspec uses the Maturin build backend.

Parameters
----------
buildspec: BaseBuildSpecDict
The build specification to inspect.

Returns
-------
bool
Whether the buildspec uses Maturin.
"""
return any(backend == "maturin" or backend.startswith("maturin.") for backend in buildspec["build_backends"])


def build_rustup_install_commands() -> str:
"""Generate commands that install the Rust toolchain required by Maturin.

Returns
-------
str
Dockerfile commands that install Rustup and add Cargo to ``PATH``.
"""
return """
# Install Rust toolchain using Rustup
RUN <<EOF
wget -q https://sh.rustup.rs -O /tmp/rustup-init.sh
sh /tmp/rustup-init.sh -y --profile minimal
EOF

ENV PATH=/root/.cargo/bin:$PATH"""


def openssl_install_commands(version: Version) -> str:
"""Appropriate openssl install commands for a given CPython version.

Expand Down Expand Up @@ -365,7 +401,11 @@ def build_backend_commands(buildspec: BaseBuildSpecDict) -> list[str]:
if not buildspec["build_requires"]:
return []
commands: list[str] = []
for backend, version_constraint in buildspec["build_requires"].items():
for requirement in buildspec["build_requires"]:
if requirement["installer"] != "pip":
continue
backend = requirement["name"]
version_constraint = requirement.get("version", "")
if backend == "setuptools":
commands.append("/deps/bin/pip install --upgrade setuptools")
else:
Expand Down
15 changes: 12 additions & 3 deletions src/macaron/resources/schemas/macaron_buildspec_schema.json

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Original file line number Diff line number Diff line change
Expand Up @@ -111,9 +111,18 @@
"description": "Entry point script, class, or binary for running the project."
},
"build_requires": {
"type": "object",
"additionalProperties": { "type": "string" },
"description": "Required packages that must be available in the build environment."
"type": "array",
"items": {
"type": "object",
"properties": {
"name": { "type": "string" },
"version": { "type": "string" },
"installer": { "type": "string" }
},
"required": ["name", "installer"],
"additionalProperties": false
},
"description": "Build environment requirements, including the installer and version constraint for each requirement."
},
"build_backends": {
"type": "array",
Expand Down
31 changes: 22 additions & 9 deletions src/macaron/resources/schemas/macaron_buildspec_schema.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ The integration cases in `tests/integration/cases/pypi_toga/test.yaml` and `test
| `ecosystem` | Yes | Package ecosystem, such as `maven` or `pypi`. This is derived from the PURL type and selects the ecosystem-specific BuildSpec resolver. |
| `purl` | Yes | Package URL for the target component. |
| `language` | Yes | Main implementation language inferred for the ecosystem, for example `java` for Maven or `python` for PyPI. |
| `build_tools` | Yes | Build tools or package managers detected for the repository. For generated specs, Macaron currently recognizes tools such as `maven`, `gradle`, `pip`, `poetry`, `uv`, `flit`, `hatch`, and `conda`, though not every tool has an ecosystem-specific default command. |
| `build_tools` | Yes | Build tools or package managers detected for the repository. For generated specs, Macaron currently recognizes tools such as `maven`, `gradle`, `pip`, `poetry`, `uv`, `flit`, `hatch`, `maturin`, and `conda`, though not every tool has an ecosystem-specific default command. |
| `macaron_version` | Yes | Version of Macaron that generated the spec. |
| `group_id` | No | Ecosystem-specific group or namespace. For Maven this is the Maven group ID. For PyPI this is usually `null`. |
| `artifact_id` | Yes | Package or artifact name. |
Expand All @@ -29,9 +29,9 @@ The integration cases in `tests/integration/cases/pypi_toga/test.yaml` and `test
| `environment` | No | Environment variables needed by the build or test steps. Values are strings. |
| `artifact_path` | No | Expected output artifact path or location, if known. |
| `entry_point` | No | Script, class, binary, or other entry point for running the project, if known. |
| `build_requires` | No | Build environment requirements as a mapping from package name to version specifier. This is currently used for PyPI packages only, where values are inferred from wheel metadata, `pyproject.toml`, source distributions, and fallback heuristics. A value may be an empty string when a package is required but no concrete version constraint is known. |
| `build_requires` | No | Build environment requirements as an array of requirement entries. Each entry identifies the requirement, its installer, and its version constraint.|
| `build_backends` | No | Build backends used by a frontend build tool. For PyPI, this can include values such as `setuptools.build_meta`; these correspond to the backend that tools such as `pip` or `python -m build` call to create a wheel. |
| `has_binaries` | No | Whether the package artifact includes non-pure binaries. Currently ony PyPI packages are supported.|
| `has_binaries` | No | Whether the package artifact includes non-pure binaries. Maturin-backed binary packages have dedicated Dockerfile generation support; other non-pure Python packages remain unsupported by that output format. |
| `upstream_artifacts` | No | Upstream artifacts analyzed while generating the spec, grouped by artifact kind. For example, PyPI may record wheel and sdist URLs; downstream rebuild formats can use the wheel URL to compare the rebuilt artifact with the published artifact. |

## `build_commands`
Expand All @@ -44,7 +44,7 @@ The entries are not only shell snippets. They also carry supporting context abou

| Field | Required by schema | Meaning |
| --- | --- | --- |
| `build_tool` | Yes | The build tool the entry applies to, for example `maven`, `gradle`, `pip`, `poetry`, `uv`, `flit`, or `hatch`. It should match one of the values in the top-level `build_tools` list. |
| `build_tool` | Yes | The build tool the entry applies to, for example `maven`, `gradle`, `pip`, `poetry`, `uv`, `flit`, `hatch`, or `maturin`. It should match one of the values in the top-level `build_tools` list. |
| `build_tool_version` | No | Detected build tool version, when Macaron can infer one. The schema allows this to be `null`, but generated specs omit the field when the version is unknown. |
| `build_config_path` | Yes | Path to the build configuration file associated with this command, relative to the repository root. Examples: `pom.xml`, `submodule/pom.xml`, `build.gradle`, `pyproject.toml`. |
| `root_build_config_path` | No | Optional path to a root or entry build configuration for multi-module builds, relative to the repository root. Maven and Gradle detection can use this when the artifact-specific config is in a module but the build should be launched from a higher-level config. |
Expand Down Expand Up @@ -86,6 +86,7 @@ Current defaults include:
| PyPI | `uv` | `["uv", "build"]` |
| PyPI | `flit` | `["flit", "build"]` |
| PyPI | `hatch` | `["hatch", "build"]` |
| PyPI | `maturin` | `["maturin", "build", "--release"]` |

For PyPI packages with non-pure binary artifacts, the PyPI resolver currently sets `build_commands` to an empty array instead of emitting a rebuild command.

Expand Down Expand Up @@ -138,11 +139,23 @@ This abbreviated example follows the same shape as the validated `pypi_toga` int
}
],
"has_binaries": false,
"build_requires": {
"setuptools": "==80.3.1",
"setuptools_scm": "==8.3.1",
"setuptools_dynamic_dependencies": "==1.0.0"
},
"build_requires": [
{
"name": "setuptools",
"version": "==80.3.1",
"installer": "pip"
},
{
"name": "setuptools_dynamic_dependencies",
"version": "==1.0.0",
"installer": "pip"
},
{
"name": "setuptools_scm",
"version": "==8.3.1",
"installer": "pip"
}
],
"build_backends": ["setuptools.build_meta"],
"upstream_artifacts": {
"wheels": ["https://files.pythonhosted.org/.../toga-0.5.1-py3-none-any.whl"],
Expand Down
3 changes: 3 additions & 0 deletions tests/build_spec_generator/common_spec/test_pypi_spec.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,12 @@
@pytest.mark.parametrize(
("build_tool", "expected_command"),
[
("pip", ["python", "-m", "build", "--wheel", "-n"]),
("poetry", ["poetry", "build"]),
("flit", ["flit", "build"]),
("uv", ["uv", "build"]),
("hatch", ["hatch", "build"]),
("maturin", ["maturin", "build", "--release"]),
],
)
def test_set_default_build_commands_for_pypi_tools(build_tool: str, expected_command: list[str]) -> None:
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,107 @@
# serializer version: 1
# name: test_maturin_binary_package_generation
'''

#syntax=docker/dockerfile:1.10
FROM oraclelinux:9

# Install core tools
RUN dnf -y install which wget tar unzip git

# Install Rust toolchain using Rustup
RUN <<EOF
wget -q https://sh.rustup.rs -O /tmp/rustup-init.sh
sh /tmp/rustup-init.sh -y --profile minimal
EOF

ENV PATH=/root/.cargo/bin:$PATH
# Install compiler and make
RUN dnf -y install gcc make

# Download and unzip interpreter
RUN <<EOF
wget https://www.python.org/ftp/python/3.9.25/Python-3.9.25.tgz
tar -xf Python-3.9.25.tgz
EOF

# Install necessary libraries to build the interpreter
# From: https://devguide.python.org/getting-started/setup-building/
RUN dnf install \
gcc-c++ gdb lzma glibc-devel libstdc++-devel openssl-devel \
readline-devel zlib-devel libzstd-devel libffi-devel bzip2-devel \
xz-devel sqlite sqlite-devel sqlite-libs libuuid-devel gdbm-libs \
perf expat expat-devel mpdecimal python3-pip \
perl perl-File-Compare

# Build OpenSSL 1.1.1w
RUN <<EOF
wget https://github.com/openssl/openssl/releases/download/OpenSSL_1_1_1w/openssl-1.1.1w.tar.gz
tar xzf openssl-1.1.1w.tar.gz
cd openssl-1.1.1w
./config --prefix=/opt/openssl --openssldir=/opt/openssl shared zlib
make -j"$(nproc)"
make install_sw
EOF

ENV LD_LIBRARY_PATH=/opt/openssl/lib
ENV CPPFLAGS=-I/opt/openssl/include
ENV LDFLAGS=-L/opt/openssl/lib

# Build interpreter and create venv
RUN <<EOF
cd Python-3.9.25
./configure --with-pydebug
make -s -j $(nproc)
make install
./python -m ensurepip
./python -m venv /deps
EOF

# Clone code to rebuild
RUN <<EOF
mkdir src
cd src
git clone https://github.com/tkem/cachetools .
git checkout --force ca7508fd56103a1b6d6f17c8e93e36c60b44ca25
EOF

WORKDIR /src

# Install build and the build backends
RUN <<EOF
/deps/bin/pip install "maturin>=1,<2"
/deps/bin/pip install build
EOF

# Run the build
RUN source /deps/bin/activate && /deps/bin/pip install wheel && python -m build --wheel -n

# Validate script
RUN cat <<'EOF' >/validate
[ -n "https://files.pythonhosted.org/packages/96/c5/1e741d26306c42e2bf6ab740b2202872727e0f606033c9dd713f8b93f5a8/cachetools-6.2.1-py3-none-any.whl" ] || { echo "No upstream artifact to validate against."; exit 1; }
# Capture artifacts generated
WHEELS=(/src/dist/*.whl)
# Ensure we only have one artifact
[ ${#WHEELS[@]} -eq 1 ] || { echo "Unexpected artifacts produced!"; exit 1; }
# BUILT_WHEEL is the artifact we built
BUILT_WHEEL=${WHEELS[0]}
# Ensure the artifact produced is not the literal returned by the glob
[ -e $BUILT_WHEEL ] || { echo "No wheels found!"; exit 1; }
# Download the wheel
wget -q https://files.pythonhosted.org/packages/96/c5/1e741d26306c42e2bf6ab740b2202872727e0f606033c9dd713f8b93f5a8/cachetools-6.2.1-py3-none-any.whl
# Compare wheel names
[ $(basename $BUILT_WHEEL) == "cachetools-6.2.1-py3-none-any.whl" ] || { echo "Wheel name does not match!"; exit 1; }
# Compare file tree
(unzip -Z1 $BUILT_WHEEL | grep -v '\.dist-info' | sort) > built.tree
(unzip -Z1 "cachetools-6.2.1-py3-none-any.whl" | grep -v '\.dist-info' | sort ) > pypi_artifact.tree
diff -u built.tree pypi_artifact.tree || { echo "File trees do not match!"; exit 1; }
echo "Success!"
EOF

ENTRYPOINT ["/bin/bash","/validate"]

'''
# ---
# name: test_successful_generation
'''

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,10 @@ def fixture_base_build_spec() -> BaseBuildSpecDict:
confidence_score=1.0,
)
],
"build_requires": {"setuptools": "==80.9.0", "wheel": ""},
"build_requires": [
{"name": "setuptools", "version": "==80.9.0", "installer": "pip"},
{"name": "wheel", "installer": "pip"},
],
"build_backends": ["setuptools.build_meta"],
"upstream_artifacts": {
"wheels": [
Expand All @@ -58,3 +61,17 @@ def test_successful_generation(
"""Ensure that dockerfile is correctly generated for pypi_build_spec."""
monkeypatch.setattr(pypi_dockerfile_output, "get_latest_cpython_patch", lambda _major, _minor: "3.9.25")
assert gen_dockerfile(pypi_build_spec) == snapshot


def test_maturin_binary_package_generation(snapshot: str, pypi_build_spec: BaseBuildSpecDict) -> None:
"""Ensure a Dockerfile is generated for a Maturin-backed binary package."""
pypi_build_spec["has_binaries"] = True
pypi_build_spec["build_backends"] = ["maturin"]
pypi_build_spec["build_requires"] = [
{"name": "maturin", "version": ">=1,<2", "installer": "pip"},
{"name": "rustup", "installer": "bootstrap"},
{"name": "rust", "installer": "rustup"},
{"name": "pyo3", "version": "==0.24.0", "installer": "cargo"},
]

assert gen_dockerfile(pypi_build_spec) == snapshot
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,17 @@
}
],
"has_binaries": false,
"build_requires": {
"setuptools": "==80.9.0",
"wheel": ""
},
"build_requires": [
{
"name": "setuptools",
"version": "==80.9.0",
"installer": "pip"
},
{
"name": "wheel",
"installer": "pip"
}
],
"build_backends": [
"setuptools.build_meta"
],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,10 +29,18 @@
}
],
"has_binaries": false,
"build_requires": {
"flit": "==3.12.0",
"flit_core": "<4,>=3.4"
},
"build_requires": [
{
"name": "flit",
"version": "==3.12.0",
"installer": "pip"
},
{
"name": "flit_core",
"version": "<4,>=3.4",
"installer": "pip"
}
],
"build_backends": [
"flit_core.buildapi"
],
Expand Down
Loading
Loading