diff --git a/samcli/commands/exceptions.py b/samcli/commands/exceptions.py index d8708c9272..5f835e0d33 100644 --- a/samcli/commands/exceptions.py +++ b/samcli/commands/exceptions.py @@ -152,3 +152,10 @@ class LinterRuleMatchedException(UserException): """ The linter matched a rule meaning that the template linting failed """ + + +class PopularRuntimeNotFoundException(Exception): + """ + Exception thrown when we were not able to parse the SUPPORTED_RUNTIMES + constant correctly for the latest runtime + """ diff --git a/samcli/commands/init/interactive_init_flow.py b/samcli/commands/init/interactive_init_flow.py index 57a1c1f64b..bebb62bb2b 100644 --- a/samcli/commands/init/interactive_init_flow.py +++ b/samcli/commands/init/interactive_init_flow.py @@ -4,6 +4,7 @@ import logging import pathlib +import re import tempfile from typing import Optional, Tuple @@ -11,7 +12,7 @@ from botocore.exceptions import ClientError, WaiterError from samcli.commands._utils.options import generate_next_command_recommendation -from samcli.commands.exceptions import InvalidInitOptionException, SchemasApiException +from samcli.commands.exceptions import InvalidInitOptionException, PopularRuntimeNotFoundException, SchemasApiException from samcli.commands.init.init_flow_helpers import ( _get_image_from_runtime, _get_runtime_from_image, @@ -28,6 +29,7 @@ ) from samcli.lib.config.samconfig import DEFAULT_CONFIG_FILE_NAME from samcli.lib.schemas.schemas_code_manager import do_download_source_code_binding, do_extract_and_merge_schemas_code +from samcli.lib.utils.architecture import SUPPORTED_RUNTIMES from samcli.lib.utils.osutils import remove from samcli.lib.utils.packagetype import IMAGE, ZIP from samcli.local.common.runtime_template import ( @@ -323,6 +325,60 @@ def _generate_from_use_case( ) +def _get_latest_python_runtime() -> str: + """ + Returns the latest support version of Python + SAM CLI supports + + Returns + ------- + str: + The name of the latest Python runtime (ex. "python3.12") + """ + latest_major = 0 + latest_minor = 0 + + compiled_regex = re.compile(r"python(.*?)\.(.*)") + + for runtime in SUPPORTED_RUNTIMES: + if not runtime.startswith("python"): + continue + + # python3.12 => 3.12 => (3, 12) + version_match = re.match(compiled_regex, runtime) + + if not version_match: + LOG.debug(f"Failed to match version while checking {runtime}") + continue + + matched_groups = version_match.groups() + + try: + version_major = int(matched_groups[0]) + version_minor = int(matched_groups[1]) + except (ValueError, IndexError): + LOG.debug(f"Failed to parse version while checking {runtime}") + continue + + if version_major > latest_major: + latest_major = version_major + latest_minor = version_minor + elif version_major == latest_major: + latest_minor = version_minor if version_minor > latest_minor else latest_minor + + if not latest_major: + # major version is still 0, assume that something went wrong + # this in theory should not happen as long as Python is + # listed in the SUPPORTED_RUNTIMES constant + raise PopularRuntimeNotFoundException("Was unable to search for the latest supported runtime") + + selected_version = f"python{latest_major}.{latest_minor}" + + LOG.debug(f"Using {selected_version} as the latest runtime version") + + return selected_version + + def _generate_default_hello_world_application( use_case: str, package_type: Optional[str], @@ -356,8 +412,10 @@ def _generate_default_hello_world_application( """ is_package_type_image = bool(package_type == IMAGE) if use_case == "Hello World Example" and not (runtime or base_image or is_package_type_image or dependency_manager): - if click.confirm("\nUse the most popular runtime and package type? (Python and zip)"): - runtime, package_type, dependency_manager, pt_explicit = "python3.9", ZIP, "pip", True + latest_python = _get_latest_python_runtime() + + if click.confirm(f"\nUse the most popular runtime and package type? ({latest_python} and zip)"): + runtime, package_type, dependency_manager, pt_explicit = _get_latest_python_runtime(), ZIP, "pip", True return (runtime, package_type, dependency_manager, pt_explicit) diff --git a/tests/unit/commands/init/test_cli.py b/tests/unit/commands/init/test_cli.py index 3825c30200..2c1dd94301 100644 --- a/tests/unit/commands/init/test_cli.py +++ b/tests/unit/commands/init/test_cli.py @@ -3,6 +3,8 @@ import shutil import subprocess import tempfile +from unittest import mock +from parameterized import parameterized import requests from pathlib import Path from typing import Dict, Any @@ -13,7 +15,7 @@ import click from click.testing import CliRunner -from samcli.commands.exceptions import UserException +from samcli.commands.exceptions import PopularRuntimeNotFoundException, UserException from samcli.commands.init import cli as init_cmd from samcli.commands.init.command import do_cli as init_cli from samcli.commands.init.command import PackageType @@ -25,7 +27,7 @@ get_template_value, template_does_not_meet_filter_criteria, ) -from samcli.commands.init.interactive_init_flow import get_sorted_runtimes +from samcli.commands.init.interactive_init_flow import _get_latest_python_runtime, get_sorted_runtimes from samcli.lib.init import GenerateProjectFailedError from samcli.lib.utils import osutils from samcli.lib.utils.git_repo import GitRepo @@ -2006,9 +2008,9 @@ def test_init_cli_generate_default_hello_world_app( request_mock.side_effect = requests.Timeout() init_options_from_manifest_mock.return_value = [ { - "directory": "python3.9/cookiecutter-aws-sam-hello-python", + "directory": "python3.12/cookiecutter-aws-sam-hello-python", "displayName": "Hello World Example", - "dependencyManager": "npm", + "dependencyManager": "pip", "appTemplate": "hello-world", "packageType": "Zip", "useCaseName": "Hello World Example", @@ -2026,10 +2028,10 @@ def test_init_cli_generate_default_hello_world_app( get_preprocessed_manifest_mock.return_value = { "Hello World Example": { - "python3.9": { + "python3.12": { "Zip": [ { - "directory": "python3.9/cookiecutter-aws-sam-hello-python3.9", + "directory": "python3.12/cookiecutter-aws-sam-hello-python3.12", "displayName": "Hello World Example", "dependencyManager": "pip", "appTemplate": "hello-world", @@ -2070,16 +2072,17 @@ def test_init_cli_generate_default_hello_world_app( runner = CliRunner() result = runner.invoke(init_cmd, input=user_input) + print(result.stdout) self.assertFalse(result.exception) generate_project_patch.assert_called_once_with( ANY, ZIP, - "python3.9", + "python3.12", "pip", ".", "test-project", True, - {"project_name": "test-project", "runtime": "python3.9", "architectures": {"value": ["x86_64"]}}, + {"project_name": "test-project", "runtime": "python3.12", "architectures": {"value": ["x86_64"]}}, False, False, False, @@ -3193,3 +3196,35 @@ def test_init_cli_generate_app_template_provide_via_application_insights_options True, False, ) + + @parameterized.expand( + [ + ({"python3.2": Any, "python3.100000": Any, "python3.14": Any}, "python3.100000"), + ({"python7.8": Any, "python9.1": Any}, "python9.1"), + ({"python6.1": Any, "python4.7": Any}, "python6.1"), + ] + ) + def test_latest_python_fetcher_correct_latest(self, versions, expected): + with mock.patch( + "samcli.commands.init.interactive_init_flow.SUPPORTED_RUNTIMES", + versions, + ): + result = _get_latest_python_runtime() + + self.assertEqual(result, expected) + + def test_latest_python_fetcher_has_valid_supported_runtimes(self): + """ + Mainly checks if the SUPPORTED_RUNTIMES constant actually has + Python runtime inside of it + """ + result = _get_latest_python_runtime() + self.assertTrue(result, "Python was not found in the SUPPORTED_RUNTIMES const") + + def test_latest_python_fetchers_raises_not_found(self): + with mock.patch( + "samcli.commands.init.interactive_init_flow.SUPPORTED_RUNTIMES", + {"invalid": Any}, + ): + with self.assertRaises(PopularRuntimeNotFoundException): + _get_latest_python_runtime()