Skip to content
Merged
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
4 changes: 2 additions & 2 deletions src/macaron/build_spec_generator/build_spec_generator.py
Original file line number Diff line number Diff line change
Expand Up @@ -98,8 +98,8 @@ def gen_build_spec_for_purl(
case BuildSpecFormat.DOCKERFILE:
try:
build_spec_content = gen_dockerfile(build_spec)
except ValueError as error:
logger.error("Error while serializing the build spec: %s.", error)
except GenerateBuildSpecError as error:
logger.error("Error while generating the build spec: %s.", error)
return os.EX_DATAERR
build_spec_file_path = os.path.join(build_spec_dir_path, "dockerfile.buildspec")

Expand Down
2 changes: 1 addition & 1 deletion src/macaron/build_spec_generator/common_spec/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -378,7 +378,7 @@ def gen_generic_build_spec(
"purl": str(purl),
"language": target_language,
"build_tools": build_tool_names,
"build_commands": [selected_build_command],
"build_commands": [selected_build_command] if selected_build_command else [],
}
)
ECOSYSTEMS[purl.type.upper()].value(base_build_spec_dict).resolve_fields(purl)
Expand Down
36 changes: 22 additions & 14 deletions src/macaron/build_spec_generator/common_spec/pypi_spec.py
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,16 @@ def resolve_fields(self, purl: PackageURL) -> None:
chronologically_likeliest_version = (
pypi_package_json.get_chronologically_suitable_setuptools_version()
)
try:
# Get information from the wheel file name.
logger.debug(pypi_package_json.wheel_filename)
_, _, _, tags = parse_wheel_filename(pypi_package_json.wheel_filename)
for tag in tags:
wheel_name_python_version_list.append(tag.interpreter)
wheel_name_platforms.add(tag.platform)
logger.debug(python_version_set)
except InvalidWheelFilename:
logger.debug("Could not parse wheel file name to extract version")
except SourceCodeError:
logger.debug("Could not find pure wheel matching this PURL")

Expand Down Expand Up @@ -214,17 +224,6 @@ def resolve_fields(self, purl: PackageURL) -> None:
except (InvalidRequirement, InvalidSpecifier) as error:
logger.debug("Malformed requirement encountered %s : %s", requirement, error)

try:
# Get information from the wheel file name.
logger.debug(pypi_package_json.wheel_filename)
_, _, _, tags = parse_wheel_filename(pypi_package_json.wheel_filename)
for tag in tags:
wheel_name_python_version_list.append(tag.interpreter)
wheel_name_platforms.add(tag.platform)
logger.debug(python_version_set)
except InvalidWheelFilename:
logger.debug("Could not parse wheel file name to extract version")

self.data["language_version"] = list(python_version_set) or wheel_name_python_version_list

# Use the default build command for pure Python packages.
Expand All @@ -243,9 +242,18 @@ def resolve_fields(self, purl: PackageURL) -> None:

if not patched_build_commands:
# Resolve and patch build commands.
selected_build_commands = self.data["build_commands"] or self.get_default_build_commands(
self.data["build_tools"]
)

# To ensure that selected_build_commands is never empty, we seed with the fallback
# command of python -m build --wheel -n
if self.data["build_commands"]:
selected_build_commands = self.data["build_commands"]
else:
self.data["build_commands"] = ["python -m build --wheel -n".split()]
selected_build_commands = (
self.get_default_build_commands(self.data["build_tools"]) or self.data["build_commands"]
)

logger.debug(selected_build_commands)

patched_build_commands = (
patch_commands(
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
# Copyright (c) 2025 - 2025, Oracle and/or its affiliates. All rights reserved.
# Copyright (c) 2025 - 2026, Oracle and/or its affiliates. All rights reserved.
# Licensed under the Universal Permissive License v 1.0 as shown at https://oss.oracle.com/licenses/upl/.

"""This module implements the logic to generate a dockerfile from a Python buildspec."""

import logging
import re
from textwrap import dedent

from packaging.specifiers import InvalidSpecifier, SpecifierSet
Expand Down Expand Up @@ -35,8 +36,7 @@ def gen_dockerfile(buildspec: BaseBuildSpecDict) -> str:
"""
language_version: str | None = pick_specific_version(buildspec)
if language_version is None:
logger.debug("Could not derive a specific interpreter version.")
raise GenerateBuildSpecError("Could not derive specific interpreter version.")
raise GenerateBuildSpecError("Could not derive specific interpreter version")
backend_install_commands: str = " && ".join(build_backend_commands(buildspec))
build_tool_install: str = ""
if (
Expand Down Expand Up @@ -124,8 +124,18 @@ def pick_specific_version(buildspec: BaseBuildSpecDict) -> str | None:
try:
version_set &= SpecifierSet(version)
except InvalidSpecifier as error:
logger.debug("Malformed interpreter version encountered: %s (%s)", version, error)
return None
logger.debug("Non-standard interpreter version encountered: %s (%s)", version, error)
# Whilst the Python tags specify interpreter implementation
# as well as version, with no standard way to parse out the
# implementation, we can attempt to heuristically:
try_parse_version = infer_interpreter_version(version)
if try_parse_version:
try:
version_set &= SpecifierSet(f">={try_parse_version}")
except InvalidSpecifier as error_for_retry:
logger.debug("Could not parse interpreter version from: %s (%s)", version, error_for_retry)

logger.debug(version_set)

# Now to get the latest acceptable one, we can step through all interpreter
# versions. For the most accurate result, we can query python.org for a
Expand All @@ -141,6 +151,50 @@ def pick_specific_version(buildspec: BaseBuildSpecDict) -> str | None:
return None


def infer_interpreter_version(specifier: str) -> str | None:
"""Infer interpreter version from Python-tag.

Note: This function is called on version specifiers
that we cannot trivially parse. In the case that
it is a Python-tag, which is obtained from the
wheel name, we attempt to infer the corresponding
interpreter version.

Parameters
----------
specifier: str
specifier string that could not be trivially parsed.

Returns
-------
str | None
The interpreter version inferred from the specifier, or
None if we cannot parse the specifier as a Python-tag.

Examples
--------
>>> infer_interpreter_version("py3")
'3'
>>> infer_interpreter_version("cp314")
'3.14'
>>> infer_interpreter_version("pypy311")
'3.11'
>>> infer_interpreter_version("malformed123")
"""
# The primary alternative interpreter implementations are documented here:
# https://www.python.org/download/alternatives/
# We parse tags for these implementations using below regular expression:
pattern = re.compile(r"^(py|cp|ip|pp|pypy|jy|graalpy)(\d{1,3})$")
parsed_tag = pattern.match(specifier)
if parsed_tag:
digits = parsed_tag.group(2)
# As match succeeded len(digits) \in {1,2,3}
if len(digits) == 1:
return parsed_tag.group(2)
return f"{digits[0]}.{digits[1:]}"
return None


def build_backend_commands(buildspec: BaseBuildSpecDict) -> list[str]:
"""Generate the installation commands for each inferred build backend.

Expand Down
Loading