Skip to content

Commit 18a806a

Browse files
authored
feat: add support for uv build tool (#1350)
This PR adds support for detecting uv as a Python build tool and improves Python build-tool reporting reliability. Signed-off-by: behnazh-w <[email protected]>
1 parent b3a61b9 commit 18a806a

13 files changed

Lines changed: 434 additions & 195 deletions

File tree

src/macaron/build_spec_generator/common_spec/core.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ class MacaronBuildToolName(str, Enum):
5858
GRADLE = "gradle"
5959
PIP = "pip"
6060
POETRY = "poetry"
61+
UV = "uv"
6162
FLIT = "flit"
6263
HATCH = "hatch"
6364
CONDA = "conda"

src/macaron/build_spec_generator/common_spec/pypi_spec.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,8 @@ def set_default_build_commands(
5656
build_cmd_spec["command"] = "python -m build --wheel -n".split()
5757
case "poetry":
5858
build_cmd_spec["command"] = "poetry build".split()
59+
case "uv":
60+
build_cmd_spec["command"] = "uv build".split()
5961

6062
case "flit":
6163
# We might also want to deal with existence flit.ini, we can do so via

src/macaron/config/defaults.ini

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -285,9 +285,9 @@ build_timeout = 600
285285
[builder.pip]
286286
entry_conf =
287287
build_configs =
288+
pyproject.toml
288289
setup.py
289290
setup.cfg
290-
pyproject.toml
291291
builder =
292292
build
293293
publisher =
@@ -338,6 +338,34 @@ deploy_arg =
338338
[builder.poetry.ci.deploy]
339339
github_actions = pypa/gh-action-pypi-publish
340340

341+
# This is the spec for the uv packaging tool.
342+
[builder.uv]
343+
entry_conf =
344+
build_configs = pyproject.toml
345+
package_lock = uv.lock
346+
builder =
347+
uv
348+
# build-system information.
349+
build_requires =
350+
uv_build
351+
pdm-backend
352+
build_backend =
353+
uv_build
354+
pdm.backend
355+
# These are the Python interpreters that may be used to load modules.
356+
interpreter =
357+
python
358+
python3
359+
interpreter_flag =
360+
-m
361+
build_arg =
362+
build
363+
deploy_arg =
364+
publish
365+
366+
[builder.uv.ci.deploy]
367+
github_actions = pypa/gh-action-pypi-publish
368+
341369
# This is the spec for Flit packaging tool.
342370
[builder.flit]
343371
entry_conf =

src/macaron/slsa_analyzer/build_tool/__init__.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
1-
# Copyright (c) 2022 - 2025, Oracle and/or its affiliates. All rights reserved.
1+
# Copyright (c) 2022 - 2026, Oracle and/or its affiliates. All rights reserved.
22
# Licensed under the Universal Permissive License v 1.0 as shown at https://oss.oracle.com/licenses/upl/.
33

44
"""The build_tool package contains the supported build tools for Macaron."""
55

66
from macaron.slsa_analyzer.build_tool.conda import Conda
77
from macaron.slsa_analyzer.build_tool.flit import Flit
88
from macaron.slsa_analyzer.build_tool.hatch import Hatch
9+
from macaron.slsa_analyzer.build_tool.uv import Uv
910

1011
from .base_build_tool import BaseBuildTool
1112
from .docker import Docker
@@ -23,6 +24,7 @@
2324
Gradle(),
2425
Maven(),
2526
Poetry(),
27+
Uv(),
2628
Flit(),
2729
Hatch(),
2830
Conda(),

src/macaron/slsa_analyzer/build_tool/pip.py

Lines changed: 22 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -70,21 +70,31 @@ def is_detected(self, target: Component) -> list[BuildToolConfig]:
7070
results: list[BuildToolConfig] = (
7171
[]
7272
)
73+
7374
confidence_score = 1.0
75+
76+
pyproject_path = file_exists(repo_path, "pyproject.toml", filters=self.path_filters)
77+
if pyproject_path:
78+
pyproject_path_relative = pyproject_path.relative_to(repo_path)
79+
# Check the build-system section. If it doesn't exist, by default setuptools should be used.
80+
if pyproject.get_build_system(pyproject_path) is None:
81+
results.append((str(pyproject_path_relative), confidence_score, None, None))
82+
else:
83+
for tool in self.build_requires + self.build_backend:
84+
if pyproject.build_system_contains_tool(tool, pyproject_path):
85+
results.append((str(pyproject_path_relative), confidence_score, None, None))
86+
break
87+
if not results:
88+
# If we still have not found an evidence, we add pip as build tool anyway but with a lower confidence score.
89+
results.append((str(pyproject_path_relative), confidence_score / 2, None, None))
90+
7491
for config_name in self.build_configs:
92+
if config_name == "pyproject.toml":
93+
continue
7594
if config_path := file_exists(repo_path, config_name, filters=self.path_filters):
76-
if os.path.basename(config_path) == "pyproject.toml":
77-
# Check the build-system section. If it doesn't exist, by default setuptools should be used.
78-
if pyproject.get_build_system(config_path) is None:
79-
results.append((str(config_path.relative_to(repo_path)), confidence_score, None, None))
80-
for tool in self.build_requires + self.build_backend:
81-
if pyproject.build_system_contains_tool(tool, config_path):
82-
results.append((str(config_path.relative_to(repo_path)), confidence_score, None, None))
83-
break
84-
else:
85-
# TODO: For other build configuration files, like setup.py, we need to improve the logic.
86-
results.append((str(config_path.relative_to(repo_path)), confidence_score, None, None))
87-
confidence_score = confidence_score / 2
95+
config_path_relative = config_path.relative_to(repo_path)
96+
confidence = confidence_score / 2 if results else confidence_score
97+
results.append((str(config_path_relative), confidence, None, None))
8898
return results
8999

90100
def get_dep_analyzer(self) -> DependencyAnalyzer:
Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
# Copyright (c) 2026 - 2026, 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+
"""This module contains the Uv class which inherits BaseBuildTool.
5+
6+
This module is used to work with repositories that use uv for dependency management.
7+
"""
8+
9+
import os
10+
11+
from cyclonedx_py import __version__ as cyclonedx_version
12+
13+
from macaron.config.defaults import defaults
14+
from macaron.config.global_config import global_config
15+
from macaron.database.table_definitions import Component
16+
from macaron.dependency_analyzer.cyclonedx import DependencyAnalyzer
17+
from macaron.dependency_analyzer.cyclonedx_python import CycloneDxPython
18+
from macaron.slsa_analyzer.build_tool import pyproject
19+
from macaron.slsa_analyzer.build_tool.base_build_tool import (
20+
BaseBuildTool,
21+
BuildToolCommand,
22+
BuildToolConfig,
23+
file_exists,
24+
)
25+
from macaron.slsa_analyzer.build_tool.language import BuildLanguage
26+
from macaron.slsa_analyzer.checks.check_result import Confidence
27+
28+
29+
class Uv(BaseBuildTool):
30+
"""This class contains the information of the uv build tool."""
31+
32+
def __init__(self) -> None:
33+
"""Initialize instance."""
34+
super().__init__(name="uv", language=BuildLanguage.PYTHON, purl_type="pypi")
35+
36+
def load_defaults(self) -> None:
37+
"""Load the default values from defaults.ini."""
38+
super().load_defaults()
39+
if "builder.uv" in defaults:
40+
for item in defaults["builder.uv"]:
41+
if hasattr(self, item):
42+
setattr(self, item, defaults.get_list("builder.uv", item))
43+
44+
if "builder.uv.ci.deploy" in defaults:
45+
for item in defaults["builder.uv.ci.deploy"]:
46+
if item in self.ci_deploy_kws:
47+
self.ci_deploy_kws[item] = defaults.get_list("builder.uv.ci.deploy", item)
48+
49+
def is_detected(self, target: Component) -> list[BuildToolConfig]:
50+
"""
51+
Return the list of build tools and their information used in the target repo.
52+
53+
Parameters
54+
----------
55+
target : Component
56+
The target software component.
57+
58+
Returns
59+
-------
60+
list[BuildToolConfig]
61+
See ``BuildToolConfig`` in ``base_build_tool.py`` for field definitions.
62+
"""
63+
repo_path, _, _ = self.resolve_component_detection_target(target)
64+
if not repo_path:
65+
return []
66+
67+
package_lock_exists = ""
68+
for file in self.package_lock:
69+
if file_exists(repo_path, file, filters=self.path_filters):
70+
package_lock_exists = file
71+
break
72+
73+
results: list[BuildToolConfig] = []
74+
confidence_score = 1.0
75+
file_paths = (file_exists(repo_path, file, filters=self.path_filters) for file in self.build_configs)
76+
for config_path in file_paths:
77+
if config_path and os.path.basename(config_path) == "pyproject.toml":
78+
if package_lock_exists:
79+
results.append((str(config_path.relative_to(repo_path)), confidence_score, None, None))
80+
elif pyproject.contains_build_tool("uv", config_path):
81+
results.append((str(config_path.relative_to(repo_path)), confidence_score, None, None))
82+
else:
83+
for tool in self.build_requires + self.build_backend:
84+
if pyproject.build_system_contains_tool(tool, config_path):
85+
results.append((str(config_path.relative_to(repo_path)), confidence_score, None, None))
86+
break
87+
88+
confidence_score = confidence_score / 2
89+
90+
return results
91+
92+
def get_dep_analyzer(self) -> DependencyAnalyzer:
93+
"""Create a DependencyAnalyzer for the build tool.
94+
95+
Returns
96+
-------
97+
DependencyAnalyzer
98+
The DependencyAnalyzer object.
99+
"""
100+
return CycloneDxPython(
101+
resources_path=global_config.resources_path,
102+
file_name="python_sbom.json",
103+
tool_name="cyclonedx_py",
104+
tool_version=cyclonedx_version,
105+
)
106+
107+
def is_deploy_command(
108+
self, cmd: BuildToolCommand, excluded_configs: list[str] | None = None, provenance_workflow: str | None = None
109+
) -> tuple[bool, Confidence]:
110+
"""
111+
Determine if the command is a deploy command.
112+
113+
Parameters
114+
----------
115+
cmd: BuildToolCommand
116+
The build tool command object.
117+
excluded_configs: list[str] | None
118+
Build tool commands that are called from these configuration files are excluded.
119+
provenance_workflow: str | None
120+
The relative path to the root CI file that is captured in a provenance or None if provenance is not found.
121+
122+
Returns
123+
-------
124+
tuple[bool, Confidence]
125+
Return True along with the inferred confidence level if the command is a deploy tool command.
126+
"""
127+
if cmd["language"] is not self.language:
128+
return False, Confidence.HIGH
129+
130+
build_cmd = cmd["command"]
131+
cmd_program_name = os.path.basename(build_cmd[0])
132+
133+
deploy_tools = self.publisher if self.publisher else self.builder
134+
deploy_args = self.deploy_arg
135+
136+
if cmd_program_name in self.interpreter and len(build_cmd) > 2 and build_cmd[1] in self.interpreter_flag:
137+
build_cmd = build_cmd[2:]
138+
139+
if not self.match_cmd_args(cmd=build_cmd, tools=deploy_tools, args=deploy_args):
140+
return False, Confidence.HIGH
141+
142+
if excluded_configs and os.path.basename(cmd["ci_path"]) in excluded_configs:
143+
return False, Confidence.HIGH
144+
145+
return True, self.infer_confidence_deploy_command(cmd, provenance_workflow)
146+
147+
def is_package_command(
148+
self, cmd: BuildToolCommand, excluded_configs: list[str] | None = None
149+
) -> tuple[bool, Confidence]:
150+
"""
151+
Determine if the command is a packaging command.
152+
153+
Parameters
154+
----------
155+
cmd: BuildToolCommand
156+
The build tool command object.
157+
excluded_configs: list[str] | None
158+
Build tool commands that are called from these configuration files are excluded.
159+
160+
Returns
161+
-------
162+
tuple[bool, Confidence]
163+
Return True along with the inferred confidence level if the command is a build tool command.
164+
"""
165+
if cmd["language"] is not self.language:
166+
return False, Confidence.HIGH
167+
168+
build_cmd = cmd["command"]
169+
cmd_program_name = os.path.basename(build_cmd[0])
170+
if not cmd_program_name:
171+
return False, Confidence.HIGH
172+
173+
builder = self.packager if self.packager else self.builder
174+
build_args = self.build_arg
175+
176+
if cmd_program_name in self.interpreter and len(build_cmd) > 2 and build_cmd[1] in self.interpreter_flag:
177+
build_cmd = build_cmd[2:]
178+
179+
if not self.match_cmd_args(cmd=build_cmd, tools=builder, args=build_args):
180+
return False, Confidence.HIGH
181+
182+
if excluded_configs and os.path.basename(cmd["ci_path"]) in excluded_configs:
183+
return False, Confidence.HIGH
184+
185+
return True, Confidence.HIGH

tests/build_spec_generator/common_spec/test_core.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,18 @@ def test_compose_shell_commands(
6060
["pip"],
6161
id="python_pip_supported",
6262
),
63+
pytest.param(
64+
[
65+
BuildToolFacts(
66+
language="python",
67+
build_tool_name="uv",
68+
confidence=1.0,
69+
)
70+
],
71+
"python",
72+
["uv"],
73+
id="python_uv_supported",
74+
),
6375
pytest.param(
6476
[
6577
BuildToolFacts(
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
# Copyright (c) 2026 - 2026, 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+
"""Tests for PyPI build spec defaults."""
5+
6+
import pytest
7+
8+
from macaron.build_spec_generator.common_spec.base_spec import BaseBuildSpecDict, SpecBuildCommandDict
9+
from macaron.build_spec_generator.common_spec.pypi_spec import PyPIBuildSpec
10+
11+
12+
@pytest.mark.parametrize(
13+
("build_tool", "expected_command"),
14+
[
15+
("poetry", ["poetry", "build"]),
16+
("flit", ["flit", "build"]),
17+
("uv", ["uv", "build"]),
18+
],
19+
)
20+
def test_set_default_build_commands_for_pypi_tools(build_tool: str, expected_command: list[str]) -> None:
21+
"""Ensure known PyPI build tools map to expected default build commands."""
22+
spec = PyPIBuildSpec(
23+
BaseBuildSpecDict(
24+
{
25+
"ecosystem": "pypi",
26+
"purl": "pkg:pypi/[email protected]",
27+
"language": "python",
28+
"build_tools": [build_tool],
29+
"macaron_version": "test",
30+
"artifact_id": "example",
31+
"version": "1.0.0",
32+
"language_version": [],
33+
"build_commands": [],
34+
}
35+
)
36+
)
37+
build_cmd_spec = SpecBuildCommandDict(
38+
build_tool=build_tool,
39+
command=[],
40+
build_config_path="pyproject.toml",
41+
confidence_score=1.0,
42+
)
43+
44+
spec.set_default_build_commands(build_cmd_spec)
45+
assert build_cmd_spec["command"] == expected_command

0 commit comments

Comments
 (0)