diff --git a/src/macaron/build_spec_generator/build_spec_generator.py b/src/macaron/build_spec_generator/build_spec_generator.py index 9d7fd94ca..e66be4ac2 100644 --- a/src/macaron/build_spec_generator/build_spec_generator.py +++ b/src/macaron/build_spec_generator/build_spec_generator.py @@ -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") diff --git a/src/macaron/build_spec_generator/common_spec/core.py b/src/macaron/build_spec_generator/common_spec/core.py index 26b2f329f..4c2cf1ecd 100644 --- a/src/macaron/build_spec_generator/common_spec/core.py +++ b/src/macaron/build_spec_generator/common_spec/core.py @@ -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) diff --git a/src/macaron/build_spec_generator/common_spec/pypi_spec.py b/src/macaron/build_spec_generator/common_spec/pypi_spec.py index 999afbb19..d9bfd4b82 100644 --- a/src/macaron/build_spec_generator/common_spec/pypi_spec.py +++ b/src/macaron/build_spec_generator/common_spec/pypi_spec.py @@ -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") @@ -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. @@ -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( diff --git a/src/macaron/build_spec_generator/dockerfile/pypi_dockerfile_output.py b/src/macaron/build_spec_generator/dockerfile/pypi_dockerfile_output.py index ef2360a5c..457cfe15c 100644 --- a/src/macaron/build_spec_generator/dockerfile/pypi_dockerfile_output.py +++ b/src/macaron/build_spec_generator/dockerfile/pypi_dockerfile_output.py @@ -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 @@ -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 ( @@ -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 @@ -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.