|
12 | 12 | # See the License for the specific language governing permissions and
|
13 | 13 | # limitations under the License.
|
14 | 14 | from typing import Set
|
15 |
| -from unittest.mock import patch |
| 15 | +from unittest.mock import MagicMock, patch |
16 | 16 | from zipfile import ZipFile
|
17 | 17 |
|
18 | 18 | import pytest
|
| 19 | +from snowflake.cli._plugins.snowpark.models import ( |
| 20 | + Requirement, |
| 21 | + WheelMetadata, |
| 22 | +) |
| 23 | +from snowflake.cli._plugins.snowpark.package.anaconda_packages import ( |
| 24 | + AnacondaPackages, |
| 25 | + AvailablePackage, |
| 26 | +) |
19 | 27 | from snowflake.cli._plugins.snowpark.package_utils import (
|
20 | 28 | DownloadUnavailablePackagesResult,
|
| 29 | + split_downloaded_dependencies, |
21 | 30 | )
|
| 31 | +from snowflake.cli.api.secure_path import SecurePath |
22 | 32 |
|
23 | 33 |
|
24 | 34 | @patch("snowflake.cli._plugins.snowpark.package_utils.download_unavailable_packages")
|
@@ -66,3 +76,222 @@ def test_build_with_glob_patterns_in_artifacts(
|
66 | 76 | def _assert_zip_contains(app_zip: str, expected_files: Set[str]):
|
67 | 77 | zip_file = ZipFile(app_zip)
|
68 | 78 | assert set(zip_file.namelist()) == expected_files
|
| 79 | + |
| 80 | + |
| 81 | +@patch("snowflake.cli._plugins.snowpark.package_utils.log") |
| 82 | +def test_split_downloaded_dependencies_handles_duplicates(mock_log, tmp_path): |
| 83 | + """Test that split_downloaded_dependencies properly handles duplicate package versions. |
| 84 | +
|
| 85 | + This test prevents regression of the bug where multiple versions of the same package |
| 86 | + (e.g., httpx-0.27.0.whl and httpx-0.28.1.whl) would both be included in dependencies.zip, |
| 87 | + causing Snowflake deployment to fail with 'Package specified with multiple versions'. |
| 88 | + """ |
| 89 | + downloads_dir = tmp_path / "downloads" |
| 90 | + downloads_dir.mkdir() |
| 91 | + |
| 92 | + httpx_v1_wheel = downloads_dir / "httpx-0.27.0-py3-none-any.whl" |
| 93 | + httpx_v2_wheel = downloads_dir / "httpx-0.28.1-py3-none-any.whl" |
| 94 | + httpx_v1_wheel.touch() |
| 95 | + httpx_v2_wheel.touch() |
| 96 | + |
| 97 | + requirements_file = tmp_path / "requirements.txt" |
| 98 | + requirements_file.write_text("httpx\n") |
| 99 | + |
| 100 | + original_from_wheel = WheelMetadata.from_wheel |
| 101 | + |
| 102 | + def mock_from_wheel(wheel_path): |
| 103 | + if "httpx-0.27.0" in str(wheel_path): |
| 104 | + return WheelMetadata(name="httpx", wheel_path=wheel_path, dependencies=[]) |
| 105 | + elif "httpx-0.28.1" in str(wheel_path): |
| 106 | + return WheelMetadata(name="httpx", wheel_path=wheel_path, dependencies=[]) |
| 107 | + return original_from_wheel(wheel_path) |
| 108 | + |
| 109 | + with patch.object(WheelMetadata, "from_wheel", side_effect=mock_from_wheel): |
| 110 | + mock_anaconda = MagicMock(spec=AnacondaPackages) |
| 111 | + mock_anaconda.is_package_available.return_value = False |
| 112 | + |
| 113 | + result = split_downloaded_dependencies( |
| 114 | + requirements_file=SecurePath(requirements_file), |
| 115 | + downloads_dir=downloads_dir, |
| 116 | + anaconda_packages=mock_anaconda, |
| 117 | + skip_version_check=False, |
| 118 | + ) |
| 119 | + |
| 120 | + # Verify that 2 warnings were logged about duplicate packages |
| 121 | + assert mock_log.warning.call_count >= 2 |
| 122 | + |
| 123 | + # Check the first warning call (multiple versions found) |
| 124 | + first_call = mock_log.warning.call_args_list[0] |
| 125 | + assert "Multiple versions of package '%s' found" in first_call.args[0] |
| 126 | + assert first_call.args[1] == "httpx" # package name |
| 127 | + assert "httpx-" in first_call.args[2] # using wheel filename |
| 128 | + assert "httpx-" in first_call.args[3] # ignoring wheel filename |
| 129 | + |
| 130 | + # Check the second warning call (duplicate packages summary) |
| 131 | + second_call = mock_log.warning.call_args_list[1] |
| 132 | + assert "Found duplicate packages: %s" in second_call.args[0] |
| 133 | + assert second_call.args[1] == "httpx" |
| 134 | + |
| 135 | + # Verify that only one version of httpx is in the result |
| 136 | + httpx_packages = [ |
| 137 | + pkg |
| 138 | + for pkg in result.unavailable_dependencies_wheels |
| 139 | + if pkg.requirement.name == "httpx" |
| 140 | + ] |
| 141 | + assert ( |
| 142 | + len(httpx_packages) == 1 |
| 143 | + ), f"Expected 1 httpx package, got {len(httpx_packages)}" |
| 144 | + |
| 145 | + # Verify that one of the duplicate wheel files was removed |
| 146 | + remaining_wheels = list(downloads_dir.glob("httpx-*.whl")) |
| 147 | + assert ( |
| 148 | + len(remaining_wheels) == 1 |
| 149 | + ), f"Expected 1 remaining wheel file, got {len(remaining_wheels)}" |
| 150 | + |
| 151 | + |
| 152 | +@patch("snowflake.cli._plugins.snowpark.package.anaconda_packages.log") |
| 153 | +def test_write_requirements_file_deduplicates_anaconda_packages(mock_log, tmp_path): |
| 154 | + """Test that write_requirements_file_in_snowflake_format deduplicates packages. |
| 155 | +
|
| 156 | + This test prevents regression of the bug where multiple entries for the same package |
| 157 | + (e.g., 'httpx==0.28.1' and 'httpx>=0.20.0') would both be written to requirements.snowflake.txt, |
| 158 | + causing Snowflake deployment issues. |
| 159 | + """ |
| 160 | + packages = { |
| 161 | + "httpx": AvailablePackage(snowflake_name="httpx", versions={"0.28.1", "0.27.0"}) |
| 162 | + } |
| 163 | + |
| 164 | + anaconda_packages = AnacondaPackages(packages) |
| 165 | + |
| 166 | + requirements = [ |
| 167 | + Requirement.parse_line("httpx==0.28.1"), |
| 168 | + Requirement.parse_line("httpx>=0.20.0"), |
| 169 | + ] |
| 170 | + |
| 171 | + output_file = tmp_path / "requirements.snowflake.txt" |
| 172 | + |
| 173 | + anaconda_packages.write_requirements_file_in_snowflake_format( |
| 174 | + file_path=SecurePath(output_file), requirements=requirements |
| 175 | + ) |
| 176 | + |
| 177 | + # Verify 2 warnings were logged |
| 178 | + assert mock_log.warning.call_count >= 2 |
| 179 | + |
| 180 | + # Check the first warning call (duplicate package found) |
| 181 | + first_call = mock_log.warning.call_args_list[0] |
| 182 | + assert "Duplicate package '%s' found in Anaconda requirements" in first_call.args[0] |
| 183 | + assert first_call.args[1] == "httpx" # package name |
| 184 | + assert first_call.args[2] == "httpx>=0.20.0" # ignored requirement |
| 185 | + |
| 186 | + # Check the second warning call (duplicate packages summary) |
| 187 | + second_call = mock_log.warning.call_args_list[1] |
| 188 | + assert "Found duplicate Anaconda packages: %s" in second_call.args[0] |
| 189 | + assert second_call.args[1] == "httpx" |
| 190 | + |
| 191 | + # Verify only one entry was written to the file |
| 192 | + content = output_file.read_text().strip() |
| 193 | + lines = [line.strip() for line in content.split("\n") if line.strip()] |
| 194 | + |
| 195 | + # Should only have one httpx entry |
| 196 | + httpx_lines = [line for line in lines if "httpx" in line] |
| 197 | + assert ( |
| 198 | + len(httpx_lines) == 1 |
| 199 | + ), f"Expected 1 httpx line, got {len(httpx_lines)}: {httpx_lines}" |
| 200 | + assert httpx_lines[0] == "httpx==0.28.1" # Should keep the first one |
| 201 | + |
| 202 | + |
| 203 | +def test_similar_package_names_not_treated_as_duplicates(): |
| 204 | + """Test that packages with similar names are treated as separate packages. |
| 205 | +
|
| 206 | + This test ensures that packages like 'httpx' and 'httpx-retries' are correctly |
| 207 | + treated as different packages and don't trigger duplicate detection. |
| 208 | + """ |
| 209 | + req1 = Requirement.parse_line("httpx==0.28.1") |
| 210 | + req2 = Requirement.parse_line("httpx-retries==0.4.2") |
| 211 | + |
| 212 | + assert req1.name == "httpx" |
| 213 | + assert req2.name == "httpx_retries" # Note: hyphen becomes underscore |
| 214 | + assert req1.name != req2.name |
| 215 | + |
| 216 | + wheel1 = "httpx-0.28.1-py3-none-any.whl" |
| 217 | + wheel2 = "httpx_retries-0.4.2-py3-none-any.whl" |
| 218 | + |
| 219 | + name1 = WheelMetadata._get_name_from_wheel_filename(wheel1) # noqa: SLF001 |
| 220 | + name2 = WheelMetadata._get_name_from_wheel_filename(wheel2) # noqa: SLF001 |
| 221 | + |
| 222 | + assert name1 == "httpx" |
| 223 | + assert name2 == "httpx_retries" |
| 224 | + assert name1 != name2 |
| 225 | + |
| 226 | + |
| 227 | +@patch("snowflake.cli._plugins.snowpark.package_utils.log") |
| 228 | +def test_multiple_different_packages_no_duplicates_detected(mock_log, tmp_path): |
| 229 | + """Test that multiple different packages don't trigger duplicate detection. |
| 230 | +
|
| 231 | + This is a regression test to ensure that legitimate different packages |
| 232 | + (like httpx, httpx-retries, requests, etc.) don't get flagged as duplicates. |
| 233 | + """ |
| 234 | + downloads_dir = tmp_path / "downloads" |
| 235 | + downloads_dir.mkdir() |
| 236 | + |
| 237 | + wheels = [ |
| 238 | + "httpx-0.28.1-py3-none-any.whl", |
| 239 | + "httpx_retries-0.4.2-py3-none-any.whl", |
| 240 | + "requests-2.31.0-py3-none-any.whl", |
| 241 | + ] |
| 242 | + |
| 243 | + for wheel in wheels: |
| 244 | + (downloads_dir / wheel).touch() |
| 245 | + |
| 246 | + requirements_file = tmp_path / "requirements.txt" |
| 247 | + requirements_file.write_text("httpx\nhttpx-retries\nrequests\n") |
| 248 | + |
| 249 | + def mock_from_wheel(wheel_path): |
| 250 | + wheel_name = wheel_path.name |
| 251 | + if "httpx-0.28.1" in wheel_name: |
| 252 | + return WheelMetadata(name="httpx", wheel_path=wheel_path, dependencies=[]) |
| 253 | + elif "httpx_retries-0.4.2" in wheel_name: |
| 254 | + return WheelMetadata( |
| 255 | + name="httpx_retries", wheel_path=wheel_path, dependencies=[] |
| 256 | + ) |
| 257 | + elif "requests-2.31.0" in wheel_name: |
| 258 | + return WheelMetadata( |
| 259 | + name="requests", wheel_path=wheel_path, dependencies=[] |
| 260 | + ) |
| 261 | + return None |
| 262 | + |
| 263 | + with patch.object(WheelMetadata, "from_wheel", side_effect=mock_from_wheel): |
| 264 | + mock_anaconda = MagicMock(spec=AnacondaPackages) |
| 265 | + mock_anaconda.is_package_available.return_value = False |
| 266 | + |
| 267 | + result = split_downloaded_dependencies( |
| 268 | + requirements_file=SecurePath(requirements_file), |
| 269 | + downloads_dir=downloads_dir, |
| 270 | + anaconda_packages=mock_anaconda, |
| 271 | + skip_version_check=False, |
| 272 | + ) |
| 273 | + |
| 274 | + # Verify NO duplicate warnings were logged |
| 275 | + warning_calls = [str(call) for call in mock_log.warning.call_args_list] |
| 276 | + duplicate_warnings = [ |
| 277 | + call |
| 278 | + for call in warning_calls |
| 279 | + if "Multiple versions of package" in call |
| 280 | + or "Found duplicate packages" in call |
| 281 | + ] |
| 282 | + assert ( |
| 283 | + len(duplicate_warnings) == 0 |
| 284 | + ), f"Unexpected duplicate warnings: {duplicate_warnings}" |
| 285 | + |
| 286 | + # Verify three packages are in the result |
| 287 | + package_names = { |
| 288 | + pkg.requirement.name for pkg in result.unavailable_dependencies_wheels |
| 289 | + } |
| 290 | + assert "httpx" in package_names |
| 291 | + assert "httpx_retries" in package_names |
| 292 | + assert "requests" in package_names |
| 293 | + assert len(package_names) == 3 |
| 294 | + |
| 295 | + # Verify all wheel files are still present |
| 296 | + remaining_wheels = list(downloads_dir.glob("*.whl")) |
| 297 | + assert len(remaining_wheels) == 3 |
0 commit comments