diff --git a/CHANGELOG.md b/CHANGELOG.md index 8eaac3d9cc..6695e7bc07 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -57,7 +57,7 @@ Unreleased changes template. {#v0-0-0-fixed} ### Fixed * (pypi) The `ppc64le` is now pointing to the right target in the `platforms` package. -* (gazelle) No longer incorrectly merge `py_binary` targets during partial updates in +* (gazelle) No longer incorrectly merge `py_binary` targets during partial updates in `file` generation mode. Fixed in [#2619](https://github.com/bazelbuild/rules_python/pull/2619). * (bzlmod) Running as root is no longer an error. `ignore_root_user_error=True` is now the default. Note that running as root may still cause spurious @@ -65,6 +65,8 @@ Unreleased changes template. ([#1169](https://github.com/bazelbuild/rules_python/issues/1169)). * (gazelle) Don't collapse depsets to a list or into args when generating the modules mapping file. Support spilling modules mapping args into a params file. +* (uv) Adds support for versions 0.5.26 through 0.6.3 and a maintenance script + for future releases. Fixed in [#2647](https://github.com/bazelbuild/rules_python/pull/2647). {#v0-0-0-added} ### Added diff --git a/python/uv/private/versions.bzl b/python/uv/private/versions.bzl index 1d68302c74..65dc2a9c07 100644 --- a/python/uv/private/versions.bzl +++ b/python/uv/private/versions.bzl @@ -68,6 +68,236 @@ UV_PLATFORMS = { # From: https://github.com/astral-sh/uv/releases UV_TOOL_VERSIONS = { + "0.6.3": { + "aarch64-apple-darwin": struct( + sha256 = "51b84818bbfe08358a298ba3389c6d448d3ddc0f2601a2d63c5a62cb7b704062", + ), + "aarch64-unknown-linux-gnu": struct( + sha256 = "447726788204106ffd8ecc59396fccc75fae7aca998555265b5ea6950b00160c", + ), + "powerpc64le-unknown-linux-gnu": struct( + sha256 = "e41eec560bd166f5bd155772ef120ec7220a80dcb4b70e71d8f4781276c5d102", + ), + "s390x-unknown-linux-gnu": struct( + sha256 = "2c3c03d95c20adb2e521efaeddf6f9947c427c5e8140e38585595f3c947cebed", + ), + "x86_64-apple-darwin": struct( + sha256 = "a675d2d0fcf533f89f4b584bfa8ee3173a1ffbc87d9d1d48fcc3abb8c55d946d", + ), + "x86_64-pc-windows-msvc": struct( + sha256 = "40b50b3da3cf74dc5717802acd076b4669b6d7d2c91c4482875b4e5e46c62ba3", + ), + "x86_64-unknown-linux-gnu": struct( + sha256 = "b7a37a33d62cb7672716c695226450231e8c02a8eb2b468fa61cd28a8f86eab2", + ), + }, + "0.6.2": { + "aarch64-apple-darwin": struct( + sha256 = "4af802a1216053650dd82eee85ea4241994f432937d41c8b0bc90f2639e6ae14", + ), + "aarch64-unknown-linux-gnu": struct( + sha256 = "ca4c08724764a2b6c8f2173c4e3ca9dcde0d9d328e73b4d725cfb6b17a925eed", + ), + "powerpc64le-unknown-linux-gnu": struct( + sha256 = "f341fd4874d2d007135626a0657d1478f331a78991d8a1a06aaa0d52fbe16183", + ), + "s390x-unknown-linux-gnu": struct( + sha256 = "17fd89bd8de75da9c91baf918b8079c1f1f92bb6a398f0cfbc5ddefe0c7f0ee5", + ), + "x86_64-apple-darwin": struct( + sha256 = "2b9e78b2562aea93f13e42df1177cb07c59a4d4f1c8ff8907d0c31f3a5e5e8db", + ), + "x86_64-pc-windows-msvc": struct( + sha256 = "5f33c3cc5c183775cc51b3e661a0d2ce31142d32a50406a67c7ad0321fc841d9", + ), + "x86_64-unknown-linux-gnu": struct( + sha256 = "37ea31f099678a3bee56f8a757d73551aad43f8025d377a8dde80dd946c1b7f2", + ), + }, + "0.6.1": { + "aarch64-apple-darwin": struct( + sha256 = "90e10cc7f26cbaf3eaa867cf99344ffd550e942fd4b660e88f2f91c23022dc5a", + ), + "aarch64-unknown-linux-gnu": struct( + sha256 = "f355989fb5ecf47c9f9087a0b21e2ee7d7c802bc3d0cf6edae07560d4297751f", + ), + "powerpc64le-unknown-linux-gnu": struct( + sha256 = "becf4913112c475b2713df01a8c0536b38dc2c48f04b1d603cd6f0a74f88caa2", + ), + "s390x-unknown-linux-gnu": struct( + sha256 = "ee687d56ba1e359a7a2e20e301b992b83882df5ffb1409d301e1b0d21b3fa16a", + ), + "x86_64-apple-darwin": struct( + sha256 = "d8609b53f280d5e784a7586bf7a3fd90c557656af109cee8572b24a0c1443191", + ), + "x86_64-pc-windows-msvc": struct( + sha256 = "32de1730597db0a7c5f34e2257ab491b660374b22c016c3d9a59ae279d837697", + ), + "x86_64-unknown-linux-gnu": struct( + sha256 = "0dcad9831d3f10f3bc4dcd7678948dfc74c0b3ab3f07aa684eb9e5135b971a58", + ), + }, + "0.6.0": { + "aarch64-apple-darwin": struct( + sha256 = "ff4f1ec24a3adb3dd251f9523e4b7a7cba379e9896ae6ed1efa163fcdcd6af8a", + ), + "aarch64-unknown-linux-gnu": struct( + sha256 = "47fa7ada7352f69a5efd19628b86b83c0bbda34541de3a4254ba75a188414953", + ), + "powerpc64le-unknown-linux-gnu": struct( + sha256 = "d782751a6ec8a0775aa57087275225b6562a115004c1f41935bec1609765508d", + ), + "s390x-unknown-linux-gnu": struct( + sha256 = "664f4165767a0cd808d1784d1d70243da4789024ec5cd779a861201b54a479b7", + ), + "x86_64-apple-darwin": struct( + sha256 = "530ef3b6f563448e8e017a8cd6693d6c72c146fb0a3c43440bb0e93fcf36264f", + ), + "x86_64-pc-windows-msvc": struct( + sha256 = "65836dae55d3a63e5fc1d51ae52e6ea175aaab1c82c4a6660d46462b27d19c2a", + ), + "x86_64-unknown-linux-gnu": struct( + sha256 = "1a26ce241f7ff1f52634d869f86db533fffba21e528597029ee9d1423bf3df18", + ), + }, + "0.5.31": { + "aarch64-apple-darwin": struct( + sha256 = "396c9bd6acd98466fdb585da2ed040eecea15228e580d4bd649c09215b490bf9", + ), + "aarch64-unknown-linux-gnu": struct( + sha256 = "e7f358efb0718bd8f98dc0c29fd0902323b590381ca765537063a2ca23ed34c7", + ), + "powerpc64le-unknown-linux-gnu": struct( + sha256 = "e292dc0a7b23fab01bbf2b6fdddf8bb0c531805b1dbc3905637af70a88ff1f5f", + ), + "s390x-unknown-linux-gnu": struct( + sha256 = "66232646bd15a38cf6877c6af6bf8668fadb2af910d7cf7a1159885487a15e70", + ), + "x86_64-apple-darwin": struct( + sha256 = "5316b82da14fab9a76b3521c901e7c0a7d641fb9d28eb07874e26a00b0ac2725", + ), + "x86_64-pc-windows-msvc": struct( + sha256 = "1ad54dace424c259b603ecd36262cb235af2bc8d6f280e24063d57919545f593", + ), + "x86_64-unknown-linux-gnu": struct( + sha256 = "017ce7ed02c967f1b0489f09162e19ee3df4586a44e681211d16206e007fce62", + ), + }, + "0.5.30": { + "aarch64-apple-darwin": struct( + sha256 = "654c3e010c9c53b024fa752d08b949e0f80f10ec4e3a1acea9437a1d127a1053", + ), + "aarch64-unknown-linux-gnu": struct( + sha256 = "d1ea4a2299768b2c8263db0abd8ea0de3b8052a34a51f5cf73094051456d4de2", + ), + "powerpc64le-unknown-linux-gnu": struct( + sha256 = "b10ba261377f89e598322f3329beeada6b868119581e2a7294e7585351d3733f", + ), + "s390x-unknown-linux-gnu": struct( + sha256 = "7341e6d62b0e02fbd33fe6ce0158e9f68617f43e5ec42fc6904d246bda5f6d34", + ), + "x86_64-apple-darwin": struct( + sha256 = "42c4a5d3611928613342958652ab16943d05980b1ab5057bb47e4283ef7e890d", + ), + "x86_64-pc-windows-msvc": struct( + sha256 = "43d6b97d2e283f6509a9199fd32411d67a64d5b5dca3e6e63e45ec2faec68f73", + ), + "x86_64-unknown-linux-gnu": struct( + sha256 = "9d82816c14c44054f0c679f2bcaecfd910c75f207e08874085cb27b482f17776", + ), + }, + "0.5.29": { + "aarch64-apple-darwin": struct( + sha256 = "c89e96bde40402cc4db2f59bcb886882ab69e557235279283a2db9dea61135c3", + ), + "aarch64-unknown-linux-gnu": struct( + sha256 = "d1f716e8362d7da654a154b8331054a987c1fc16562bd719190a42458e945785", + ), + "powerpc64le-unknown-linux-gnu": struct( + sha256 = "0e38436e4068eec23498f88a5c1b721411986e6a983f243680a60b716b7c301c", + ), + "s390x-unknown-linux-gnu": struct( + sha256 = "6a42886dd10c6437a1a56982cd0c116d063f05483aa7db1cc0343f705ef96f91", + ), + "x86_64-apple-darwin": struct( + sha256 = "2f13ef5a82b91ba137fd6441f478c406a0a8b0df41e9573d1e61551a1de5a3a2", + ), + "x86_64-pc-windows-msvc": struct( + sha256 = "2453b17df889822a5b8dcd3467dd6b75a410d61f5e6504362e3852fb3175c19c", + ), + "x86_64-unknown-linux-gnu": struct( + sha256 = "46d3fcf04d64be42bded914d648657cd62d968172604e3aaf8386142c09d2317", + ), + }, + "0.5.28": { + "aarch64-apple-darwin": struct( + sha256 = "57cbf655a5bc5c1ffa7315c0b25ff342f44a919fa099311c0d994914011b421e", + ), + "aarch64-unknown-linux-gnu": struct( + sha256 = "fe3c481940c5542d034a863239f23d64ee45abcd636c480c1ea0f34469a66c86", + ), + "powerpc64le-unknown-linux-gnu": struct( + sha256 = "74bc6aacea26c67305910bcbe4b6178b96fefe643b2002567cc094ad2c209ef1", + ), + "s390x-unknown-linux-gnu": struct( + sha256 = "b3f49b0268ab971ff7f39ca924fb8291ce3d8ffe8f6c0d7ff16bc12055cd1e85", + ), + "x86_64-apple-darwin": struct( + sha256 = "36484907ec1988f1553bdc7de659d8bc0b46b8eaca09b0f67359b116caac170d", + ), + "x86_64-pc-windows-msvc": struct( + sha256 = "31053741c49624726d5ce8cb1ab8f5fc267ed0333ab8257450bd71a7c2a68d05", + ), + "x86_64-unknown-linux-gnu": struct( + sha256 = "1f2a654627e02fed5f8b883592439b842e74d98091bbafe9e71c7101f4f97d74", + ), + }, + "0.5.27": { + "aarch64-apple-darwin": struct( + sha256 = "efe367393fc02b8e8609c38bce78d743261d7fc885e5eabfbd08ce881816aea3", + ), + "aarch64-unknown-linux-gnu": struct( + sha256 = "7b8175e7370056efa6e8f4c8fec854f3a026c0ecda628694f5200fdf666167fa", + ), + "powerpc64le-unknown-linux-gnu": struct( + sha256 = "b63051bdd5392fa6a3d8d98c661b395c62a2a05a0e96ae877047c4c7be1b92ff", + ), + "s390x-unknown-linux-gnu": struct( + sha256 = "07377ed611dbf1548f06b65ad6d2bb84f3ff1ccce936ba972d7b7f5492e47d30", + ), + "x86_64-apple-darwin": struct( + sha256 = "a75c9d77c90c4ac367690134cd471108c09b95226c62cd6422ca0db8bbea2197", + ), + "x86_64-pc-windows-msvc": struct( + sha256 = "195d43f6578c33838523bf4f3c80d690914496592b2946bda8598b8500e744f6", + ), + "x86_64-unknown-linux-gnu": struct( + sha256 = "27261ddf7654d4f34ed4600348415e0c30de2a307cc6eff6a671a849263b2dcf", + ), + }, + "0.5.26": { + "aarch64-apple-darwin": struct( + sha256 = "3b503c630dc65b991502e1d9fe0ffc410ae50c503e8df6d4900f23b9ad436366", + ), + "aarch64-unknown-linux-gnu": struct( + sha256 = "6ce061c2f14bf2f0b12c2b7a0f80c65408bf2dcee9743c4fc4ec1f30b85ecb98", + ), + "powerpc64le-unknown-linux-gnu": struct( + sha256 = "fe1d770840110b59554228b12382881abefc1ab2d2ca009adc1502179422bc0d", + ), + "s390x-unknown-linux-gnu": struct( + sha256 = "086c8d03ee4aff702a32d58086accf971ce58a2f000323414935e0f50e816c04", + ), + "x86_64-apple-darwin": struct( + sha256 = "7cf20dd534545a74290a244d3e8244d1010ba38d2d5950f504b6c93fab169f57", + ), + "x86_64-pc-windows-msvc": struct( + sha256 = "a938eebb7433eb7097ae1cf3d53f9bb083edd4c746045f284a1c8904af1a1a11", + ), + "x86_64-unknown-linux-gnu": struct( + sha256 = "555f17717e7663109104b62976e9da6cfda1ad84213407b437fd9c8f573cc0ef", + ), + }, "0.4.25": { "aarch64-apple-darwin": struct( sha256 = "bb2ff4348114ef220ca52e44d5086640c4a1a18f797a5f1ab6f8559fc37b1230", @@ -92,3 +322,4 @@ UV_TOOL_VERSIONS = { ), }, } + diff --git a/tools/test_update_uv_versions.py b/tools/test_update_uv_versions.py new file mode 100644 index 0000000000..2c5ee10719 --- /dev/null +++ b/tools/test_update_uv_versions.py @@ -0,0 +1,311 @@ +import json +import unittest +from unittest.mock import patch + +import requests +from update_uv_versions import ( + clean_trailing_commas, + get_uv_releases_info, + parse_uv_platforms, + parse_uv_versions, + update_versions_content, +) + + +class TestCleanTrailingCommas(unittest.TestCase): + """Unit tests for the clean_trailing_commas function.""" + + def test_clean_trailing_commas_success(self): + """Tests successful removal of trailing commas.""" + test_cases = [ + ('{"a": 1, "b": 2,}', '{"a": 1, "b": 2}'), + ('{"a": 1, "b": 2, "c": 3}', '{"a": 1, "b": 2, "c": 3}'), + ("[1, 2,]", "[1, 2]"), + ("[1, 2, 3]", "[1, 2, 3]"), + ('{"a": [1, 2,]}', '{"a": [1, 2]}'), + ('{"a": [1, 2, 3]}', '{"a": [1, 2, 3]}'), + ('{"a": 1, "b": 2, }', '{"a": 1, "b": 2}'), + ('{"a": 1, "b": 2, }', '{"a": 1, "b": 2}'), + ('{"a": 1, "b": 2,\n}', '{"a": 1, "b": 2}'), + ('{"a": 1, "b": 2,\r}', '{"a": 1, "b": 2}'), + ('{"a": 1, "b": 2,\t}', '{"a": 1, "b": 2}'), + ('{"a": 1, "b": 2,\r\n}', '{"a": 1, "b": 2}'), + ('{"a": 1, "b": 2,\n\r}', '{"a": 1, "b": 2}'), + ('{"a": 1, "b": 2,\t\r}', '{"a": 1, "b": 2}'), + ('{"a": 1, "b": 2,\t\n}', '{"a": 1, "b": 2}'), + ('{"a": 1, "b": 2,\t\r\n}', '{"a": 1, "b": 2}'), + ] + for test_case in test_cases: + self.assertEqual(clean_trailing_commas(test_case[0]), test_case[1]) + + +class TestGetUVReleasesInfo(unittest.TestCase): + """Unit tests for the get_uv_releases_info function.""" + + @patch("requests.get") + def test_get_uv_releases_info_success(self, mock_get): + """Tests successful retrieval of release information.""" + mock_response = unittest.mock.Mock() + mock_response.status_code = 200 + mock_response.json.return_value = [ + { + "tag_name": "0.1.1", + "assets": [ + { + "name": "uv-0.1.1-x86_64-unknown-linux-gnu.tar.gz", + "browser_download_url": "https://example.com/uv-0.1.1-x86_64-unknown-linux-gnu.tar.gz", + }, + { + "name": "uv-0.1.1-aarch64-apple-darwin.tar.gz", + "browser_download_url": "https://example.com/uv-0.1.1-aarch64-apple-darwin.tar.gz", + }, + ], + } + ] + mock_sha256_response_1 = unittest.mock.Mock() + mock_sha256_response_1.status_code = 200 + mock_sha256_response_1.text = "sha256_1 file1" + mock_sha256_response_2 = unittest.mock.Mock() + mock_sha256_response_2.status_code = 200 + mock_sha256_response_2.text = "sha256_2 file2" + mock_get.side_effect = [ + mock_response, + mock_sha256_response_1, + mock_sha256_response_2, + ] + result = get_uv_releases_info() + self.assertIsNotNone(result) + self.assertEqual(len(result), 1) + self.assertIn("0.1.1", result) + self.assertEqual(len(result["0.1.1"]), 2) + self.assertEqual( + result["0.1.1"]["uv-0.1.1-x86_64-unknown-linux-gnu.tar.gz"]["sha256"], + "sha256_1", + ) + self.assertEqual( + result["0.1.1"]["uv-0.1.1-aarch64-apple-darwin.tar.gz"]["sha256"], + "sha256_2", + ) + + @patch("requests.get") + def test_get_uv_releases_info_request_error(self, mock_get): + """Tests handling of request errors.""" + mock_get.side_effect = requests.exceptions.RequestException("Request error") + result = get_uv_releases_info() + self.assertIsNone(result) + + @patch("requests.get") + def test_get_uv_releases_info_json_decode_error(self, mock_get): + """Tests handling of JSON decoding errors.""" + mock_response = unittest.mock.Mock() + mock_response.status_code = 200 + mock_response.json.side_effect = json.JSONDecodeError("Decoding error", "", 0) + mock_get.return_value = mock_response + result = get_uv_releases_info() + self.assertIsNone(result) + + +class TestParseUVPlatforms(unittest.TestCase): + """Unit tests for the parse_uv_platforms function.""" + + def test_parse_uv_platforms_success(self): + """Tests successful parsing of UV_PLATFORMS section.""" + content = """ +UV_PLATFORMS = { + "x86_64-unknown-linux-gnu": struct(default_repo_name = "x86_64-unknown-linux-gnu", compatible_with = ["@platforms//os:linux", "@platforms//cpu:x86_64"]), + "aarch64-apple-darwin": struct(default_repo_name = "aarch64-apple-darwin", compatible_with = ["@platforms//os:osx", "@platforms//cpu:arm64"]), +} +""" + expected_platforms = { + "x86_64-unknown-linux-gnu": { + "default_repo_name": "x86_64-unknown-linux-gnu", + "compatible_with": ["@platforms//os:linux", "@platforms//cpu:x86_64"], + }, + "aarch64-apple-darwin": { + "default_repo_name": "aarch64-apple-darwin", + "compatible_with": ["@platforms//os:osx", "@platforms//cpu:arm64"], + }, + } + platforms = parse_uv_platforms(content) + self.assertEqual(platforms, expected_platforms) + + def test_parse_uv_platforms_empty(self): + """Tests parsing when UV_PLATFORMS section is empty.""" + content = "UV_PLATFORMS = {}" + platforms = parse_uv_platforms(content) + self.assertEqual(platforms, {}) + + def test_parse_uv_platforms_missing(self): + """Tests parsing when UV_PLATFORMS section is missing.""" + content = "some other content" + platforms = parse_uv_platforms(content) + self.assertEqual(platforms, {}) + + def test_parse_uv_platforms_invalid_json(self): + """Tests handling of invalid JSON in UV_PLATFORMS section.""" + content = """ +UV_PLATFORMS = { + "x86_64-unknown-linux-gnu": struct(default_repo_name = "x86_64-unknown-linux-gnu", compatible_with = ["@platforms//os:linux", "@platforms//cpu:x86_64"]), + "aarch64-apple-darwin": struct(default_repo_name = "aarch64-apple-darwin", compatible_with = ["@platforms//os:osx", "@platforms//cpu:arm64"] +} +""" + platforms = parse_uv_platforms(content) + self.assertEqual(platforms, {}) + + def test_parse_uv_platforms_no_compatible_with(self): + """Tests parsing when compatible_with is missing.""" + content = """ +UV_PLATFORMS = { + "x86_64-unknown-linux-gnu": struct(default_repo_name = "x86_64-unknown-linux-gnu"), +} +""" + expected_platforms = { + "x86_64-unknown-linux-gnu": { + "default_repo_name": "x86_64-unknown-linux-gnu", + }, + } + platforms = parse_uv_platforms(content) + self.assertEqual(platforms, expected_platforms) + + +class TestParseUVVersions(unittest.TestCase): + """Unit tests for the parse_uv_versions function.""" + + def test_parse_uv_versions_success(self): + """Tests successful parsing of UV_TOOL_VERSIONS section.""" + content = """ +UV_TOOL_VERSIONS = { + "0.1.1": { + "x86_64-unknown-linux-gnu": struct(sha256 = "sha256_1"), + "aarch64-apple-darwin": struct(sha256 = "sha256_2"), + }, + "0.1.0": { + "x86_64-unknown-linux-gnu": struct(sha256 = "sha256_3"), + }, +} +""" + expected_versions = { + "0.1.1": { + "x86_64-unknown-linux-gnu": {"sha256": "sha256_1"}, + "aarch64-apple-darwin": {"sha256": "sha256_2"}, + }, + "0.1.0": {"x86_64-unknown-linux-gnu": {"sha256": "sha256_3"}}, + } + versions = parse_uv_versions(content) + self.assertEqual(versions, expected_versions) + + def test_parse_uv_versions_empty(self): + """Tests parsing when UV_TOOL_VERSIONS section is empty.""" + content = "UV_TOOL_VERSIONS = {}" + versions = parse_uv_versions(content) + self.assertEqual(versions, {}) + + def test_parse_uv_versions_missing(self): + """Tests parsing when UV_TOOL_VERSIONS section is missing.""" + content = "some other content" + versions = parse_uv_versions(content) + self.assertEqual(versions, {}) + + def test_parse_uv_versions_invalid_json(self): + """Tests handling of invalid JSON in UV_TOOL_VERSIONS section.""" + content = """ +UV_TOOL_VERSIONS = { + "0.1.1": { + "x86_64-unknown-linux-gnu": struct(sha256 = "sha256_1"), + "aarch64-apple-darwin": struct(sha256 = "sha256_2"), + }, + "0.1.0": { + "x86_64-unknown-linux-gnu": struct(sha256 = "sha256_3"), +} +""" + versions = parse_uv_versions(content) + self.assertEqual(versions, {}) + + +class TestUpdateVersionsContent(unittest.TestCase): + """Unit tests for the update_versions_content function.""" + + @patch("update_uv_versions.parse_uv_platforms") + @patch("update_uv_versions.parse_uv_versions") + def test_update_versions_content_success( + self, mock_parse_versions, mock_parse_platforms + ): + """Tests successful update of versions.bzl content.""" + release_info = { + "0.1.1": { + "uv-0.1.1-x86_64-unknown-linux-gnu.tar.gz": { + "sha256": "sha256_1", + }, + "uv-0.1.1-aarch64-apple-darwin.tar.gz": { + "sha256": "sha256_2", + }, + }, + "0.1.0": { + "uv-0.1.0-x86_64-unknown-linux-gnu.tar.gz": { + "sha256": "sha256_3", + }, + }, + } + initial_content = """ +UV_PLATFORMS = { + "x86_64-unknown-linux-gnu": struct( + default_repo_name = "x86_64-unknown-linux-gnu", + compatible_with = [ + "@platforms//os:linux", + "@platforms//cpu:x86_64" + ], + ), + "aarch64-apple-darwin": struct( + default_repo_name = "aarch64-apple-darwin", + compatible_with = [ + "@platforms//os:osx", + "@platforms//cpu:arm64" + ], + ), +} +UV_TOOL_VERSIONS = {} +""" + mock_parse_platforms.return_value = { + "x86_64-unknown-linux-gnu": { + "default_repo_name": "x86_64-unknown-linux-gnu", + "compatible_with": ["@platforms//os:linux", "@platforms//cpu:x86_64"], + }, + "aarch64-apple-darwin": { + "default_repo_name": "aarch64-apple-darwin", + "compatible_with": ["@platforms//os:osx", "@platforms//cpu:arm64"], + }, + } + mock_parse_versions.return_value = {} + updated_content = update_versions_content(release_info, initial_content) + self.assertIn('"0.1.1": {', updated_content) + self.assertIn('sha256 = "sha256_1"', updated_content) + self.assertIn('sha256 = "sha256_2"', updated_content) + self.assertIn('"0.1.0": {', updated_content) + self.assertIn('sha256 = "sha256_3"', updated_content) + + @patch("update_uv_versions.parse_uv_platforms") + @patch("update_uv_versions.parse_uv_versions") + def test_update_versions_content_no_release_info( + self, mock_parse_versions, mock_parse_platforms + ): + """Tests handling of no release information.""" + mock_parse_platforms.return_value = {} + mock_parse_versions.return_value = {} + updated_content = update_versions_content(None) + self.assertIsNone(updated_content) + + @patch("update_uv_versions.parse_uv_platforms") + @patch("update_uv_versions.parse_uv_versions") + def test_update_versions_content_empty_initial_content( + self, mock_parse_versions, mock_parse_platforms + ): + """Tests handling of empty initial content.""" + release_info = {"0.1.1": {"a": {"sha256": "x"}}} + mock_parse_platforms.return_value = {} + mock_parse_versions.return_value = {} + updated_content = update_versions_content(release_info, "") + self.assertEqual(updated_content, "") + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/update_uv_versions.py b/tools/update_uv_versions.py new file mode 100644 index 0000000000..b25c17e240 --- /dev/null +++ b/tools/update_uv_versions.py @@ -0,0 +1,325 @@ +#!/bin/env python3 +""" +This module provides functionality to fetch release information for the uv +project from GitHub and update the `versions.bzl` file accordingly. + +The module defines several functions: + +- `clean_trailing_commas`: Removes trailing commas from JSON-like strings to + prepare them for JSON parsing. +- `get_uv_releases_info`: Fetches release information (tag names, asset URLs, + and SHA256 hashes) from the Astral uv GitHub repository. It filters out + source code archives and allows limiting the number of releases fetched. It + also allows specifying a list of platforms to filter assets by. +- `parse_uv_platforms`: Parses the `UV_PLATFORMS` section from `versions.bzl` + content to extract platform definitions. +- `parse_uv_versions`: Parses the `UV_TOOL_VERSIONS` section from + `versions.bzl` content to extract existing version information. +- `update_versions_content`: Updates the content of `versions.bzl` by merging + new release information into the existing `UV_TOOL_VERSIONS` section. + +The module also includes a `main` function that orchestrates the entire update +process, reading from and writing to the `versions.bzl` file. + +Error handling is implemented throughout the module to manage network requests, +JSON parsing, and file operations. Logging is used for debugging and +informational purposes. + +This module is intended to be used as a utility for automatically updating the +`versions.bzl` file with the latest uv releases. +""" + + +import json +import logging +import re + +import requests + +logger = logging.getLogger(__name__) +TIMEOUT: int = 60 + +def clean_trailing_commas(string) -> str: + """Removes trailing commas from JSON-like strings. + + This helper function prepares JSON-like strings for parsing by removing + trailing commas that might cause errors. + + Args: + string: The input string. + + Returns: + The string with trailing commas removed. + """ + string = re.sub(",[ \t\r\n]*}", "}", string) + string = re.sub(",[ \t\r\n]*]", "]", string) + + return string + +def get_uv_releases_info( + limit: int | None = 10, + platforms: list[str] | None = None, +) -> dict[str, str | dict[str, str] | None]: + """Fetches release information for the uv project from GitHub. + + This function retrieves the latest releases from the Astral uv GitHub + repository, extracting information about each release's assets (binaries) + and their corresponding SHA256 hashes. It filters out source code + archives and provides details for a limited number of releases. It also + allows filtering assets by a list of supported platforms. + + Args: + limit: The maximum number of releases to fetch. Defaults to 10. Set to + None to fetch all releases. + platforms: An optional list of supported platforms to filter assets by. + + Returns: + A dictionary where keys are release tag names (e.g., "0.1.1") and + values are dictionaries of assets. Each asset dictionary contains + "name", "url", and "sha256". Returns None if there's an error. + + Raises: + requests.exceptions.RequestException: For HTTP request errors. + json.JSONDecodeError: For JSON decoding errors. + Exception: For other unexpected errors. + """ + try: + repo_url = "https://api.github.com/repos/astral-sh/uv/releases" + response = requests.get(repo_url, timeout=TIMEOUT) + response.raise_for_status() # Raise an exception for bad status codes + + release_data = response.json() + releases: dict[str, dict[str, str]] = {} + + count = 0 + for release in release_data: + if limit is not None and count >= limit: + break + + release_tag = release["tag_name"] + logger.debug("Release tag: %s", release_tag) + assets = {} + + for asset in release["assets"]: + if ( + not any( + [ + asset["name"].endswith(".zip"), + asset["name"].endswith(".tar.gz"), + ] + ) + or asset["name"] == "source.tar.gz" + ): + continue + + # If none of the platforms match this asset, skip + if platforms is not None and not any( + [platform in asset["name"] for platform in platforms] + ): + continue + + try: + logger.debug("Asset: %s", asset["name"]) + asset_name = asset["name"] + asset_url = asset["browser_download_url"] + + # Fetch the SHA256 hash for the asset + sha256_url = f"{asset_url}.sha256" + sha256_response = requests.get(sha256_url, timeout=TIMEOUT) + sha256_response.raise_for_status() + sha256_hash = sha256_response.text.strip().split()[0] + + assets[asset_name] = { + "name": asset_name, + "url": asset_url, + "sha256": sha256_hash, + } + except requests.exceptions.RequestException as e: + logger.warning( + "Error fetching SHA256 hash for asset '%s': %s", + asset_name, + e, + ) + continue + + releases[release_tag] = assets + count += 1 + + return releases + + except requests.exceptions.RequestException as e: + logging.error("Error fetching release information: %s", e) + return None + except json.JSONDecodeError as e: + logging.error("Error decoding JSON response: %s", e) + return None + except Exception as e: # pylint: disable=broad-exception-caught + logging.error("An unexpected error occurred: %s", e) + return None + +def parse_uv_platforms(content: str) -> dict[str, dict[str, list[str]]]: + """Parses the UV_PLATFORMS section from versions.bzl content. + + Args: + content: The content of the versions.bzl file. + + Returns: + A dictionary representing the UV_PLATFORMS section. Keys are platform + names (e.g., "x86_64-unknown-linux-gnu"), and values are dictionaries + with "default_repo_name" and "compatible_with" (list of strings). + Returns an empty dictionary if the section is not found or is empty. + Handles potential errors gracefully. + """ + match = re.search(r"UV_PLATFORMS = {(.*?)}", content, re.DOTALL) + platforms = {} + if match: + platforms_str = match.group(1).strip() + if platforms_str: + try: + # Attempt to parse directly as JSON if it's already in a close-to-JSON format + platform_str = clean_trailing_commas( + "{" + + ( + platforms_str.replace("struct(", "{") + .replace(")", "}") + .replace("=", ":") + .replace("compatible_with", '"compatible_with"') + .replace("default_repo_name", '"default_repo_name"') + ) + + "}" + ) + platforms = json.loads(platform_str) + except (json.JSONDecodeError, Exception) as e: # pylint: disable=broad-exception-caught + logger.error("Error parsing UV_PLATFORMS section: %s", e) + print(platform_str) + + return platforms + + +def parse_uv_versions(content: str) -> dict[str, dict[str, str]]: + """Parses the UV_TOOL_VERSIONS section from versions.bzl content. + + Args: + content: The content of the versions.bzl file. + + Returns: + A dictionary representing the UV_TOOL_VERSIONS section. The keys are + version tags, and the values are dictionaries mapping platform names + to sha256 hashes. Returns an empty dictionary if the section is not + found or is empty. + """ + match = re.search(r"UV_TOOL_VERSIONS = {(.*?)}$", content, re.DOTALL) + versions = {} + if match: + versions_str = match.group(1).strip() + if versions_str: + try: + versions_str = clean_trailing_commas( + "{" + + versions_str.replace("struct(", "{") + .replace(")", "}") + .replace("sha256 = ", '"sha256": ') + + "}" + ) + versions = json.loads(versions_str) + except json.JSONDecodeError as e: + logger.error("Error decoding JSON in UV_TOOL_VERSIONS section: %s", e) + + return versions + + +def update_versions_content( + release_info: dict[str, dict[str, str]] | None, + content: str | None = None, +) -> str | None: + """Updates the versions.bzl file content with new release information. + + This function merges new release information into the existing + `UV_TOOL_VERSIONS` section of the `versions.bzl` content. + + Args: + release_info: A dictionary containing release information (as returned + by `get_uv_releases_info`). + content: The initial content of the versions.bzl file. + + Returns: + The updated content of the versions.bzl file, or None if there's an + error or no release information is provided. + """ + if release_info is None: + logger.warning("No release information to update.") + return content + + try: + platforms = parse_uv_platforms(content) + existing_versions = parse_uv_versions(content) + if not platforms: + logger.error("No platforms found in versions.bzl.") + return content + + versions = {} + for version in release_info.keys(): + assets = {} + version_assets = release_info[version] + for asset_name, asset_data in version_assets.items(): + matched_platform = None + logger.debug("Asset name: %s", asset_name) + for platform in platforms: + logger.debug("Platform: %s", platform) + if platform in asset_name: + logger.debug("matched platform: %s", platform) + matched_platform = platform + break + + if matched_platform: + logger.debug("asset_data: %s", asset_data) + assets[matched_platform] = { + "sha256": asset_data["sha256"], + } + + versions[version] = assets + + versions = {**existing_versions, **versions} + # Sort versions from latest to oldest before rendering + versions = dict(sorted(versions.items(), reverse=True)) + + logging.debug("Updated Versions: %s", versions) + + binaries_str = "UV_TOOL_VERSIONS = {\n" + for version_tag, version_data in versions.items(): + binaries_str += f' "{version_tag}": {{\n' + for platform_name, platform_data in version_data.items(): + binaries_str += f' "{platform_name}": struct(\n' + binaries_str += f' sha256 = "{platform_data["sha256"]}",\n' + binaries_str += " ),\n" + binaries_str += " },\n" + binaries_str += "}\n" + + # Replace the old UV_BINARIES with the new one + content = re.sub( + r"UV_TOOL_VERSIONS = {(.*?)}$", binaries_str, content, 1, flags=re.DOTALL + ) + + return content + except Exception as e: # pylint: disable=broad-exception-caught + logger.error("An error occurred while updating the content: %s", e) + + +def main(): + """Main entry point for the script.""" + logging.basicConfig(level=logging.INFO) + + version_file = "python/uv/private/versions.bzl" + with open(version_file, "r", encoding="utf-8") as f: + content = f.read() + + platforms = parse_uv_platforms(content) + info = get_uv_releases_info(limit=10, platforms=platforms.keys()) + content = update_versions_content(info, content) + + with open(version_file, "w", encoding="utf-8") as f: + f.write(content) + + +if __name__ == "__main__": + main()