From c2d25c34af28dca745de64f6f67256eb3dbddaf1 Mon Sep 17 00:00:00 2001 From: xujiantop-crypto <265865031+xujiantop-crypto@users.noreply.github.com> Date: Wed, 16 Sep 2026 17:40:38 +0800 Subject: [PATCH 1/6] fix(python-uv): pass compile bytecode option to uv --- aws_lambda_builders/workflows/python_uv/utils.py | 3 +++ tests/unit/workflows/python_uv/test_utils.py | 7 ++++++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/aws_lambda_builders/workflows/python_uv/utils.py b/aws_lambda_builders/workflows/python_uv/utils.py index cb7bfeb5b..21783bd5b 100644 --- a/aws_lambda_builders/workflows/python_uv/utils.py +++ b/aws_lambda_builders/workflows/python_uv/utils.py @@ -140,6 +140,9 @@ def to_uv_args(self) -> List[str]: if self.resolution != "highest": args.extend(["--resolution", self.resolution]) + if self.compile_bytecode: + args.append("--compile-bytecode") + if self.exclude_newer: args.extend(["--exclude-newer", self.exclude_newer]) diff --git a/tests/unit/workflows/python_uv/test_utils.py b/tests/unit/workflows/python_uv/test_utils.py index f15e3e550..6cb075b8b 100644 --- a/tests/unit/workflows/python_uv/test_utils.py +++ b/tests/unit/workflows/python_uv/test_utils.py @@ -106,7 +106,12 @@ class TestUvConfig(TestCase): def test_uv_config_defaults(self): config = UvConfig() args = config.to_uv_args() - self.assertEqual(args, []) + self.assertEqual(args, ["--compile-bytecode"]) + + def test_uv_config_can_disable_bytecode_compilation(self): + config = UvConfig(compile_bytecode=False) + args = config.to_uv_args() + self.assertNotIn("--compile-bytecode", args) def test_uv_config_with_index_url(self): config = UvConfig(index_url="https://pypi.org/simple/") From 4300971137f7f430c65cf600b4477449cf3131eb Mon Sep 17 00:00:00 2001 From: xujiantop-crypto <265865031+xujiantop-crypto@users.noreply.github.com> Date: Wed, 16 Sep 2026 23:36:01 +0800 Subject: [PATCH 2/6] fix(python-uv): guard bytecode compilation by runtime --- .../workflows/python_uv/DESIGN.md | 2 +- .../workflows/python_uv/packager.py | 2 +- .../workflows/python_uv/utils.py | 16 +++++++--- .../unit/workflows/python_uv/test_packager.py | 30 +++++++++++++++++++ tests/unit/workflows/python_uv/test_utils.py | 19 ++++++++++-- 5 files changed, 61 insertions(+), 8 deletions(-) diff --git a/aws_lambda_builders/workflows/python_uv/DESIGN.md b/aws_lambda_builders/workflows/python_uv/DESIGN.md index 61a27c092..f3df9e0fe 100644 --- a/aws_lambda_builders/workflows/python_uv/DESIGN.md +++ b/aws_lambda_builders/workflows/python_uv/DESIGN.md @@ -205,7 +205,7 @@ config = { "no_cache": False, # Disable caching "prerelease": "disallow", # Handle pre-release versions "resolution": "highest", # Resolution strategy - "compile_bytecode": True, # Compile .pyc files + "compile_bytecode": False, # Compile .pyc files when explicitly enabled and safe "exclude_newer": None, # Exclude packages newer than date "generate_hashes": False, # Generate package hashes } diff --git a/aws_lambda_builders/workflows/python_uv/packager.py b/aws_lambda_builders/workflows/python_uv/packager.py index b6c88a9d7..f666ad0a0 100644 --- a/aws_lambda_builders/workflows/python_uv/packager.py +++ b/aws_lambda_builders/workflows/python_uv/packager.py @@ -136,7 +136,7 @@ def install_requirements( args.extend(["--target", os.path.abspath(target_dir)]) # Add configuration arguments - args.extend(config.to_uv_args()) + args.extend(config.to_uv_args(python_version=python_version)) # Add platform-specific arguments if python_version: diff --git a/aws_lambda_builders/workflows/python_uv/utils.py b/aws_lambda_builders/workflows/python_uv/utils.py index 21783bd5b..c6eae1ff2 100644 --- a/aws_lambda_builders/workflows/python_uv/utils.py +++ b/aws_lambda_builders/workflows/python_uv/utils.py @@ -5,6 +5,7 @@ import os import shutil import subprocess +import sys from typing import List, Optional from aws_lambda_builders.workflows.python_pip.utils import OSUtils as BaseOSUtils @@ -104,7 +105,7 @@ def __init__( no_cache: bool = False, prerelease: str = "disallow", resolution: str = "highest", - compile_bytecode: bool = True, + compile_bytecode: bool = False, exclude_newer: Optional[str] = None, generate_hashes: bool = False, ): @@ -118,7 +119,7 @@ def __init__( self.exclude_newer = exclude_newer self.generate_hashes = generate_hashes - def to_uv_args(self) -> List[str]: + def to_uv_args(self, python_version: Optional[str] = None) -> List[str]: """Convert configuration to UV command line arguments.""" args = [] @@ -140,8 +141,15 @@ def to_uv_args(self) -> List[str]: if self.resolution != "highest": args.extend(["--resolution", self.resolution]) - if self.compile_bytecode: - args.append("--compile-bytecode") + compile_bytecode = self.compile_bytecode + if python_version: + # UV compiles with the build host's interpreter. Bytecode from a different Python minor + # version cannot be loaded by the target Lambda runtime. + host_python_version = f"{sys.version_info.major}.{sys.version_info.minor}" + compile_bytecode = compile_bytecode and python_version == host_python_version + + # Always pass an explicit value so UV_COMPILE_BYTECODE cannot override this configuration. + args.append("--compile-bytecode" if compile_bytecode else "--no-compile-bytecode") if self.exclude_newer: args.extend(["--exclude-newer", self.exclude_newer]) diff --git a/tests/unit/workflows/python_uv/test_packager.py b/tests/unit/workflows/python_uv/test_packager.py index 1c199ae24..4745ae641 100644 --- a/tests/unit/workflows/python_uv/test_packager.py +++ b/tests/unit/workflows/python_uv/test_packager.py @@ -1,4 +1,5 @@ import os +import sys from unittest import TestCase from unittest.mock import Mock, patch @@ -124,6 +125,35 @@ def test_install_requirements_success(self): self.assertIn("-r", args_called) self.assertIn("/path/to/requirements.txt", args_called) + def test_install_requirements_compiles_bytecode_for_matching_python(self): + self.mock_subprocess_uv.run_uv_command.return_value = (0, "success", "") + host_python_version = f"{sys.version_info.major}.{sys.version_info.minor}" + + self.uv_runner.install_requirements( + requirements_path="/path/to/requirements.txt", + target_dir="/target", + config=UvConfig(compile_bytecode=True), + python_version=host_python_version, + ) + + args_called = self.mock_subprocess_uv.run_uv_command.call_args[0][0] + self.assertIn("--compile-bytecode", args_called) + self.assertNotIn("--no-compile-bytecode", args_called) + + def test_install_requirements_disables_bytecode_for_mismatched_python(self): + self.mock_subprocess_uv.run_uv_command.return_value = (0, "success", "") + + self.uv_runner.install_requirements( + requirements_path="/path/to/requirements.txt", + target_dir="/target", + config=UvConfig(compile_bytecode=True), + python_version="0.0", + ) + + args_called = self.mock_subprocess_uv.run_uv_command.call_args[0][0] + self.assertIn("--no-compile-bytecode", args_called) + self.assertNotIn("--compile-bytecode", args_called) + def test_install_requirements_resolves_relative_target_to_absolute(self): # UV runs with cwd=project_dir, so a relative --target must be resolved to an absolute path # first, otherwise dependencies land under the source dir instead of the build root. diff --git a/tests/unit/workflows/python_uv/test_utils.py b/tests/unit/workflows/python_uv/test_utils.py index 6cb075b8b..657ca1a39 100644 --- a/tests/unit/workflows/python_uv/test_utils.py +++ b/tests/unit/workflows/python_uv/test_utils.py @@ -1,4 +1,5 @@ import os +import sys import tempfile from unittest import TestCase from unittest.mock import Mock @@ -106,11 +107,25 @@ class TestUvConfig(TestCase): def test_uv_config_defaults(self): config = UvConfig() args = config.to_uv_args() - self.assertEqual(args, ["--compile-bytecode"]) + self.assertEqual(args, ["--no-compile-bytecode"]) + + def test_uv_config_can_enable_bytecode_compilation_for_matching_python(self): + host_python_version = f"{sys.version_info.major}.{sys.version_info.minor}" + config = UvConfig(compile_bytecode=True) + args = config.to_uv_args(python_version=host_python_version) + self.assertIn("--compile-bytecode", args) + self.assertNotIn("--no-compile-bytecode", args) + + def test_uv_config_disables_bytecode_compilation_for_mismatched_python(self): + config = UvConfig(compile_bytecode=True) + args = config.to_uv_args(python_version="0.0") + self.assertIn("--no-compile-bytecode", args) + self.assertNotIn("--compile-bytecode", args) - def test_uv_config_can_disable_bytecode_compilation(self): + def test_uv_config_explicitly_disables_bytecode_compilation(self): config = UvConfig(compile_bytecode=False) args = config.to_uv_args() + self.assertIn("--no-compile-bytecode", args) self.assertNotIn("--compile-bytecode", args) def test_uv_config_with_index_url(self): From 6848bc2a3110811120400e71d81b9b7267731ae9 Mon Sep 17 00:00:00 2001 From: xujiantop-crypto <265865031+xujiantop-crypto@users.noreply.github.com> Date: Wed, 16 Sep 2026 23:57:32 +0800 Subject: [PATCH 3/6] fix(python-uv): select target interpreter for bytecode --- .../workflows/python_uv/DESIGN.md | 2 +- .../workflows/python_uv/packager.py | 7 ++++++- aws_lambda_builders/workflows/python_uv/utils.py | 12 ++---------- tests/unit/workflows/python_uv/test_packager.py | 16 +++++++++------- tests/unit/workflows/python_uv/test_utils.py | 12 ++---------- 5 files changed, 20 insertions(+), 29 deletions(-) diff --git a/aws_lambda_builders/workflows/python_uv/DESIGN.md b/aws_lambda_builders/workflows/python_uv/DESIGN.md index f3df9e0fe..14a0aacb4 100644 --- a/aws_lambda_builders/workflows/python_uv/DESIGN.md +++ b/aws_lambda_builders/workflows/python_uv/DESIGN.md @@ -205,7 +205,7 @@ config = { "no_cache": False, # Disable caching "prerelease": "disallow", # Handle pre-release versions "resolution": "highest", # Resolution strategy - "compile_bytecode": False, # Compile .pyc files when explicitly enabled and safe + "compile_bytecode": False, # Compile .pyc files with the target Python when enabled "exclude_newer": None, # Exclude packages newer than date "generate_hashes": False, # Generate package hashes } diff --git a/aws_lambda_builders/workflows/python_uv/packager.py b/aws_lambda_builders/workflows/python_uv/packager.py index f666ad0a0..dc5e13299 100644 --- a/aws_lambda_builders/workflows/python_uv/packager.py +++ b/aws_lambda_builders/workflows/python_uv/packager.py @@ -136,12 +136,17 @@ def install_requirements( args.extend(["--target", os.path.abspath(target_dir)]) # Add configuration arguments - args.extend(config.to_uv_args(python_version=python_version)) + args.extend(config.to_uv_args()) # Add platform-specific arguments if python_version: args.extend(["--python-version", python_version]) + # UV performs bytecode compilation with its selected interpreter. Pin that interpreter + # to the target runtime instead of relying on UV_PYTHON, VIRTUAL_ENV, or PATH discovery. + if config.compile_bytecode: + args.extend(["--python", python_version]) + if platform and architecture: # UV pip install uses --python-platform format # Map Lambda architectures to UV platform strings diff --git a/aws_lambda_builders/workflows/python_uv/utils.py b/aws_lambda_builders/workflows/python_uv/utils.py index c6eae1ff2..44f7ce75e 100644 --- a/aws_lambda_builders/workflows/python_uv/utils.py +++ b/aws_lambda_builders/workflows/python_uv/utils.py @@ -5,7 +5,6 @@ import os import shutil import subprocess -import sys from typing import List, Optional from aws_lambda_builders.workflows.python_pip.utils import OSUtils as BaseOSUtils @@ -119,7 +118,7 @@ def __init__( self.exclude_newer = exclude_newer self.generate_hashes = generate_hashes - def to_uv_args(self, python_version: Optional[str] = None) -> List[str]: + def to_uv_args(self) -> List[str]: """Convert configuration to UV command line arguments.""" args = [] @@ -141,15 +140,8 @@ def to_uv_args(self, python_version: Optional[str] = None) -> List[str]: if self.resolution != "highest": args.extend(["--resolution", self.resolution]) - compile_bytecode = self.compile_bytecode - if python_version: - # UV compiles with the build host's interpreter. Bytecode from a different Python minor - # version cannot be loaded by the target Lambda runtime. - host_python_version = f"{sys.version_info.major}.{sys.version_info.minor}" - compile_bytecode = compile_bytecode and python_version == host_python_version - # Always pass an explicit value so UV_COMPILE_BYTECODE cannot override this configuration. - args.append("--compile-bytecode" if compile_bytecode else "--no-compile-bytecode") + args.append("--compile-bytecode" if self.compile_bytecode else "--no-compile-bytecode") if self.exclude_newer: args.extend(["--exclude-newer", self.exclude_newer]) diff --git a/tests/unit/workflows/python_uv/test_packager.py b/tests/unit/workflows/python_uv/test_packager.py index 4745ae641..83c6670a5 100644 --- a/tests/unit/workflows/python_uv/test_packager.py +++ b/tests/unit/workflows/python_uv/test_packager.py @@ -1,5 +1,4 @@ import os -import sys from unittest import TestCase from unittest.mock import Mock, patch @@ -125,34 +124,37 @@ def test_install_requirements_success(self): self.assertIn("-r", args_called) self.assertIn("/path/to/requirements.txt", args_called) - def test_install_requirements_compiles_bytecode_for_matching_python(self): + def test_install_requirements_compiles_bytecode_with_target_python(self): self.mock_subprocess_uv.run_uv_command.return_value = (0, "success", "") - host_python_version = f"{sys.version_info.major}.{sys.version_info.minor}" self.uv_runner.install_requirements( requirements_path="/path/to/requirements.txt", target_dir="/target", config=UvConfig(compile_bytecode=True), - python_version=host_python_version, + python_version="3.13", ) args_called = self.mock_subprocess_uv.run_uv_command.call_args[0][0] self.assertIn("--compile-bytecode", args_called) self.assertNotIn("--no-compile-bytecode", args_called) + self.assertEqual(args_called[args_called.index("--python") + 1], "3.13") + self.assertEqual(args_called[args_called.index("--python-version") + 1], "3.13") - def test_install_requirements_disables_bytecode_for_mismatched_python(self): + def test_install_requirements_does_not_select_python_when_bytecode_disabled(self): self.mock_subprocess_uv.run_uv_command.return_value = (0, "success", "") self.uv_runner.install_requirements( requirements_path="/path/to/requirements.txt", target_dir="/target", - config=UvConfig(compile_bytecode=True), - python_version="0.0", + config=UvConfig(compile_bytecode=False), + python_version="3.13", ) args_called = self.mock_subprocess_uv.run_uv_command.call_args[0][0] self.assertIn("--no-compile-bytecode", args_called) self.assertNotIn("--compile-bytecode", args_called) + self.assertNotIn("--python", args_called) + self.assertEqual(args_called[args_called.index("--python-version") + 1], "3.13") def test_install_requirements_resolves_relative_target_to_absolute(self): # UV runs with cwd=project_dir, so a relative --target must be resolved to an absolute path diff --git a/tests/unit/workflows/python_uv/test_utils.py b/tests/unit/workflows/python_uv/test_utils.py index 657ca1a39..13fa63ffb 100644 --- a/tests/unit/workflows/python_uv/test_utils.py +++ b/tests/unit/workflows/python_uv/test_utils.py @@ -1,5 +1,4 @@ import os -import sys import tempfile from unittest import TestCase from unittest.mock import Mock @@ -109,19 +108,12 @@ def test_uv_config_defaults(self): args = config.to_uv_args() self.assertEqual(args, ["--no-compile-bytecode"]) - def test_uv_config_can_enable_bytecode_compilation_for_matching_python(self): - host_python_version = f"{sys.version_info.major}.{sys.version_info.minor}" + def test_uv_config_can_enable_bytecode_compilation(self): config = UvConfig(compile_bytecode=True) - args = config.to_uv_args(python_version=host_python_version) + args = config.to_uv_args() self.assertIn("--compile-bytecode", args) self.assertNotIn("--no-compile-bytecode", args) - def test_uv_config_disables_bytecode_compilation_for_mismatched_python(self): - config = UvConfig(compile_bytecode=True) - args = config.to_uv_args(python_version="0.0") - self.assertIn("--no-compile-bytecode", args) - self.assertNotIn("--compile-bytecode", args) - def test_uv_config_explicitly_disables_bytecode_compilation(self): config = UvConfig(compile_bytecode=False) args = config.to_uv_args() From 514513d256d231b4eafdd2d393c4e2db81322773 Mon Sep 17 00:00:00 2001 From: xujiantop-crypto <265865031+xujiantop-crypto@users.noreply.github.com> Date: Thu, 17 Sep 2026 00:21:33 +0800 Subject: [PATCH 4/6] fix(python-uv): skip bytecode without target interpreter --- .../workflows/python_uv/packager.py | 34 ++++++++-- .../workflows/python_uv/utils.py | 6 +- .../unit/workflows/python_uv/test_packager.py | 68 ++++++++++++++++++- tests/unit/workflows/python_uv/test_utils.py | 6 ++ 4 files changed, 105 insertions(+), 9 deletions(-) diff --git a/aws_lambda_builders/workflows/python_uv/packager.py b/aws_lambda_builders/workflows/python_uv/packager.py index dc5e13299..b2771f943 100644 --- a/aws_lambda_builders/workflows/python_uv/packager.py +++ b/aws_lambda_builders/workflows/python_uv/packager.py @@ -54,6 +54,13 @@ def get_uv_version(self) -> Optional[str]: pass return None + def find_python(self, python_version: str) -> Optional[str]: + """Find an already-installed interpreter matching the requested Python version.""" + rc, stdout, _ = self.run_uv_command(["python", "find", "--no-project", "--no-python-downloads", python_version]) + if rc == 0 and stdout: + return stdout.strip() + return None + def run_uv_command(self, args: List[str], cwd: Optional[str] = None, env: Optional[Dict[str, str]] = None) -> tuple: """ Execute UV command with given arguments. @@ -135,17 +142,32 @@ def install_requirements( # under the source directory instead of the build root. args.extend(["--target", os.path.abspath(target_dir)]) - # Add configuration arguments - args.extend(config.to_uv_args()) + target_python = None + compile_bytecode = False + if config.compile_bytecode: + if python_version: + target_python = self._uv.find_python(python_version) + compile_bytecode = target_python is not None + if not compile_bytecode: + LOG.warning( + "Target Python %s is not installed; skipping bytecode compilation", + python_version, + ) + else: + LOG.warning("Target Python version is unavailable; skipping bytecode compilation") + + # Add configuration arguments. The effective bytecode setting is decided together with + # interpreter selection so compilation can never run without a matching target interpreter. + args.extend(config.to_uv_args(compile_bytecode=compile_bytecode)) # Add platform-specific arguments if python_version: args.extend(["--python-version", python_version]) - # UV performs bytecode compilation with its selected interpreter. Pin that interpreter - # to the target runtime instead of relying on UV_PYTHON, VIRTUAL_ENV, or PATH discovery. - if config.compile_bytecode: - args.extend(["--python", python_version]) + # Use the exact interpreter found by UV instead of repeating a version request that + # could resolve differently when the install command runs. + if target_python: + args.extend(["--python", target_python]) if platform and architecture: # UV pip install uses --python-platform format diff --git a/aws_lambda_builders/workflows/python_uv/utils.py b/aws_lambda_builders/workflows/python_uv/utils.py index 44f7ce75e..d99b0b789 100644 --- a/aws_lambda_builders/workflows/python_uv/utils.py +++ b/aws_lambda_builders/workflows/python_uv/utils.py @@ -118,7 +118,7 @@ def __init__( self.exclude_newer = exclude_newer self.generate_hashes = generate_hashes - def to_uv_args(self) -> List[str]: + def to_uv_args(self, compile_bytecode: Optional[bool] = None) -> List[str]: """Convert configuration to UV command line arguments.""" args = [] @@ -141,7 +141,9 @@ def to_uv_args(self) -> List[str]: args.extend(["--resolution", self.resolution]) # Always pass an explicit value so UV_COMPILE_BYTECODE cannot override this configuration. - args.append("--compile-bytecode" if self.compile_bytecode else "--no-compile-bytecode") + if compile_bytecode is None: + compile_bytecode = self.compile_bytecode + args.append("--compile-bytecode" if compile_bytecode else "--no-compile-bytecode") if self.exclude_newer: args.extend(["--exclude-newer", self.exclude_newer]) diff --git a/tests/unit/workflows/python_uv/test_packager.py b/tests/unit/workflows/python_uv/test_packager.py index 83c6670a5..ab0cfe59e 100644 --- a/tests/unit/workflows/python_uv/test_packager.py +++ b/tests/unit/workflows/python_uv/test_packager.py @@ -91,6 +91,33 @@ def test_get_uv_version_failure(self, mock_osutils_class): self.assertIsNone(version) + @patch("aws_lambda_builders.workflows.python_uv.packager.OSUtils") + def test_find_python_success(self, mock_osutils_class): + mock_osutils = Mock() + mock_osutils.which.return_value = "/usr/bin/uv" + mock_osutils.run_subprocess.return_value = (0, "/usr/bin/python3.13\n", "") + mock_osutils_class.return_value = mock_osutils + + subprocess_uv = SubprocessUv() + + self.assertEqual(subprocess_uv.find_python("3.13"), "/usr/bin/python3.13") + mock_osutils.run_subprocess.assert_called_once_with( + ["/usr/bin/uv", "python", "find", "--no-project", "--no-python-downloads", "3.13"], + cwd=None, + env=None, + ) + + @patch("aws_lambda_builders.workflows.python_uv.packager.OSUtils") + def test_find_python_failure(self, mock_osutils_class): + mock_osutils = Mock() + mock_osutils.which.return_value = "/usr/bin/uv" + mock_osutils.run_subprocess.return_value = (2, "", "interpreter not found") + mock_osutils_class.return_value = mock_osutils + + subprocess_uv = SubprocessUv() + + self.assertIsNone(subprocess_uv.find_python("3.9")) + class TestUvRunner(TestCase): def setUp(self): @@ -126,6 +153,7 @@ def test_install_requirements_success(self): def test_install_requirements_compiles_bytecode_with_target_python(self): self.mock_subprocess_uv.run_uv_command.return_value = (0, "success", "") + self.mock_subprocess_uv.find_python.return_value = "/usr/bin/python3.13" self.uv_runner.install_requirements( requirements_path="/path/to/requirements.txt", @@ -137,8 +165,45 @@ def test_install_requirements_compiles_bytecode_with_target_python(self): args_called = self.mock_subprocess_uv.run_uv_command.call_args[0][0] self.assertIn("--compile-bytecode", args_called) self.assertNotIn("--no-compile-bytecode", args_called) - self.assertEqual(args_called[args_called.index("--python") + 1], "3.13") + self.assertEqual(args_called[args_called.index("--python") + 1], "/usr/bin/python3.13") self.assertEqual(args_called[args_called.index("--python-version") + 1], "3.13") + self.mock_subprocess_uv.find_python.assert_called_once_with("3.13") + + def test_install_requirements_skips_bytecode_when_target_python_is_unavailable(self): + self.mock_subprocess_uv.run_uv_command.return_value = (0, "success", "") + self.mock_subprocess_uv.find_python.return_value = None + + with self.assertLogs("aws_lambda_builders.workflows.python_uv.packager", level="WARNING") as logs: + self.uv_runner.install_requirements( + requirements_path="/path/to/requirements.txt", + target_dir="/target", + config=UvConfig(compile_bytecode=True), + python_version="3.9", + ) + + args_called = self.mock_subprocess_uv.run_uv_command.call_args[0][0] + self.assertIn("--no-compile-bytecode", args_called) + self.assertNotIn("--compile-bytecode", args_called) + self.assertNotIn("--python", args_called) + self.assertEqual(args_called[args_called.index("--python-version") + 1], "3.9") + self.assertIn("Target Python 3.9 is not installed", logs.output[0]) + + def test_install_requirements_skips_bytecode_without_target_python_version(self): + self.mock_subprocess_uv.run_uv_command.return_value = (0, "success", "") + + with self.assertLogs("aws_lambda_builders.workflows.python_uv.packager", level="WARNING") as logs: + self.uv_runner.install_requirements( + requirements_path="/path/to/requirements.txt", + target_dir="/target", + config=UvConfig(compile_bytecode=True), + ) + + args_called = self.mock_subprocess_uv.run_uv_command.call_args[0][0] + self.assertIn("--no-compile-bytecode", args_called) + self.assertNotIn("--compile-bytecode", args_called) + self.assertNotIn("--python", args_called) + self.mock_subprocess_uv.find_python.assert_not_called() + self.assertIn("Target Python version is unavailable", logs.output[0]) def test_install_requirements_does_not_select_python_when_bytecode_disabled(self): self.mock_subprocess_uv.run_uv_command.return_value = (0, "success", "") @@ -155,6 +220,7 @@ def test_install_requirements_does_not_select_python_when_bytecode_disabled(self self.assertNotIn("--compile-bytecode", args_called) self.assertNotIn("--python", args_called) self.assertEqual(args_called[args_called.index("--python-version") + 1], "3.13") + self.mock_subprocess_uv.find_python.assert_not_called() def test_install_requirements_resolves_relative_target_to_absolute(self): # UV runs with cwd=project_dir, so a relative --target must be resolved to an absolute path diff --git a/tests/unit/workflows/python_uv/test_utils.py b/tests/unit/workflows/python_uv/test_utils.py index 13fa63ffb..dc467a38e 100644 --- a/tests/unit/workflows/python_uv/test_utils.py +++ b/tests/unit/workflows/python_uv/test_utils.py @@ -120,6 +120,12 @@ def test_uv_config_explicitly_disables_bytecode_compilation(self): self.assertIn("--no-compile-bytecode", args) self.assertNotIn("--compile-bytecode", args) + def test_uv_config_accepts_effective_bytecode_override(self): + config = UvConfig(compile_bytecode=True) + args = config.to_uv_args(compile_bytecode=False) + self.assertIn("--no-compile-bytecode", args) + self.assertNotIn("--compile-bytecode", args) + def test_uv_config_with_index_url(self): config = UvConfig(index_url="https://pypi.org/simple/") args = config.to_uv_args() From 0facd03f873c0e6f216b0e09915addd8025efac5 Mon Sep 17 00:00:00 2001 From: xujiantop-crypto <265865031+xujiantop-crypto@users.noreply.github.com> Date: Thu, 17 Sep 2026 00:39:03 +0800 Subject: [PATCH 5/6] fix(python-uv): centralize safe bytecode selection --- .../workflows/python_uv/packager.py | 22 +++++++++---------- .../workflows/python_uv/utils.py | 7 +----- .../unit/workflows/python_uv/test_packager.py | 21 ++++++++++-------- tests/unit/workflows/python_uv/test_utils.py | 20 +---------------- 4 files changed, 25 insertions(+), 45 deletions(-) diff --git a/aws_lambda_builders/workflows/python_uv/packager.py b/aws_lambda_builders/workflows/python_uv/packager.py index b2771f943..9d064094d 100644 --- a/aws_lambda_builders/workflows/python_uv/packager.py +++ b/aws_lambda_builders/workflows/python_uv/packager.py @@ -56,9 +56,13 @@ def get_uv_version(self) -> Optional[str]: def find_python(self, python_version: str) -> Optional[str]: """Find an already-installed interpreter matching the requested Python version.""" - rc, stdout, _ = self.run_uv_command(["python", "find", "--no-project", "--no-python-downloads", python_version]) + rc, stdout, stderr = self.run_uv_command( + ["python", "find", "--no-project", "--no-python-downloads", python_version] + ) if rc == 0 and stdout: return stdout.strip() + diagnostic = stderr.strip() or stdout.strip() or "no diagnostic output" + LOG.warning("Could not locate target Python %s via uv (exit code %d): %s", python_version, rc, diagnostic) return None def run_uv_command(self, args: List[str], cwd: Optional[str] = None, env: Optional[Dict[str, str]] = None) -> tuple: @@ -143,22 +147,14 @@ def install_requirements( args.extend(["--target", os.path.abspath(target_dir)]) target_python = None - compile_bytecode = False if config.compile_bytecode: if python_version: target_python = self._uv.find_python(python_version) - compile_bytecode = target_python is not None - if not compile_bytecode: - LOG.warning( - "Target Python %s is not installed; skipping bytecode compilation", - python_version, - ) else: LOG.warning("Target Python version is unavailable; skipping bytecode compilation") - # Add configuration arguments. The effective bytecode setting is decided together with - # interpreter selection so compilation can never run without a matching target interpreter. - args.extend(config.to_uv_args(compile_bytecode=compile_bytecode)) + # Add configuration arguments that are independent of interpreter selection. + args.extend(config.to_uv_args()) # Add platform-specific arguments if python_version: @@ -169,6 +165,10 @@ def install_requirements( if target_python: args.extend(["--python", target_python]) + # Keep the bytecode flag next to interpreter selection so compilation can never be enabled + # without an explicitly resolved interpreter matching the target runtime. + args.append("--compile-bytecode" if target_python else "--no-compile-bytecode") + if platform and architecture: # UV pip install uses --python-platform format # Map Lambda architectures to UV platform strings diff --git a/aws_lambda_builders/workflows/python_uv/utils.py b/aws_lambda_builders/workflows/python_uv/utils.py index d99b0b789..e8666aa6f 100644 --- a/aws_lambda_builders/workflows/python_uv/utils.py +++ b/aws_lambda_builders/workflows/python_uv/utils.py @@ -118,7 +118,7 @@ def __init__( self.exclude_newer = exclude_newer self.generate_hashes = generate_hashes - def to_uv_args(self, compile_bytecode: Optional[bool] = None) -> List[str]: + def to_uv_args(self) -> List[str]: """Convert configuration to UV command line arguments.""" args = [] @@ -140,11 +140,6 @@ def to_uv_args(self, compile_bytecode: Optional[bool] = None) -> List[str]: if self.resolution != "highest": args.extend(["--resolution", self.resolution]) - # Always pass an explicit value so UV_COMPILE_BYTECODE cannot override this configuration. - if compile_bytecode is None: - compile_bytecode = self.compile_bytecode - args.append("--compile-bytecode" if compile_bytecode else "--no-compile-bytecode") - if self.exclude_newer: args.extend(["--exclude-newer", self.exclude_newer]) diff --git a/tests/unit/workflows/python_uv/test_packager.py b/tests/unit/workflows/python_uv/test_packager.py index ab0cfe59e..ebe5a2998 100644 --- a/tests/unit/workflows/python_uv/test_packager.py +++ b/tests/unit/workflows/python_uv/test_packager.py @@ -116,7 +116,11 @@ def test_find_python_failure(self, mock_osutils_class): subprocess_uv = SubprocessUv() - self.assertIsNone(subprocess_uv.find_python("3.9")) + with self.assertLogs("aws_lambda_builders.workflows.python_uv.packager", level="WARNING") as logs: + self.assertIsNone(subprocess_uv.find_python("3.9")) + + self.assertIn("Could not locate target Python 3.9 via uv (exit code 2)", logs.output[0]) + self.assertIn("interpreter not found", logs.output[0]) class TestUvRunner(TestCase): @@ -173,20 +177,19 @@ def test_install_requirements_skips_bytecode_when_target_python_is_unavailable(s self.mock_subprocess_uv.run_uv_command.return_value = (0, "success", "") self.mock_subprocess_uv.find_python.return_value = None - with self.assertLogs("aws_lambda_builders.workflows.python_uv.packager", level="WARNING") as logs: - self.uv_runner.install_requirements( - requirements_path="/path/to/requirements.txt", - target_dir="/target", - config=UvConfig(compile_bytecode=True), - python_version="3.9", - ) + self.uv_runner.install_requirements( + requirements_path="/path/to/requirements.txt", + target_dir="/target", + config=UvConfig(compile_bytecode=True), + python_version="3.9", + ) args_called = self.mock_subprocess_uv.run_uv_command.call_args[0][0] self.assertIn("--no-compile-bytecode", args_called) self.assertNotIn("--compile-bytecode", args_called) self.assertNotIn("--python", args_called) self.assertEqual(args_called[args_called.index("--python-version") + 1], "3.9") - self.assertIn("Target Python 3.9 is not installed", logs.output[0]) + self.mock_subprocess_uv.find_python.assert_called_once_with("3.9") def test_install_requirements_skips_bytecode_without_target_python_version(self): self.mock_subprocess_uv.run_uv_command.return_value = (0, "success", "") diff --git a/tests/unit/workflows/python_uv/test_utils.py b/tests/unit/workflows/python_uv/test_utils.py index dc467a38e..f15e3e550 100644 --- a/tests/unit/workflows/python_uv/test_utils.py +++ b/tests/unit/workflows/python_uv/test_utils.py @@ -106,25 +106,7 @@ class TestUvConfig(TestCase): def test_uv_config_defaults(self): config = UvConfig() args = config.to_uv_args() - self.assertEqual(args, ["--no-compile-bytecode"]) - - def test_uv_config_can_enable_bytecode_compilation(self): - config = UvConfig(compile_bytecode=True) - args = config.to_uv_args() - self.assertIn("--compile-bytecode", args) - self.assertNotIn("--no-compile-bytecode", args) - - def test_uv_config_explicitly_disables_bytecode_compilation(self): - config = UvConfig(compile_bytecode=False) - args = config.to_uv_args() - self.assertIn("--no-compile-bytecode", args) - self.assertNotIn("--compile-bytecode", args) - - def test_uv_config_accepts_effective_bytecode_override(self): - config = UvConfig(compile_bytecode=True) - args = config.to_uv_args(compile_bytecode=False) - self.assertIn("--no-compile-bytecode", args) - self.assertNotIn("--compile-bytecode", args) + self.assertEqual(args, []) def test_uv_config_with_index_url(self): config = UvConfig(index_url="https://pypi.org/simple/") From 34b4881b3c13ef6810a3aa2fa4bb20ceb82c84cf Mon Sep 17 00:00:00 2001 From: xujiantop-crypto <265865031+xujiantop-crypto@users.noreply.github.com> Date: Thu, 17 Sep 2026 01:19:23 +0800 Subject: [PATCH 6/6] fix(python-uv): preserve compiled bytecode after packaging --- .../workflows/python_uv/DESIGN.md | 2 +- .../workflows/python_uv/packager.py | 40 ++++++++++++-- .../workflows/python_uv/utils.py | 13 ++++- .../unit/workflows/python_uv/test_packager.py | 55 +++++++++++++++++-- tests/unit/workflows/python_uv/test_utils.py | 22 +++++++- 5 files changed, 119 insertions(+), 13 deletions(-) diff --git a/aws_lambda_builders/workflows/python_uv/DESIGN.md b/aws_lambda_builders/workflows/python_uv/DESIGN.md index 14a0aacb4..ac1b0d293 100644 --- a/aws_lambda_builders/workflows/python_uv/DESIGN.md +++ b/aws_lambda_builders/workflows/python_uv/DESIGN.md @@ -205,7 +205,7 @@ config = { "no_cache": False, # Disable caching "prerelease": "disallow", # Handle pre-release versions "resolution": "highest", # Resolution strategy - "compile_bytecode": False, # Compile .pyc files with the target Python when enabled + "compile_bytecode": True, # Compile unchecked-hash .pyc with the target Python when enabled "exclude_newer": None, # Exclude packages newer than date "generate_hashes": False, # Generate package hashes } diff --git a/aws_lambda_builders/workflows/python_uv/packager.py b/aws_lambda_builders/workflows/python_uv/packager.py index 9d064094d..19285e9c0 100644 --- a/aws_lambda_builders/workflows/python_uv/packager.py +++ b/aws_lambda_builders/workflows/python_uv/packager.py @@ -61,7 +61,7 @@ def find_python(self, python_version: str) -> Optional[str]: ) if rc == 0 and stdout: return stdout.strip() - diagnostic = stderr.strip() or stdout.strip() or "no diagnostic output" + diagnostic = (stderr or "").strip() or (stdout or "").strip() or "no diagnostic output" LOG.warning("Could not locate target Python %s via uv (exit code %d): %s", python_version, rc, diagnostic) return None @@ -144,7 +144,8 @@ def install_requirements( # Resolve --target to an absolute path: UV runs with cwd set to the project directory, so a # relative target (e.g. the incremental-build dependencies dir) would otherwise be created # under the source directory instead of the build root. - args.extend(["--target", os.path.abspath(target_dir)]) + target_dir = os.path.abspath(target_dir) + args.extend(["--target", target_dir]) target_python = None if config.compile_bytecode: @@ -165,9 +166,10 @@ def install_requirements( if target_python: args.extend(["--python", target_python]) - # Keep the bytecode flag next to interpreter selection so compilation can never be enabled - # without an explicitly resolved interpreter matching the target runtime. - args.append("--compile-bytecode" if target_python else "--no-compile-bytecode") + # UV currently produces timestamp-based bytecode. SAM packages files with normalized + # timestamps, so that bytecode would be rejected by the Lambda runtime after deployment. + # Keep UV compilation disabled and generate unchecked-hash bytecode below instead. + args.append("--no-compile-bytecode") if platform and architecture: # UV pip install uses --python-platform format @@ -189,6 +191,34 @@ def install_requirements( LOG.debug("UV pip install completed successfully: %s", stdout) + if target_python: + self._compile_bytecode(target_python, target_dir) + + def _compile_bytecode(self, python_executable: str, target_dir: str) -> None: + """Compile reusable bytecode with the interpreter selected for the target runtime.""" + command = [ + python_executable, + "-m", + "compileall", + "-f", + "-q", + "--invalidation-mode", + "unchecked-hash", + target_dir, + ] + LOG.debug("Compiling unchecked-hash bytecode: %s", " ".join(command)) + rc, stdout, stderr = self._osutils.run_subprocess(command) + if rc != 0: + diagnostic = stderr.strip() or stdout.strip() or "no diagnostic output" + LOG.warning( + "Could not compile unchecked-hash bytecode with target Python (exit code %d): %s", + rc, + diagnostic, + ) + return + + LOG.debug("Python bytecode compilation completed successfully: %s", stdout) + class PythonUvDependencyBuilder: """High-level dependency builder that orchestrates UV operations.""" diff --git a/aws_lambda_builders/workflows/python_uv/utils.py b/aws_lambda_builders/workflows/python_uv/utils.py index e8666aa6f..220291882 100644 --- a/aws_lambda_builders/workflows/python_uv/utils.py +++ b/aws_lambda_builders/workflows/python_uv/utils.py @@ -25,7 +25,16 @@ def run_subprocess(self, cmd, cwd=None, env=None): env = self.original_environ() try: - result = subprocess.run(cmd, cwd=cwd, env=env, capture_output=True, text=True, check=False) + result = subprocess.run( + cmd, + cwd=cwd, + env=env, + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + check=False, + ) return result.returncode, result.stdout, result.stderr except Exception as e: return 1, "", str(e) @@ -104,7 +113,7 @@ def __init__( no_cache: bool = False, prerelease: str = "disallow", resolution: str = "highest", - compile_bytecode: bool = False, + compile_bytecode: bool = True, exclude_newer: Optional[str] = None, generate_hashes: bool = False, ): diff --git a/tests/unit/workflows/python_uv/test_packager.py b/tests/unit/workflows/python_uv/test_packager.py index ebe5a2998..2bdf1ed63 100644 --- a/tests/unit/workflows/python_uv/test_packager.py +++ b/tests/unit/workflows/python_uv/test_packager.py @@ -122,11 +122,26 @@ def test_find_python_failure(self, mock_osutils_class): self.assertIn("Could not locate target Python 3.9 via uv (exit code 2)", logs.output[0]) self.assertIn("interpreter not found", logs.output[0]) + @patch("aws_lambda_builders.workflows.python_uv.packager.OSUtils") + def test_find_python_failure_without_diagnostics(self, mock_osutils_class): + mock_osutils = Mock() + mock_osutils.which.return_value = "/usr/bin/uv" + mock_osutils.run_subprocess.return_value = (1, None, None) + mock_osutils_class.return_value = mock_osutils + + subprocess_uv = SubprocessUv() + + with self.assertLogs("aws_lambda_builders.workflows.python_uv.packager", level="WARNING") as logs: + self.assertIsNone(subprocess_uv.find_python("3.9")) + + self.assertIn("no diagnostic output", logs.output[0]) + class TestUvRunner(TestCase): def setUp(self): self.mock_subprocess_uv = Mock() self.mock_subprocess_uv.uv_executable = "/usr/bin/uv" + self.mock_subprocess_uv.find_python.return_value = None self.mock_osutils = Mock() self.uv_runner = UvRunner(uv_subprocess=self.mock_subprocess_uv, osutils=self.mock_osutils) @@ -154,24 +169,37 @@ def test_install_requirements_success(self): self.assertIn("install", args_called) self.assertIn("-r", args_called) self.assertIn("/path/to/requirements.txt", args_called) + self.mock_subprocess_uv.find_python.assert_called_once_with("3.9") - def test_install_requirements_compiles_bytecode_with_target_python(self): + def test_install_requirements_compiles_unchecked_hash_bytecode_by_default(self): self.mock_subprocess_uv.run_uv_command.return_value = (0, "success", "") self.mock_subprocess_uv.find_python.return_value = "/usr/bin/python3.13" + self.mock_osutils.run_subprocess.return_value = (0, "", "") self.uv_runner.install_requirements( requirements_path="/path/to/requirements.txt", target_dir="/target", - config=UvConfig(compile_bytecode=True), python_version="3.13", ) args_called = self.mock_subprocess_uv.run_uv_command.call_args[0][0] - self.assertIn("--compile-bytecode", args_called) - self.assertNotIn("--no-compile-bytecode", args_called) + self.assertIn("--no-compile-bytecode", args_called) + self.assertNotIn("--compile-bytecode", args_called) self.assertEqual(args_called[args_called.index("--python") + 1], "/usr/bin/python3.13") self.assertEqual(args_called[args_called.index("--python-version") + 1], "3.13") self.mock_subprocess_uv.find_python.assert_called_once_with("3.13") + self.mock_osutils.run_subprocess.assert_called_once_with( + [ + "/usr/bin/python3.13", + "-m", + "compileall", + "-f", + "-q", + "--invalidation-mode", + "unchecked-hash", + os.path.abspath("/target"), + ] + ) def test_install_requirements_skips_bytecode_when_target_python_is_unavailable(self): self.mock_subprocess_uv.run_uv_command.return_value = (0, "success", "") @@ -190,6 +218,7 @@ def test_install_requirements_skips_bytecode_when_target_python_is_unavailable(s self.assertNotIn("--python", args_called) self.assertEqual(args_called[args_called.index("--python-version") + 1], "3.9") self.mock_subprocess_uv.find_python.assert_called_once_with("3.9") + self.mock_osutils.run_subprocess.assert_not_called() def test_install_requirements_skips_bytecode_without_target_python_version(self): self.mock_subprocess_uv.run_uv_command.return_value = (0, "success", "") @@ -207,6 +236,7 @@ def test_install_requirements_skips_bytecode_without_target_python_version(self) self.assertNotIn("--python", args_called) self.mock_subprocess_uv.find_python.assert_not_called() self.assertIn("Target Python version is unavailable", logs.output[0]) + self.mock_osutils.run_subprocess.assert_not_called() def test_install_requirements_does_not_select_python_when_bytecode_disabled(self): self.mock_subprocess_uv.run_uv_command.return_value = (0, "success", "") @@ -224,6 +254,23 @@ def test_install_requirements_does_not_select_python_when_bytecode_disabled(self self.assertNotIn("--python", args_called) self.assertEqual(args_called[args_called.index("--python-version") + 1], "3.13") self.mock_subprocess_uv.find_python.assert_not_called() + self.mock_osutils.run_subprocess.assert_not_called() + + def test_install_requirements_keeps_build_non_fatal_when_bytecode_compilation_fails(self): + self.mock_subprocess_uv.run_uv_command.return_value = (0, "success", "") + self.mock_subprocess_uv.find_python.return_value = "/usr/bin/python3.13" + self.mock_osutils.run_subprocess.return_value = (1, "", "compile error") + + with self.assertLogs("aws_lambda_builders.workflows.python_uv.packager", level="WARNING") as logs: + self.uv_runner.install_requirements( + requirements_path="/path/to/requirements.txt", + target_dir="/target", + config=UvConfig(compile_bytecode=True), + python_version="3.13", + ) + + self.assertIn("Could not compile unchecked-hash bytecode with target Python (exit code 1)", logs.output[0]) + self.assertIn("compile error", logs.output[0]) def test_install_requirements_resolves_relative_target_to_absolute(self): # UV runs with cwd=project_dir, so a relative --target must be resolved to an absolute path diff --git a/tests/unit/workflows/python_uv/test_utils.py b/tests/unit/workflows/python_uv/test_utils.py index f15e3e550..878a51d4d 100644 --- a/tests/unit/workflows/python_uv/test_utils.py +++ b/tests/unit/workflows/python_uv/test_utils.py @@ -1,7 +1,7 @@ import os import tempfile from unittest import TestCase -from unittest.mock import Mock +from unittest.mock import Mock, patch from aws_lambda_builders.workflows.python_uv.utils import ( OSUtils, @@ -35,6 +35,25 @@ def test_run_subprocess_failure(self): rc, stdout, stderr = self.osutils.run_subprocess(["false"]) self.assertEqual(rc, 1) + @patch("aws_lambda_builders.workflows.python_uv.utils.subprocess.run") + def test_run_subprocess_decodes_utf8_with_replacement(self, mock_run): + mock_run.return_value = Mock(returncode=0, stdout="success", stderr="") + env = {"UV_TEST": "1"} + + result = self.osutils.run_subprocess(["uv", "--version"], cwd="/work", env=env) + + self.assertEqual(result, (0, "success", "")) + mock_run.assert_called_once_with( + ["uv", "--version"], + cwd="/work", + env=env, + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + check=False, + ) + class TestDetectUvManifest(TestCase): def test_detect_uv_manifest_no_files(self): @@ -107,6 +126,7 @@ def test_uv_config_defaults(self): config = UvConfig() args = config.to_uv_args() self.assertEqual(args, []) + self.assertTrue(config.compile_bytecode) def test_uv_config_with_index_url(self): config = UvConfig(index_url="https://pypi.org/simple/")