From be3c18a991ad1c0ce91894885c321de326da7555 Mon Sep 17 00:00:00 2001 From: david-hummingbot Date: Fri, 18 Sep 2026 11:23:06 +0800 Subject: [PATCH 01/43] Bound requires-python below 3.14 and pin the interpreter The manifest declared a floor and no ceiling, so uv picked the newest interpreter on the host. The dependency tree does have a ceiling -- pandas-ta pulls numba, which pulls llvmlite -- but it is only discovered after resolution, inside a transitive build, in a message naming neither Condor nor the real constraint. On a host whose newest Python is 3.14 (a stock Anaconda is enough) `make install` simply fails. Measured in the install sandbox: `uv sync --python 3.13` completes and gives Python 3.13.15, while `--python 3.14` dies building llvmlite with `TypeError: Popen.__init__() got an unexpected keyword argument 'dry_run'`. So <3.14 is the honest ceiling -- 3.13 is genuinely supported -- and stating it turns an unreadable transitive build error into an accurate resolver message. .python-version makes the choice explicit for a fresh clone rather than leaving it to whatever happens to be first on PATH. --- .python-version | 1 + pyproject.toml | 2 +- uv.lock | 884 ++---------------------------------------------- 3 files changed, 24 insertions(+), 863 deletions(-) create mode 100644 .python-version diff --git a/.python-version b/.python-version new file mode 100644 index 000000000..e4fba2183 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.12 diff --git a/pyproject.toml b/pyproject.toml index e3577e49a..8dfd02ef0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -2,7 +2,7 @@ name = "condor" version = "0.1.0" description = "Telegram bot for Hummingbot trading via Backend API" -requires-python = ">=3.12" +requires-python = ">=3.12,<3.14" dependencies = [ "python-telegram-bot[job-queue]", "hummingbot-api-client==1.5.9", diff --git a/uv.lock b/uv.lock index e41d6e639..3ffc340da 100644 --- a/uv.lock +++ b/uv.lock @@ -1,16 +1,10 @@ version = 1 revision = 3 -requires-python = ">=3.12" +requires-python = ">=3.12, <3.14" resolution-markers = [ - "python_full_version >= '3.14' and sys_platform == 'win32'", - "python_full_version >= '3.14' and sys_platform == 'emscripten'", - "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", - "python_full_version == '3.13.*' and sys_platform == 'win32'", - "python_full_version < '3.13' and sys_platform == 'win32'", - "python_full_version == '3.13.*' and sys_platform == 'emscripten'", - "python_full_version < '3.13' and sys_platform == 'emscripten'", - "python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", - "python_full_version < '3.13' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "sys_platform == 'win32'", + "sys_platform == 'emscripten'", + "sys_platform != 'emscripten' and sys_platform != 'win32'", ] [[package]] @@ -95,40 +89,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e8/35/4a3daeb8b9fab49240d21c04d50732313295e4bd813a465d840236dd0ce1/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8057c98e0c8472d8846b9c79f56766bcc57e3e8ac7bfd510482332366c56c591", size = 1721120, upload-time = "2026-01-03T17:31:12.575Z" }, { url = "https://files.pythonhosted.org/packages/bc/9f/d643bb3c5fb99547323e635e251c609fbbc660d983144cfebec529e09264/aiohttp-3.13.3-cp313-cp313-win32.whl", hash = "sha256:1449ceddcdbcf2e0446957863af03ebaaa03f94c090f945411b61269e2cb5daf", size = 427383, upload-time = "2026-01-03T17:31:14.382Z" }, { url = "https://files.pythonhosted.org/packages/4e/f1/ab0395f8a79933577cdd996dd2f9aa6014af9535f65dddcf88204682fe62/aiohttp-3.13.3-cp313-cp313-win_amd64.whl", hash = "sha256:693781c45a4033d31d4187d2436f5ac701e7bbfe5df40d917736108c1cc7436e", size = 453899, upload-time = "2026-01-03T17:31:15.958Z" }, - { url = "https://files.pythonhosted.org/packages/99/36/5b6514a9f5d66f4e2597e40dea2e3db271e023eb7a5d22defe96ba560996/aiohttp-3.13.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:ea37047c6b367fd4bd632bff8077449b8fa034b69e812a18e0132a00fae6e808", size = 737238, upload-time = "2026-01-03T17:31:17.909Z" }, - { url = "https://files.pythonhosted.org/packages/f7/49/459327f0d5bcd8c6c9ca69e60fdeebc3622861e696490d8674a6d0cb90a6/aiohttp-3.13.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:6fc0e2337d1a4c3e6acafda6a78a39d4c14caea625124817420abceed36e2415", size = 492292, upload-time = "2026-01-03T17:31:19.919Z" }, - { url = "https://files.pythonhosted.org/packages/e8/0b/b97660c5fd05d3495b4eb27f2d0ef18dc1dc4eff7511a9bf371397ff0264/aiohttp-3.13.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c685f2d80bb67ca8c3837823ad76196b3694b0159d232206d1e461d3d434666f", size = 493021, upload-time = "2026-01-03T17:31:21.636Z" }, - { url = "https://files.pythonhosted.org/packages/54/d4/438efabdf74e30aeceb890c3290bbaa449780583b1270b00661126b8aae4/aiohttp-3.13.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:48e377758516d262bde50c2584fc6c578af272559c409eecbdd2bae1601184d6", size = 1717263, upload-time = "2026-01-03T17:31:23.296Z" }, - { url = "https://files.pythonhosted.org/packages/71/f2/7bddc7fd612367d1459c5bcf598a9e8f7092d6580d98de0e057eb42697ad/aiohttp-3.13.3-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:34749271508078b261c4abb1767d42b8d0c0cc9449c73a4df494777dc55f0687", size = 1669107, upload-time = "2026-01-03T17:31:25.334Z" }, - { url = "https://files.pythonhosted.org/packages/00/5a/1aeaecca40e22560f97610a329e0e5efef5e0b5afdf9f857f0d93839ab2e/aiohttp-3.13.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:82611aeec80eb144416956ec85b6ca45a64d76429c1ed46ae1b5f86c6e0c9a26", size = 1760196, upload-time = "2026-01-03T17:31:27.394Z" }, - { url = "https://files.pythonhosted.org/packages/f8/f8/0ff6992bea7bd560fc510ea1c815f87eedd745fe035589c71ce05612a19a/aiohttp-3.13.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2fff83cfc93f18f215896e3a190e8e5cb413ce01553901aca925176e7568963a", size = 1843591, upload-time = "2026-01-03T17:31:29.238Z" }, - { url = "https://files.pythonhosted.org/packages/e3/d1/e30e537a15f53485b61f5be525f2157da719819e8377298502aebac45536/aiohttp-3.13.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bbe7d4cecacb439e2e2a8a1a7b935c25b812af7a5fd26503a66dadf428e79ec1", size = 1720277, upload-time = "2026-01-03T17:31:31.053Z" }, - { url = "https://files.pythonhosted.org/packages/84/45/23f4c451d8192f553d38d838831ebbc156907ea6e05557f39563101b7717/aiohttp-3.13.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b928f30fe49574253644b1ca44b1b8adbd903aa0da4b9054a6c20fc7f4092a25", size = 1548575, upload-time = "2026-01-03T17:31:32.87Z" }, - { url = "https://files.pythonhosted.org/packages/6a/ed/0a42b127a43712eda7807e7892c083eadfaf8429ca8fb619662a530a3aab/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7b5e8fe4de30df199155baaf64f2fcd604f4c678ed20910db8e2c66dc4b11603", size = 1679455, upload-time = "2026-01-03T17:31:34.76Z" }, - { url = "https://files.pythonhosted.org/packages/2e/b5/c05f0c2b4b4fe2c9d55e73b6d3ed4fd6c9dc2684b1d81cbdf77e7fad9adb/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:8542f41a62bcc58fc7f11cf7c90e0ec324ce44950003feb70640fc2a9092c32a", size = 1687417, upload-time = "2026-01-03T17:31:36.699Z" }, - { url = "https://files.pythonhosted.org/packages/c9/6b/915bc5dad66aef602b9e459b5a973529304d4e89ca86999d9d75d80cbd0b/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:5e1d8c8b8f1d91cd08d8f4a3c2b067bfca6ec043d3ff36de0f3a715feeedf926", size = 1729968, upload-time = "2026-01-03T17:31:38.622Z" }, - { url = "https://files.pythonhosted.org/packages/11/3b/e84581290a9520024a08640b63d07673057aec5ca548177a82026187ba73/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:90455115e5da1c3c51ab619ac57f877da8fd6d73c05aacd125c5ae9819582aba", size = 1545690, upload-time = "2026-01-03T17:31:40.57Z" }, - { url = "https://files.pythonhosted.org/packages/f5/04/0c3655a566c43fd647c81b895dfe361b9f9ad6d58c19309d45cff52d6c3b/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:042e9e0bcb5fba81886c8b4fbb9a09d6b8a00245fd8d88e4d989c1f96c74164c", size = 1746390, upload-time = "2026-01-03T17:31:42.857Z" }, - { url = "https://files.pythonhosted.org/packages/1f/53/71165b26978f719c3419381514c9690bd5980e764a09440a10bb816ea4ab/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2eb752b102b12a76ca02dff751a801f028b4ffbbc478840b473597fc91a9ed43", size = 1702188, upload-time = "2026-01-03T17:31:44.984Z" }, - { url = "https://files.pythonhosted.org/packages/29/a7/cbe6c9e8e136314fa1980da388a59d2f35f35395948a08b6747baebb6aa6/aiohttp-3.13.3-cp314-cp314-win32.whl", hash = "sha256:b556c85915d8efaed322bf1bdae9486aa0f3f764195a0fb6ee962e5c71ef5ce1", size = 433126, upload-time = "2026-01-03T17:31:47.463Z" }, - { url = "https://files.pythonhosted.org/packages/de/56/982704adea7d3b16614fc5936014e9af85c0e34b58f9046655817f04306e/aiohttp-3.13.3-cp314-cp314-win_amd64.whl", hash = "sha256:9bf9f7a65e7aa20dd764151fb3d616c81088f91f8df39c3893a536e279b4b984", size = 459128, upload-time = "2026-01-03T17:31:49.2Z" }, - { url = "https://files.pythonhosted.org/packages/6c/2a/3c79b638a9c3d4658d345339d22070241ea341ed4e07b5ac60fb0f418003/aiohttp-3.13.3-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:05861afbbec40650d8a07ea324367cb93e9e8cc7762e04dd4405df99fa65159c", size = 769512, upload-time = "2026-01-03T17:31:51.134Z" }, - { url = "https://files.pythonhosted.org/packages/29/b9/3e5014d46c0ab0db8707e0ac2711ed28c4da0218c358a4e7c17bae0d8722/aiohttp-3.13.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2fc82186fadc4a8316768d61f3722c230e2c1dcab4200d52d2ebdf2482e47592", size = 506444, upload-time = "2026-01-03T17:31:52.85Z" }, - { url = "https://files.pythonhosted.org/packages/90/03/c1d4ef9a054e151cd7839cdc497f2638f00b93cbe8043983986630d7a80c/aiohttp-3.13.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0add0900ff220d1d5c5ebbf99ed88b0c1bbf87aa7e4262300ed1376a6b13414f", size = 510798, upload-time = "2026-01-03T17:31:54.91Z" }, - { url = "https://files.pythonhosted.org/packages/ea/76/8c1e5abbfe8e127c893fe7ead569148a4d5a799f7cf958d8c09f3eedf097/aiohttp-3.13.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:568f416a4072fbfae453dcf9a99194bbb8bdeab718e08ee13dfa2ba0e4bebf29", size = 1868835, upload-time = "2026-01-03T17:31:56.733Z" }, - { url = "https://files.pythonhosted.org/packages/8e/ac/984c5a6f74c363b01ff97adc96a3976d9c98940b8969a1881575b279ac5d/aiohttp-3.13.3-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:add1da70de90a2569c5e15249ff76a631ccacfe198375eead4aadf3b8dc849dc", size = 1720486, upload-time = "2026-01-03T17:31:58.65Z" }, - { url = "https://files.pythonhosted.org/packages/b2/9a/b7039c5f099c4eb632138728828b33428585031a1e658d693d41d07d89d1/aiohttp-3.13.3-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:10b47b7ba335d2e9b1239fa571131a87e2d8ec96b333e68b2a305e7a98b0bae2", size = 1847951, upload-time = "2026-01-03T17:32:00.989Z" }, - { url = "https://files.pythonhosted.org/packages/3c/02/3bec2b9a1ba3c19ff89a43a19324202b8eb187ca1e928d8bdac9bbdddebd/aiohttp-3.13.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3dd4dce1c718e38081c8f35f323209d4c1df7d4db4bab1b5c88a6b4d12b74587", size = 1941001, upload-time = "2026-01-03T17:32:03.122Z" }, - { url = "https://files.pythonhosted.org/packages/37/df/d879401cedeef27ac4717f6426c8c36c3091c6e9f08a9178cc87549c537f/aiohttp-3.13.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34bac00a67a812570d4a460447e1e9e06fae622946955f939051e7cc895cfab8", size = 1797246, upload-time = "2026-01-03T17:32:05.255Z" }, - { url = "https://files.pythonhosted.org/packages/8d/15/be122de1f67e6953add23335c8ece6d314ab67c8bebb3f181063010795a7/aiohttp-3.13.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a19884d2ee70b06d9204b2727a7b9f983d0c684c650254679e716b0b77920632", size = 1627131, upload-time = "2026-01-03T17:32:07.607Z" }, - { url = "https://files.pythonhosted.org/packages/12/12/70eedcac9134cfa3219ab7af31ea56bc877395b1ac30d65b1bc4b27d0438/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5f8ca7f2bb6ba8348a3614c7918cc4bb73268c5ac2a207576b7afea19d3d9f64", size = 1795196, upload-time = "2026-01-03T17:32:09.59Z" }, - { url = "https://files.pythonhosted.org/packages/32/11/b30e1b1cd1f3054af86ebe60df96989c6a414dd87e27ad16950eee420bea/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:b0d95340658b9d2f11d9697f59b3814a9d3bb4b7a7c20b131df4bcef464037c0", size = 1782841, upload-time = "2026-01-03T17:32:11.445Z" }, - { url = "https://files.pythonhosted.org/packages/88/0d/d98a9367b38912384a17e287850f5695c528cff0f14f791ce8ee2e4f7796/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:a1e53262fd202e4b40b70c3aff944a8155059beedc8a89bba9dc1f9ef06a1b56", size = 1795193, upload-time = "2026-01-03T17:32:13.705Z" }, - { url = "https://files.pythonhosted.org/packages/43/a5/a2dfd1f5ff5581632c7f6a30e1744deda03808974f94f6534241ef60c751/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:d60ac9663f44168038586cab2157e122e46bdef09e9368b37f2d82d354c23f72", size = 1621979, upload-time = "2026-01-03T17:32:15.965Z" }, - { url = "https://files.pythonhosted.org/packages/fa/f0/12973c382ae7c1cccbc4417e129c5bf54c374dfb85af70893646e1f0e749/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:90751b8eed69435bac9ff4e3d2f6b3af1f57e37ecb0fbeee59c0174c9e2d41df", size = 1822193, upload-time = "2026-01-03T17:32:18.219Z" }, - { url = "https://files.pythonhosted.org/packages/3c/5f/24155e30ba7f8c96918af1350eb0663e2430aad9e001c0489d89cd708ab1/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:fc353029f176fd2b3ec6cfc71be166aba1936fe5d73dd1992ce289ca6647a9aa", size = 1769801, upload-time = "2026-01-03T17:32:20.25Z" }, - { url = "https://files.pythonhosted.org/packages/eb/f8/7314031ff5c10e6ece114da79b338ec17eeff3a079e53151f7e9f43c4723/aiohttp-3.13.3-cp314-cp314t-win32.whl", hash = "sha256:2e41b18a58da1e474a057b3d35248d8320029f61d70a37629535b16a0c8f3767", size = 466523, upload-time = "2026-01-03T17:32:22.215Z" }, - { url = "https://files.pythonhosted.org/packages/b4/63/278a98c715ae467624eafe375542d8ba9b4383a016df8fdefe0ae28382a7/aiohttp-3.13.3-cp314-cp314t-win_amd64.whl", hash = "sha256:44531a36aa2264a1860089ffd4dce7baf875ee5a6079d5fb42e261c704ef7344", size = 499694, upload-time = "2026-01-03T17:32:24.546Z" }, ] [[package]] @@ -250,14 +210,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/90/ea/52b0fc6f69432c7bf3f5fbe6f707113650aa40a1a05b9096ffc2bba4f77d/av-17.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ffaf266a1a9c2148072de0a4b5ae98061465178d2cfaa69ee089761149342974", size = 37444817, upload-time = "2026-03-14T14:38:18.563Z" }, { url = "https://files.pythonhosted.org/packages/34/ad/d2172966282cb8f146c13b6be7416efefde74186460c5e1708ddfc13dba6/av-17.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:45a35a40b2875bf2f98de7c952d74d960f92f319734e6d28e03b4c62a49e6f49", size = 28888553, upload-time = "2026-03-14T14:38:21.223Z" }, { url = "https://files.pythonhosted.org/packages/b0/bb/c5a4c4172c514d631fb506e6366b503576b8c7f29809cf42aca73e28ff01/av-17.0.0-cp311-abi3-win_arm64.whl", hash = "sha256:3d32e9b5c5bbcb872a0b6917b352a1db8a42142237826c9b49a36d5dbd9e9c26", size = 21916910, upload-time = "2026-03-14T14:38:23.706Z" }, - { url = "https://files.pythonhosted.org/packages/7f/8e/c40ac08e63f79387c59f6ecc38f47d4c942b549130eee579ec1a91f6a291/av-17.0.0-cp314-cp314t-macosx_11_0_x86_64.whl", hash = "sha256:d13250fb4b4522e9a6bec32da082556d5f257110ea223758151375748d9bbe25", size = 23483029, upload-time = "2026-03-14T14:38:25.758Z" }, - { url = "https://files.pythonhosted.org/packages/a9/fb/b4419494bfc249163ec393c613966d66db7e95c76da3345711cd115a79df/av-17.0.0-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:dbb56aa3b7ae72451d1bf6e9d37c7d83d39b97af712f73583ff419fbf08fc237", size = 18920446, upload-time = "2026-03-14T14:38:27.905Z" }, - { url = "https://files.pythonhosted.org/packages/30/62/c2306d91602ddad2c56106f21dcb334fd51d5ea2e952f7fa025bb8aa39fc/av-17.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:a213ac9e83b7ab12c2e9f277a09cac8e9d85cf0883efdab7a87a60e2e4e48879", size = 37477266, upload-time = "2026-03-14T14:38:30.404Z" }, - { url = "https://files.pythonhosted.org/packages/28/cd/c8510a9607886785c0b3ca019d503e888c3757529be42a7287fe2bfa92d5/av-17.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:e15c88bb0921f9435bcc5a27a0863dba571a80ad5e1389c4fcf2073833bb4a74", size = 39572988, upload-time = "2026-03-14T14:38:32.984Z" }, - { url = "https://files.pythonhosted.org/packages/7d/2d/207d9361e25b5abec9be335bbab4df6b6b838e2214be4b374f4cfb285427/av-17.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:096cfd1e9fc896506726c7c42aaf9b370e78c2f257cde4d6ddb6c889bfcc49ec", size = 38399591, upload-time = "2026-03-14T14:38:35.465Z" }, - { url = "https://files.pythonhosted.org/packages/73/ca/307740c6aa2980966bf11383ffcb04bacc5b13f3d268ab4cfb274ad6f793/av-17.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:3649ab3d2c7f58049ded1a36e100c0d8fd529cf258f41dd88678ba824034d8c9", size = 40590681, upload-time = "2026-03-14T14:38:38.269Z" }, - { url = "https://files.pythonhosted.org/packages/35/f2/6fdb26d0651adf409864cb2a0d60da107e467d3d1aabc94b234ead54324a/av-17.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:e5002271ab2135b551d980c2db8f3299d452e3b9d3633f24f6bb57fffe91cd10", size = 29216337, upload-time = "2026-03-14T14:38:40.83Z" }, - { url = "https://files.pythonhosted.org/packages/41/0a/0896b829a39b5669a2d811e1a79598de661693685cd62b31f11d0c18e65b/av-17.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dba98603fc4665b4f750de86fbaf6c0cfaece970671a9b529e0e3d1711e8367e", size = 22071058, upload-time = "2026-03-14T14:38:43.663Z" }, ] [[package]] @@ -306,11 +258,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/da/7c/b05f22964316a52ab6b4265bcd52c0ad2c30d7ca6bd3d0637e438fc32d6e/black-26.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1de0f7d01cc894066a1153b738145b194414cc6eeaad8ef4397ac9abacf40f6b", size = 1775212, upload-time = "2026-01-18T04:59:42.545Z" }, { url = "https://files.pythonhosted.org/packages/a6/a3/e8d1526bea0446e040193185353920a9506eab60a7d8beb062029129c7d2/black-26.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:91a68ae46bf07868963671e4d05611b179c2313301bd756a89ad4e3b3db2325b", size = 1409953, upload-time = "2026-01-18T04:59:44.357Z" }, { url = "https://files.pythonhosted.org/packages/c7/5a/d62ebf4d8f5e3a1daa54adaab94c107b57be1b1a2f115a0249b41931e188/black-26.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:be5e2fe860b9bd9edbf676d5b60a9282994c03fbbd40fe8f5e75d194f96064ca", size = 1217707, upload-time = "2026-01-18T04:59:45.719Z" }, - { url = "https://files.pythonhosted.org/packages/6a/83/be35a175aacfce4b05584ac415fd317dd6c24e93a0af2dcedce0f686f5d8/black-26.1.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:9dc8c71656a79ca49b8d3e2ce8103210c9481c57798b48deeb3a8bb02db5f115", size = 1871864, upload-time = "2026-01-18T04:59:47.586Z" }, - { url = "https://files.pythonhosted.org/packages/a5/f5/d33696c099450b1274d925a42b7a030cd3ea1f56d72e5ca8bbed5f52759c/black-26.1.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:b22b3810451abe359a964cc88121d57f7bce482b53a066de0f1584988ca36e79", size = 1701009, upload-time = "2026-01-18T04:59:49.443Z" }, - { url = "https://files.pythonhosted.org/packages/1b/87/670dd888c537acb53a863bc15abbd85b22b429237d9de1b77c0ed6b79c42/black-26.1.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:53c62883b3f999f14e5d30b5a79bd437236658ad45b2f853906c7cbe79de00af", size = 1767806, upload-time = "2026-01-18T04:59:50.769Z" }, - { url = "https://files.pythonhosted.org/packages/fe/9c/cd3deb79bfec5bcf30f9d2100ffeec63eecce826eb63e3961708b9431ff1/black-26.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:f016baaadc423dc960cdddf9acae679e71ee02c4c341f78f3179d7e4819c095f", size = 1433217, upload-time = "2026-01-18T04:59:52.218Z" }, - { url = "https://files.pythonhosted.org/packages/4e/29/f3be41a1cf502a283506f40f5d27203249d181f7a1a2abce1c6ce188035a/black-26.1.0-cp314-cp314-win_arm64.whl", hash = "sha256:66912475200b67ef5a0ab665011964bf924745103f51977a78b4fb92a9fc1bf0", size = 1245773, upload-time = "2026-01-18T04:59:54.457Z" }, { url = "https://files.pythonhosted.org/packages/e4/3d/51bdb3ecbfadfaf825ec0c75e1de6077422b4afa2091c6c9ba34fbfc0c2d/black-26.1.0-py3-none-any.whl", hash = "sha256:1054e8e47ebd686e078c0bb0eaf31e6ce69c966058d122f2c0c950311f9f3ede", size = 204010, upload-time = "2026-01-18T04:50:09.978Z" }, ] @@ -386,10 +333,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a3/9f/f21af50e72117eb528c422d4276cbac11fb941b1b812b182e0a9c70d19c5/caio-0.9.25-cp313-cp313-manylinux2010_x86_64.manylinux2014_x86_64.manylinux_2_12_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0998210a4d5cd5cb565b32ccfe4e53d67303f868a76f212e002a8554692870e6", size = 81900, upload-time = "2025-12-26T15:22:21.919Z" }, { url = "https://files.pythonhosted.org/packages/9c/12/c39ae2a4037cb10ad5eb3578eb4d5f8c1a2575c62bba675f3406b7ef0824/caio-0.9.25-cp313-cp313-manylinux_2_34_aarch64.whl", hash = "sha256:1a177d4777141b96f175fe2c37a3d96dec7911ed9ad5f02bac38aaa1c936611f", size = 81523, upload-time = "2026-03-04T22:08:25.187Z" }, { url = "https://files.pythonhosted.org/packages/22/59/f8f2e950eb4f1a5a3883e198dca514b9d475415cb6cd7b78b9213a0dd45a/caio-0.9.25-cp313-cp313-manylinux_2_34_x86_64.whl", hash = "sha256:9ed3cfb28c0e99fec5e208c934e5c157d0866aa9c32aa4dc5e9b6034af6286b7", size = 80243, upload-time = "2026-03-04T22:08:26.449Z" }, - { url = "https://files.pythonhosted.org/packages/69/ca/a08fdc7efdcc24e6a6131a93c85be1f204d41c58f474c42b0670af8c016b/caio-0.9.25-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:fab6078b9348e883c80a5e14b382e6ad6aabbc4429ca034e76e730cf464269db", size = 36978, upload-time = "2025-12-26T15:21:41.055Z" }, - { url = "https://files.pythonhosted.org/packages/5e/6c/d4d24f65e690213c097174d26eda6831f45f4734d9d036d81790a27e7b78/caio-0.9.25-cp314-cp314-manylinux2010_x86_64.manylinux2014_x86_64.manylinux_2_12_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:44a6b58e52d488c75cfaa5ecaa404b2b41cc965e6c417e03251e868ecd5b6d77", size = 81832, upload-time = "2025-12-26T15:22:22.757Z" }, - { url = "https://files.pythonhosted.org/packages/87/a4/e534cf7d2d0e8d880e25dd61e8d921ffcfe15bd696734589826f5a2df727/caio-0.9.25-cp314-cp314-manylinux_2_34_aarch64.whl", hash = "sha256:628a630eb7fb22381dd8e3c8ab7f59e854b9c806639811fc3f4310c6bd711d79", size = 81565, upload-time = "2026-03-04T22:08:27.483Z" }, - { url = "https://files.pythonhosted.org/packages/3f/ed/bf81aeac1d290017e5e5ac3e880fd56ee15e50a6d0353986799d1bc5cfd5/caio-0.9.25-cp314-cp314-manylinux_2_34_x86_64.whl", hash = "sha256:0ba16aa605ccb174665357fc729cf500679c2d94d5f1458a6f0d5ca48f2060a7", size = 80071, upload-time = "2026-03-04T22:08:28.751Z" }, { url = "https://files.pythonhosted.org/packages/86/93/1f76c8d1bafe3b0614e06b2195784a3765bbf7b0a067661af9e2dd47fc33/caio-0.9.25-py3-none-any.whl", hash = "sha256:06c0bb02d6b929119b1cfbe1ca403c768b2013a369e2db46bfa2a5761cf82e40", size = 19087, upload-time = "2025-12-26T15:22:00.221Z" }, ] @@ -435,28 +378,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" }, { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" }, { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" }, - { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" }, - { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" }, - { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" }, - { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" }, - { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" }, - { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" }, - { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" }, - { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" }, - { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" }, - { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" }, - { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" }, - { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" }, - { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" }, - { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" }, - { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" }, - { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" }, - { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" }, - { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" }, - { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" }, - { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" }, - { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" }, - { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, ] [[package]] @@ -506,38 +427,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/73/55/c469897448a06e49f8fa03f6caae97074fde823f432a98f979cc42b90e69/charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e", size = 148085, upload-time = "2026-04-02T09:27:03.192Z" }, { url = "https://files.pythonhosted.org/packages/5d/78/1b74c5bbb3f99b77a1715c91b3e0b5bdb6fe302d95ace4f5b1bec37b0167/charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110", size = 158819, upload-time = "2026-04-02T09:27:04.454Z" }, { url = "https://files.pythonhosted.org/packages/68/86/46bd42279d323deb8687c4a5a811fd548cb7d1de10cf6535d099877a9a9f/charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b", size = 147915, upload-time = "2026-04-02T09:27:05.971Z" }, - { url = "https://files.pythonhosted.org/packages/97/c8/c67cb8c70e19ef1960b97b22ed2a1567711de46c4ddf19799923adc836c2/charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0", size = 309234, upload-time = "2026-04-02T09:27:07.194Z" }, - { url = "https://files.pythonhosted.org/packages/99/85/c091fdee33f20de70d6c8b522743b6f831a2f1cd3ff86de4c6a827c48a76/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a", size = 208042, upload-time = "2026-04-02T09:27:08.749Z" }, - { url = "https://files.pythonhosted.org/packages/87/1c/ab2ce611b984d2fd5d86a5a8a19c1ae26acac6bad967da4967562c75114d/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b", size = 228706, upload-time = "2026-04-02T09:27:09.951Z" }, - { url = "https://files.pythonhosted.org/packages/a8/29/2b1d2cb00bf085f59d29eb773ce58ec2d325430f8c216804a0a5cd83cbca/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41", size = 224727, upload-time = "2026-04-02T09:27:11.175Z" }, - { url = "https://files.pythonhosted.org/packages/47/5c/032c2d5a07fe4d4855fea851209cca2b6f03ebeb6d4e3afdb3358386a684/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e", size = 215882, upload-time = "2026-04-02T09:27:12.446Z" }, - { url = "https://files.pythonhosted.org/packages/2c/c2/356065d5a8b78ed04499cae5f339f091946a6a74f91e03476c33f0ab7100/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae", size = 200860, upload-time = "2026-04-02T09:27:13.721Z" }, - { url = "https://files.pythonhosted.org/packages/0c/cd/a32a84217ced5039f53b29f460962abb2d4420def55afabe45b1c3c7483d/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18", size = 211564, upload-time = "2026-04-02T09:27:15.272Z" }, - { url = "https://files.pythonhosted.org/packages/44/86/58e6f13ce26cc3b8f4a36b94a0f22ae2f00a72534520f4ae6857c4b81f89/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b", size = 211276, upload-time = "2026-04-02T09:27:16.834Z" }, - { url = "https://files.pythonhosted.org/packages/8f/fe/d17c32dc72e17e155e06883efa84514ca375f8a528ba2546bee73fc4df81/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356", size = 201238, upload-time = "2026-04-02T09:27:18.229Z" }, - { url = "https://files.pythonhosted.org/packages/6a/29/f33daa50b06525a237451cdb6c69da366c381a3dadcd833fa5676bc468b3/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab", size = 230189, upload-time = "2026-04-02T09:27:19.445Z" }, - { url = "https://files.pythonhosted.org/packages/b6/6e/52c84015394a6a0bdcd435210a7e944c5f94ea1055f5cc5d56c5fe368e7b/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46", size = 211352, upload-time = "2026-04-02T09:27:20.79Z" }, - { url = "https://files.pythonhosted.org/packages/8c/d7/4353be581b373033fb9198bf1da3cf8f09c1082561e8e922aa7b39bf9fe8/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44", size = 227024, upload-time = "2026-04-02T09:27:22.063Z" }, - { url = "https://files.pythonhosted.org/packages/30/45/99d18aa925bd1740098ccd3060e238e21115fffbfdcb8f3ece837d0ace6c/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72", size = 217869, upload-time = "2026-04-02T09:27:23.486Z" }, - { url = "https://files.pythonhosted.org/packages/5c/05/5ee478aa53f4bb7996482153d4bfe1b89e0f087f0ab6b294fcf92d595873/charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10", size = 148541, upload-time = "2026-04-02T09:27:25.146Z" }, - { url = "https://files.pythonhosted.org/packages/48/77/72dcb0921b2ce86420b2d79d454c7022bf5be40202a2a07906b9f2a35c97/charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f", size = 159634, upload-time = "2026-04-02T09:27:26.642Z" }, - { url = "https://files.pythonhosted.org/packages/c6/a3/c2369911cd72f02386e4e340770f6e158c7980267da16af8f668217abaa0/charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246", size = 148384, upload-time = "2026-04-02T09:27:28.271Z" }, - { url = "https://files.pythonhosted.org/packages/94/09/7e8a7f73d24dba1f0035fbbf014d2c36828fc1bf9c88f84093e57d315935/charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24", size = 330133, upload-time = "2026-04-02T09:27:29.474Z" }, - { url = "https://files.pythonhosted.org/packages/8d/da/96975ddb11f8e977f706f45cddd8540fd8242f71ecdb5d18a80723dcf62c/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79", size = 216257, upload-time = "2026-04-02T09:27:30.793Z" }, - { url = "https://files.pythonhosted.org/packages/e5/e8/1d63bf8ef2d388e95c64b2098f45f84758f6d102a087552da1485912637b/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960", size = 234851, upload-time = "2026-04-02T09:27:32.44Z" }, - { url = "https://files.pythonhosted.org/packages/9b/40/e5ff04233e70da2681fa43969ad6f66ca5611d7e669be0246c4c7aaf6dc8/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4", size = 233393, upload-time = "2026-04-02T09:27:34.03Z" }, - { url = "https://files.pythonhosted.org/packages/be/c1/06c6c49d5a5450f76899992f1ee40b41d076aee9279b49cf9974d2f313d5/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e", size = 223251, upload-time = "2026-04-02T09:27:35.369Z" }, - { url = "https://files.pythonhosted.org/packages/2b/9f/f2ff16fb050946169e3e1f82134d107e5d4ae72647ec8a1b1446c148480f/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1", size = 206609, upload-time = "2026-04-02T09:27:36.661Z" }, - { url = "https://files.pythonhosted.org/packages/69/d5/a527c0cd8d64d2eab7459784fb4169a0ac76e5a6fc5237337982fd61347e/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44", size = 220014, upload-time = "2026-04-02T09:27:38.019Z" }, - { url = "https://files.pythonhosted.org/packages/7e/80/8a7b8104a3e203074dc9aa2c613d4b726c0e136bad1cc734594b02867972/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e", size = 218979, upload-time = "2026-04-02T09:27:39.37Z" }, - { url = "https://files.pythonhosted.org/packages/02/9a/b759b503d507f375b2b5c153e4d2ee0a75aa215b7f2489cf314f4541f2c0/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3", size = 209238, upload-time = "2026-04-02T09:27:40.722Z" }, - { url = "https://files.pythonhosted.org/packages/c2/4e/0f3f5d47b86bdb79256e7290b26ac847a2832d9a4033f7eb2cd4bcf4bb5b/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0", size = 236110, upload-time = "2026-04-02T09:27:42.33Z" }, - { url = "https://files.pythonhosted.org/packages/96/23/bce28734eb3ed2c91dcf93abeb8a5cf393a7b2749725030bb630e554fdd8/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e", size = 219824, upload-time = "2026-04-02T09:27:43.924Z" }, - { url = "https://files.pythonhosted.org/packages/2c/6f/6e897c6984cc4d41af319b077f2f600fc8214eb2fe2d6bcb79141b882400/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb", size = 233103, upload-time = "2026-04-02T09:27:45.348Z" }, - { url = "https://files.pythonhosted.org/packages/76/22/ef7bd0fe480a0ae9b656189ec00744b60933f68b4f42a7bb06589f6f576a/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe", size = 225194, upload-time = "2026-04-02T09:27:46.706Z" }, - { url = "https://files.pythonhosted.org/packages/c5/a7/0e0ab3e0b5bc1219bd80a6a0d4d72ca74d9250cb2382b7c699c147e06017/charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0", size = 159827, upload-time = "2026-04-02T09:27:48.053Z" }, - { url = "https://files.pythonhosted.org/packages/7a/1d/29d32e0fb40864b1f878c7f5a0b343ae676c6e2b271a2d55cc3a152391da/charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c", size = 174168, upload-time = "2026-04-02T09:27:49.795Z" }, - { url = "https://files.pythonhosted.org/packages/de/32/d92444ad05c7a6e41fb2036749777c163baf7a0301a040cb672d6b2b1ae9/charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d", size = 153018, upload-time = "2026-04-02T09:27:51.116Z" }, { url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" }, ] @@ -571,14 +460,14 @@ name = "cohere" version = "6.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "fastavro", marker = "sys_platform != 'emscripten'" }, - { name = "httpx", marker = "sys_platform != 'emscripten'" }, - { name = "pydantic", marker = "sys_platform != 'emscripten'" }, - { name = "pydantic-core", marker = "sys_platform != 'emscripten'" }, - { name = "requests", marker = "sys_platform != 'emscripten'" }, - { name = "tokenizers", marker = "sys_platform != 'emscripten'" }, - { name = "types-requests", marker = "sys_platform != 'emscripten'" }, - { name = "typing-extensions", marker = "sys_platform != 'emscripten'" }, + { name = "fastavro" }, + { name = "httpx" }, + { name = "pydantic" }, + { name = "pydantic-core" }, + { name = "requests" }, + { name = "tokenizers" }, + { name = "types-requests" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/85/e2/d38ef3a50a0b2bc75868df26ad0590ace76669559bd13c0f0edf412ca4c6/cohere-6.0.0.tar.gz", hash = "sha256:d08319957bc6f61c3c07f129299773868c0c6796786c9bb940ac8dcfcebbb3d5", size = 194128, upload-time = "2026-04-08T22:32:52.005Z" } wheels = [ @@ -610,8 +499,7 @@ dependencies = [ { name = "markdown" }, { name = "matplotlib" }, { name = "mcp" }, - { name = "pandas", version = "2.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.14'" }, - { name = "pandas", version = "3.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.14'" }, + { name = "pandas" }, { name = "pandas-ta" }, { name = "plotly" }, { name = "pydantic-ai" }, @@ -714,28 +602,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b9/70/f308384a3ae9cd2209e0849f33c913f658d3326900d0ff5d378d6a1422d2/contourpy-1.3.3-cp313-cp313t-win32.whl", hash = "sha256:283edd842a01e3dcd435b1c5116798d661378d83d36d337b8dde1d16a5fc9ba3", size = 196157, upload-time = "2025-07-26T12:02:11.488Z" }, { url = "https://files.pythonhosted.org/packages/b2/dd/880f890a6663b84d9e34a6f88cded89d78f0091e0045a284427cb6b18521/contourpy-1.3.3-cp313-cp313t-win_amd64.whl", hash = "sha256:87acf5963fc2b34825e5b6b048f40e3635dd547f590b04d2ab317c2619ef7ae8", size = 240570, upload-time = "2025-07-26T12:02:12.754Z" }, { url = "https://files.pythonhosted.org/packages/80/99/2adc7d8ffead633234817ef8e9a87115c8a11927a94478f6bb3d3f4d4f7d/contourpy-1.3.3-cp313-cp313t-win_arm64.whl", hash = "sha256:3c30273eb2a55024ff31ba7d052dde990d7d8e5450f4bbb6e913558b3d6c2301", size = 199713, upload-time = "2025-07-26T12:02:14.4Z" }, - { url = "https://files.pythonhosted.org/packages/72/8b/4546f3ab60f78c514ffb7d01a0bd743f90de36f0019d1be84d0a708a580a/contourpy-1.3.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fde6c716d51c04b1c25d0b90364d0be954624a0ee9d60e23e850e8d48353d07a", size = 292189, upload-time = "2025-07-26T12:02:16.095Z" }, - { url = "https://files.pythonhosted.org/packages/fd/e1/3542a9cb596cadd76fcef413f19c79216e002623158befe6daa03dbfa88c/contourpy-1.3.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:cbedb772ed74ff5be440fa8eee9bd49f64f6e3fc09436d9c7d8f1c287b121d77", size = 273251, upload-time = "2025-07-26T12:02:17.524Z" }, - { url = "https://files.pythonhosted.org/packages/b1/71/f93e1e9471d189f79d0ce2497007731c1e6bf9ef6d1d61b911430c3db4e5/contourpy-1.3.3-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:22e9b1bd7a9b1d652cd77388465dc358dafcd2e217d35552424aa4f996f524f5", size = 335810, upload-time = "2025-07-26T12:02:18.9Z" }, - { url = "https://files.pythonhosted.org/packages/91/f9/e35f4c1c93f9275d4e38681a80506b5510e9327350c51f8d4a5a724d178c/contourpy-1.3.3-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a22738912262aa3e254e4f3cb079a95a67132fc5a063890e224393596902f5a4", size = 382871, upload-time = "2025-07-26T12:02:20.418Z" }, - { url = "https://files.pythonhosted.org/packages/b5/71/47b512f936f66a0a900d81c396a7e60d73419868fba959c61efed7a8ab46/contourpy-1.3.3-cp314-cp314-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:afe5a512f31ee6bd7d0dda52ec9864c984ca3d66664444f2d72e0dc4eb832e36", size = 386264, upload-time = "2025-07-26T12:02:21.916Z" }, - { url = "https://files.pythonhosted.org/packages/04/5f/9ff93450ba96b09c7c2b3f81c94de31c89f92292f1380261bd7195bea4ea/contourpy-1.3.3-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f64836de09927cba6f79dcd00fdd7d5329f3fccc633468507079c829ca4db4e3", size = 363819, upload-time = "2025-07-26T12:02:23.759Z" }, - { url = "https://files.pythonhosted.org/packages/3e/a6/0b185d4cc480ee494945cde102cb0149ae830b5fa17bf855b95f2e70ad13/contourpy-1.3.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:1fd43c3be4c8e5fd6e4f2baeae35ae18176cf2e5cced681cca908addf1cdd53b", size = 1333650, upload-time = "2025-07-26T12:02:26.181Z" }, - { url = "https://files.pythonhosted.org/packages/43/d7/afdc95580ca56f30fbcd3060250f66cedbde69b4547028863abd8aa3b47e/contourpy-1.3.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6afc576f7b33cf00996e5c1102dc2a8f7cc89e39c0b55df93a0b78c1bd992b36", size = 1404833, upload-time = "2025-07-26T12:02:28.782Z" }, - { url = "https://files.pythonhosted.org/packages/e2/e2/366af18a6d386f41132a48f033cbd2102e9b0cf6345d35ff0826cd984566/contourpy-1.3.3-cp314-cp314-win32.whl", hash = "sha256:66c8a43a4f7b8df8b71ee1840e4211a3c8d93b214b213f590e18a1beca458f7d", size = 189692, upload-time = "2025-07-26T12:02:30.128Z" }, - { url = "https://files.pythonhosted.org/packages/7d/c2/57f54b03d0f22d4044b8afb9ca0e184f8b1afd57b4f735c2fa70883dc601/contourpy-1.3.3-cp314-cp314-win_amd64.whl", hash = "sha256:cf9022ef053f2694e31d630feaacb21ea24224be1c3ad0520b13d844274614fd", size = 232424, upload-time = "2025-07-26T12:02:31.395Z" }, - { url = "https://files.pythonhosted.org/packages/18/79/a9416650df9b525737ab521aa181ccc42d56016d2123ddcb7b58e926a42c/contourpy-1.3.3-cp314-cp314-win_arm64.whl", hash = "sha256:95b181891b4c71de4bb404c6621e7e2390745f887f2a026b2d99e92c17892339", size = 198300, upload-time = "2025-07-26T12:02:32.956Z" }, - { url = "https://files.pythonhosted.org/packages/1f/42/38c159a7d0f2b7b9c04c64ab317042bb6952b713ba875c1681529a2932fe/contourpy-1.3.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:33c82d0138c0a062380332c861387650c82e4cf1747aaa6938b9b6516762e772", size = 306769, upload-time = "2025-07-26T12:02:34.2Z" }, - { url = "https://files.pythonhosted.org/packages/c3/6c/26a8205f24bca10974e77460de68d3d7c63e282e23782f1239f226fcae6f/contourpy-1.3.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ea37e7b45949df430fe649e5de8351c423430046a2af20b1c1961cae3afcda77", size = 287892, upload-time = "2025-07-26T12:02:35.807Z" }, - { url = "https://files.pythonhosted.org/packages/66/06/8a475c8ab718ebfd7925661747dbb3c3ee9c82ac834ccb3570be49d129f4/contourpy-1.3.3-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d304906ecc71672e9c89e87c4675dc5c2645e1f4269a5063b99b0bb29f232d13", size = 326748, upload-time = "2025-07-26T12:02:37.193Z" }, - { url = "https://files.pythonhosted.org/packages/b4/a3/c5ca9f010a44c223f098fccd8b158bb1cb287378a31ac141f04730dc49be/contourpy-1.3.3-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ca658cd1a680a5c9ea96dc61cdbae1e85c8f25849843aa799dfd3cb370ad4fbe", size = 375554, upload-time = "2025-07-26T12:02:38.894Z" }, - { url = "https://files.pythonhosted.org/packages/80/5b/68bd33ae63fac658a4145088c1e894405e07584a316738710b636c6d0333/contourpy-1.3.3-cp314-cp314t-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ab2fd90904c503739a75b7c8c5c01160130ba67944a7b77bbf36ef8054576e7f", size = 388118, upload-time = "2025-07-26T12:02:40.642Z" }, - { url = "https://files.pythonhosted.org/packages/40/52/4c285a6435940ae25d7410a6c36bda5145839bc3f0beb20c707cda18b9d2/contourpy-1.3.3-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b7301b89040075c30e5768810bc96a8e8d78085b47d8be6e4c3f5a0b4ed478a0", size = 352555, upload-time = "2025-07-26T12:02:42.25Z" }, - { url = "https://files.pythonhosted.org/packages/24/ee/3e81e1dd174f5c7fefe50e85d0892de05ca4e26ef1c9a59c2a57e43b865a/contourpy-1.3.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:2a2a8b627d5cc6b7c41a4beff6c5ad5eb848c88255fda4a8745f7e901b32d8e4", size = 1322295, upload-time = "2025-07-26T12:02:44.668Z" }, - { url = "https://files.pythonhosted.org/packages/3c/b2/6d913d4d04e14379de429057cd169e5e00f6c2af3bb13e1710bcbdb5da12/contourpy-1.3.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:fd6ec6be509c787f1caf6b247f0b1ca598bef13f4ddeaa126b7658215529ba0f", size = 1391027, upload-time = "2025-07-26T12:02:47.09Z" }, - { url = "https://files.pythonhosted.org/packages/93/8a/68a4ec5c55a2971213d29a9374913f7e9f18581945a7a31d1a39b5d2dfe5/contourpy-1.3.3-cp314-cp314t-win32.whl", hash = "sha256:e74a9a0f5e3fff48fb5a7f2fd2b9b70a3fe014a67522f79b7cca4c0c7e43c9ae", size = 202428, upload-time = "2025-07-26T12:02:48.691Z" }, - { url = "https://files.pythonhosted.org/packages/fa/96/fd9f641ffedc4fa3ace923af73b9d07e869496c9cc7a459103e6e978992f/contourpy-1.3.3-cp314-cp314t-win_amd64.whl", hash = "sha256:13b68d6a62db8eafaebb8039218921399baf6e47bf85006fd8529f2a08ef33fc", size = 250331, upload-time = "2025-07-26T12:02:50.137Z" }, - { url = "https://files.pythonhosted.org/packages/ae/8c/469afb6465b853afff216f9528ffda78a915ff880ed58813ba4faf4ba0b6/contourpy-1.3.3-cp314-cp314t-win_arm64.whl", hash = "sha256:b7448cb5a725bb1e35ce88771b86fba35ef418952474492cf7c764059933ff8b", size = 203831, upload-time = "2025-07-26T12:02:51.449Z" }, ] [[package]] @@ -760,19 +626,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/13/0e/b1f92e013228111413f2e6743948b80bc24dfd3c1b87ba98ceea16f5df89/cryptography-50.0.1-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:be224a65493ec5b74a158ff22a5522ce4a5ca1e543c647a3a4730d4a09e5f959", size = 4862596, upload-time = "2026-08-25T19:44:24.472Z" }, { url = "https://files.pythonhosted.org/packages/7e/22/c3654cccc856e9d682817b04ac3ee79731cb09ca6f95996a95c904de2883/cryptography-50.0.1-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9ebcdd5519be9b652a46f507817a74591774fc3d6923ac364e4dfa64e36b291b", size = 5014082, upload-time = "2026-08-25T19:44:26.709Z" }, { url = "https://files.pythonhosted.org/packages/42/8b/cb12b1b60c91b074ca6bf0fdd59aa8f10d8bc5f73af8faece86ef0421b37/cryptography-50.0.1-cp311-abi3-win_amd64.whl", hash = "sha256:aed8db4f6d71c51efb89530e12d9464e7bf2923d46c3205dc794a2a93f8c0648", size = 3842826, upload-time = "2026-08-25T19:44:28.784Z" }, - { url = "https://files.pythonhosted.org/packages/5b/f0/424cb557d99aa86ac55da5e2add02e2882e44047b6264f93ade1b975a993/cryptography-50.0.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:30a125032e5642a21ff816e021152bd4e7e94f03eff3f4b7fca41cd22bc3110f", size = 3973525, upload-time = "2026-08-25T19:44:30.7Z" }, - { url = "https://files.pythonhosted.org/packages/4d/72/3a2711d967977ab5fc80b782837c7e8d1ac7445e764c20c381a265c57ef3/cryptography-50.0.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a0b1a59e3a089064a0ec309e9428c8e3ae4e161419d20ac33600767e83fc658a", size = 4708817, upload-time = "2026-08-25T19:44:32.773Z" }, - { url = "https://files.pythonhosted.org/packages/b4/f2/bb1f56e10815b789df0b409a69fa4992ff3d3fef9c72747f4a6b26fed38e/cryptography-50.0.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8921d58f426793c5f1b47f0b59575780de9a095214958d0eb37d909593db8367", size = 4697300, upload-time = "2026-08-25T19:44:35.144Z" }, - { url = "https://files.pythonhosted.org/packages/08/bd/ed5396be499ffcf8807a585bfe38b71a1fbdd1c342b4f9b6d0ef5162a946/cryptography-50.0.1-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:a8f40ea47330e71b594a7e246898f93177c259490c63183dbaf9e571d71ed9a5", size = 4716039, upload-time = "2026-08-25T19:44:37.192Z" }, - { url = "https://files.pythonhosted.org/packages/f6/6e/1cf405c5c8e8df7545378048e954792f00b7f2367af8863ce8b8f3e10607/cryptography-50.0.1-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:a255449073358275b64b67d3f595f268bbef70e72b6edb65e0c70c735bf739c9", size = 5332388, upload-time = "2026-08-25T19:44:39.16Z" }, - { url = "https://files.pythonhosted.org/packages/47/92/b4317e8c32c4f47b062f5398bd79106b220a124546f42be83bf32b761e2a/cryptography-50.0.1-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:8df2de9102026855887e4587084f6eabd80ed0f345b8ad8a7ac27ab9bf4723e0", size = 4730293, upload-time = "2026-08-25T19:44:41.298Z" }, - { url = "https://files.pythonhosted.org/packages/39/0d/a1e7633e2c744d0f2983320a27e924ef2264c79c56e1a58d5fb0a1cfd413/cryptography-50.0.1-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:ac02b07824d4d1001bd4367599f839c19cb171924c796e52c23508ac14c2c0cc", size = 4346031, upload-time = "2026-08-25T19:44:43.245Z" }, - { url = "https://files.pythonhosted.org/packages/88/dd/b215616f9bab3fc18510c78a4e5c9f362d77838503c363dc747c7d4f5c6f/cryptography-50.0.1-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:cbf74a81765ee67413503ca6e26dcc4f6f5a519822436cc0a1b97aab6c1b8a17", size = 4715344, upload-time = "2026-08-25T19:44:45.291Z" }, - { url = "https://files.pythonhosted.org/packages/b1/1b/ec3ebd31741d0e963612c4fe43caa39341b9b1e031e469820e42e4c83918/cryptography-50.0.1-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:16c5ecd954b3330ebfb6605eca4fd952da8bef376551d5cc264534e3770a9ee6", size = 5287201, upload-time = "2026-08-25T19:44:47.297Z" }, - { url = "https://files.pythonhosted.org/packages/1a/01/0127d11a762b31a9ee0221894f540318761783f3fdc4bc5d057698caebd5/cryptography-50.0.1-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:79bf008d1f9af6071c797ad133e39915dfee7614f18f18f4db9072eb715064a3", size = 4730023, upload-time = "2026-08-25T19:44:49.435Z" }, - { url = "https://files.pythonhosted.org/packages/9e/b9/e7425ebfb599241a0c1d7000f1b466c3062da66c19d9525031315dff7213/cryptography-50.0.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:330fbb252391c596f1ae42c5754449dc924e6ad012dca8efe0d703f9f2d12ec6", size = 4847362, upload-time = "2026-08-25T19:44:51.94Z" }, - { url = "https://files.pythonhosted.org/packages/2d/fd/60d0ddf4defa12e482c9d5e0f554384d6e8ab25341fd15f060028fd92e6a/cryptography-50.0.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:42be3bb70596b3abe4ac097b75be223e8b3ab614a0e5de068e3dcc54d71d6149", size = 4999247, upload-time = "2026-08-25T19:44:53.876Z" }, - { url = "https://files.pythonhosted.org/packages/4d/56/bc4f2b209e766c93372cfcd59b781a0b2b59700f62a969580415b699c2b2/cryptography-50.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:f74455bb086a85d5e81246412602aaa97ed095e504cd40dd261ef50be42205bf", size = 3825806, upload-time = "2026-08-25T19:44:56.209Z" }, { url = "https://files.pythonhosted.org/packages/84/a9/ee16a903f13755e914d1eecc482fe64d1f10761c3960e5d8fa6837377aff/cryptography-50.0.1-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ca83d00d9e69cd5eb63f2e69c3a5a59e0cecae5ae14c6ae0b35830fe3b37bad0", size = 4035307, upload-time = "2026-08-25T19:44:58.305Z" }, { url = "https://files.pythonhosted.org/packages/5e/a5/9ec7e81e8526c0d7a387d73386b2daed3f39e10d81a85930bd1b6bfba65c/cryptography-50.0.1-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:05ba322c4da95b262a212c345af888ef2c37c88c0509756ea00a0e6d68850f23", size = 4751900, upload-time = "2026-08-25T19:45:00.401Z" }, { url = "https://files.pythonhosted.org/packages/7e/3c/0e77bd5ffcf078e9dd27d3074aad6c030d9b10d0bf69329d573c927a188c/cryptography-50.0.1-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e22dfed744bd4002e909464cb23d2f0b05c6f3113a79ef2e9864a53db737c733", size = 4738357, upload-time = "2026-08-25T19:45:02.786Z" }, @@ -808,16 +661,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ea/03/c0db0a5276599fb44ceafa2f2cb1afd5628808ec406fe036060a39693680/ctranslate2-4.7.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:393a9e7e989034660526a2c0e8bb65d1924f43d9a5c77d336494a353d16ba2a4", size = 16860452, upload-time = "2026-02-04T06:11:52.276Z" }, { url = "https://files.pythonhosted.org/packages/0b/03/4e3728ce29d192ee75ed9a2d8589bf4f19edafe5bed3845187de51b179a3/ctranslate2-4.7.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a3d0682f2b9082e31c73d75b45f16cde77355ab76d7e8356a24c3cb2480a6d3", size = 38995174, upload-time = "2026-02-04T06:11:55.477Z" }, { url = "https://files.pythonhosted.org/packages/9b/15/6e8e87c6a201d69803a79ac2e29623ce7c2cc9cd1df9db99810cca714373/ctranslate2-4.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:baa6d2b10f57933d8c11791e8522659217918722d07bbef2389a443801125fe7", size = 18844953, upload-time = "2026-02-04T06:11:58.519Z" }, - { url = "https://files.pythonhosted.org/packages/fd/73/8a6b7ba18cad0c8667ee221ddab8c361cb70926440e5b8dd0e81924c28ac/ctranslate2-4.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d5dfb076566551f4959dfd0706f94c923c1931def9b7bb249a2caa6ab23353a0", size = 1257560, upload-time = "2026-02-04T06:12:00.926Z" }, - { url = "https://files.pythonhosted.org/packages/70/c2/8817ca5d6c1b175b23a12f7c8b91484652f8718a76353317e5919b038733/ctranslate2-4.7.1-cp314-cp314-macosx_11_0_x86_64.whl", hash = "sha256:eecdb4ed934b384f16e8c01b185b082d6b5ffc7dcbb0b6a6eb48cd465282d957", size = 11918995, upload-time = "2026-02-04T06:12:02.875Z" }, - { url = "https://files.pythonhosted.org/packages/ac/33/b8eb3acc67bbca4d9872fc9ff94db78e6167a7ba5cd932f585d1560effc7/ctranslate2-4.7.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1aa6796edcc3c8d163c9e39c429d50076d266d68980fed9d1b2443f617c67e9e", size = 16844162, upload-time = "2026-02-04T06:12:05.099Z" }, - { url = "https://files.pythonhosted.org/packages/80/11/6474893b07121057035069a0a483fe1cd8c47878213f282afb4c0c6fc275/ctranslate2-4.7.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:24c0482c51726430fb83724451921c0e539d769c8618dcfd46b1645e7f75960d", size = 38966728, upload-time = "2026-02-04T06:12:07.923Z" }, - { url = "https://files.pythonhosted.org/packages/94/88/8fc7ff435c5e783e5fad9586d839d463e023988dbbbad949d442092d01f1/ctranslate2-4.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:76db234c0446a23d20dd8eeaa7a789cc87d1d05283f48bf3152bae9fa0a69844", size = 19100788, upload-time = "2026-02-04T06:12:10.592Z" }, - { url = "https://files.pythonhosted.org/packages/d9/b3/f100013a76a98d64e67c721bd4559ea4eeb54be3e4ac45f4d801769899af/ctranslate2-4.7.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:058c9db2277dc8b19ecc86c7937628f69022f341844b9081d2ab642965d88fc6", size = 1280179, upload-time = "2026-02-04T06:12:12.596Z" }, - { url = "https://files.pythonhosted.org/packages/39/22/b77f748015667a5e2ca54a5ee080d7016fce34314f0e8cf904784549305a/ctranslate2-4.7.1-cp314-cp314t-macosx_11_0_x86_64.whl", hash = "sha256:5abcf885062c7f28a3f9a46be8d185795e8706ac6230ad086cae0bc82917df31", size = 11940166, upload-time = "2026-02-04T06:12:14.054Z" }, - { url = "https://files.pythonhosted.org/packages/7d/78/6d7fd52f646c6ba3343f71277a9bbef33734632949d1651231948b0f0359/ctranslate2-4.7.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9950acb04a002d5c60ae90a1ddceead1a803af1f00cadd9b1a1dc76e1f017481", size = 16849483, upload-time = "2026-02-04T06:12:17.082Z" }, - { url = "https://files.pythonhosted.org/packages/40/27/58769ff15ac31b44205bd7a8aeca80cf7357c657ea5df1b94ce0f5c83771/ctranslate2-4.7.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1dcc734e92e3f1ceeaa0c42bbfd009352857be179ecd4a7ed6cccc086a202f58", size = 38949393, upload-time = "2026-02-04T06:12:21.302Z" }, - { url = "https://files.pythonhosted.org/packages/0e/5c/9fa0ad6462b62efd0fb5ac1100eee47bc96ecc198ff4e237c731e5473616/ctranslate2-4.7.1-cp314-cp314t-win_amd64.whl", hash = "sha256:dfb7657bdb7b8211c8f9ecb6f3b70bc0db0e0384d01a8b1808cb66fe7199df59", size = 19123451, upload-time = "2026-02-04T06:12:24.115Z" }, ] [[package]] @@ -842,15 +685,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9b/72/1732a24ef4a2aeba994b80ec163debe8deda403c07e4abbc0443bca078b8/curl_cffi-0.16.3-cp310-abi3-win_amd64.whl", hash = "sha256:fe87b66e324ed7318166698e02169f3208dbda32b872a27d2bc61a9c19b335eb", size = 1978602, upload-time = "2026-09-02T11:58:00.033Z" }, { url = "https://files.pythonhosted.org/packages/45/bb/67bec3132aeabac99dfe2f299a9b43dcb5de23ad96219ee98516d177fc9c/curl_cffi-0.16.3-cp310-abi3-win_arm64.whl", hash = "sha256:5a2ba880019f9e5a9e8f38ae22de6e4ea4c8d34a51ae4f1a2fce962c7b632006", size = 1713140, upload-time = "2026-09-02T11:58:01.558Z" }, { url = "https://files.pythonhosted.org/packages/49/e3/b88f9b1a60a1e29b42e9371c1b3f4fdd83bf8177fcf863df67438da12693/curl_cffi-0.16.3-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:01c31369b1c8063c7e459152c508c90de7a4218aa66ee3a1f575ae37ce44bc5a", size = 8607348, upload-time = "2026-09-02T11:58:03.095Z" }, - { url = "https://files.pythonhosted.org/packages/fb/f4/3dedff1a31c93a9b18acaa346e23832c29bc18075138e90e9af795188e5e/curl_cffi-0.16.3-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:0c8b70191dc88ea770a5c39d7e213bff1606e248c13566777e6527f0d8cf96ec", size = 8607323, upload-time = "2026-09-02T11:58:05.099Z" }, - { url = "https://files.pythonhosted.org/packages/73/b7/99708ed83c11132ec0311a28ed46fe1cd10e8cb6ecd3c82f01f1f80c3c2c/curl_cffi-0.16.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:8055ec9d7c15237747be254739c40057e3684f56854e95c12aaf3c95838ba2d6", size = 3026149, upload-time = "2026-09-02T11:58:06.973Z" }, - { url = "https://files.pythonhosted.org/packages/5d/d5/6c0400fb64097c4662da4e5d2d1e7daa8027d1431b1c8880c0f8f2051ae1/curl_cffi-0.16.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:391096e903ec98b909bb355e008ec7c211d710b6a23663a7f1f10aa54a027538", size = 2784153, upload-time = "2026-09-02T11:58:08.726Z" }, - { url = "https://files.pythonhosted.org/packages/87/a4/3c8702d25e21f420e88707701af15006e72a2a2b9f3fa419c7c80ce7451c/curl_cffi-0.16.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6cef43f248b3635de9b82337e0ed2c7403aa1506e51587144d552702eb9d0775", size = 12839612, upload-time = "2026-09-02T11:58:10.745Z" }, - { url = "https://files.pythonhosted.org/packages/be/bf/44a7e7a1e309136a1b086332feb03c7718169af550bdbf7eab52ae0497e0/curl_cffi-0.16.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e1fffac4b5a02c5ec74d184d668c5b882f80fa1d961e7adba6e1755877af41e1", size = 13488945, upload-time = "2026-09-02T11:58:13.15Z" }, - { url = "https://files.pythonhosted.org/packages/52/83/5321d5fb67ff16195fb0c3bd5434be4532c85967c80546092a1cf3654cc9/curl_cffi-0.16.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:849026be5b36cf7b95d5fce63a84aa7b17248e83b4374e67715e7387ca2be50c", size = 12592235, upload-time = "2026-09-02T11:58:15.58Z" }, - { url = "https://files.pythonhosted.org/packages/12/aa/0b4e110729a86b434196d15e2e2839d992a9b8f3003f0569c77e27a9faca/curl_cffi-0.16.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:82cc688349c8e8955d346cc5cc7759b68742edc587ae47ba5783a096502a7a92", size = 13259593, upload-time = "2026-09-02T11:58:17.971Z" }, - { url = "https://files.pythonhosted.org/packages/4c/3a/e4f199cfc9f131411543aacdf6811d8b72b81ce6ac6e9f6ddffecfc31e54/curl_cffi-0.16.3-cp314-cp314t-win_amd64.whl", hash = "sha256:72376595490c4822ad1a5360adb568660ca66dff4ba2c2de2912778c15f43edb", size = 2031033, upload-time = "2026-09-02T11:58:19.949Z" }, - { url = "https://files.pythonhosted.org/packages/18/8f/9354e5552982d38abd3ce2db859f049fee6bff0eee4e25aacaaa2b29f0b4/curl_cffi-0.16.3-cp314-cp314t-win_arm64.whl", hash = "sha256:b450fad876aa9f9ed3edfb6e3a48a8c28eafa66aae634eff17800a8b5006568d", size = 1782234, upload-time = "2026-09-02T11:58:21.629Z" }, ] [[package]] @@ -1028,16 +862,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/16/08/a2eea4f20b85897740efe44887e1ac08f30dfa4bfc3de8962bdcbb21a5a1/fastavro-1.12.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:469fecb25cba07f2e1bfa4c8d008477cd6b5b34a59d48715e1b1a73f6160097d", size = 3432217, upload-time = "2025-10-10T15:41:53.149Z" }, { url = "https://files.pythonhosted.org/packages/87/bb/b4c620b9eb6e9838c7f7e4b7be0762834443adf9daeb252a214e9ad3178c/fastavro-1.12.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:d71c8aa841ef65cfab709a22bb887955f42934bced3ddb571e98fdbdade4c609", size = 3366742, upload-time = "2025-10-10T15:41:55.237Z" }, { url = "https://files.pythonhosted.org/packages/3d/d1/e69534ccdd5368350646fea7d93be39e5f77c614cca825c990bd9ca58f67/fastavro-1.12.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:b81fc04e85dfccf7c028e0580c606e33aa8472370b767ef058aae2c674a90746", size = 3383743, upload-time = "2025-10-10T15:41:57.68Z" }, - { url = "https://files.pythonhosted.org/packages/58/54/b7b4a0c3fb5fcba38128542da1b26c4e6d69933c923f493548bdfd63ab6a/fastavro-1.12.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:9445da127751ba65975d8e4bdabf36bfcfdad70fc35b2d988e3950cce0ec0e7c", size = 1001377, upload-time = "2025-10-10T15:41:59.241Z" }, - { url = "https://files.pythonhosted.org/packages/1e/4f/0e589089c7df0d8f57d7e5293fdc34efec9a3b758a0d4d0c99a7937e2492/fastavro-1.12.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ed924233272719b5d5a6a0b4d80ef3345fc7e84fc7a382b6232192a9112d38a6", size = 3320401, upload-time = "2025-10-10T15:42:01.682Z" }, - { url = "https://files.pythonhosted.org/packages/f9/19/260110d56194ae29d7e423a336fccea8bcd103196d00f0b364b732bdb84e/fastavro-1.12.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3616e2f0e1c9265e92954fa099db79c6e7817356d3ff34f4bcc92699ae99697c", size = 3350894, upload-time = "2025-10-10T15:42:04.073Z" }, - { url = "https://files.pythonhosted.org/packages/d0/96/58b0411e8be9694d5972bee3167d6c1fd1fdfdf7ce253c1a19a327208f4f/fastavro-1.12.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:cb0337b42fd3c047fcf0e9b7597bd6ad25868de719f29da81eabb6343f08d399", size = 3229644, upload-time = "2025-10-10T15:42:06.221Z" }, - { url = "https://files.pythonhosted.org/packages/5b/db/38660660eac82c30471d9101f45b3acfdcbadfe42d8f7cdb129459a45050/fastavro-1.12.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:64961ab15b74b7c168717bbece5660e0f3d457837c3cc9d9145181d011199fa7", size = 3329704, upload-time = "2025-10-10T15:42:08.384Z" }, - { url = "https://files.pythonhosted.org/packages/9d/a9/1672910f458ecb30b596c9e59e41b7c00309b602a0494341451e92e62747/fastavro-1.12.1-cp314-cp314-win_amd64.whl", hash = "sha256:792356d320f6e757e89f7ac9c22f481e546c886454a6709247f43c0dd7058004", size = 452911, upload-time = "2025-10-10T15:42:09.795Z" }, - { url = "https://files.pythonhosted.org/packages/dc/8d/2e15d0938ded1891b33eff252e8500605508b799c2e57188a933f0bd744c/fastavro-1.12.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:120aaf82ac19d60a1016afe410935fe94728752d9c2d684e267e5b7f0e70f6d9", size = 3541999, upload-time = "2025-10-10T15:42:11.794Z" }, - { url = "https://files.pythonhosted.org/packages/a7/1c/6dfd082a205be4510543221b734b1191299e6a1810c452b6bc76dfa6968e/fastavro-1.12.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b6a3462934b20a74f9ece1daa49c2e4e749bd9a35fa2657b53bf62898fba80f5", size = 3433972, upload-time = "2025-10-10T15:42:14.485Z" }, - { url = "https://files.pythonhosted.org/packages/24/90/9de694625a1a4b727b1ad0958d220cab25a9b6cf7f16a5c7faa9ea7b2261/fastavro-1.12.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1f81011d54dd47b12437b51dd93a70a9aa17b61307abf26542fc3c13efbc6c51", size = 3368752, upload-time = "2025-10-10T15:42:16.618Z" }, - { url = "https://files.pythonhosted.org/packages/fa/93/b44f67589e4d439913dab6720f7e3507b0fa8b8e56d06f6fc875ced26afb/fastavro-1.12.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:43ded16b3f4a9f1a42f5970c2aa618acb23ea59c4fcaa06680bdf470b255e5a8", size = 3386636, upload-time = "2025-10-10T15:42:18.974Z" }, ] [[package]] @@ -1127,22 +951,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1f/ae/b41f8628ec0be3c1b934fc12b84f4576a5c646119db4d3bdd76a217c90b5/fonttools-4.62.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:403d28ce06ebfc547fbcb0cb8b7f7cc2f7a2d3e1a67ba9a34b14632df9e080f9", size = 5094920, upload-time = "2026-03-13T13:53:29.329Z" }, { url = "https://files.pythonhosted.org/packages/f2/f6/53a1e9469331a23dcc400970a27a4caa3d9f6edbf5baab0260285238b884/fonttools-4.62.1-cp313-cp313-win32.whl", hash = "sha256:93c316e0f5301b2adbe6a5f658634307c096fd5aae60a5b3412e4f3e1728ab24", size = 2279928, upload-time = "2026-03-13T13:53:32.352Z" }, { url = "https://files.pythonhosted.org/packages/38/60/35186529de1db3c01f5ad625bde07c1f576305eab6d86bbda4c58445f721/fonttools-4.62.1-cp313-cp313-win_amd64.whl", hash = "sha256:7aa21ff53e28a9c2157acbc44e5b401149d3c9178107130e82d74ceb500e5056", size = 2330514, upload-time = "2026-03-13T13:53:34.991Z" }, - { url = "https://files.pythonhosted.org/packages/36/f0/2888cdac391807d68d90dcb16ef858ddc1b5309bfc6966195a459dd326e2/fonttools-4.62.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:fa1d16210b6b10a826d71bed68dd9ec24a9e218d5a5e2797f37c573e7ec215ca", size = 2864442, upload-time = "2026-03-13T13:53:37.509Z" }, - { url = "https://files.pythonhosted.org/packages/4b/b2/e521803081f8dc35990816b82da6360fa668a21b44da4b53fc9e77efcd62/fonttools-4.62.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:aa69d10ed420d8121118e628ad47d86e4caa79ba37f968597b958f6cceab7eca", size = 2410901, upload-time = "2026-03-13T13:53:40.55Z" }, - { url = "https://files.pythonhosted.org/packages/00/a4/8c3511ff06e53110039358dbbdc1a65d72157a054638387aa2ada300a8b8/fonttools-4.62.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bd13b7999d59c5eb1c2b442eb2d0c427cb517a0b7a1f5798fc5c9e003f5ff782", size = 4999608, upload-time = "2026-03-13T13:53:42.798Z" }, - { url = "https://files.pythonhosted.org/packages/28/63/cd0c3b26afe60995a5295f37c246a93d454023726c3261cfbb3559969bb9/fonttools-4.62.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8d337fdd49a79b0d51c4da87bc38169d21c3abbf0c1aa9367eff5c6656fb6dae", size = 4912726, upload-time = "2026-03-13T13:53:45.405Z" }, - { url = "https://files.pythonhosted.org/packages/70/b9/ac677cb07c24c685cf34f64e140617d58789d67a3dd524164b63648c6114/fonttools-4.62.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d241cdc4a67b5431c6d7f115fdf63335222414995e3a1df1a41e1182acd4bcc7", size = 4951422, upload-time = "2026-03-13T13:53:48.326Z" }, - { url = "https://files.pythonhosted.org/packages/e6/10/11c08419a14b85b7ca9a9faca321accccc8842dd9e0b1c8a72908de05945/fonttools-4.62.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c05557a78f8fa514da0f869556eeda40887a8abc77c76ee3f74cf241778afd5a", size = 5060979, upload-time = "2026-03-13T13:53:51.366Z" }, - { url = "https://files.pythonhosted.org/packages/4e/3c/12eea4a4cf054e7ab058ed5ceada43b46809fce2bf319017c4d63ae55bb4/fonttools-4.62.1-cp314-cp314-win32.whl", hash = "sha256:49a445d2f544ce4a69338694cad575ba97b9a75fff02720da0882d1a73f12800", size = 2283733, upload-time = "2026-03-13T13:53:53.606Z" }, - { url = "https://files.pythonhosted.org/packages/6b/67/74b070029043186b5dd13462c958cb7c7f811be0d2e634309d9a1ffb1505/fonttools-4.62.1-cp314-cp314-win_amd64.whl", hash = "sha256:1eecc128c86c552fb963fe846ca4e011b1be053728f798185a1687502f6d398e", size = 2335663, upload-time = "2026-03-13T13:53:56.23Z" }, - { url = "https://files.pythonhosted.org/packages/42/c5/4d2ed3ca6e33617fc5624467da353337f06e7f637707478903c785bd8e20/fonttools-4.62.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:1596aeaddf7f78e21e68293c011316a25267b3effdaccaf4d59bc9159d681b82", size = 2947288, upload-time = "2026-03-13T13:53:59.397Z" }, - { url = "https://files.pythonhosted.org/packages/1f/e9/7ab11ddfda48ed0f89b13380e5595ba572619c27077be0b2c447a63ff351/fonttools-4.62.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:8f8fca95d3bb3208f59626a4b0ea6e526ee51f5a8ad5d91821c165903e8d9260", size = 2449023, upload-time = "2026-03-13T13:54:01.642Z" }, - { url = "https://files.pythonhosted.org/packages/b2/10/a800fa090b5e8819942e54e19b55fc7c21fe14a08757c3aa3ca8db358939/fonttools-4.62.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee91628c08e76f77b533d65feb3fbe6d9dad699f95be51cf0d022db94089cdc4", size = 5137599, upload-time = "2026-03-13T13:54:04.495Z" }, - { url = "https://files.pythonhosted.org/packages/37/dc/8ccd45033fffd74deb6912fa1ca524643f584b94c87a16036855b498a1ed/fonttools-4.62.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5f37df1cac61d906e7b836abe356bc2f34c99d4477467755c216b72aa3dc748b", size = 4920933, upload-time = "2026-03-13T13:54:07.557Z" }, - { url = "https://files.pythonhosted.org/packages/99/eb/e618adefb839598d25ac8136cd577925d6c513dc0d931d93b8af956210f0/fonttools-4.62.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:92bb00a947e666169c99b43753c4305fc95a890a60ef3aeb2a6963e07902cc87", size = 5016232, upload-time = "2026-03-13T13:54:10.611Z" }, - { url = "https://files.pythonhosted.org/packages/d9/5f/9b5c9bfaa8ec82def8d8168c4f13615990d6ce5996fe52bd49bfb5e05134/fonttools-4.62.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:bdfe592802ef939a0e33106ea4a318eeb17822c7ee168c290273cbd5fabd746c", size = 5042987, upload-time = "2026-03-13T13:54:13.569Z" }, - { url = "https://files.pythonhosted.org/packages/90/aa/dfbbe24c6a6afc5c203d90cc0343e24bcbb09e76d67c4d6eef8c2558d7ba/fonttools-4.62.1-cp314-cp314t-win32.whl", hash = "sha256:b820fcb92d4655513d8402d5b219f94481c4443d825b4372c75a2072aa4b357a", size = 2348021, upload-time = "2026-03-13T13:54:16.98Z" }, - { url = "https://files.pythonhosted.org/packages/13/6f/ae9c4e4dd417948407b680855c2c7790efb52add6009aaecff1e3bc50e8e/fonttools-4.62.1-cp314-cp314t-win_amd64.whl", hash = "sha256:59b372b4f0e113d3746b88985f1c796e7bf830dd54b28374cd85c2b8acd7583e", size = 2414147, upload-time = "2026-03-13T13:54:19.416Z" }, { url = "https://files.pythonhosted.org/packages/fd/ba/56147c165442cc5ba7e82ecf301c9a68353cede498185869e6e02b4c264f/fonttools-4.62.1-py3-none-any.whl", hash = "sha256:7487782e2113861f4ddcc07c3436450659e3caa5e470b27dc2177cade2d8e7fd", size = 1152647, upload-time = "2026-03-13T13:54:22.735Z" }, ] @@ -1200,38 +1008,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fd/00/04ca1c3a7a124b6de4f8a9a17cc2fcad138b4608e7a3fc5877804b8715d7/frozenlist-1.8.0-cp313-cp313t-win32.whl", hash = "sha256:0f96534f8bfebc1a394209427d0f8a63d343c9779cda6fc25e8e121b5fd8555b", size = 43492, upload-time = "2025-10-06T05:37:04.915Z" }, { url = "https://files.pythonhosted.org/packages/59/5e/c69f733a86a94ab10f68e496dc6b7e8bc078ebb415281d5698313e3af3a1/frozenlist-1.8.0-cp313-cp313t-win_amd64.whl", hash = "sha256:5d63a068f978fc69421fb0e6eb91a9603187527c86b7cd3f534a5b77a592b888", size = 48034, upload-time = "2025-10-06T05:37:06.343Z" }, { url = "https://files.pythonhosted.org/packages/16/6c/be9d79775d8abe79b05fa6d23da99ad6e7763a1d080fbae7290b286093fd/frozenlist-1.8.0-cp313-cp313t-win_arm64.whl", hash = "sha256:bf0a7e10b077bf5fb9380ad3ae8ce20ef919a6ad93b4552896419ac7e1d8e042", size = 41749, upload-time = "2025-10-06T05:37:07.431Z" }, - { url = "https://files.pythonhosted.org/packages/f1/c8/85da824b7e7b9b6e7f7705b2ecaf9591ba6f79c1177f324c2735e41d36a2/frozenlist-1.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:cee686f1f4cadeb2136007ddedd0aaf928ab95216e7691c63e50a8ec066336d0", size = 86127, upload-time = "2025-10-06T05:37:08.438Z" }, - { url = "https://files.pythonhosted.org/packages/8e/e8/a1185e236ec66c20afd72399522f142c3724c785789255202d27ae992818/frozenlist-1.8.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:119fb2a1bd47307e899c2fac7f28e85b9a543864df47aa7ec9d3c1b4545f096f", size = 49698, upload-time = "2025-10-06T05:37:09.48Z" }, - { url = "https://files.pythonhosted.org/packages/a1/93/72b1736d68f03fda5fdf0f2180fb6caaae3894f1b854d006ac61ecc727ee/frozenlist-1.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4970ece02dbc8c3a92fcc5228e36a3e933a01a999f7094ff7c23fbd2beeaa67c", size = 49749, upload-time = "2025-10-06T05:37:10.569Z" }, - { url = "https://files.pythonhosted.org/packages/a7/b2/fabede9fafd976b991e9f1b9c8c873ed86f202889b864756f240ce6dd855/frozenlist-1.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:cba69cb73723c3f329622e34bdbf5ce1f80c21c290ff04256cff1cd3c2036ed2", size = 231298, upload-time = "2025-10-06T05:37:11.993Z" }, - { url = "https://files.pythonhosted.org/packages/3a/3b/d9b1e0b0eed36e70477ffb8360c49c85c8ca8ef9700a4e6711f39a6e8b45/frozenlist-1.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:778a11b15673f6f1df23d9586f83c4846c471a8af693a22e066508b77d201ec8", size = 232015, upload-time = "2025-10-06T05:37:13.194Z" }, - { url = "https://files.pythonhosted.org/packages/dc/94/be719d2766c1138148564a3960fc2c06eb688da592bdc25adcf856101be7/frozenlist-1.8.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0325024fe97f94c41c08872db482cf8ac4800d80e79222c6b0b7b162d5b13686", size = 225038, upload-time = "2025-10-06T05:37:14.577Z" }, - { url = "https://files.pythonhosted.org/packages/e4/09/6712b6c5465f083f52f50cf74167b92d4ea2f50e46a9eea0523d658454ae/frozenlist-1.8.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:97260ff46b207a82a7567b581ab4190bd4dfa09f4db8a8b49d1a958f6aa4940e", size = 240130, upload-time = "2025-10-06T05:37:15.781Z" }, - { url = "https://files.pythonhosted.org/packages/f8/d4/cd065cdcf21550b54f3ce6a22e143ac9e4836ca42a0de1022da8498eac89/frozenlist-1.8.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:54b2077180eb7f83dd52c40b2750d0a9f175e06a42e3213ce047219de902717a", size = 242845, upload-time = "2025-10-06T05:37:17.037Z" }, - { url = "https://files.pythonhosted.org/packages/62/c3/f57a5c8c70cd1ead3d5d5f776f89d33110b1addae0ab010ad774d9a44fb9/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2f05983daecab868a31e1da44462873306d3cbfd76d1f0b5b69c473d21dbb128", size = 229131, upload-time = "2025-10-06T05:37:18.221Z" }, - { url = "https://files.pythonhosted.org/packages/6c/52/232476fe9cb64f0742f3fde2b7d26c1dac18b6d62071c74d4ded55e0ef94/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:33f48f51a446114bc5d251fb2954ab0164d5be02ad3382abcbfe07e2531d650f", size = 240542, upload-time = "2025-10-06T05:37:19.771Z" }, - { url = "https://files.pythonhosted.org/packages/5f/85/07bf3f5d0fb5414aee5f47d33c6f5c77bfe49aac680bfece33d4fdf6a246/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:154e55ec0655291b5dd1b8731c637ecdb50975a2ae70c606d100750a540082f7", size = 237308, upload-time = "2025-10-06T05:37:20.969Z" }, - { url = "https://files.pythonhosted.org/packages/11/99/ae3a33d5befd41ac0ca2cc7fd3aa707c9c324de2e89db0e0f45db9a64c26/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:4314debad13beb564b708b4a496020e5306c7333fa9a3ab90374169a20ffab30", size = 238210, upload-time = "2025-10-06T05:37:22.252Z" }, - { url = "https://files.pythonhosted.org/packages/b2/60/b1d2da22f4970e7a155f0adde9b1435712ece01b3cd45ba63702aea33938/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:073f8bf8becba60aa931eb3bc420b217bb7d5b8f4750e6f8b3be7f3da85d38b7", size = 231972, upload-time = "2025-10-06T05:37:23.5Z" }, - { url = "https://files.pythonhosted.org/packages/3f/ab/945b2f32de889993b9c9133216c068b7fcf257d8595a0ac420ac8677cab0/frozenlist-1.8.0-cp314-cp314-win32.whl", hash = "sha256:bac9c42ba2ac65ddc115d930c78d24ab8d4f465fd3fc473cdedfccadb9429806", size = 40536, upload-time = "2025-10-06T05:37:25.581Z" }, - { url = "https://files.pythonhosted.org/packages/59/ad/9caa9b9c836d9ad6f067157a531ac48b7d36499f5036d4141ce78c230b1b/frozenlist-1.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:3e0761f4d1a44f1d1a47996511752cf3dcec5bbdd9cc2b4fe595caf97754b7a0", size = 44330, upload-time = "2025-10-06T05:37:26.928Z" }, - { url = "https://files.pythonhosted.org/packages/82/13/e6950121764f2676f43534c555249f57030150260aee9dcf7d64efda11dd/frozenlist-1.8.0-cp314-cp314-win_arm64.whl", hash = "sha256:d1eaff1d00c7751b7c6662e9c5ba6eb2c17a2306ba5e2a37f24ddf3cc953402b", size = 40627, upload-time = "2025-10-06T05:37:28.075Z" }, - { url = "https://files.pythonhosted.org/packages/c0/c7/43200656ecc4e02d3f8bc248df68256cd9572b3f0017f0a0c4e93440ae23/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:d3bb933317c52d7ea5004a1c442eef86f426886fba134ef8cf4226ea6ee1821d", size = 89238, upload-time = "2025-10-06T05:37:29.373Z" }, - { url = "https://files.pythonhosted.org/packages/d1/29/55c5f0689b9c0fb765055629f472c0de484dcaf0acee2f7707266ae3583c/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:8009897cdef112072f93a0efdce29cd819e717fd2f649ee3016efd3cd885a7ed", size = 50738, upload-time = "2025-10-06T05:37:30.792Z" }, - { url = "https://files.pythonhosted.org/packages/ba/7d/b7282a445956506fa11da8c2db7d276adcbf2b17d8bb8407a47685263f90/frozenlist-1.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2c5dcbbc55383e5883246d11fd179782a9d07a986c40f49abe89ddf865913930", size = 51739, upload-time = "2025-10-06T05:37:32.127Z" }, - { url = "https://files.pythonhosted.org/packages/62/1c/3d8622e60d0b767a5510d1d3cf21065b9db874696a51ea6d7a43180a259c/frozenlist-1.8.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:39ecbc32f1390387d2aa4f5a995e465e9e2f79ba3adcac92d68e3e0afae6657c", size = 284186, upload-time = "2025-10-06T05:37:33.21Z" }, - { url = "https://files.pythonhosted.org/packages/2d/14/aa36d5f85a89679a85a1d44cd7a6657e0b1c75f61e7cad987b203d2daca8/frozenlist-1.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92db2bf818d5cc8d9c1f1fc56b897662e24ea5adb36ad1f1d82875bd64e03c24", size = 292196, upload-time = "2025-10-06T05:37:36.107Z" }, - { url = "https://files.pythonhosted.org/packages/05/23/6bde59eb55abd407d34f77d39a5126fb7b4f109a3f611d3929f14b700c66/frozenlist-1.8.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2dc43a022e555de94c3b68a4ef0b11c4f747d12c024a520c7101709a2144fb37", size = 273830, upload-time = "2025-10-06T05:37:37.663Z" }, - { url = "https://files.pythonhosted.org/packages/d2/3f/22cff331bfad7a8afa616289000ba793347fcd7bc275f3b28ecea2a27909/frozenlist-1.8.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cb89a7f2de3602cfed448095bab3f178399646ab7c61454315089787df07733a", size = 294289, upload-time = "2025-10-06T05:37:39.261Z" }, - { url = "https://files.pythonhosted.org/packages/a4/89/5b057c799de4838b6c69aa82b79705f2027615e01be996d2486a69ca99c4/frozenlist-1.8.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:33139dc858c580ea50e7e60a1b0ea003efa1fd42e6ec7fdbad78fff65fad2fd2", size = 300318, upload-time = "2025-10-06T05:37:43.213Z" }, - { url = "https://files.pythonhosted.org/packages/30/de/2c22ab3eb2a8af6d69dc799e48455813bab3690c760de58e1bf43b36da3e/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:168c0969a329b416119507ba30b9ea13688fafffac1b7822802537569a1cb0ef", size = 282814, upload-time = "2025-10-06T05:37:45.337Z" }, - { url = "https://files.pythonhosted.org/packages/59/f7/970141a6a8dbd7f556d94977858cfb36fa9b66e0892c6dd780d2219d8cd8/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:28bd570e8e189d7f7b001966435f9dac6718324b5be2990ac496cf1ea9ddb7fe", size = 291762, upload-time = "2025-10-06T05:37:46.657Z" }, - { url = "https://files.pythonhosted.org/packages/c1/15/ca1adae83a719f82df9116d66f5bb28bb95557b3951903d39135620ef157/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b2a095d45c5d46e5e79ba1e5b9cb787f541a8dee0433836cea4b96a2c439dcd8", size = 289470, upload-time = "2025-10-06T05:37:47.946Z" }, - { url = "https://files.pythonhosted.org/packages/ac/83/dca6dc53bf657d371fbc88ddeb21b79891e747189c5de990b9dfff2ccba1/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:eab8145831a0d56ec9c4139b6c3e594c7a83c2c8be25d5bcf2d86136a532287a", size = 289042, upload-time = "2025-10-06T05:37:49.499Z" }, - { url = "https://files.pythonhosted.org/packages/96/52/abddd34ca99be142f354398700536c5bd315880ed0a213812bc491cff5e4/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:974b28cf63cc99dfb2188d8d222bc6843656188164848c4f679e63dae4b0708e", size = 283148, upload-time = "2025-10-06T05:37:50.745Z" }, - { url = "https://files.pythonhosted.org/packages/af/d3/76bd4ed4317e7119c2b7f57c3f6934aba26d277acc6309f873341640e21f/frozenlist-1.8.0-cp314-cp314t-win32.whl", hash = "sha256:342c97bf697ac5480c0a7ec73cd700ecfa5a8a40ac923bd035484616efecc2df", size = 44676, upload-time = "2025-10-06T05:37:52.222Z" }, - { url = "https://files.pythonhosted.org/packages/89/76/c615883b7b521ead2944bb3480398cbb07e12b7b4e4d073d3752eb721558/frozenlist-1.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:06be8f67f39c8b1dc671f5d83aaefd3358ae5cdcf8314552c57e7ed3e6475bdd", size = 49451, upload-time = "2025-10-06T05:37:53.425Z" }, - { url = "https://files.pythonhosted.org/packages/e0/a3/5982da14e113d07b325230f95060e2169f5311b1017ea8af2a29b374c289/frozenlist-1.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:102e6314ca4da683dca92e3b1355490fed5f313b768500084fbe6371fddfdb79", size = 42507, upload-time = "2025-10-06T05:37:54.513Z" }, { url = "https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", size = 13409, upload-time = "2025-10-06T05:38:16.721Z" }, ] @@ -1251,8 +1027,7 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "glom" }, { name = "httpx" }, - { name = "pandas", version = "2.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.14'" }, - { name = "pandas", version = "3.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.14'" }, + { name = "pandas" }, ] sdist = { url = "https://files.pythonhosted.org/packages/a6/99/165b68d01a243a6bbbe147fcdc6cb7e39b7287a5fcaa37d72e0542ba34da/geckoterminal_py-0.2.5.tar.gz", hash = "sha256:54be3953a44cefe0f3f46ffd4b1deec5eca09b295db8d42d9baf84ba0a565e15", size = 196040, upload-time = "2025-04-09T23:05:53.72Z" } wheels = [ @@ -1392,16 +1167,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/10/bb/dd06f4c24c01db9cf11341b547d0a016b2c90ed7dbbb086a5710df7dd1d7/grpcio-1.80.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8eb613f02d34721f1acf3626dfdb3545bd3c8505b0e52bf8b5710a28d02e8aa7", size = 7826752, upload-time = "2026-03-30T08:48:01.311Z" }, { url = "https://files.pythonhosted.org/packages/f9/1e/9d67992ba23371fd63d4527096eb8c6b76d74d52b500df992a3343fd7251/grpcio-1.80.0-cp313-cp313-win32.whl", hash = "sha256:93b6f823810720912fd131f561f91f5fed0fda372b6b7028a2681b8194d5d294", size = 4142310, upload-time = "2026-03-30T08:48:04.594Z" }, { url = "https://files.pythonhosted.org/packages/cf/e6/283326a27da9e2c3038bc93eeea36fb118ce0b2d03922a9cda6688f53c5b/grpcio-1.80.0-cp313-cp313-win_amd64.whl", hash = "sha256:e172cf795a3ba5246d3529e4d34c53db70e888fa582a8ffebd2e6e48bc0cba50", size = 4882833, upload-time = "2026-03-30T08:48:07.363Z" }, - { url = "https://files.pythonhosted.org/packages/c5/6d/e65307ce20f5a09244ba9e9d8476e99fb039de7154f37fb85f26978b59c3/grpcio-1.80.0-cp314-cp314-linux_armv7l.whl", hash = "sha256:3d4147a97c8344d065d01bbf8b6acec2cf86fb0400d40696c8bdad34a64ffc0e", size = 6017376, upload-time = "2026-03-30T08:48:10.005Z" }, - { url = "https://files.pythonhosted.org/packages/69/10/9cef5d9650c72625a699c549940f0abb3c4bfdb5ed45a5ce431f92f31806/grpcio-1.80.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:d8e11f167935b3eb089ac9038e1a063e6d7dbe995c0bb4a661e614583352e76f", size = 12018133, upload-time = "2026-03-30T08:48:12.927Z" }, - { url = "https://files.pythonhosted.org/packages/04/82/983aabaad82ba26113caceeb9091706a0696b25da004fe3defb5b346e15b/grpcio-1.80.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f14b618fc30de822681ee986cfdcc2d9327229dc4c98aed16896761cacd468b9", size = 6574748, upload-time = "2026-03-30T08:48:16.386Z" }, - { url = "https://files.pythonhosted.org/packages/07/d7/031666ef155aa0bf399ed7e19439656c38bbd143779ae0861b038ce82abd/grpcio-1.80.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:4ed39fbdcf9b87370f6e8df4e39ca7b38b3e5e9d1b0013c7b6be9639d6578d14", size = 7277711, upload-time = "2026-03-30T08:48:19.627Z" }, - { url = "https://files.pythonhosted.org/packages/e8/43/f437a78f7f4f1d311804189e8f11fb311a01049b2e08557c1068d470cb2e/grpcio-1.80.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2dcc70e9f0ba987526e8e8603a610fb4f460e42899e74e7a518bf3c68fe1bf05", size = 6785372, upload-time = "2026-03-30T08:48:22.373Z" }, - { url = "https://files.pythonhosted.org/packages/93/3d/f6558e9c6296cb4227faa5c43c54a34c68d32654b829f53288313d16a86e/grpcio-1.80.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:448c884b668b868562b1bda833c5fce6272d26e1926ec46747cda05741d302c1", size = 7395268, upload-time = "2026-03-30T08:48:25.638Z" }, - { url = "https://files.pythonhosted.org/packages/06/21/0fdd77e84720b08843c371a2efa6f2e19dbebf56adc72df73d891f5506f0/grpcio-1.80.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:a1dc80fe55685b4a543555e6eef975303b36c8db1023b1599b094b92aa77965f", size = 8392000, upload-time = "2026-03-30T08:48:28.974Z" }, - { url = "https://files.pythonhosted.org/packages/f5/68/67f4947ed55d2e69f2cc199ab9fd85e0a0034d813bbeef84df6d2ba4d4b7/grpcio-1.80.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:31b9ac4ad1aa28ffee5503821fafd09e4da0a261ce1c1281c6c8da0423c83b6e", size = 7828477, upload-time = "2026-03-30T08:48:32.054Z" }, - { url = "https://files.pythonhosted.org/packages/44/b6/8d4096691b2e385e8271911a0de4f35f0a6c7d05aff7098e296c3de86939/grpcio-1.80.0-cp314-cp314-win32.whl", hash = "sha256:367ce30ba67d05e0592470428f0ec1c31714cab9ef19b8f2e37be1f4c7d32fae", size = 4218563, upload-time = "2026-03-30T08:48:34.538Z" }, - { url = "https://files.pythonhosted.org/packages/e5/8c/bbe6baf2557262834f2070cf668515fa308b2d38a4bbf771f8f7872a7036/grpcio-1.80.0-cp314-cp314-win_amd64.whl", hash = "sha256:3b01e1f5464c583d2f567b2e46ff0d516ef979978f72091fd81f5ab7fa6e2e7f", size = 5019457, upload-time = "2026-03-30T08:48:37.308Z" }, ] [[package]] @@ -1427,14 +1192,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/21/a6/cbd4188b22abd80ebd0edbb2b3e87f2633e958983519980815fb8314eae5/hf_xet-1.4.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:fca58a2ae4e6f6755cc971ac6fcdf777ea9284d7e540e350bb000813b9a3008d", size = 4428287, upload-time = "2026-03-13T06:58:42.601Z" }, { url = "https://files.pythonhosted.org/packages/b2/4e/84e45b25e2e3e903ed3db68d7eafa96dae9a1d1f6d0e7fc85120347a852f/hf_xet-1.4.2-cp313-cp313t-win_amd64.whl", hash = "sha256:163aab46854ccae0ab6a786f8edecbbfbaa38fcaa0184db6feceebf7000c93c0", size = 3665574, upload-time = "2026-03-13T06:58:53.881Z" }, { url = "https://files.pythonhosted.org/packages/ee/71/c5ac2b9a7ae39c14e91973035286e73911c31980fe44e7b1d03730c00adc/hf_xet-1.4.2-cp313-cp313t-win_arm64.whl", hash = "sha256:09b138422ecbe50fd0c84d4da5ff537d27d487d3607183cd10e3e53f05188e82", size = 3528760, upload-time = "2026-03-13T06:58:52.187Z" }, - { url = "https://files.pythonhosted.org/packages/1e/0f/fcd2504015eab26358d8f0f232a1aed6b8d363a011adef83fe130bff88f7/hf_xet-1.4.2-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:949dcf88b484bb9d9276ca83f6599e4aa03d493c08fc168c124ad10b2e6f75d7", size = 3796493, upload-time = "2026-03-13T06:58:39.267Z" }, - { url = "https://files.pythonhosted.org/packages/82/56/19c25105ff81731ca6d55a188b5de2aa99d7a2644c7aa9de1810d5d3b726/hf_xet-1.4.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:41659966020d59eb9559c57de2cde8128b706a26a64c60f0531fa2318f409418", size = 3555797, upload-time = "2026-03-13T06:58:37.546Z" }, - { url = "https://files.pythonhosted.org/packages/bf/e3/8933c073186849b5e06762aa89847991d913d10a95d1603eb7f2c3834086/hf_xet-1.4.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5c588e21d80010119458dd5d02a69093f0d115d84e3467efe71ffb2c67c19146", size = 4212127, upload-time = "2026-03-13T06:58:30.539Z" }, - { url = "https://files.pythonhosted.org/packages/eb/01/f89ebba4e369b4ed699dcb60d3152753870996f41c6d22d3d7cac01310e1/hf_xet-1.4.2-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:a296744d771a8621ad1d50c098d7ab975d599800dae6d48528ba3944e5001ba0", size = 3987788, upload-time = "2026-03-13T06:58:29.139Z" }, - { url = "https://files.pythonhosted.org/packages/84/4d/8a53e5ffbc2cc33bbf755382ac1552c6d9af13f623ed125fe67cc3e6772f/hf_xet-1.4.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:f563f7efe49588b7d0629d18d36f46d1658fe7e08dce3fa3d6526e1c98315e2d", size = 4188315, upload-time = "2026-03-13T06:58:48.017Z" }, - { url = "https://files.pythonhosted.org/packages/d1/b8/b7a1c1b5592254bd67050632ebbc1b42cc48588bf4757cb03c2ef87e704a/hf_xet-1.4.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5b2e0132c56d7ee1bf55bdb638c4b62e7106f6ac74f0b786fed499d5548c5570", size = 4428306, upload-time = "2026-03-13T06:58:49.502Z" }, - { url = "https://files.pythonhosted.org/packages/a0/0c/40779e45b20e11c7c5821a94135e0207080d6b3d76e7b78ccb413c6f839b/hf_xet-1.4.2-cp314-cp314t-win_amd64.whl", hash = "sha256:2f45c712c2fa1215713db10df6ac84b49d0e1c393465440e9cb1de73ecf7bbf6", size = 3665826, upload-time = "2026-03-13T06:58:59.88Z" }, - { url = "https://files.pythonhosted.org/packages/51/4c/e2688c8ad1760d7c30f7c429c79f35f825932581bc7c9ec811436d2f21a0/hf_xet-1.4.2-cp314-cp314t-win_arm64.whl", hash = "sha256:6d53df40616f7168abfccff100d232e9d460583b9d86fa4912c24845f192f2b8", size = 3529113, upload-time = "2026-03-13T06:58:58.491Z" }, { url = "https://files.pythonhosted.org/packages/b4/86/b40b83a2ff03ef05c4478d2672b1fc2b9683ff870e2b25f4f3af240f2e7b/hf_xet-1.4.2-cp37-abi3-macosx_10_12_x86_64.whl", hash = "sha256:71f02d6e4cdd07f344f6844845d78518cc7186bd2bc52d37c3b73dc26a3b0bc5", size = 3800339, upload-time = "2026-03-13T06:58:36.245Z" }, { url = "https://files.pythonhosted.org/packages/64/2e/af4475c32b4378b0e92a587adb1aa3ec53e3450fd3e5fe0372a874531c00/hf_xet-1.4.2-cp37-abi3-macosx_11_0_arm64.whl", hash = "sha256:e9b38d876e94d4bdcf650778d6ebbaa791dd28de08db9736c43faff06ede1b5a", size = 3559664, upload-time = "2026-03-13T06:58:34.787Z" }, { url = "https://files.pythonhosted.org/packages/3c/4c/781267da3188db679e601de18112021a5cb16506fe86b246e22c5401a9c4/hf_xet-1.4.2-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:77e8c180b7ef12d8a96739a4e1e558847002afe9ea63b6f6358b2271a8bdda1c", size = 4217422, upload-time = "2026-03-13T06:58:27.472Z" }, @@ -1487,13 +1244,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/32/4d/9dd616c38da088e3f436e9a616e1d0cc66544b8cdac405cc4e81c8679fc7/httptools-0.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:44c8f4347d4b31269c8a9205d8a5ee2df5322b09bbbd30f8f862185bb6b05346", size = 455517, upload-time = "2025-10-10T03:54:51.066Z" }, { url = "https://files.pythonhosted.org/packages/1d/3a/a6c595c310b7df958e739aae88724e24f9246a514d909547778d776799be/httptools-0.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:465275d76db4d554918aba40bf1cbebe324670f3dfc979eaffaa5d108e2ed650", size = 458337, upload-time = "2025-10-10T03:54:52.196Z" }, { url = "https://files.pythonhosted.org/packages/fd/82/88e8d6d2c51edc1cc391b6e044c6c435b6aebe97b1abc33db1b0b24cd582/httptools-0.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:322d00c2068d125bd570f7bf78b2d367dad02b919d8581d7476d8b75b294e3e6", size = 85743, upload-time = "2025-10-10T03:54:53.448Z" }, - { url = "https://files.pythonhosted.org/packages/34/50/9d095fcbb6de2d523e027a2f304d4551855c2f46e0b82befd718b8b20056/httptools-0.7.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:c08fe65728b8d70b6923ce31e3956f859d5e1e8548e6f22ec520a962c6757270", size = 203619, upload-time = "2025-10-10T03:54:54.321Z" }, - { url = "https://files.pythonhosted.org/packages/07/f0/89720dc5139ae54b03f861b5e2c55a37dba9a5da7d51e1e824a1f343627f/httptools-0.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7aea2e3c3953521c3c51106ee11487a910d45586e351202474d45472db7d72d3", size = 108714, upload-time = "2025-10-10T03:54:55.163Z" }, - { url = "https://files.pythonhosted.org/packages/b3/cb/eea88506f191fb552c11787c23f9a405f4c7b0c5799bf73f2249cd4f5228/httptools-0.7.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0e68b8582f4ea9166be62926077a3334064d422cf08ab87d8b74664f8e9058e1", size = 472909, upload-time = "2025-10-10T03:54:56.056Z" }, - { url = "https://files.pythonhosted.org/packages/e0/4a/a548bdfae6369c0d078bab5769f7b66f17f1bfaa6fa28f81d6be6959066b/httptools-0.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df091cf961a3be783d6aebae963cc9b71e00d57fa6f149025075217bc6a55a7b", size = 470831, upload-time = "2025-10-10T03:54:57.219Z" }, - { url = "https://files.pythonhosted.org/packages/4d/31/14df99e1c43bd132eec921c2e7e11cda7852f65619bc0fc5bdc2d0cb126c/httptools-0.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f084813239e1eb403ddacd06a30de3d3e09a9b76e7894dcda2b22f8a726e9c60", size = 452631, upload-time = "2025-10-10T03:54:58.219Z" }, - { url = "https://files.pythonhosted.org/packages/22/d2/b7e131f7be8d854d48cb6d048113c30f9a46dca0c9a8b08fcb3fcd588cdc/httptools-0.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7347714368fb2b335e9063bc2b96f2f87a9ceffcd9758ac295f8bbcd3ffbc0ca", size = 452910, upload-time = "2025-10-10T03:54:59.366Z" }, - { url = "https://files.pythonhosted.org/packages/53/cf/878f3b91e4e6e011eff6d1fa9ca39f7eb17d19c9d7971b04873734112f30/httptools-0.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:cfabda2a5bb85aa2a904ce06d974a3f30fb36cc63d7feaddec05d2050acede96", size = 88205, upload-time = "2025-10-10T03:55:00.389Z" }, ] [[package]] @@ -1679,31 +1429,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/36/4a/94e831c6bf287754a8a019cb966ed39ff8be6ab78cadecf08df3bb02d505/jiter-0.13.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9950290340acc1adaded363edd94baebcee7dabdfa8bee4790794cd5cfad2af6", size = 358551, upload-time = "2026-02-02T12:36:39.417Z" }, { url = "https://files.pythonhosted.org/packages/a2/ec/a4c72c822695fa80e55d2b4142b73f0012035d9fcf90eccc56bc060db37c/jiter-0.13.0-cp313-cp313t-win_amd64.whl", hash = "sha256:2b4972c6df33731aac0742b64fd0d18e0a69bc7d6e03108ce7d40c85fd9e3e6d", size = 201950, upload-time = "2026-02-02T12:36:40.791Z" }, { url = "https://files.pythonhosted.org/packages/b6/00/393553ec27b824fbc29047e9c7cd4a3951d7fbe4a76743f17e44034fa4e4/jiter-0.13.0-cp313-cp313t-win_arm64.whl", hash = "sha256:701a1e77d1e593c1b435315ff625fd071f0998c5f02792038a5ca98899261b7d", size = 185852, upload-time = "2026-02-02T12:36:42.077Z" }, - { url = "https://files.pythonhosted.org/packages/6e/f5/f1997e987211f6f9bd71b8083047b316208b4aca0b529bb5f8c96c89ef3e/jiter-0.13.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:cc5223ab19fe25e2f0bf2643204ad7318896fe3729bf12fde41b77bfc4fafff0", size = 308804, upload-time = "2026-02-02T12:36:43.496Z" }, - { url = "https://files.pythonhosted.org/packages/cd/8f/5482a7677731fd44881f0204981ce2d7175db271f82cba2085dd2212e095/jiter-0.13.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9776ebe51713acf438fd9b4405fcd86893ae5d03487546dae7f34993217f8a91", size = 318787, upload-time = "2026-02-02T12:36:45.071Z" }, - { url = "https://files.pythonhosted.org/packages/f3/b9/7257ac59778f1cd025b26a23c5520a36a424f7f1b068f2442a5b499b7464/jiter-0.13.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:879e768938e7b49b5e90b7e3fecc0dbec01b8cb89595861fb39a8967c5220d09", size = 353880, upload-time = "2026-02-02T12:36:47.365Z" }, - { url = "https://files.pythonhosted.org/packages/c3/87/719eec4a3f0841dad99e3d3604ee4cba36af4419a76f3cb0b8e2e691ad67/jiter-0.13.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:682161a67adea11e3aae9038c06c8b4a9a71023228767477d683f69903ebc607", size = 366702, upload-time = "2026-02-02T12:36:48.871Z" }, - { url = "https://files.pythonhosted.org/packages/d2/65/415f0a75cf6921e43365a1bc227c565cb949caca8b7532776e430cbaa530/jiter-0.13.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a13b68cd1cd8cc9de8f244ebae18ccb3e4067ad205220ef324c39181e23bbf66", size = 486319, upload-time = "2026-02-02T12:36:53.006Z" }, - { url = "https://files.pythonhosted.org/packages/54/a2/9e12b48e82c6bbc6081fd81abf915e1443add1b13d8fc586e1d90bb02bb8/jiter-0.13.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:87ce0f14c6c08892b610686ae8be350bf368467b6acd5085a5b65441e2bf36d2", size = 372289, upload-time = "2026-02-02T12:36:54.593Z" }, - { url = "https://files.pythonhosted.org/packages/4e/c1/e4693f107a1789a239c759a432e9afc592366f04e901470c2af89cfd28e1/jiter-0.13.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c365005b05505a90d1c47856420980d0237adf82f70c4aff7aebd3c1cc143ad", size = 360165, upload-time = "2026-02-02T12:36:56.112Z" }, - { url = "https://files.pythonhosted.org/packages/17/08/91b9ea976c1c758240614bd88442681a87672eebc3d9a6dde476874e706b/jiter-0.13.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1317fdffd16f5873e46ce27d0e0f7f4f90f0cdf1d86bf6abeaea9f63ca2c401d", size = 389634, upload-time = "2026-02-02T12:36:57.495Z" }, - { url = "https://files.pythonhosted.org/packages/18/23/58325ef99390d6d40427ed6005bf1ad54f2577866594bcf13ce55675f87d/jiter-0.13.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:c05b450d37ba0c9e21c77fef1f205f56bcee2330bddca68d344baebfc55ae0df", size = 514933, upload-time = "2026-02-02T12:36:58.909Z" }, - { url = "https://files.pythonhosted.org/packages/5b/25/69f1120c7c395fd276c3996bb8adefa9c6b84c12bb7111e5c6ccdcd8526d/jiter-0.13.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:775e10de3849d0631a97c603f996f518159272db00fdda0a780f81752255ee9d", size = 548842, upload-time = "2026-02-02T12:37:00.433Z" }, - { url = "https://files.pythonhosted.org/packages/18/05/981c9669d86850c5fbb0d9e62bba144787f9fba84546ba43d624ee27ef29/jiter-0.13.0-cp314-cp314-win32.whl", hash = "sha256:632bf7c1d28421c00dd8bbb8a3bac5663e1f57d5cd5ed962bce3c73bf62608e6", size = 202108, upload-time = "2026-02-02T12:37:01.718Z" }, - { url = "https://files.pythonhosted.org/packages/8d/96/cdcf54dd0b0341db7d25413229888a346c7130bd20820530905fdb65727b/jiter-0.13.0-cp314-cp314-win_amd64.whl", hash = "sha256:f22ef501c3f87ede88f23f9b11e608581c14f04db59b6a801f354397ae13739f", size = 204027, upload-time = "2026-02-02T12:37:03.075Z" }, - { url = "https://files.pythonhosted.org/packages/fb/f9/724bcaaab7a3cd727031fe4f6995cb86c4bd344909177c186699c8dec51a/jiter-0.13.0-cp314-cp314-win_arm64.whl", hash = "sha256:07b75fe09a4ee8e0c606200622e571e44943f47254f95e2436c8bdcaceb36d7d", size = 187199, upload-time = "2026-02-02T12:37:04.414Z" }, - { url = "https://files.pythonhosted.org/packages/62/92/1661d8b9fd6a3d7a2d89831db26fe3c1509a287d83ad7838831c7b7a5c7e/jiter-0.13.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:964538479359059a35fb400e769295d4b315ae61e4105396d355a12f7fef09f0", size = 318423, upload-time = "2026-02-02T12:37:05.806Z" }, - { url = "https://files.pythonhosted.org/packages/4f/3b/f77d342a54d4ebcd128e520fc58ec2f5b30a423b0fd26acdfc0c6fef8e26/jiter-0.13.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e104da1db1c0991b3eaed391ccd650ae8d947eab1480c733e5a3fb28d4313e40", size = 351438, upload-time = "2026-02-02T12:37:07.189Z" }, - { url = "https://files.pythonhosted.org/packages/76/b3/ba9a69f0e4209bd3331470c723c2f5509e6f0482e416b612431a5061ed71/jiter-0.13.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0e3a5f0cde8ff433b8e88e41aa40131455420fb3649a3c7abdda6145f8cb7202", size = 364774, upload-time = "2026-02-02T12:37:08.579Z" }, - { url = "https://files.pythonhosted.org/packages/b3/16/6cdb31fa342932602458dbb631bfbd47f601e03d2e4950740e0b2100b570/jiter-0.13.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:57aab48f40be1db920a582b30b116fe2435d184f77f0e4226f546794cedd9cf0", size = 487238, upload-time = "2026-02-02T12:37:10.066Z" }, - { url = "https://files.pythonhosted.org/packages/ed/b1/956cc7abaca8d95c13aa8d6c9b3f3797241c246cd6e792934cc4c8b250d2/jiter-0.13.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7772115877c53f62beeb8fd853cab692dbc04374ef623b30f997959a4c0e7e95", size = 372892, upload-time = "2026-02-02T12:37:11.656Z" }, - { url = "https://files.pythonhosted.org/packages/26/c4/97ecde8b1e74f67b8598c57c6fccf6df86ea7861ed29da84629cdbba76c4/jiter-0.13.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1211427574b17b633cfceba5040de8081e5abf114f7a7602f73d2e16f9fdaa59", size = 360309, upload-time = "2026-02-02T12:37:13.244Z" }, - { url = "https://files.pythonhosted.org/packages/4b/d7/eabe3cf46715854ccc80be2cd78dd4c36aedeb30751dbf85a1d08c14373c/jiter-0.13.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7beae3a3d3b5212d3a55d2961db3c292e02e302feb43fce6a3f7a31b90ea6dfe", size = 389607, upload-time = "2026-02-02T12:37:14.881Z" }, - { url = "https://files.pythonhosted.org/packages/df/2d/03963fc0804e6109b82decfb9974eb92df3797fe7222428cae12f8ccaa0c/jiter-0.13.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:e5562a0f0e90a6223b704163ea28e831bd3a9faa3512a711f031611e6b06c939", size = 514986, upload-time = "2026-02-02T12:37:16.326Z" }, - { url = "https://files.pythonhosted.org/packages/f6/6c/8c83b45eb3eb1c1e18d841fe30b4b5bc5619d781267ca9bc03e005d8fd0a/jiter-0.13.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:6c26a424569a59140fb51160a56df13f438a2b0967365e987889186d5fc2f6f9", size = 548756, upload-time = "2026-02-02T12:37:17.736Z" }, - { url = "https://files.pythonhosted.org/packages/47/66/eea81dfff765ed66c68fd2ed8c96245109e13c896c2a5015c7839c92367e/jiter-0.13.0-cp314-cp314t-win32.whl", hash = "sha256:24dc96eca9f84da4131cdf87a95e6ce36765c3b156fc9ae33280873b1c32d5f6", size = 201196, upload-time = "2026-02-02T12:37:19.101Z" }, - { url = "https://files.pythonhosted.org/packages/ff/32/4ac9c7a76402f8f00d00842a7f6b83b284d0cf7c1e9d4227bc95aa6d17fa/jiter-0.13.0-cp314-cp314t-win_amd64.whl", hash = "sha256:0a8d76c7524087272c8ae913f5d9d608bd839154b62c4322ef65723d2e5bb0b8", size = 204215, upload-time = "2026-02-02T12:37:20.495Z" }, - { url = "https://files.pythonhosted.org/packages/f9/8e/7def204fea9f9be8b3c21a6f2dd6c020cf56c7d5ff753e0e23ed7f9ea57e/jiter-0.13.0-cp314-cp314t-win_arm64.whl", hash = "sha256:2c26cf47e2cad140fa23b6d58d435a7c0161f5c514284802f25e87fddfe11024", size = 187152, upload-time = "2026-02-02T12:37:22.124Z" }, { url = "https://files.pythonhosted.org/packages/80/60/e50fa45dd7e2eae049f0ce964663849e897300433921198aef94b6ffa23a/jiter-0.13.0-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:3d744a6061afba08dd7ae375dcde870cffb14429b7477e10f67e9e6d68772a0a", size = 305169, upload-time = "2026-02-02T12:37:50.376Z" }, { url = "https://files.pythonhosted.org/packages/d2/73/a009f41c5eed71c49bec53036c4b33555afcdee70682a18c6f66e396c039/jiter-0.13.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:ff732bd0a0e778f43d5009840f20b935e79087b4dc65bd36f1cd0f9b04b8ff7f", size = 303808, upload-time = "2026-02-02T12:37:52.092Z" }, { url = "https://files.pythonhosted.org/packages/c4/10/528b439290763bff3d939268085d03382471b442f212dca4ff5f12802d43/jiter-0.13.0-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ab44b178f7981fcaea7e0a5df20e773c663d06ffda0198f1a524e91b2fde7e59", size = 337384, upload-time = "2026-02-02T12:37:53.582Z" }, @@ -1861,36 +1586,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1b/bd/877056304626943ff0f1f44c08f584300c199b887cb3176cd7e34f1515f1/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:fc4d3f1fb9ca0ae9f97b095963bc6326f1dbfd3779d6679a1e016b9baaa153d3", size = 2597482, upload-time = "2026-03-09T13:14:34.971Z" }, { url = "https://files.pythonhosted.org/packages/75/19/c60626c47bf0f8ac5dcf72c6c98e266d714f2fbbfd50cf6dab5ede3aaa50/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f443b4825c50a51ee68585522ab4a1d1257fac65896f282b4c6763337ac9f5d2", size = 2394328, upload-time = "2026-03-09T13:14:36.816Z" }, { url = "https://files.pythonhosted.org/packages/47/84/6a6d5e5bb8273756c27b7d810d47f7ef2f1f9b9fd23c9ee9a3f8c75c9cef/kiwisolver-1.5.0-cp313-cp313t-win_arm64.whl", hash = "sha256:893ff3a711d1b515ba9da14ee090519bad4610ed1962fbe298a434e8c5f8db53", size = 68410, upload-time = "2026-03-09T13:14:38.695Z" }, - { url = "https://files.pythonhosted.org/packages/e4/d7/060f45052f2a01ad5762c8fdecd6d7a752b43400dc29ff75cd47225a40fd/kiwisolver-1.5.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8df31fe574b8b3993cc61764f40941111b25c2d9fea13d3ce24a49907cd2d615", size = 123231, upload-time = "2026-03-09T13:14:41.323Z" }, - { url = "https://files.pythonhosted.org/packages/c2/a7/78da680eadd06ff35edef6ef68a1ad273bad3e2a0936c9a885103230aece/kiwisolver-1.5.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:1d49a49ac4cbfb7c1375301cd1ec90169dfeae55ff84710d782260ce77a75a02", size = 66489, upload-time = "2026-03-09T13:14:42.534Z" }, - { url = "https://files.pythonhosted.org/packages/49/b2/97980f3ad4fae37dd7fe31626e2bf75fbf8bdf5d303950ec1fab39a12da8/kiwisolver-1.5.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0cbe94b69b819209a62cb27bdfa5dc2a8977d8de2f89dfd97ba4f53ed3af754e", size = 64063, upload-time = "2026-03-09T13:14:44.759Z" }, - { url = "https://files.pythonhosted.org/packages/e7/f9/b06c934a6aa8bc91f566bd2a214fd04c30506c2d9e2b6b171953216a65b6/kiwisolver-1.5.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:80aa065ffd378ff784822a6d7c3212f2d5f5e9c3589614b5c228b311fd3063ac", size = 1475913, upload-time = "2026-03-09T13:14:46.247Z" }, - { url = "https://files.pythonhosted.org/packages/6b/f0/f768ae564a710135630672981231320bc403cf9152b5596ec5289de0f106/kiwisolver-1.5.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e7f886f47ab881692f278ae901039a234e4025a68e6dfab514263a0b1c4ae05", size = 1282782, upload-time = "2026-03-09T13:14:48.458Z" }, - { url = "https://files.pythonhosted.org/packages/e2/9f/1de7aad00697325f05238a5f2eafbd487fb637cc27a558b5367a5f37fb7f/kiwisolver-1.5.0-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5060731cc3ed12ca3a8b57acd4aeca5bbc2f49216dd0bec1650a1acd89486bcd", size = 1300815, upload-time = "2026-03-09T13:14:50.721Z" }, - { url = "https://files.pythonhosted.org/packages/5a/c2/297f25141d2e468e0ce7f7a7b92e0cf8918143a0cbd3422c1ad627e85a06/kiwisolver-1.5.0-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7a4aa69609f40fce3cbc3f87b2061f042eee32f94b8f11db707b66a26461591a", size = 1347925, upload-time = "2026-03-09T13:14:52.304Z" }, - { url = "https://files.pythonhosted.org/packages/b9/d3/f4c73a02eb41520c47610207b21afa8cdd18fdbf64ffd94674ae21c4812d/kiwisolver-1.5.0-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:d168fda2dbff7b9b5f38e693182d792a938c31db4dac3a80a4888de603c99554", size = 991322, upload-time = "2026-03-09T13:14:54.637Z" }, - { url = "https://files.pythonhosted.org/packages/7b/46/d3f2efef7732fcda98d22bf4ad5d3d71d545167a852ca710a494f4c15343/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:413b820229730d358efd838ecbab79902fe97094565fdc80ddb6b0a18c18a581", size = 2232857, upload-time = "2026-03-09T13:14:56.471Z" }, - { url = "https://files.pythonhosted.org/packages/3f/ec/2d9756bf2b6d26ae4349b8d3662fb3993f16d80c1f971c179ce862b9dbae/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:5124d1ea754509b09e53738ec185584cc609aae4a3b510aaf4ed6aa047ef9303", size = 2329376, upload-time = "2026-03-09T13:14:58.072Z" }, - { url = "https://files.pythonhosted.org/packages/8f/9f/876a0a0f2260f1bde92e002b3019a5fabc35e0939c7d945e0fa66185eb20/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e4415a8db000bf49a6dd1c478bf70062eaacff0f462b92b0ba68791a905861f9", size = 1982549, upload-time = "2026-03-09T13:14:59.668Z" }, - { url = "https://files.pythonhosted.org/packages/6c/4f/ba3624dfac23a64d54ac4179832860cb537c1b0af06024936e82ca4154a0/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d618fd27420381a4f6044faa71f46d8bfd911bd077c555f7138ed88729bfbe79", size = 2494680, upload-time = "2026-03-09T13:15:01.364Z" }, - { url = "https://files.pythonhosted.org/packages/39/b7/97716b190ab98911b20d10bf92eca469121ec483b8ce0edd314f51bc85af/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5092eb5b1172947f57d6ea7d89b2f29650414e4293c47707eb499ec07a0ac796", size = 2297905, upload-time = "2026-03-09T13:15:03.925Z" }, - { url = "https://files.pythonhosted.org/packages/a3/36/4e551e8aa55c9188bca9abb5096805edbf7431072b76e2298e34fd3a3008/kiwisolver-1.5.0-cp314-cp314-win_amd64.whl", hash = "sha256:d76e2d8c75051d58177e762164d2e9ab92886534e3a12e795f103524f221dd8e", size = 75086, upload-time = "2026-03-09T13:15:07.775Z" }, - { url = "https://files.pythonhosted.org/packages/70/15/9b90f7df0e31a003c71649cf66ef61c3c1b862f48c81007fa2383c8bd8d7/kiwisolver-1.5.0-cp314-cp314-win_arm64.whl", hash = "sha256:fa6248cd194edff41d7ea9425ced8ca3a6f838bfb295f6f1d6e6bb694a8518df", size = 66577, upload-time = "2026-03-09T13:15:09.139Z" }, - { url = "https://files.pythonhosted.org/packages/17/01/7dc8c5443ff42b38e72731643ed7cf1ed9bf01691ae5cdca98501999ed83/kiwisolver-1.5.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:d1ffeb80b5676463d7a7d56acbe8e37a20ce725570e09549fe738e02ca6b7e1e", size = 125794, upload-time = "2026-03-09T13:15:10.525Z" }, - { url = "https://files.pythonhosted.org/packages/46/8a/b4ebe46ebaac6a303417fab10c2e165c557ddaff558f9699d302b256bc53/kiwisolver-1.5.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:bc4d8e252f532ab46a1de9349e2d27b91fce46736a9eedaa37beaca66f574ed4", size = 67646, upload-time = "2026-03-09T13:15:12.016Z" }, - { url = "https://files.pythonhosted.org/packages/60/35/10a844afc5f19d6f567359bf4789e26661755a2f36200d5d1ed8ad0126e5/kiwisolver-1.5.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6783e069732715ad0c3ce96dbf21dbc2235ab0593f2baf6338101f70371f4028", size = 65511, upload-time = "2026-03-09T13:15:13.311Z" }, - { url = "https://files.pythonhosted.org/packages/f8/8a/685b297052dd041dcebce8e8787b58923b6e78acc6115a0dc9189011c44b/kiwisolver-1.5.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e7c4c09a490dc4d4a7f8cbee56c606a320f9dc28cf92a7157a39d1ce7676a657", size = 1584858, upload-time = "2026-03-09T13:15:15.103Z" }, - { url = "https://files.pythonhosted.org/packages/9e/80/04865e3d4638ac5bddec28908916df4a3075b8c6cc101786a96803188b96/kiwisolver-1.5.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2a075bd7bd19c70cf67c8badfa36cf7c5d8de3c9ddb8420c51e10d9c50e94920", size = 1392539, upload-time = "2026-03-09T13:15:16.661Z" }, - { url = "https://files.pythonhosted.org/packages/ba/01/77a19cacc0893fa13fafa46d1bba06fb4dc2360b3292baf4b56d8e067b24/kiwisolver-1.5.0-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bdd3e53429ff02aa319ba59dfe4ceeec345bf46cf180ec2cf6fd5b942e7975e9", size = 1405310, upload-time = "2026-03-09T13:15:18.229Z" }, - { url = "https://files.pythonhosted.org/packages/53/39/bcaf5d0cca50e604cfa9b4e3ae1d64b50ca1ae5b754122396084599ef903/kiwisolver-1.5.0-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3cdcb35dc9d807259c981a85531048ede628eabcffb3239adf3d17463518992d", size = 1456244, upload-time = "2026-03-09T13:15:20.444Z" }, - { url = "https://files.pythonhosted.org/packages/d0/7a/72c187abc6975f6978c3e39b7cf67aeb8b3c0a8f9790aa7fd412855e9e1f/kiwisolver-1.5.0-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:70d593af6a6ca332d1df73d519fddb5148edb15cd90d5f0155e3746a6d4fcc65", size = 1073154, upload-time = "2026-03-09T13:15:22.039Z" }, - { url = "https://files.pythonhosted.org/packages/c7/ca/cf5b25783ebbd59143b4371ed0c8428a278abe68d6d0104b01865b1bbd0f/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:377815a8616074cabbf3f53354e1d040c35815a134e01d7614b7692e4bf8acfa", size = 2334377, upload-time = "2026-03-09T13:15:23.741Z" }, - { url = "https://files.pythonhosted.org/packages/4a/e5/b1f492adc516796e88751282276745340e2a72dcd0d36cf7173e0daf3210/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0255a027391d52944eae1dbb5d4cc5903f57092f3674e8e544cdd2622826b3f0", size = 2425288, upload-time = "2026-03-09T13:15:25.789Z" }, - { url = "https://files.pythonhosted.org/packages/e6/e5/9b21fbe91a61b8f409d74a26498706e97a48008bfcd1864373d32a6ba31c/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:012b1eb16e28718fa782b5e61dc6f2da1f0792ca73bd05d54de6cb9561665fc9", size = 2063158, upload-time = "2026-03-09T13:15:27.63Z" }, - { url = "https://files.pythonhosted.org/packages/b1/02/83f47986138310f95ea95531f851b2a62227c11cbc3e690ae1374fe49f0f/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:0e3aafb33aed7479377e5e9a82e9d4bf87063741fc99fc7ae48b0f16e32bdd6f", size = 2597260, upload-time = "2026-03-09T13:15:29.421Z" }, - { url = "https://files.pythonhosted.org/packages/07/18/43a5f24608d8c313dd189cf838c8e68d75b115567c6279de7796197cfb6a/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e7a116ae737f0000343218c4edf5bd45893bfeaff0993c0b215d7124c9f77646", size = 2394403, upload-time = "2026-03-09T13:15:31.517Z" }, - { url = "https://files.pythonhosted.org/packages/3b/b5/98222136d839b8afabcaa943b09bd05888c2d36355b7e448550211d1fca4/kiwisolver-1.5.0-cp314-cp314t-win_amd64.whl", hash = "sha256:1dd9b0b119a350976a6d781e7278ec7aca0b201e1a9e2d23d9804afecb6ca681", size = 79687, upload-time = "2026-03-09T13:15:33.204Z" }, - { url = "https://files.pythonhosted.org/packages/99/a2/ca7dc962848040befed12732dff6acae7fb3c4f6fc4272b3f6c9a30b8713/kiwisolver-1.5.0-cp314-cp314t-win_arm64.whl", hash = "sha256:58f812017cd2985c21fbffb4864d59174d4903dd66fa23815e74bbc7a0e2dd57", size = 70032, upload-time = "2026-03-09T13:15:34.411Z" }, { url = "https://files.pythonhosted.org/packages/1c/fa/2910df836372d8761bb6eff7d8bdcb1613b5c2e03f260efe7abe34d388a7/kiwisolver-1.5.0-graalpy312-graalpy250_312_native-macosx_10_13_x86_64.whl", hash = "sha256:5ae8e62c147495b01a0f4765c878e9bfdf843412446a247e28df59936e99e797", size = 130262, upload-time = "2026-03-09T13:15:35.629Z" }, { url = "https://files.pythonhosted.org/packages/0f/41/c5f71f9f00aabcc71fee8b7475e3f64747282580c2fe748961ba29b18385/kiwisolver-1.5.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:f6764a4ccab3078db14a632420930f6186058750df066b8ea2a7106df91d3203", size = 138036, upload-time = "2026-03-09T13:15:36.894Z" }, { url = "https://files.pythonhosted.org/packages/fa/06/7399a607f434119c6e1fdc8ec89a8d51ccccadf3341dee4ead6bd14caaf5/kiwisolver-1.5.0-graalpy312-graalpy250_312_native-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c31c13da98624f957b0fb1b5bae5383b2333c2c3f6793d9825dd5ce79b525cb7", size = 194295, upload-time = "2026-03-09T13:15:38.22Z" }, @@ -1998,72 +1693,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e7/a7/485df55acf55dc35e4ca89d2f48f03889e5a3241826b18b85102b32ce9d8/lxml-6.1.3-cp313-cp313-win32.whl", hash = "sha256:160fcf381f76c3aeac28a756bec44f48942a8f7245a87aa28e3a523b4d90cd87", size = 3602214, upload-time = "2026-09-02T14:48:53.236Z" }, { url = "https://files.pythonhosted.org/packages/c0/28/e46a7702bd95e9043291f7c3539b6184cba66f96cea9936f20939b284eeb/lxml-6.1.3-cp313-cp313-win_amd64.whl", hash = "sha256:e477aca0bc0d19f3b4ae9e4f2a1cfd687c31bf772d78734910658186b40b2477", size = 4004091, upload-time = "2026-09-02T14:48:55.699Z" }, { url = "https://files.pythonhosted.org/packages/8a/1d/154c78e20479a43916e63f19cb720d83f44f024b03228be44c92d9a97b24/lxml-6.1.3-cp313-cp313-win_arm64.whl", hash = "sha256:b1cc980905221a5d8b3c476330730b3adb40ff80add71ffbdb6215ba055656f1", size = 3665468, upload-time = "2026-09-02T14:48:57.703Z" }, - { url = "https://files.pythonhosted.org/packages/0c/15/fc75a70b0af6021d0ea16811f1fc71cc42cd06ce90fe10f007a69b2eed84/lxml-6.1.3-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:2bec13085dc8ef48a3fe62f7dfcacfeda2c785cdf19cc8eeda2bb9ed081da165", size = 8609725, upload-time = "2026-09-02T14:49:00.156Z" }, - { url = "https://files.pythonhosted.org/packages/84/ef/398fcf9018f881ec9aeaafae1ddd6586dfb13314a35d35e899de373dcae0/lxml-6.1.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:4f4db7c7e954d289d71878938348b3d91b904a3e8210a11939359fb758a58e7d", size = 4639629, upload-time = "2026-09-02T14:49:02.81Z" }, - { url = "https://files.pythonhosted.org/packages/a7/2d/49b6a6ad7ce8f64b07b9fe852ff0c6d3fcbb26db61bee4f63d4120180a1c/lxml-6.1.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2cae5d5c90a62d9139c512a0cb1aad1d182b022b5740daea2617eb5bf7fc658e", size = 4965074, upload-time = "2026-09-02T14:49:05.133Z" }, - { url = "https://files.pythonhosted.org/packages/66/bc/6230cf80e4331c33383b0b6b73dc31a393dd76edd4cb73d761de5123034d/lxml-6.1.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c6c0c13128a32eb04a51357e56a094e13aa8e6d3d1884de2e9ae923f6915e1a8", size = 5099355, upload-time = "2026-09-02T14:49:07.343Z" }, - { url = "https://files.pythonhosted.org/packages/ac/cf/d1143d9b7717e07a82f158a1fc9ce6e581fdad1226734950af869e3ffde4/lxml-6.1.3-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2221e88679d1351e9a40aaee54bc65679b9795bbd0160bc3d5e36b163344eb75", size = 5036795, upload-time = "2026-09-02T14:49:09.65Z" }, - { url = "https://files.pythonhosted.org/packages/31/6f/194bb00ffb89712c30f5a7e1b8e685590e140fad6c8261fec172c09a3dc0/lxml-6.1.3-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cfb398886a7eb4c719161c3efcff2a1248febc53a4d8e5072d2d8a87fed84ac9", size = 5658740, upload-time = "2026-09-02T14:49:11.9Z" }, - { url = "https://files.pythonhosted.org/packages/e9/44/27e3cee3dcdb3b7bc09727b642bdbfcd098490ea77df04611db9060d7722/lxml-6.1.3-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a7eb78ba28b187e1e9203a55c60fcf70df2d22cb205fe6d51b9383d6097419f0", size = 5245991, upload-time = "2026-09-02T14:49:14.154Z" }, - { url = "https://files.pythonhosted.org/packages/ca/e9/8312560579fc980bbd2233a8a673cc46f7d613d3633f2bf08a21e8f4ad13/lxml-6.1.3-cp314-cp314-manylinux_2_28_i686.whl", hash = "sha256:ea6b1e9105b4b24a34c722432d9fb578f9ed83af21fa1abda639011e0f22bbb6", size = 5354136, upload-time = "2026-09-02T14:49:16.459Z" }, - { url = "https://files.pythonhosted.org/packages/74/d8/eda60f4f73a9c780b5d6e1175484f66e6c81a2c93346e2906a1fec9c7a02/lxml-6.1.3-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:e8b17e23df3e827a69d25af70990ca2420e92668aaffaeeb3cd2351d7916a023", size = 4704379, upload-time = "2026-09-02T14:49:19.032Z" }, - { url = "https://files.pythonhosted.org/packages/ba/c8/c9cc60057be78ac34bd2b842e45e6e88edbfe5e532e82c3b82381b7aab49/lxml-6.1.3-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1b7c37339d7e75cab9a123a04248e243cefefb302ad6db566ea0c77cbcde421e", size = 5258676, upload-time = "2026-09-02T14:49:21.306Z" }, - { url = "https://files.pythonhosted.org/packages/41/7b/66894008fee8d1785b8db129747ae963fd427b68f456918df7f2f24a8b98/lxml-6.1.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:83e3a51e7933db700a0da0db31849db3a24022d9970da9bb73001e1d0326fd92", size = 5090069, upload-time = "2026-09-02T14:49:23.562Z" }, - { url = "https://files.pythonhosted.org/packages/8b/31/c1b60404859f4c3cd1f41f29c65a24e25cea78fde822d9574a21f66810be/lxml-6.1.3-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:9bde9ae026a55b9a192078dfa6e27dd0ca4a050171ab6272e92f97b757dfdf48", size = 4741958, upload-time = "2026-09-02T14:49:26.037Z" }, - { url = "https://files.pythonhosted.org/packages/23/b8/6285f0cf546f14da2554cabdeaf7c2c2ff3190c74807f0de2e8810a786f9/lxml-6.1.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:1a635e837b50a1819bebfedaac5916498ea024120969da8790500148fb0a894d", size = 5683245, upload-time = "2026-09-02T14:49:28.438Z" }, - { url = "https://files.pythonhosted.org/packages/d3/f6/2168cab44336dcb15fed0f0b78577225b83297cdf0dee349c95420c3dcb0/lxml-6.1.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:d0c5c362bc94f1929dc7e96e715bbe7bd17037f802e6d8f0d1545df9133c0559", size = 5246087, upload-time = "2026-09-02T14:49:30.955Z" }, - { url = "https://files.pythonhosted.org/packages/f5/89/32f5de69a0a31f30e6164981851f87b37ecb2c4ee838e504b88d49d4818e/lxml-6.1.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c59e4265608da6a041f54646ecc0c9ecdbb19aaf14c4c684bb6c2114998cc415", size = 5269352, upload-time = "2026-09-02T14:49:33.502Z" }, - { url = "https://files.pythonhosted.org/packages/a2/a1/741d952ed3a7ef7a50055c6415aec3f067015e97f72f4389ce77b09657ba/lxml-6.1.3-cp314-cp314-win32.whl", hash = "sha256:2e62c569ec7531b679b184cbfe335c501c1d13c4b363560013019962eb630e6d", size = 3662783, upload-time = "2026-09-02T14:50:23.751Z" }, - { url = "https://files.pythonhosted.org/packages/0f/bc/5811cc73cac05e324e05ba9b0924e1a163a317a167ede8a9c748b11db30a/lxml-6.1.3-cp314-cp314-win_amd64.whl", hash = "sha256:66299564c046bc7e0cc5de5106601eae907e9fa5904cd68a323380a8502f7861", size = 4073951, upload-time = "2026-09-02T14:50:26.348Z" }, - { url = "https://files.pythonhosted.org/packages/92/18/3768c8b01ac3a9bed1914715e6011711b00e2a11628ffa6f7fa37f8e0269/lxml-6.1.3-cp314-cp314-win_arm64.whl", hash = "sha256:ebd054ad1737a68fb7c5c073d405cef2b88bb824e294de3b4a4e995b47f0e376", size = 3749279, upload-time = "2026-09-02T14:50:28.749Z" }, - { url = "https://files.pythonhosted.org/packages/72/38/84684784738d9451db2b330de2483f496690c3a5c642071df24135739b37/lxml-6.1.3-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:5a143e6207579de8baeded4eaac9134413200359f1969d636f0bfb98ee8c3c8f", size = 8860296, upload-time = "2026-09-02T14:49:36.346Z" }, - { url = "https://files.pythonhosted.org/packages/24/b7/fc4c50bb1b38e864010ea396046cabe85129bf9e65b11edcfbc37d356241/lxml-6.1.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:a1cec0f99b9b914d39176347a93b7610dc09324491aee1cbc57cd291a41a1d55", size = 4755190, upload-time = "2026-09-02T14:49:39.872Z" }, - { url = "https://files.pythonhosted.org/packages/94/e2/ee9aa6ed2b666b2db1f6f7fd48964ff9da39ebe827ef5eac0ab881f639d9/lxml-6.1.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f6b9d2aad499c769ee8287609ab0e6de99d8bcea99c6e6c2e64945259fd52fb2", size = 4979517, upload-time = "2026-09-02T14:49:42.153Z" }, - { url = "https://files.pythonhosted.org/packages/29/e3/e7763d1661b283ddd4fa36f91b9a497db6b8d2aff55028b16c7f642e0755/lxml-6.1.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a23fefdb345b2d4d0ff2860571b5ff9a89a28b6a120f720e8fb0324d346626", size = 5115270, upload-time = "2026-09-02T14:49:44.493Z" }, - { url = "https://files.pythonhosted.org/packages/2d/cd/22205d5b4d177e3f4156f780412426ee7c7f8107809f119f0dcc40fa51e3/lxml-6.1.3-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:545ccc14fb05485f48b4439ec35beb16d5b5280eb6c81c658bd4707a2a119414", size = 5032449, upload-time = "2026-09-02T14:49:46.841Z" }, - { url = "https://files.pythonhosted.org/packages/da/43/06a4626c3bb79ef8c501b674afab8100d64e798665bb2a97d1c960636a49/lxml-6.1.3-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:93476b6514b373fc6ca67d26c442784f7807c86f00635bfe79f935c3eab2af17", size = 5603325, upload-time = "2026-09-02T14:49:49.664Z" }, - { url = "https://files.pythonhosted.org/packages/d0/9c/733682a0c2de9f5779ba207bbb3f3f6be8c6bda863fc01739b186b38783a/lxml-6.1.3-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8db38ff3fb7aee7d6a82ae4da2eef1178656fe1216841fbd24870062a9d60473", size = 5229023, upload-time = "2026-09-02T14:49:52.447Z" }, - { url = "https://files.pythonhosted.org/packages/c6/8a/e69cdaca3fd33a647942925664f01b20908d41a6968c182305be9c38fb11/lxml-6.1.3-cp314-cp314t-manylinux_2_28_i686.whl", hash = "sha256:25f4118c438f96bb466e83108506d03d5c31b1bd2387e83e5b070bda6ded9c37", size = 5317811, upload-time = "2026-09-02T14:49:55.25Z" }, - { url = "https://files.pythonhosted.org/packages/2e/b2/0c397588174403c2ab68fc464abf97e03e7324f9c6cb6a99023104707195/lxml-6.1.3-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:1beb0f9909b26cee938df9ba56b15252a84429b1fc30ce6fca161390b9789a70", size = 4646516, upload-time = "2026-09-02T14:49:57.761Z" }, - { url = "https://files.pythonhosted.org/packages/56/7e/cfea25afafbe49db8b225764f7f74bb37c2a7f5e717d917d3d4a5e098ed4/lxml-6.1.3-cp314-cp314t-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3a27ac6c780c8b8a1cd231b58407634cafc1c4cc28cd6c7141362df0f36351e7", size = 5240626, upload-time = "2026-09-02T14:50:00.279Z" }, - { url = "https://files.pythonhosted.org/packages/a1/75/7a587771bb52ebb0e2c57b6dbe9fd96a70fbb54d72ddd97d54c5f8ec18d5/lxml-6.1.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:a1932d7ce78a561367512c594fe66eac2b2ec9b9264cfd9b5f950622f4a116e2", size = 5086619, upload-time = "2026-09-02T14:50:03.245Z" }, - { url = "https://files.pythonhosted.org/packages/1e/01/94c0ebe6d831861542d251e038052e52bf6d33f1d18f1cfffdc82851065a/lxml-6.1.3-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:7d0f5976aa2701996f759b30172925829867547bb073af0ae67d1307a0f0262c", size = 4758828, upload-time = "2026-09-02T14:50:05.873Z" }, - { url = "https://files.pythonhosted.org/packages/1f/f1/938d67bd0e5b1fdfa52be28aefdffbad57e1f6b8e921c2aab88542c75f40/lxml-6.1.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:c5e7ce578aa8a80910a72a8ca0bbea3baae10100827249001999726a788456d8", size = 5627083, upload-time = "2026-09-02T14:50:08.555Z" }, - { url = "https://files.pythonhosted.org/packages/d8/65/4e51522f6c214650db0abb7b16ccd11b1238b8a05a8d59aa4ebed59c9f67/lxml-6.1.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:d97c5227621af74b111882a290b10f371780a38eef9d9e730408fba2259b52fb", size = 5235170, upload-time = "2026-09-02T14:50:11.255Z" }, - { url = "https://files.pythonhosted.org/packages/92/c2/e73d19365665f6b16ef84df21199befc3b06e4c539046ad2d9595f6fb9ea/lxml-6.1.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:da707f14ea3c35ee463d50acd596d6488e4b2b4ae7cf77a5bf93f55c023d63e8", size = 5252273, upload-time = "2026-09-02T14:50:13.782Z" }, - { url = "https://files.pythonhosted.org/packages/48/a9/7f386c84c9fe2854e1ca6e231c285e1c8f392971ac353c6865e6ec49faff/lxml-6.1.3-cp314-cp314t-win32.whl", hash = "sha256:9efe56a68179f3adc4de41861c9358931db03837c48dd5e1c78077b84dd07f3a", size = 3902712, upload-time = "2026-09-02T14:50:16.171Z" }, - { url = "https://files.pythonhosted.org/packages/82/a6/8a3eb793f7900ef01c7f99e6f5fcbcfbdff35251cfaef66b32a4c16352d6/lxml-6.1.3-cp314-cp314t-win_amd64.whl", hash = "sha256:c9389b3784b56c58d933b5e0aecdf28f901b073ff385358d8a7d40907f6e14b2", size = 4400979, upload-time = "2026-09-02T14:50:18.621Z" }, - { url = "https://files.pythonhosted.org/packages/cc/c4/3807bea283b4fe9e9d9f5dde46a73df91178472b335d2778e10b2a37aa22/lxml-6.1.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32a409be3190b088f960ac92bfedfbef2f86c49ff940765e1548177592d20026", size = 3823401, upload-time = "2026-09-02T14:50:21.119Z" }, - { url = "https://files.pythonhosted.org/packages/e1/8e/4614fcd65496054cfb7172662f3576a59200278739506433b8c241ea422a/lxml-6.1.3-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:6ea2f13dce778ca072ccee598bca46a092ce192e8fd907b6c1f0e52c800529a0", size = 8609378, upload-time = "2026-09-02T14:50:31.772Z" }, - { url = "https://files.pythonhosted.org/packages/f2/51/2cdce3c65fa99a6195dd8fbd512d33407c1000ad99f63e0a285b63d7a8eb/lxml-6.1.3-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:c581b1d68b3845fb86c6b2983e755b29bf001461c59fa411d2c26a911b6559a9", size = 4640022, upload-time = "2026-09-02T14:50:34.41Z" }, - { url = "https://files.pythonhosted.org/packages/52/09/0b30084e9eb1c546a4be3d9c56df70058d116b1a320400a59b0f7da87bf0/lxml-6.1.3-cp315-cp315-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2e01125896585139453cab8cb235893644d8815d7509520da95ae3ee8d1c1f79", size = 5037928, upload-time = "2026-09-02T14:50:37.007Z" }, - { url = "https://files.pythonhosted.org/packages/b8/0e/5c37275a3e361f6138dc06db748ea565c1fe8a5f4ee5e2ddd80047c81a89/lxml-6.1.3-cp315-cp315-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:290f66b97ede0e552e1cb44a0fd8a74f9753ee635b50830a0b122fb72788d015", size = 5661932, upload-time = "2026-09-02T14:50:39.777Z" }, - { url = "https://files.pythonhosted.org/packages/70/c5/b71ffb289b15e2642e2a3cf6d468c44da39ea119061a99e5b05e3d10f217/lxml-6.1.3-cp315-cp315-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:73fc05988ed20809450474ba760a87c8ad4e455fc09783c02195e56ec634b41a", size = 5249209, upload-time = "2026-09-02T14:50:42.141Z" }, - { url = "https://files.pythonhosted.org/packages/81/ea/9910da149a23932f9301652e57661cd9e42b0df18f12be21159b7255f92b/lxml-6.1.3-cp315-cp315-manylinux_2_31_armv7l.whl", hash = "sha256:dc3a44689eea43eab836e5c98a8ab015dc2419987d1ea6eafc7c590cdff86bed", size = 4704543, upload-time = "2026-09-02T14:50:44.634Z" }, - { url = "https://files.pythonhosted.org/packages/76/07/9290329cd188c62e22021f79df04ee0cc33d9a93b0d38bd65ccd452ad9d0/lxml-6.1.3-cp315-cp315-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:209c3ccbfe35a04ac6d24f0611f9d1cbf8025d49991b14acd935236234d6c156", size = 5261298, upload-time = "2026-09-02T14:50:47.301Z" }, - { url = "https://files.pythonhosted.org/packages/c9/0c/aba78bd3401cd99b73a0aed8e2b9b43e14be94fab3603d4bbc8a62365f2a/lxml-6.1.3-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:2f5b2a2b9811b853b39bfa41367c6d78747b8e3e80e07fc5a24aae295c1a4d7d", size = 5090453, upload-time = "2026-09-02T14:50:49.952Z" }, - { url = "https://files.pythonhosted.org/packages/8d/dc/fa4426c3355aa0216cbeb3911495b5f65a26e0df85859a89928fe28f0396/lxml-6.1.3-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:6a406d0b3cb207b0fa460ed4dc93e866f44f105da0169361cb18ff998a44c7f0", size = 4744709, upload-time = "2026-09-02T14:50:52.394Z" }, - { url = "https://files.pythonhosted.org/packages/be/2b/224fe7918658ab7c532ac2412f3c1eb28f71e6364fb07566262d0cc6a7b6/lxml-6.1.3-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:53258656846f5c48996b882fb4b135885e088a3ad3d96b4bc0530f95124d1f69", size = 5685802, upload-time = "2026-09-02T14:50:55.043Z" }, - { url = "https://files.pythonhosted.org/packages/21/44/7d480819b9adcae5f84dd8ac529132c6b7a578544398225cd20321adcd91/lxml-6.1.3-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:aa633613ff907ea91b9b0489a1f0da1b8725d8c6ccec6b77e8a1c9c235044bb0", size = 5249019, upload-time = "2026-09-02T14:50:57.985Z" }, - { url = "https://files.pythonhosted.org/packages/72/83/385a267ea1b6b283f2249dd827ef360a295e9db14e13ef4665a120c60d64/lxml-6.1.3-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:90f709b9accab6b2e4d14f5c8718203877a0486bcb3afd74d8b539ecd1e961d4", size = 5271886, upload-time = "2026-09-02T14:51:01.667Z" }, - { url = "https://files.pythonhosted.org/packages/d8/0d/f967b0eb172ae876855a402d6d9b11fa86e3e0c89ca9bbfeadf7ffbfa719/lxml-6.1.3-cp315-cp315-win32.whl", hash = "sha256:b4fc6b03b9d9d90557274f571ab30e7fbbfc527955536935d96f98b6817a86e4", size = 3662894, upload-time = "2026-09-02T14:51:45.173Z" }, - { url = "https://files.pythonhosted.org/packages/f4/48/d8a8c4160a29e663109ad520bac2deb37fcd014756d024561e8bc3e611ec/lxml-6.1.3-cp315-cp315-win_amd64.whl", hash = "sha256:33cadd956b667997e4de1635fce9541f2e8ede2038fcde8cf55aa14d571d1bad", size = 4074626, upload-time = "2026-09-02T14:51:47.77Z" }, - { url = "https://files.pythonhosted.org/packages/25/20/3e1395d34d19f9254625d0b567b81cf70d37d3417be074f4d63b94a2be3c/lxml-6.1.3-cp315-cp315-win_arm64.whl", hash = "sha256:8a330c0ee5fa318c7b5cbbaad882baeca3f570357e7eb25ab34bf31008150758", size = 3749495, upload-time = "2026-09-02T14:51:50.663Z" }, - { url = "https://files.pythonhosted.org/packages/8f/c6/7465ffd9c43883526a382df6fa4846c9d8d419214f7effbf65270e795471/lxml-6.1.3-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:0bf5a3e397df2ec4258eb5eea4c1ac6cf013ca1abd04a176903bff20a70021fe", size = 8857677, upload-time = "2026-09-02T14:51:05.109Z" }, - { url = "https://files.pythonhosted.org/packages/ed/eb/1f3a917e299df43c8162c3e6f64fc2cea3bcf277910f35bff5b8e5d39901/lxml-6.1.3-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:13d22c0d57355366b393936acf6b98a5e0edeadddd3fccbc6a846c50a76b8741", size = 4754522, upload-time = "2026-09-02T14:51:08.137Z" }, - { url = "https://files.pythonhosted.org/packages/d7/f9/f81b4bdb6efb7a596be29603d8758154d00a5f545db9f3cef9d9041c8f64/lxml-6.1.3-cp315-cp315t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cad7617727a96d189bd6f979d0fadf765198c7934e85f4edaba9bf3ad919a300", size = 5033744, upload-time = "2026-09-02T14:51:10.633Z" }, - { url = "https://files.pythonhosted.org/packages/c8/0f/26d9bfaacb319c86e0eca8a1a0bf1130d36a7afbd318883e23caea63763d/lxml-6.1.3-cp315-cp315t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cae82b5ca24b0c2beedb269f6e2a96f466acd926879ab00ae19f1a65cbf9ffb0", size = 5615269, upload-time = "2026-09-02T14:51:13.357Z" }, - { url = "https://files.pythonhosted.org/packages/5d/90/73675f3f4141350ed65d6fec533b107d4e802c5caa340cf111771edd86e0/lxml-6.1.3-cp315-cp315t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:69cafd61aea04ebb3502c93c2aaa568b12931ca0802231e0b5de76bf8b6e74bd", size = 5236280, upload-time = "2026-09-02T14:51:16.051Z" }, - { url = "https://files.pythonhosted.org/packages/fd/be/ed260767e7977de463a0f91f3f4fffcab85c0a2a024a21ffe1fa442c2c79/lxml-6.1.3-cp315-cp315t-manylinux_2_31_armv7l.whl", hash = "sha256:dc205732d593118cf701d986f40e9de7801bb2e371cb189ddbda9b7348f4d97e", size = 4650718, upload-time = "2026-09-02T14:51:19.102Z" }, - { url = "https://files.pythonhosted.org/packages/d0/fd/e9839d03b1e767f2725cf7d7d81b80d5f3f9fdc10ad8827e2479311b046e/lxml-6.1.3-cp315-cp315t-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:88e719b9437f148f7e1465df845c758dd1598618cbea3a2fd1e61a715542f2b2", size = 5243376, upload-time = "2026-09-02T14:51:21.606Z" }, - { url = "https://files.pythonhosted.org/packages/34/a5/4606e347e2788c301f677004aa83e28d24da9fe663a24380122af57be6fc/lxml-6.1.3-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:40983eabefd13da003e68170928c7acc011f0d095eefce5871a3c71c9385fb9a", size = 5092340, upload-time = "2026-09-02T14:51:24.21Z" }, - { url = "https://files.pythonhosted.org/packages/ea/99/3314a8661cdf30f493c55a87db283961dfaae08451976a2ca418958e1804/lxml-6.1.3-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:fad67b12ffe0f71e02b4932b04883cbc76a9072bbd30731409d3523cf058b011", size = 4758768, upload-time = "2026-09-02T14:51:26.813Z" }, - { url = "https://files.pythonhosted.org/packages/30/58/3bdc577f78ea8b7d72d39a84506f7001d5b28728f43e5b84891e3b7d9a4a/lxml-6.1.3-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:6cd11e7550d89e551a87dcec30f04b1fca32e86b68708aa01a4daa455d8605e5", size = 5649546, upload-time = "2026-09-02T14:51:29.453Z" }, - { url = "https://files.pythonhosted.org/packages/6a/e4/652633de1a2395949ebb7a8fc7d089aba12a2b45f0fefbc9d29e3e3ab3cf/lxml-6.1.3-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:ca0ec532ad2f5ba1e5ec120ac157769c57f01855b3d8bf37213f5d88abd9ba0a", size = 5234874, upload-time = "2026-09-02T14:51:32.262Z" }, - { url = "https://files.pythonhosted.org/packages/65/a6/c4581d171de30449304b4859bbd3607e9b40da13c0f88b68e6097c8d785e/lxml-6.1.3-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:e99e09ab7741f1281e2677f4c0058c7f5267d182530b09c87e4f6aa26adf3887", size = 5260043, upload-time = "2026-09-02T14:51:34.841Z" }, - { url = "https://files.pythonhosted.org/packages/b8/d7/ed6ee6186a89e69ca4ea9658b2a278f46a5efe8b5d4db56c7197f18653fe/lxml-6.1.3-cp315-cp315t-win32.whl", hash = "sha256:ace1d2c83b2bd24db5940600541140e87a325e119cb32d5fa9ad720d7e76648e", size = 3901093, upload-time = "2026-09-02T14:51:37.234Z" }, - { url = "https://files.pythonhosted.org/packages/67/9d/11d10257a4a048d04195d638bb61f0246ce2448eb05f682bcbab25a257a8/lxml-6.1.3-cp315-cp315t-win_amd64.whl", hash = "sha256:b49638355ea3bebba70da783ccbc630fd72afa16bc46c54474bfa1f9a915bbc6", size = 4395446, upload-time = "2026-09-02T14:51:39.884Z" }, - { url = "https://files.pythonhosted.org/packages/f8/b7/44edd7de434181c582892e68d1ffe6775ca403ce14aea07cb5a218a936cf/lxml-6.1.3-cp315-cp315t-win_arm64.whl", hash = "sha256:5a721a98c649855963811b59b55755b30566e7f7fc40bdc9803d66dee9f811cf", size = 3822836, upload-time = "2026-09-02T14:51:42.471Z" }, ] [[package]] @@ -2125,20 +1754,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c0/3d/8b94a481456dfc9dfe6e39e93b5ab376e50998cddfd23f4ae3b431708f16/matplotlib-3.10.8-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:0a33deb84c15ede243aead39f77e990469fff93ad1521163305095b77b72ce4a", size = 9614000, upload-time = "2025-12-10T22:56:05.411Z" }, { url = "https://files.pythonhosted.org/packages/bd/cd/bc06149fe5585ba800b189a6a654a75f1f127e8aab02fd2be10df7fa500c/matplotlib-3.10.8-cp313-cp313t-win_amd64.whl", hash = "sha256:3a48a78d2786784cc2413e57397981fb45c79e968d99656706018d6e62e57958", size = 8220043, upload-time = "2025-12-10T22:56:07.551Z" }, { url = "https://files.pythonhosted.org/packages/e3/de/b22cf255abec916562cc04eef457c13e58a1990048de0c0c3604d082355e/matplotlib-3.10.8-cp313-cp313t-win_arm64.whl", hash = "sha256:15d30132718972c2c074cd14638c7f4592bd98719e2308bccea40e0538bc0cb5", size = 8062075, upload-time = "2025-12-10T22:56:09.178Z" }, - { url = "https://files.pythonhosted.org/packages/3c/43/9c0ff7a2f11615e516c3b058e1e6e8f9614ddeca53faca06da267c48345d/matplotlib-3.10.8-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:b53285e65d4fa4c86399979e956235deb900be5baa7fc1218ea67fbfaeaadd6f", size = 8262481, upload-time = "2025-12-10T22:56:10.885Z" }, - { url = "https://files.pythonhosted.org/packages/6f/ca/e8ae28649fcdf039fda5ef554b40a95f50592a3c47e6f7270c9561c12b07/matplotlib-3.10.8-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:32f8dce744be5569bebe789e46727946041199030db8aeb2954d26013a0eb26b", size = 8151473, upload-time = "2025-12-10T22:56:12.377Z" }, - { url = "https://files.pythonhosted.org/packages/f1/6f/009d129ae70b75e88cbe7e503a12a4c0670e08ed748a902c2568909e9eb5/matplotlib-3.10.8-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4cf267add95b1c88300d96ca837833d4112756045364f5c734a2276038dae27d", size = 9553896, upload-time = "2025-12-10T22:56:14.432Z" }, - { url = "https://files.pythonhosted.org/packages/f5/26/4221a741eb97967bc1fd5e4c52b9aa5a91b2f4ec05b59f6def4d820f9df9/matplotlib-3.10.8-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2cf5bd12cecf46908f286d7838b2abc6c91cda506c0445b8223a7c19a00df008", size = 9824193, upload-time = "2025-12-10T22:56:16.29Z" }, - { url = "https://files.pythonhosted.org/packages/1f/f3/3abf75f38605772cf48a9daf5821cd4f563472f38b4b828c6fba6fa6d06e/matplotlib-3.10.8-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:41703cc95688f2516b480f7f339d8851a6035f18e100ee6a32bc0b8536a12a9c", size = 9615444, upload-time = "2025-12-10T22:56:18.155Z" }, - { url = "https://files.pythonhosted.org/packages/93/a5/de89ac80f10b8dc615807ee1133cd99ac74082581196d4d9590bea10690d/matplotlib-3.10.8-cp314-cp314-win_amd64.whl", hash = "sha256:83d282364ea9f3e52363da262ce32a09dfe241e4080dcedda3c0db059d3c1f11", size = 8272719, upload-time = "2025-12-10T22:56:20.366Z" }, - { url = "https://files.pythonhosted.org/packages/69/ce/b006495c19ccc0a137b48083168a37bd056392dee02f87dba0472f2797fe/matplotlib-3.10.8-cp314-cp314-win_arm64.whl", hash = "sha256:2c1998e92cd5999e295a731bcb2911c75f597d937341f3030cc24ef2733d78a8", size = 8144205, upload-time = "2025-12-10T22:56:22.239Z" }, - { url = "https://files.pythonhosted.org/packages/68/d9/b31116a3a855bd313c6fcdb7226926d59b041f26061c6c5b1be66a08c826/matplotlib-3.10.8-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:b5a2b97dbdc7d4f353ebf343744f1d1f1cca8aa8bfddb4262fcf4306c3761d50", size = 8305785, upload-time = "2025-12-10T22:56:24.218Z" }, - { url = "https://files.pythonhosted.org/packages/1e/90/6effe8103f0272685767ba5f094f453784057072f49b393e3ea178fe70a5/matplotlib-3.10.8-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3f5c3e4da343bba819f0234186b9004faba952cc420fbc522dc4e103c1985908", size = 8198361, upload-time = "2025-12-10T22:56:26.787Z" }, - { url = "https://files.pythonhosted.org/packages/d7/65/a73188711bea603615fc0baecca1061429ac16940e2385433cc778a9d8e7/matplotlib-3.10.8-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f62550b9a30afde8c1c3ae450e5eb547d579dd69b25c2fc7a1c67f934c1717a", size = 9561357, upload-time = "2025-12-10T22:56:28.953Z" }, - { url = "https://files.pythonhosted.org/packages/f4/3d/b5c5d5d5be8ce63292567f0e2c43dde9953d3ed86ac2de0a72e93c8f07a1/matplotlib-3.10.8-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:495672de149445ec1b772ff2c9ede9b769e3cb4f0d0aa7fa730d7f59e2d4e1c1", size = 9823610, upload-time = "2025-12-10T22:56:31.455Z" }, - { url = "https://files.pythonhosted.org/packages/4d/4b/e7beb6bbd49f6bae727a12b270a2654d13c397576d25bd6786e47033300f/matplotlib-3.10.8-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:595ba4d8fe983b88f0eec8c26a241e16d6376fe1979086232f481f8f3f67494c", size = 9614011, upload-time = "2025-12-10T22:56:33.85Z" }, - { url = "https://files.pythonhosted.org/packages/7c/e6/76f2813d31f032e65f6f797e3f2f6e4aab95b65015924b1c51370395c28a/matplotlib-3.10.8-cp314-cp314t-win_amd64.whl", hash = "sha256:25d380fe8b1dc32cf8f0b1b448470a77afb195438bafdf1d858bfb876f3edf7b", size = 8362801, upload-time = "2025-12-10T22:56:36.107Z" }, - { url = "https://files.pythonhosted.org/packages/5d/49/d651878698a0b67f23aa28e17f45a6d6dd3d3f933fa29087fa4ce5947b5a/matplotlib-3.10.8-cp314-cp314t-win_arm64.whl", hash = "sha256:113bb52413ea508ce954a02c10ffd0d565f9c3bc7f2eddc27dfe1731e71c7b5f", size = 8192560, upload-time = "2025-12-10T22:56:38.008Z" }, ] [[package]] @@ -2272,42 +1887,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/dc/1d/b31650eab6c5778aceed46ba735bd97f7c7d2f54b319fa916c0f96e7805b/multidict-6.7.1-cp313-cp313t-win32.whl", hash = "sha256:df9f19c28adcb40b6aae30bbaa1478c389efd50c28d541d76760199fc1037c32", size = 47770, upload-time = "2026-01-26T02:45:06.754Z" }, { url = "https://files.pythonhosted.org/packages/ac/5b/2d2d1d522e51285bd61b1e20df8f47ae1a9d80839db0b24ea783b3832832/multidict-6.7.1-cp313-cp313t-win_amd64.whl", hash = "sha256:d54ecf9f301853f2c5e802da559604b3e95bb7a3b01a9c295c6ee591b9882de8", size = 53109, upload-time = "2026-01-26T02:45:08.044Z" }, { url = "https://files.pythonhosted.org/packages/3d/a3/cc409ba012c83ca024a308516703cf339bdc4b696195644a7215a5164a24/multidict-6.7.1-cp313-cp313t-win_arm64.whl", hash = "sha256:5a37ca18e360377cfda1d62f5f382ff41f2b8c4ccb329ed974cc2e1643440118", size = 45573, upload-time = "2026-01-26T02:45:09.349Z" }, - { url = "https://files.pythonhosted.org/packages/91/cc/db74228a8be41884a567e88a62fd589a913708fcf180d029898c17a9a371/multidict-6.7.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8f333ec9c5eb1b7105e3b84b53141e66ca05a19a605368c55450b6ba208cb9ee", size = 75190, upload-time = "2026-01-26T02:45:10.651Z" }, - { url = "https://files.pythonhosted.org/packages/d5/22/492f2246bb5b534abd44804292e81eeaf835388901f0c574bac4eeec73c5/multidict-6.7.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a407f13c188f804c759fc6a9f88286a565c242a76b27626594c133b82883b5c2", size = 44486, upload-time = "2026-01-26T02:45:11.938Z" }, - { url = "https://files.pythonhosted.org/packages/f1/4f/733c48f270565d78b4544f2baddc2fb2a245e5a8640254b12c36ac7ac68e/multidict-6.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0e161ddf326db5577c3a4cc2d8648f81456e8a20d40415541587a71620d7a7d1", size = 43219, upload-time = "2026-01-26T02:45:14.346Z" }, - { url = "https://files.pythonhosted.org/packages/24/bb/2c0c2287963f4259c85e8bcbba9182ced8d7fca65c780c38e99e61629d11/multidict-6.7.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1e3a8bb24342a8201d178c3b4984c26ba81a577c80d4d525727427460a50c22d", size = 245132, upload-time = "2026-01-26T02:45:15.712Z" }, - { url = "https://files.pythonhosted.org/packages/a7/f9/44d4b3064c65079d2467888794dea218d1601898ac50222ab8a9a8094460/multidict-6.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97231140a50f5d447d3164f994b86a0bed7cd016e2682f8650d6a9158e14fd31", size = 252420, upload-time = "2026-01-26T02:45:17.293Z" }, - { url = "https://files.pythonhosted.org/packages/8b/13/78f7275e73fa17b24c9a51b0bd9d73ba64bb32d0ed51b02a746eb876abe7/multidict-6.7.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6b10359683bd8806a200fd2909e7c8ca3a7b24ec1d8132e483d58e791d881048", size = 233510, upload-time = "2026-01-26T02:45:19.356Z" }, - { url = "https://files.pythonhosted.org/packages/4b/25/8167187f62ae3cbd52da7893f58cb036b47ea3fb67138787c76800158982/multidict-6.7.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:283ddac99f7ac25a4acadbf004cb5ae34480bbeb063520f70ce397b281859362", size = 264094, upload-time = "2026-01-26T02:45:20.834Z" }, - { url = "https://files.pythonhosted.org/packages/a1/e7/69a3a83b7b030cf283fb06ce074a05a02322359783424d7edf0f15fe5022/multidict-6.7.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:538cec1e18c067d0e6103aa9a74f9e832904c957adc260e61cd9d8cf0c3b3d37", size = 260786, upload-time = "2026-01-26T02:45:22.818Z" }, - { url = "https://files.pythonhosted.org/packages/fe/3b/8ec5074bcfc450fe84273713b4b0a0dd47c0249358f5d82eb8104ffe2520/multidict-6.7.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7eee46ccb30ff48a1e35bb818cc90846c6be2b68240e42a78599166722cea709", size = 248483, upload-time = "2026-01-26T02:45:24.368Z" }, - { url = "https://files.pythonhosted.org/packages/48/5a/d5a99e3acbca0e29c5d9cba8f92ceb15dce78bab963b308ae692981e3a5d/multidict-6.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa263a02f4f2dd2d11a7b1bb4362aa7cb1049f84a9235d31adf63f30143469a0", size = 248403, upload-time = "2026-01-26T02:45:25.982Z" }, - { url = "https://files.pythonhosted.org/packages/35/48/e58cd31f6c7d5102f2a4bf89f96b9cf7e00b6c6f3d04ecc44417c00a5a3c/multidict-6.7.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:2e1425e2f99ec5bd36c15a01b690a1a2456209c5deed58f95469ffb46039ccbb", size = 240315, upload-time = "2026-01-26T02:45:27.487Z" }, - { url = "https://files.pythonhosted.org/packages/94/33/1cd210229559cb90b6786c30676bb0c58249ff42f942765f88793b41fdce/multidict-6.7.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:497394b3239fc6f0e13a78a3e1b61296e72bf1c5f94b4c4eb80b265c37a131cd", size = 245528, upload-time = "2026-01-26T02:45:28.991Z" }, - { url = "https://files.pythonhosted.org/packages/64/f2/6e1107d226278c876c783056b7db43d800bb64c6131cec9c8dfb6903698e/multidict-6.7.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:233b398c29d3f1b9676b4b6f75c518a06fcb2ea0b925119fb2c1bc35c05e1601", size = 258784, upload-time = "2026-01-26T02:45:30.503Z" }, - { url = "https://files.pythonhosted.org/packages/4d/c1/11f664f14d525e4a1b5327a82d4de61a1db604ab34c6603bb3c2cc63ad34/multidict-6.7.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:93b1818e4a6e0930454f0f2af7dfce69307ca03cdcfb3739bf4d91241967b6c1", size = 251980, upload-time = "2026-01-26T02:45:32.603Z" }, - { url = "https://files.pythonhosted.org/packages/e1/9f/75a9ac888121d0c5bbd4ecf4eead45668b1766f6baabfb3b7f66a410e231/multidict-6.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f33dc2a3abe9249ea5d8360f969ec7f4142e7ac45ee7014d8f8d5acddf178b7b", size = 243602, upload-time = "2026-01-26T02:45:34.043Z" }, - { url = "https://files.pythonhosted.org/packages/9a/e7/50bf7b004cc8525d80dbbbedfdc7aed3e4c323810890be4413e589074032/multidict-6.7.1-cp314-cp314-win32.whl", hash = "sha256:3ab8b9d8b75aef9df299595d5388b14530839f6422333357af1339443cff777d", size = 40930, upload-time = "2026-01-26T02:45:36.278Z" }, - { url = "https://files.pythonhosted.org/packages/e0/bf/52f25716bbe93745595800f36fb17b73711f14da59ed0bb2eba141bc9f0f/multidict-6.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:5e01429a929600e7dab7b166062d9bb54a5eed752384c7384c968c2afab8f50f", size = 45074, upload-time = "2026-01-26T02:45:37.546Z" }, - { url = "https://files.pythonhosted.org/packages/97/ab/22803b03285fa3a525f48217963da3a65ae40f6a1b6f6cf2768879e208f9/multidict-6.7.1-cp314-cp314-win_arm64.whl", hash = "sha256:4885cb0e817aef5d00a2e8451d4665c1808378dc27c2705f1bf4ef8505c0d2e5", size = 42471, upload-time = "2026-01-26T02:45:38.889Z" }, - { url = "https://files.pythonhosted.org/packages/e0/6d/f9293baa6146ba9507e360ea0292b6422b016907c393e2f63fc40ab7b7b5/multidict-6.7.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:0458c978acd8e6ea53c81eefaddbbee9c6c5e591f41b3f5e8e194780fe026581", size = 82401, upload-time = "2026-01-26T02:45:40.254Z" }, - { url = "https://files.pythonhosted.org/packages/7a/68/53b5494738d83558d87c3c71a486504d8373421c3e0dbb6d0db48ad42ee0/multidict-6.7.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:c0abd12629b0af3cf590982c0b413b1e7395cd4ec026f30986818ab95bfaa94a", size = 48143, upload-time = "2026-01-26T02:45:41.635Z" }, - { url = "https://files.pythonhosted.org/packages/37/e8/5284c53310dcdc99ce5d66563f6e5773531a9b9fe9ec7a615e9bc306b05f/multidict-6.7.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:14525a5f61d7d0c94b368a42cff4c9a4e7ba2d52e2672a7b23d84dc86fb02b0c", size = 46507, upload-time = "2026-01-26T02:45:42.99Z" }, - { url = "https://files.pythonhosted.org/packages/e4/fc/6800d0e5b3875568b4083ecf5f310dcf91d86d52573160834fb4bfcf5e4f/multidict-6.7.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:17307b22c217b4cf05033dabefe68255a534d637c6c9b0cc8382718f87be4262", size = 239358, upload-time = "2026-01-26T02:45:44.376Z" }, - { url = "https://files.pythonhosted.org/packages/41/75/4ad0973179361cdf3a113905e6e088173198349131be2b390f9fa4da5fc6/multidict-6.7.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7a7e590ff876a3eaf1c02a4dfe0724b6e69a9e9de6d8f556816f29c496046e59", size = 246884, upload-time = "2026-01-26T02:45:47.167Z" }, - { url = "https://files.pythonhosted.org/packages/c3/9c/095bb28b5da139bd41fb9a5d5caff412584f377914bd8787c2aa98717130/multidict-6.7.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5fa6a95dfee63893d80a34758cd0e0c118a30b8dcb46372bf75106c591b77889", size = 225878, upload-time = "2026-01-26T02:45:48.698Z" }, - { url = "https://files.pythonhosted.org/packages/07/d0/c0a72000243756e8f5a277b6b514fa005f2c73d481b7d9e47cd4568aa2e4/multidict-6.7.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a0543217a6a017692aa6ae5cc39adb75e587af0f3a82288b1492eb73dd6cc2a4", size = 253542, upload-time = "2026-01-26T02:45:50.164Z" }, - { url = "https://files.pythonhosted.org/packages/c0/6b/f69da15289e384ecf2a68837ec8b5ad8c33e973aa18b266f50fe55f24b8c/multidict-6.7.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f99fe611c312b3c1c0ace793f92464d8cd263cc3b26b5721950d977b006b6c4d", size = 252403, upload-time = "2026-01-26T02:45:51.779Z" }, - { url = "https://files.pythonhosted.org/packages/a2/76/b9669547afa5a1a25cd93eaca91c0da1c095b06b6d2d8ec25b713588d3a1/multidict-6.7.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9004d8386d133b7e6135679424c91b0b854d2d164af6ea3f289f8f2761064609", size = 244889, upload-time = "2026-01-26T02:45:53.27Z" }, - { url = "https://files.pythonhosted.org/packages/7e/a9/a50d2669e506dad33cfc45b5d574a205587b7b8a5f426f2fbb2e90882588/multidict-6.7.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e628ef0e6859ffd8273c69412a2465c4be4a9517d07261b33334b5ec6f3c7489", size = 241982, upload-time = "2026-01-26T02:45:54.919Z" }, - { url = "https://files.pythonhosted.org/packages/c5/bb/1609558ad8b456b4827d3c5a5b775c93b87878fd3117ed3db3423dfbce1b/multidict-6.7.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:841189848ba629c3552035a6a7f5bf3b02eb304e9fea7492ca220a8eda6b0e5c", size = 232415, upload-time = "2026-01-26T02:45:56.981Z" }, - { url = "https://files.pythonhosted.org/packages/d8/59/6f61039d2aa9261871e03ab9dc058a550d240f25859b05b67fd70f80d4b3/multidict-6.7.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:ce1bbd7d780bb5a0da032e095c951f7014d6b0a205f8318308140f1a6aba159e", size = 240337, upload-time = "2026-01-26T02:45:58.698Z" }, - { url = "https://files.pythonhosted.org/packages/a1/29/fdc6a43c203890dc2ae9249971ecd0c41deaedfe00d25cb6564b2edd99eb/multidict-6.7.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b26684587228afed0d50cf804cc71062cc9c1cdf55051c4c6345d372947b268c", size = 248788, upload-time = "2026-01-26T02:46:00.862Z" }, - { url = "https://files.pythonhosted.org/packages/a9/14/a153a06101323e4cf086ecee3faadba52ff71633d471f9685c42e3736163/multidict-6.7.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9f9af11306994335398293f9958071019e3ab95e9a707dc1383a35613f6abcb9", size = 242842, upload-time = "2026-01-26T02:46:02.824Z" }, - { url = "https://files.pythonhosted.org/packages/41/5f/604ae839e64a4a6efc80db94465348d3b328ee955e37acb24badbcd24d83/multidict-6.7.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b4938326284c4f1224178a560987b6cf8b4d38458b113d9b8c1db1a836e640a2", size = 240237, upload-time = "2026-01-26T02:46:05.898Z" }, - { url = "https://files.pythonhosted.org/packages/5f/60/c3a5187bf66f6fb546ff4ab8fb5a077cbdd832d7b1908d4365c7f74a1917/multidict-6.7.1-cp314-cp314t-win32.whl", hash = "sha256:98655c737850c064a65e006a3df7c997cd3b220be4ec8fe26215760b9697d4d7", size = 48008, upload-time = "2026-01-26T02:46:07.468Z" }, - { url = "https://files.pythonhosted.org/packages/0c/f7/addf1087b860ac60e6f382240f64fb99f8bfb532bb06f7c542b83c29ca61/multidict-6.7.1-cp314-cp314t-win_amd64.whl", hash = "sha256:497bde6223c212ba11d462853cfa4f0ae6ef97465033e7dc9940cdb3ab5b48e5", size = 53542, upload-time = "2026-01-26T02:46:08.809Z" }, - { url = "https://files.pythonhosted.org/packages/4c/81/4629d0aa32302ef7b2ec65c75a728cc5ff4fa410c50096174c1632e70b3e/multidict-6.7.1-cp314-cp314t-win_arm64.whl", hash = "sha256:2bbd113e0d4af5db41d5ebfe9ccaff89de2120578164f86a5d17d5a576d1e5b2", size = 44719, upload-time = "2026-01-26T02:46:11.146Z" }, { url = "https://files.pythonhosted.org/packages/81/08/7036c080d7117f28a4af526d794aab6a84463126db031b007717c1a6676e/multidict-6.7.1-py3-none-any.whl", hash = "sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56", size = 12319, upload-time = "2026-01-26T02:46:44.004Z" }, ] @@ -2443,13 +2022,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ad/04/ae2479e9841b64bd2eb44f8a64756c62593f896514369a11243b1b86ca5c/onnxruntime-1.24.4-cp313-cp313-win_arm64.whl", hash = "sha256:71d6a5c1821d6e8586a024000ece458db8f2fc0ecd050435d45794827ce81e19", size = 12269852, upload-time = "2026-03-17T22:05:33.353Z" }, { url = "https://files.pythonhosted.org/packages/b4/af/a479a536c4398ffaf49fbbe755f45d5b8726bdb4335ab31b537f3d7149b8/onnxruntime-1.24.4-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1700f559c8086d06b2a4d5de51e62cb4ff5e2631822f71a36db8c72383db71ee", size = 15176861, upload-time = "2026-03-17T22:03:40.143Z" }, { url = "https://files.pythonhosted.org/packages/be/13/19f5da70c346a76037da2c2851ecbf1266e61d7f0dcdb887c667210d4608/onnxruntime-1.24.4-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4c74e268dc808e61e63784d43f9ddcdaf50a776c2819e8bd1d1b11ef64bf7e36", size = 17247454, upload-time = "2026-03-17T22:04:46.643Z" }, - { url = "https://files.pythonhosted.org/packages/89/db/b30dbbd6037847b205ab75d962bc349bf1e46d02a65b30d7047a6893ffd6/onnxruntime-1.24.4-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:fbff2a248940e3398ae78374c5a839e49a2f39079b488bc64439fa0ec327a3e4", size = 17343300, upload-time = "2026-03-17T22:03:59.223Z" }, - { url = "https://files.pythonhosted.org/packages/61/88/1746c0e7959961475b84c776d35601a21d445f463c93b1433a409ec3e188/onnxruntime-1.24.4-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e2b7969e72d8cb53ffc88ab6d49dd5e75c1c663bda7be7eb0ece192f127343d1", size = 15175936, upload-time = "2026-03-17T22:03:43.671Z" }, - { url = "https://files.pythonhosted.org/packages/5f/ba/4699cde04a52cece66cbebc85bd8335a0d3b9ad485abc9a2e15946a1349d/onnxruntime-1.24.4-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:14ed1f197fab812b695a5eaddb536c635e58a2fbbe50a517c78f082cc6ce9177", size = 17246432, upload-time = "2026-03-17T22:04:49.58Z" }, - { url = "https://files.pythonhosted.org/packages/ef/60/4590910841bb28bd3b4b388a9efbedf4e2d2cca99ddf0c863642b4e87814/onnxruntime-1.24.4-cp314-cp314-win_amd64.whl", hash = "sha256:311e309f573bf3c12aa5723e23823077f83d5e412a18499d4485c7eb41040858", size = 12903276, upload-time = "2026-03-17T22:05:46.349Z" }, - { url = "https://files.pythonhosted.org/packages/7f/6f/60e2c0acea1e1ac09b3e794b5a19c166eebf91c0b860b3e6db8e74983fda/onnxruntime-1.24.4-cp314-cp314-win_arm64.whl", hash = "sha256:3f0b910e86b759a4732663ec61fd57ac42ee1b0066f68299de164220b660546d", size = 12594365, upload-time = "2026-03-17T22:05:35.795Z" }, - { url = "https://files.pythonhosted.org/packages/cf/68/0c05d10f8f6c40fe0912ebec0d5a33884aaa2af2053507e864dab0883208/onnxruntime-1.24.4-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa12ddc54c9c4594073abcaa265cd9681e95fb89dae982a6f508a794ca42e661", size = 15176889, upload-time = "2026-03-17T22:03:48.021Z" }, - { url = "https://files.pythonhosted.org/packages/6c/1d/1666dc64e78d8587d168fec4e3b7922b92eb286a2ddeebcf6acb55c7dc82/onnxruntime-1.24.4-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e1cc6a518255f012134bc791975a6294806be9a3b20c4a54cca25194c90cf731", size = 17247021, upload-time = "2026-03-17T22:04:52.377Z" }, ] [[package]] @@ -2641,21 +2213,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0a/5b/6ebcf3defc1aab3a338ca777214966851e92efb1f30dc7fc8285216e6d1b/orjson-3.11.7-cp313-cp313-win32.whl", hash = "sha256:7477aa6a6ec6139c5cb1cc7b214643592169a5494d200397c7fc95d740d5fcf3", size = 127914, upload-time = "2026-02-02T15:38:23.511Z" }, { url = "https://files.pythonhosted.org/packages/00/04/c6f72daca5092e3117840a1b1e88dfc809cc1470cf0734890d0366b684a1/orjson-3.11.7-cp313-cp313-win_amd64.whl", hash = "sha256:b9f95dcdea9d4f805daa9ddf02617a89e484c6985fa03055459f90e87d7a0757", size = 124986, upload-time = "2026-02-02T15:38:24.836Z" }, { url = "https://files.pythonhosted.org/packages/03/ba/077a0f6f1085d6b806937246860fafbd5b17f3919c70ee3f3d8d9c713f38/orjson-3.11.7-cp313-cp313-win_arm64.whl", hash = "sha256:800988273a014a0541483dc81021247d7eacb0c845a9d1a34a422bc718f41539", size = 126045, upload-time = "2026-02-02T15:38:26.216Z" }, - { url = "https://files.pythonhosted.org/packages/e9/1e/745565dca749813db9a093c5ebc4bac1a9475c64d54b95654336ac3ed961/orjson-3.11.7-cp314-cp314-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:de0a37f21d0d364954ad5de1970491d7fbd0fb1ef7417d4d56a36dc01ba0c0a0", size = 228391, upload-time = "2026-02-02T15:38:27.757Z" }, - { url = "https://files.pythonhosted.org/packages/46/19/e40f6225da4d3aa0c8dc6e5219c5e87c2063a560fe0d72a88deb59776794/orjson-3.11.7-cp314-cp314-macosx_15_0_arm64.whl", hash = "sha256:c2428d358d85e8da9d37cba18b8c4047c55222007a84f97156a5b22028dfbfc0", size = 125188, upload-time = "2026-02-02T15:38:29.241Z" }, - { url = "https://files.pythonhosted.org/packages/9d/7e/c4de2babef2c0817fd1f048fd176aa48c37bec8aef53d2fa932983032cce/orjson-3.11.7-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3c4bc6c6ac52cdaa267552544c73e486fecbd710b7ac09bc024d5a78555a22f6", size = 128097, upload-time = "2026-02-02T15:38:30.618Z" }, - { url = "https://files.pythonhosted.org/packages/eb/74/233d360632bafd2197f217eee7fb9c9d0229eac0c18128aee5b35b0014fe/orjson-3.11.7-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bd0d68edd7dfca1b2eca9361a44ac9f24b078de3481003159929a0573f21a6bf", size = 123364, upload-time = "2026-02-02T15:38:32.363Z" }, - { url = "https://files.pythonhosted.org/packages/79/51/af79504981dd31efe20a9e360eb49c15f06df2b40e7f25a0a52d9ae888e8/orjson-3.11.7-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:623ad1b9548ef63886319c16fa317848e465a21513b31a6ad7b57443c3e0dcf5", size = 129076, upload-time = "2026-02-02T15:38:33.68Z" }, - { url = "https://files.pythonhosted.org/packages/67/e2/da898eb68b72304f8de05ca6715870d09d603ee98d30a27e8a9629abc64b/orjson-3.11.7-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6e776b998ac37c0396093d10290e60283f59cfe0fc3fccbd0ccc4bd04dd19892", size = 141705, upload-time = "2026-02-02T15:38:34.989Z" }, - { url = "https://files.pythonhosted.org/packages/c5/89/15364d92acb3d903b029e28d834edb8780c2b97404cbf7929aa6b9abdb24/orjson-3.11.7-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:652c6c3af76716f4a9c290371ba2e390ede06f6603edb277b481daf37f6f464e", size = 130855, upload-time = "2026-02-02T15:38:36.379Z" }, - { url = "https://files.pythonhosted.org/packages/c2/8b/ecdad52d0b38d4b8f514be603e69ccd5eacf4e7241f972e37e79792212ec/orjson-3.11.7-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a56df3239294ea5964adf074c54bcc4f0ccd21636049a2cf3ca9cf03b5d03cf1", size = 133386, upload-time = "2026-02-02T15:38:37.704Z" }, - { url = "https://files.pythonhosted.org/packages/b9/0e/45e1dcf10e17d0924b7c9162f87ec7b4ca79e28a0548acf6a71788d3e108/orjson-3.11.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:bda117c4148e81f746655d5a3239ae9bd00cb7bc3ca178b5fc5a5997e9744183", size = 138295, upload-time = "2026-02-02T15:38:39.096Z" }, - { url = "https://files.pythonhosted.org/packages/63/d7/4d2e8b03561257af0450f2845b91fbd111d7e526ccdf737267108075e0ba/orjson-3.11.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:23d6c20517a97a9daf1d48b580fcdc6f0516c6f4b5038823426033690b4d2650", size = 408720, upload-time = "2026-02-02T15:38:40.634Z" }, - { url = "https://files.pythonhosted.org/packages/78/cf/d45343518282108b29c12a65892445fc51f9319dc3c552ceb51bb5905ed2/orjson-3.11.7-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:8ff206156006da5b847c9304b6308a01e8cdbc8cce824e2779a5ba71c3def141", size = 144152, upload-time = "2026-02-02T15:38:42.262Z" }, - { url = "https://files.pythonhosted.org/packages/a9/3a/d6001f51a7275aacd342e77b735c71fa04125a3f93c36fee4526bc8c654e/orjson-3.11.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:962d046ee1765f74a1da723f4b33e3b228fe3a48bd307acce5021dfefe0e29b2", size = 134814, upload-time = "2026-02-02T15:38:43.627Z" }, - { url = "https://files.pythonhosted.org/packages/1d/d3/f19b47ce16820cc2c480f7f1723e17f6d411b3a295c60c8ad3aa9ff1c96a/orjson-3.11.7-cp314-cp314-win32.whl", hash = "sha256:89e13dd3f89f1c38a9c9eba5fbf7cdc2d1feca82f5f290864b4b7a6aac704576", size = 127997, upload-time = "2026-02-02T15:38:45.06Z" }, - { url = "https://files.pythonhosted.org/packages/12/df/172771902943af54bf661a8d102bdf2e7f932127968080632bda6054b62c/orjson-3.11.7-cp314-cp314-win_amd64.whl", hash = "sha256:845c3e0d8ded9c9271cd79596b9b552448b885b97110f628fb687aee2eed11c1", size = 124985, upload-time = "2026-02-02T15:38:46.388Z" }, - { url = "https://files.pythonhosted.org/packages/6f/1c/f2a8d8a1b17514660a614ce5f7aac74b934e69f5abc2700cc7ced882a009/orjson-3.11.7-cp314-cp314-win_arm64.whl", hash = "sha256:4a2e9c5be347b937a2e0203866f12bba36082e89b402ddb9e927d5822e43088d", size = 126038, upload-time = "2026-02-02T15:38:47.703Z" }, ] [[package]] @@ -2667,74 +2224,14 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, ] -[[package]] -name = "pandas" -version = "2.3.3" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.14' and sys_platform == 'win32'", - "python_full_version >= '3.14' and sys_platform == 'emscripten'", - "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", -] -dependencies = [ - { name = "numpy", marker = "python_full_version >= '3.14'" }, - { name = "python-dateutil", marker = "python_full_version >= '3.14'" }, - { name = "pytz", marker = "python_full_version >= '3.14'" }, - { name = "tzdata", marker = "python_full_version >= '3.14'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/33/01/d40b85317f86cf08d853a4f495195c73815fdf205eef3993821720274518/pandas-2.3.3.tar.gz", hash = "sha256:e05e1af93b977f7eafa636d043f9f94c7ee3ac81af99c13508215942e64c993b", size = 4495223, upload-time = "2025-09-29T23:34:51.853Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9c/fb/231d89e8637c808b997d172b18e9d4a4bc7bf31296196c260526055d1ea0/pandas-2.3.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d21f6d74eb1725c2efaa71a2bfc661a0689579b58e9c0ca58a739ff0b002b53", size = 11597846, upload-time = "2025-09-29T23:19:48.856Z" }, - { url = "https://files.pythonhosted.org/packages/5c/bd/bf8064d9cfa214294356c2d6702b716d3cf3bb24be59287a6a21e24cae6b/pandas-2.3.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3fd2f887589c7aa868e02632612ba39acb0b8948faf5cc58f0850e165bd46f35", size = 10729618, upload-time = "2025-09-29T23:39:08.659Z" }, - { url = "https://files.pythonhosted.org/packages/57/56/cf2dbe1a3f5271370669475ead12ce77c61726ffd19a35546e31aa8edf4e/pandas-2.3.3-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ecaf1e12bdc03c86ad4a7ea848d66c685cb6851d807a26aa245ca3d2017a1908", size = 11737212, upload-time = "2025-09-29T23:19:59.765Z" }, - { url = "https://files.pythonhosted.org/packages/e5/63/cd7d615331b328e287d8233ba9fdf191a9c2d11b6af0c7a59cfcec23de68/pandas-2.3.3-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b3d11d2fda7eb164ef27ffc14b4fcab16a80e1ce67e9f57e19ec0afaf715ba89", size = 12362693, upload-time = "2025-09-29T23:20:14.098Z" }, - { url = "https://files.pythonhosted.org/packages/a6/de/8b1895b107277d52f2b42d3a6806e69cfef0d5cf1d0ba343470b9d8e0a04/pandas-2.3.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a68e15f780eddf2b07d242e17a04aa187a7ee12b40b930bfdd78070556550e98", size = 12771002, upload-time = "2025-09-29T23:20:26.76Z" }, - { url = "https://files.pythonhosted.org/packages/87/21/84072af3187a677c5893b170ba2c8fbe450a6ff911234916da889b698220/pandas-2.3.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:371a4ab48e950033bcf52b6527eccb564f52dc826c02afd9a1bc0ab731bba084", size = 13450971, upload-time = "2025-09-29T23:20:41.344Z" }, - { url = "https://files.pythonhosted.org/packages/86/41/585a168330ff063014880a80d744219dbf1dd7a1c706e75ab3425a987384/pandas-2.3.3-cp312-cp312-win_amd64.whl", hash = "sha256:a16dcec078a01eeef8ee61bf64074b4e524a2a3f4b3be9326420cabe59c4778b", size = 10992722, upload-time = "2025-09-29T23:20:54.139Z" }, - { url = "https://files.pythonhosted.org/packages/cd/4b/18b035ee18f97c1040d94debd8f2e737000ad70ccc8f5513f4eefad75f4b/pandas-2.3.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:56851a737e3470de7fa88e6131f41281ed440d29a9268dcbf0002da5ac366713", size = 11544671, upload-time = "2025-09-29T23:21:05.024Z" }, - { url = "https://files.pythonhosted.org/packages/31/94/72fac03573102779920099bcac1c3b05975c2cb5f01eac609faf34bed1ca/pandas-2.3.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bdcd9d1167f4885211e401b3036c0c8d9e274eee67ea8d0758a256d60704cfe8", size = 10680807, upload-time = "2025-09-29T23:21:15.979Z" }, - { url = "https://files.pythonhosted.org/packages/16/87/9472cf4a487d848476865321de18cc8c920b8cab98453ab79dbbc98db63a/pandas-2.3.3-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e32e7cc9af0f1cc15548288a51a3b681cc2a219faa838e995f7dc53dbab1062d", size = 11709872, upload-time = "2025-09-29T23:21:27.165Z" }, - { url = "https://files.pythonhosted.org/packages/15/07/284f757f63f8a8d69ed4472bfd85122bd086e637bf4ed09de572d575a693/pandas-2.3.3-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:318d77e0e42a628c04dc56bcef4b40de67918f7041c2b061af1da41dcff670ac", size = 12306371, upload-time = "2025-09-29T23:21:40.532Z" }, - { url = "https://files.pythonhosted.org/packages/33/81/a3afc88fca4aa925804a27d2676d22dcd2031c2ebe08aabd0ae55b9ff282/pandas-2.3.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4e0a175408804d566144e170d0476b15d78458795bb18f1304fb94160cabf40c", size = 12765333, upload-time = "2025-09-29T23:21:55.77Z" }, - { url = "https://files.pythonhosted.org/packages/8d/0f/b4d4ae743a83742f1153464cf1a8ecfafc3ac59722a0b5c8602310cb7158/pandas-2.3.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:93c2d9ab0fc11822b5eece72ec9587e172f63cff87c00b062f6e37448ced4493", size = 13418120, upload-time = "2025-09-29T23:22:10.109Z" }, - { url = "https://files.pythonhosted.org/packages/4f/c7/e54682c96a895d0c808453269e0b5928a07a127a15704fedb643e9b0a4c8/pandas-2.3.3-cp313-cp313-win_amd64.whl", hash = "sha256:f8bfc0e12dc78f777f323f55c58649591b2cd0c43534e8355c51d3fede5f4dee", size = 10993991, upload-time = "2025-09-29T23:25:04.889Z" }, - { url = "https://files.pythonhosted.org/packages/f9/ca/3f8d4f49740799189e1395812f3bf23b5e8fc7c190827d55a610da72ce55/pandas-2.3.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:75ea25f9529fdec2d2e93a42c523962261e567d250b0013b16210e1d40d7c2e5", size = 12048227, upload-time = "2025-09-29T23:22:24.343Z" }, - { url = "https://files.pythonhosted.org/packages/0e/5a/f43efec3e8c0cc92c4663ccad372dbdff72b60bdb56b2749f04aa1d07d7e/pandas-2.3.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:74ecdf1d301e812db96a465a525952f4dde225fdb6d8e5a521d47e1f42041e21", size = 11411056, upload-time = "2025-09-29T23:22:37.762Z" }, - { url = "https://files.pythonhosted.org/packages/46/b1/85331edfc591208c9d1a63a06baa67b21d332e63b7a591a5ba42a10bb507/pandas-2.3.3-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6435cb949cb34ec11cc9860246ccb2fdc9ecd742c12d3304989017d53f039a78", size = 11645189, upload-time = "2025-09-29T23:22:51.688Z" }, - { url = "https://files.pythonhosted.org/packages/44/23/78d645adc35d94d1ac4f2a3c4112ab6f5b8999f4898b8cdf01252f8df4a9/pandas-2.3.3-cp313-cp313t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:900f47d8f20860de523a1ac881c4c36d65efcb2eb850e6948140fa781736e110", size = 12121912, upload-time = "2025-09-29T23:23:05.042Z" }, - { url = "https://files.pythonhosted.org/packages/53/da/d10013df5e6aaef6b425aa0c32e1fc1f3e431e4bcabd420517dceadce354/pandas-2.3.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:a45c765238e2ed7d7c608fc5bc4a6f88b642f2f01e70c0c23d2224dd21829d86", size = 12712160, upload-time = "2025-09-29T23:23:28.57Z" }, - { url = "https://files.pythonhosted.org/packages/bd/17/e756653095a083d8a37cbd816cb87148debcfcd920129b25f99dd8d04271/pandas-2.3.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c4fc4c21971a1a9f4bdb4c73978c7f7256caa3e62b323f70d6cb80db583350bc", size = 13199233, upload-time = "2025-09-29T23:24:24.876Z" }, - { url = "https://files.pythonhosted.org/packages/04/fd/74903979833db8390b73b3a8a7d30d146d710bd32703724dd9083950386f/pandas-2.3.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:ee15f284898e7b246df8087fc82b87b01686f98ee67d85a17b7ab44143a3a9a0", size = 11540635, upload-time = "2025-09-29T23:25:52.486Z" }, - { url = "https://files.pythonhosted.org/packages/21/00/266d6b357ad5e6d3ad55093a7e8efc7dd245f5a842b584db9f30b0f0a287/pandas-2.3.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1611aedd912e1ff81ff41c745822980c49ce4a7907537be8692c8dbc31924593", size = 10759079, upload-time = "2025-09-29T23:26:33.204Z" }, - { url = "https://files.pythonhosted.org/packages/ca/05/d01ef80a7a3a12b2f8bbf16daba1e17c98a2f039cbc8e2f77a2c5a63d382/pandas-2.3.3-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6d2cefc361461662ac48810cb14365a365ce864afe85ef1f447ff5a1e99ea81c", size = 11814049, upload-time = "2025-09-29T23:27:15.384Z" }, - { url = "https://files.pythonhosted.org/packages/15/b2/0e62f78c0c5ba7e3d2c5945a82456f4fac76c480940f805e0b97fcbc2f65/pandas-2.3.3-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ee67acbbf05014ea6c763beb097e03cd629961c8a632075eeb34247120abcb4b", size = 12332638, upload-time = "2025-09-29T23:27:51.625Z" }, - { url = "https://files.pythonhosted.org/packages/c5/33/dd70400631b62b9b29c3c93d2feee1d0964dc2bae2e5ad7a6c73a7f25325/pandas-2.3.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c46467899aaa4da076d5abc11084634e2d197e9460643dd455ac3db5856b24d6", size = 12886834, upload-time = "2025-09-29T23:28:21.289Z" }, - { url = "https://files.pythonhosted.org/packages/d3/18/b5d48f55821228d0d2692b34fd5034bb185e854bdb592e9c640f6290e012/pandas-2.3.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6253c72c6a1d990a410bc7de641d34053364ef8bcd3126f7e7450125887dffe3", size = 13409925, upload-time = "2025-09-29T23:28:58.261Z" }, - { url = "https://files.pythonhosted.org/packages/a6/3d/124ac75fcd0ecc09b8fdccb0246ef65e35b012030defb0e0eba2cbbbe948/pandas-2.3.3-cp314-cp314-win_amd64.whl", hash = "sha256:1b07204a219b3b7350abaae088f451860223a52cfb8a6c53358e7948735158e5", size = 11109071, upload-time = "2025-09-29T23:32:27.484Z" }, - { url = "https://files.pythonhosted.org/packages/89/9c/0e21c895c38a157e0faa1fb64587a9226d6dd46452cac4532d80c3c4a244/pandas-2.3.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2462b1a365b6109d275250baaae7b760fd25c726aaca0054649286bcfbb3e8ec", size = 12048504, upload-time = "2025-09-29T23:29:31.47Z" }, - { url = "https://files.pythonhosted.org/packages/d7/82/b69a1c95df796858777b68fbe6a81d37443a33319761d7c652ce77797475/pandas-2.3.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0242fe9a49aa8b4d78a4fa03acb397a58833ef6199e9aa40a95f027bb3a1b6e7", size = 11410702, upload-time = "2025-09-29T23:29:54.591Z" }, - { url = "https://files.pythonhosted.org/packages/f9/88/702bde3ba0a94b8c73a0181e05144b10f13f29ebfc2150c3a79062a8195d/pandas-2.3.3-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a21d830e78df0a515db2b3d2f5570610f5e6bd2e27749770e8bb7b524b89b450", size = 11634535, upload-time = "2025-09-29T23:30:21.003Z" }, - { url = "https://files.pythonhosted.org/packages/a4/1e/1bac1a839d12e6a82ec6cb40cda2edde64a2013a66963293696bbf31fbbb/pandas-2.3.3-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2e3ebdb170b5ef78f19bfb71b0dc5dc58775032361fa188e814959b74d726dd5", size = 12121582, upload-time = "2025-09-29T23:30:43.391Z" }, - { url = "https://files.pythonhosted.org/packages/44/91/483de934193e12a3b1d6ae7c8645d083ff88dec75f46e827562f1e4b4da6/pandas-2.3.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d051c0e065b94b7a3cea50eb1ec32e912cd96dba41647eb24104b6c6c14c5788", size = 12699963, upload-time = "2025-09-29T23:31:10.009Z" }, - { url = "https://files.pythonhosted.org/packages/70/44/5191d2e4026f86a2a109053e194d3ba7a31a2d10a9c2348368c63ed4e85a/pandas-2.3.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:3869faf4bd07b3b66a9f462417d0ca3a9df29a9f6abd5d0d0dbab15dac7abe87", size = 13202175, upload-time = "2025-09-29T23:31:59.173Z" }, -] - [[package]] name = "pandas" version = "3.0.1" source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version == '3.13.*' and sys_platform == 'win32'", - "python_full_version < '3.13' and sys_platform == 'win32'", - "python_full_version == '3.13.*' and sys_platform == 'emscripten'", - "python_full_version < '3.13' and sys_platform == 'emscripten'", - "python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", - "python_full_version < '3.13' and sys_platform != 'emscripten' and sys_platform != 'win32'", -] dependencies = [ - { name = "numpy", marker = "python_full_version < '3.14'" }, - { name = "python-dateutil", marker = "python_full_version < '3.14'" }, - { name = "tzdata", marker = "(python_full_version < '3.14' and sys_platform == 'emscripten') or (python_full_version < '3.14' and sys_platform == 'win32')" }, + { name = "numpy" }, + { name = "python-dateutil" }, + { name = "tzdata", marker = "sys_platform == 'emscripten' or sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/2e/0c/b28ed414f080ee0ad153f848586d61d1878f91689950f037f976ce15f6c8/pandas-3.0.1.tar.gz", hash = "sha256:4186a699674af418f655dbd420ed87f50d56b4cd6603784279d9eef6627823c8", size = 4641901, upload-time = "2026-02-17T22:20:16.434Z" } wheels = [ @@ -2761,22 +2258,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a2/61/772b2e2757855e232b7ccf7cb8079a5711becb3a97f291c953def15a833f/pandas-3.0.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6bf0603c2e30e2cafac32807b06435f28741135cb8697eae8b28c7d492fc7d76", size = 11334191, upload-time = "2026-02-17T22:19:29.411Z" }, { url = "https://files.pythonhosted.org/packages/1b/08/b16c6df3ef555d8495d1d265a7963b65be166785d28f06a350913a4fac78/pandas-3.0.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6c426422973973cae1f4a23e51d4ae85974f44871b24844e4f7de752dd877098", size = 11782256, upload-time = "2026-02-17T22:19:32.34Z" }, { url = "https://files.pythonhosted.org/packages/55/80/178af0594890dee17e239fca96d3d8670ba0f5ff59b7d0439850924a9c09/pandas-3.0.1-cp313-cp313t-win_amd64.whl", hash = "sha256:b03f91ae8c10a85c1613102c7bef5229b5379f343030a3ccefeca8a33414cf35", size = 10485047, upload-time = "2026-02-17T22:19:34.605Z" }, - { url = "https://files.pythonhosted.org/packages/bb/8b/4bb774a998b97e6c2fd62a9e6cfdaae133b636fd1c468f92afb4ae9a447a/pandas-3.0.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:99d0f92ed92d3083d140bf6b97774f9f13863924cf3f52a70711f4e7588f9d0a", size = 10322465, upload-time = "2026-02-17T22:19:36.803Z" }, - { url = "https://files.pythonhosted.org/packages/72/3a/5b39b51c64159f470f1ca3b1c2a87da290657ca022f7cd11442606f607d1/pandas-3.0.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:3b66857e983208654294bb6477b8a63dee26b37bdd0eb34d010556e91261784f", size = 9910632, upload-time = "2026-02-17T22:19:39.001Z" }, - { url = "https://files.pythonhosted.org/packages/4e/f7/b449ffb3f68c11da12fc06fbf6d2fa3a41c41e17d0284d23a79e1c13a7e4/pandas-3.0.1-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:56cf59638bf24dc9bdf2154c81e248b3289f9a09a6d04e63608c159022352749", size = 10440535, upload-time = "2026-02-17T22:19:41.157Z" }, - { url = "https://files.pythonhosted.org/packages/55/77/6ea82043db22cb0f2bbfe7198da3544000ddaadb12d26be36e19b03a2dc5/pandas-3.0.1-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c1a9f55e0f46951874b863d1f3906dcb57df2d9be5c5847ba4dfb55b2c815249", size = 10893940, upload-time = "2026-02-17T22:19:43.493Z" }, - { url = "https://files.pythonhosted.org/packages/03/30/f1b502a72468c89412c1b882a08f6eed8a4ee9dc033f35f65d0663df6081/pandas-3.0.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:1849f0bba9c8a2fb0f691d492b834cc8dadf617e29015c66e989448d58d011ee", size = 11442711, upload-time = "2026-02-17T22:19:46.074Z" }, - { url = "https://files.pythonhosted.org/packages/0d/f0/ebb6ddd8fc049e98cabac5c2924d14d1dda26a20adb70d41ea2e428d3ec4/pandas-3.0.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c3d288439e11b5325b02ae6e9cc83e6805a62c40c5a6220bea9beb899c073b1c", size = 11963918, upload-time = "2026-02-17T22:19:48.838Z" }, - { url = "https://files.pythonhosted.org/packages/09/f8/8ce132104074f977f907442790eaae24e27bce3b3b454e82faa3237ff098/pandas-3.0.1-cp314-cp314-win_amd64.whl", hash = "sha256:93325b0fe372d192965f4cca88d97667f49557398bbf94abdda3bf1b591dbe66", size = 9862099, upload-time = "2026-02-17T22:19:51.081Z" }, - { url = "https://files.pythonhosted.org/packages/e6/b7/6af9aac41ef2456b768ef0ae60acf8abcebb450a52043d030a65b4b7c9bd/pandas-3.0.1-cp314-cp314-win_arm64.whl", hash = "sha256:97ca08674e3287c7148f4858b01136f8bdfe7202ad25ad04fec602dd1d29d132", size = 9185333, upload-time = "2026-02-17T22:19:53.266Z" }, - { url = "https://files.pythonhosted.org/packages/66/fc/848bb6710bc6061cb0c5badd65b92ff75c81302e0e31e496d00029fe4953/pandas-3.0.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:58eeb1b2e0fb322befcf2bbc9ba0af41e616abadb3d3414a6bc7167f6cbfce32", size = 10772664, upload-time = "2026-02-17T22:19:55.806Z" }, - { url = "https://files.pythonhosted.org/packages/69/5c/866a9bbd0f79263b4b0db6ec1a341be13a1473323f05c122388e0f15b21d/pandas-3.0.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:cd9af1276b5ca9e298bd79a26bda32fa9cc87ed095b2a9a60978d2ca058eaf87", size = 10421286, upload-time = "2026-02-17T22:19:58.091Z" }, - { url = "https://files.pythonhosted.org/packages/51/a4/2058fb84fb1cfbfb2d4a6d485e1940bb4ad5716e539d779852494479c580/pandas-3.0.1-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:94f87a04984d6b63788327cd9f79dda62b7f9043909d2440ceccf709249ca988", size = 10342050, upload-time = "2026-02-17T22:20:01.376Z" }, - { url = "https://files.pythonhosted.org/packages/22/1b/674e89996cc4be74db3c4eb09240c4bb549865c9c3f5d9b086ff8fcfbf00/pandas-3.0.1-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:85fe4c4df62e1e20f9db6ebfb88c844b092c22cd5324bdcf94bfa2fc1b391221", size = 10740055, upload-time = "2026-02-17T22:20:04.328Z" }, - { url = "https://files.pythonhosted.org/packages/d0/f8/e954b750764298c22fa4614376531fe63c521ef517e7059a51f062b87dca/pandas-3.0.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:331ca75a2f8672c365ae25c0b29e46f5ac0c6551fdace8eec4cd65e4fac271ff", size = 11357632, upload-time = "2026-02-17T22:20:06.647Z" }, - { url = "https://files.pythonhosted.org/packages/6d/02/c6e04b694ffd68568297abd03588b6d30295265176a5c01b7459d3bc35a3/pandas-3.0.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:15860b1fdb1973fffade772fdb931ccf9b2f400a3f5665aef94a00445d7d8dd5", size = 11810974, upload-time = "2026-02-17T22:20:08.946Z" }, - { url = "https://files.pythonhosted.org/packages/89/41/d7dfb63d2407f12055215070c42fc6ac41b66e90a2946cdc5e759058398b/pandas-3.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:44f1364411d5670efa692b146c748f4ed013df91ee91e9bec5677fb1fd58b937", size = 10884622, upload-time = "2026-02-17T22:20:11.711Z" }, - { url = "https://files.pythonhosted.org/packages/68/b0/34937815889fa982613775e4b97fddd13250f11012d769949c5465af2150/pandas-3.0.1-cp314-cp314t-win_arm64.whl", hash = "sha256:108dd1790337a494aa80e38def654ca3f0968cf4f362c85f44c15e471667102d", size = 9452085, upload-time = "2026-02-17T22:20:14.331Z" }, ] [[package]] @@ -2786,8 +2267,7 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "numba" }, { name = "numpy" }, - { name = "pandas", version = "2.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.14'" }, - { name = "pandas", version = "3.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.14'" }, + { name = "pandas" }, { name = "tqdm" }, ] sdist = { url = "https://files.pythonhosted.org/packages/95/6d/60b88a0334a8c6a5be114ed2c46c8f3e164127d0eccd9ff99b50773f2b20/pandas_ta-0.4.71b0.tar.gz", hash = "sha256:782ef8a874d2e0bdf80f445136617bda084f1fc5d14d3b1c525b282a152de37a", size = 1310317, upload-time = "2025-09-14T19:08:36.151Z" } @@ -2864,31 +2344,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ad/4b/926ab182c07fccae9fcb120043464e1ff1564775ec8864f21a0ebce6ac25/pillow-12.2.0-cp313-cp313t-win32.whl", hash = "sha256:ee3120ae9dff32f121610bb08e4313be87e03efeadfc6c0d18f89127e24d0c24", size = 6379592, upload-time = "2026-04-01T14:44:40.336Z" }, { url = "https://files.pythonhosted.org/packages/c2/c4/f9e476451a098181b30050cc4c9a3556b64c02cf6497ea421ac047e89e4b/pillow-12.2.0-cp313-cp313t-win_amd64.whl", hash = "sha256:325ca0528c6788d2a6c3d40e3568639398137346c3d6e66bb61db96b96511c98", size = 7085542, upload-time = "2026-04-01T14:44:43.251Z" }, { url = "https://files.pythonhosted.org/packages/00/a4/285f12aeacbe2d6dc36c407dfbbe9e96d4a80b0fb710a337f6d2ad978c75/pillow-12.2.0-cp313-cp313t-win_arm64.whl", hash = "sha256:2e5a76d03a6c6dcef67edabda7a52494afa4035021a79c8558e14af25313d453", size = 2465765, upload-time = "2026-04-01T14:44:45.996Z" }, - { url = "https://files.pythonhosted.org/packages/bf/98/4595daa2365416a86cb0d495248a393dfc84e96d62ad080c8546256cb9c0/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:3adc9215e8be0448ed6e814966ecf3d9952f0ea40eb14e89a102b87f450660d8", size = 4100848, upload-time = "2026-04-01T14:44:48.48Z" }, - { url = "https://files.pythonhosted.org/packages/0b/79/40184d464cf89f6663e18dfcf7ca21aae2491fff1a16127681bf1fa9b8cf/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:6a9adfc6d24b10f89588096364cc726174118c62130c817c2837c60cf08a392b", size = 4176515, upload-time = "2026-04-01T14:44:51.353Z" }, - { url = "https://files.pythonhosted.org/packages/b0/63/703f86fd4c422a9cf722833670f4f71418fb116b2853ff7da722ea43f184/pillow-12.2.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:6a6e67ea2e6feda684ed370f9a1c52e7a243631c025ba42149a2cc5934dec295", size = 3640159, upload-time = "2026-04-01T14:44:53.588Z" }, - { url = "https://files.pythonhosted.org/packages/71/e0/fb22f797187d0be2270f83500aab851536101b254bfa1eae10795709d283/pillow-12.2.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2bb4a8d594eacdfc59d9e5ad972aa8afdd48d584ffd5f13a937a664c3e7db0ed", size = 5312185, upload-time = "2026-04-01T14:44:56.039Z" }, - { url = "https://files.pythonhosted.org/packages/ba/8c/1a9e46228571de18f8e28f16fabdfc20212a5d019f3e3303452b3f0a580d/pillow-12.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:80b2da48193b2f33ed0c32c38140f9d3186583ce7d516526d462645fd98660ae", size = 4695386, upload-time = "2026-04-01T14:44:58.663Z" }, - { url = "https://files.pythonhosted.org/packages/70/62/98f6b7f0c88b9addd0e87c217ded307b36be024d4ff8869a812b241d1345/pillow-12.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:22db17c68434de69d8ecfc2fe821569195c0c373b25cccb9cbdacf2c6e53c601", size = 6280384, upload-time = "2026-04-01T14:45:01.5Z" }, - { url = "https://files.pythonhosted.org/packages/5e/03/688747d2e91cfbe0e64f316cd2e8005698f76ada3130d0194664174fa5de/pillow-12.2.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7b14cc0106cd9aecda615dd6903840a058b4700fcb817687d0ee4fc8b6e389be", size = 8091599, upload-time = "2026-04-01T14:45:04.5Z" }, - { url = "https://files.pythonhosted.org/packages/f6/35/577e22b936fcdd66537329b33af0b4ccfefaeabd8aec04b266528cddb33c/pillow-12.2.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cbeb542b2ebc6fcdacabf8aca8c1a97c9b3ad3927d46b8723f9d4f033288a0f", size = 6396021, upload-time = "2026-04-01T14:45:07.117Z" }, - { url = "https://files.pythonhosted.org/packages/11/8d/d2532ad2a603ca2b93ad9f5135732124e57811d0168155852f37fbce2458/pillow-12.2.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4bfd07bc812fbd20395212969e41931001fd59eb55a60658b0e5710872e95286", size = 7083360, upload-time = "2026-04-01T14:45:09.763Z" }, - { url = "https://files.pythonhosted.org/packages/5e/26/d325f9f56c7e039034897e7380e9cc202b1e368bfd04d4cbe6a441f02885/pillow-12.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9aba9a17b623ef750a4d11b742cbafffeb48a869821252b30ee21b5e91392c50", size = 6507628, upload-time = "2026-04-01T14:45:12.378Z" }, - { url = "https://files.pythonhosted.org/packages/5f/f7/769d5632ffb0988f1c5e7660b3e731e30f7f8ec4318e94d0a5d674eb65a4/pillow-12.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:deede7c263feb25dba4e82ea23058a235dcc2fe1f6021025dc71f2b618e26104", size = 7209321, upload-time = "2026-04-01T14:45:15.122Z" }, - { url = "https://files.pythonhosted.org/packages/6a/7a/c253e3c645cd47f1aceea6a8bacdba9991bf45bb7dfe927f7c893e89c93c/pillow-12.2.0-cp314-cp314-win32.whl", hash = "sha256:632ff19b2778e43162304d50da0181ce24ac5bb8180122cbe1bf4673428328c7", size = 6479723, upload-time = "2026-04-01T14:45:17.797Z" }, - { url = "https://files.pythonhosted.org/packages/cd/8b/601e6566b957ca50e28725cb6c355c59c2c8609751efbecd980db44e0349/pillow-12.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:4e6c62e9d237e9b65fac06857d511e90d8461a32adcc1b9065ea0c0fa3a28150", size = 7217400, upload-time = "2026-04-01T14:45:20.529Z" }, - { url = "https://files.pythonhosted.org/packages/d6/94/220e46c73065c3e2951bb91c11a1fb636c8c9ad427ac3ce7d7f3359b9b2f/pillow-12.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:b1c1fbd8a5a1af3412a0810d060a78b5136ec0836c8a4ef9aa11807f2a22f4e1", size = 2554835, upload-time = "2026-04-01T14:45:23.162Z" }, - { url = "https://files.pythonhosted.org/packages/b6/ab/1b426a3974cb0e7da5c29ccff4807871d48110933a57207b5a676cccc155/pillow-12.2.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:57850958fe9c751670e49b2cecf6294acc99e562531f4bd317fa5ddee2068463", size = 5314225, upload-time = "2026-04-01T14:45:25.637Z" }, - { url = "https://files.pythonhosted.org/packages/19/1e/dce46f371be2438eecfee2a1960ee2a243bbe5e961890146d2dee1ff0f12/pillow-12.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d5d38f1411c0ed9f97bcb49b7bd59b6b7c314e0e27420e34d99d844b9ce3b6f3", size = 4698541, upload-time = "2026-04-01T14:45:28.355Z" }, - { url = "https://files.pythonhosted.org/packages/55/c3/7fbecf70adb3a0c33b77a300dc52e424dc22ad8cdc06557a2e49523b703d/pillow-12.2.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5c0a9f29ca8e79f09de89293f82fc9b0270bb4af1d58bc98f540cc4aedf03166", size = 6322251, upload-time = "2026-04-01T14:45:30.924Z" }, - { url = "https://files.pythonhosted.org/packages/1c/3c/7fbc17cfb7e4fe0ef1642e0abc17fc6c94c9f7a16be41498e12e2ba60408/pillow-12.2.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1610dd6c61621ae1cf811bef44d77e149ce3f7b95afe66a4512f8c59f25d9ebe", size = 8127807, upload-time = "2026-04-01T14:45:33.908Z" }, - { url = "https://files.pythonhosted.org/packages/ff/c3/a8ae14d6defd2e448493ff512fae903b1e9bd40b72efb6ec55ce0048c8ce/pillow-12.2.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a34329707af4f73cf1782a36cd2289c0368880654a2c11f027bcee9052d35dd", size = 6433935, upload-time = "2026-04-01T14:45:36.623Z" }, - { url = "https://files.pythonhosted.org/packages/6e/32/2880fb3a074847ac159d8f902cb43278a61e85f681661e7419e6596803ed/pillow-12.2.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e9c4f5b3c546fa3458a29ab22646c1c6c787ea8f5ef51300e5a60300736905e", size = 7116720, upload-time = "2026-04-01T14:45:39.258Z" }, - { url = "https://files.pythonhosted.org/packages/46/87/495cc9c30e0129501643f24d320076f4cc54f718341df18cc70ec94c44e1/pillow-12.2.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fb043ee2f06b41473269765c2feae53fc2e2fbf96e5e22ca94fb5ad677856f06", size = 6540498, upload-time = "2026-04-01T14:45:41.879Z" }, - { url = "https://files.pythonhosted.org/packages/18/53/773f5edca692009d883a72211b60fdaf8871cbef075eaa9d577f0a2f989e/pillow-12.2.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f278f034eb75b4e8a13a54a876cc4a5ab39173d2cdd93a638e1b467fc545ac43", size = 7239413, upload-time = "2026-04-01T14:45:44.705Z" }, - { url = "https://files.pythonhosted.org/packages/c9/e4/4b64a97d71b2a83158134abbb2f5bd3f8a2ea691361282f010998f339ec7/pillow-12.2.0-cp314-cp314t-win32.whl", hash = "sha256:6bb77b2dcb06b20f9f4b4a8454caa581cd4dd0643a08bacf821216a16d9c8354", size = 6482084, upload-time = "2026-04-01T14:45:47.568Z" }, - { url = "https://files.pythonhosted.org/packages/ba/13/306d275efd3a3453f72114b7431c877d10b1154014c1ebbedd067770d629/pillow-12.2.0-cp314-cp314t-win_amd64.whl", hash = "sha256:6562ace0d3fb5f20ed7290f1f929cae41b25ae29528f2af1722966a0a02e2aa1", size = 7225152, upload-time = "2026-04-01T14:45:50.032Z" }, - { url = "https://files.pythonhosted.org/packages/ff/6e/cf826fae916b8658848d7b9f38d88da6396895c676e8086fc0988073aaf8/pillow-12.2.0-cp314-cp314t-win_arm64.whl", hash = "sha256:aa88ccfe4e32d362816319ed727a004423aab09c5cea43c01a4b435643fa34eb", size = 2556579, upload-time = "2026-04-01T14:45:52.529Z" }, ] [[package]] @@ -3001,36 +2456,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/92/f7/1d4ec5841505f423469efbfc381d64b7b467438cd5a4bbcbb063f3b73d27/propcache-0.4.1-cp313-cp313t-win32.whl", hash = "sha256:2ad890caa1d928c7c2965b48f3a3815c853180831d0e5503d35cf00c472f4717", size = 41396, upload-time = "2025-10-08T19:47:47.202Z" }, { url = "https://files.pythonhosted.org/packages/48/f0/615c30622316496d2cbbc29f5985f7777d3ada70f23370608c1d3e081c1f/propcache-0.4.1-cp313-cp313t-win_amd64.whl", hash = "sha256:f7ee0e597f495cf415bcbd3da3caa3bd7e816b74d0d52b8145954c5e6fd3ff37", size = 44897, upload-time = "2025-10-08T19:47:48.336Z" }, { url = "https://files.pythonhosted.org/packages/fd/ca/6002e46eccbe0e33dcd4069ef32f7f1c9e243736e07adca37ae8c4830ec3/propcache-0.4.1-cp313-cp313t-win_arm64.whl", hash = "sha256:929d7cbe1f01bb7baffb33dc14eb5691c95831450a26354cd210a8155170c93a", size = 39789, upload-time = "2025-10-08T19:47:49.876Z" }, - { url = "https://files.pythonhosted.org/packages/8e/5c/bca52d654a896f831b8256683457ceddd490ec18d9ec50e97dfd8fc726a8/propcache-0.4.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3f7124c9d820ba5548d431afb4632301acf965db49e666aa21c305cbe8c6de12", size = 78152, upload-time = "2025-10-08T19:47:51.051Z" }, - { url = "https://files.pythonhosted.org/packages/65/9b/03b04e7d82a5f54fb16113d839f5ea1ede58a61e90edf515f6577c66fa8f/propcache-0.4.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:c0d4b719b7da33599dfe3b22d3db1ef789210a0597bc650b7cee9c77c2be8c5c", size = 44869, upload-time = "2025-10-08T19:47:52.594Z" }, - { url = "https://files.pythonhosted.org/packages/b2/fa/89a8ef0468d5833a23fff277b143d0573897cf75bd56670a6d28126c7d68/propcache-0.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9f302f4783709a78240ebc311b793f123328716a60911d667e0c036bc5dcbded", size = 46596, upload-time = "2025-10-08T19:47:54.073Z" }, - { url = "https://files.pythonhosted.org/packages/86/bd/47816020d337f4a746edc42fe8d53669965138f39ee117414c7d7a340cfe/propcache-0.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c80ee5802e3fb9ea37938e7eecc307fb984837091d5fd262bb37238b1ae97641", size = 206981, upload-time = "2025-10-08T19:47:55.715Z" }, - { url = "https://files.pythonhosted.org/packages/df/f6/c5fa1357cc9748510ee55f37173eb31bfde6d94e98ccd9e6f033f2fc06e1/propcache-0.4.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ed5a841e8bb29a55fb8159ed526b26adc5bdd7e8bd7bf793ce647cb08656cdf4", size = 211490, upload-time = "2025-10-08T19:47:57.499Z" }, - { url = "https://files.pythonhosted.org/packages/80/1e/e5889652a7c4a3846683401a48f0f2e5083ce0ec1a8a5221d8058fbd1adf/propcache-0.4.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:55c72fd6ea2da4c318e74ffdf93c4fe4e926051133657459131a95c846d16d44", size = 215371, upload-time = "2025-10-08T19:47:59.317Z" }, - { url = "https://files.pythonhosted.org/packages/b2/f2/889ad4b2408f72fe1a4f6a19491177b30ea7bf1a0fd5f17050ca08cfc882/propcache-0.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8326e144341460402713f91df60ade3c999d601e7eb5ff8f6f7862d54de0610d", size = 201424, upload-time = "2025-10-08T19:48:00.67Z" }, - { url = "https://files.pythonhosted.org/packages/27/73/033d63069b57b0812c8bd19f311faebeceb6ba31b8f32b73432d12a0b826/propcache-0.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:060b16ae65bc098da7f6d25bf359f1f31f688384858204fe5d652979e0015e5b", size = 197566, upload-time = "2025-10-08T19:48:02.604Z" }, - { url = "https://files.pythonhosted.org/packages/dc/89/ce24f3dc182630b4e07aa6d15f0ff4b14ed4b9955fae95a0b54c58d66c05/propcache-0.4.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:89eb3fa9524f7bec9de6e83cf3faed9d79bffa560672c118a96a171a6f55831e", size = 193130, upload-time = "2025-10-08T19:48:04.499Z" }, - { url = "https://files.pythonhosted.org/packages/a9/24/ef0d5fd1a811fb5c609278d0209c9f10c35f20581fcc16f818da959fc5b4/propcache-0.4.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:dee69d7015dc235f526fe80a9c90d65eb0039103fe565776250881731f06349f", size = 202625, upload-time = "2025-10-08T19:48:06.213Z" }, - { url = "https://files.pythonhosted.org/packages/f5/02/98ec20ff5546f68d673df2f7a69e8c0d076b5abd05ca882dc7ee3a83653d/propcache-0.4.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:5558992a00dfd54ccbc64a32726a3357ec93825a418a401f5cc67df0ac5d9e49", size = 204209, upload-time = "2025-10-08T19:48:08.432Z" }, - { url = "https://files.pythonhosted.org/packages/a0/87/492694f76759b15f0467a2a93ab68d32859672b646aa8a04ce4864e7932d/propcache-0.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c9b822a577f560fbd9554812526831712c1436d2c046cedee4c3796d3543b144", size = 197797, upload-time = "2025-10-08T19:48:09.968Z" }, - { url = "https://files.pythonhosted.org/packages/ee/36/66367de3575db1d2d3f3d177432bd14ee577a39d3f5d1b3d5df8afe3b6e2/propcache-0.4.1-cp314-cp314-win32.whl", hash = "sha256:ab4c29b49d560fe48b696cdcb127dd36e0bc2472548f3bf56cc5cb3da2b2984f", size = 38140, upload-time = "2025-10-08T19:48:11.232Z" }, - { url = "https://files.pythonhosted.org/packages/0c/2a/a758b47de253636e1b8aef181c0b4f4f204bf0dd964914fb2af90a95b49b/propcache-0.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:5a103c3eb905fcea0ab98be99c3a9a5ab2de60228aa5aceedc614c0281cf6153", size = 41257, upload-time = "2025-10-08T19:48:12.707Z" }, - { url = "https://files.pythonhosted.org/packages/34/5e/63bd5896c3fec12edcbd6f12508d4890d23c265df28c74b175e1ef9f4f3b/propcache-0.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:74c1fb26515153e482e00177a1ad654721bf9207da8a494a0c05e797ad27b992", size = 38097, upload-time = "2025-10-08T19:48:13.923Z" }, - { url = "https://files.pythonhosted.org/packages/99/85/9ff785d787ccf9bbb3f3106f79884a130951436f58392000231b4c737c80/propcache-0.4.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:824e908bce90fb2743bd6b59db36eb4f45cd350a39637c9f73b1c1ea66f5b75f", size = 81455, upload-time = "2025-10-08T19:48:15.16Z" }, - { url = "https://files.pythonhosted.org/packages/90/85/2431c10c8e7ddb1445c1f7c4b54d886e8ad20e3c6307e7218f05922cad67/propcache-0.4.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:c2b5e7db5328427c57c8e8831abda175421b709672f6cfc3d630c3b7e2146393", size = 46372, upload-time = "2025-10-08T19:48:16.424Z" }, - { url = "https://files.pythonhosted.org/packages/01/20/b0972d902472da9bcb683fa595099911f4d2e86e5683bcc45de60dd05dc3/propcache-0.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6f6ff873ed40292cd4969ef5310179afd5db59fdf055897e282485043fc80ad0", size = 48411, upload-time = "2025-10-08T19:48:17.577Z" }, - { url = "https://files.pythonhosted.org/packages/e2/e3/7dc89f4f21e8f99bad3d5ddb3a3389afcf9da4ac69e3deb2dcdc96e74169/propcache-0.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:49a2dc67c154db2c1463013594c458881a069fcf98940e61a0569016a583020a", size = 275712, upload-time = "2025-10-08T19:48:18.901Z" }, - { url = "https://files.pythonhosted.org/packages/20/67/89800c8352489b21a8047c773067644e3897f02ecbbd610f4d46b7f08612/propcache-0.4.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:005f08e6a0529984491e37d8dbc3dd86f84bd78a8ceb5fa9a021f4c48d4984be", size = 273557, upload-time = "2025-10-08T19:48:20.762Z" }, - { url = "https://files.pythonhosted.org/packages/e2/a1/b52b055c766a54ce6d9c16d9aca0cad8059acd9637cdf8aa0222f4a026ef/propcache-0.4.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5c3310452e0d31390da9035c348633b43d7e7feb2e37be252be6da45abd1abcc", size = 280015, upload-time = "2025-10-08T19:48:22.592Z" }, - { url = "https://files.pythonhosted.org/packages/48/c8/33cee30bd890672c63743049f3c9e4be087e6780906bfc3ec58528be59c1/propcache-0.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4c3c70630930447f9ef1caac7728c8ad1c56bc5015338b20fed0d08ea2480b3a", size = 262880, upload-time = "2025-10-08T19:48:23.947Z" }, - { url = "https://files.pythonhosted.org/packages/0c/b1/8f08a143b204b418285c88b83d00edbd61afbc2c6415ffafc8905da7038b/propcache-0.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8e57061305815dfc910a3634dcf584f08168a8836e6999983569f51a8544cd89", size = 260938, upload-time = "2025-10-08T19:48:25.656Z" }, - { url = "https://files.pythonhosted.org/packages/cf/12/96e4664c82ca2f31e1c8dff86afb867348979eb78d3cb8546a680287a1e9/propcache-0.4.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:521a463429ef54143092c11a77e04056dd00636f72e8c45b70aaa3140d639726", size = 247641, upload-time = "2025-10-08T19:48:27.207Z" }, - { url = "https://files.pythonhosted.org/packages/18/ed/e7a9cfca28133386ba52278136d42209d3125db08d0a6395f0cba0c0285c/propcache-0.4.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:120c964da3fdc75e3731aa392527136d4ad35868cc556fd09bb6d09172d9a367", size = 262510, upload-time = "2025-10-08T19:48:28.65Z" }, - { url = "https://files.pythonhosted.org/packages/f5/76/16d8bf65e8845dd62b4e2b57444ab81f07f40caa5652b8969b87ddcf2ef6/propcache-0.4.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:d8f353eb14ee3441ee844ade4277d560cdd68288838673273b978e3d6d2c8f36", size = 263161, upload-time = "2025-10-08T19:48:30.133Z" }, - { url = "https://files.pythonhosted.org/packages/e7/70/c99e9edb5d91d5ad8a49fa3c1e8285ba64f1476782fed10ab251ff413ba1/propcache-0.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ab2943be7c652f09638800905ee1bab2c544e537edb57d527997a24c13dc1455", size = 257393, upload-time = "2025-10-08T19:48:31.567Z" }, - { url = "https://files.pythonhosted.org/packages/08/02/87b25304249a35c0915d236575bc3574a323f60b47939a2262b77632a3ee/propcache-0.4.1-cp314-cp314t-win32.whl", hash = "sha256:05674a162469f31358c30bcaa8883cb7829fa3110bf9c0991fe27d7896c42d85", size = 42546, upload-time = "2025-10-08T19:48:32.872Z" }, - { url = "https://files.pythonhosted.org/packages/cb/ef/3c6ecf8b317aa982f309835e8f96987466123c6e596646d4e6a1dfcd080f/propcache-0.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:990f6b3e2a27d683cb7602ed6c86f15ee6b43b1194736f9baaeb93d0016633b1", size = 46259, upload-time = "2025-10-08T19:48:34.226Z" }, - { url = "https://files.pythonhosted.org/packages/c4/2d/346e946d4951f37eca1e4f55be0f0174c52cd70720f84029b02f296f4a38/propcache-0.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ecef2343af4cc68e05131e45024ba34f6095821988a9d0a02aa7c73fcc448aa9", size = 40428, upload-time = "2025-10-08T19:48:35.441Z" }, { url = "https://files.pythonhosted.org/packages/5b/5a/bc7b4a4ef808fa59a816c17b20c4bef6884daebbdf627ff2a161da67da19/propcache-0.4.1-py3-none-any.whl", hash = "sha256:af2a6052aeb6cf17d3e46ee169099044fd8224cbaf75c76a2ef596e8163e2237", size = 13305, upload-time = "2025-10-08T19:49:00.792Z" }, ] @@ -3273,34 +2698,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679, upload-time = "2025-11-04T13:40:50.619Z" }, { url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766, upload-time = "2025-11-04T13:40:52.631Z" }, { url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005, upload-time = "2025-11-04T13:40:54.734Z" }, - { url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622, upload-time = "2025-11-04T13:40:56.68Z" }, - { url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725, upload-time = "2025-11-04T13:40:58.807Z" }, - { url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040, upload-time = "2025-11-04T13:41:00.853Z" }, - { url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691, upload-time = "2025-11-04T13:41:03.504Z" }, - { url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897, upload-time = "2025-11-04T13:41:05.804Z" }, - { url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302, upload-time = "2025-11-04T13:41:07.809Z" }, - { url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877, upload-time = "2025-11-04T13:41:09.827Z" }, - { url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680, upload-time = "2025-11-04T13:41:12.379Z" }, - { url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960, upload-time = "2025-11-04T13:41:14.627Z" }, - { url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102, upload-time = "2025-11-04T13:41:16.868Z" }, - { url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039, upload-time = "2025-11-04T13:41:18.934Z" }, - { url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126, upload-time = "2025-11-04T13:41:21.418Z" }, - { url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489, upload-time = "2025-11-04T13:41:24.076Z" }, - { url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288, upload-time = "2025-11-04T13:41:26.33Z" }, - { url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255, upload-time = "2025-11-04T13:41:28.569Z" }, - { url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760, upload-time = "2025-11-04T13:41:31.055Z" }, - { url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092, upload-time = "2025-11-04T13:41:33.21Z" }, - { url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385, upload-time = "2025-11-04T13:41:35.508Z" }, - { url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832, upload-time = "2025-11-04T13:41:37.732Z" }, - { url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585, upload-time = "2025-11-04T13:41:40Z" }, - { url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078, upload-time = "2025-11-04T13:41:42.323Z" }, - { url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914, upload-time = "2025-11-04T13:41:45.221Z" }, - { url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560, upload-time = "2025-11-04T13:41:47.474Z" }, - { url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244, upload-time = "2025-11-04T13:41:49.992Z" }, - { url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955, upload-time = "2025-11-04T13:41:54.079Z" }, - { url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906, upload-time = "2025-11-04T13:41:56.606Z" }, - { url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607, upload-time = "2025-11-04T13:41:58.889Z" }, - { url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" }, { url = "https://files.pythonhosted.org/packages/09/32/59b0c7e63e277fa7911c2fc70ccfb45ce4b98991e7ef37110663437005af/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd", size = 2110495, upload-time = "2025-11-04T13:42:49.689Z" }, { url = "https://files.pythonhosted.org/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", size = 1915388, upload-time = "2025-11-04T13:42:52.215Z" }, { url = "https://files.pythonhosted.org/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", size = 1942879, upload-time = "2025-11-04T13:42:56.483Z" }, @@ -3514,7 +2911,6 @@ name = "python-telegram-bot" version = "22.7" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "httpcore", marker = "python_full_version >= '3.14'" }, { name = "httpx" }, ] sdist = { url = "https://files.pythonhosted.org/packages/e4/25/2258161b1069e66d6c39c0a602dbe57461d4767dc0012539970ea40bc9d6/python_telegram_bot-22.7.tar.gz", hash = "sha256:784b59ea3852fe4616ad63b4a0264c755637f5d725e87755ecdee28300febf61", size = 1516454, upload-time = "2026-03-16T09:36:03.174Z" } @@ -3543,16 +2939,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e0/d2/afe5c7f8607018beb99971489dbb846508f1b8f351fcefc225fcf4b2adc0/pytokens-0.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:29d1d8fb1030af4d231789959f21821ab6325e463f0503a61d204343c9b355d1", size = 268423, upload-time = "2026-01-30T01:03:15.936Z" }, { url = "https://files.pythonhosted.org/packages/68/d4/00ffdbd370410c04e9591da9220a68dc1693ef7499173eb3e30d06e05ed1/pytokens-0.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:970b08dd6b86058b6dc07efe9e98414f5102974716232d10f32ff39701e841c4", size = 266859, upload-time = "2026-01-30T01:03:17.458Z" }, { url = "https://files.pythonhosted.org/packages/a7/c9/c3161313b4ca0c601eeefabd3d3b576edaa9afdefd32da97210700e47652/pytokens-0.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:9bd7d7f544d362576be74f9d5901a22f317efc20046efe2034dced238cbbfe78", size = 103520, upload-time = "2026-01-30T01:03:18.652Z" }, - { url = "https://files.pythonhosted.org/packages/8f/a7/b470f672e6fc5fee0a01d9e75005a0e617e162381974213a945fcd274843/pytokens-0.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4a14d5f5fc78ce85e426aa159489e2d5961acf0e47575e08f35584009178e321", size = 160821, upload-time = "2026-01-30T01:03:19.684Z" }, - { url = "https://files.pythonhosted.org/packages/80/98/e83a36fe8d170c911f864bfded690d2542bfcfacb9c649d11a9e6eb9dc41/pytokens-0.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97f50fd18543be72da51dd505e2ed20d2228c74e0464e4262e4899797803d7fa", size = 254263, upload-time = "2026-01-30T01:03:20.834Z" }, - { url = "https://files.pythonhosted.org/packages/0f/95/70d7041273890f9f97a24234c00b746e8da86df462620194cef1d411ddeb/pytokens-0.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dc74c035f9bfca0255c1af77ddd2d6ae8419012805453e4b0e7513e17904545d", size = 268071, upload-time = "2026-01-30T01:03:21.888Z" }, - { url = "https://files.pythonhosted.org/packages/da/79/76e6d09ae19c99404656d7db9c35dfd20f2086f3eb6ecb496b5b31163bad/pytokens-0.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f66a6bbe741bd431f6d741e617e0f39ec7257ca1f89089593479347cc4d13324", size = 271716, upload-time = "2026-01-30T01:03:23.633Z" }, - { url = "https://files.pythonhosted.org/packages/79/37/482e55fa1602e0a7ff012661d8c946bafdc05e480ea5a32f4f7e336d4aa9/pytokens-0.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:b35d7e5ad269804f6697727702da3c517bb8a5228afa450ab0fa787732055fc9", size = 104539, upload-time = "2026-01-30T01:03:24.788Z" }, - { url = "https://files.pythonhosted.org/packages/30/e8/20e7db907c23f3d63b0be3b8a4fd1927f6da2395f5bcc7f72242bb963dfe/pytokens-0.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:8fcb9ba3709ff77e77f1c7022ff11d13553f3c30299a9fe246a166903e9091eb", size = 168474, upload-time = "2026-01-30T01:03:26.428Z" }, - { url = "https://files.pythonhosted.org/packages/d6/81/88a95ee9fafdd8f5f3452107748fd04c24930d500b9aba9738f3ade642cc/pytokens-0.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:79fc6b8699564e1f9b521582c35435f1bd32dd06822322ec44afdeba666d8cb3", size = 290473, upload-time = "2026-01-30T01:03:27.415Z" }, - { url = "https://files.pythonhosted.org/packages/cf/35/3aa899645e29b6375b4aed9f8d21df219e7c958c4c186b465e42ee0a06bf/pytokens-0.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d31b97b3de0f61571a124a00ffe9a81fb9939146c122c11060725bd5aea79975", size = 303485, upload-time = "2026-01-30T01:03:28.558Z" }, - { url = "https://files.pythonhosted.org/packages/52/a0/07907b6ff512674d9b201859f7d212298c44933633c946703a20c25e9d81/pytokens-0.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:967cf6e3fd4adf7de8fc73cd3043754ae79c36475c1c11d514fc72cf5490094a", size = 306698, upload-time = "2026-01-30T01:03:29.653Z" }, - { url = "https://files.pythonhosted.org/packages/39/2a/cbbf9250020a4a8dd53ba83a46c097b69e5eb49dd14e708f496f548c6612/pytokens-0.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:584c80c24b078eec1e227079d56dc22ff755e0ba8654d8383b2c549107528918", size = 116287, upload-time = "2026-01-30T01:03:30.912Z" }, { url = "https://files.pythonhosted.org/packages/c6/78/397db326746f0a342855b81216ae1f0a32965deccfd7c830a2dbc66d2483/pytokens-0.4.1-py3-none-any.whl", hash = "sha256:26cef14744a8385f35d0e095dc8b3a7583f6c953c2e3d269c7f82484bf5ad2de", size = 13729, upload-time = "2026-01-30T01:03:45.029Z" }, ] @@ -3592,9 +2978,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a5/be/3fd5de0979fcb3994bfee0d65ed8ca9506a8a1260651b86174f6a86f52b3/pywin32-311-cp313-cp313-win32.whl", hash = "sha256:f95ba5a847cba10dd8c4d8fefa9f2a6cf283b8b88ed6178fa8a6c1ab16054d0d", size = 8705700, upload-time = "2025-07-14T20:13:26.471Z" }, { url = "https://files.pythonhosted.org/packages/e3/28/e0a1909523c6890208295a29e05c2adb2126364e289826c0a8bc7297bd5c/pywin32-311-cp313-cp313-win_amd64.whl", hash = "sha256:718a38f7e5b058e76aee1c56ddd06908116d35147e133427e59a3983f703a20d", size = 9494700, upload-time = "2025-07-14T20:13:28.243Z" }, { url = "https://files.pythonhosted.org/packages/04/bf/90339ac0f55726dce7d794e6d79a18a91265bdf3aa70b6b9ca52f35e022a/pywin32-311-cp313-cp313-win_arm64.whl", hash = "sha256:7b4075d959648406202d92a2310cb990fea19b535c7f4a78d3f5e10b926eeb8a", size = 8709318, upload-time = "2025-07-14T20:13:30.348Z" }, - { url = "https://files.pythonhosted.org/packages/c9/31/097f2e132c4f16d99a22bfb777e0fd88bd8e1c634304e102f313af69ace5/pywin32-311-cp314-cp314-win32.whl", hash = "sha256:b7a2c10b93f8986666d0c803ee19b5990885872a7de910fc460f9b0c2fbf92ee", size = 8840714, upload-time = "2025-07-14T20:13:32.449Z" }, - { url = "https://files.pythonhosted.org/packages/90/4b/07c77d8ba0e01349358082713400435347df8426208171ce297da32c313d/pywin32-311-cp314-cp314-win_amd64.whl", hash = "sha256:3aca44c046bd2ed8c90de9cb8427f581c479e594e99b5c0bb19b29c10fd6cb87", size = 9656800, upload-time = "2025-07-14T20:13:34.312Z" }, - { url = "https://files.pythonhosted.org/packages/c0/d2/21af5c535501a7233e734b8af901574572da66fcc254cb35d0609c9080dd/pywin32-311-cp314-cp314-win_arm64.whl", hash = "sha256:a508e2d9025764a8270f93111a970e1d0fbfc33f4153b388bb649b7eec4f9b42", size = 8932540, upload-time = "2025-07-14T20:13:36.379Z" }, ] [[package]] @@ -3632,24 +3015,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, - { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, - { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, - { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, - { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, - { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, - { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, - { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, - { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, - { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, - { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, - { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, - { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, - { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, - { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, - { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, - { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, - { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, - { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, ] [[package]] @@ -3720,38 +3085,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2a/5c/83e3b1d89fa4f6e5a1bc97b4abd4a9a97b3c1ac7854164f694f5f0ba98a0/regex-2026.4.4-cp313-cp313t-win32.whl", hash = "sha256:e014a797de43d1847df957c0a2a8e861d1c17547ee08467d1db2c370b7568baa", size = 269921, upload-time = "2026-04-03T20:55:09.62Z" }, { url = "https://files.pythonhosted.org/packages/28/07/077c387121f42cdb4d92b1301133c0d93b5709d096d1669ab847dda9fe2e/regex-2026.4.4-cp313-cp313t-win_amd64.whl", hash = "sha256:b15b88b0d52b179712632832c1d6e58e5774f93717849a41096880442da41ab0", size = 281240, upload-time = "2026-04-03T20:55:11.521Z" }, { url = "https://files.pythonhosted.org/packages/9d/22/ead4a4abc7c59a4d882662aa292ca02c8b617f30b6e163bc1728879e9353/regex-2026.4.4-cp313-cp313t-win_arm64.whl", hash = "sha256:586b89cdadf7d67bf86ae3342a4dcd2b8d70a832d90c18a0ae955105caf34dbe", size = 272440, upload-time = "2026-04-03T20:55:13.365Z" }, - { url = "https://files.pythonhosted.org/packages/f0/f5/ed97c2dc47b5fbd4b73c0d7d75f9ebc8eca139f2bbef476bba35f28c0a77/regex-2026.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:2da82d643fa698e5e5210e54af90181603d5853cf469f5eedf9bfc8f59b4b8c7", size = 490343, upload-time = "2026-04-03T20:55:15.241Z" }, - { url = "https://files.pythonhosted.org/packages/80/e9/de4828a7385ec166d673a5790ad06ac48cdaa98bc0960108dd4b9cc1aef7/regex-2026.4.4-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:54a1189ad9d9357760557c91103d5e421f0a2dabe68a5cdf9103d0dcf4e00752", size = 291909, upload-time = "2026-04-03T20:55:17.558Z" }, - { url = "https://files.pythonhosted.org/packages/b4/d6/5cfbfc97f3201a4d24b596a77957e092030dcc4205894bc035cedcfce62f/regex-2026.4.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:76d67d5afb1fe402d10a6403bae668d000441e2ab115191a804287d53b772951", size = 289692, upload-time = "2026-04-03T20:55:20.561Z" }, - { url = "https://files.pythonhosted.org/packages/8e/ac/f2212d9fd56fe897e36d0110ba30ba2d247bd6410c5bd98499c7e5a1e1f2/regex-2026.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e7cd3e4ee8d80447a83bbc9ab0c8459781fa77087f856c3e740d7763be0df27f", size = 796979, upload-time = "2026-04-03T20:55:22.56Z" }, - { url = "https://files.pythonhosted.org/packages/c9/e3/a016c12675fbac988a60c7e1c16e67823ff0bc016beb27bd7a001dbdabc6/regex-2026.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2e19e18c568d2866d8b6a6dfad823db86193503f90823a8f66689315ba28fbe8", size = 866744, upload-time = "2026-04-03T20:55:24.646Z" }, - { url = "https://files.pythonhosted.org/packages/af/a4/0b90ca4cf17adc3cb43de80ec71018c37c88ad64987e8d0d481a95ca60b5/regex-2026.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7698a6f38730fd1385d390d1ed07bb13dce39aa616aca6a6d89bea178464b9a4", size = 911613, upload-time = "2026-04-03T20:55:27.033Z" }, - { url = "https://files.pythonhosted.org/packages/8e/3b/2b3dac0b82d41ab43aa87c6ecde63d71189d03fe8854b8ca455a315edac3/regex-2026.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:173a66f3651cdb761018078e2d9487f4cf971232c990035ec0eb1cdc6bf929a9", size = 800551, upload-time = "2026-04-03T20:55:29.532Z" }, - { url = "https://files.pythonhosted.org/packages/25/fe/5365eb7aa0e753c4b5957815c321519ecab033c279c60e1b1ae2367fa810/regex-2026.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fa7922bbb2cc84fa062d37723f199d4c0cd200245ce269c05db82d904db66b83", size = 776911, upload-time = "2026-04-03T20:55:31.526Z" }, - { url = "https://files.pythonhosted.org/packages/aa/b3/7fb0072156bba065e3b778a7bc7b0a6328212be5dd6a86fd207e0c4f2dab/regex-2026.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:59f67cd0a0acaf0e564c20bbd7f767286f23e91e2572c5703bf3e56ea7557edb", size = 785751, upload-time = "2026-04-03T20:55:33.797Z" }, - { url = "https://files.pythonhosted.org/packages/02/1a/9f83677eb699273e56e858f7bd95acdbee376d42f59e8bfca2fd80d79df3/regex-2026.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:475e50f3f73f73614f7cba5524d6de49dee269df00272a1b85e3d19f6d498465", size = 860484, upload-time = "2026-04-03T20:55:35.745Z" }, - { url = "https://files.pythonhosted.org/packages/3b/7a/93937507b61cfcff8b4c5857f1b452852b09f741daa9acae15c971d8554e/regex-2026.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:a1c0c7d67b64d85ac2e1879923bad2f08a08f3004055f2f406ef73c850114bd4", size = 765939, upload-time = "2026-04-03T20:55:37.972Z" }, - { url = "https://files.pythonhosted.org/packages/86/ea/81a7f968a351c6552b1670ead861e2a385be730ee28402233020c67f9e0f/regex-2026.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:1371c2ccbb744d66ee63631cc9ca12aa233d5749972626b68fe1a649dd98e566", size = 851417, upload-time = "2026-04-03T20:55:39.92Z" }, - { url = "https://files.pythonhosted.org/packages/4c/7e/323c18ce4b5b8f44517a36342961a0306e931e499febbd876bb149d900f0/regex-2026.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:59968142787042db793348a3f5b918cf24ced1f23247328530e063f89c128a95", size = 789056, upload-time = "2026-04-03T20:55:42.303Z" }, - { url = "https://files.pythonhosted.org/packages/c0/af/e7510f9b11b1913b0cd44eddb784b2d650b2af6515bfce4cffcc5bfd1d38/regex-2026.4.4-cp314-cp314-win32.whl", hash = "sha256:59efe72d37fd5a91e373e5146f187f921f365f4abc1249a5ab446a60f30dd5f8", size = 272130, upload-time = "2026-04-03T20:55:44.995Z" }, - { url = "https://files.pythonhosted.org/packages/9a/51/57dae534c915e2d3a21490e88836fa2ae79dde3b66255ecc0c0a155d2c10/regex-2026.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:e0aab3ff447845049d676827d2ff714aab4f73f340e155b7de7458cf53baa5a4", size = 280992, upload-time = "2026-04-03T20:55:47.316Z" }, - { url = "https://files.pythonhosted.org/packages/0a/5e/abaf9f4c3792e34edb1434f06717fae2b07888d85cb5cec29f9204931bf8/regex-2026.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:a7a5bb6aa0cf62208bb4fa079b0c756734f8ad0e333b425732e8609bd51ee22f", size = 273563, upload-time = "2026-04-03T20:55:49.273Z" }, - { url = "https://files.pythonhosted.org/packages/ff/06/35da85f9f217b9538b99cbb170738993bcc3b23784322decb77619f11502/regex-2026.4.4-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:97850d0638391bdc7d35dc1c1039974dcb921eaafa8cc935ae4d7f272b1d60b3", size = 494191, upload-time = "2026-04-03T20:55:51.258Z" }, - { url = "https://files.pythonhosted.org/packages/54/5b/1bc35f479eef8285c4baf88d8c002023efdeebb7b44a8735b36195486ae7/regex-2026.4.4-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:ee7337f88f2a580679f7bbfe69dc86c043954f9f9c541012f49abc554a962f2e", size = 293877, upload-time = "2026-04-03T20:55:53.214Z" }, - { url = "https://files.pythonhosted.org/packages/39/5b/f53b9ad17480b3ddd14c90da04bfb55ac6894b129e5dea87bcaf7d00e336/regex-2026.4.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7429f4e6192c11d659900c0648ba8776243bf396ab95558b8c51a345afeddde6", size = 292410, upload-time = "2026-04-03T20:55:55.736Z" }, - { url = "https://files.pythonhosted.org/packages/bb/56/52377f59f60a7c51aa4161eecf0b6032c20b461805aca051250da435ffc9/regex-2026.4.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dc4f10fbd5dd13dcf4265b4cc07d69ca70280742870c97ae10093e3d66000359", size = 811831, upload-time = "2026-04-03T20:55:57.802Z" }, - { url = "https://files.pythonhosted.org/packages/dd/63/8026310bf066f702a9c361f83a8c9658f3fe4edb349f9c1e5d5273b7c40c/regex-2026.4.4-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a152560af4f9742b96f3827090f866eeec5becd4765c8e0d3473d9d280e76a5a", size = 871199, upload-time = "2026-04-03T20:56:00.333Z" }, - { url = "https://files.pythonhosted.org/packages/20/9f/a514bbb00a466dbb506d43f187a04047f7be1505f10a9a15615ead5080ee/regex-2026.4.4-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:54170b3e95339f415d54651f97df3bff7434a663912f9358237941bbf9143f55", size = 917649, upload-time = "2026-04-03T20:56:02.445Z" }, - { url = "https://files.pythonhosted.org/packages/cb/6b/8399f68dd41a2030218839b9b18360d79b86d22b9fab5ef477c7f23ca67c/regex-2026.4.4-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:07f190d65f5a72dcb9cf7106bfc3d21e7a49dd2879eda2207b683f32165e4d99", size = 816388, upload-time = "2026-04-03T20:56:04.595Z" }, - { url = "https://files.pythonhosted.org/packages/1e/9c/103963f47c24339a483b05edd568594c2be486188f688c0170fd504b2948/regex-2026.4.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9a2741ce5a29d3c84b0b94261ba630ab459a1b847a0d6beca7d62d188175c790", size = 785746, upload-time = "2026-04-03T20:56:07.13Z" }, - { url = "https://files.pythonhosted.org/packages/fa/ee/7f6054c0dec0cee3463c304405e4ff42e27cff05bf36fcb34be549ab17bd/regex-2026.4.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:b26c30df3a28fd9793113dac7385a4deb7294a06c0f760dd2b008bd49a9139bc", size = 801483, upload-time = "2026-04-03T20:56:09.365Z" }, - { url = "https://files.pythonhosted.org/packages/30/c2/51d3d941cf6070dc00c3338ecf138615fc3cce0421c3df6abe97a08af61a/regex-2026.4.4-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:421439d1bee44b19f4583ccf42670ca464ffb90e9fdc38d37f39d1ddd1e44f1f", size = 866331, upload-time = "2026-04-03T20:56:12.039Z" }, - { url = "https://files.pythonhosted.org/packages/16/e8/76d50dcc122ac33927d939f350eebcfe3dbcbda96913e03433fc36de5e63/regex-2026.4.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:b40379b53ecbc747fd9bdf4a0ea14eb8188ca1bd0f54f78893a39024b28f4863", size = 772673, upload-time = "2026-04-03T20:56:14.558Z" }, - { url = "https://files.pythonhosted.org/packages/a5/6e/5f6bf75e20ea6873d05ba4ec78378c375cbe08cdec571c83fbb01606e563/regex-2026.4.4-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:08c55c13d2eef54f73eeadc33146fb0baaa49e7335eb1aff6ae1324bf0ddbe4a", size = 857146, upload-time = "2026-04-03T20:56:16.663Z" }, - { url = "https://files.pythonhosted.org/packages/0b/33/3c76d9962949e487ebba353a18e89399f292287204ac8f2f4cfc3a51c233/regex-2026.4.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9776b85f510062f5a75ef112afe5f494ef1635607bf1cc220c1391e9ac2f5e81", size = 803463, upload-time = "2026-04-03T20:56:18.923Z" }, - { url = "https://files.pythonhosted.org/packages/19/eb/ef32dcd2cb69b69bc0c3e55205bce94a7def48d495358946bc42186dcccc/regex-2026.4.4-cp314-cp314t-win32.whl", hash = "sha256:385edaebde5db5be103577afc8699fea73a0e36a734ba24870be7ffa61119d74", size = 275709, upload-time = "2026-04-03T20:56:20.996Z" }, - { url = "https://files.pythonhosted.org/packages/a0/86/c291bf740945acbf35ed7dbebf8e2eea2f3f78041f6bd7cdab80cb274dc0/regex-2026.4.4-cp314-cp314t-win_amd64.whl", hash = "sha256:5d354b18839328927832e2fa5f7c95b7a3ccc39e7a681529e1685898e6436d45", size = 285622, upload-time = "2026-04-03T20:56:23.641Z" }, - { url = "https://files.pythonhosted.org/packages/d5/e7/ec846d560ae6a597115153c02ca6138a7877a1748b2072d9521c10a93e58/regex-2026.4.4-cp314-cp314t-win_arm64.whl", hash = "sha256:af0384cb01a33600c49505c27c6c57ab0b27bf84a74e28524c92ca897ebdac9d", size = 275773, upload-time = "2026-04-03T20:56:26.07Z" }, ] [[package]] @@ -3845,35 +3178,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6d/61/21b8c41f68e60c8cc3b2e25644f0e3681926020f11d06ab0b78e3c6bbff1/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4c5f36a861bc4b7da6516dbdf302c55313afa09b81931e8280361a4f6c9a2d27", size = 555806, upload-time = "2025-11-30T20:23:22.488Z" }, { url = "https://files.pythonhosted.org/packages/f9/39/7e067bb06c31de48de3eb200f9fc7c58982a4d3db44b07e73963e10d3be9/rpds_py-0.30.0-cp313-cp313t-win32.whl", hash = "sha256:3d4a69de7a3e50ffc214ae16d79d8fbb0922972da0356dcf4d0fdca2878559c6", size = 211341, upload-time = "2025-11-30T20:23:24.449Z" }, { url = "https://files.pythonhosted.org/packages/0a/4d/222ef0b46443cf4cf46764d9c630f3fe4abaa7245be9417e56e9f52b8f65/rpds_py-0.30.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f14fc5df50a716f7ece6a80b6c78bb35ea2ca47c499e422aa4463455dd96d56d", size = 225768, upload-time = "2025-11-30T20:23:25.908Z" }, - { url = "https://files.pythonhosted.org/packages/86/81/dad16382ebbd3d0e0328776d8fd7ca94220e4fa0798d1dc5e7da48cb3201/rpds_py-0.30.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:68f19c879420aa08f61203801423f6cd5ac5f0ac4ac82a2368a9fcd6a9a075e0", size = 362099, upload-time = "2025-11-30T20:23:27.316Z" }, - { url = "https://files.pythonhosted.org/packages/2b/60/19f7884db5d5603edf3c6bce35408f45ad3e97e10007df0e17dd57af18f8/rpds_py-0.30.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ec7c4490c672c1a0389d319b3a9cfcd098dcdc4783991553c332a15acf7249be", size = 353192, upload-time = "2025-11-30T20:23:29.151Z" }, - { url = "https://files.pythonhosted.org/packages/bf/c4/76eb0e1e72d1a9c4703c69607cec123c29028bff28ce41588792417098ac/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f251c812357a3fed308d684a5079ddfb9d933860fc6de89f2b7ab00da481e65f", size = 384080, upload-time = "2025-11-30T20:23:30.785Z" }, - { url = "https://files.pythonhosted.org/packages/72/87/87ea665e92f3298d1b26d78814721dc39ed8d2c74b86e83348d6b48a6f31/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac98b175585ecf4c0348fd7b29c3864bda53b805c773cbf7bfdaffc8070c976f", size = 394841, upload-time = "2025-11-30T20:23:32.209Z" }, - { url = "https://files.pythonhosted.org/packages/77/ad/7783a89ca0587c15dcbf139b4a8364a872a25f861bdb88ed99f9b0dec985/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3e62880792319dbeb7eb866547f2e35973289e7d5696c6e295476448f5b63c87", size = 516670, upload-time = "2025-11-30T20:23:33.742Z" }, - { url = "https://files.pythonhosted.org/packages/5b/3c/2882bdac942bd2172f3da574eab16f309ae10a3925644e969536553cb4ee/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e7fc54e0900ab35d041b0601431b0a0eb495f0851a0639b6ef90f7741b39a18", size = 408005, upload-time = "2025-11-30T20:23:35.253Z" }, - { url = "https://files.pythonhosted.org/packages/ce/81/9a91c0111ce1758c92516a3e44776920b579d9a7c09b2b06b642d4de3f0f/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47e77dc9822d3ad616c3d5759ea5631a75e5809d5a28707744ef79d7a1bcfcad", size = 382112, upload-time = "2025-11-30T20:23:36.842Z" }, - { url = "https://files.pythonhosted.org/packages/cf/8e/1da49d4a107027e5fbc64daeab96a0706361a2918da10cb41769244b805d/rpds_py-0.30.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:b4dc1a6ff022ff85ecafef7979a2c6eb423430e05f1165d6688234e62ba99a07", size = 399049, upload-time = "2025-11-30T20:23:38.343Z" }, - { url = "https://files.pythonhosted.org/packages/df/5a/7ee239b1aa48a127570ec03becbb29c9d5a9eb092febbd1699d567cae859/rpds_py-0.30.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4559c972db3a360808309e06a74628b95eaccbf961c335c8fe0d590cf587456f", size = 415661, upload-time = "2025-11-30T20:23:40.263Z" }, - { url = "https://files.pythonhosted.org/packages/70/ea/caa143cf6b772f823bc7929a45da1fa83569ee49b11d18d0ada7f5ee6fd6/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0ed177ed9bded28f8deb6ab40c183cd1192aa0de40c12f38be4d59cd33cb5c65", size = 565606, upload-time = "2025-11-30T20:23:42.186Z" }, - { url = "https://files.pythonhosted.org/packages/64/91/ac20ba2d69303f961ad8cf55bf7dbdb4763f627291ba3d0d7d67333cced9/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ad1fa8db769b76ea911cb4e10f049d80bf518c104f15b3edb2371cc65375c46f", size = 591126, upload-time = "2025-11-30T20:23:44.086Z" }, - { url = "https://files.pythonhosted.org/packages/21/20/7ff5f3c8b00c8a95f75985128c26ba44503fb35b8e0259d812766ea966c7/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:46e83c697b1f1c72b50e5ee5adb4353eef7406fb3f2043d64c33f20ad1c2fc53", size = 553371, upload-time = "2025-11-30T20:23:46.004Z" }, - { url = "https://files.pythonhosted.org/packages/72/c7/81dadd7b27c8ee391c132a6b192111ca58d866577ce2d9b0ca157552cce0/rpds_py-0.30.0-cp314-cp314-win32.whl", hash = "sha256:ee454b2a007d57363c2dfd5b6ca4a5d7e2c518938f8ed3b706e37e5d470801ed", size = 215298, upload-time = "2025-11-30T20:23:47.696Z" }, - { url = "https://files.pythonhosted.org/packages/3e/d2/1aaac33287e8cfb07aab2e6b8ac1deca62f6f65411344f1433c55e6f3eb8/rpds_py-0.30.0-cp314-cp314-win_amd64.whl", hash = "sha256:95f0802447ac2d10bcc69f6dc28fe95fdf17940367b21d34e34c737870758950", size = 228604, upload-time = "2025-11-30T20:23:49.501Z" }, - { url = "https://files.pythonhosted.org/packages/e8/95/ab005315818cc519ad074cb7784dae60d939163108bd2b394e60dc7b5461/rpds_py-0.30.0-cp314-cp314-win_arm64.whl", hash = "sha256:613aa4771c99f03346e54c3f038e4cc574ac09a3ddfb0e8878487335e96dead6", size = 222391, upload-time = "2025-11-30T20:23:50.96Z" }, - { url = "https://files.pythonhosted.org/packages/9e/68/154fe0194d83b973cdedcdcc88947a2752411165930182ae41d983dcefa6/rpds_py-0.30.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:7e6ecfcb62edfd632e56983964e6884851786443739dbfe3582947e87274f7cb", size = 364868, upload-time = "2025-11-30T20:23:52.494Z" }, - { url = "https://files.pythonhosted.org/packages/83/69/8bbc8b07ec854d92a8b75668c24d2abcb1719ebf890f5604c61c9369a16f/rpds_py-0.30.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a1d0bc22a7cdc173fedebb73ef81e07faef93692b8c1ad3733b67e31e1b6e1b8", size = 353747, upload-time = "2025-11-30T20:23:54.036Z" }, - { url = "https://files.pythonhosted.org/packages/ab/00/ba2e50183dbd9abcce9497fa5149c62b4ff3e22d338a30d690f9af970561/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0d08f00679177226c4cb8c5265012eea897c8ca3b93f429e546600c971bcbae7", size = 383795, upload-time = "2025-11-30T20:23:55.556Z" }, - { url = "https://files.pythonhosted.org/packages/05/6f/86f0272b84926bcb0e4c972262f54223e8ecc556b3224d281e6598fc9268/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5965af57d5848192c13534f90f9dd16464f3c37aaf166cc1da1cae1fd5a34898", size = 393330, upload-time = "2025-11-30T20:23:57.033Z" }, - { url = "https://files.pythonhosted.org/packages/cb/e9/0e02bb2e6dc63d212641da45df2b0bf29699d01715913e0d0f017ee29438/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9a4e86e34e9ab6b667c27f3211ca48f73dba7cd3d90f8d5b11be56e5dbc3fb4e", size = 518194, upload-time = "2025-11-30T20:23:58.637Z" }, - { url = "https://files.pythonhosted.org/packages/ee/ca/be7bca14cf21513bdf9c0606aba17d1f389ea2b6987035eb4f62bd923f25/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e5d3e6b26f2c785d65cc25ef1e5267ccbe1b069c5c21b8cc724efee290554419", size = 408340, upload-time = "2025-11-30T20:24:00.2Z" }, - { url = "https://files.pythonhosted.org/packages/c2/c7/736e00ebf39ed81d75544c0da6ef7b0998f8201b369acf842f9a90dc8fce/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:626a7433c34566535b6e56a1b39a7b17ba961e97ce3b80ec62e6f1312c025551", size = 383765, upload-time = "2025-11-30T20:24:01.759Z" }, - { url = "https://files.pythonhosted.org/packages/4a/3f/da50dfde9956aaf365c4adc9533b100008ed31aea635f2b8d7b627e25b49/rpds_py-0.30.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:acd7eb3f4471577b9b5a41baf02a978e8bdeb08b4b355273994f8b87032000a8", size = 396834, upload-time = "2025-11-30T20:24:03.687Z" }, - { url = "https://files.pythonhosted.org/packages/4e/00/34bcc2565b6020eab2623349efbdec810676ad571995911f1abdae62a3a0/rpds_py-0.30.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fe5fa731a1fa8a0a56b0977413f8cacac1768dad38d16b3a296712709476fbd5", size = 415470, upload-time = "2025-11-30T20:24:05.232Z" }, - { url = "https://files.pythonhosted.org/packages/8c/28/882e72b5b3e6f718d5453bd4d0d9cf8df36fddeb4ddbbab17869d5868616/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:74a3243a411126362712ee1524dfc90c650a503502f135d54d1b352bd01f2404", size = 565630, upload-time = "2025-11-30T20:24:06.878Z" }, - { url = "https://files.pythonhosted.org/packages/3b/97/04a65539c17692de5b85c6e293520fd01317fd878ea1995f0367d4532fb1/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:3e8eeb0544f2eb0d2581774be4c3410356eba189529a6b3e36bbbf9696175856", size = 591148, upload-time = "2025-11-30T20:24:08.445Z" }, - { url = "https://files.pythonhosted.org/packages/85/70/92482ccffb96f5441aab93e26c4d66489eb599efdcf96fad90c14bbfb976/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dbd936cde57abfee19ab3213cf9c26be06d60750e60a8e4dd85d1ab12c8b1f40", size = 556030, upload-time = "2025-11-30T20:24:10.956Z" }, - { url = "https://files.pythonhosted.org/packages/20/53/7c7e784abfa500a2b6b583b147ee4bb5a2b3747a9166bab52fec4b5b5e7d/rpds_py-0.30.0-cp314-cp314t-win32.whl", hash = "sha256:dc824125c72246d924f7f796b4f63c1e9dc810c7d9e2355864b3c3a73d59ade0", size = 211570, upload-time = "2025-11-30T20:24:12.735Z" }, - { url = "https://files.pythonhosted.org/packages/d0/02/fa464cdfbe6b26e0600b62c528b72d8608f5cc49f96b8d6e38c95d60c676/rpds_py-0.30.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27f4b0e92de5bfbc6f86e43959e6edd1425c33b5e69aab0984a72047f2bcf1e3", size = 226532, upload-time = "2025-11-30T20:24:14.634Z" }, ] [[package]] @@ -3939,26 +3243,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/bd/12/d19da97efde68ca1ee5538bb261d5d2c062f0c055575128f11a2730e3ac1/scipy-1.17.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:94055a11dfebe37c656e70317e1996dc197e1a15bbcc351bcdd4610e128fe1ca", size = 37665910, upload-time = "2026-02-23T00:20:34.743Z" }, { url = "https://files.pythonhosted.org/packages/06/1c/1172a88d507a4baaf72c5a09bb6c018fe2ae0ab622e5830b703a46cc9e44/scipy-1.17.1-cp313-cp313t-win_amd64.whl", hash = "sha256:e30bdeaa5deed6bc27b4cc490823cd0347d7dae09119b8803ae576ea0ce52e4c", size = 36562980, upload-time = "2026-02-23T00:20:40.575Z" }, { url = "https://files.pythonhosted.org/packages/70/b0/eb757336e5a76dfa7911f63252e3b7d1de00935d7705cf772db5b45ec238/scipy-1.17.1-cp313-cp313t-win_arm64.whl", hash = "sha256:a720477885a9d2411f94a93d16f9d89bad0f28ca23c3f8daa521e2dcc3f44d49", size = 24856543, upload-time = "2026-02-23T00:20:45.313Z" }, - { url = "https://files.pythonhosted.org/packages/cf/83/333afb452af6f0fd70414dc04f898647ee1423979ce02efa75c3b0f2c28e/scipy-1.17.1-cp314-cp314-macosx_10_14_x86_64.whl", hash = "sha256:a48a72c77a310327f6a3a920092fa2b8fd03d7deaa60f093038f22d98e096717", size = 31584510, upload-time = "2026-02-23T00:21:01.015Z" }, - { url = "https://files.pythonhosted.org/packages/ed/a6/d05a85fd51daeb2e4ea71d102f15b34fedca8e931af02594193ae4fd25f7/scipy-1.17.1-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:45abad819184f07240d8a696117a7aacd39787af9e0b719d00285549ed19a1e9", size = 28170131, upload-time = "2026-02-23T00:21:05.888Z" }, - { url = "https://files.pythonhosted.org/packages/db/7b/8624a203326675d7746a254083a187398090a179335b2e4a20e2ddc46e83/scipy-1.17.1-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:3fd1fcdab3ea951b610dc4cef356d416d5802991e7e32b5254828d342f7b7e0b", size = 20342032, upload-time = "2026-02-23T00:21:09.904Z" }, - { url = "https://files.pythonhosted.org/packages/c9/35/2c342897c00775d688d8ff3987aced3426858fd89d5a0e26e020b660b301/scipy-1.17.1-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:7bdf2da170b67fdf10bca777614b1c7d96ae3ca5794fd9587dce41eb2966e866", size = 22678766, upload-time = "2026-02-23T00:21:14.313Z" }, - { url = "https://files.pythonhosted.org/packages/ef/f2/7cdb8eb308a1a6ae1e19f945913c82c23c0c442a462a46480ce487fdc0ac/scipy-1.17.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:adb2642e060a6549c343603a3851ba76ef0b74cc8c079a9a58121c7ec9fe2350", size = 32957007, upload-time = "2026-02-23T00:21:19.663Z" }, - { url = "https://files.pythonhosted.org/packages/0b/2e/7eea398450457ecb54e18e9d10110993fa65561c4f3add5e8eccd2b9cd41/scipy-1.17.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eee2cfda04c00a857206a4330f0c5e3e56535494e30ca445eb19ec624ae75118", size = 35221333, upload-time = "2026-02-23T00:21:25.278Z" }, - { url = "https://files.pythonhosted.org/packages/d9/77/5b8509d03b77f093a0d52e606d3c4f79e8b06d1d38c441dacb1e26cacf46/scipy-1.17.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d2650c1fb97e184d12d8ba010493ee7b322864f7d3d00d3f9bb97d9c21de4068", size = 35042066, upload-time = "2026-02-23T00:21:31.358Z" }, - { url = "https://files.pythonhosted.org/packages/f9/df/18f80fb99df40b4070328d5ae5c596f2f00fffb50167e31439e932f29e7d/scipy-1.17.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:08b900519463543aa604a06bec02461558a6e1cef8fdbb8098f77a48a83c8118", size = 37612763, upload-time = "2026-02-23T00:21:37.247Z" }, - { url = "https://files.pythonhosted.org/packages/4b/39/f0e8ea762a764a9dc52aa7dabcfad51a354819de1f0d4652b6a1122424d6/scipy-1.17.1-cp314-cp314-win_amd64.whl", hash = "sha256:3877ac408e14da24a6196de0ddcace62092bfc12a83823e92e49e40747e52c19", size = 37290984, upload-time = "2026-02-23T00:22:35.023Z" }, - { url = "https://files.pythonhosted.org/packages/7c/56/fe201e3b0f93d1a8bcf75d3379affd228a63d7e2d80ab45467a74b494947/scipy-1.17.1-cp314-cp314-win_arm64.whl", hash = "sha256:f8885db0bc2bffa59d5c1b72fad7a6a92d3e80e7257f967dd81abb553a90d293", size = 25192877, upload-time = "2026-02-23T00:22:39.798Z" }, - { url = "https://files.pythonhosted.org/packages/96/ad/f8c414e121f82e02d76f310f16db9899c4fcde36710329502a6b2a3c0392/scipy-1.17.1-cp314-cp314t-macosx_10_14_x86_64.whl", hash = "sha256:1cc682cea2ae55524432f3cdff9e9a3be743d52a7443d0cba9017c23c87ae2f6", size = 31949750, upload-time = "2026-02-23T00:21:42.289Z" }, - { url = "https://files.pythonhosted.org/packages/7c/b0/c741e8865d61b67c81e255f4f0a832846c064e426636cd7de84e74d209be/scipy-1.17.1-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:2040ad4d1795a0ae89bfc7e8429677f365d45aa9fd5e4587cf1ea737f927b4a1", size = 28585858, upload-time = "2026-02-23T00:21:47.706Z" }, - { url = "https://files.pythonhosted.org/packages/ed/1b/3985219c6177866628fa7c2595bfd23f193ceebbe472c98a08824b9466ff/scipy-1.17.1-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:131f5aaea57602008f9822e2115029b55d4b5f7c070287699fe45c661d051e39", size = 20757723, upload-time = "2026-02-23T00:21:52.039Z" }, - { url = "https://files.pythonhosted.org/packages/c0/19/2a04aa25050d656d6f7b9e7b685cc83d6957fb101665bfd9369ca6534563/scipy-1.17.1-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:9cdc1a2fcfd5c52cfb3045feb399f7b3ce822abdde3a193a6b9a60b3cb5854ca", size = 23043098, upload-time = "2026-02-23T00:21:56.185Z" }, - { url = "https://files.pythonhosted.org/packages/86/f1/3383beb9b5d0dbddd030335bf8a8b32d4317185efe495374f134d8be6cce/scipy-1.17.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e3dcd57ab780c741fde8dc68619de988b966db759a3c3152e8e9142c26295ad", size = 33030397, upload-time = "2026-02-23T00:22:01.404Z" }, - { url = "https://files.pythonhosted.org/packages/41/68/8f21e8a65a5a03f25a79165ec9d2b28c00e66dc80546cf5eb803aeeff35b/scipy-1.17.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a9956e4d4f4a301ebf6cde39850333a6b6110799d470dbbb1e25326ac447f52a", size = 35281163, upload-time = "2026-02-23T00:22:07.024Z" }, - { url = "https://files.pythonhosted.org/packages/84/8d/c8a5e19479554007a5632ed7529e665c315ae7492b4f946b0deb39870e39/scipy-1.17.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:a4328d245944d09fd639771de275701ccadf5f781ba0ff092ad141e017eccda4", size = 35116291, upload-time = "2026-02-23T00:22:12.585Z" }, - { url = "https://files.pythonhosted.org/packages/52/52/e57eceff0e342a1f50e274264ed47497b59e6a4e3118808ee58ddda7b74a/scipy-1.17.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a77cbd07b940d326d39a1d1b37817e2ee4d79cb30e7338f3d0cddffae70fcaa2", size = 37682317, upload-time = "2026-02-23T00:22:18.513Z" }, - { url = "https://files.pythonhosted.org/packages/11/2f/b29eafe4a3fbc3d6de9662b36e028d5f039e72d345e05c250e121a230dd4/scipy-1.17.1-cp314-cp314t-win_amd64.whl", hash = "sha256:eb092099205ef62cd1782b006658db09e2fed75bffcae7cc0d44052d8aa0f484", size = 37345327, upload-time = "2026-02-23T00:22:24.442Z" }, - { url = "https://files.pythonhosted.org/packages/07/39/338d9219c4e87f3e708f18857ecd24d22a0c3094752393319553096b98af/scipy-1.17.1-cp314-cp314t-win_arm64.whl", hash = "sha256:200e1050faffacc162be6a486a984a0497866ec54149a01270adc8a59b7c7d21", size = 25489165, upload-time = "2026-02-23T00:22:29.563Z" }, ] [[package]] @@ -3966,8 +3250,8 @@ name = "secretstorage" version = "3.5.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "cryptography", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, - { name = "jeepney", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "cryptography" }, + { name = "jeepney" }, ] sdist = { url = "https://files.pythonhosted.org/packages/1c/03/e834bcd866f2f8a49a85eaff47340affa3bfa391ee9912a952a1faa68c7b/secretstorage-3.5.0.tar.gz", hash = "sha256:f04b8e4689cbce351744d5537bf6b1329c6fc68f91fa666f60a380edddcd11be", size = 19884, upload-time = "2025-11-23T19:02:53.191Z" } wheels = [ @@ -4151,20 +3435,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/22/1f/ae535223a8c4ef4c0c1192e3f9b82da660be9eb66b9279e95c99288e9dab/tiktoken-0.12.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:04f0e6a985d95913cabc96a741c5ffec525a2c72e9df086ff17ebe35985c800e", size = 1194451, upload-time = "2025-10-06T20:22:15.545Z" }, { url = "https://files.pythonhosted.org/packages/78/a7/f8ead382fce0243cb625c4f266e66c27f65ae65ee9e77f59ea1653b6d730/tiktoken-0.12.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:0ee8f9ae00c41770b5f9b0bb1235474768884ae157de3beb5439ca0fd70f3e25", size = 1253794, upload-time = "2025-10-06T20:22:16.624Z" }, { url = "https://files.pythonhosted.org/packages/93/e0/6cc82a562bc6365785a3ff0af27a2a092d57c47d7a81d9e2295d8c36f011/tiktoken-0.12.0-cp313-cp313t-win_amd64.whl", hash = "sha256:dc2dd125a62cb2b3d858484d6c614d136b5b848976794edfb63688d539b8b93f", size = 878777, upload-time = "2025-10-06T20:22:18.036Z" }, - { url = "https://files.pythonhosted.org/packages/72/05/3abc1db5d2c9aadc4d2c76fa5640134e475e58d9fbb82b5c535dc0de9b01/tiktoken-0.12.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:a90388128df3b3abeb2bfd1895b0681412a8d7dc644142519e6f0a97c2111646", size = 1050188, upload-time = "2025-10-06T20:22:19.563Z" }, - { url = "https://files.pythonhosted.org/packages/e3/7b/50c2f060412202d6c95f32b20755c7a6273543b125c0985d6fa9465105af/tiktoken-0.12.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:da900aa0ad52247d8794e307d6446bd3cdea8e192769b56276695d34d2c9aa88", size = 993978, upload-time = "2025-10-06T20:22:20.702Z" }, - { url = "https://files.pythonhosted.org/packages/14/27/bf795595a2b897e271771cd31cb847d479073497344c637966bdf2853da1/tiktoken-0.12.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:285ba9d73ea0d6171e7f9407039a290ca77efcdb026be7769dccc01d2c8d7fff", size = 1129271, upload-time = "2025-10-06T20:22:22.06Z" }, - { url = "https://files.pythonhosted.org/packages/f5/de/9341a6d7a8f1b448573bbf3425fa57669ac58258a667eb48a25dfe916d70/tiktoken-0.12.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:d186a5c60c6a0213f04a7a802264083dea1bbde92a2d4c7069e1a56630aef830", size = 1151216, upload-time = "2025-10-06T20:22:23.085Z" }, - { url = "https://files.pythonhosted.org/packages/75/0d/881866647b8d1be4d67cb24e50d0c26f9f807f994aa1510cb9ba2fe5f612/tiktoken-0.12.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:604831189bd05480f2b885ecd2d1986dc7686f609de48208ebbbddeea071fc0b", size = 1194860, upload-time = "2025-10-06T20:22:24.602Z" }, - { url = "https://files.pythonhosted.org/packages/b3/1e/b651ec3059474dab649b8d5b69f5c65cd8fcd8918568c1935bd4136c9392/tiktoken-0.12.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8f317e8530bb3a222547b85a58583238c8f74fd7a7408305f9f63246d1a0958b", size = 1254567, upload-time = "2025-10-06T20:22:25.671Z" }, - { url = "https://files.pythonhosted.org/packages/80/57/ce64fd16ac390fafde001268c364d559447ba09b509181b2808622420eec/tiktoken-0.12.0-cp314-cp314-win_amd64.whl", hash = "sha256:399c3dd672a6406719d84442299a490420b458c44d3ae65516302a99675888f3", size = 921067, upload-time = "2025-10-06T20:22:26.753Z" }, - { url = "https://files.pythonhosted.org/packages/ac/a4/72eed53e8976a099539cdd5eb36f241987212c29629d0a52c305173e0a68/tiktoken-0.12.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:c2c714c72bc00a38ca969dae79e8266ddec999c7ceccd603cc4f0d04ccd76365", size = 1050473, upload-time = "2025-10-06T20:22:27.775Z" }, - { url = "https://files.pythonhosted.org/packages/e6/d7/0110b8f54c008466b19672c615f2168896b83706a6611ba6e47313dbc6e9/tiktoken-0.12.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:cbb9a3ba275165a2cb0f9a83f5d7025afe6b9d0ab01a22b50f0e74fee2ad253e", size = 993855, upload-time = "2025-10-06T20:22:28.799Z" }, - { url = "https://files.pythonhosted.org/packages/5f/77/4f268c41a3957c418b084dd576ea2fad2e95da0d8e1ab705372892c2ca22/tiktoken-0.12.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:dfdfaa5ffff8993a3af94d1125870b1d27aed7cb97aa7eb8c1cefdbc87dbee63", size = 1129022, upload-time = "2025-10-06T20:22:29.981Z" }, - { url = "https://files.pythonhosted.org/packages/4e/2b/fc46c90fe5028bd094cd6ee25a7db321cb91d45dc87531e2bdbb26b4867a/tiktoken-0.12.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:584c3ad3d0c74f5269906eb8a659c8bfc6144a52895d9261cdaf90a0ae5f4de0", size = 1150736, upload-time = "2025-10-06T20:22:30.996Z" }, - { url = "https://files.pythonhosted.org/packages/28/c0/3c7a39ff68022ddfd7d93f3337ad90389a342f761c4d71de99a3ccc57857/tiktoken-0.12.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:54c891b416a0e36b8e2045b12b33dd66fb34a4fe7965565f1b482da50da3e86a", size = 1194908, upload-time = "2025-10-06T20:22:32.073Z" }, - { url = "https://files.pythonhosted.org/packages/ab/0d/c1ad6f4016a3968c048545f5d9b8ffebf577774b2ede3e2e352553b685fe/tiktoken-0.12.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5edb8743b88d5be814b1a8a8854494719080c28faaa1ccbef02e87354fe71ef0", size = 1253706, upload-time = "2025-10-06T20:22:33.385Z" }, - { url = "https://files.pythonhosted.org/packages/af/df/c7891ef9d2712ad774777271d39fdef63941ffba0a9d59b7ad1fd2765e57/tiktoken-0.12.0-cp314-cp314t-win_amd64.whl", hash = "sha256:f61c0aea5565ac82e2ec50a05e02a6c44734e91b51c10510b084ea1b8e633a71", size = 920667, upload-time = "2025-10-06T20:22:34.444Z" }, ] [[package]] @@ -4234,7 +3504,7 @@ name = "types-requests" version = "2.33.0.20260408" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "urllib3", marker = "sys_platform != 'emscripten'" }, + { name = "urllib3" }, ] sdist = { url = "https://files.pythonhosted.org/packages/69/6a/749dc53a54a3f35842c1f8197b3ca6b54af6d7458a1bfc75f6629b6da666/types_requests-2.33.0.20260408.tar.gz", hash = "sha256:95b9a86376807a216b2fb412b47617b202091c3ea7c078f47cc358d5528ccb7b", size = 23882, upload-time = "2026-04-08T04:34:49.33Z" } wheels = [ @@ -4343,18 +3613,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/15/c0/0be24758891ef825f2065cd5db8741aaddabe3e248ee6acc5e8a80f04005/uvloop-0.22.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0530a5fbad9c9e4ee3f2b33b148c6a64d47bbad8000ea63704fa8260f4cf728e", size = 4366890, upload-time = "2025-10-16T22:16:40.547Z" }, { url = "https://files.pythonhosted.org/packages/d2/53/8369e5219a5855869bcee5f4d317f6da0e2c669aecf0ef7d371e3d084449/uvloop-0.22.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bc5ef13bbc10b5335792360623cc378d52d7e62c2de64660616478c32cd0598e", size = 4119472, upload-time = "2025-10-16T22:16:41.694Z" }, { url = "https://files.pythonhosted.org/packages/f8/ba/d69adbe699b768f6b29a5eec7b47dd610bd17a69de51b251126a801369ea/uvloop-0.22.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1f38ec5e3f18c8a10ded09742f7fb8de0108796eb673f30ce7762ce1b8550cad", size = 4239051, upload-time = "2025-10-16T22:16:43.224Z" }, - { url = "https://files.pythonhosted.org/packages/90/cd/b62bdeaa429758aee8de8b00ac0dd26593a9de93d302bff3d21439e9791d/uvloop-0.22.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3879b88423ec7e97cd4eba2a443aa26ed4e59b45e6b76aabf13fe2f27023a142", size = 1362067, upload-time = "2025-10-16T22:16:44.503Z" }, - { url = "https://files.pythonhosted.org/packages/0d/f8/a132124dfda0777e489ca86732e85e69afcd1ff7686647000050ba670689/uvloop-0.22.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4baa86acedf1d62115c1dc6ad1e17134476688f08c6efd8a2ab076e815665c74", size = 752423, upload-time = "2025-10-16T22:16:45.968Z" }, - { url = "https://files.pythonhosted.org/packages/a3/94/94af78c156f88da4b3a733773ad5ba0b164393e357cc4bd0ab2e2677a7d6/uvloop-0.22.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:297c27d8003520596236bdb2335e6b3f649480bd09e00d1e3a99144b691d2a35", size = 4272437, upload-time = "2025-10-16T22:16:47.451Z" }, - { url = "https://files.pythonhosted.org/packages/b5/35/60249e9fd07b32c665192cec7af29e06c7cd96fa1d08b84f012a56a0b38e/uvloop-0.22.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c1955d5a1dd43198244d47664a5858082a3239766a839b2102a269aaff7a4e25", size = 4292101, upload-time = "2025-10-16T22:16:49.318Z" }, - { url = "https://files.pythonhosted.org/packages/02/62/67d382dfcb25d0a98ce73c11ed1a6fba5037a1a1d533dcbb7cab033a2636/uvloop-0.22.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b31dc2fccbd42adc73bc4e7cdbae4fc5086cf378979e53ca5d0301838c5682c6", size = 4114158, upload-time = "2025-10-16T22:16:50.517Z" }, - { url = "https://files.pythonhosted.org/packages/f0/7a/f1171b4a882a5d13c8b7576f348acfe6074d72eaf52cccef752f748d4a9f/uvloop-0.22.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:93f617675b2d03af4e72a5333ef89450dfaa5321303ede6e67ba9c9d26878079", size = 4177360, upload-time = "2025-10-16T22:16:52.646Z" }, - { url = "https://files.pythonhosted.org/packages/79/7b/b01414f31546caf0919da80ad57cbfe24c56b151d12af68cee1b04922ca8/uvloop-0.22.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:37554f70528f60cad66945b885eb01f1bb514f132d92b6eeed1c90fd54ed6289", size = 1454790, upload-time = "2025-10-16T22:16:54.355Z" }, - { url = "https://files.pythonhosted.org/packages/d4/31/0bb232318dd838cad3fa8fb0c68c8b40e1145b32025581975e18b11fab40/uvloop-0.22.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:b76324e2dc033a0b2f435f33eb88ff9913c156ef78e153fb210e03c13da746b3", size = 796783, upload-time = "2025-10-16T22:16:55.906Z" }, - { url = "https://files.pythonhosted.org/packages/42/38/c9b09f3271a7a723a5de69f8e237ab8e7803183131bc57c890db0b6bb872/uvloop-0.22.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:badb4d8e58ee08dad957002027830d5c3b06aea446a6a3744483c2b3b745345c", size = 4647548, upload-time = "2025-10-16T22:16:57.008Z" }, - { url = "https://files.pythonhosted.org/packages/c1/37/945b4ca0ac27e3dc4952642d4c900edd030b3da6c9634875af6e13ae80e5/uvloop-0.22.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b91328c72635f6f9e0282e4a57da7470c7350ab1c9f48546c0f2866205349d21", size = 4467065, upload-time = "2025-10-16T22:16:58.206Z" }, - { url = "https://files.pythonhosted.org/packages/97/cc/48d232f33d60e2e2e0b42f4e73455b146b76ebe216487e862700457fbf3c/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:daf620c2995d193449393d6c62131b3fbd40a63bf7b307a1527856ace637fe88", size = 4328384, upload-time = "2025-10-16T22:16:59.36Z" }, - { url = "https://files.pythonhosted.org/packages/e4/16/c1fd27e9549f3c4baf1dc9c20c456cd2f822dbf8de9f463824b0c0357e06/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6cde23eeda1a25c75b2e07d39970f3374105d5eafbaab2a4482be82f272d5a5e", size = 4296730, upload-time = "2025-10-16T22:17:00.744Z" }, ] [[package]] @@ -4417,29 +3675,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e1/f7/0a4467be0a56e80447c8529c9fce5b38eab4f513cb3d9bf82e7392a5696b/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3f7eb7da0eb23aa2ba036d4f616d46906013a68caf61b7fdbe42fc8b25132e77", size = 455425, upload-time = "2025-10-14T15:05:23.348Z" }, { url = "https://files.pythonhosted.org/packages/8e/e0/82583485ea00137ddf69bc84a2db88bd92ab4a6e3c405e5fb878ead8d0e7/watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:831a62658609f0e5c64178211c942ace999517f5770fe9436be4c2faeba0c0ef", size = 628826, upload-time = "2025-10-14T15:05:24.398Z" }, { url = "https://files.pythonhosted.org/packages/28/9a/a785356fccf9fae84c0cc90570f11702ae9571036fb25932f1242c82191c/watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:f9a2ae5c91cecc9edd47e041a930490c31c3afb1f5e6d71de3dc671bfaca02bf", size = 622208, upload-time = "2025-10-14T15:05:25.45Z" }, - { url = "https://files.pythonhosted.org/packages/c3/f4/0872229324ef69b2c3edec35e84bd57a1289e7d3fe74588048ed8947a323/watchfiles-1.1.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:d1715143123baeeaeadec0528bb7441103979a1d5f6fd0e1f915383fea7ea6d5", size = 404315, upload-time = "2025-10-14T15:05:26.501Z" }, - { url = "https://files.pythonhosted.org/packages/7b/22/16d5331eaed1cb107b873f6ae1b69e9ced582fcf0c59a50cd84f403b1c32/watchfiles-1.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:39574d6370c4579d7f5d0ad940ce5b20db0e4117444e39b6d8f99db5676c52fd", size = 390869, upload-time = "2025-10-14T15:05:27.649Z" }, - { url = "https://files.pythonhosted.org/packages/b2/7e/5643bfff5acb6539b18483128fdc0ef2cccc94a5b8fbda130c823e8ed636/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7365b92c2e69ee952902e8f70f3ba6360d0d596d9299d55d7d386df84b6941fb", size = 449919, upload-time = "2025-10-14T15:05:28.701Z" }, - { url = "https://files.pythonhosted.org/packages/51/2e/c410993ba5025a9f9357c376f48976ef0e1b1aefb73b97a5ae01a5972755/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bfff9740c69c0e4ed32416f013f3c45e2ae42ccedd1167ef2d805c000b6c71a5", size = 460845, upload-time = "2025-10-14T15:05:30.064Z" }, - { url = "https://files.pythonhosted.org/packages/8e/a4/2df3b404469122e8680f0fcd06079317e48db58a2da2950fb45020947734/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b27cf2eb1dda37b2089e3907d8ea92922b673c0c427886d4edc6b94d8dfe5db3", size = 489027, upload-time = "2025-10-14T15:05:31.064Z" }, - { url = "https://files.pythonhosted.org/packages/ea/84/4587ba5b1f267167ee715b7f66e6382cca6938e0a4b870adad93e44747e6/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:526e86aced14a65a5b0ec50827c745597c782ff46b571dbfe46192ab9e0b3c33", size = 595615, upload-time = "2025-10-14T15:05:32.074Z" }, - { url = "https://files.pythonhosted.org/packages/6a/0f/c6988c91d06e93cd0bb3d4a808bcf32375ca1904609835c3031799e3ecae/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:04e78dd0b6352db95507fd8cb46f39d185cf8c74e4cf1e4fbad1d3df96faf510", size = 474836, upload-time = "2025-10-14T15:05:33.209Z" }, - { url = "https://files.pythonhosted.org/packages/b4/36/ded8aebea91919485b7bbabbd14f5f359326cb5ec218cd67074d1e426d74/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5c85794a4cfa094714fb9c08d4a218375b2b95b8ed1666e8677c349906246c05", size = 455099, upload-time = "2025-10-14T15:05:34.189Z" }, - { url = "https://files.pythonhosted.org/packages/98/e0/8c9bdba88af756a2fce230dd365fab2baf927ba42cd47521ee7498fd5211/watchfiles-1.1.1-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:74d5012b7630714b66be7b7b7a78855ef7ad58e8650c73afc4c076a1f480a8d6", size = 630626, upload-time = "2025-10-14T15:05:35.216Z" }, - { url = "https://files.pythonhosted.org/packages/2a/84/a95db05354bf2d19e438520d92a8ca475e578c647f78f53197f5a2f17aaf/watchfiles-1.1.1-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:8fbe85cb3201c7d380d3d0b90e63d520f15d6afe217165d7f98c9c649654db81", size = 622519, upload-time = "2025-10-14T15:05:36.259Z" }, - { url = "https://files.pythonhosted.org/packages/1d/ce/d8acdc8de545de995c339be67711e474c77d643555a9bb74a9334252bd55/watchfiles-1.1.1-cp314-cp314-win32.whl", hash = "sha256:3fa0b59c92278b5a7800d3ee7733da9d096d4aabcfabb9a928918bd276ef9b9b", size = 272078, upload-time = "2025-10-14T15:05:37.63Z" }, - { url = "https://files.pythonhosted.org/packages/c4/c9/a74487f72d0451524be827e8edec251da0cc1fcf111646a511ae752e1a3d/watchfiles-1.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:c2047d0b6cea13b3316bdbafbfa0c4228ae593d995030fda39089d36e64fc03a", size = 287664, upload-time = "2025-10-14T15:05:38.95Z" }, - { url = "https://files.pythonhosted.org/packages/df/b8/8ac000702cdd496cdce998c6f4ee0ca1f15977bba51bdf07d872ebdfc34c/watchfiles-1.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:842178b126593addc05acf6fce960d28bc5fae7afbaa2c6c1b3a7b9460e5be02", size = 277154, upload-time = "2025-10-14T15:05:39.954Z" }, - { url = "https://files.pythonhosted.org/packages/47/a8/e3af2184707c29f0f14b1963c0aace6529f9d1b8582d5b99f31bbf42f59e/watchfiles-1.1.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:88863fbbc1a7312972f1c511f202eb30866370ebb8493aef2812b9ff28156a21", size = 403820, upload-time = "2025-10-14T15:05:40.932Z" }, - { url = "https://files.pythonhosted.org/packages/c0/ec/e47e307c2f4bd75f9f9e8afbe3876679b18e1bcec449beca132a1c5ffb2d/watchfiles-1.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:55c7475190662e202c08c6c0f4d9e345a29367438cf8e8037f3155e10a88d5a5", size = 390510, upload-time = "2025-10-14T15:05:41.945Z" }, - { url = "https://files.pythonhosted.org/packages/d5/a0/ad235642118090f66e7b2f18fd5c42082418404a79205cdfca50b6309c13/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3f53fa183d53a1d7a8852277c92b967ae99c2d4dcee2bfacff8868e6e30b15f7", size = 448408, upload-time = "2025-10-14T15:05:43.385Z" }, - { url = "https://files.pythonhosted.org/packages/df/85/97fa10fd5ff3332ae17e7e40e20784e419e28521549780869f1413742e9d/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6aae418a8b323732fa89721d86f39ec8f092fc2af67f4217a2b07fd3e93c6101", size = 458968, upload-time = "2025-10-14T15:05:44.404Z" }, - { url = "https://files.pythonhosted.org/packages/47/c2/9059c2e8966ea5ce678166617a7f75ecba6164375f3b288e50a40dc6d489/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f096076119da54a6080e8920cbdaac3dbee667eb91dcc5e5b78840b87415bd44", size = 488096, upload-time = "2025-10-14T15:05:45.398Z" }, - { url = "https://files.pythonhosted.org/packages/94/44/d90a9ec8ac309bc26db808a13e7bfc0e4e78b6fc051078a554e132e80160/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:00485f441d183717038ed2e887a7c868154f216877653121068107b227a2f64c", size = 596040, upload-time = "2025-10-14T15:05:46.502Z" }, - { url = "https://files.pythonhosted.org/packages/95/68/4e3479b20ca305cfc561db3ed207a8a1c745ee32bf24f2026a129d0ddb6e/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a55f3e9e493158d7bfdb60a1165035f1cf7d320914e7b7ea83fe22c6023b58fc", size = 473847, upload-time = "2025-10-14T15:05:47.484Z" }, - { url = "https://files.pythonhosted.org/packages/4f/55/2af26693fd15165c4ff7857e38330e1b61ab8c37d15dc79118cdba115b7a/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8c91ed27800188c2ae96d16e3149f199d62f86c7af5f5f4d2c61a3ed8cd3666c", size = 455072, upload-time = "2025-10-14T15:05:48.928Z" }, - { url = "https://files.pythonhosted.org/packages/66/1d/d0d200b10c9311ec25d2273f8aad8c3ef7cc7ea11808022501811208a750/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:311ff15a0bae3714ffb603e6ba6dbfba4065ab60865d15a6ec544133bdb21099", size = 629104, upload-time = "2025-10-14T15:05:49.908Z" }, - { url = "https://files.pythonhosted.org/packages/e3/bd/fa9bb053192491b3867ba07d2343d9f2252e00811567d30ae8d0f78136fe/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:a916a2932da8f8ab582f242c065f5c81bed3462849ca79ee357dd9551b0e9b01", size = 622112, upload-time = "2025-10-14T15:05:50.941Z" }, ] [[package]] @@ -4484,24 +3719,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9b/67/f926bac29882894669368dc73f4da900fcdf47955d0a0185d60103df5737/websockets-16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6e5a82b677f8f6f59e8dfc34ec06ca6b5b48bc4fcda346acd093694cc2c24d8f", size = 184920, upload-time = "2026-01-10T09:23:07.492Z" }, { url = "https://files.pythonhosted.org/packages/3c/a1/3d6ccdcd125b0a42a311bcd15a7f705d688f73b2a22d8cf1c0875d35d34a/websockets-16.0-cp313-cp313-win32.whl", hash = "sha256:abf050a199613f64c886ea10f38b47770a65154dc37181bfaff70c160f45315a", size = 178255, upload-time = "2026-01-10T09:23:09.245Z" }, { url = "https://files.pythonhosted.org/packages/6b/ae/90366304d7c2ce80f9b826096a9e9048b4bb760e44d3b873bb272cba696b/websockets-16.0-cp313-cp313-win_amd64.whl", hash = "sha256:3425ac5cf448801335d6fdc7ae1eb22072055417a96cc6b31b3861f455fbc156", size = 178689, upload-time = "2026-01-10T09:23:10.483Z" }, - { url = "https://files.pythonhosted.org/packages/f3/1d/e88022630271f5bd349ed82417136281931e558d628dd52c4d8621b4a0b2/websockets-16.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0", size = 177406, upload-time = "2026-01-10T09:23:12.178Z" }, - { url = "https://files.pythonhosted.org/packages/f2/78/e63be1bf0724eeb4616efb1ae1c9044f7c3953b7957799abb5915bffd38e/websockets-16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:daa3b6ff70a9241cf6c7fc9e949d41232d9d7d26fd3522b1ad2b4d62487e9904", size = 175085, upload-time = "2026-01-10T09:23:13.511Z" }, - { url = "https://files.pythonhosted.org/packages/bb/f4/d3c9220d818ee955ae390cf319a7c7a467beceb24f05ee7aaaa2414345ba/websockets-16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4", size = 175328, upload-time = "2026-01-10T09:23:14.727Z" }, - { url = "https://files.pythonhosted.org/packages/63/bc/d3e208028de777087e6fb2b122051a6ff7bbcca0d6df9d9c2bf1dd869ae9/websockets-16.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:781caf5e8eee67f663126490c2f96f40906594cb86b408a703630f95550a8c3e", size = 185044, upload-time = "2026-01-10T09:23:15.939Z" }, - { url = "https://files.pythonhosted.org/packages/ad/6e/9a0927ac24bd33a0a9af834d89e0abc7cfd8e13bed17a86407a66773cc0e/websockets-16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caab51a72c51973ca21fa8a18bd8165e1a0183f1ac7066a182ff27107b71e1a4", size = 186279, upload-time = "2026-01-10T09:23:17.148Z" }, - { url = "https://files.pythonhosted.org/packages/b9/ca/bf1c68440d7a868180e11be653c85959502efd3a709323230314fda6e0b3/websockets-16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19c4dc84098e523fd63711e563077d39e90ec6702aff4b5d9e344a60cb3c0cb1", size = 185711, upload-time = "2026-01-10T09:23:18.372Z" }, - { url = "https://files.pythonhosted.org/packages/c4/f8/fdc34643a989561f217bb477cbc47a3a07212cbda91c0e4389c43c296ebf/websockets-16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5e18a238a2b2249c9a9235466b90e96ae4795672598a58772dd806edc7ac6d3", size = 184982, upload-time = "2026-01-10T09:23:19.652Z" }, - { url = "https://files.pythonhosted.org/packages/dd/d1/574fa27e233764dbac9c52730d63fcf2823b16f0856b3329fc6268d6ae4f/websockets-16.0-cp314-cp314-win32.whl", hash = "sha256:a069d734c4a043182729edd3e9f247c3b2a4035415a9172fd0f1b71658a320a8", size = 177915, upload-time = "2026-01-10T09:23:21.458Z" }, - { url = "https://files.pythonhosted.org/packages/8a/f1/ae6b937bf3126b5134ce1f482365fde31a357c784ac51852978768b5eff4/websockets-16.0-cp314-cp314-win_amd64.whl", hash = "sha256:c0ee0e63f23914732c6d7e0cce24915c48f3f1512ec1d079ed01fc629dab269d", size = 178381, upload-time = "2026-01-10T09:23:22.715Z" }, - { url = "https://files.pythonhosted.org/packages/06/9b/f791d1db48403e1f0a27577a6beb37afae94254a8c6f08be4a23e4930bc0/websockets-16.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a35539cacc3febb22b8f4d4a99cc79b104226a756aa7400adc722e83b0d03244", size = 177737, upload-time = "2026-01-10T09:23:24.523Z" }, - { url = "https://files.pythonhosted.org/packages/bd/40/53ad02341fa33b3ce489023f635367a4ac98b73570102ad2cdd770dacc9a/websockets-16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b784ca5de850f4ce93ec85d3269d24d4c82f22b7212023c974c401d4980ebc5e", size = 175268, upload-time = "2026-01-10T09:23:25.781Z" }, - { url = "https://files.pythonhosted.org/packages/74/9b/6158d4e459b984f949dcbbb0c5d270154c7618e11c01029b9bbd1bb4c4f9/websockets-16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:569d01a4e7fba956c5ae4fc988f0d4e187900f5497ce46339c996dbf24f17641", size = 175486, upload-time = "2026-01-10T09:23:27.033Z" }, - { url = "https://files.pythonhosted.org/packages/e5/2d/7583b30208b639c8090206f95073646c2c9ffd66f44df967981a64f849ad/websockets-16.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50f23cdd8343b984957e4077839841146f67a3d31ab0d00e6b824e74c5b2f6e8", size = 185331, upload-time = "2026-01-10T09:23:28.259Z" }, - { url = "https://files.pythonhosted.org/packages/45/b0/cce3784eb519b7b5ad680d14b9673a31ab8dcb7aad8b64d81709d2430aa8/websockets-16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:152284a83a00c59b759697b7f9e9cddf4e3c7861dd0d964b472b70f78f89e80e", size = 186501, upload-time = "2026-01-10T09:23:29.449Z" }, - { url = "https://files.pythonhosted.org/packages/19/60/b8ebe4c7e89fb5f6cdf080623c9d92789a53636950f7abacfc33fe2b3135/websockets-16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc59589ab64b0022385f429b94697348a6a234e8ce22544e3681b2e9331b5944", size = 186062, upload-time = "2026-01-10T09:23:31.368Z" }, - { url = "https://files.pythonhosted.org/packages/88/a8/a080593f89b0138b6cba1b28f8df5673b5506f72879322288b031337c0b8/websockets-16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206", size = 185356, upload-time = "2026-01-10T09:23:32.627Z" }, - { url = "https://files.pythonhosted.org/packages/c2/b6/b9afed2afadddaf5ebb2afa801abf4b0868f42f8539bfe4b071b5266c9fe/websockets-16.0-cp314-cp314t-win32.whl", hash = "sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6", size = 178085, upload-time = "2026-01-10T09:23:33.816Z" }, - { url = "https://files.pythonhosted.org/packages/9f/3e/28135a24e384493fa804216b79a6a6759a38cc4ff59118787b9fb693df93/websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd", size = 178531, upload-time = "2026-01-10T09:23:35.016Z" }, { url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" }, ] @@ -4531,26 +3748,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e8/cf/7d848740203c7b4b27eb55dbfede11aca974a51c3d894f6cc4b865f42f58/wrapt-1.17.3-cp313-cp313-win32.whl", hash = "sha256:53e5e39ff71b3fc484df8a522c933ea2b7cdd0d5d15ae82e5b23fde87d44cbd8", size = 36711, upload-time = "2025-08-12T05:53:10.074Z" }, { url = "https://files.pythonhosted.org/packages/57/54/35a84d0a4d23ea675994104e667ceff49227ce473ba6a59ba2c84f250b74/wrapt-1.17.3-cp313-cp313-win_amd64.whl", hash = "sha256:1f0b2f40cf341ee8cc1a97d51ff50dddb9fcc73241b9143ec74b30fc4f44f6cb", size = 38885, upload-time = "2025-08-12T05:53:08.695Z" }, { url = "https://files.pythonhosted.org/packages/01/77/66e54407c59d7b02a3c4e0af3783168fff8e5d61def52cda8728439d86bc/wrapt-1.17.3-cp313-cp313-win_arm64.whl", hash = "sha256:7425ac3c54430f5fc5e7b6f41d41e704db073309acfc09305816bc6a0b26bb16", size = 36896, upload-time = "2025-08-12T05:52:55.34Z" }, - { url = "https://files.pythonhosted.org/packages/02/a2/cd864b2a14f20d14f4c496fab97802001560f9f41554eef6df201cd7f76c/wrapt-1.17.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:cf30f6e3c077c8e6a9a7809c94551203c8843e74ba0c960f4a98cd80d4665d39", size = 54132, upload-time = "2025-08-12T05:51:49.864Z" }, - { url = "https://files.pythonhosted.org/packages/d5/46/d011725b0c89e853dc44cceb738a307cde5d240d023d6d40a82d1b4e1182/wrapt-1.17.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:e228514a06843cae89621384cfe3a80418f3c04aadf8a3b14e46a7be704e4235", size = 39091, upload-time = "2025-08-12T05:51:38.935Z" }, - { url = "https://files.pythonhosted.org/packages/2e/9e/3ad852d77c35aae7ddebdbc3b6d35ec8013af7d7dddad0ad911f3d891dae/wrapt-1.17.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:5ea5eb3c0c071862997d6f3e02af1d055f381b1d25b286b9d6644b79db77657c", size = 39172, upload-time = "2025-08-12T05:51:59.365Z" }, - { url = "https://files.pythonhosted.org/packages/c3/f7/c983d2762bcce2326c317c26a6a1e7016f7eb039c27cdf5c4e30f4160f31/wrapt-1.17.3-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:281262213373b6d5e4bb4353bc36d1ba4084e6d6b5d242863721ef2bf2c2930b", size = 87163, upload-time = "2025-08-12T05:52:40.965Z" }, - { url = "https://files.pythonhosted.org/packages/e4/0f/f673f75d489c7f22d17fe0193e84b41540d962f75fce579cf6873167c29b/wrapt-1.17.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dc4a8d2b25efb6681ecacad42fca8859f88092d8732b170de6a5dddd80a1c8fa", size = 87963, upload-time = "2025-08-12T05:52:20.326Z" }, - { url = "https://files.pythonhosted.org/packages/df/61/515ad6caca68995da2fac7a6af97faab8f78ebe3bf4f761e1b77efbc47b5/wrapt-1.17.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:373342dd05b1d07d752cecbec0c41817231f29f3a89aa8b8843f7b95992ed0c7", size = 86945, upload-time = "2025-08-12T05:52:21.581Z" }, - { url = "https://files.pythonhosted.org/packages/d3/bd/4e70162ce398462a467bc09e768bee112f1412e563620adc353de9055d33/wrapt-1.17.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d40770d7c0fd5cbed9d84b2c3f2e156431a12c9a37dc6284060fb4bec0b7ffd4", size = 86857, upload-time = "2025-08-12T05:52:43.043Z" }, - { url = "https://files.pythonhosted.org/packages/2b/b8/da8560695e9284810b8d3df8a19396a6e40e7518059584a1a394a2b35e0a/wrapt-1.17.3-cp314-cp314-win32.whl", hash = "sha256:fbd3c8319de8e1dc79d346929cd71d523622da527cca14e0c1d257e31c2b8b10", size = 37178, upload-time = "2025-08-12T05:53:12.605Z" }, - { url = "https://files.pythonhosted.org/packages/db/c8/b71eeb192c440d67a5a0449aaee2310a1a1e8eca41676046f99ed2487e9f/wrapt-1.17.3-cp314-cp314-win_amd64.whl", hash = "sha256:e1a4120ae5705f673727d3253de3ed0e016f7cd78dc463db1b31e2463e1f3cf6", size = 39310, upload-time = "2025-08-12T05:53:11.106Z" }, - { url = "https://files.pythonhosted.org/packages/45/20/2cda20fd4865fa40f86f6c46ed37a2a8356a7a2fde0773269311f2af56c7/wrapt-1.17.3-cp314-cp314-win_arm64.whl", hash = "sha256:507553480670cab08a800b9463bdb881b2edeed77dc677b0a5915e6106e91a58", size = 37266, upload-time = "2025-08-12T05:52:56.531Z" }, - { url = "https://files.pythonhosted.org/packages/77/ed/dd5cf21aec36c80443c6f900449260b80e2a65cf963668eaef3b9accce36/wrapt-1.17.3-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:ed7c635ae45cfbc1a7371f708727bf74690daedc49b4dba310590ca0bd28aa8a", size = 56544, upload-time = "2025-08-12T05:51:51.109Z" }, - { url = "https://files.pythonhosted.org/packages/8d/96/450c651cc753877ad100c7949ab4d2e2ecc4d97157e00fa8f45df682456a/wrapt-1.17.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:249f88ed15503f6492a71f01442abddd73856a0032ae860de6d75ca62eed8067", size = 40283, upload-time = "2025-08-12T05:51:39.912Z" }, - { url = "https://files.pythonhosted.org/packages/d1/86/2fcad95994d9b572db57632acb6f900695a648c3e063f2cd344b3f5c5a37/wrapt-1.17.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a03a38adec8066d5a37bea22f2ba6bbf39fcdefbe2d91419ab864c3fb515454", size = 40366, upload-time = "2025-08-12T05:52:00.693Z" }, - { url = "https://files.pythonhosted.org/packages/64/0e/f4472f2fdde2d4617975144311f8800ef73677a159be7fe61fa50997d6c0/wrapt-1.17.3-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:5d4478d72eb61c36e5b446e375bbc49ed002430d17cdec3cecb36993398e1a9e", size = 108571, upload-time = "2025-08-12T05:52:44.521Z" }, - { url = "https://files.pythonhosted.org/packages/cc/01/9b85a99996b0a97c8a17484684f206cbb6ba73c1ce6890ac668bcf3838fb/wrapt-1.17.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:223db574bb38637e8230eb14b185565023ab624474df94d2af18f1cdb625216f", size = 113094, upload-time = "2025-08-12T05:52:22.618Z" }, - { url = "https://files.pythonhosted.org/packages/25/02/78926c1efddcc7b3aa0bc3d6b33a822f7d898059f7cd9ace8c8318e559ef/wrapt-1.17.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e405adefb53a435f01efa7ccdec012c016b5a1d3f35459990afc39b6be4d5056", size = 110659, upload-time = "2025-08-12T05:52:24.057Z" }, - { url = "https://files.pythonhosted.org/packages/dc/ee/c414501ad518ac3e6fe184753632fe5e5ecacdcf0effc23f31c1e4f7bfcf/wrapt-1.17.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:88547535b787a6c9ce4086917b6e1d291aa8ed914fdd3a838b3539dc95c12804", size = 106946, upload-time = "2025-08-12T05:52:45.976Z" }, - { url = "https://files.pythonhosted.org/packages/be/44/a1bd64b723d13bb151d6cc91b986146a1952385e0392a78567e12149c7b4/wrapt-1.17.3-cp314-cp314t-win32.whl", hash = "sha256:41b1d2bc74c2cac6f9074df52b2efbef2b30bdfe5f40cb78f8ca22963bc62977", size = 38717, upload-time = "2025-08-12T05:53:15.214Z" }, - { url = "https://files.pythonhosted.org/packages/79/d9/7cfd5a312760ac4dd8bf0184a6ee9e43c33e47f3dadc303032ce012b8fa3/wrapt-1.17.3-cp314-cp314t-win_amd64.whl", hash = "sha256:73d496de46cd2cdbdbcce4ae4bcdb4afb6a11234a1df9c085249d55166b95116", size = 41334, upload-time = "2025-08-12T05:53:14.178Z" }, - { url = "https://files.pythonhosted.org/packages/46/78/10ad9781128ed2f99dbc474f43283b13fea8ba58723e98844367531c18e9/wrapt-1.17.3-cp314-cp314t-win_arm64.whl", hash = "sha256:f38e60678850c42461d4202739f9bf1e3a737c7ad283638251e79cc49effb6b6", size = 38471, upload-time = "2025-08-12T05:52:57.784Z" }, { url = "https://files.pythonhosted.org/packages/1f/f6/a933bd70f98e9cf3e08167fc5cd7aaaca49147e48411c0bd5ae701bb2194/wrapt-1.17.3-py3-none-any.whl", hash = "sha256:7171ae35d2c33d326ac19dd8facb1e82e5fd04ef8c6c0e394d7af55a55051c22", size = 23591, upload-time = "2025-08-12T05:53:20.674Z" }, ] @@ -4638,42 +3835,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/80/25/a3892b46182c586c202629fc2159aa13975d3741d52ebd7347fd501d48d5/yarl-1.23.0-cp313-cp313t-win32.whl", hash = "sha256:93a784271881035ab4406a172edb0faecb6e7d00f4b53dc2f55919d6c9688595", size = 88313, upload-time = "2026-03-01T22:06:37.39Z" }, { url = "https://files.pythonhosted.org/packages/43/68/8c5b36aa5178900b37387937bc2c2fe0e9505537f713495472dcf6f6fccc/yarl-1.23.0-cp313-cp313t-win_amd64.whl", hash = "sha256:dd00607bffbf30250fe108065f07453ec124dbf223420f57f5e749b04295e090", size = 94932, upload-time = "2026-03-01T22:06:39.579Z" }, { url = "https://files.pythonhosted.org/packages/c6/cc/d79ba8292f51f81f4dc533a8ccfb9fc6992cabf0998ed3245de7589dc07c/yarl-1.23.0-cp313-cp313t-win_arm64.whl", hash = "sha256:ac09d42f48f80c9ee1635b2fcaa819496a44502737660d3c0f2ade7526d29144", size = 84786, upload-time = "2026-03-01T22:06:41.988Z" }, - { url = "https://files.pythonhosted.org/packages/90/98/b85a038d65d1b92c3903ab89444f48d3cee490a883477b716d7a24b1a78c/yarl-1.23.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:21d1b7305a71a15b4794b5ff22e8eef96ff4a6d7f9657155e5aa419444b28912", size = 124455, upload-time = "2026-03-01T22:06:43.615Z" }, - { url = "https://files.pythonhosted.org/packages/39/54/bc2b45559f86543d163b6e294417a107bb87557609007c007ad889afec18/yarl-1.23.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:85610b4f27f69984932a7abbe52703688de3724d9f72bceb1cca667deff27474", size = 86752, upload-time = "2026-03-01T22:06:45.425Z" }, - { url = "https://files.pythonhosted.org/packages/24/f9/e8242b68362bffe6fb536c8db5076861466fc780f0f1b479fc4ffbebb128/yarl-1.23.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23f371bd662cf44a7630d4d113101eafc0cfa7518a2760d20760b26021454719", size = 86291, upload-time = "2026-03-01T22:06:46.974Z" }, - { url = "https://files.pythonhosted.org/packages/ea/d8/d1cb2378c81dd729e98c716582b1ccb08357e8488e4c24714658cc6630e8/yarl-1.23.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c4a80f77dc1acaaa61f0934176fccca7096d9b1ff08c8ba9cddf5ae034a24319", size = 99026, upload-time = "2026-03-01T22:06:48.459Z" }, - { url = "https://files.pythonhosted.org/packages/0a/ff/7196790538f31debe3341283b5b0707e7feb947620fc5e8236ef28d44f72/yarl-1.23.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:bd654fad46d8d9e823afbb4f87c79160b5a374ed1ff5bde24e542e6ba8f41434", size = 92355, upload-time = "2026-03-01T22:06:50.306Z" }, - { url = "https://files.pythonhosted.org/packages/c1/56/25d58c3eddde825890a5fe6aa1866228377354a3c39262235234ab5f616b/yarl-1.23.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:682bae25f0a0dd23a056739f23a134db9f52a63e2afd6bfb37ddc76292bbd723", size = 106417, upload-time = "2026-03-01T22:06:52.1Z" }, - { url = "https://files.pythonhosted.org/packages/51/8a/882c0e7bc8277eb895b31bce0138f51a1ba551fc2e1ec6753ffc1e7c1377/yarl-1.23.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a82836cab5f197a0514235aaf7ffccdc886ccdaa2324bc0aafdd4ae898103039", size = 106422, upload-time = "2026-03-01T22:06:54.424Z" }, - { url = "https://files.pythonhosted.org/packages/42/2b/fef67d616931055bf3d6764885990a3ac647d68734a2d6a9e1d13de437a2/yarl-1.23.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c57676bdedc94cd3bc37724cf6f8cd2779f02f6aba48de45feca073e714fe52", size = 101915, upload-time = "2026-03-01T22:06:55.895Z" }, - { url = "https://files.pythonhosted.org/packages/18/6a/530e16aebce27c5937920f3431c628a29a4b6b430fab3fd1c117b26ff3f6/yarl-1.23.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c7f8dc16c498ff06497c015642333219871effba93e4a2e8604a06264aca5c5c", size = 100690, upload-time = "2026-03-01T22:06:58.21Z" }, - { url = "https://files.pythonhosted.org/packages/88/08/93749219179a45e27b036e03260fda05190b911de8e18225c294ac95bbc9/yarl-1.23.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:5ee586fb17ff8f90c91cf73c6108a434b02d69925f44f5f8e0d7f2f260607eae", size = 98750, upload-time = "2026-03-01T22:06:59.794Z" }, - { url = "https://files.pythonhosted.org/packages/d9/cf/ea424a004969f5d81a362110a6ac1496d79efdc6d50c2c4b2e3ea0fc2519/yarl-1.23.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:17235362f580149742739cc3828b80e24029d08cbb9c4bda0242c7b5bc610a8e", size = 94685, upload-time = "2026-03-01T22:07:01.375Z" }, - { url = "https://files.pythonhosted.org/packages/e2/b7/14341481fe568e2b0408bcf1484c652accafe06a0ade9387b5d3fd9df446/yarl-1.23.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:0793e2bd0cf14234983bbb371591e6bea9e876ddf6896cdcc93450996b0b5c85", size = 106009, upload-time = "2026-03-01T22:07:03.151Z" }, - { url = "https://files.pythonhosted.org/packages/0a/e6/5c744a9b54f4e8007ad35bce96fbc9218338e84812d36f3390cea616881a/yarl-1.23.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:3650dc2480f94f7116c364096bc84b1d602f44224ef7d5c7208425915c0475dd", size = 100033, upload-time = "2026-03-01T22:07:04.701Z" }, - { url = "https://files.pythonhosted.org/packages/0c/23/e3bfc188d0b400f025bc49d99793d02c9abe15752138dcc27e4eaf0c4a9e/yarl-1.23.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:f40e782d49630ad384db66d4d8b73ff4f1b8955dc12e26b09a3e3af064b3b9d6", size = 106483, upload-time = "2026-03-01T22:07:06.231Z" }, - { url = "https://files.pythonhosted.org/packages/72/42/f0505f949a90b3f8b7a363d6cbdf398f6e6c58946d85c6d3a3bc70595b26/yarl-1.23.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:94f8575fbdf81749008d980c17796097e645574a3b8c28ee313931068dad14fe", size = 102175, upload-time = "2026-03-01T22:07:08.4Z" }, - { url = "https://files.pythonhosted.org/packages/aa/65/b39290f1d892a9dd671d1c722014ca062a9c35d60885d57e5375db0404b5/yarl-1.23.0-cp314-cp314-win32.whl", hash = "sha256:c8aa34a5c864db1087d911a0b902d60d203ea3607d91f615acd3f3108ac32169", size = 83871, upload-time = "2026-03-01T22:07:09.968Z" }, - { url = "https://files.pythonhosted.org/packages/a9/5b/9b92f54c784c26e2a422e55a8d2607ab15b7ea3349e28359282f84f01d43/yarl-1.23.0-cp314-cp314-win_amd64.whl", hash = "sha256:63e92247f383c85ab00dd0091e8c3fa331a96e865459f5ee80353c70a4a42d70", size = 89093, upload-time = "2026-03-01T22:07:11.501Z" }, - { url = "https://files.pythonhosted.org/packages/e0/7d/8a84dc9381fd4412d5e7ff04926f9865f6372b4c2fd91e10092e65d29eb8/yarl-1.23.0-cp314-cp314-win_arm64.whl", hash = "sha256:70efd20be968c76ece7baa8dafe04c5be06abc57f754d6f36f3741f7aa7a208e", size = 83384, upload-time = "2026-03-01T22:07:13.069Z" }, - { url = "https://files.pythonhosted.org/packages/dd/8d/d2fad34b1c08aa161b74394183daa7d800141aaaee207317e82c790b418d/yarl-1.23.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:9a18d6f9359e45722c064c97464ec883eb0e0366d33eda61cb19a244bf222679", size = 131019, upload-time = "2026-03-01T22:07:14.903Z" }, - { url = "https://files.pythonhosted.org/packages/19/ff/33009a39d3ccf4b94d7d7880dfe17fb5816c5a4fe0096d9b56abceea9ac7/yarl-1.23.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:2803ed8b21ca47a43da80a6fd1ed3019d30061f7061daa35ac54f63933409412", size = 89894, upload-time = "2026-03-01T22:07:17.372Z" }, - { url = "https://files.pythonhosted.org/packages/0c/f1/dab7ac5e7306fb79c0190766a3c00b4cb8d09a1f390ded68c85a5934faf5/yarl-1.23.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:394906945aa8b19fc14a61cf69743a868bb8c465efe85eee687109cc540b98f4", size = 89979, upload-time = "2026-03-01T22:07:19.361Z" }, - { url = "https://files.pythonhosted.org/packages/aa/b1/08e95f3caee1fad6e65017b9f26c1d79877b502622d60e517de01e72f95d/yarl-1.23.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:71d006bee8397a4a89f469b8deb22469fe7508132d3c17fa6ed871e79832691c", size = 95943, upload-time = "2026-03-01T22:07:21.266Z" }, - { url = "https://files.pythonhosted.org/packages/c0/cc/6409f9018864a6aa186c61175b977131f373f1988e198e031236916e87e4/yarl-1.23.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:62694e275c93d54f7ccedcfef57d42761b2aad5234b6be1f3e3026cae4001cd4", size = 88786, upload-time = "2026-03-01T22:07:23.129Z" }, - { url = "https://files.pythonhosted.org/packages/76/40/cc22d1d7714b717fde2006fad2ced5efe5580606cb059ae42117542122f3/yarl-1.23.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a31de1613658308efdb21ada98cbc86a97c181aa050ba22a808120bb5be3ab94", size = 101307, upload-time = "2026-03-01T22:07:24.689Z" }, - { url = "https://files.pythonhosted.org/packages/8f/0d/476c38e85ddb4c6ec6b20b815bdd779aa386a013f3d8b85516feee55c8dc/yarl-1.23.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fb1e8b8d66c278b21d13b0a7ca22c41dd757a7c209c6b12c313e445c31dd3b28", size = 100904, upload-time = "2026-03-01T22:07:26.287Z" }, - { url = "https://files.pythonhosted.org/packages/72/32/0abe4a76d59adf2081dcb0397168553ece4616ada1c54d1c49d8936c74f8/yarl-1.23.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:50f9d8d531dfb767c565f348f33dd5139a6c43f5cbdf3f67da40d54241df93f6", size = 97728, upload-time = "2026-03-01T22:07:27.906Z" }, - { url = "https://files.pythonhosted.org/packages/b7/35/7b30f4810fba112f60f5a43237545867504e15b1c7647a785fbaf588fac2/yarl-1.23.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:575aa4405a656e61a540f4a80eaa5260f2a38fff7bfdc4b5f611840d76e9e277", size = 95964, upload-time = "2026-03-01T22:07:30.198Z" }, - { url = "https://files.pythonhosted.org/packages/2d/86/ed7a73ab85ef00e8bb70b0cb5421d8a2a625b81a333941a469a6f4022828/yarl-1.23.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:041b1a4cefacf65840b4e295c6985f334ba83c30607441ae3cf206a0eed1a2e4", size = 95882, upload-time = "2026-03-01T22:07:32.132Z" }, - { url = "https://files.pythonhosted.org/packages/19/90/d56967f61a29d8498efb7afb651e0b2b422a1e9b47b0ab5f4e40a19b699b/yarl-1.23.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:d38c1e8231722c4ce40d7593f28d92b5fc72f3e9774fe73d7e800ec32299f63a", size = 90797, upload-time = "2026-03-01T22:07:34.404Z" }, - { url = "https://files.pythonhosted.org/packages/72/00/8b8f76909259f56647adb1011d7ed8b321bcf97e464515c65016a47ecdf0/yarl-1.23.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:d53834e23c015ee83a99377db6e5e37d8484f333edb03bd15b4bc312cc7254fb", size = 101023, upload-time = "2026-03-01T22:07:35.953Z" }, - { url = "https://files.pythonhosted.org/packages/ac/e2/cab11b126fb7d440281b7df8e9ddbe4851e70a4dde47a202b6642586b8d9/yarl-1.23.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:2e27c8841126e017dd2a054a95771569e6070b9ee1b133366d8b31beb5018a41", size = 96227, upload-time = "2026-03-01T22:07:37.594Z" }, - { url = "https://files.pythonhosted.org/packages/c2/9b/2c893e16bfc50e6b2edf76c1a9eb6cb0c744346197e74c65e99ad8d634d0/yarl-1.23.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:76855800ac56f878847a09ce6dba727c93ca2d89c9e9d63002d26b916810b0a2", size = 100302, upload-time = "2026-03-01T22:07:39.334Z" }, - { url = "https://files.pythonhosted.org/packages/28/ec/5498c4e3a6d5f1003beb23405671c2eb9cdbf3067d1c80f15eeafe301010/yarl-1.23.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e09fd068c2e169a7070d83d3bde728a4d48de0549f975290be3c108c02e499b4", size = 98202, upload-time = "2026-03-01T22:07:41.717Z" }, - { url = "https://files.pythonhosted.org/packages/fe/c3/cd737e2d45e70717907f83e146f6949f20cc23cd4bf7b2688727763aa458/yarl-1.23.0-cp314-cp314t-win32.whl", hash = "sha256:73309162a6a571d4cbd3b6a1dcc703c7311843ae0d1578df6f09be4e98df38d4", size = 90558, upload-time = "2026-03-01T22:07:43.433Z" }, - { url = "https://files.pythonhosted.org/packages/e1/19/3774d162f6732d1cfb0b47b4140a942a35ca82bb19b6db1f80e9e7bdc8f8/yarl-1.23.0-cp314-cp314t-win_amd64.whl", hash = "sha256:4503053d296bc6e4cbd1fad61cf3b6e33b939886c4f249ba7c78b602214fabe2", size = 97610, upload-time = "2026-03-01T22:07:45.773Z" }, - { url = "https://files.pythonhosted.org/packages/51/47/3fa2286c3cb162c71cdb34c4224d5745a1ceceb391b2bd9b19b668a8d724/yarl-1.23.0-cp314-cp314t-win_arm64.whl", hash = "sha256:44bb7bef4ea409384e3f8bc36c063d77ea1b8d4a5b2706956c0d6695f07dcc25", size = 86041, upload-time = "2026-03-01T22:07:49.026Z" }, { url = "https://files.pythonhosted.org/packages/69/68/c8739671f5699c7dc470580a4f821ef37c32c4cb0b047ce223a7f115757f/yarl-1.23.0-py3-none-any.whl", hash = "sha256:a2df6afe50dea8ae15fa34c9f824a3ee958d785fd5d089063d960bae1daa0a3f", size = 48288, upload-time = "2026-03-01T22:07:51.388Z" }, ] @@ -4687,8 +3848,7 @@ dependencies = [ { name = "lxml" }, { name = "multitasking" }, { name = "numpy" }, - { name = "pandas", version = "2.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.14'" }, - { name = "pandas", version = "3.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.14'" }, + { name = "pandas" }, { name = "peewee" }, { name = "platformdirs" }, { name = "protobuf" }, From 2bd9b81a225f36bb22dfa0c370e46c7d8f3a6b5c Mon Sep 17 00:00:00 2001 From: david-hummingbot Date: Fri, 18 Sep 2026 11:23:22 +0800 Subject: [PATCH 02/43] Manage line endings, because some of them are hashed There was no .gitattributes, so line endings were whatever each host's git config produced. That is not cosmetic here: content_digest() hashes a stock agent file's raw bytes to stamp a fork with the revision it diverged from, so the same file yields a different digest on a CRLF checkout than on an LF one (measured: sha256:6d9b716cc24c against sha256:ee60f3851972). It also made an untouched file able to block an update. With core.autocrlf applied at clone time but not persisted into the clone's config -- which some GUI clients and a later global-config change both produce -- tracked files show as modified with no user edit, and if upstream also touched one, repo_blocks() raises dirty-conflict on a file nobody edited. (The persisted case is clean; I checked rather than assumed.) *.sh and Makefile get eol=lf because a CRLF copy does not merely hash differently, it fails to execute. This lands before the digest change that depends on it. --- .gitattributes | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 .gitattributes diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 000000000..6495adffc --- /dev/null +++ b/.gitattributes @@ -0,0 +1,29 @@ +# Line endings are part of a file's bytes, and Condor digests some of those +# bytes: `content_digest` in condor/layering.py stamps a forked agent file with +# a hash of the stock file it came from, and the staleness check compares that +# stamp against the stock file later. Leaving endings to each host's git config +# makes that hash platform-dependent. +# +# `text=auto` normalizes to LF in the repository. The explicit `eol=lf` entries +# are the files whose bytes are either hashed or executed, where a CRLF working +# tree would be actively wrong rather than merely inconsistent. +* text=auto + +# Agent content: hashed by content_digest, so the working tree must match what +# was committed byte for byte on every platform. +*.md text eol=lf +*.yml text eol=lf +*.yaml text eol=lf + +# Executed by a POSIX shell; a CRLF copy fails with a bad-interpreter error. +*.sh text eol=lf +Makefile text eol=lf + +# Binary, so git must not touch them. +*.png binary +*.jpg binary +*.jpeg binary +*.gif binary +*.ico binary +*.woff binary +*.woff2 binary From acfa2ac9370ee193aaf157b38afb26c0ccaa1ca2 Mon Sep 17 00:00:00 2001 From: david-hummingbot Date: Fri, 18 Sep 2026 11:23:53 +0800 Subject: [PATCH 03/43] Digest agent content over normalized newlines content_digest() hashed raw bytes, which made a fork's stamp mean different things on different platforms: the same file digests to sha256:6d9b716cc24c on an LF checkout and sha256:ee60f3851972 on a CRLF one. A forked_from written on one host therefore compares as *changed* on another, and the staleness check added later in this branch would report every fork as stale for a reason that has nothing to do with upstream -- a warning that cries wolf about files carrying trading rules. The preceding .gitattributes keeps the tracked tree LF, which fixes the common case. This fixes the comparison itself, so it also holds where git's config does not reach: a .condor/ local root carried between machines, or a stamp written before the attributes landed. Legacy stamps are unaffected on an LF checkout, where the normalized and raw digests are identical. The one case that can differ -- a stamp written from a CRLF working tree -- is handled where the comparison happens, in the staleness check. --- condor/layering.py | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/condor/layering.py b/condor/layering.py index 24e54b291..dc5aa7819 100644 --- a/condor/layering.py +++ b/condor/layering.py @@ -50,13 +50,23 @@ def content_digest(path: Path) -> str: - """``sha256:<12>`` of a file's bytes — short enough to read in a log line. + """``sha256:<12>`` of a file's content, with line endings normalized. Twelve hex characters is the same order of collision resistance a git short hash gives, against a corpus of a few hundred markdown files. It identifies *which* upstream revision was forked; it is not a security claim. + + Newlines are normalized to LF before hashing, because the stamp has to mean + the same thing on every platform. Hashing raw bytes made the digest depend + on the checkout's line endings — the same file gave sha256:6d9b716cc24c on + an LF checkout and sha256:ee60f3851972 on a CRLF one — so a ``forked_from`` + written on one host compared as *changed* on another, and every fork looked + stale for a reason that had nothing to do with upstream. ``.gitattributes`` + now keeps the tracked tree LF; this makes the comparison correct even where + it does not, such as a local root carried between machines. """ - digest = hashlib.sha256(path.read_bytes()).hexdigest() + raw = path.read_bytes().replace(b"\r\n", b"\n").replace(b"\r", b"\n") + digest = hashlib.sha256(raw).hexdigest() return f"sha256:{digest[:12]}" From 198e27bc4007619f07629e3ac5884e41ea65359d Mon Sep 17 00:00:00 2001 From: david-hummingbot Date: Fri, 18 Sep 2026 11:25:11 +0800 Subject: [PATCH 04/43] Preserve the fork stamp on agent and strategy saves fork_path() stamps a forked file with forked_from -- the digest of the stock file it came from -- and that stamp is the only record of what a local customization diverged from. The three stores render their frontmatter through carry_fork_stamp() and keep it. The two web routes did not. They write a body the browser round-tripped from a GET, and that GET had read the *stock* file, which carries no stamp. So fork_if_stock() wrote the stamp and the very next line erased it: after fork_if_stock: forked_from: sha256:ce2f8b03fc63 after the route's write: NO STAMP Editing an agent through the dashboard is the primary way anyone edits these files, so in practice the stamp almost never survived its first save -- which leaves the staleness check nothing to compare and is why this lands first. The dance goes in layering.write_preserving_stamp() rather than inline at both call sites, so a third write path cannot reintroduce the bug by forgetting it. --- condor/layering.py | 23 ++++++++++++++++++ condor/web/routes/agents.py | 14 +++++++---- tests/test_agents_layering.py | 45 +++++++++++++++++++++++++++++++++++ 3 files changed, 77 insertions(+), 5 deletions(-) diff --git a/condor/layering.py b/condor/layering.py index dc5aa7819..e11b8e5c9 100644 --- a/condor/layering.py +++ b/condor/layering.py @@ -235,6 +235,29 @@ def carry_fork_stamp(path: Path, meta: dict) -> dict: return meta +def write_preserving_stamp(path: Path, content: str) -> None: + """Write ``content`` to ``path`` without destroying its fork stamp. + + The three *stores* already do this by hand, rendering their frontmatter + through :func:`carry_fork_stamp`. The web routes did not: they took a body + the browser had round-tripped and wrote it straight down, and because the + GET that produced it had read the **stock** file — which carries no stamp — + the fork's stamp was written by :func:`fork_path` and erased by the very + next line. Editing an agent through the dashboard is the primary way anyone + edits these files, so in practice the stamp almost never survived. + + Factored out rather than inlined at the two call sites so a third write path + cannot reintroduce the same bug by forgetting the dance. + """ + if path.suffix != ".md": + atomic_write_text(path, content) + return + meta, body = parse_frontmatter(content) + atomic_write_text( + path, render_frontmatter(carry_fork_stamp(path, dict(meta or {})), body) + ) + + def clear_fork_stamp(path: Path) -> None: """Drop ``forked_from``/``forked_at`` — used when publishing back into stock. diff --git a/condor/web/routes/agents.py b/condor/web/routes/agents.py index 02a19eeb5..885be21cd 100644 --- a/condor/web/routes/agents.py +++ b/condor/web/routes/agents.py @@ -55,7 +55,7 @@ ) from condor.agents.strategy import LOOP_MD, LOOPS_DIRNAME from condor.fsutil import atomic_write_text -from condor.layering import fork_if_stock +from condor.layering import fork_if_stock, write_preserving_stamp from condor.web.auth import ( check_server_access, get_current_user, @@ -2296,8 +2296,11 @@ async def update_agent_md( ) # Straight past ``AgentStore``, so the stock guard is stated here rather # than inherited: a shipped AGENT.md is forked into the local root first and - # this writes the fork (FEAT-115). - atomic_write_text(fork_if_stock(agent.slug, "AGENT.md"), req.content) + # this writes the fork (FEAT-115). The write has to go through + # ``write_preserving_stamp`` -- ``req.content`` came from a GET that read the + # stock file, which carries no stamp, so a plain write erases the one the + # fork just gained. + write_preserving_stamp(fork_if_stock(agent.slug, "AGENT.md"), req.content) return {"updated": True} @@ -2848,9 +2851,10 @@ async def update_strategy_md( new_defaults.get("server_name") if isinstance(new_defaults, dict) else None, (strategy.default_config or {}).get("server_name") or "", ) - # Same as ``update_agent_md``: past ``StrategyStore``, so the fork is here. + # Same as ``update_agent_md``: past ``StrategyStore``, so the fork is here, + # and the stamp has to be carried across the write for the same reason. target = fork_if_stock(slug, LOOPS_DIRNAME, strategy.slug, LOOP_MD) - atomic_write_text(target, req.content) + write_preserving_stamp(target, req.content) return {"updated": True} diff --git a/tests/test_agents_layering.py b/tests/test_agents_layering.py index 80e805736..aab9ee6da 100644 --- a/tests/test_agents_layering.py +++ b/tests/test_agents_layering.py @@ -841,3 +841,48 @@ def test_safe_slug_accepts_every_slug_slugify_produces(): safe_slug(bad) with pytest.raises(UnsafeIdError): resolve_agent_file("scout", "skills", "../../other", "SKILL.md") + + +# ── 4. The fork stamp survives the write that follows the fork ── + + +def test_a_dashboard_style_write_keeps_the_fork_stamp(stock): + """The web routes' write must not erase the stamp ``fork_path`` just wrote. + + ``fork_if_stock`` stamps the copy with the digest of the stock file it came + from, and the staleness check has nothing to compare without it. The routes + write a body the browser round-tripped from a GET of the **stock** file, + which carries no stamp — so a plain ``atomic_write_text`` erased it on the + very first save, and editing through the dashboard is how these files are + normally edited. + """ + from condor.frontmatter import parse_frontmatter + from condor.layering import fork_if_stock, write_preserving_stamp + + target = fork_if_stock("scout", "AGENT.md") + stamped, _ = parse_frontmatter(target.read_text("utf-8")) + assert stamped is not None and FORKED_FROM_KEY in stamped + original = stamped[FORKED_FROM_KEY] + + # Exactly what the route hands down: stock's body, with no stamp on it. + write_preserving_stamp( + target, AGENT_MD.format(name="Scout", desc="shipped", body="My edit.") + ) + + after, body = parse_frontmatter(target.read_text("utf-8")) + assert after is not None, "the write destroyed the frontmatter" + assert after[FORKED_FROM_KEY] == original, "the write destroyed the stamp" + assert "My edit." in body + # And the shipped file is still the shipped file. + assert "Ship." in (stock / "AGENT.md").read_text("utf-8") + + +def test_the_digest_ignores_line_endings(tmp_path): + """A stamp has to mean the same thing on a CRLF checkout as on an LF one.""" + from condor.layering import content_digest + + lf = tmp_path / "lf.md" + crlf = tmp_path / "crlf.md" + lf.write_bytes(b"---\nname: s\n---\n\n# S\nRule.\n") + crlf.write_bytes(b"---\r\nname: s\r\n---\r\n\r\n# S\r\nRule.\r\n") + assert content_digest(lf) == content_digest(crlf) From ff84a771f55b6b7e41839dd20877ec6b3e5ee4cc Mon Sep 17 00:00:00 2001 From: david-hummingbot Date: Fri, 18 Sep 2026 11:26:15 +0800 Subject: [PATCH 05/43] Detect forks whose stock counterpart has moved, or gone fork_path() records forked_from and nothing ever read it back. content_digest() was called in exactly two places, both inside fork_path at fork time, so the stamp was written and never compared -- not in condor/updates/, not in the frontend, nowhere. That is the mirror image of the problem FEAT-115 solved. The old failure was loud: your edit blocked the update. The new one is silent: the update succeeds, upstream's rewrite lands in the shipped tree, and the agent goes on reading your copy for ever. Upstream churns exactly these files, and they carry trading rules and risk limits, so a limit tightened upstream never arrives. stale_forks() supplies the missing comparison, in one walk with two outcomes: the counterpart moved (stale), or the counterpart is gone (retired -- upstream withdrew a playbook and reads resolve local-before-stock, so it keeps running). It only reports. Local still wins: that is the deliberate promise of FEAT-115, and an update that quietly took upstream's version back would be the old loud failure wearing a new coat. Items with no stamp are skipped, because a file the operator authored from scratch was never a fork of anything. A stamp written from a CRLF tree holds the pre-normalization hash, so the legacy form is checked too -- otherwise the preceding digest change would report every such fork stale at once. --- condor/layering.py | 94 +++++++++++++++++++++++++++++ tests/test_stale_forks.py | 121 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 215 insertions(+) create mode 100644 tests/test_stale_forks.py diff --git a/condor/layering.py b/condor/layering.py index e11b8e5c9..f78cde094 100644 --- a/condor/layering.py +++ b/condor/layering.py @@ -37,6 +37,7 @@ import hashlib import logging import shutil +from dataclasses import dataclass from datetime import datetime, timezone from pathlib import Path @@ -235,6 +236,99 @@ def carry_fork_stamp(path: Path, meta: dict) -> dict: return meta +@dataclass(frozen=True) +class StaleFork: + """A forked item whose stock counterpart has moved, or gone, since the fork.""" + + path: Path + """The local file that is being used instead of stock.""" + rel: str + """Its path relative to the local root — what a message should show.""" + forked_from: str + """The digest of the stock file at fork time, from the stamp.""" + stock_digest: str | None + """The stock file's digest now; ``None`` when it no longer exists.""" + + @property + def retired(self) -> bool: + """Whether upstream removed the stock counterpart entirely.""" + return self.stock_digest is None + + +def _legacy_digest(path: Path) -> str: + """What :func:`content_digest` returned before newlines were normalized. + + A stamp written from a CRLF working tree holds the raw-bytes hash, and after + normalization it would compare as changed for a reason that has nothing to + do with upstream. Checking the legacy form as well keeps those stamps + meaningful instead of reporting every one of them stale once. + """ + return f"sha256:{hashlib.sha256(path.read_bytes()).hexdigest()[:12]}" + + +def stale_forks(agent_slug: str | None) -> list[StaleFork]: + """Forked items of one agent whose stock counterpart has since moved. + + The other half of copy-on-write, and the half that was missing. + :func:`fork_path` records ``forked_from`` and nothing ever read it back, so + a customized playbook kept shadowing stock for ever: the update succeeded, + upstream's rewrite landed on disk, and the agent never saw it. Upstream + churns exactly these files, and they carry trading rules and risk limits, so + the divergence is both silent and consequential. + + This only *reports*. Local still wins — that is the deliberate promise of + FEAT-115, and an update that quietly took upstream's version back would be + the old loud failure wearing a new coat. What the operator gets is the + sentence nobody was being told. + + Items with no stamp are skipped: a file the operator authored from scratch + was never a fork of anything, so there is nothing for it to be stale + against. + """ + local, stock = _homes(agent_slug) + if not local.is_dir(): + return [] + + out: list[StaleFork] = [] + for path in sorted(local.rglob("*.md")): + try: + meta, _ = parse_frontmatter(path.read_text(encoding="utf-8")) + except OSError: + continue + forked_from = (meta or {}).get(FORKED_FROM_KEY) + if not forked_from: + continue + + rel = path.relative_to(local) + counterpart = stock / rel + if not counterpart.is_file(): + # Upstream retired it. Reads resolve local-before-stock and nothing + # reconciles the roots, so a withdrawn playbook keeps running. + out.append(StaleFork(path, str(rel), str(forked_from), None)) + continue + + now = content_digest(counterpart) + if now == forked_from or _legacy_digest(counterpart) == forked_from: + continue + out.append(StaleFork(path, str(rel), str(forked_from), now)) + return out + + +def all_stale_forks() -> list[StaleFork]: + """:func:`stale_forks` across every agent this install has, plus the shared root.""" + from condor.memory.paths import iter_agent_slugs + + out: list[StaleFork] = [] + seen: set[Path] = set() + for slug in [None, *iter_agent_slugs()]: + for fork in stale_forks(slug): + if fork.path in seen: + continue + seen.add(fork.path) + out.append(fork) + return out + + def write_preserving_stamp(path: Path, content: str) -> None: """Write ``content`` to ``path`` without destroying its fork stamp. diff --git a/tests/test_stale_forks.py b/tests/test_stale_forks.py new file mode 100644 index 000000000..39218f107 --- /dev/null +++ b/tests/test_stale_forks.py @@ -0,0 +1,121 @@ +"""A fork stops receiving upstream changes — and now says so (C11). + +FEAT-115 solved the loud failure: a customized playbook no longer blocks an +update. It introduced the quiet one. ``fork_path`` stamps the copy with +``forked_from`` and nothing ever read the stamp back, so the update succeeded, +upstream's rewrite landed in the shipped tree, and the agent went on reading the +local copy — for ever, with no notice, on files that carry trading rules. + +These tests pin the comparison that was missing. They do not pin a merge: local +still wins. What is asserted is that the operator is *told*. +""" + +from __future__ import annotations + +import pytest + +from condor.layering import ( + FORKED_FROM_KEY, + content_digest, + fork_if_stock, + stale_forks, + write_preserving_stamp, +) +from condor.memory.paths import agent_home_layers, stock_agent_home + +AGENT_MD = "---\nname: {name}\n---\n\n{body}\n" + + +def _write(path, text): + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text, "utf-8") + return path + + +@pytest.fixture +def shipped(): + """One shipped playbook carrying a rule upstream will later tighten.""" + return _write( + stock_agent_home("scout") / "AGENT.md", + AGENT_MD.format(name="Scout", body="Never trade on Sundays."), + ) + + +def test_an_untouched_install_has_no_stale_forks(shipped): + assert stale_forks("scout") == [] + + +def test_a_fork_whose_stock_file_never_moved_is_not_stale(shipped): + fork_if_stock("scout", "AGENT.md") + assert stale_forks("scout") == [] + + +def test_a_fork_is_reported_once_upstream_rewrites_the_stock_file(shipped): + """The whole scenario, end to end: fork, then an update rewrites stock.""" + target = fork_if_stock("scout", "AGENT.md") + write_preserving_stamp( + target, AGENT_MD.format(name="Scout", body="MY EDIT: also never Mondays.") + ) + assert stale_forks("scout") == [], "nothing has changed upstream yet" + + # What an update does to the shipped tree. + shipped.write_text( + AGENT_MD.format( + name="Scout", body="UPSTREAM: never trade on Sundays OR holidays." + ), + "utf-8", + ) + + stale = stale_forks("scout") + assert len(stale) == 1 + (fork,) = stale + assert fork.rel == "AGENT.md" + assert fork.retired is False + assert fork.stock_digest == content_digest(shipped) + assert fork.forked_from != fork.stock_digest + + # And the agent is still reading the local copy — the report is the fix, + # not a silent takeover. + assert "MY EDIT" in target.read_text("utf-8") + + +def test_a_retired_agent_is_reported_as_retired(shipped): + """Upstream withdrawing a playbook does not propagate, so it must be said.""" + fork_if_stock("scout", "AGENT.md") + shipped.unlink() + + stale = stale_forks("scout") + assert len(stale) == 1 + assert stale[0].retired is True + assert stale[0].stock_digest is None + + +def test_a_file_the_operator_authored_is_never_stale(shipped): + """No stamp means it was never a fork, so there is nothing to diverge from.""" + local, _ = agent_home_layers("scout") + _write(local / "MINE.md", AGENT_MD.format(name="mine", body="All my own work.")) + assert stale_forks("scout") == [] + + +def test_a_legacy_crlf_stamp_is_not_reported_stale(shipped): + """Stamps written before newline normalization must not all fire at once. + + A stamp taken from a CRLF working tree holds the raw-bytes hash. After + normalization that compares as changed, which would report every such fork + stale for a reason that has nothing to do with upstream. + """ + import hashlib + + target = fork_if_stock("scout", "AGENT.md") + shipped.write_bytes(shipped.read_bytes().replace(b"\n", b"\r\n")) + legacy = f"sha256:{hashlib.sha256(shipped.read_bytes()).hexdigest()[:12]}" + + text = target.read_text("utf-8") + target.write_text( + text.replace( + [ln for ln in text.splitlines() if ln.startswith(FORKED_FROM_KEY)][0], + f"{FORKED_FROM_KEY}: {legacy}", + ), + "utf-8", + ) + assert stale_forks("scout") == [] From 019309e1c5289de35013feb65cca6023c1a3d401 Mon Sep 17 00:00:00 2001 From: david-hummingbot Date: Fri, 18 Sep 2026 11:27:06 +0800 Subject: [PATCH 06/43] Warn when local forks will shadow an update's changes stale_forks() had nobody to tell. This raises it where the question is actually being asked -- "what will this update do to my install" -- as a non-blocking warning naming the paths. Non-blocking is the point. Owning a customization is what FEAT-115 shipped; the missing half was the sentence saying upstream has moved on without it. The previous audit asked for exactly this: treat a locally-edited playbook as owned and let the update leave it alone with a clear "your version, upstream moved on" notice. The owning half shipped; this is the notice. Two shapes, because they need different advice: files upstream changed (your version stays in use, those changes will not reach your agents) and files upstream removed (they keep running -- mute a retired playbook rather than deleting it, which is what stock_delete_error already establishes). The path list is capped at eight. An install that forked thirty playbooks needs a count and a few examples, not thirty lines in a Telegram message. Both surfaces render warnings by message, so neither needed a change. --- condor/updates/components.py | 53 ++++++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/condor/updates/components.py b/condor/updates/components.py index b2ad97f4e..7baf413e2 100644 --- a/condor/updates/components.py +++ b/condor/updates/components.py @@ -582,6 +582,56 @@ async def _executor_warning() -> Warning | None: ) +_STALE_FORK_CAP = 8 + + +def _stale_fork_warning() -> Warning | None: + """Local versions of shipped files that this install will keep using. + + Non-blocking on purpose. Owning a customization is what FEAT-115 shipped; + what never shipped is the notice, so the divergence compounded silently on + files carrying trading rules. This is that notice. + + The path list is capped -- an install that has forked thirty playbooks needs + a number and a few examples, not thirty lines in a Telegram message. + """ + from condor.layering import all_stale_forks + + try: + stale = all_stale_forks() + except OSError: + log.debug("Could not check for stale forks", exc_info=True) + return None + if not stale: + return None + + moved = [f for f in stale if not f.retired] + retired = [f for f in stale if f.retired] + + parts: list[str] = [] + if moved: + shown = ", ".join(f.rel for f in moved[:_STALE_FORK_CAP]) + if len(moved) > _STALE_FORK_CAP: + shown += f", and {len(moved) - _STALE_FORK_CAP} more" + parts.append( + f"You have local versions of {len(moved)} shipped " + f"file{'s' if len(moved) != 1 else ''} that upstream has since " + f"changed ({shown}). Your versions stay in use, so those changes " + "will not reach your agents." + ) + if retired: + shown = ", ".join(f.rel for f in retired[:_STALE_FORK_CAP]) + if len(retired) > _STALE_FORK_CAP: + shown += f", and {len(retired) - _STALE_FORK_CAP} more" + parts.append( + f"{len(retired)} local file{'s' if len(retired) != 1 else ''} " + f"no longer exist{'' if len(retired) != 1 else 's'} upstream " + f"({shown}) and will keep running. Mute a retired playbook rather " + "than deleting it." + ) + return Warning(component=CONDOR, code="stale-forks", message=" ".join(parts)) + + async def preflight(component_keys: list[str]) -> Preflight: """Can this update run, what will it do, and what should the admin know.""" known = _table() @@ -639,6 +689,9 @@ async def preflight(component_keys: list[str]) -> Preflight: if executor_warning is not None: warnings.append(executor_warning) if CONDOR in selected: + stale_warning = _stale_fork_warning() + if stale_warning is not None: + warnings.append(stale_warning) warnings.append( Warning( component=CONDOR, From 320c3bdbea8c9735d9dd51350ec7cb2d91b1b437 Mon Sep 17 00:00:00 2001 From: david-hummingbot Date: Fri, 18 Sep 2026 11:28:55 +0800 Subject: [PATCH 07/43] Refuse to update a detached checkout, and name a stale git lock Two gaps in the shared git machinery, so both components inherit both. S1: rev-parse --abbrev-ref HEAD returns the literal string "HEAD" when the checkout is detached, and everything downstream treated that as a branch name. origin/HEAD then resolves to the remote's default branch, so ahead_count found nothing ahead and raised no diverged block, and `git merge --ff-only origin/HEAD` quietly succeeded -- fast-forwarding a detached checkout onto main and leaving it detached, 299 commits of someone else's history applied with no message saying anything had happened. Nothing anywhere called symbolic-ref. is_detached() asks the question --abbrev-ref cannot answer, and repo_blocks() refuses before it reads a branch name or fetches. The message names the commit, because the operator needs somewhere to go back to. remote_default_branch() comes along now because it is the other half of the same symbolic-ref gap, and the image-versus-branch work later in this branch needs it rather than hardcoding "main". It returns None when the ref is absent, which callers must read as "cannot tell". S3: a crashed git leaves .git/index.lock behind and every later subcommand fails on it. The raw error says "Another git process seems to be running", which sends the reader looking for a process that is not there; one line naming the file turns that into a fix. Appended only when the output actually mentions the lock. --- condor/updates/components.py | 24 +++++++ tests/test_update_git_guards.py | 113 ++++++++++++++++++++++++++++++++ tests/test_update_preflight.py | 2 + utils/updater.py | 56 +++++++++++++++- 4 files changed, 193 insertions(+), 2 deletions(-) create mode 100644 tests/test_update_git_guards.py diff --git a/condor/updates/components.py b/condor/updates/components.py index 7baf413e2..cec70d184 100644 --- a/condor/updates/components.py +++ b/condor/updates/components.py @@ -460,6 +460,30 @@ async def repo_blocks(component: Component) -> list[Block]: ) ] + # Before anything reads a branch name: a detached checkout has none, but + # ``rev-parse --abbrev-ref HEAD`` answers the literal string ``HEAD``, and + # everything downstream then treats that as a branch. ``origin/HEAD`` + # resolves to the remote's default branch, so the comparison silently + # becomes "how far is this commit behind main", nothing reports being + # ahead, and ``merge --ff-only origin/HEAD`` succeeds -- fast-forwarding a + # detached checkout onto main and leaving it detached, with no message + # saying so. Refuse instead, and say which commit it is sitting on. + if await updater.is_detached(component.repo_dir): + sha = await updater.get_local_commit(component.repo_dir) + return [ + Block( + component=component.key, + code="detached-head", + message=( + f"HEAD is detached at {sha} in {component.repo_dir}. " + "Check out a branch before updating — otherwise the update " + "would fast-forward onto the remote's default branch and " + "leave the checkout detached." + ), + resolutions=["cancel"], + ) + ] + branch = await updater.get_current_branch(component.repo_dir) # Fetch first, always. The incoming set is only meaningful against an diff --git a/tests/test_update_git_guards.py b/tests/test_update_git_guards.py new file mode 100644 index 000000000..185890c0b --- /dev/null +++ b/tests/test_update_git_guards.py @@ -0,0 +1,113 @@ +"""Two things the shared git machinery could not see (S1, S3). + +Both fire once per checkout, so both components inherit them. +""" + +from __future__ import annotations + +import subprocess + +import pytest + +from condor.updates import components +from utils import updater + + +def _git(*args, cwd): + subprocess.run( + ["git", *args], + cwd=cwd, + check=True, + capture_output=True, + env={ + "GIT_AUTHOR_NAME": "t", + "GIT_AUTHOR_EMAIL": "t@t", + "GIT_COMMITTER_NAME": "t", + "GIT_COMMITTER_EMAIL": "t@t", + "PATH": "/usr/bin:/bin", + }, + ) + + +@pytest.fixture +def repo(tmp_path): + """A throwaway checkout with three commits.""" + d = tmp_path / "repo" + d.mkdir() + _git("init", "-q", "-b", "main", cwd=d) + for i in range(3): + (d / "f.txt").write_text(f"{i}\n") + _git("add", "-A", cwd=d) + _git("commit", "-qm", f"c{i}", cwd=d) + return d + + +@pytest.mark.asyncio +async def test_an_attached_checkout_is_not_detached(repo): + assert await updater.is_detached(str(repo)) is False + + +@pytest.mark.asyncio +async def test_a_detached_checkout_is_detected(repo): + """``--abbrev-ref`` cannot answer this — it returns the string ``HEAD``.""" + _git("checkout", "-q", "--detach", "HEAD~2", cwd=repo) + + assert await updater.get_current_branch(str(repo)) == "HEAD" + assert await updater.is_detached(str(repo)) is True + + +@pytest.mark.asyncio +async def test_a_detached_checkout_blocks_the_update(repo, monkeypatch): + """And it blocks before anything reads a branch name or fetches.""" + _git("checkout", "-q", "--detach", "HEAD~2", cwd=repo) + component = components.Component( + key="condor", name="Condor", repo_dir=str(repo), service=None + ) + + async def _never(*a, **k): # pragma: no cover - asserts it is not reached + raise AssertionError("fetched despite a detached HEAD") + + monkeypatch.setattr(updater, "fetch", _never) + + blocks = await components.repo_blocks(component) + assert [b.code for b in blocks] == ["detached-head"] + assert blocks[0].resolutions == ["cancel"] + # The message has to name the commit, or there is nothing to go back to. + short = subprocess.run( + ["git", "rev-parse", "--short", "HEAD"], + cwd=repo, + capture_output=True, + text=True, + ).stdout.strip() + assert short in blocks[0].message + + +@pytest.mark.asyncio +async def test_a_stale_index_lock_gets_a_hint(repo): + """The raw git error sends the reader hunting a process that is not there.""" + (repo / ".git" / "index.lock").touch() + + rc, out = await updater._run_git("add", "-A", repo_dir=str(repo)) + assert rc != 0 + assert "index.lock" in out + assert "Stale git lock" in out + assert str(repo) in out + + +@pytest.mark.asyncio +async def test_the_hint_is_not_appended_to_unrelated_failures(repo): + rc, out = await updater._run_git("rev-parse", "nope-not-a-ref", repo_dir=str(repo)) + assert rc != 0 + assert "Stale git lock" not in out + + +@pytest.mark.asyncio +async def test_the_remote_default_branch_is_read_not_assumed(repo, tmp_path): + """A clone knows its remote's default branch; a bare repo with no origin does not.""" + clone = tmp_path / "clone" + subprocess.run( + ["git", "clone", "-q", str(repo), str(clone)], check=True, capture_output=True + ) + assert await updater.remote_default_branch(str(clone)) == "main" + # No origin at all -> "cannot tell", never a guess. + assert await updater.remote_default_branch(str(repo)) is None diff --git a/tests/test_update_preflight.py b/tests/test_update_preflight.py index 31b0f560c..cff9ce0e6 100644 --- a/tests/test_update_preflight.py +++ b/tests/test_update_preflight.py @@ -45,6 +45,7 @@ def _blocks( with patch.multiple( "utils.updater", is_git_repo=AsyncMock(return_value=is_repo), + is_detached=AsyncMock(return_value=False), fetch=AsyncMock(return_value=(fetched, "" if fetched else "no such remote")), get_current_branch=AsyncMock(return_value="main"), ahead_count=AsyncMock(return_value=ahead), @@ -60,6 +61,7 @@ def test_the_blockers_are_computed_against_a_freshly_fetched_origin(): with patch.multiple( "utils.updater", is_git_repo=AsyncMock(return_value=True), + is_detached=AsyncMock(return_value=False), fetch=fetch, get_current_branch=AsyncMock(return_value="main"), ahead_count=AsyncMock(return_value=0), diff --git a/utils/updater.py b/utils/updater.py index 497f46ec2..fbbfd848c 100644 --- a/utils/updater.py +++ b/utils/updater.py @@ -75,9 +75,22 @@ async def _run_cmd( return proc.returncode, output +# A crashed git leaves .git/index.lock behind and every later subcommand fails +# on it. The raw error does reach output_tail, but it says "Another git process +# seems to be running" -- which sends the reader looking for a process that is +# not there. One line naming the file turns a dead end into a fix. +_LOCK_HINT = ( + "\nStale git lock: remove {repo}/.git/index.lock if no git process is " + "actually running." +) + + async def _run_git(*args: str, repo_dir: str = CONDOR_DIR) -> tuple[int, str]: """Run a git command in the given repo and return (returncode, stdout).""" - return await _run_cmd("git", *args, cwd=repo_dir, timeout=GIT_TIMEOUT) + rc, out = await _run_cmd("git", *args, cwd=repo_dir, timeout=GIT_TIMEOUT) + if rc != 0 and "index.lock" in out: + out += _LOCK_HINT.format(repo=repo_dir) + return rc, out async def get_local_commit(repo_dir: str = CONDOR_DIR) -> str: @@ -93,11 +106,50 @@ async def get_local_commit_full(repo_dir: str = CONDOR_DIR) -> str: async def get_current_branch(repo_dir: str = CONDOR_DIR) -> str: - """Return the current branch name.""" + """Return the current branch name, or the literal ``HEAD`` when detached. + + ``rev-parse --abbrev-ref`` answers ``HEAD`` for a detached checkout rather + than failing, and every caller downstream then treats that string as a + branch name. Use :func:`is_detached` to ask the question this cannot + answer. + """ _, out = await _run_git("rev-parse", "--abbrev-ref", "HEAD", repo_dir=repo_dir) return out +async def is_detached(repo_dir: str = CONDOR_DIR) -> bool: + """Whether ``HEAD`` points at a commit rather than a branch. + + ``symbolic-ref`` is the only probe that distinguishes the two: + ``rev-parse --abbrev-ref HEAD`` returns the *string* ``HEAD`` when detached, + which reads as an ordinary branch name everywhere downstream. That is how a + detached checkout came to fast-forward onto the remote's default branch -- + ``ahead_count(repo, "HEAD")`` resolves ``origin/HEAD``, finds nothing ahead, + so no ``diverged`` block fires, and ``git merge --ff-only origin/HEAD`` + quietly succeeds and leaves the checkout detached. + """ + rc, _ = await _run_git("symbolic-ref", "-q", "HEAD", repo_dir=repo_dir) + return rc != 0 + + +async def remote_default_branch(repo_dir: str = CONDOR_DIR) -> str | None: + """The remote's default branch (e.g. ``main``), or ``None`` if unknown. + + Read from ``refs/remotes/origin/HEAD`` rather than hardcoded, so a repo that + renames its default branch does not need a code change here. ``None`` when + the ref is absent -- a clone made with ``--single-branch``, or one that has + never run ``git remote set-head`` -- and callers must treat that as "cannot + tell" rather than assuming anything. + """ + rc, out = await _run_git( + "symbolic-ref", "-q", "refs/remotes/origin/HEAD", repo_dir=repo_dir + ) + if rc != 0 or not out: + return None + # refs/remotes/origin/main -> main + return out.strip().rsplit("/", 1)[-1] or None + + async def check_for_updates(repo_dir: str = CONDOR_DIR) -> dict: """ Fetch from remote and compare local vs remote HEAD. From ad5c59cc908a14df6be4edb27785e719a1d4a799 Mon Sep 17 00:00:00 2001 From: david-hummingbot Date: Fri, 18 Sep 2026 11:32:25 +0800 Subject: [PATCH 08/43] Stop reporting an operator's own image build as behind hummingbot-api's Makefile ships `docker build -t hummingbot/hummingbot-api:latest .` as the documented way to run your own code, and Condor offered to overwrite the result with the registry's: image: current='sha256:1b2e7341' available='sha256:6341fe50' behind=1 plan : Pull the published hummingbot-api image The obvious discriminator does not work. "A built image has no RepoDigests" is true of the old graphdriver store and false of the containerd one, which records a canonical digest for a build as well as a pull -- so on Docker 29.x the built image has a RepoDigest like any other, and a fix keyed on the list being empty never fires at all. .Comment is no help either: both read buildkit.dockerfile.v0, because the published image was itself built by buildkit in CI. What does work is asking the registry whether it holds *this* digest. A pulled digest resolves; a built one comes back "not found", because it was never pushed. Verified against the real registry in the install sandbox: pull, then build over the same tag, and the facet goes from behind=1 to "Built locally". registry_has_digest() returns None rather than False when it could not ask, and only False suppresses the "behind" reading -- an unreachable registry is not evidence that an image was built locally. The probe runs only when the digests already differ, so the common up-to-date path costs nothing. --- condor/updates/components.py | 36 ++++++++++++++++++++++++++++++++---- utils/updater.py | 36 ++++++++++++++++++++++++++++++++++++ 2 files changed, 68 insertions(+), 4 deletions(-) diff --git a/condor/updates/components.py b/condor/updates/components.py index cec70d184..3307052ef 100644 --- a/condor/updates/components.py +++ b/condor/updates/components.py @@ -303,15 +303,43 @@ async def _image_facet(repo_dir: str, service: str) -> tuple[Facet, str]: ) same = local == remote + if same: + return ( + Facet(kind="image", current=_short_digest(local), behind=0), + mode, + ) + + # Different digests are not yet evidence of being behind. `make build` in + # hummingbot-api tags `hummingbot/hummingbot-api:latest` -- the *same* tag + # the registry publishes -- so the operator's own build lands here looking + # exactly like an out-of-date pull, and Condor would offer to overwrite it + # with the registry's. Neither the tag nor the presence of a RepoDigest can + # tell the two apart (a build gets a RepoDigest too, from the containerd + # image store). Asking the registry whether it holds *this* digest can. + published = await updater.registry_has_digest(image_ref, local) + if published is False: + return ( + Facet( + kind="image", + current=_short_digest(local), + behind=0, + detail=[ + "Built locally; not comparable to the published tag. " + "Rebuild it yourself to pick up changes." + ], + ), + mode, + ) + return ( Facet( kind="image", current=_short_digest(local), - available=None if same else _short_digest(remote), + available=_short_digest(remote), # An image is behind or it is not; there is no commit count to give. - behind=0 if same else 1, - up_to_date=same, - detail=[] if same else ["A newer image is published under this tag."], + behind=1, + up_to_date=False, + detail=["A newer image is published under this tag."], ), mode, ) diff --git a/utils/updater.py b/utils/updater.py index fbbfd848c..52bed4884 100644 --- a/utils/updater.py +++ b/utils/updater.py @@ -720,6 +720,42 @@ async def registry_image_digest(image_ref: str) -> str | None: return digest if digest.startswith("sha256:") else None +async def registry_has_digest(image_ref: str, digest: str) -> bool | None: + """Whether the registry holds ``digest`` for ``image_ref``'s repository. + + The only reliable way to tell an operator's own build from a published + image. The obvious test -- "a built image has no ``RepoDigests``" -- does + **not** work: Docker's containerd image store records a canonical digest for + a build as well as for a pull, so a locally built image has a RepoDigest + like any other, it simply is not one the registry has ever seen. Measured on + 29.8.1: pull, then ``docker build`` over the same tag, and RepoDigests is + populated both times with different values. ``.Comment`` is no help either + (both say ``buildkit.dockerfile.v0``, because the published image was itself + built by buildkit in CI). + + Returns ``None`` when the question could not be answered, which callers must + keep distinct from ``False`` -- an unreachable registry is not evidence that + an image was built locally. + """ + repo = image_ref.split("@", 1)[0].rsplit(":", 1)[0] + rc, out = await _run_cmd( + "docker", + "buildx", + "imagetools", + "inspect", + f"{repo}@{digest}", + "--format", + "{{.Manifest.Digest}}", + timeout=30, + ) + if rc == 0 and (out or "").strip().startswith("sha256:"): + return True + # "not found" is an answer; anything else is a failure to ask. + if "not found" in (out or "").lower(): + return False + return None + + async def compose_pull(repo_dir: str, service: str) -> tuple[bool, str]: """Pull the published image for one service.""" rc, output = await _run_cmd( From 183e0ec746e85c106fe66e442be47f44d4ef64da Mon Sep 17 00:00:00 2001 From: david-hummingbot Date: Fri, 18 Sep 2026 11:35:54 +0800 Subject: [PATCH 09/43] Only offer an image update when the image can match the checkout hummingbot-api is described by two facets and only one of them was honest. The repo facet compares HEAD against origin/, correctly. The image facet compared the local digest against whatever the registry's tag resolved to -- and CI publishes only on merges to the default branch, tagging only `latest` and the version (the registry holds exactly `latest`, `1.0.1`, and one stale one-off). So on a feature branch the update silently offered, and applied, the default branch's code, while the status card went on truthfully reporting the checkout as up to date. The report suggested warning. Deciding is simpler and stronger: if the published tag cannot correspond to this checkout, do not offer to move the container at all. PRs merge straight to the default branch and there is no per-branch tag, so there is no second mapping to make -- either this checkout is the one the tag is built from, or the operator owns the image. Branch name alone is not the test. image_tracks_this_checkout() requires four things, each of which is a way the name gets it wrong: the remote must be the canonical repository (a fork has a `main` too), HEAD must not be detached (--abbrev-ref answers the literal string "HEAD"), the branch must be the remote's default (read via symbolic-ref, not hardcoded), and the checkout must not be ahead (unpushed commits mean the branch is `main` and the code is not). The skip is stated, not silent -- a silent skip would be the same class of defect as the silent pull it replaces. The running digest stays visible, the offer disappears, and the detail says why and whose image it is. The plan drops the image steps to match, because a plan that lists a pull the run then skips is its own small lie. The checkout is still fast-forwarded, and the step now says what for: it supplies the shared files the container reads. "source" mode goes with it. _steps_for had a "Rebuild from source" branch and compose_mode/compose_build existed to serve it, but no shipped compose file has a build: key, so it was unreachable on every real install -- a mode nobody could enter and therefore nobody tested. An operator who hand-adds one is running their own image, which is precisely the case the checkout gate now covers by leaving it alone. --- condor/updates/components.py | 120 ++++++++++++++++--- condor/updates/run.py | 33 ++---- frontend/src/lib/updates-api.ts | 3 +- tests/test_image_tracks_checkout.py | 174 ++++++++++++++++++++++++++++ tests/test_update_journal.py | 24 +++- tests/test_update_preflight.py | 15 ++- utils/updater.py | 24 ---- 7 files changed, 321 insertions(+), 72 deletions(-) create mode 100644 tests/test_image_tracks_checkout.py diff --git a/condor/updates/components.py b/condor/updates/components.py index 3307052ef..1228464d5 100644 --- a/condor/updates/components.py +++ b/condor/updates/components.py @@ -244,20 +244,14 @@ async def _image_facet(repo_dir: str, service: str) -> tuple[Facet, str]: "unknown", ) - mode = "source" if definition.get("build") else "image" + # A ``build:`` key would mean the image is produced here, but no shipped + # compose file has one and hummingbot-api is deployed from the published + # image, so "source" is a mode the product cannot enter. An operator who + # hand-adds a build key is running their own image, which is exactly what + # the checkout gate and the locally-built check below already cover. + mode = "image" image_ref = str(definition.get("image") or "") - if mode == "source": - # Built here: the repo is the version, and there is no registry to ask. - return ( - Facet( - kind="image", - current=image_ref or "(built from source)", - detail=["Built from source; the repo below is the version."], - ), - mode, - ) - if not image_ref: return ( Facet( @@ -345,6 +339,71 @@ async def _image_facet(repo_dir: str, service: str) -> tuple[Facet, str]: ) +# hummingbot-api's CI publishes only on merges to the default branch, and only +# the `latest` and version tags (verified against the registry: `latest`, +# `1.0.1`, and one stale one-off). There is no per-branch tag, so on any other +# checkout the published image has nothing to do with the code on disk -- +# offering to pull it means silently replacing the container with the default +# branch's code. PRs merge straight to the default branch, so there is no second +# mapping to make: either the checkout is the one the tag is built from, or the +# operator owns the image. +_CANONICAL_REMOTES = ("github.com/hummingbot/hummingbot-api",) + + +def _is_canonical_remote(url: str) -> bool: + """Whether ``url`` is the repository whose merges publish the tag. + + A fork has a default branch too, usually with the same name, so the branch + name alone would claim the published image matches code it has never seen. + Normalized loosely on purpose -- ssh, https, with or without ``.git`` -- and + conservative: anything unrecognized is treated as not canonical, which + skips the image rather than overwriting it. + """ + cleaned = url.strip().removesuffix(".git").replace(":", "/").lower() + return any(cleaned.endswith(r) or f"/{r}" in cleaned for r in _CANONICAL_REMOTES) + + +async def image_tracks_this_checkout(repo_dir: str) -> tuple[bool, str]: + """Whether the published tag can correspond to this checkout, and why not. + + Four conditions, each of which is a way the branch *name* alone gets it + wrong: + + * the remote must be the canonical repository -- a fork's ``main`` is not + the ``main`` the tag is built from; + * ``HEAD`` must not be detached -- ``--abbrev-ref`` answers the literal + string ``HEAD``, which would otherwise read as an ordinary branch name; + * the branch must be the remote's default -- read, not hardcoded; + * the checkout must not be ahead -- unpushed commits mean the branch is the + default one and the code is not. + + Returns ``(True, "")`` when an image update is meaningful, otherwise + ``(False, reason)`` with the reason in words an operator can act on. + """ + rc, url = await updater._run_git("remote", "get-url", "origin", repo_dir=repo_dir) + if rc != 0 or not url: + return False, "this checkout has no origin remote" + if not _is_canonical_remote(url): + return ( + False, + f"origin is {url.strip()}, not the repository the image is built from", + ) + + if await updater.is_detached(repo_dir): + return False, "HEAD is detached, so it is not on any branch" + + default = await updater.remote_default_branch(repo_dir) + branch = await updater.get_current_branch(repo_dir) + if default is None: + return False, "the remote's default branch could not be determined" + if branch != default: + return False, f"this checkout is on `{branch}`, not `{default}`" + + if await updater.ahead_count(repo_dir, branch) > 0: + return False, f"this checkout has commits that are not on `{default}`" + return True, "" + + async def status(key: str) -> ComponentStatus: """Every facet of one component, fetched concurrently.""" component = _table()[key] @@ -358,10 +417,30 @@ async def status(key: str) -> ComponentStatus: up_to_date=repo.up_to_date, ) - repo, (image, mode) = await asyncio.gather( + repo, (image, mode), (tracks, why_not) = await asyncio.gather( _repo_facet(component.repo_dir), _image_facet(component.repo_dir, component.service), + image_tracks_this_checkout(component.repo_dir), ) + + if not tracks: + # The comparison is still *correct* -- it is just meaningless here, and + # acting on it would replace the operator's container with the default + # branch's code. Keep the running digest visible, drop the offer, and + # say why. Stated rather than silently skipped: a silent skip is the + # same class of defect as the silent pull it replaces. + image = Facet( + kind="image", + current=image.current, + behind=0, + detail=[ + f"The container runs the published image, which is built only " + f"from the default branch — and {why_not}. Left alone; you own " + f"this image." + ], + error=image.error, + ) + return ComponentStatus( key=component.key, name=component.name, @@ -593,12 +672,17 @@ def _steps_for(component_key: str, status_: ComponentStatus) -> list[str]: steps: list[str] = [] if component_key == HUMMINGBOT_API: repo = status_.facets.get("repo") + image = status_.facets.get("image") if repo is not None and not repo.up_to_date and repo.error is None: - steps.append("Fast-forward the hummingbot-api checkout") - if status_.mode == "source": - steps.append("Rebuild the hummingbot-api image from source") - else: - steps.append("Pull the published hummingbot-api image") + # Worth saying what this is *for*: the checkout supplies the shared + # files the container reads, so it is fast-forwarded even when the + # image is left alone. + steps.append("Fast-forward the hummingbot-api checkout (shared files)") + if image is not None and image.up_to_date: + # Either already current, or an image this checkout does not track. + # Nothing to pull, and nothing to restart for. + return steps + steps.append("Pull the published hummingbot-api image") steps.append("Recreate the containers") steps.append("Wait for the API to answer") else: diff --git a/condor/updates/run.py b/condor/updates/run.py index 85eeaf335..429a5a7af 100644 --- a/condor/updates/run.py +++ b/condor/updates/run.py @@ -324,22 +324,19 @@ def _plan(component_keys: list[str], statuses: dict[str, components.ComponentSta continue if key == components.HUMMINGBOT_API: status = statuses.get(key) - mode = status.mode if status else "image" steps.append( Step(f"{key}.fast-forward", "Fast-forwarding the hummingbot-api repo") ) - steps.append( - Step( - f"{key}.image", - ( - "Rebuilding the API image" - if mode == "source" - else "Pulling the API image" - ), - ) - ) - steps.append(Step(f"{key}.up", "Recreating the containers")) - steps.append(Step(f"{key}.health", "Waiting for the API to answer")) + # The image steps only exist when the published tag can correspond + # to this checkout. Off the default branch the container is the + # operator's, so there is nothing to pull and nothing to restart + # for -- and a plan that listed them anyway would be promising to do + # something the run then skips. + image = status.facets.get("image") if status else None + if image is None or not image.up_to_date: + steps.append(Step(f"{key}.image", "Pulling the API image")) + steps.append(Step(f"{key}.up", "Recreating the containers")) + steps.append(Step(f"{key}.health", "Waiting for the API to answer")) else: # No restart step: the run stops with the new code on disk and asks # for the relaunch instead (see the module docstring). @@ -464,17 +461,9 @@ async def _update_hb_api(run: Run) -> bool: await _fail(run, "The hummingbot-api checkout could not be moved.") return False - mode = await updater.compose_mode(component.repo_dir, component.service) step = await _begin(run, f"{prefix}.image") if step is not None: - if mode == "source": - ok, output = await updater.compose_build( - component.repo_dir, component.service - ) - else: - ok, output = await updater.compose_pull( - component.repo_dir, component.service - ) + ok, output = await updater.compose_pull(component.repo_dir, component.service) await _finish(run, step, OK if ok else FAILED, output) if not ok: await _fail(run, "The API image could not be produced.") diff --git a/frontend/src/lib/updates-api.ts b/frontend/src/lib/updates-api.ts index 5cdf1c432..8330a5189 100644 --- a/frontend/src/lib/updates-api.ts +++ b/frontend/src/lib/updates-api.ts @@ -36,7 +36,8 @@ export interface ComponentStatus { key: string; name: string; facets: Partial>; - mode: "image" | "source" | null; + /** Always "image": hummingbot-api is deployed from the published image. */ + mode: "image" | null; up_to_date: boolean; } diff --git a/tests/test_image_tracks_checkout.py b/tests/test_image_tracks_checkout.py new file mode 100644 index 000000000..64b0cc160 --- /dev/null +++ b/tests/test_image_tracks_checkout.py @@ -0,0 +1,174 @@ +"""The container does not run the checkout, so only offer what can match (H1). + +hummingbot-api's CI publishes on merges to the default branch and nothing else, +tagging only ``latest`` and the version. There is no per-branch tag, so on any +other checkout the published image has nothing to do with the code on disk and +"pull the newer image" means "silently run the default branch's code". + +The rule is therefore two-way: either this checkout is the one the tag is built +from, or the operator owns the image. These tests pin the four conditions that +make up "is", because the branch *name* alone gets each of them wrong. +""" + +from __future__ import annotations + +import subprocess + +import pytest + +from condor.updates import components +from utils import updater + +CANONICAL = "https://github.com/hummingbot/hummingbot-api.git" + + +def _git(*args, cwd): + subprocess.run( + ["git", *args], + cwd=cwd, + check=True, + capture_output=True, + env={ + "GIT_AUTHOR_NAME": "t", + "GIT_AUTHOR_EMAIL": "t@t", + "GIT_COMMITTER_NAME": "t", + "GIT_COMMITTER_EMAIL": "t@t", + "PATH": "/usr/bin:/bin", + }, + ) + + +@pytest.fixture +def checkout(tmp_path): + """A clone of a local "canonical" origin, on its default branch, clean.""" + origin = tmp_path / "origin" + origin.mkdir() + _git("init", "-q", "-b", "main", cwd=origin) + for i in range(2): + (origin / "f.txt").write_text(f"{i}\n") + _git("add", "-A", cwd=origin) + _git("commit", "-qm", f"c{i}", cwd=origin) + + clone = tmp_path / "clone" + subprocess.run( + ["git", "clone", "-q", str(origin), str(clone)], check=True, capture_output=True + ) + # Present the clone as the canonical repository; origin/HEAD already points + # at main from the clone. + _git("remote", "set-url", "origin", CANONICAL, cwd=clone) + return clone + + +# ── the canonical-remote condition ── + + +def test_a_fork_is_not_treated_as_the_canonical_repository(): + assert components._is_canonical_remote(CANONICAL) is True + assert ( + components._is_canonical_remote("git@github.com:hummingbot/hummingbot-api.git") + is True + ) + assert ( + components._is_canonical_remote("https://github.com/someone/hummingbot-api") + is False + ) + assert components._is_canonical_remote("https://example.com/mirror") is False + + +@pytest.mark.asyncio +async def test_the_default_branch_of_the_canonical_repo_tracks_the_image(checkout): + tracks, why = await components.image_tracks_this_checkout(str(checkout)) + assert tracks is True, why + + +@pytest.mark.asyncio +async def test_a_forks_main_does_not_track_the_image(checkout): + _git( + "remote", + "set-url", + "origin", + "https://github.com/someone/hummingbot-api", + cwd=checkout, + ) + tracks, why = await components.image_tracks_this_checkout(str(checkout)) + assert tracks is False + assert "not the repository the image is built from" in why + + +@pytest.mark.asyncio +async def test_a_feature_branch_does_not_track_the_image(checkout): + _git("checkout", "-qb", "feat/add-https", cwd=checkout) + tracks, why = await components.image_tracks_this_checkout(str(checkout)) + assert tracks is False + assert "feat/add-https" in why and "main" in why + + +@pytest.mark.asyncio +async def test_a_detached_head_does_not_track_the_image(checkout): + _git("checkout", "-q", "--detach", "HEAD~1", cwd=checkout) + tracks, why = await components.image_tracks_this_checkout(str(checkout)) + assert tracks is False + assert "detached" in why + + +@pytest.mark.asyncio +async def test_unpushed_commits_mean_the_code_is_not_the_default_branch(checkout): + """The branch is `main`; the code is not. The name alone cannot see this.""" + (checkout / "mine.txt").write_text("local work\n") + _git("add", "-A", cwd=checkout) + _git("commit", "-qm", "my own commit", cwd=checkout) + + tracks, why = await components.image_tracks_this_checkout(str(checkout)) + assert tracks is False + assert "not on `main`" in why + + +# ── what the operator is shown ── + + +@pytest.mark.asyncio +async def test_an_untracked_checkout_is_not_offered_an_image_pull( + monkeypatch, checkout +): + """The offer disappears and the reason is stated -- not silently skipped.""" + _git("checkout", "-qb", "feat/mine", cwd=checkout) + + async def _service(*a, **k): + return {"image": "hummingbot/hummingbot-api:latest"} + + monkeypatch.setattr(updater, "compose_service", _service) + monkeypatch.setattr( + updater, "local_image_digest", lambda *a, **k: _val("sha256:aaa") + ) + monkeypatch.setattr( + updater, "registry_image_digest", lambda *a, **k: _val("sha256:bbb") + ) + monkeypatch.setattr( + components, + "_table", + lambda: { + components.HUMMINGBOT_API: components.Component( + key=components.HUMMINGBOT_API, + name="Hummingbot API", + repo_dir=str(checkout), + service="hummingbot-api", + ) + }, + ) + + status = await components.status(components.HUMMINGBOT_API) + image = status.facets["image"] + assert image.behind == 0 + assert image.up_to_date is True + assert image.available is None + detail = " ".join(image.detail) + assert "you own this image" in detail.lower() + assert "feat/mine" in detail + + steps = components._steps_for(components.HUMMINGBOT_API, status) + assert not any("Pull the published" in s for s in steps) + assert not any("Recreate the containers" in s for s in steps) + + +async def _val(v): + return v diff --git a/tests/test_update_journal.py b/tests/test_update_journal.py index 30a9ab8e4..c689d52e7 100644 --- a/tests/test_update_journal.py +++ b/tests/test_update_journal.py @@ -335,14 +335,32 @@ def test_the_plan_is_laid_out_before_anything_runs(_a_sibling_api): assert "Pulling" in steps[1].label -def test_source_mode_builds_instead_of_pulling(_a_sibling_api): +def test_an_image_this_checkout_does_not_track_gets_no_image_steps(_a_sibling_api): + """The plan must not promise a pull the run is going to skip. + + Off the default branch the published tag has nothing to do with the code on + disk, so the image is the operator's and is left alone. The checkout is + still fast-forwarded -- it supplies the shared files the container reads. + """ status = components.ComponentStatus( - key=components.HUMMINGBOT_API, name="x", facets={}, mode="source" + key=components.HUMMINGBOT_API, + name="x", + facets={ + "image": components.Facet( + kind="image", + current="sha256:abc", + behind=0, + detail=["Left alone; you own this image."], + ) + }, + mode="image", ) steps = run_module._plan( [components.HUMMINGBOT_API], {components.HUMMINGBOT_API: status} ) - assert "Rebuilding" in steps[1].label + labels = [s.label for s in steps] + assert labels == ["Fast-forwarding the hummingbot-api repo"] + assert not any("Pulling" in label for label in labels) # --------------------------------------------------------------------------- diff --git a/tests/test_update_preflight.py b/tests/test_update_preflight.py index cff9ce0e6..ad373bc01 100644 --- a/tests/test_update_preflight.py +++ b/tests/test_update_preflight.py @@ -282,10 +282,17 @@ def test_an_image_with_no_local_digest_is_unknown_not_behind(): assert facet.error and "never been pulled" in facet.error -def test_a_build_key_means_source_mode_and_no_registry_lookup(): - facet, mode = _facet(service={"build": {"context": "."}, "image": "local/api"}) - assert mode == "source" - assert facet.up_to_date is True +def test_a_hand_added_build_key_no_longer_means_a_second_mode(): + """ "source" was a mode the product could not enter, so it is gone. + + No shipped compose file has a ``build:`` key and hummingbot-api is deployed + from the published image, so the branch was dead code. An operator who adds + one is running their own image, which the checkout gate and the + locally-built check already cover -- by leaving it alone, not by rebuilding + it on their behalf. + """ + _, mode = _facet(service={"build": {"context": "."}, "image": "local/api"}) + assert mode == "image" def test_docker_being_down_is_an_error_not_a_verdict(): diff --git a/utils/updater.py b/utils/updater.py index 52bed4884..8a2de6e2f 100644 --- a/utils/updater.py +++ b/utils/updater.py @@ -653,20 +653,6 @@ async def compose_service(repo_dir: str, service: str) -> dict | None: return definition if isinstance(definition, dict) else None -async def compose_mode(repo_dir: str, service: str) -> str: - """How this install produces ``service``: ``source``, ``image`` or ``unknown``. - - A ``build:`` key means the image is built here, so an update is - ``compose build``. No ``build:`` key means a published image is pulled, so - an update is ``compose pull`` -- and the git checkout, whatever it says, - has nothing to do with the version running inside the container. - """ - definition = await compose_service(repo_dir, service) - if definition is None: - return "unknown" - return "source" if definition.get("build") else "image" - - async def local_image_digest(image_ref: str) -> str | None: """The registry digest of the local copy of ``image_ref``, or None. @@ -766,16 +752,6 @@ async def compose_pull(repo_dir: str, service: str) -> tuple[bool, str]: return True, output -async def compose_build(repo_dir: str, service: str) -> tuple[bool, str]: - """Rebuild one service's image from source.""" - rc, output = await _run_cmd( - "docker", "compose", "build", service, cwd=repo_dir, timeout=DOCKER_TIMEOUT - ) - if rc != 0: - return False, output or "docker compose build failed (no output)" - return True, output - - async def compose_up(repo_dir: str) -> tuple[bool, str]: """Recreate the stack on whatever images are now on disk.""" rc, output = await _run_cmd( From c913d0120ab46742e77b5cd360d4f2e371f81f2f Mon Sep 17 00:00:00 2001 From: david-hummingbot Date: Fri, 18 Sep 2026 11:40:03 +0800 Subject: [PATCH 10/43] Build the dashboard alongside the running one, not on top of it vite.config.ts overrides neither outDir nor emptyOutDir, so vite's defaults applied and `npm run build` emptied frontend/dist while uvicorn was serving out of it. Measured against a live instance with index.html moved aside: baseline: 200 index.html gone: 500 <- not 404: FileResponse raises on a missing path deep route: 500 asset 404 check: 404 <- the mount itself is healthy restored: 200 Three consequences from one emptied directory. A reload during the build got a 500 that nothing upstream can tell from a crash. A build that *failed* left the install with no dashboard at all, while the run reported that "the dashboard would come back on the previous bundle" -- a sentence that was false, because vite had already deleted it. And a restart on a destroyed dist came up with no static mount and a bare 404 at /, because the mount and the SPA fallback both sit inside a single dist.is_dir() evaluated once at create_app() time. The build now writes dist.new and swaps, so exposure is one rename instead of minutes, dist is untouched unless the build succeeds, and dist.old is a free instant rollback. Verified against the real toolchain in the install sandbox: the pinned vite accepts --outDir/--emptyOutDir, two builds leave dist plus a populated dist.old, and the tree stays clean. .gitignore gets both scratch names in this commit, not a later one: `dist/` on line 13 matches neither, and an unignored scratch dir would make every mid-build tree dirty -- which is itself something repo_blocks() can refuse an update over. The remaining window is the instant between the two renames, and a process killed inside it leaves a good dist.new and dist.old with no dist. That is what _adopt_orphaned_bundle() is for: at boot, put one back, preferring dist.new because its build had already succeeded. The SPA fallback now answers 503 with an explanation rather than an opaque 500, the failure message says what is actually true, and npm ci / OOM-kill / timeout failures are named separately because they look identical in the raw output and have different remedies. The preflight warns when a rebuild is coming, since nothing told the person reading the dashboard that it was the thing being replaced. --- .gitignore | 7 ++ condor/updates/components.py | 22 +++++ condor/updates/run.py | 6 +- condor/web/app.py | 51 +++++++++++- tests/test_update_frontend_build.py | 124 ++++++++++++++++++++++++++++ tests/test_update_restart_flow.py | 14 +++- utils/updater.py | 61 +++++++++++++- 7 files changed, 277 insertions(+), 8 deletions(-) create mode 100644 tests/test_update_frontend_build.py diff --git a/.gitignore b/.gitignore index 771026a86..559115945 100644 --- a/.gitignore +++ b/.gitignore @@ -226,3 +226,10 @@ frontend/.claude/ # one's commit — which is how 1529d04 came to carry a message describing work it does not # contain. Kept on disk, out of the index. GATEWAY_ISSUES.md + +# The dashboard is built into dist.new and swapped into place, keeping dist.old +# as a rollback (see build_frontend). `dist/` above does not match either name, +# and an unignored scratch directory would make every mid-build tree dirty -- +# which is itself something repo_blocks() can refuse an update over. +frontend/dist.new/ +frontend/dist.old/ diff --git a/condor/updates/components.py b/condor/updates/components.py index 1228464d5..32657db81 100644 --- a/condor/updates/components.py +++ b/condor/updates/components.py @@ -825,6 +825,28 @@ async def preflight(component_keys: list[str]) -> Preflight: if executor_warning is not None: warnings.append(executor_warning) if CONDOR in selected: + condor_status = statuses.get(CONDOR) + repo = condor_status.facets.get("repo") if condor_status else None + if repo is not None and not repo.up_to_date: + # frontend_needs_build() is consulted inside the run and never here, + # so nothing told the person reading the dashboard that the page + # they are on is the one about to be replaced. + if await updater.frontend_needs_build( + await updater.get_local_commit_full(_table()[CONDOR].repo_dir), + "", + ): + warnings.append( + Warning( + component=CONDOR, + code="frontend-will-rebuild", + message=( + "This update rebuilds the dashboard. The bundle is " + "built alongside the running one and swapped in, so " + "the page you are reading keeps working — but reload " + "it once the update finishes to pick up the new one." + ), + ) + ) stale_warning = _stale_fork_warning() if stale_warning is not None: warnings.append(stale_warning) diff --git a/condor/updates/run.py b/condor/updates/run.py index 429a5a7af..1075d3424 100644 --- a/condor/updates/run.py +++ b/condor/updates/run.py @@ -544,8 +544,10 @@ async def _update_condor(run: Run) -> bool: if not ok: await _fail( run, - "Code and deps are updated, but the dashboard would come " - "back on the previous bundle.", + "Code and deps are updated, but the dashboard could not be " + "rebuilt — so it is still serving the bundle it was " + "serving before, which is intact. The build writes to " + "frontend/dist.new and only swaps it in on success.", ) return False diff --git a/condor/web/app.py b/condor/web/app.py index d4709bb5d..e41d9c415 100644 --- a/condor/web/app.py +++ b/condor/web/app.py @@ -1,12 +1,13 @@ from __future__ import annotations +import logging import os from pathlib import Path from urllib.parse import urlparse from fastapi import FastAPI, HTTPException, Request from fastapi.middleware.cors import CORSMiddleware -from fastapi.responses import FileResponse +from fastapi.responses import FileResponse, PlainTextResponse from fastapi.staticfiles import StaticFiles from condor.telemetry.taps import web_tap @@ -62,6 +63,8 @@ def _build_cors_origins() -> list[str]: # The SPA shell and every unhashed file beside it: revalidate on each load. # Cheap — a 304 — and the only thing that guarantees a refresh is looking at the # build that is actually installed. +log = logging.getLogger(__name__) + _NO_CACHE = {"Cache-Control": "no-cache, must-revalidate"} @@ -81,6 +84,39 @@ def file_response(self, *args, **kwargs): # type: ignore[override] return response +def _adopt_orphaned_bundle(dist: Path) -> None: + """Put a bundle back at ``dist`` if the swap was interrupted mid-rename. + + ``build_frontend`` builds into ``dist.new`` and swaps: + ``mv dist dist.old && mv dist.new dist``. Killed between those two renames + — a reboot, an OOM kill, a restart — the tree has a perfectly good + ``dist.new`` and ``dist.old`` and no ``dist``. That matters more than the + window suggests, because the static mount *and* the SPA fallback both sit + inside a single ``dist.is_dir()`` evaluated once here at startup: with no + ``dist`` Condor comes up serving the API and a bare 404 at ``/``, and + nothing recovers it without a human. + + ``dist.new`` is preferred: the build that produced it had already succeeded, + so it is the newer of the two. + """ + if dist.is_dir(): + return + for candidate in (dist.with_name("dist.new"), dist.with_name("dist.old")): + if not candidate.is_dir(): + continue + try: + candidate.rename(dist) + except OSError: + log.warning("Could not adopt %s as the dashboard bundle", candidate) + continue + log.warning( + "Dashboard bundle was missing; adopted %s. A frontend build was " + "interrupted mid-swap.", + candidate.name, + ) + return + + def create_app() -> FastAPI: app = FastAPI(title="Condor Dashboard API", version="0.1.0") @@ -143,6 +179,7 @@ def create_app() -> FastAPI: # ── Serve built frontend (production) ── dist = Path(__file__).resolve().parent.parent.parent / "frontend" / "dist" + _adopt_orphaned_bundle(dist) if dist.is_dir(): index_html = dist / "index.html" dist_root = dist.resolve() @@ -189,6 +226,18 @@ async def serve_spa(request: Request, full_path: str): # stale *build*: a reader who refreshed the chat got a UI from # before the routine library existed — its "Browse all" gone and # `/routines` back in the nav — with no way to tell it was old. + if not index_html.exists(): + # The bundle is mid-swap, or a build left it absent. Starlette + # raises inside FileResponse on a missing path, which surfaces + # as an opaque 500 that nothing upstream can tell from a crash. + # 503 is what this actually is, and it says so. + return PlainTextResponse( + "The dashboard bundle is not in place — it is being " + "rebuilt, or a build failed. The API is unaffected. Retry " + "in a moment, or run `make restart` if it persists.", + status_code=503, + headers=_NO_CACHE, + ) return FileResponse(index_html, headers=_NO_CACHE) return app diff --git a/tests/test_update_frontend_build.py b/tests/test_update_frontend_build.py new file mode 100644 index 000000000..7fe6dc751 --- /dev/null +++ b/tests/test_update_frontend_build.py @@ -0,0 +1,124 @@ +"""The dashboard bundle is not destroyed to build its replacement (C1, C13). + +vite defaults to ``outDir: "dist"`` with ``emptyOutDir: true``, and +``vite.config.ts`` overrides neither — so the build emptied the directory +uvicorn was serving out of, for the whole length of the build. A reload in that +window returned 500 (a missing ``index.html`` raises inside ``FileResponse``, +which nothing upstream can tell from a crash), and a build that *failed* left +the install with no dashboard at all while the run said the previous bundle +would come back. +""" + +from __future__ import annotations + +import asyncio +from unittest.mock import AsyncMock, patch + +import pytest +from fastapi.testclient import TestClient + +from condor.web.app import _adopt_orphaned_bundle +from utils import updater + +# ── the build script ── + + +def _script() -> str: + """The script build_frontend hands to bash, with npm stubbed out.""" + captured = {} + + async def _fake_run(*args, **kwargs): + captured["script"] = args[2] + return 0, "" + + with ( + patch.object(updater, "_run_cmd", _fake_run), + patch.object(updater, "npm_deps_stale", AsyncMock(return_value=False)), + patch.object(updater.os.path, "isdir", lambda p: True), + ): + asyncio.run(updater.build_frontend("a", "b")) + return captured["script"] + + +def test_the_build_never_writes_into_the_directory_being_served(): + script = _script() + assert "--outDir dist.new" in script + # The bare form is what emptied dist in place. + assert "npm run build\n" not in script + assert script.count("npm run build") == 1 + + +def test_the_swap_keeps_the_previous_bundle_as_a_rollback(): + script = _script() + # Order matters: the old bundle is only moved aside once the build has won. + assert script.index("npm run build") < script.index("mv dist dist.old") + assert script.index("mv dist dist.old") < script.index("mv dist.new dist") + + +def test_a_stale_scratch_dir_is_cleared_before_building(): + """A dist.new left by an interrupted run must not be built on top of.""" + assert "rm -rf dist.new" in _script() + + +# ── failure messages that name the actual failure ── + + +@pytest.mark.parametrize( + "rc, expected", + [ + (137, "memory limit"), + (-9, "memory limit"), + (124, "timed out"), + (90, "npm ci` failed"), + ], +) +def test_the_common_build_failures_are_named(rc, expected): + message = updater._explain_build_failure(rc, "some output") + assert expected in message + # And each says the bundle survived, which is the thing the operator + # actually needs to know. + assert "not touched" in message + + +def test_an_unremarkable_failure_is_passed_through_unchanged(): + assert updater._explain_build_failure(1, "tsc error TS2304") == "tsc error TS2304" + + +# ── boot-time recovery from an interrupted swap ── + + +def test_an_interrupted_swap_is_adopted_at_boot(tmp_path): + (tmp_path / "dist.old").mkdir() + (tmp_path / "dist.old" / "index.html").write_text("old") + (tmp_path / "dist.new").mkdir() + (tmp_path / "dist.new" / "index.html").write_text("new") + + _adopt_orphaned_bundle(tmp_path / "dist") + + # dist.new won the build; it is the newer of the two. + assert (tmp_path / "dist" / "index.html").read_text() == "new" + + +def test_only_a_rollback_bundle_is_still_adopted(tmp_path): + (tmp_path / "dist.old").mkdir() + (tmp_path / "dist.old" / "index.html").write_text("old") + + _adopt_orphaned_bundle(tmp_path / "dist") + + assert (tmp_path / "dist" / "index.html").read_text() == "old" + + +def test_adoption_does_nothing_when_a_bundle_is_already_in_place(tmp_path): + (tmp_path / "dist").mkdir() + (tmp_path / "dist" / "index.html").write_text("live") + (tmp_path / "dist.old").mkdir() + (tmp_path / "dist.old" / "index.html").write_text("old") + + _adopt_orphaned_bundle(tmp_path / "dist") + + assert (tmp_path / "dist" / "index.html").read_text() == "live" + + +def test_adoption_is_a_no_op_with_nothing_to_adopt(tmp_path): + _adopt_orphaned_bundle(tmp_path / "dist") + assert not (tmp_path / "dist").exists() diff --git a/tests/test_update_restart_flow.py b/tests/test_update_restart_flow.py index 11f444a5a..264915164 100644 --- a/tests/test_update_restart_flow.py +++ b/tests/test_update_restart_flow.py @@ -227,13 +227,23 @@ def _build_script(**stale): def test_the_build_installs_first_when_the_deps_are_stale(): - assert "npm ci && npm run build" in _build_script(return_value=True) + script = _build_script(return_value=True) + assert "npm ci" in script + assert script.index("npm ci") < script.index("npm run build") def test_the_build_skips_the_install_when_the_deps_are_current(): script = _build_script(return_value=False) assert "npm ci" not in script - assert script.endswith("npm run build") + assert "npm run build" in script + + +def test_the_build_never_targets_the_directory_being_served(): + """Both paths build into the scratch dir and swap; neither empties dist.""" + for stale in (True, False): + script = _build_script(return_value=stale) + assert "--outDir dist.new" in script + assert script.rstrip().endswith("mv dist.new dist") # --------------------------------------------------------------------------- diff --git a/utils/updater.py b/utils/updater.py index 8a2de6e2f..f3583d57c 100644 --- a/utils/updater.py +++ b/utils/updater.py @@ -541,6 +541,43 @@ async def npm_deps_stale(old_commit: str = "", new_commit: str = "") -> bool: ) +def _explain_build_failure(rc: int, output: str) -> str: + """Turn a build exit code into something the operator can act on. + + Three failures are common, look identical in the raw output, and have + different remedies — so each gets named rather than all three arriving as + "Frontend build failed". + """ + base = output or "Frontend build failed (no output)" + if "__CONDOR_NPM_CI_FAILED__" in base or rc == 90: + return ( + base.replace("__CONDOR_NPM_CI_FAILED__", "").strip() + + "\n\n`npm ci` failed, and it removes node_modules before it " + "installs — so there is currently no dependency tree and the build " + "could not have run. Fix the network or free some disk, then " + "`cd frontend && npm ci`. The bundle on disk was not touched." + ) + # 137 is SIGKILL, which for a vite build is almost always the memory + # ceiling: WSL2 caps the VM by default and Docker Desktop caps it on macOS. + # The process gets no chance to say so, so the output is unhelpfully empty. + if rc in (137, -9): + return ( + base + "\n\nThe build was killed (signal 9), which for a bundler is " + "almost always the memory limit. On WSL2 raise `memory=` in " + "`.wslconfig`; on macOS raise Docker Desktop's memory. The bundle " + "on disk was not touched." + ) + if rc == 124: + return ( + base + "\n\nThe build timed out. On a checkout under /mnt/c on WSL2, or " + "on Docker Desktop for macOS, this step can legitimately take " + "several times longer than it does on Linux. Retrying is safe — " + "`npm ci` and the build are both idempotent, and the bundle on " + "disk was not touched." + ) + return base + + async def build_frontend( old_commit: str = "", new_commit: str = "" ) -> tuple[bool, str]: @@ -555,17 +592,35 @@ async def build_frontend( if not os.path.isdir(FRONTEND_DIR): return True, "No frontend directory; skipped." - install = "npm ci && " if await npm_deps_stale(old_commit, new_commit) else "" + stale = await npm_deps_stale(old_commit, new_commit) + # `npm ci` deletes node_modules before it installs, so a registry blip or a + # full disk part-way through leaves the install with no dependency tree at + # all -- and then the build cannot run either. Say which of the two failed, + # because the remedies are different. + install = ( + 'npm ci || { echo "__CONDOR_NPM_CI_FAILED__"; exit 90; }; ' if stale else "" + ) + # Build into a scratch directory and swap. vite's defaults are + # `outDir: "dist"` with `emptyOutDir: true`, so building in place emptied + # the directory uvicorn was serving out of and every request during the + # build returned an error -- and a build that then *failed* left the install + # with no dashboard at all, while the run reported that the previous bundle + # would come back. Exposure is now one rename, and dist.old is a free + # instant rollback. script = ( 'export NVM_DIR="$HOME/.nvm"; ' '[ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh"; ' - 'cd "$1" || exit 1; ' + install + "npm run build" + 'cd "$1" || exit 1; ' + install + "rm -rf dist.new && " + "npm run build -- --outDir dist.new --emptyOutDir && " + "rm -rf dist.old && " + "{ [ -d dist ] && mv dist dist.old || true; } && " + "mv dist.new dist" ) rc, output = await _run_cmd( "bash", "-c", script, "bash", FRONTEND_DIR, timeout=FRONTEND_BUILD_TIMEOUT ) if rc != 0: - return False, output or "Frontend build failed (no output)" + return False, _explain_build_failure(rc, output) return True, output From 2614ade6f394387595b289ad35af0eeaabd412ce Mon Sep 17 00:00:00 2001 From: david-hummingbot Date: Fri, 18 Sep 2026 11:42:31 +0800 Subject: [PATCH 11/43] Stop offering a restart that cannot work, and make a failed one readable Two defects that compounded: the flow offered a restart it had just finished arguing against, and if that restart died the reason was unrecoverable. The keyboard's condition was `run.state == "failed" or relaunch_pending()`, so any failure produced a Restart Now button -- including one whose own message says not to. The admin read "Code was pulled but dependencies failed. Fix it before restarting" with Restart Now directly beneath it, and pressing it booted the new code against the old venv. The `or` arm is gone; the condition that was always right is untouched. The second half is why every other failure here was hard to diagnose. The Makefile turns remain-on-exit on for the startup probe and back off once the boot is confirmed, and that is correct -- a crash three hours later must still take the session down, or `make status` would report a dead Condor as running. But it means an in-process restart hands over with the pane set to close, so a successor that fails to boot takes its own traceback with it: WITH remain-on-exit OFF (what `make run` leaves): session GONE WITH remain-on-exit ON: Pane is dead (status 1) -- readable So the same dance is repeated around the handover: on immediately before the exec, off again from main() once the successor is serving. The Makefile's own toggle is unchanged. And the exec itself is guarded. os.execv failing is the one way a restart ends with nothing running and nothing said -- the loop is gone and the pane is about to close, so a log line can vanish with it. The traceback now also goes to restart-failure.log in the runtime root, where the next boot and the operator can both find it. --- handlers/admin/update.py | 6 ++- main.py | 8 ++++ tests/test_update_restart_flow.py | 75 ++++++++++++++++++++++++++++++- utils/updater.py | 64 +++++++++++++++++++++++++- 4 files changed, 150 insertions(+), 3 deletions(-) diff --git a/handlers/admin/update.py b/handlers/admin/update.py index 234911e32..ff315623f 100644 --- a/handlers/admin/update.py +++ b/handlers/admin/update.py @@ -332,7 +332,11 @@ def _run_keyboard(run) -> InlineKeyboardMarkup | None: # Offered, never taken automatically: an in-process restart is an ``execv`` # that can race whatever started Condor into a second copy of it, so it is # only ever something the admin chooses (see :mod:`condor.updates.run`). - if run.state == "failed" or updates.relaunch_pending() is not None: + # Only when there is something to apply. The failed-run arm used to be here + # too, which put a Restart Now button directly beneath the engine's own + # "dependencies failed -- fix it before restarting": pressing it booted the + # new code against the old venv. + if updates.relaunch_pending() is not None: rows.append( [InlineKeyboardButton("Restart Now", callback_data="admin:update_restart")] ) diff --git a/main.py b/main.py index 0b2d3b1f8..4f06770f3 100644 --- a/main.py +++ b/main.py @@ -1255,6 +1255,14 @@ def _signal_handler(): for sig in (signal.SIGINT, signal.SIGTERM): loop.add_signal_handler(sig, _signal_handler) + # The boot got this far, so a later crash should take the tmux session with + # it again -- otherwise `make status` would report a dead Condor as running. + # exec_restart() turned this on so that a failed handover stayed readable; + # this is the other half, mirroring what the Makefile does after its probe. + from utils.updater import set_tmux_remain_on_exit + + set_tmux_remain_on_exit(False) + # Run uvicorn as a task web_task = asyncio.create_task(server.serve()) stop_task = asyncio.create_task(shutdown_event.wait()) diff --git a/tests/test_update_restart_flow.py b/tests/test_update_restart_flow.py index 264915164..ee0e16215 100644 --- a/tests/test_update_restart_flow.py +++ b/tests/test_update_restart_flow.py @@ -20,7 +20,7 @@ import asyncio import os import signal -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock, Mock, patch import pytest @@ -372,3 +372,76 @@ def test_hung_step_is_killed_rather_than_waited_on(): rc, output = asyncio.run(updater._run_cmd("bash", "-c", "sleep 30", timeout=0.5)) assert rc == 124 assert "Timed out" in output + + +# --------------------------------------------------------------------------- +# A restart is only offered when it can work (C2.1, C2.2) +# --------------------------------------------------------------------------- + + +def test_a_failed_run_is_not_offered_a_restart(): + """The button used to sit directly under "fix it before restarting". + + The keyboard's condition was ``run.state == "failed" or + relaunch_pending()``, so *any* failure produced a Restart Now -- including + one whose own message forbids restarting. Pressing it booted the new code + against the old venv. + """ + from handlers.admin import update as update_handler + + run = update_run.Run( + id="r1", started=0.0, actor={}, components=["condor"], state="failed" + ) + with patch.object(update_handler.updates, "relaunch_pending", lambda: None): + keyboard = update_handler._run_keyboard(run) + + labels = [b.text for row in keyboard.inline_keyboard for b in row] + assert "Restart Now" not in labels + assert labels == ["Back"] + + +def test_a_pending_relaunch_is_still_offered_a_restart(): + """The condition that was always right is untouched.""" + from handlers.admin import update as update_handler + + run = update_run.Run( + id="r1", started=0.0, actor={}, components=["condor"], state="succeeded" + ) + with patch.object( + update_handler.updates, "relaunch_pending", lambda: {"target_commit": "abc1234"} + ): + keyboard = update_handler._run_keyboard(run) + + labels = [b.text for row in keyboard.inline_keyboard for b in row] + assert "Restart Now" in labels + + +def test_a_failed_exec_leaves_a_record(tmp_path, monkeypatch): + """Otherwise the reason the restart died goes with the tmux pane.""" + monkeypatch.setenv("CONDOR_RUNTIME_ROOT", str(tmp_path)) + monkeypatch.setattr(updater, "set_tmux_remain_on_exit", lambda on: None) + monkeypatch.setattr( + updater.os, "execv", Mock(side_effect=OSError(8, "Exec format error")) + ) + + with pytest.raises(OSError): + updater.exec_restart() + + record = tmp_path / "restart-failure.log" + assert record.exists(), "a failed exec left nothing behind" + body = record.read_text() + assert "Exec format error" in body + assert "exec " in body + + +def test_the_pane_is_kept_alive_across_the_handover(tmp_path, monkeypatch): + """A successor that fails to boot must not close the pane with the traceback.""" + monkeypatch.setenv("CONDOR_RUNTIME_ROOT", str(tmp_path)) + calls = [] + monkeypatch.setattr(updater, "set_tmux_remain_on_exit", lambda on: calls.append(on)) + monkeypatch.setattr(updater.os, "execv", Mock(side_effect=OSError("nope"))) + + with pytest.raises(OSError): + updater.exec_restart() + + assert calls == [True], "remain-on-exit was not enabled before the exec" diff --git a/utils/updater.py b/utils/updater.py index f3583d57c..43a3bb8bb 100644 --- a/utils/updater.py +++ b/utils/updater.py @@ -19,6 +19,7 @@ import logging import os import signal +import subprocess import sys from dataclasses import dataclass @@ -652,6 +653,34 @@ def restart_pending() -> bool: return _restart_pending +def set_tmux_remain_on_exit(on: bool) -> None: + """Toggle ``remain-on-exit`` on Condor's own tmux session, best effort. + + The Makefile turns this on for the startup probe and back off once the boot + is confirmed, deliberately: a crash three hours later must still take the + session down, or ``make status`` would report a dead Condor as running. That + leaves an in-process restart with no such protection — the successor's own + startup failure closes the pane and takes the traceback with it. + + So the same dance is repeated around the exec: on just before, off again + once the new process has got far enough to serve. Silent when there is no + tmux, which is the ordinary case for a foreground or containerized run. + """ + session = os.environ.get("CONDOR_TMUX_SESSION", "condor") + value = "on" if on else "off" + for scope in ("set-option", "set-window-option"): + try: + result = subprocess.run( + ["tmux", scope, "-t", session, "remain-on-exit", value], + capture_output=True, + timeout=5, + ) + except (OSError, subprocess.SubprocessError): + return + if result.returncode == 0: + return + + def exec_restart() -> None: """Replace this process with a fresh one. Never returns. @@ -668,7 +697,40 @@ def exec_restart() -> None: pass # sys.argv[0] is usually the relative "main.py"; make sure it still resolves. os.chdir(CONDOR_DIR) - os.execv(python, [python] + sys.argv) + # Keep the pane alive across the handover so the successor's own startup + # failure is readable. main() turns it back off once it is serving. + set_tmux_remain_on_exit(True) + try: + os.execv(python, [python] + sys.argv) + except OSError: + # A failed exec is the one way a restart can end with nothing running + # and nothing said. The process image is still this one, but the event + # loop is gone and the tmux pane is about to close, so a log line alone + # can vanish with it -- hence a file as well, in the runtime root where + # the next boot (and the operator) can find it. + logger.exception("exec_restart failed; Condor is not coming back") + _record_restart_failure() + raise + + +def _record_restart_failure() -> None: + """Leave the reason a restart died somewhere it will survive the pane.""" + import traceback + from datetime import datetime, timezone + + try: + from condor.paths import runtime_root + + path = runtime_root() / "restart-failure.log" + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text( + f"{datetime.now(timezone.utc).isoformat()}\n" + f"exec {sys.executable} {' '.join(sys.argv)}\n\n" + f"{traceback.format_exc()}", + encoding="utf-8", + ) + except Exception: # noqa: BLE001 - never mask the original failure + logger.debug("Could not write restart-failure.log", exc_info=True) # --------------------------------------------------------------------------- From 27d38e22ea9cf3bc81e1ff05a7c1660d688687a5 Mon Sep 17 00:00:00 2001 From: david-hummingbot Date: Fri, 18 Sep 2026 11:45:59 +0800 Subject: [PATCH 12/43] Apply an update by restarting, instead of asking for a shell A Local-mode admin could discover, preflight, resolve conflicts and apply an update entirely in the browser, and then had to leave the browser for any of it to take effect: the panel's advice was to run `make restart`. Telegram had a Restart Now button the whole time. The asymmetry was explained, in both the engine and the banner, as a safety trade -- that re-execing races whatever started Condor into a second copy on the same port. The code does not do that. request_restart() raises SIGTERM rather than exec'ing, so teardown() runs and main() execs only once the loop is gone; the exec then replaces the process image in place, keeping the pid, the parent and the controlling terminal. Nothing exits, so nothing can observe a child dying and start a replacement. The listening socket is non-inheritable (PEP 446) and released by the exec -- measured three exec generations deep, rebinding to the same port each time with the pid unchanged: gen1: bound :18099 pid=45185 inheritable=False gen2: bound :18099 pid=45185 inheritable=False gen3: bound :18099 pid=45185 inheritable=False So the browser now does what a router does when its firmware updates: counts down, restarts, and comes back on its own. The countdown is cancellable because two admins may be watching and one may want to pick the moment, and a cancel leaves the old banner and a manual button. A failed run never triggers it -- that is the mistake the previous commit removed from the other surface. The reload at the end is not optional: the build swaps in a bundle with new hashed asset names, so a browser still holding the old index.html would be a different build from the API answering it. Reconnection is a plain fetch loop rather than the app's socket, because it has to work before any of the app's channels are back. The POST goes through condor.updates.request_relaunch() rather than importing utils directly -- the routes are a view over the engine, and a test pins that. The 202 may never arrive, since the process handling it is the one being replaced; the client treats that as "probably restarting" and lets the reconnect decide. --- condor/updates/__init__.py | 2 + condor/updates/run.py | 15 ++ condor/web/routes/updates.py | 39 +++- frontend/src/components/RelaunchBanner.tsx | 215 ++++++++++++++---- .../components/settings/UpdatesSettings.tsx | 20 +- frontend/src/lib/updates-api.ts | 14 ++ tests/test_web_updates_routes.py | 30 +++ 7 files changed, 282 insertions(+), 53 deletions(-) diff --git a/condor/updates/__init__.py b/condor/updates/__init__.py index c1c806661..7d6d0166d 100644 --- a/condor/updates/__init__.py +++ b/condor/updates/__init__.py @@ -41,6 +41,7 @@ read_journal, register_observer, relaunch_pending, + request_relaunch, resolve, start, tail, @@ -69,6 +70,7 @@ "read_journal", "relaunch_pending", "repo_blocks", + "request_relaunch", "register_observer", "resolve", "start", diff --git a/condor/updates/run.py b/condor/updates/run.py index 1075d3424..41d6729b2 100644 --- a/condor/updates/run.py +++ b/condor/updates/run.py @@ -248,6 +248,21 @@ async def _emit(run: Run) -> None: _relaunch: dict[str, Any] | None = None +def request_relaunch() -> None: + """Apply a pending update by restarting Condor in place. + + A one-line pass-through to :func:`utils.updater.request_restart`, and it + earns its place: the surfaces are views over this package and do not import + ``utils`` directly, so without it the web route would have to reach past the + engine to do the one thing the engine exists to coordinate. + + Not an exec. ``request_restart`` raises SIGTERM, ``teardown()`` runs, and + ``main()`` execs once the loop is gone -- so persistence is flushed, agent + loops record their final state and ACP/MCP children are not orphaned. + """ + updater.request_restart() + + def relaunch_pending() -> dict[str, Any] | None: """What this process is missing, or ``None`` if it is running the update. diff --git a/condor/web/routes/updates.py b/condor/web/routes/updates.py index 45fbbb2c2..e34f55d8a 100644 --- a/condor/web/routes/updates.py +++ b/condor/web/routes/updates.py @@ -26,7 +26,7 @@ import logging -from fastapi import APIRouter, Depends +from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel, Field from condor import updates @@ -152,3 +152,40 @@ async def dismiss_run( require_admin(user) run = updates.acknowledge_run(body.run_id if body else "") return {"run": run.to_wire() if run is not None else None} + + +@router.post("/relaunch", status_code=202) +async def post_relaunch(user: WebUser = Depends(get_current_user)) -> dict: + """Apply a finished update by restarting Condor in place. + + The Local surface could discover, preflight, resolve and apply an update + entirely in the browser, and then had to tell the operator to go and run + ``make restart`` — the one step that makes any of it take effect. Telegram + has had a button for this all along, and it makes exactly this call. + + The asymmetry used to be explained as a safety trade: that re-execing races + whatever started Condor into a second copy on the same port. That is not + what the code does. ``request_restart()`` raises SIGTERM rather than + exec'ing, so ``teardown()`` runs and ``main()`` execs only once the loop is + gone; the exec then replaces the process image in place, keeping the pid, + the parent and the controlling terminal, so nothing exits and there is no + second copy. The listening socket is non-inheritable (PEP 446) and released + by the exec, so the successor rebinds cleanly. + + 202, not 200: the answer is "accepted", and the process serving it is about + to be replaced. The panel already knows how to poll across that — it is what + ``GET /updates/run`` was built for. + """ + require_admin(user) + pending = updates.relaunch_pending() + if pending is None: + raise HTTPException( + status_code=409, + detail="Nothing to apply — this process is already running the code on disk.", + ) + log.info("Relaunch requested from the dashboard by user %s", user.id) + updates.request_relaunch() + return { + "relaunching": True, + "target_commit": (pending.get("target_commit") or "")[:7], + } diff --git a/frontend/src/components/RelaunchBanner.tsx b/frontend/src/components/RelaunchBanner.tsx index 02fae749c..05c2fe786 100644 --- a/frontend/src/components/RelaunchBanner.tsx +++ b/frontend/src/components/RelaunchBanner.tsx @@ -1,69 +1,202 @@ import { RotateCw } from "lucide-react"; -import { useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; -import { useReloadAfterRelaunch } from "@/hooks/useRelaunch"; +import { useRelaunch } from "@/hooks/useRelaunch"; +import { updatesApi } from "@/lib/updates-api"; /** - * "You updated; the running Condor is still the old one." + * "You updated; apply it" — and then it applies it. * - * An update deliberately stops short of exec'ing the server — Condor is rarely - * the top of its own process tree, and re-execing races whatever started it - * into a second copy on the same port (`condor/updates/run.py`). What that - * trade buys is safety; what it costs is a window where the dashboard bundle in - * an open tab is older than the updated files on disk. This strip makes that - * window legible instead of just weird, so it rides above every page rather - * than living in Settings where only the person who ran the update would see it. + * This used to say "run `make restart`", on the reasoning that re-execing races + * whatever started Condor into a second copy on the same port. That is not what + * the code does. `request_restart()` raises SIGTERM rather than exec'ing, so + * `teardown()` runs and `main()` execs only once the loop is gone; the exec then + * replaces the process image in place, keeping the pid, the parent and the + * controlling terminal. Nothing exits, so there is no second copy, and the + * listening socket is non-inheritable (PEP 446) and released by the exec. + * Telegram has had a button for this all along. * - * There is no dismiss. The only thing that clears it is the relaunch, and the - * poll notices that on its own — a dismissed banner would just be a mismatch - * nobody is told about any more. + * So the browser does what a router does when its firmware updates: counts + * down, restarts, and comes back on its own. The countdown is cancellable + * because two admins may be watching and one of them may want to pick the + * moment. + * + * The reload at the end is not optional. The build swaps in a new bundle with + * new hashed asset names, so a browser holding the old index.html would be + * running a different build from the API answering it. */ + +/** Long enough to read the sentence and hit Cancel; short enough not to nag. */ +const COUNTDOWN_SECONDS = 5; + +/** How hard to try before admitting the server is not coming back. */ +const RECONNECT_TIMEOUT_MS = 90_000; +const RECONNECT_INTERVAL_MS = 1_000; + +type Phase = "idle" | "counting" | "restarting" | "cancelled" | "stuck"; + export function RelaunchBanner() { - const { data } = useReloadAfterRelaunch(); + const { data } = useRelaunch(); + const [phase, setPhase] = useState("idle"); + const [left, setLeft] = useState(COUNTDOWN_SECONDS); const [showHow, setShowHow] = useState(false); + const started = useRef(false); + + const required = data?.required ?? false; + + // Wait for the server to answer again, then reload once to pick up the new + // bundle. Polled rather than pushed: the socket is down for the whole window, + // and this needs to work before any of the app's own channels are back. + const waitForServer = useCallback(() => { + const deadline = Date.now() + RECONNECT_TIMEOUT_MS; + const tick = async () => { + try { + const res = await fetch("/api/v1/meta/relaunch", { cache: "no-store" }); + if (res.ok) { + window.location.reload(); + return; + } + } catch { + // Expected for most of the window — the server is not listening yet. + } + if (Date.now() > deadline) { + setPhase("stuck"); + return; + } + window.setTimeout(tick, RECONNECT_INTERVAL_MS); + }; + window.setTimeout(tick, RECONNECT_INTERVAL_MS); + }, []); + + const relaunchNow = useCallback(async () => { + setPhase("restarting"); + try { + await updatesApi.relaunch(); + } catch { + // The request is *expected* not to answer: the process handling it tears + // down and execs, so the socket usually closes first. A failure here says + // nothing about whether the restart is happening — the reconnect decides. + } + waitForServer(); + }, [waitForServer]); - if (!data?.required) return null; + // Start the countdown once, the first time a relaunch becomes required. + useEffect(() => { + if (!required || started.current) return; + started.current = true; + setPhase("counting"); + }, [required]); + + useEffect(() => { + if (phase !== "counting") return; + if (left <= 0) { + void relaunchNow(); + return; + } + const id = window.setTimeout(() => setLeft((n) => n - 1), 1000); + return () => window.clearTimeout(id); + }, [phase, left, relaunchNow]); + + if (!required) return null; const moved = - data.from_commit && data.target_commit + data?.from_commit && data?.target_commit ? `${data.from_commit} → ${data.target_commit}` - : (data.target_commit ?? ""); + : (data?.target_commit ?? ""); return (
- +

- Condor has been updated.{" "} - - Relaunch it to apply the changes — it is still running the code it - booted with - {moved ? ( - <> - {" "} - ({moved}) - - ) : null} - . - + {phase === "counting" && ( + <> + Condor has been updated.{" "} + + Restarting in {left}s to apply it + {moved ? ( + <> + {" "} + ({moved}) + + ) : null} + . Bots and open positions are untouched. + + + )} + {phase === "restarting" && ( + <> + Restarting Condor…{" "} + + This page will reconnect and reload on its own. + + + )} + {phase === "stuck" && ( + <> + Condor has not come back.{" "} + + Check the terminal it was started from — if the restart failed, the + reason is in .condor/restart-failure.log. + + + )} + {phase === "cancelled" && ( + <> + Condor has been updated.{" "} + + It is still running the code it booted with + {moved ? ( + <> + {" "} + ({moved}) + + ) : null} + . + + + )}

- + {phase === "counting" && ( + + )} + + {(phase === "cancelled" || phase === "stuck") && ( + + )} + + {phase === "cancelled" && ( + + )} - {showHow && ( + {showHow && phase === "cancelled" && (

- Run{" "} + Restart now restarts Condor in place. You can also run{" "} make restart {" "} - from the Condor directory, or stop and start it however you launched - it. Bots and open positions are untouched; continuous routines and - agent loops are restored on the way back up. + from the Condor directory. Bots and open positions are untouched; + continuous routines and agent loops are restored on the way back up.

)}
diff --git a/frontend/src/components/settings/UpdatesSettings.tsx b/frontend/src/components/settings/UpdatesSettings.tsx index 4ac8a090f..ebf645edf 100644 --- a/frontend/src/components/settings/UpdatesSettings.tsx +++ b/frontend/src/components/settings/UpdatesSettings.tsx @@ -556,22 +556,20 @@ function FinishedView({ )} - {/* The one step the engine deliberately leaves undone. Spelled out here - rather than left to the banner alone: this is the screen the person - who just pressed Update is looking at. */} + {/* The banner above handles the restart itself, including the countdown + and the cancel. This says the same thing on the screen the person who + just pressed Update is actually looking at, without a second set of + controls competing with it. */} {relaunch?.required && (
Condor is still running{" "} - {relaunch.from_commit}. Relaunch - it —{" "} - - make restart - {" "} - — to come up on{" "} - {relaunch.target_commit}. Bots and - open positions are untouched. + {relaunch.from_commit}. It is + restarting to come up on{" "} + {relaunch.target_commit} — see the + banner at the top of the page, which counts down and can be + cancelled. Bots and open positions are untouched.
)} diff --git a/frontend/src/lib/updates-api.ts b/frontend/src/lib/updates-api.ts index 8330a5189..ff3e83f72 100644 --- a/frontend/src/lib/updates-api.ts +++ b/frontend/src/lib/updates-api.ts @@ -148,6 +148,20 @@ export const updatesApi = { body: JSON.stringify({ run_id: runId }), }), + /** + * Apply a finished update by restarting Condor in place. + * + * The request is expected not to answer: the process handling it tears down + * and execs. 202 if it does answer, a network error if the socket closes + * first — both mean the same thing, so callers treat a failure here as + * "probably restarting" and let the reconnect decide. + */ + relaunch: () => + adminFetch<{ relaunching: boolean; target_commit: string }>( + "/api/v1/updates/relaunch", + { method: "POST" }, + ), + /** Trigger the update. Answers immediately with the run to watch. */ start: (components: string[]) => adminFetch<{ run_id: string; state: RunState }>("/api/v1/updates/start", { diff --git a/tests/test_web_updates_routes.py b/tests/test_web_updates_routes.py index 2ba0fee46..dcc3f691f 100644 --- a/tests/test_web_updates_routes.py +++ b/tests/test_web_updates_routes.py @@ -10,6 +10,7 @@ """ import asyncio +from unittest.mock import patch import pytest from starlette.testclient import TestClient @@ -413,3 +414,32 @@ def test_routes_module_shells_out_to_nothing(): # The one thing it does import for update knowledge is the engine. assert "condor" in imported + + +# --------------------------------------------------------------------------- +# Relaunch: the verb the Local surface was missing (C3) +# --------------------------------------------------------------------------- + + +def test_relaunch_restarts_when_something_is_pending(as_user, admin): + """Telegram has always had this button; the dashboard now makes the same call.""" + called = [] + with patch.object( + updates, "relaunch_pending", lambda: {"target_commit": "abc1234"} + ): + with patch.object(updates, "request_relaunch", lambda: called.append(True)): + response = as_user(admin, "admin").post("/api/v1/updates/relaunch") + + assert response.status_code == 202 + assert response.json()["relaunching"] is True + assert called == [True], "the restart was never requested" + + +def test_relaunch_refuses_when_there_is_nothing_to_apply(as_user, admin): + called = [] + with patch.object(updates, "relaunch_pending", lambda: None): + with patch.object(updates, "request_relaunch", lambda: called.append(True)): + response = as_user(admin, "admin").post("/api/v1/updates/relaunch") + + assert response.status_code == 409 + assert called == [], "a restart was requested with nothing pending" From 69619f3bc0e5a31d57f59a4a09c619759921c8e7 Mon Sep 17 00:00:00 2001 From: david-hummingbot Date: Fri, 18 Sep 2026 11:49:49 +0800 Subject: [PATCH 13/43] Offer a way out of a conflict that keeps the work A hand-edited playbook was a dead end. FEAT-115 redirects writes made through the product into the gitignored local root, but a text editor knows nothing about that, and these are markdown files sitting in a git checkout -- exactly what people open in one. The edit raised dirty-conflict, and both escapes lost something: before discard: # Scout v1 + TWO HOURS OF MY WORK resolve('condor','discard') -> True | "Restored 1 tracked file(s)." after discard: # Scout v1 A destructive act reported in the vocabulary of a repair. The other escape, stash, kept the work but behind a shell command the Local user was never asked to open. keep-mine moves the edit into .condor/agents -- where FEAT-115 would have put it had the write gone through the product -- and resets the tracked file to HEAD. The customization survives and keeps being used, the shipped copy fast-forwards normally, and the install ends up in the state the design supports instead of a dead end. Offered only when every conflicting path is forkable. On a mixed set it would read as "keep all of this" while quietly resetting the paths it could not move, and a path with no local layer -- main.py, a compose file, anything in hummingbot-api -- would be "kept" somewhere nothing reads, which is a quieter way of losing it than discarding. The check is repeated in resolve() rather than trusted from the button, since that screen may be minutes old. The stash escape stops being one-way as well. The whole point of parking work rather than discarding it is getting it back, and a clean pop is the common case once the fast-forward has landed, so the run now pops it and says what happened. The original caution was right about the *failure* -- a conflicting pop leaves a half-merged tree plus a stash entry nobody was told about -- so a conflict warns instead of failing the update, and says where the work still is. A stash we did not make is left alone. That needs a step state for "ran, did not fail the update, but read this": WARNED, taught to both renderers. The discard message now names what was lost. --- condor/updates/components.py | 34 +++- condor/updates/run.py | 26 ++++ .../components/settings/UpdatesSettings.tsx | 40 ++++- frontend/src/lib/updates-api.ts | 9 +- handlers/admin/update.py | 2 + tests/test_conflict_resolutions.py | 147 ++++++++++++++++++ tests/test_update_journal.py | 1 + utils/updater.py | 82 +++++++++- 8 files changed, 330 insertions(+), 11 deletions(-) create mode 100644 tests/test_conflict_resolutions.py diff --git a/condor/updates/components.py b/condor/updates/components.py index 32657db81..e8e3b7ccb 100644 --- a/condor/updates/components.py +++ b/condor/updates/components.py @@ -24,6 +24,7 @@ import os import time from dataclasses import asdict, dataclass, field +from pathlib import PurePosixPath from typing import Any from utils import updater @@ -525,6 +526,35 @@ async def running_executor_count() -> int | None: # ── Preflight ── +def is_forkable(component_key: str, rel_path: str) -> bool: + """Whether a conflicting path can be kept by moving it to the local root. + + Only Condor's shipped agent library. ``agents//...`` has a local + counterpart that reads shadow per item, so moving an edit there is exactly + what the product would have done with it. Anything else -- ``main.py``, a + compose file, anything in hummingbot-api -- has no such layer, and "keeping" + it by moving it somewhere nothing reads would be a quieter way of losing it + than discarding. + """ + if component_key != CONDOR: + return False + parts = PurePosixPath(rel_path).parts + return len(parts) > 2 and parts[0] == "agents" and not parts[1].startswith("_") + + +def _resolutions_for(component_key: str, paths: list[str]) -> list[str]: + """The ways out of a conflict, with the non-lossy one offered where it works. + + ``keep-mine`` appears only when every conflicting path is forkable. Offering + it on a mixed set would be worse than not offering it: it reads as "keep all + of this", and the paths it could not move would be reset anyway. + """ + base = ["discard", "stash", "cancel"] + if paths and all(is_forkable(component_key, p) for p in paths): + return ["keep-mine", *base] + return base + + def _incoming_unknown(component: Component, branch: str, because: str) -> Block: """The block for "we could not work out what this update would bring in". @@ -648,7 +678,7 @@ async def repo_blocks(component: Component) -> list[Block]: "commits." ), paths=tracked, - resolutions=["discard", "stash", "cancel"], + resolutions=_resolutions_for(component.key, tracked), ) ) if untracked: @@ -661,7 +691,7 @@ async def repo_blocks(component: Component) -> list[Block]: "not in git would be overwritten by the incoming commits." ), paths=untracked, - resolutions=["discard", "stash", "cancel"], + resolutions=_resolutions_for(component.key, untracked), ) ) return blocks diff --git a/condor/updates/run.py b/condor/updates/run.py index 41d6729b2..998de516b 100644 --- a/condor/updates/run.py +++ b/condor/updates/run.py @@ -50,6 +50,11 @@ PENDING = "pending" OK = "ok" SKIPPED = "skipped" +# Ran, did not fail the update, but the operator needs to read the output -- +# a stash that would not pop, a doctor check that came back unhappy. Distinct +# from OK because "the update succeeded" must not swallow "and here is what is +# still wrong", and distinct from FAILED because neither aborts the run. +WARNED = "warned" # Command output kept per step. Enough to diagnose, far under Telegram's limit. OUTPUT_TAIL_CHARS = 2000 @@ -318,6 +323,13 @@ async def resolve(component_key: str, action: str) -> tuple[bool, str]: ok, message = await updater.discard_paths(component.repo_dir, conflicting) elif action == "stash": ok, message = await updater.stash_paths(component.repo_dir, conflicting) + elif action == "keep-mine": + # Re-checked here rather than trusted from the button: the block that + # offered this may be minutes old, and moving a path that is not + # forkable would put it somewhere nothing reads. + if not all(components.is_forkable(component_key, p) for p in conflicting): + return False, "Those files cannot be kept this way." + ok, message = await updater.move_to_local_root(component.repo_dir, conflicting) else: return False, f"Unknown resolution: {action}" @@ -356,6 +368,7 @@ def _plan(component_keys: list[str], statuses: dict[str, components.ComponentSta # No restart step: the run stops with the new code on disk and asks # for the relaunch instead (see the module docstring). steps.append(Step(f"{key}.fast-forward", "Fast-forwarding Condor")) + steps.append(Step(f"{key}.unstash", "Restoring stashed work")) steps.append(Step(f"{key}.deps", "Syncing dependencies")) steps.append(Step(f"{key}.frontend", "Rebuilding the dashboard")) return steps @@ -533,6 +546,19 @@ async def _update_condor(run: Run) -> bool: await _fail(run, "Condor could not be fast-forwarded.") return False + # Work parked by the `stash` resolution goes back now that the fast-forward + # has landed. Stashing was never meant to be where it ended -- the point of + # parking work rather than discarding it is getting it back, and the common + # case applies cleanly. A conflicting pop does not fail the update: the + # stash is intact either way, and the step says so. + step = await _begin(run, f"{prefix}.unstash") + if step is not None: + popped, output = await updater.stash_pop(component.repo_dir) + if "Nothing of ours" in output: + await _finish(run, step, SKIPPED, output) + else: + await _finish(run, step, OK if popped else WARNED, output) + after = await updater.get_local_commit_full(component.repo_dir) run.target_commit = after diff --git a/frontend/src/components/settings/UpdatesSettings.tsx b/frontend/src/components/settings/UpdatesSettings.tsx index ebf645edf..acfd102cd 100644 --- a/frontend/src/components/settings/UpdatesSettings.tsx +++ b/frontend/src/components/settings/UpdatesSettings.tsx @@ -182,8 +182,18 @@ export function UpdatesSettings() { onClose={() => setConfirmDiscard(null)} >

- These files will be reset to the last commit. The changes cannot be - recovered — Stash keeps them instead. + Your changes to these files will be thrown away and the files reset + to the last commit. This cannot be undone.{" "} + {confirmDiscard?.resolutions.includes("keep-mine") ? ( + <> + Keep my version moves them somewhere updates + leave alone; Stash parks them in git. + + ) : ( + <> + Stash parks them in git instead. + + )}

    {confirmDiscard?.paths.map((p) =>
  • {p}
  • )} @@ -343,6 +353,19 @@ function ComponentCard({ } /** The plan, what it will cost, and anything standing in the way. */ +/** + * What each resolution is actually called on screen. + * + * "keep-mine" reads as jargon and is the one an operator most wants to find, so + * it says what it does. The others keep their plain names. + */ +const RESOLUTION_LABELS: Record = { + "keep-mine": "Keep my version", + discard: "Discard", + stash: "Stash", + cancel: "Cancel", +}; + function PreflightView({ preflight, isStarting, @@ -409,13 +432,16 @@ function PreflightView({ key={action} onClick={() => onResolve(block, action)} disabled={isResolving} - className={`rounded-md px-3 py-1.5 text-xs font-medium capitalize transition-colors disabled:opacity-50 ${ + className={`rounded-md px-3 py-1.5 text-xs font-medium transition-colors disabled:opacity-50 ${ action === "discard" ? "bg-[var(--color-red)]/15 text-[var(--color-red)] hover:opacity-80" - : "border border-[var(--color-border)] text-[var(--color-text-muted)] hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text)]" + : action === "keep-mine" + ? // The only non-lossy way out, so it leads. + "bg-[var(--color-green)]/15 text-[var(--color-green)] hover:opacity-80" + : "border border-[var(--color-border)] text-[var(--color-text-muted)] hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text)]" }`} > - {action} + {RESOLUTION_LABELS[action] ?? action} ))} @@ -491,7 +517,7 @@ function StepRow({ step }: { step: Step }) { {step.label} - {step.state === "failed" && step.output_tail && ( + {(step.state === "failed" || step.state === "warned") && step.output_tail && (
    Output @@ -512,6 +538,8 @@ function StepIcon({ state }: { state: Step["state"] }) { if (state === "ok") return ; if (state === "failed") return ; + if (state === "warned") + return ; if (state === "skipped") return ; return ; diff --git a/frontend/src/lib/updates-api.ts b/frontend/src/lib/updates-api.ts index ff3e83f72..a89fa0760 100644 --- a/frontend/src/lib/updates-api.ts +++ b/frontend/src/lib/updates-api.ts @@ -67,7 +67,14 @@ export interface Preflight { ok: boolean; } -export type StepState = "pending" | "running" | "ok" | "failed" | "skipped"; +export type StepState = + | "pending" + | "running" + | "ok" + /** Ran, did not fail the update, but the output is worth reading. */ + | "warned" + | "failed" + | "skipped"; export interface Step { key: string; diff --git a/handlers/admin/update.py b/handlers/admin/update.py index ff315623f..2b2745594 100644 --- a/handlers/admin/update.py +++ b/handlers/admin/update.py @@ -33,6 +33,8 @@ "ok": "✓", "failed": "✗", "skipped": "–", + # Ran, did not fail the update, but the output is worth reading. + "warned": "!", } diff --git a/tests/test_conflict_resolutions.py b/tests/test_conflict_resolutions.py new file mode 100644 index 000000000..1d15f7cf6 --- /dev/null +++ b/tests/test_conflict_resolutions.py @@ -0,0 +1,147 @@ +"""A hand-edited playbook no longer has to be destroyed to update (C12, S2). + +FEAT-115 redirects writes made *through the product* into the gitignored local +root. It does nothing about a text editor — and these are markdown files sitting +in a git checkout, which is exactly what people open in one. Such an edit blocks +the update with a `dirty-conflict`, and both escapes lost something: `discard` +destroyed the work outright (while reporting it as a *restoration*), and `stash` +parked it behind a shell command the Local user was never asked to open. +""" + +from __future__ import annotations + +import subprocess + +import pytest + +from condor.updates import components +from utils import updater + +ENV = { + "GIT_AUTHOR_NAME": "t", + "GIT_AUTHOR_EMAIL": "t@t", + "GIT_COMMITTER_NAME": "t", + "GIT_COMMITTER_EMAIL": "t@t", + "PATH": "/usr/bin:/bin", +} + + +def _git(*args, cwd): + subprocess.run(["git", *args], cwd=cwd, check=True, capture_output=True, env=ENV) + + +@pytest.fixture +def repo(tmp_path, monkeypatch): + d = tmp_path / "repo" + (d / "agents" / "scout").mkdir(parents=True) + (d / "agents" / "scout" / "AGENT.md").write_text("# Scout v1\nNever on Sundays.\n") + _git("init", "-q", "-b", "main", cwd=d) + _git("add", "-A", cwd=d) + _git("commit", "-qm", "v1", cwd=d) + monkeypatch.setenv("CONDOR_AGENTS_ROOT", str(tmp_path / "local")) + return d + + +# ── which conflicts can be kept ── + + +def test_only_agent_content_can_be_kept(): + keep = components.is_forkable + assert keep(components.CONDOR, "agents/scout/AGENT.md") is True + assert keep(components.CONDOR, "agents/scout/skills/recon/SKILL.md") is True + # No local layer, so "keeping" it would be a quieter way of losing it. + assert keep(components.CONDOR, "main.py") is False + assert keep(components.CONDOR, "agents/_shared/skills/x/SKILL.md") is False + assert keep(components.HUMMINGBOT_API, "agents/scout/AGENT.md") is False + + +def test_keep_mine_is_offered_only_when_every_path_can_take_it(): + """A mixed set would read as "keep all of this" and quietly reset the rest.""" + all_agents = components._resolutions_for( + components.CONDOR, ["agents/scout/AGENT.md", "agents/keeper/AGENT.md"] + ) + assert all_agents[0] == "keep-mine" + + mixed = components._resolutions_for( + components.CONDOR, ["agents/scout/AGENT.md", "main.py"] + ) + assert "keep-mine" not in mixed + assert mixed == ["discard", "stash", "cancel"] + + +# ── keeping it ── + + +@pytest.mark.asyncio +async def test_keep_mine_preserves_the_edit_and_frees_the_update(repo, tmp_path): + edited = repo / "agents" / "scout" / "AGENT.md" + edited.write_text("# Scout v1 + TWO HOURS OF MY WORK\n") + + ok, message = await updater.move_to_local_root(str(repo), ["agents/scout/AGENT.md"]) + assert ok, message + + # The work survives, where the product would have put it. + kept = tmp_path / "local" / "scout" / "AGENT.md" + assert kept.read_text() == "# Scout v1 + TWO HOURS OF MY WORK\n" + + # The tracked file is back to HEAD, so the fast-forward is unobstructed. + assert "TWO HOURS" not in edited.read_text() + status = subprocess.run( + ["git", "status", "--short"], cwd=repo, capture_output=True, text=True + ) + assert status.stdout.strip() == "" + + +@pytest.mark.asyncio +async def test_the_discard_message_says_what_was_lost(repo): + """It used to report a destructive act as "Restored 1 tracked file(s)".""" + (repo / "agents" / "scout" / "AGENT.md").write_text("my work\n") + + ok, message = await updater.discard_paths(str(repo), ["agents/scout/AGENT.md"]) + assert ok + assert "Discarded" in message + assert "Restored" not in message + + +# ── and getting stashed work back ── + + +@pytest.mark.asyncio +async def test_a_clean_stash_pops_itself(repo): + (repo / "agents" / "scout" / "AGENT.md").write_text("my work\n") + ok, _ = await updater.stash_paths(str(repo), ["agents/scout/AGENT.md"]) + assert ok + assert "my work" not in (repo / "agents" / "scout" / "AGENT.md").read_text() + + ok, message = await updater.stash_pop(str(repo)) + assert ok, message + assert (repo / "agents" / "scout" / "AGENT.md").read_text() == "my work\n" + + +@pytest.mark.asyncio +async def test_popping_nothing_is_not_an_error(repo): + ok, message = await updater.stash_pop(str(repo)) + assert ok + assert "Nothing of ours" in message + + +@pytest.mark.asyncio +async def test_a_stash_we_did_not_make_is_left_alone(repo): + """Someone else's stash is not ours to pop.""" + (repo / "agents" / "scout" / "AGENT.md").write_text("their work\n") + subprocess.run( + ["git", "stash", "push", "-m", "someone else's"], + cwd=repo, + check=True, + capture_output=True, + env=ENV, + ) + + ok, message = await updater.stash_pop(str(repo)) + assert ok + assert "Nothing of ours" in message + # Still there, untouched. + listing = subprocess.run( + ["git", "stash", "list"], cwd=repo, capture_output=True, text=True + ) + assert "someone else's" in listing.stdout diff --git a/tests/test_update_journal.py b/tests/test_update_journal.py index c689d52e7..62194b5af 100644 --- a/tests/test_update_journal.py +++ b/tests/test_update_journal.py @@ -328,6 +328,7 @@ def test_the_plan_is_laid_out_before_anything_runs(_a_sibling_api): "hummingbot-api.up", "hummingbot-api.health", "condor.fast-forward", + "condor.unstash", "condor.deps", "condor.frontend", ] diff --git a/utils/updater.py b/utils/updater.py index 43a3bb8bb..ceec11c16 100644 --- a/utils/updater.py +++ b/utils/updater.py @@ -18,10 +18,12 @@ import json import logging import os +import shutil import signal import subprocess import sys from dataclasses import dataclass +from pathlib import Path logger = logging.getLogger(__name__) @@ -409,12 +411,15 @@ async def discard_paths(repo_dir: str, paths: list[str]) -> tuple[bool, str]: ) if rc != 0: return False, f"Could not restore {len(to_checkout)} file(s):\n{out}" - messages.append(f"Restored {len(to_checkout)} tracked file(s).") + messages.append( + f"Discarded your changes to {len(to_checkout)} tracked file(s); " + "they are back to the committed version." + ) if to_clean: rc, out = await _run_git("clean", "-fd", "--", *to_clean, repo_dir=repo_dir) if rc != 0: return False, f"Could not remove {len(to_clean)} file(s):\n{out}" - messages.append(f"Removed {len(to_clean)} untracked file(s).") + messages.append(f"Deleted {len(to_clean)} untracked file(s).") return True, " ".join(messages) or "Nothing to discard." @@ -442,6 +447,79 @@ async def stash_paths(repo_dir: str, paths: list[str]) -> tuple[bool, str]: return True, out or "Stashed." +async def stash_pop(repo_dir: str) -> tuple[bool, str]: + """Put back the work ``stash_paths`` parked, if it still applies cleanly. + + Stashing was never meant to be the end of the story -- the whole point of + parking work rather than discarding it is getting it back, and a clean pop + is the common case once the fast-forward has landed. What the original + caution was right about is the *failure*: a conflicting pop leaves a + half-merged tree plus a stash entry nobody was told about. So a conflict + aborts and says where the work still is, rather than leaving the operator + to discover both. + """ + _, listing = await _run_git("stash", "list", repo_dir=repo_dir) + if "condor /update" not in listing: + return True, "Nothing of ours was stashed." + + rc, out = await _run_git("stash", "pop", repo_dir=repo_dir) + if rc != 0: + return False, ( + f"{out}\n\nYour work is still in stash@{{0}} — nothing was lost. " + "Resolve it by hand with: git stash pop" + ) + return True, "Restored the work that was stashed before the update." + + +async def move_to_local_root(repo_dir: str, rel_paths: list[str]) -> tuple[bool, str]: + """Move edited agent files into the gitignored local root, then reset stock. + + The non-lossy exit from a ``dirty-conflict``. FEAT-115 redirects writes made + *through the product* into ``.condor/agents``, where they shadow stock per + item and no update can touch them -- but a text editor knows nothing about + that, and these are markdown files sitting in a git checkout, which is + exactly what people open in one. Such an edit blocked the update, and the + only one-click way out destroyed it. + + This puts the edit where the product would have put it: the customization + survives and keeps being used, the tracked file fast-forwards normally, and + the install ends up in the state FEAT-115 supports rather than a dead end. + """ + from condor.paths import local_agents_root + + if not rel_paths: + return True, "Nothing to move." + + moved: list[str] = [] + local_root = local_agents_root() + for rel in rel_paths: + source = Path(repo_dir) / rel + if not source.is_file(): + continue + # ``agents/scout/AGENT.md`` -> ``/scout/AGENT.md`` + target = local_root / Path(rel).relative_to("agents") + try: + target.parent.mkdir(parents=True, exist_ok=True) + shutil.move(str(source), str(target)) + except (OSError, ValueError) as exc: + return False, f"Could not move {rel}: {exc}" + moved.append(rel) + + if not moved: + return True, "Nothing to move." + + # The tracked copies go back to HEAD so the fast-forward is unobstructed. + rc, out = await _run_git("checkout", "HEAD", "--", *moved, repo_dir=repo_dir) + if rc != 0: + return False, f"Moved your edits, but could not reset the tracked files:\n{out}" + + return True, ( + f"Kept your version of {len(moved)} file(s), moved into .condor/agents " + "where updates leave them alone. The shipped copies will now " + "fast-forward; your versions stay in use." + ) + + async def install_dependencies() -> tuple[bool, str]: """Run uv sync to install any new dependencies, then bring the Claude bridge current.""" rc, output = await _run_cmd("uv", "sync", cwd=CONDOR_DIR, timeout=DEPS_TIMEOUT) From df39ca034cc8b05290f2c43bbf8538424b39cd66 Mon Sep 17 00:00:00 2001 From: david-hummingbot Date: Fri, 18 Sep 2026 12:00:55 +0800 Subject: [PATCH 14/43] Give the chat agent's routines a local layer Every other agent's authored content lands in the gitignored local root. The chat's did not: assistant_routines_dir() returned the repo-root routines/ for both a falsy slug and "condor". assistant_routines_dir(None) -> /routines # tracked assistant_routines_dir('condor') -> /routines # tracked assistant_routines_dir('hyperliquid_expert') -> /.../routines # ignored That directory is tracked and upstream actively maintains it -- 13 files, 48 commits touching them across the last 60 -- and edit_routine overwrites in place. So asking the chat agent to improve a shipped routine modified a file upstream also modifies, and the next update whose commits touched it raised a dirty-conflict. The carve-out is not removed, because it is load-bearing for a different reason: FEAT-033 found that threading the slug through naively relocates the general *library* to agents/condor/routines, which does not exist, so the whole catalog empties without a single error. Only the **write target** moves; the shipped root stays as the read fallback, so reads are unchanged and the catalog is as full as it ever was. The test that pinned this now asserts the property it was actually protecting -- a populated catalog -- rather than the implementation detail that writes and reads shared a directory. A v5 migration lifts what the product already wrote there. Untracked files only: a tracked file is either shipped or a modification of something shipped, and moving it would take the operator's edit out of the tree the update is about to touch. keep-mine exists for exactly that case and it is theirs to choose. Anything already present at the destination is left alone, so the lift is idempotent. --- condor/migrations.py | 68 +++++++++++++ routines/base.py | 35 ++++--- tests/test_chat_routines_layering.py | 147 +++++++++++++++++++++++++++ tests/test_condor_is_an_agent.py | 24 +++-- tests/test_shared_routines.py | 8 +- 5 files changed, 262 insertions(+), 20 deletions(-) create mode 100644 tests/test_chat_routines_layering.py diff --git a/condor/migrations.py b/condor/migrations.py index 652fb469d..37985be4a 100644 --- a/condor/migrations.py +++ b/condor/migrations.py @@ -132,6 +132,7 @@ MARKER_V3_FILENAME = ".migrated-v3" MARKER_V4_FILENAME = ".migrated-v4" MARKER_V5_FILENAME = ".migrated-v5" +MARKER_V6_FILENAME = ".migrated-v6" BACKUPS_DIRNAME = "migration-backups" # Runtime output that lived under a *tracked* agent directory. Everything here @@ -176,6 +177,8 @@ class MigrationReport: loops_renamed: int = 0 loop_md_renamed: int = 0 mutes_rewritten: int = 0 + # v6: chat-authored routines lifted out of the tracked library + chat_routines: int = 0 @property def total(self) -> int: @@ -195,6 +198,60 @@ def total(self) -> int: ) +def _lift_chat_routines(report: MigrationReport) -> None: + """Move chat-authored routines out of the tracked library into the local root. + + The chat agent's writable routines dir used to be the repo-root + ``routines/``, which git tracks and upstream actively maintains, so + ``create_routine`` and ``edit_routine`` wrote into a directory a later + ``git pull`` would want to change. Now that the write target is + ``/condor/routines``, anything the product already wrote there has to + come with it, or it stops being found. + + **Untracked files only.** A file git knows about is either shipped or a + modification of something shipped, and moving it would silently take the + operator's edit out of the tree the update is about to touch -- the + ``keep-mine`` resolution exists for exactly that case, and it is theirs to + choose. Anything already present in the destination is left alone. + """ + library = paths._PROJECT_ROOT / "routines" + if not library.is_dir(): + return + + try: + result = subprocess.run( + ["git", "ls-files", "--others", "--exclude-standard", "--", "routines"], + cwd=str(paths._PROJECT_ROOT), + capture_output=True, + text=True, + timeout=30, + ) + except (OSError, subprocess.SubprocessError): + log.debug("Could not list untracked routines; skipping the lift") + return + if result.returncode != 0: + return + + destination = paths.local_agents_root() / "condor" / "routines" + for line in result.stdout.splitlines(): + rel = line.strip() + if not rel.endswith(".py") or rel.endswith("__init__.py"): + continue + source = paths._PROJECT_ROOT / rel + if not source.is_file(): + continue + target = destination / Path(rel).relative_to("routines") + if target.exists(): + continue + try: + target.parent.mkdir(parents=True, exist_ok=True) + shutil.move(str(source), str(target)) + except OSError: + log.warning("Could not lift %s into the local root", rel, exc_info=True) + continue + report.chat_routines += 1 + + def ensure_migrated(agents_root: Path | None = None) -> MigrationReport: """Bring this install onto ``.condor/``. Safe to call on every boot. @@ -253,6 +310,17 @@ def ensure_migrated(agents_root: Path | None = None) -> MigrationReport: return report _write_marker(root, MARKER_V5_FILENAME, "FEAT-128") + # v6 rather than a second v5: FEAT-128 landed on main under the same marker + # while this was in review, and two migrations sharing ``.migrated-v5`` + # means whichever writes it first stops the other ever running. + if not (root / MARKER_V6_FILENAME).is_file(): + try: + _lift_chat_routines(report) + except Exception: # noqa: BLE001 - same rule: never block a boot + log.exception("Chat routine lift failed; leaving the library in place") + return report + _write_marker(root, MARKER_V6_FILENAME, "chat-routines-local-layer") + if report.total or report.dropped_stubs: log.warning( "Runtime migrated to %s: %d conversations, %d delegations, " diff --git a/routines/base.py b/routines/base.py index 4900dc939..1be4872ea 100644 --- a/routines/base.py +++ b/routines/base.py @@ -22,6 +22,7 @@ agent_home_layers, shared_routines_roots, ) +from condor.paths import local_agents_root logger = logging.getLogger(__name__) @@ -41,17 +42,24 @@ def library_dir() -> Path: def assistant_routines_dir(agent_slug: str | None) -> Path: """The **writable** routines dir of an assistant — the one it owns. - There is a single home for the general library — the repo-root ``routines/``, - owned by the chat ``condor``. A trading agent / domain expert owns - ``//routines``. + Every assistant writes under the local agent root, the chat included: - - chat ``condor`` (``agent_slug`` None **or** ``"condor"``) → ``routines`` + - chat ``condor`` (``agent_slug`` None **or** ``"condor"``) → + ``/condor/routines`` - trading agent / domain expert (slug) → ``//routines`` - The explicit ``"condor"`` carve-out is load-bearing (FEAT-033): Condor is now - an ordinary entry under the agent roots, so threading its slug through - naively would relocate the general library to ``/routines`` and empty - the catalog — silently, since a missing dir simply lists nothing. + The chat used to write into the repo-root ``routines/`` instead, which is + tracked and actively maintained upstream (13 files, 48 commits touching them + over 60) — so asking the chat agent to improve a shipped routine edited a + file upstream also edits, and the next update that touched it raised a + dirty-conflict whose escapes were lossy. + + The explicit ``"condor"`` carve-out that remains is still load-bearing + (FEAT-033): Condor is an ordinary entry under the agent roots, so threading + its slug through naively would relocate the general *library* as well and + empty the catalog — silently, since a missing dir simply lists nothing. That + is why only the write target moves here, and the shipped root stays as the + read fallback in :func:`assistant_routines_dirs`. This is what an assistant may **write**, not everything it may run: since FEAT-038 every assistant also *reads* the shared library, and since FEAT-115 @@ -63,18 +71,21 @@ def assistant_routines_dir(agent_slug: str | None) -> Path: """ if agent_slug and agent_slug != CHAT_SLUG: return agent_home_layers(agent_slug)[0] / "routines" - return _PROJECT_ROOT / "routines" + return local_agents_root() / CHAT_SLUG / "routines" def assistant_routines_dirs(agent_slug: str | None) -> tuple[Path, ...]: """An assistant's own routine dirs in **read** order: local, then stock. - The chat's general library is the repo-root ``routines/`` and has no second - layer — it is not under either agent root. + The chat now has two layers like every other assistant: a writable local one + under the agent root, and the shipped ``routines/`` as the read fallback. The + fallback is what keeps FEAT-033's carve-out satisfied — the general library + stays fully visible, which is the thing that would break silently if the + slug were simply threaded through. """ if agent_slug and agent_slug != CHAT_SLUG: return tuple(home / "routines" for home in agent_home_layers(agent_slug)) - return (_PROJECT_ROOT / "routines",) + return (local_agents_root() / CHAT_SLUG / "routines", _PROJECT_ROOT / "routines") @dataclass diff --git a/tests/test_chat_routines_layering.py b/tests/test_chat_routines_layering.py new file mode 100644 index 000000000..ad217354a --- /dev/null +++ b/tests/test_chat_routines_layering.py @@ -0,0 +1,147 @@ +"""The chat agent stops writing into the tracked library (C6). + +Every other assistant's authored content lands in the gitignored local root. +The chat's did not: its writable routines dir was the repo-root ``routines/``, +which git tracks and upstream actively maintains — 13 files, 48 commits touching +them over 60. So asking the chat agent to improve a shipped routine modified a +file upstream also modifies, and the next update touching it raised a +dirty-conflict whose escapes were lossy. + +The carve-out that remains is load-bearing and these tests say so: FEAT-033 +found that threading the slug through naively relocates the general *library* +and empties the catalog, silently, because a missing directory simply lists +nothing. Only the write target moves; the shipped root stays as the read +fallback. +""" + +from __future__ import annotations + +import subprocess + +import pytest + +from condor import migrations, paths +from condor.memory.paths import CHAT_SLUG +from routines.base import assistant_routines_dir, assistant_routines_dirs + +# ── where writes land ── + + +@pytest.mark.parametrize("slug", [None, CHAT_SLUG]) +def test_the_chat_writes_into_the_local_root(slug): + """The whole point: `create_routine` can no longer touch the tracked tree.""" + target = assistant_routines_dir(slug) + assert target == paths.local_agents_root() / CHAT_SLUG / "routines" + # The thing that matters: it is not under the tracked tree any more. + assert paths.stock_agents_root() not in target.parents + assert not target.is_relative_to(paths._PROJECT_ROOT / "routines") + + +def test_a_specialist_is_unchanged(): + target = assistant_routines_dir("hyperliquid_expert") + assert target == paths.local_agents_root() / "hyperliquid_expert" / "routines" + + +# ── and the catalog stays populated (the FEAT-033 regression) ── + + +@pytest.mark.parametrize("slug", [None, CHAT_SLUG]) +def test_the_shipped_library_is_still_read(slug): + """If this ever regresses, the catalog empties silently. Fail loudly instead.""" + dirs = assistant_routines_dirs(slug) + assert len(dirs) == 2, "the chat lost a layer" + assert dirs[0] == paths.local_agents_root() / CHAT_SLUG / "routines" + + shipped = dirs[1] + assert shipped.name == "routines" + assert shipped.is_dir(), "the shipped library is not where reads look" + # The thing FEAT-033 broke: a populated catalog. + assert list(shipped.glob("*.py")), "the shipped routine catalog reads as empty" + + +def test_local_shadows_shipped_not_the_other_way_round(): + dirs = assistant_routines_dirs(None) + assert dirs.index(paths.local_agents_root() / CHAT_SLUG / "routines") == 0 + + +# ── existing chat-authored routines come along ── + + +@pytest.fixture +def repo(tmp_path, monkeypatch): + """A checkout with one shipped routine committed and one authored by the chat.""" + monkeypatch.setattr(paths, "_PROJECT_ROOT", tmp_path) + monkeypatch.setattr(migrations.paths, "_PROJECT_ROOT", tmp_path) + monkeypatch.setenv("CONDOR_RUNTIME_ROOT", str(tmp_path / ".condor")) + + library = tmp_path / "routines" + library.mkdir() + (library / "shipped.py").write_text("# upstream maintains this\n") + env = { + "GIT_AUTHOR_NAME": "t", + "GIT_AUTHOR_EMAIL": "t@t", + "GIT_COMMITTER_NAME": "t", + "GIT_COMMITTER_EMAIL": "t@t", + "PATH": "/usr/bin:/bin", + } + for args in (["init", "-q", "-b", "main"], ["add", "-A"], ["commit", "-qm", "v1"]): + subprocess.run( + ["git", *args], cwd=tmp_path, check=True, capture_output=True, env=env + ) + # What the chat agent wrote before this change: untracked, in the tracked dir. + (library / "my_scanner.py").write_text("# written by the chat agent\n") + return tmp_path + + +def test_a_chat_authored_routine_is_lifted_into_the_local_root(repo): + report = migrations.MigrationReport() + migrations._lift_chat_routines(report) + + assert report.chat_routines == 1 + lifted = paths.local_agents_root() / CHAT_SLUG / "routines" / "my_scanner.py" + assert lifted.read_text() == "# written by the chat agent\n" + assert not (repo / "routines" / "my_scanner.py").exists() + + +def test_a_tracked_routine_is_never_moved(repo): + """Moving it would take the operator's edit out of the tree being updated. + + A tracked file is either shipped or a modification of something shipped, and + ``keep-mine`` exists for exactly that case — it is the operator's choice, + not the migration's. + """ + (repo / "routines" / "shipped.py").write_text("# upstream, plus my edit\n") + + report = migrations.MigrationReport() + migrations._lift_chat_routines(report) + + # The untracked one moved; the tracked one stayed exactly where it was. + assert report.chat_routines == 1 + assert ( + repo / "routines" / "shipped.py" + ).read_text() == "# upstream, plus my edit\n" + assert not ( + paths.local_agents_root() / CHAT_SLUG / "routines" / "shipped.py" + ).exists() + + +def test_the_lift_does_not_overwrite_something_already_there(repo): + destination = paths.local_agents_root() / CHAT_SLUG / "routines" + destination.mkdir(parents=True) + (destination / "my_scanner.py").write_text("# the newer local one\n") + + report = migrations.MigrationReport() + migrations._lift_chat_routines(report) + + assert report.chat_routines == 0 + assert (destination / "my_scanner.py").read_text() == "# the newer local one\n" + + +def test_the_lift_is_idempotent(repo): + first = migrations.MigrationReport() + migrations._lift_chat_routines(first) + second = migrations.MigrationReport() + migrations._lift_chat_routines(second) + + assert first.chat_routines == 1 + assert second.chat_routines == 0 diff --git a/tests/test_condor_is_an_agent.py b/tests/test_condor_is_an_agent.py index 160a4187b..89d6ffe21 100644 --- a/tests/test_condor_is_an_agent.py +++ b/tests/test_condor_is_an_agent.py @@ -44,20 +44,30 @@ def registry(tmp_path, monkeypatch): # ── Carve-out 1: the chat's routine library does not move ── -def test_chat_routines_dir_is_the_repo_root_library(): - """A falsy slug AND ``"condor"`` both resolve the general library. +def test_the_chat_still_reads_the_repo_root_library(): + """A falsy slug AND ``"condor"`` both still *read* the general library. Condor is an ordinary ``agents/`` entry now, so the naive ``agents//routines`` rule would point the chat at ``agents/condor/routines`` — a dir that does not exist, so every routine - would vanish from the catalog without a single error. + would vanish from the catalog without a single error. That is what this + carve-out protects, and it is about **reads**. + + The chat's *write* target did move, to the gitignored local root: writing + into the repo-root library meant the chat agent edited files upstream also + maintains, which the next update then conflicted on. The shipped root + remains the read fallback, so the catalog is as full as it ever was. """ - from routines.base import assistant_routines_dir + from routines.base import assistant_routines_dir, assistant_routines_dirs general = _REPO_ROOT / "routines" - assert assistant_routines_dir(None) == general - assert assistant_routines_dir("") == general - assert assistant_routines_dir(CHAT_SLUG) == general + for slug in (None, "", CHAT_SLUG): + assert general in assistant_routines_dirs(slug), "the catalog would empty" + # And writes land somewhere git has never heard of. + assert assistant_routines_dir(slug) != general + + # The guarantee in the terms that actually matter: routines to list. + assert list(general.glob("*.py")), "the shipped catalog reads as empty" def test_a_specialist_still_owns_an_isolated_routines_dir(): diff --git a/tests/test_shared_routines.py b/tests/test_shared_routines.py index e0af1378f..0c3195c48 100644 --- a/tests/test_shared_routines.py +++ b/tests/test_shared_routines.py @@ -298,7 +298,13 @@ def test_chat_create_without_shared_stays_local(self, isolated, monkeypatch): assert result["created"] is True assert "shared" not in result - assert (tmp_path / "routines" / "local.py").exists() + # The chat's own writable library, which is now the gitignored local + # root rather than the tracked repo-root one: what the chat agent + # authors must not land in a directory upstream also maintains. + assert ( + paths_mod.local_agents_root() / CHAT_SLUG / "routines" / "local.py" + ).exists() + assert not (tmp_path / "routines" / "local.py").exists() assert not (paths_mod.shared_routines_root() / "local.py").exists() def test_agent_edit_and_delete_cannot_reach_the_shared_root( From 7d1663dcae2c2a06cb1cdf8a38c966c6ec5081e4 Mon Sep 17 00:00:00 2001 From: david-hummingbot Date: Fri, 18 Sep 2026 12:13:10 +0800 Subject: [PATCH 15/43] Check the install over, warn before it, and stop the quiet edges Five things that each reuse machinery already here. Verify the update worked (C5). make doctor / python -m condor.doctor already exists, is read-only, and exits non-zero on real failures -- and was wired only into make install. Nothing in the update path ran it, so a broken boot was indistinguishable from a good one until someone opened the dashboard. The contrast is the argument: the hummingbot-api path gets a health gate whose step is literally "wait for the API to answer", while Condor -- the component running the update -- got none. Never fatal: the code is already on disk, so failing the run on the doctor's verdict would report a completed update as a failed one. Warn on incoming migrations (C4). startup() migrates this deployment's data on every boot and the moves are one-way, so restoring old code onto migrated data leaves Condor looking for directories that no longer exist -- and FEAT-115 widened that to the agent tree, so a rollback now also strands forked playbooks, skills and mutes. Keyed off what the fast-forward would actually write, via incoming_paths: paths_changed() answers True for an unresolvable range by design, so asking it against an empty commit would fire this on every update. The frontend-rebuild warning moved onto the same footing. Precheck disk (C14). shutil.disk_usage appeared nowhere in the update path, and an update writes a synced venv, a fresh node_modules, a new bundle and a pulled image. Running out part-way is the worst input to everything else here. Say what an interrupted run left behind (C9). The old message described the state of the disk accurately and omitted the part that matters: the process reading it booted on the partial result. The fast-forward lands before deps are synced and before the dashboard is rebuilt, so that Condor may be running new code against a stale venv. It now names the steps that finished and says to re-run before anything else. Name the role, not the tab (C10). Local mode admits both user and admin at login while every admin route demands admin, so a hand-edited config.yml boots, logs in, and silently has no Updates tab. Best effort, because a gate must never crash or fail open because a courtesy could not be paid. And two guards against silent breakage elsewhere: an advisory lock file so a second Condor on the same checkout cannot fast-forward the same repo concurrently (_lock and _current are per-process, and a stray `make run` in another pane is exactly what someone tries when a restart looks stuck), and tests that fail if two tracked paths ever differ only in case or a CRLF file lands -- both of which are invisible on Linux and break a clone on macOS or Windows. The second one immediately found *.jpeg missing from .gitattributes. --- condor/updates/components.py | 98 ++++++++++++++++++++++++------ condor/updates/run.py | 105 ++++++++++++++++++++++++++++++++- condor/web/auth.py | 20 +++++-- tests/test_repo_portability.py | 77 ++++++++++++++++++++++++ tests/test_update_journal.py | 3 +- utils/updater.py | 25 ++++++++ 6 files changed, 301 insertions(+), 27 deletions(-) create mode 100644 tests/test_repo_portability.py diff --git a/condor/updates/components.py b/condor/updates/components.py index e8e3b7ccb..7adb9afc6 100644 --- a/condor/updates/components.py +++ b/condor/updates/components.py @@ -22,6 +22,7 @@ import asyncio import logging import os +import shutil import time from dataclasses import asdict, dataclass, field from pathlib import PurePosixPath @@ -751,6 +752,73 @@ async def _executor_warning() -> Warning | None: _STALE_FORK_CAP = 8 +# An update can write a rebuilt venv, a fresh node_modules, a new bundle and a +# pulled image. Running out part-way is the worst input to everything else here, +# and nothing checked: shutil.disk_usage appeared nowhere in the update path. +_DISK_FLOOR_GB = 3.0 + + +def _incoming_warnings(incoming: list[str]) -> list[Warning]: + """Consequences that follow from *which files* an update would write.""" + out: list[Warning] = [] + if any(p.startswith("frontend/") for p in incoming): + # frontend_needs_build() is consulted inside the run and never in the + # preflight, so nothing told the person reading the dashboard that the + # page they are on is the thing about to be replaced. + out.append( + Warning( + component=CONDOR, + code="frontend-will-rebuild", + message=( + "This update rebuilds the dashboard. The new bundle is built " + "alongside the running one and swapped in, so the page you " + "are reading keeps working — reload it once the update " + "finishes to pick up the new one." + ), + ) + ) + if "condor/migrations.py" in incoming: + # startup() migrates this deployment's data on every boot and the moves + # are one-way, so restoring old *code* onto migrated *data* leaves + # Condor looking for directories that no longer exist. FEAT-115 widened + # that to the agent tree, so a rollback now also strands forked + # playbooks, skills and mutes. + out.append( + Warning( + component=CONDOR, + code="migration-incoming", + message=( + "This update migrates stored data, and the moves are " + "one-way. Rolling back to the current commit afterwards " + "will not restore the old layout." + ), + ) + ) + return out + + +def _disk_warning(repo_dir: str) -> Warning | None: + """Warn when there is not obviously room for what the update is about to write.""" + try: + usage = shutil.disk_usage(repo_dir) + except OSError: + log.debug("Could not read free space for %s", repo_dir, exc_info=True) + return None + free_gb = usage.free / (1024**3) + if free_gb >= _DISK_FLOOR_GB: + return None + return Warning( + component=CONDOR, + code="low-disk", + message=( + f"Only {free_gb:.1f} GB free where Condor is installed. An update " + f"writes a synced virtualenv, a fresh node_modules and a new " + f"dashboard bundle — running out part-way through leaves the " + f"install half-built. Free some space first." + ), + ) + + def _stale_fork_warning() -> Warning | None: """Local versions of shipped files that this install will keep using. @@ -858,25 +926,17 @@ async def preflight(component_keys: list[str]) -> Preflight: condor_status = statuses.get(CONDOR) repo = condor_status.facets.get("repo") if condor_status else None if repo is not None and not repo.up_to_date: - # frontend_needs_build() is consulted inside the run and never here, - # so nothing told the person reading the dashboard that the page - # they are on is the one about to be replaced. - if await updater.frontend_needs_build( - await updater.get_local_commit_full(_table()[CONDOR].repo_dir), - "", - ): - warnings.append( - Warning( - component=CONDOR, - code="frontend-will-rebuild", - message=( - "This update rebuilds the dashboard. The bundle is " - "built alongside the running one and swapped in, so " - "the page you are reading keeps working — but reload " - "it once the update finishes to pick up the new one." - ), - ) - ) + # What this update would actually write, rather than a diff against + # an empty commit -- ``paths_changed`` answers True for an + # unresolvable range by design, so asking it that way would make + # both warnings below fire on every update. + incoming = await updater.incoming_paths(_table()[CONDOR].repo_dir) + for warning in _incoming_warnings(incoming or []): + warnings.append(warning) + disk_warning = _disk_warning(_table()[CONDOR].repo_dir) + if disk_warning is not None: + warnings.append(disk_warning) + stale_warning = _stale_fork_warning() if stale_warning is not None: warnings.append(stale_warning) diff --git a/condor/updates/run.py b/condor/updates/run.py index 998de516b..c0b514fbb 100644 --- a/condor/updates/run.py +++ b/condor/updates/run.py @@ -25,14 +25,18 @@ from __future__ import annotations import asyncio +import fcntl +import io import json import logging +import os import time from dataclasses import dataclass, field from typing import Any, Awaitable, Callable from condor import paths from condor.fsutil import atomic_write_json +from condor.paths import runtime_root from condor.updates import components from utils import updater @@ -371,9 +375,64 @@ def _plan(component_keys: list[str], statuses: dict[str, components.ComponentSta steps.append(Step(f"{key}.unstash", "Restoring stashed work")) steps.append(Step(f"{key}.deps", "Syncing dependencies")) steps.append(Step(f"{key}.frontend", "Rebuilding the dashboard")) + steps.append(Step(f"{key}.doctor", "Checking the install over")) return steps +_LOCK_FILENAME = "update.lock" +# One handle per process. flock conflicts between two descriptors even in the +# same process, so re-acquiring would have this process refusing itself -- and +# within a process ``_lock``/``_current`` already answer the question. +_lock_handle: "io.TextIOWrapper | None" = None + + +def _acquire_run_lock() -> "io.TextIOWrapper | None": + """Take an advisory lock for the length of a run, across processes. + + ``_lock`` and ``_current`` guard this process only. A second Condor on the + same checkout -- a stray ``make run`` in another pane, which is exactly what + someone does when a restart looks stuck -- would fast-forward the same repo + concurrently with the first, and neither would know. + + Advisory and best effort: if the lock cannot be taken *because someone holds + it*, the caller refuses; if locking is unavailable at all, the update + proceeds as before rather than being blocked by the safety net. + """ + path = runtime_root() / _LOCK_FILENAME + try: + path.parent.mkdir(parents=True, exist_ok=True) + handle = path.open("w") + except OSError: + log.debug("Could not open the update lock; proceeding unlocked") + return None + try: + fcntl.flock(handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) + except OSError: + handle.close() + return None + except (AttributeError, NameError): # pragma: no cover - no flock here + return handle + try: + handle.write(f"{os.getpid()}\n") + handle.flush() + except OSError: + pass + return handle + + +def _release_run_lock(handle) -> None: + if handle is None: + return + try: + fcntl.flock(handle.fileno(), fcntl.LOCK_UN) + except (OSError, AttributeError, NameError): + pass + try: + handle.close() + except OSError: + pass + + async def start( component_keys: list[str], *, @@ -392,6 +451,22 @@ async def start( if _current is not None and _current.live: return _current + global _lock_handle + if _lock_handle is None: + _lock_handle = _acquire_run_lock() + if _lock_handle is None and (runtime_root() / _LOCK_FILENAME).exists(): + holder = "" + try: + holder = (runtime_root() / _LOCK_FILENAME).read_text().strip() + except OSError: + pass + raise RuntimeError( + "Another Condor process on this checkout is already running " + f"an update{f' (pid {holder})' if holder else ''}. Wait for " + "it to finish rather than running a second one over the " + "same repo." + ) + selected = [k for k in components.keys() if k in set(component_keys)] statuses = {s.key: s for s in await components.check()} @@ -464,6 +539,10 @@ async def _execute(run: Run, resolutions: dict[str, str]) -> None: except Exception as e: # noqa: BLE001 - an update must never die silently log.exception("Update run %s crashed", run.id) await _fail(run, f"{type(e).__name__}: {e}") + finally: + global _lock_handle + _release_run_lock(_lock_handle) + _lock_handle = None async def _update_hb_api(run: Run) -> bool: @@ -592,6 +671,15 @@ async def _update_condor(run: Run) -> bool: ) return False + # Check the install over before declaring victory. Read-only, and never + # fatal: the code is already on disk, so failing the run on the doctor's + # verdict would report a completed update as a failed one. WARNED is the + # honest state -- the update did happen, and there is something to read. + step = await _begin(run, f"{prefix}.doctor") + if step is not None: + ok, output = await updater.run_doctor() + await _finish(run, step, OK if ok else WARNED, output) + # Everything that can be done without ending the process is done. The one # remaining step belongs to a human, so the run is judged here rather than # at the next boot: it succeeded, and it owes a relaunch. @@ -652,10 +740,21 @@ async def finalize_pending_run() -> Run | None: if not was_restarting: run.state = FAILED where = f" during {step.label.lower()}" if step is not None else "" + done = [s.label.lower() for s in run.steps if s.state == OK] + completed = ( + f" What did finish: {', '.join(done)}." if done else " Nothing finished." + ) + # The old wording described the state of the *disk* accurately and left + # out the part that matters now: the process reading this booted on the + # partial result. The fast-forward lands before dependencies are synced + # and before the dashboard is rebuilt, so this Condor may be running new + # code against a stale venv. run.error = ( - f"This update was interrupted{where} — Condor stopped before the " - "run finished. Nothing was rolled back; start it again to pick up " - "wherever it got to." + f"This update was interrupted{where} — Condor stopped before the run " + f"finished.{completed} Nothing was rolled back, so Condor is now " + "*running* that partial result: new code, possibly with dependencies " + "or a dashboard bundle that never caught up. Run the update again " + "before anything else." ) if step is not None: step.state = FAILED diff --git a/condor/web/auth.py b/condor/web/auth.py index 78ede7366..1b9ff409b 100644 --- a/condor/web/auth.py +++ b/condor/web/auth.py @@ -146,10 +146,22 @@ def require_admin(user: WebUser) -> None: copy. """ if not get_config_manager().is_admin(user.id): - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="Admin access required", - ) + # Name the role rather than the screen. Local mode admits both `user` + # and `admin` at login while every admin route demands `admin`, so a + # hand-edited config.yml produces an install that boots, logs in, and + # silently has no Updates tab — with nothing anywhere saying why. + # Best effort: naming the role is a courtesy, and a gate must never + # fail open (or crash) because the courtesy could not be paid. + try: + role = get_config_manager().get_user_role(user.id) or "" + except Exception: # noqa: BLE001 + role = "" + detail = "Admin access required" + if role: + detail += ( + f" — this user's role is '{role}'. Set it to 'admin' in config.yml." + ) + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=detail) def report_owner_filter(user: WebUser) -> int | None: diff --git a/tests/test_repo_portability.py b/tests/test_repo_portability.py new file mode 100644 index 000000000..4f08a9bbc --- /dev/null +++ b/tests/test_repo_portability.py @@ -0,0 +1,77 @@ +"""The checkout has to work on macOS and Windows too (P5, P2). + +Condor is developed on Linux, where the filesystem is case-sensitive and line +endings are LF. Both of the other supported hosts differ, and both differences +are silent: a case-collision makes a clean clone impossible on APFS or NTFS, and +an unmanaged line ending changes the bytes `content_digest` hashes. + +These are cheap assertions against the tree itself, so a commit that breaks +either one fails here rather than on someone's laptop. +""" + +from __future__ import annotations + +import subprocess +from collections import defaultdict +from pathlib import Path + +_REPO_ROOT = Path(__file__).resolve().parent.parent + + +def _tracked_files() -> list[str]: + result = subprocess.run( + ["git", "ls-files"], + cwd=_REPO_ROOT, + capture_output=True, + text=True, + check=True, + ) + return [line for line in result.stdout.splitlines() if line] + + +def test_no_two_tracked_paths_differ_only_in_case(): + """macOS (APFS) and Windows (NTFS) are case-insensitive by default. + + Two paths differing only in case are one file there, so a clone silently + loses one of them — and the fork layering, which resolves local-before-stock + per item, then resolves unpredictably. + """ + by_lower: dict[str, list[str]] = defaultdict(list) + for path in _tracked_files(): + by_lower[path.lower()].append(path) + + collisions = {k: v for k, v in by_lower.items() if len(v) > 1} + assert not collisions, f"paths differing only in case: {collisions}" + + +def test_agent_content_is_pinned_to_lf(): + """`content_digest` hashes these files, so their bytes must not vary by host.""" + attributes = (_REPO_ROOT / ".gitattributes").read_text() + assert "* text=auto" in attributes + for pattern in ("*.md text eol=lf", "*.sh text eol=lf"): + assert pattern in attributes, f"{pattern} is not pinned" + + +def test_no_tracked_file_has_crlf_endings(): + """`* text=auto` normalizes on commit; this catches one that slipped in.""" + offenders = [] + for rel in _tracked_files(): + path = _REPO_ROOT / rel + # Binary files have no line endings to normalize; .gitattributes marks + # them, and this list mirrors it. + if not path.is_file() or path.suffix in { + ".png", + ".jpg", + ".jpeg", + ".gif", + ".ico", + ".woff", + ".woff2", + }: + continue + try: + if b"\r\n" in path.read_bytes(): + offenders.append(rel) + except OSError: + continue + assert not offenders, f"CRLF in tracked files: {offenders}" diff --git a/tests/test_update_journal.py b/tests/test_update_journal.py index 62194b5af..a28a42089 100644 --- a/tests/test_update_journal.py +++ b/tests/test_update_journal.py @@ -331,6 +331,7 @@ def test_the_plan_is_laid_out_before_anything_runs(_a_sibling_api): "condor.unstash", "condor.deps", "condor.frontend", + "condor.doctor", ] assert all(s.state == "pending" for s in steps) assert "Pulling" in steps[1].label @@ -466,7 +467,7 @@ def test_a_condor_update_never_restarts_the_process(): lives in the helper because every test here depends on it. """ run = _run_condor_update(before=BEFORE, after=AFTER) - assert [s.key for s in run.steps][-1] == "condor.frontend" + assert [s.key for s in run.steps][-1] == "condor.doctor" assert run.target_commit == AFTER diff --git a/utils/updater.py b/utils/updater.py index ceec11c16..8d7b8c161 100644 --- a/utils/updater.py +++ b/utils/updater.py @@ -657,6 +657,31 @@ def _explain_build_failure(rc: int, output: str) -> str: return base +async def run_doctor() -> tuple[bool, str]: + """Run Condor's own health check and report what it said. + + ``python -m condor.doctor`` already exists, is read-only, and exits non-zero + on real failures -- and was wired only into ``make install``. Nothing in the + update path ran it, so a broken boot was indistinguishable from a good one + until somebody opened the dashboard. The contrast is the sharpest argument + for it: the hummingbot-api path gets a health gate whose step is literally + "wait for the API to answer", while Condor -- the component actually running + the update -- got none. + + Never fatal. It runs after the code is already on disk, so failing the run + on its verdict would report a completed update as a failed one. + """ + script = ( + 'export NVM_DIR="$HOME/.nvm"; ' + '[ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh"; ' + "uv run python -m condor.doctor" + ) + rc, output = await _run_cmd( + "bash", "-c", script, cwd=CONDOR_DIR, timeout=DEPS_TIMEOUT + ) + return rc == 0, output or ("Doctor reported no output." if rc == 0 else "") + + async def build_frontend( old_commit: str = "", new_commit: str = "" ) -> tuple[bool, str]: From fe472f459db753c9da1258bf643527e24dee463f Mon Sep 17 00:00:00 2001 From: david-hummingbot Date: Fri, 18 Sep 2026 12:26:32 +0800 Subject: [PATCH 16/43] Keep the preflight plan in step with the run _plan() gained an unstash and a doctor step; _steps_for(), which renders the plan on the confirm screen, did not. The two are meant to be the same list -- the whole reason the run's steps are built up front is so the confirm screen and the progress screen agree, and a step that turns out to be unnecessary is marked skipped rather than quietly missing. Caught by running a real update through the live API and watching the confirm screen promise four steps while the progress screen ran six. --- condor/updates/components.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/condor/updates/components.py b/condor/updates/components.py index 7adb9afc6..88bb9077e 100644 --- a/condor/updates/components.py +++ b/condor/updates/components.py @@ -717,10 +717,14 @@ def _steps_for(component_key: str, status_: ComponentStatus) -> list[str]: steps.append("Recreate the containers") steps.append("Wait for the API to answer") else: + # Must stay in step with ``run._plan``: the confirm screen and the + # progress screen are meant to be the same list. steps.append("Fast-forward the Condor checkout") + steps.append("Restore any work stashed to clear the way") steps.append("Sync dependencies") steps.append("Rebuild the dashboard if the update touched it") - steps.append("Ask you to relaunch Condor to apply it") + steps.append("Check the install over") + steps.append("Relaunch Condor to apply it") return steps From 4a52c0cdb1cd03548b101ad6ae000d032ece1330 Mon Sep 17 00:00:00 2001 From: david-hummingbot Date: Fri, 18 Sep 2026 14:35:57 +0800 Subject: [PATCH 17/43] Warn before an update overrides your files, and cover the ones with no stamp Two gaps in the staleness work, both found by asking what happens to a file an agent creates rather than edits. The warning was retrospective. stale_forks() compares a fork against the stock file *on disk*, and that only moves once the fast-forward has landed -- so it reported the divergence on the preflight *after* the update that caused it, when there was nothing left to decide: === BEFORE the update === stale forks : none === AFTER the update === stale forks : ['AGENT.md'] locally_overridden() intersects the incoming path set with what this install already has its own copy of, so the preflight now says "this update changes 2 shipped files that you have your own version of" while the decision is still open. The retrospective check stays -- they answer different questions, and the silent-forever case is the one that needed both. The detector also only walked *.md. A stamp lives in frontmatter, and _stamp() deliberately refuses to invent frontmatter for a file that has none, because that would change what the file is and break the machinery that imports it. So everything else forked down was invisible to it -- and the library ships nineteen .py routines. An agent editing a shipped routine shadowed it for ever with nothing able to notice. Same shape, different cause: a file the agent *creates* under a name upstream later ships collides identically. Local wins per item, upstream's version is unreachable, and no stamp ever existed to compare. So the walk now covers every file and classifies by what it can prove: a stamp that no longer matches is stale, a stamp with no counterpart is retired, and no stamp with a differing counterpart is shadowing. An identical unstamped copy is not reported -- copying is not diverging -- and a file with no counterpart at all is exactly the ordinary "the agent wrote something new" case, which was never a fork of anything. The shadowing message is separate because the advice is: rename yours if you meant to keep both. --- condor/layering.py | 95 ++++++++++++++++++++++++++++++------ condor/updates/components.py | 51 ++++++++++++++++++- tests/test_stale_forks.py | 95 ++++++++++++++++++++++++++++++++++++ 3 files changed, 226 insertions(+), 15 deletions(-) diff --git a/condor/layering.py b/condor/layering.py index f78cde094..522390f78 100644 --- a/condor/layering.py +++ b/condor/layering.py @@ -39,7 +39,7 @@ import shutil from dataclasses import dataclass from datetime import datetime, timezone -from pathlib import Path +from pathlib import Path, PurePosixPath from condor.frontmatter import parse_frontmatter, render_frontmatter from condor.fsutil import atomic_write_text @@ -245,7 +245,7 @@ class StaleFork: rel: str """Its path relative to the local root — what a message should show.""" forked_from: str - """The digest of the stock file at fork time, from the stamp.""" + """The digest of the stock file at fork time; ``""`` when never stamped.""" stock_digest: str | None """The stock file's digest now; ``None`` when it no longer exists.""" @@ -254,6 +254,19 @@ def retired(self) -> bool: """Whether upstream removed the stock counterpart entirely.""" return self.stock_digest is None + @property + def unprovenanced(self) -> bool: + """Shadowing stock with no record of ever having been the same file. + + Two ways to get here, and they need the same sentence. A file with no + frontmatter cannot hold a stamp -- a routine's ``.py`` is the case that + matters, since the library ships nineteen of them -- so a forked skill + folder stamps its ``SKILL.md`` and nothing else. And a file the agent + *created* under a name upstream later ships collides the same way: local + wins per item, so upstream's version never appears. + """ + return not self.forked_from + def _legacy_digest(path: Path) -> str: """What :func:`content_digest` returned before newlines were normalized. @@ -290,27 +303,81 @@ def stale_forks(agent_slug: str | None) -> list[StaleFork]: return [] out: list[StaleFork] = [] - for path in sorted(local.rglob("*.md")): - try: - meta, _ = parse_frontmatter(path.read_text(encoding="utf-8")) - except OSError: - continue - forked_from = (meta or {}).get(FORKED_FROM_KEY) - if not forked_from: + for path in sorted(local.rglob("*")): + if not path.is_file(): continue rel = path.relative_to(local) counterpart = stock / rel + forked_from = _stamp_of(path) + if not counterpart.is_file(): - # Upstream retired it. Reads resolve local-before-stock and nothing - # reconciles the roots, so a withdrawn playbook keeps running. - out.append(StaleFork(path, str(rel), str(forked_from), None)) + # No stock counterpart. Either upstream retired something this + # install forked -- worth saying, since reads never reconcile the + # roots and a withdrawn playbook keeps running -- or it is an + # ordinary file the agent authored, which was never a fork of + # anything and has nothing to be stale against. + if forked_from: + out.append(StaleFork(path, str(rel), forked_from, None)) continue now = content_digest(counterpart) - if now == forked_from or _legacy_digest(counterpart) == forked_from: + if forked_from: + if now == forked_from or _legacy_digest(counterpart) == forked_from: + continue + out.append(StaleFork(path, str(rel), forked_from, now)) + continue + + # No stamp, but stock ships this exact path, so the local copy is + # shadowing it. Identical content means the fork simply never diverged + # -- nothing to report. Different content means upstream's version is + # unreachable and nothing recorded that it ever matched. + if now != content_digest(path): + out.append(StaleFork(path, str(rel), "", now)) + return out + + +def _stamp_of(path: Path) -> str: + """The ``forked_from`` digest recorded on ``path``, or ``""``. + + Only markdown can carry one: :func:`_stamp` deliberately refuses to invent + frontmatter for a file that has none, because that would change what the + file *is* and the machinery reading a routine's ``.py`` would not survive it. + """ + if path.suffix != ".md": + return "" + try: + meta, _ = parse_frontmatter(path.read_text(encoding="utf-8")) + except (OSError, UnicodeDecodeError): + return "" + return str((meta or {}).get(FORKED_FROM_KEY) or "") + + +def locally_overridden(rel_paths: list[str]) -> list[str]: + """Which of ``rel_paths`` this install already has its own version of. + + ``rel_paths`` are repo-relative, as ``incoming_paths`` reports them, so only + those under ``agents/`` can have a local counterpart at all. + + This is the *predictive* half of the staleness story. :func:`stale_forks` + compares a fork against the stock file on disk, and that only moves once the + fast-forward has landed -- so it answers "upstream has already moved on" + after the fact. This answers "the update you are about to apply will change + files you have overridden" while there is still a decision to make. + """ + from condor.paths import local_agents_root + + root = local_agents_root() + if not root.is_dir(): + return [] + + out: list[str] = [] + for rel in rel_paths: + parts = PurePosixPath(rel).parts + if len(parts) < 2 or parts[0] != "agents": continue - out.append(StaleFork(path, str(rel), str(forked_from), now)) + if (root.joinpath(*parts[1:])).is_file(): + out.append(rel) return out diff --git a/condor/updates/components.py b/condor/updates/components.py index 88bb9077e..8c0666ccd 100644 --- a/condor/updates/components.py +++ b/condor/updates/components.py @@ -762,6 +762,17 @@ async def _executor_warning() -> Warning | None: _DISK_FLOOR_GB = 3.0 +def _locally_overridden(incoming: list[str]) -> list[str]: + """:func:`condor.layering.locally_overridden`, but never fatal to a preflight.""" + from condor.layering import locally_overridden + + try: + return locally_overridden(incoming) + except OSError: + log.debug("Could not check for locally overridden paths", exc_info=True) + return [] + + def _incoming_warnings(incoming: list[str]) -> list[Warning]: """Consequences that follow from *which files* an update would write.""" out: list[Warning] = [] @@ -781,6 +792,27 @@ def _incoming_warnings(incoming: list[str]) -> list[Warning]: ), ) ) + overridden = _locally_overridden(incoming) + if overridden: + # The predictive half. stale_forks() compares a fork against the stock + # file on disk, which only moves once the fast-forward has landed -- so + # on its own it reports the divergence *after* the update that caused + # it. This says so while there is still a decision to make. + shown = ", ".join(overridden[:_STALE_FORK_CAP]) + if len(overridden) > _STALE_FORK_CAP: + shown += f", and {len(overridden) - _STALE_FORK_CAP} more" + out.append( + Warning( + component=CONDOR, + code="update-changes-overridden-files", + message=( + f"This update changes {len(overridden)} shipped " + f"file{'s' if len(overridden) != 1 else ''} that you have " + f"your own version of ({shown}). Your versions will keep " + "being used, so these changes will not reach your agents." + ), + ) + ) if "condor/migrations.py" in incoming: # startup() migrates this deployment's data on every boot and the moves # are one-way, so restoring old *code* onto migrated *data* leaves @@ -843,10 +875,27 @@ def _stale_fork_warning() -> Warning | None: if not stale: return None - moved = [f for f in stale if not f.retired] + moved = [f for f in stale if not f.retired and not f.unprovenanced] + shadowing = [f for f in stale if not f.retired and f.unprovenanced] retired = [f for f in stale if f.retired] parts: list[str] = [] + if shadowing: + # Separated because the advice differs: a stamped fork was deliberately + # taken from a known version, while these never recorded one -- a + # routine's .py cannot hold a stamp, and a file the agent created under + # a name upstream later shipped never had one to take. Either way the + # local copy wins and upstream's is unreachable. + shown = ", ".join(f.rel for f in shadowing[:_STALE_FORK_CAP]) + if len(shadowing) > _STALE_FORK_CAP: + shown += f", and {len(shadowing) - _STALE_FORK_CAP} more" + parts.append( + f"{len(shadowing)} local file{'s' if len(shadowing) != 1 else ''} " + f"shadow{'' if len(shadowing) != 1 else 's'} a shipped one of the " + f"same name and differ{'' if len(shadowing) != 1 else 's'} from it " + f"({shown}). Yours are used; upstream's are not reachable. Rename " + "yours if you meant to keep both." + ) if moved: shown = ", ".join(f.rel for f in moved[:_STALE_FORK_CAP]) if len(moved) > _STALE_FORK_CAP: diff --git a/tests/test_stale_forks.py b/tests/test_stale_forks.py index 39218f107..27c403911 100644 --- a/tests/test_stale_forks.py +++ b/tests/test_stale_forks.py @@ -119,3 +119,98 @@ def test_a_legacy_crlf_stamp_is_not_reported_stale(shipped): "utf-8", ) assert stale_forks("scout") == [] + + +# ── Files that cannot carry a stamp, and files that never had one ── + + +def test_a_shipped_routine_edited_locally_is_reported(shipped): + """A routine's ``.py`` has no frontmatter, so it can never hold a stamp. + + The library ships nineteen of them, and ``fork_path`` copies one down + unstamped by design — inventing frontmatter would change what the file is + and break the machinery that imports it. So the stamp cannot be the only + way divergence is noticed: the local copy shadows the shipped one for ever + and nothing in the stamp-based check would ever see it. + """ + from condor.layering import fork_if_stock + + stock_routine = shipped.parent / "routines" / "scanner.py" + stock_routine.parent.mkdir(parents=True, exist_ok=True) + stock_routine.write_text("# shipped scanner\n") + + local_routine = fork_if_stock("scout", "routines", "scanner.py") + local_routine.write_text("# my own scanner\n") + + stale = stale_forks("scout") + assert [f.rel for f in stale] == ["routines/scanner.py"] + assert stale[0].unprovenanced is True + assert stale[0].retired is False + + +def test_a_forked_copy_that_never_diverged_is_not_reported(shipped): + """Copying is not diverging — an identical unstamped copy has nothing to say.""" + from condor.layering import fork_if_stock + + stock_routine = shipped.parent / "routines" / "scanner.py" + stock_routine.parent.mkdir(parents=True, exist_ok=True) + stock_routine.write_text("# shipped scanner\n") + + fork_if_stock("scout", "routines", "scanner.py") # copied, not edited + + assert stale_forks("scout") == [] + + +def test_an_agent_authored_file_upstream_later_ships_is_reported(shipped): + """A name collision shadows exactly like a fork, with no stamp to show it.""" + from condor.memory.paths import agent_home_layers + + local, stock = agent_home_layers("scout") + (local / "skills" / "alpha").mkdir(parents=True) + (local / "skills" / "alpha" / "SKILL.md").write_text("# mine\n") + assert stale_forks("scout") == [], "nothing upstream ships this name yet" + + (stock / "skills" / "alpha").mkdir(parents=True) + (stock / "skills" / "alpha" / "SKILL.md").write_text("# upstream ships this now\n") + + stale = stale_forks("scout") + assert [f.rel for f in stale] == ["skills/alpha/SKILL.md"] + assert stale[0].unprovenanced is True + + +def test_a_brand_new_agent_file_is_never_reported(shipped): + """Authored, with no shipped counterpart: there is nothing to diverge from.""" + from condor.memory.paths import agent_home_layers + + local, _ = agent_home_layers("scout") + (local / "routines").mkdir(parents=True) + (local / "routines" / "my_own.py").write_text("# all my own work\n") + + assert stale_forks("scout") == [] + + +# ── The predictive half: what *this* update would change ── + + +def test_the_incoming_set_is_checked_against_local_overrides(shipped): + """stale_forks() is retrospective; this answers before the update lands.""" + from condor.layering import fork_if_stock, locally_overridden + + fork_if_stock("scout", "AGENT.md") + + overridden = locally_overridden( + [ + "agents/scout/AGENT.md", + "agents/keeper/AGENT.md", + "main.py", + "frontend/src/App.tsx", + ] + ) + # Only the one this install actually has its own copy of. + assert overridden == ["agents/scout/AGENT.md"] + + +def test_nothing_is_overridden_on_an_untouched_install(shipped): + from condor.layering import locally_overridden + + assert locally_overridden(["agents/scout/AGENT.md", "main.py"]) == [] From 6fab0e4f2338f315b1401854b177ef7845c36065 Mon Sep 17 00:00:00 2001 From: david-hummingbot Date: Fri, 18 Sep 2026 15:00:39 +0800 Subject: [PATCH 18/43] Say whether a timed-out docker step is safe to retry The 1800s ceiling exists so a hung pull cannot wedge the update forever, but hitting it returned a bare "Timed out after 1800s: docker compose pull" and left the operator to guess whether a half-rebuilt stack was safe to touch. It is. compose pull and compose up -d are both idempotent, so the answer is to run the update again -- which is the one thing the message did not say. The frontend build already got this treatment; the docker steps did not, and they are the ones most likely to hit a ceiling: on Docker Desktop for macOS, or on WSL2 with the daemon behind a VM, a cold pull legitimately takes several times longer than it does on Linux. --- tests/test_update_frontend_build.py | 26 ++++++++++++++++++++++++++ utils/updater.py | 25 +++++++++++++++++++++++-- 2 files changed, 49 insertions(+), 2 deletions(-) diff --git a/tests/test_update_frontend_build.py b/tests/test_update_frontend_build.py index 7fe6dc751..b8cd63418 100644 --- a/tests/test_update_frontend_build.py +++ b/tests/test_update_frontend_build.py @@ -122,3 +122,29 @@ def test_adoption_does_nothing_when_a_bundle_is_already_in_place(tmp_path): def test_adoption_is_a_no_op_with_nothing_to_adopt(tmp_path): _adopt_orphaned_bundle(tmp_path / "dist") assert not (tmp_path / "dist").exists() + + +# ── docker steps get the same treatment (H2, P3) ── + + +def test_a_docker_timeout_says_retrying_is_safe(): + """1800s exists so a hung pull cannot wedge the update; hitting it said nothing. + + Both compose steps are idempotent, so the honest answer is "run it again" — + but the bare "Timed out after 1800s" left the operator guessing whether a + half-rebuilt stack was safe to touch. + """ + message = updater._explain_docker_failure( + 124, "Timed out after 1800s: docker compose pull", "docker compose pull" + ) + assert "idempotent" in message + assert "safe" in message + # And the platforms where the ceiling is actually reachable. + assert "macOS" in message and "WSL2" in message + + +def test_an_ordinary_docker_failure_is_passed_through_unchanged(): + assert ( + updater._explain_docker_failure(1, "no such image", "docker compose pull") + == "no such image" + ) diff --git a/utils/updater.py b/utils/updater.py index 8d7b8c161..8e2ecfbb3 100644 --- a/utils/updater.py +++ b/utils/updater.py @@ -962,13 +962,34 @@ async def registry_has_digest(image_ref: str, digest: str) -> bool | None: return None +def _explain_docker_failure(rc: int, output: str, what: str) -> str: + """Turn a docker step's exit code into something the operator can act on. + + The 1800s ceiling exists so a hung pull cannot wedge the update forever, but + hitting it returned a bare "Timed out after 1800s" and left the operator to + guess whether a half-rebuilt stack was safe to touch. It is: ``compose pull`` + and ``compose up -d`` are both idempotent, so the answer is simply to run the + update again. + """ + base = output or f"{what} failed (no output)" + if rc == 124: + return ( + base + f"\n\nThe step timed out. `{what}` is idempotent, so re-running " + "the update is safe and will pick up wherever it got to. On Docker " + "Desktop for macOS, or on WSL2 with the daemon behind a VM, a cold " + "pull can legitimately take several times longer than it does on " + "Linux." + ) + return base + + async def compose_pull(repo_dir: str, service: str) -> tuple[bool, str]: """Pull the published image for one service.""" rc, output = await _run_cmd( "docker", "compose", "pull", service, cwd=repo_dir, timeout=DOCKER_TIMEOUT ) if rc != 0: - return False, output or "docker compose pull failed (no output)" + return False, _explain_docker_failure(rc, output, "docker compose pull") return True, output @@ -978,7 +999,7 @@ async def compose_up(repo_dir: str) -> tuple[bool, str]: "docker", "compose", "up", "-d", cwd=repo_dir, timeout=DOCKER_TIMEOUT ) if rc != 0: - return False, output or "docker compose up failed (no output)" + return False, _explain_docker_failure(rc, output, "docker compose up -d") return True, output From 148f1cf9b45a34127082a6a972b993ad44c0ecbd Mon Sep 17 00:00:00 2001 From: david-hummingbot Date: Fri, 18 Sep 2026 15:07:23 +0800 Subject: [PATCH 19/43] Start the relaunch countdown without an effect react-hooks/set-state-in-effect, twice, both mine: setPhase("counting") ran synchronously in an effect body, and the countdown effect called relaunchNow() directly when it hit zero, which does the same thing one frame later. The first was an effect doing work that belongs in initial state. The banner now splits: the outer component is only a gate on `required`, and the strip it mounts opens in "counting" because that is its useState default. Nothing has to set it after the first render, and the `started` ref that existed only to make the effect fire once is gone with it. The second was a real cascade: reaching zero re-rendered to set the phase, which re-ran the effect. The timer now owns it -- one timeout whose callback either decrements or fires the relaunch, so every setState happens across an async boundary rather than during the effect. No behaviour change: same countdown, same cancel, same reconnect-and-reload. --- frontend/src/components/RelaunchBanner.tsx | 49 ++++++++++++---------- 1 file changed, 27 insertions(+), 22 deletions(-) diff --git a/frontend/src/components/RelaunchBanner.tsx b/frontend/src/components/RelaunchBanner.tsx index 05c2fe786..d080563b7 100644 --- a/frontend/src/components/RelaunchBanner.tsx +++ b/frontend/src/components/RelaunchBanner.tsx @@ -1,5 +1,5 @@ import { RotateCw } from "lucide-react"; -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import { useRelaunch } from "@/hooks/useRelaunch"; import { updatesApi } from "@/lib/updates-api"; @@ -33,16 +33,28 @@ const COUNTDOWN_SECONDS = 5; const RECONNECT_TIMEOUT_MS = 90_000; const RECONNECT_INTERVAL_MS = 1_000; -type Phase = "idle" | "counting" | "restarting" | "cancelled" | "stuck"; +type Phase = "counting" | "restarting" | "cancelled" | "stuck"; +type Relaunch = { + from_commit?: string; + target_commit?: string; +}; + +/** + * Gate only. The countdown lives in `RelaunchStrip`, which is mounted the + * moment a relaunch becomes required — so its opening phase is its *initial* + * state rather than something an effect has to set after the first render. + */ export function RelaunchBanner() { const { data } = useRelaunch(); - const [phase, setPhase] = useState("idle"); + if (!data?.required) return null; + return ; +} + +function RelaunchStrip({ data }: { data: Relaunch }) { + const [phase, setPhase] = useState("counting"); const [left, setLeft] = useState(COUNTDOWN_SECONDS); const [showHow, setShowHow] = useState(false); - const started = useRef(false); - - const required = data?.required ?? false; // Wait for the server to answer again, then reload once to pick up the new // bundle. Polled rather than pushed: the socket is down for the whole window, @@ -80,29 +92,22 @@ export function RelaunchBanner() { waitForServer(); }, [waitForServer]); - // Start the countdown once, the first time a relaunch becomes required. - useEffect(() => { - if (!required || started.current) return; - started.current = true; - setPhase("counting"); - }, [required]); - + // One timer, and everything it does happens inside the callback. Firing the + // relaunch from the effect body instead would be a synchronous setState + // during an effect, which cascades renders. useEffect(() => { if (phase !== "counting") return; - if (left <= 0) { - void relaunchNow(); - return; - } - const id = window.setTimeout(() => setLeft((n) => n - 1), 1000); + const id = window.setTimeout(() => { + if (left <= 1) void relaunchNow(); + else setLeft(left - 1); + }, 1000); return () => window.clearTimeout(id); }, [phase, left, relaunchNow]); - if (!required) return null; - const moved = - data?.from_commit && data?.target_commit + data.from_commit && data.target_commit ? `${data.from_commit} → ${data.target_commit}` - : (data?.target_commit ?? ""); + : (data.target_commit ?? ""); return (
    From 10edc3f8f897b3dacfca6b87ab4f28aa05a3ddc8 Mon Sep 17 00:00:00 2001 From: david-hummingbot Date: Tue, 29 Sep 2026 10:08:51 +0700 Subject: [PATCH 20/43] Name the library a stale fork belongs to MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `StaleFork.rel` is relative to one library's root, so every forked playbook rendered as a bare `AGENT.md` and the warning read "(AGENT.md, AGENT.md, AGENT.md)" — three files it could not tell apart. Nine agents ship, so an install that has customized more than one is the ordinary case, not an edge one, and main retiring three agents makes a retired entry collide with a stale one under the same name. `rel` keeps its meaning, because the stores and the tests read it as library-relative. The qualified form is a new `label`, which is what the surfaces show. --- condor/layering.py | 22 +++++++++++++++++++--- condor/updates/components.py | 6 +++--- tests/test_stale_forks.py | 27 +++++++++++++++++++++++++++ 3 files changed, 49 insertions(+), 6 deletions(-) diff --git a/condor/layering.py b/condor/layering.py index 522390f78..697e0dd97 100644 --- a/condor/layering.py +++ b/condor/layering.py @@ -248,6 +248,19 @@ class StaleFork: """The digest of the stock file at fork time; ``""`` when never stamped.""" stock_digest: str | None """The stock file's digest now; ``None`` when it no longer exists.""" + owner: str = "" + """Which library it belongs to -- an agent slug, or ``_shared``.""" + + @property + def label(self) -> str: + """What a message should show: the item qualified by its library. + + :attr:`rel` is relative to *one* library's root, so three agents with a + customized playbook each render as a bare ``AGENT.md`` and the reader + cannot tell which is which. Nine agents ship, so that is the ordinary + case rather than an edge one. + """ + return f"{self.owner}/{self.rel}" if self.owner else self.rel @property def retired(self) -> bool: @@ -302,6 +315,9 @@ def stale_forks(agent_slug: str | None) -> list[StaleFork]: if not local.is_dir(): return [] + # Read off the resolved path rather than from ``agent_slug``, which is None + # for the chat agent and would leave its items unqualified. + owner = local.name out: list[StaleFork] = [] for path in sorted(local.rglob("*")): if not path.is_file(): @@ -318,14 +334,14 @@ def stale_forks(agent_slug: str | None) -> list[StaleFork]: # ordinary file the agent authored, which was never a fork of # anything and has nothing to be stale against. if forked_from: - out.append(StaleFork(path, str(rel), forked_from, None)) + out.append(StaleFork(path, str(rel), forked_from, None, owner)) continue now = content_digest(counterpart) if forked_from: if now == forked_from or _legacy_digest(counterpart) == forked_from: continue - out.append(StaleFork(path, str(rel), forked_from, now)) + out.append(StaleFork(path, str(rel), forked_from, now, owner)) continue # No stamp, but stock ships this exact path, so the local copy is @@ -333,7 +349,7 @@ def stale_forks(agent_slug: str | None) -> list[StaleFork]: # -- nothing to report. Different content means upstream's version is # unreachable and nothing recorded that it ever matched. if now != content_digest(path): - out.append(StaleFork(path, str(rel), "", now)) + out.append(StaleFork(path, str(rel), "", now, owner)) return out diff --git a/condor/updates/components.py b/condor/updates/components.py index 8c0666ccd..de2a09993 100644 --- a/condor/updates/components.py +++ b/condor/updates/components.py @@ -886,7 +886,7 @@ def _stale_fork_warning() -> Warning | None: # routine's .py cannot hold a stamp, and a file the agent created under # a name upstream later shipped never had one to take. Either way the # local copy wins and upstream's is unreachable. - shown = ", ".join(f.rel for f in shadowing[:_STALE_FORK_CAP]) + shown = ", ".join(f.label for f in shadowing[:_STALE_FORK_CAP]) if len(shadowing) > _STALE_FORK_CAP: shown += f", and {len(shadowing) - _STALE_FORK_CAP} more" parts.append( @@ -897,7 +897,7 @@ def _stale_fork_warning() -> Warning | None: "yours if you meant to keep both." ) if moved: - shown = ", ".join(f.rel for f in moved[:_STALE_FORK_CAP]) + shown = ", ".join(f.label for f in moved[:_STALE_FORK_CAP]) if len(moved) > _STALE_FORK_CAP: shown += f", and {len(moved) - _STALE_FORK_CAP} more" parts.append( @@ -907,7 +907,7 @@ def _stale_fork_warning() -> Warning | None: "will not reach your agents." ) if retired: - shown = ", ".join(f.rel for f in retired[:_STALE_FORK_CAP]) + shown = ", ".join(f.label for f in retired[:_STALE_FORK_CAP]) if len(retired) > _STALE_FORK_CAP: shown += f", and {len(retired) - _STALE_FORK_CAP} more" parts.append( diff --git a/tests/test_stale_forks.py b/tests/test_stale_forks.py index 27c403911..0e7eadacb 100644 --- a/tests/test_stale_forks.py +++ b/tests/test_stale_forks.py @@ -214,3 +214,30 @@ def test_nothing_is_overridden_on_an_untouched_install(shipped): from condor.layering import locally_overridden assert locally_overridden(["agents/scout/AGENT.md", "main.py"]) == [] + + +# ── The report names which library each item came from ── + + +def test_two_agents_with_the_same_filename_are_told_apart(shipped): + """``rel`` alone renders every forked playbook as a bare ``AGENT.md``. + + Nine agents ship, so an install that has customized more than one is the + ordinary case — and "(AGENT.md, AGENT.md, AGENT.md)" names none of them. + """ + from condor.layering import all_stale_forks + + other = _write( + stock_agent_home("brigado") / "AGENT.md", + AGENT_MD.format(name="Brigado", body="Hold overnight."), + ) + for slug in ("scout", "brigado"): + fork_if_stock(slug, "AGENT.md") + # Upstream rewrites both. + for path, body in ((shipped, "UPSTREAM scout."), (other, "UPSTREAM brigado.")): + path.write_text(AGENT_MD.format(name="x", body=body), "utf-8") + + labels = sorted(f.label for f in all_stale_forks()) + assert labels == ["brigado/AGENT.md", "scout/AGENT.md"] + # ``rel`` keeps its meaning — relative to the library it belongs to. + assert {f.rel for f in all_stale_forks()} == {"AGENT.md"} From 0df54648db6917d53d89675a4b29de9f7c2f2370 Mon Sep 17 00:00:00 2001 From: david-hummingbot Date: Tue, 29 Sep 2026 10:10:03 +0700 Subject: [PATCH 21/43] Scan the shared library for stale forks too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `all_stale_forks` walked `iter_agent_slugs()`, which is built on `_is_agent_dir` — and that rejects a leading underscore. So `_shared`, the library every agent reads *under* its own, was never passed in: published skills, routines and (since FEAT-126) controllers could all diverge from the shipped copies in silence. The comparison itself already worked — `stale_forks("_shared")` returned the right answer all along, nothing ever called it. Only the enumeration was short. The asymmetry this leaves behind is worth naming: `locally_overridden` walks raw paths, so the *pre-update* warning has always covered `_shared`. An operator was told once, at the update that changed the file, and then never again — including when the customization came after the change had landed. --- condor/layering.py | 9 ++++++- tests/test_stale_forks.py | 49 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 1 deletion(-) diff --git a/condor/layering.py b/condor/layering.py index 697e0dd97..0cab36a6f 100644 --- a/condor/layering.py +++ b/condor/layering.py @@ -397,13 +397,20 @@ def locally_overridden(rel_paths: list[str]) -> list[str]: return out +#: The library every agent reads *under* its own — published skills, routines +#: and controllers. Not an agent, so ``iter_agent_slugs`` never yields it: +#: ``_is_agent_dir`` rejects a leading underscore, which is what kept three +#: whole trees out of the scan below. +SHARED_LIBRARY = "_shared" + + def all_stale_forks() -> list[StaleFork]: """:func:`stale_forks` across every agent this install has, plus the shared root.""" from condor.memory.paths import iter_agent_slugs out: list[StaleFork] = [] seen: set[Path] = set() - for slug in [None, *iter_agent_slugs()]: + for slug in [None, SHARED_LIBRARY, *iter_agent_slugs()]: for fork in stale_forks(slug): if fork.path in seen: continue diff --git a/tests/test_stale_forks.py b/tests/test_stale_forks.py index 0e7eadacb..363a4c8be 100644 --- a/tests/test_stale_forks.py +++ b/tests/test_stale_forks.py @@ -241,3 +241,52 @@ def test_two_agents_with_the_same_filename_are_told_apart(shipped): assert labels == ["brigado/AGENT.md", "scout/AGENT.md"] # ``rel`` keeps its meaning — relative to the library it belongs to. assert {f.rel for f in all_stale_forks()} == {"AGENT.md"} + + +# ── The shared library is scanned too ── + + +@pytest.mark.parametrize( + ("rel", "body"), + [ + # A skill: markdown, so it could carry a stamp — this one does not, + # because a hand copy is how these actually get customized. + (("skills", "charting", "SKILL.md"), "---\nname: Charting\n---\n\n{tag}\n"), + # A routine: .py cannot hold frontmatter, so it never has a stamp. + (("routines", "backtest_chart.py"), "# {tag}\n"), + # A controller: the shape FEAT-126 added, same layering rule. + (("controllers", "pmm_v1", "pmm_v1.py"), "# {tag}\n"), + ], +) +def test_a_customized_shared_item_is_reported(rel, body): + """``_shared`` is read by every agent but is not an agent. + + ``iter_agent_slugs`` is built on ``_is_agent_dir``, which rejects a leading + underscore — so the shared skills, routines and controllers were the one + tree that no scan ever reached, and a customized one diverged in silence. + """ + from condor.layering import all_stale_forks + from condor.memory.paths import local_agents_root, stock_agents_root + + shipped = _write(stock_agents_root().joinpath("_shared", *rel), body.format(tag="shipped")) + _write(local_agents_root().joinpath("_shared", *rel), body.format(tag="mine")) + + # Identical-but-for-my-edit already differs, so it reports immediately; + # what matters is that an upstream rewrite keeps reporting it. + shipped.write_text(body.format(tag="UPSTREAM rewrote this"), "utf-8") + + stale = [f for f in all_stale_forks() if f.owner == "_shared"] + assert [f.label for f in stale] == ["_shared/" + "/".join(rel)] + assert stale[0].unprovenanced is True + + +def test_an_identical_shared_copy_is_not_reported(): + """Copying is not diverging — the same rule the per-agent scan applies.""" + from condor.layering import all_stale_forks + from condor.memory.paths import local_agents_root, stock_agents_root + + text = "---\nname: Charting\n---\n\nSame.\n" + _write(stock_agents_root() / "_shared" / "skills" / "charting" / "SKILL.md", text) + _write(local_agents_root() / "_shared" / "skills" / "charting" / "SKILL.md", text) + + assert [f for f in all_stale_forks() if f.owner == "_shared"] == [] From 12227b45e7e194c33e734a1fcaecf594b71ec93f Mon Sep 17 00:00:00 2001 From: david-hummingbot Date: Tue, 29 Sep 2026 10:15:04 +0700 Subject: [PATCH 22/43] Compare chat routines against the library they actually shadow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FEAT-033 keeps the shipped routine library at the repo-root `routines/` and FEAT-115 moved only the *write* target under the agent, so the chat's pair is `(/condor/routines, /routines)` — the one layer whose stock root is not `//`. `stale_forks` could not see that. It asks `_homes` for the pair, gets `/condor/routines` as the stock side, finds nothing there on any install, and files every chat routine under "an ordinary file the agent authored". `locally_overridden` missed it from the other end: it skipped any incoming path whose first segment was not `agents/`. So improving a shipped routine — the thing the local layer exists to allow — shadowed it in silence, before the update and after it. Both ends now ask the loader where stock is. The per-file comparison moves into `_compare_layer` so the second root reuses it rather than growing a second copy that can drift, and `rel_prefix` keeps the reported path the one the reader would go looking for. --- condor/layering.py | 77 +++++++++++++++++++++++++--- tests/test_chat_routines_layering.py | 59 +++++++++++++++++++++ tests/test_stale_forks.py | 4 +- 3 files changed, 131 insertions(+), 9 deletions(-) diff --git a/condor/layering.py b/condor/layering.py index 0cab36a6f..d989ce912 100644 --- a/condor/layering.py +++ b/condor/layering.py @@ -312,12 +312,28 @@ def stale_forks(agent_slug: str | None) -> list[StaleFork]: against. """ local, stock = _homes(agent_slug) + # Read the owner off the resolved path rather than from ``agent_slug``, + # which is None for the chat agent and would leave its items unqualified. + return _compare_layer(local, stock, local.name) + + +def _compare_layer( + local: Path, stock: Path, owner: str, rel_prefix: str = "" +) -> list[StaleFork]: + """Every file under ``local`` measured against ``stock/``. + + Split out of :func:`stale_forks` because one layer does not resolve that + way: the chat agent's routines fall back to the repo-root ``routines/`` + rather than to ``/condor/routines`` (see :func:`_chat_routine_forks`), + so it needs the same comparison against a stock root of its own. + + ``rel_prefix`` keeps the reported path the one the reader would look for -- + a chat routine is reported as ``routines/.py``, which is where it sits + under the agent, not as the bare filename the scan happens to walk. + """ if not local.is_dir(): return [] - # Read off the resolved path rather than from ``agent_slug``, which is None - # for the chat agent and would leave its items unqualified. - owner = local.name out: list[StaleFork] = [] for path in sorted(local.rglob("*")): if not path.is_file(): @@ -325,6 +341,7 @@ def stale_forks(agent_slug: str | None) -> list[StaleFork]: rel = path.relative_to(local) counterpart = stock / rel + shown = f"{rel_prefix}/{rel}" if rel_prefix else str(rel) forked_from = _stamp_of(path) if not counterpart.is_file(): @@ -334,14 +351,14 @@ def stale_forks(agent_slug: str | None) -> list[StaleFork]: # ordinary file the agent authored, which was never a fork of # anything and has nothing to be stale against. if forked_from: - out.append(StaleFork(path, str(rel), forked_from, None, owner)) + out.append(StaleFork(path, shown, forked_from, None, owner)) continue now = content_digest(counterpart) if forked_from: if now == forked_from or _legacy_digest(counterpart) == forked_from: continue - out.append(StaleFork(path, str(rel), forked_from, now, owner)) + out.append(StaleFork(path, shown, forked_from, now, owner)) continue # No stamp, but stock ships this exact path, so the local copy is @@ -349,7 +366,7 @@ def stale_forks(agent_slug: str | None) -> list[StaleFork]: # -- nothing to report. Different content means upstream's version is # unreachable and nothing recorded that it ever matched. if now != content_digest(path): - out.append(StaleFork(path, str(rel), "", now, owner)) + out.append(StaleFork(path, shown, "", now, owner)) return out @@ -390,9 +407,21 @@ def locally_overridden(rel_paths: list[str]) -> list[str]: out: list[str] = [] for rel in rel_paths: parts = PurePosixPath(rel).parts - if len(parts) < 2 or parts[0] != "agents": + if len(parts) < 2: + continue + if parts[0] == "agents": + local = root.joinpath(*parts[1:]) + elif parts[0] == "routines": + # The chat agent's library is the exception: it is shipped at the + # repo root, not under ``agents/``, and its local layer sits at + # ``/condor/routines`` (see ``_chat_routine_forks``). Without + # this arm an update that rewrites a shipped routine the chat has + # improved matched nothing, and the one warning that fires before + # the change lands never mentioned it. + local = root.joinpath(CHAT_LIBRARY, *parts) + else: continue - if (root.joinpath(*parts[1:])).is_file(): + if local.is_file(): out.append(rel) return out @@ -403,6 +432,33 @@ def locally_overridden(rel_paths: list[str]) -> list[str]: #: whole trees out of the scan below. SHARED_LIBRARY = "_shared" +#: The chat agent's own library. Named here because its routines are the one +#: layer that falls back to the repo root rather than to the shipped agent tree. +CHAT_LIBRARY = "condor" + + +def _chat_routine_forks() -> list[StaleFork]: + """Chat-authored routines shadowing the library they were copied from. + + The one layer whose stock root is not ``//``. FEAT-033 keeps + the shipped library at the repo-root ``routines/`` and FEAT-115 moved only + the *write* target under the agent, so the pair is + ``(/condor/routines, /routines)`` -- which is what + :func:`routines.base.assistant_routines_dirs` resolves and what the loader + actually reads. + + :func:`stale_forks` cannot see it. It asks ``_homes`` for the pair, gets + ``/condor/routines`` as the stock side, finds nothing there on any + install, and files every chat routine under "an ordinary file the agent + authored" -- so improving a shipped routine, which is the whole point of + the local layer, shadowed it in silence. Ask the loader where stock is + instead. + """ + from routines.base import assistant_routines_dirs + + local, stock = assistant_routines_dirs(None) + return _compare_layer(local, stock, CHAT_LIBRARY, rel_prefix="routines") + def all_stale_forks() -> list[StaleFork]: """:func:`stale_forks` across every agent this install has, plus the shared root.""" @@ -410,6 +466,11 @@ def all_stale_forks() -> list[StaleFork]: out: list[StaleFork] = [] seen: set[Path] = set() + # The chat routines first: the per-agent scan below walks the same files + # under ``/condor`` and would judge them against the wrong root. + for fork in [*_chat_routine_forks()]: + seen.add(fork.path) + out.append(fork) for slug in [None, SHARED_LIBRARY, *iter_agent_slugs()]: for fork in stale_forks(slug): if fork.path in seen: diff --git a/tests/test_chat_routines_layering.py b/tests/test_chat_routines_layering.py index ad217354a..b82d532c0 100644 --- a/tests/test_chat_routines_layering.py +++ b/tests/test_chat_routines_layering.py @@ -145,3 +145,62 @@ def test_the_lift_is_idempotent(repo): assert first.chat_routines == 1 assert second.chat_routines == 0 + + +# ── The chat's local layer is reported when upstream moves under it ── + + +def _chat_layers(tmp_path=None): + from routines.base import assistant_routines_dirs + + return assistant_routines_dirs(None) + + +def test_an_improved_shipped_routine_is_reported_before_and_after(monkeypatch): + """The scenario the local layer exists to create, and nothing reported it. + + FEAT-033 keeps the shipped library at the repo-root ``routines/`` and only + the write target moved under the agent — so the generic scan looked for + stock at ``/condor/routines``, found nothing, and filed the file as + something the agent authored from scratch. + """ + from condor.layering import all_stale_forks, locally_overridden + + local_dir, stock_dir = _chat_layers() + shipped = stock_dir / "arb_check.py" + original = shipped.read_bytes() if shipped.is_file() else None + try: + shipped.parent.mkdir(parents=True, exist_ok=True) + shipped.write_text("# shipped\n", "utf-8") + local_dir.mkdir(parents=True, exist_ok=True) + (local_dir / "arb_check.py").write_text( + "# shipped\n# my improvement\n", "utf-8" + ) + + # Before: the update is about to rewrite a file we have our own copy of. + assert locally_overridden(["routines/arb_check.py"]) == [ + "routines/arb_check.py" + ] + + # After: upstream's rewrite has landed and ours still wins. + shipped.write_text("# UPSTREAM rewrote this\n", "utf-8") + stale = [f for f in all_stale_forks() if f.rel == "routines/arb_check.py"] + assert len(stale) == 1, "the chat's shadow of a shipped routine went unreported" + assert stale[0].label == "condor/routines/arb_check.py" + assert stale[0].unprovenanced is True, ".py can never carry a stamp" + finally: + if original is None: + shipped.unlink(missing_ok=True) + else: + shipped.write_bytes(original) + + +def test_a_routine_the_chat_invented_is_not_reported(): + """No shipped counterpart means it was never a fork of anything.""" + from condor.layering import all_stale_forks + + local_dir, _ = _chat_layers() + local_dir.mkdir(parents=True, exist_ok=True) + (local_dir / "my_own_idea.py").write_text("# mine alone\n", "utf-8") + + assert [f for f in all_stale_forks() if f.rel.endswith("my_own_idea.py")] == [] diff --git a/tests/test_stale_forks.py b/tests/test_stale_forks.py index 363a4c8be..3ee5c91dc 100644 --- a/tests/test_stale_forks.py +++ b/tests/test_stale_forks.py @@ -268,7 +268,9 @@ def test_a_customized_shared_item_is_reported(rel, body): from condor.layering import all_stale_forks from condor.memory.paths import local_agents_root, stock_agents_root - shipped = _write(stock_agents_root().joinpath("_shared", *rel), body.format(tag="shipped")) + shipped = _write( + stock_agents_root().joinpath("_shared", *rel), body.format(tag="shipped") + ) _write(local_agents_root().joinpath("_shared", *rel), body.format(tag="mine")) # Identical-but-for-my-edit already differs, so it reports immediately; From c0ad88cd5f5b360ce64bfca6777fd5e918f3b055 Mon Sep 17 00:00:00 2001 From: david-hummingbot Date: Tue, 29 Sep 2026 10:51:52 +0700 Subject: [PATCH 23/43] Stop the update lock erasing the pid it is about to report MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `_acquire_run_lock` opened the file `"w"`, which truncates at open — before `flock` is even attempted. So the process that lost the race wiped the pid written by the process holding it, `start` then read an empty file, and the `(pid N)` the refusal exists to carry was dropped from every message. Anyone who went and looked at `update.lock` afterwards found it empty too. Open `"a+"` and empty the file after the lock is held, which is the point at which this process is entitled to write it. --- condor/updates/run.py | 9 +++++- tests/test_update_run_lock.py | 61 +++++++++++++++++++++++++++++++++++ 2 files changed, 69 insertions(+), 1 deletion(-) create mode 100644 tests/test_update_run_lock.py diff --git a/condor/updates/run.py b/condor/updates/run.py index c0b514fbb..d53cf08ea 100644 --- a/condor/updates/run.py +++ b/condor/updates/run.py @@ -401,7 +401,12 @@ def _acquire_run_lock() -> "io.TextIOWrapper | None": path = runtime_root() / _LOCK_FILENAME try: path.parent.mkdir(parents=True, exist_ok=True) - handle = path.open("w") + # "a+" rather than "w". "w" truncates at *open*, before the lock is even + # attempted, so the process that loses the race wipes the pid written by + # the one holding it -- and ``start`` then reads an empty file and drops + # the "(pid N)" the refusal exists to carry. The file is emptied below, + # once this process is the one entitled to write it. + handle = path.open("a+") except OSError: log.debug("Could not open the update lock; proceeding unlocked") return None @@ -413,6 +418,8 @@ def _acquire_run_lock() -> "io.TextIOWrapper | None": except (AttributeError, NameError): # pragma: no cover - no flock here return handle try: + handle.seek(0) + handle.truncate() handle.write(f"{os.getpid()}\n") handle.flush() except OSError: diff --git a/tests/test_update_run_lock.py b/tests/test_update_run_lock.py new file mode 100644 index 000000000..334406281 --- /dev/null +++ b/tests/test_update_run_lock.py @@ -0,0 +1,61 @@ +"""The advisory lock names who holds it (C16). + +``start`` refuses a second run on the same checkout and says which process is +already updating. It could only ever say that if the pid survives being read +by the process that lost the race. +""" + +from __future__ import annotations + +import fcntl +import os + +from condor.paths import runtime_root +from condor.updates.run import _acquire_run_lock, _release_run_lock + + +def test_the_loser_of_the_race_can_still_read_the_holders_pid(): + """Opening ``"w"`` truncated the file before ``flock`` was even attempted. + + So the contender erased the pid on its way to being refused, ``start`` + read an empty file, and the "(pid N)" clause was dropped from every + conflict message — and anyone inspecting ``update.lock`` afterwards found + it empty too. + """ + handle = _acquire_run_lock() + assert handle is not None, "nothing else should hold the lock in a test" + path = runtime_root() / "update.lock" + try: + assert path.read_text().strip() == str(os.getpid()) + + # Exactly what a second process does: open the same path, then fail to + # take the lock. The open must not destroy what the holder wrote. + contender = path.open("a+") + try: + raised = False + try: + fcntl.flock(contender.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) + except OSError: + raised = True + assert raised, "the contender should not have got the lock" + finally: + contender.close() + + assert path.read_text().strip() == str( + os.getpid() + ), "the contender's open truncated the holder's pid" + finally: + _release_run_lock(handle) + + +def test_the_lock_is_rewritten_rather_than_appended(): + """``"a+"`` must not leave a run's pid stacked under the previous one.""" + first = _acquire_run_lock() + assert first is not None + _release_run_lock(first) + second = _acquire_run_lock() + assert second is not None + try: + assert (runtime_root() / "update.lock").read_text().strip() == str(os.getpid()) + finally: + _release_run_lock(second) From 96eeb56926ae601b1abb1ddf3fe7594e8b986042 Mon Sep 17 00:00:00 2001 From: david-hummingbot Date: Tue, 29 Sep 2026 10:51:52 +0700 Subject: [PATCH 24/43] Read the dashboard's real bind on hosts without ss MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `_listening_binds` falls back to `lsof`, and `-sTCP:LISTEN` makes lsof print the connection state as its own trailing token: NAME is `TCP 127.0.0.1:8088 (LISTEN)`, so the last column is `(LISTEN)`, not an address. Splitting it on "(" produced the empty string — and the empty string was in `_is_public_bind`'s wildcard set. So on every host without `ss` (macOS, WSL without iproute2) any listener on the dashboard port read as bound to all interfaces. In local mode that is a FAIL, and its remedy is "unset WEB_HOST" — a variable the operator never set, on a port that was on loopback all along. That matters more now than it did: this branch runs the doctor at the end of every update, so the one thing the new WARNED step reported on a healthy macOS install was this phantom. The empty string also stops counting as a wildcard. A bind this code could not parse is unknown, and unknown is not evidence of exposure — otherwise the next parsing slip becomes a security warning again. --- condor/doctor.py | 20 ++++++++++++++-- tests/test_doctor.py | 55 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 73 insertions(+), 2 deletions(-) diff --git a/condor/doctor.py b/condor/doctor.py index 206d85265..4b2846726 100644 --- a/condor/doctor.py +++ b/condor/doctor.py @@ -317,8 +317,17 @@ def _listening_binds(port: int) -> list[str]: binds = [] for line in proc.stdout.splitlines()[1:]: # skip header row cols = line.split() + # ``-sTCP:LISTEN`` makes lsof print the state as its own + # trailing token, so NAME is ``TCP 127.0.0.1:8088 (LISTEN)`` + # and the last column is ``(LISTEN)`` -- not an address at all. + # Taking it and splitting on "(" yielded the empty string, + # which ``_is_public_bind`` used to read as a wildcard: every + # loopback listener on a host without ``ss`` reported as bound + # to all interfaces. + if cols and cols[-1].startswith("("): + cols = cols[:-1] if cols: - binds.append(cols[-1].split("(")[0]) + binds.append(cols[-1]) return binds except Exception: pass @@ -357,8 +366,15 @@ def _bind_host(addr: str) -> str: def _is_public_bind(addr: str) -> bool: + """Whether ``addr`` is a wildcard bind — reachable from off this machine. + + The empty string is deliberately *not* a wildcard. It is what a bind this + code failed to parse looks like, and answering True for it turns a parsing + bug into a security warning about a port that may well be on loopback. + Unknown is not public; a bind nobody could read is reported by nothing. + """ host = _bind_host(addr) - return host in ("0.0.0.0", "*", "::", "[::]", "") + return host in ("0.0.0.0", "*", "::", "[::]") def check_dashboard_port() -> list[Check]: diff --git a/tests/test_doctor.py b/tests/test_doctor.py index d3b8ae378..5d3507227 100644 --- a/tests/test_doctor.py +++ b/tests/test_doctor.py @@ -479,3 +479,58 @@ def test_hummingbot_api_check_fails_when_env_exists_but_config_does_not( checks = doctor.check_hummingbot_api() assert checks[0].state == doctor.FAIL + + +# ── The dashboard-port check reads the bind it was given (macOS lsof) ── + + +def test_a_loopback_bind_is_not_reported_as_public(): + """``lsof -sTCP:LISTEN`` prints the state as its own trailing token. + + NAME is ``TCP 127.0.0.1:8088 (LISTEN)``, so the last column is + ``(LISTEN)``. Taking it and splitting on "(" produced the empty string, + which counted as a wildcard — so on any host without ``ss`` (macOS, and + WSL without iproute2) a loopback-only dashboard was reported as reachable + on all interfaces, and in local mode that is a FAIL whose remedy is to + unset a variable nobody set. + """ + import socket + + from condor.doctor import _is_public_bind, _listening_binds + + sock = socket.socket() + sock.bind(("127.0.0.1", 0)) + sock.listen(1) + try: + binds = _listening_binds(sock.getsockname()[1]) + assert binds, "nothing parsed out of the listener listing" + assert all(not _is_public_bind(b) for b in binds), binds + assert all(b.startswith("127.0.0.1:") for b in binds), binds + finally: + sock.close() + + +def test_a_wildcard_bind_is_still_reported_as_public(): + """The check has to keep catching the thing it exists for.""" + import socket + + from condor.doctor import _is_public_bind, _listening_binds + + sock = socket.socket() + sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + sock.bind(("0.0.0.0", 0)) + sock.listen(1) + try: + binds = _listening_binds(sock.getsockname()[1]) + assert binds and any(_is_public_bind(b) for b in binds), binds + finally: + sock.close() + + +def test_an_unparseable_bind_is_unknown_rather_than_public(): + """A bind this code could not read is not evidence of exposure.""" + from condor.doctor import _is_public_bind + + assert _is_public_bind("") is False + assert _is_public_bind("0.0.0.0:8088") is True + assert _is_public_bind("[::]:8088") is True From 0c9d40c9e52e734efec92251d811e7249022c881 Mon Sep 17 00:00:00 2001 From: david-hummingbot Date: Tue, 29 Sep 2026 10:53:24 +0700 Subject: [PATCH 25/43] Say a branch was never pushed, rather than blaming the network MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `git fetch origin ` exits 128 both when the remote is unreachable and when the remote answered fine and has no branch of that name. The guard read every failure as the first, so an unpushed feature branch — the ordinary state of anyone mid-change — produced "the remote is unreachable", and the status card said "Failed to fetch from remote". Both send the reader to debug a network that was never the problem, and `incoming-unknown` is the wrong shape besides: nothing is incoming, there is no upstream at all. `no-upstream-branch` names the branch, says it exists only here, and gives the two ways out — push it, or switch to one that tracks origin, naming that branch when the remote's default can be read. The block stays, because a branch with no upstream genuinely cannot be fast-forwarded; what changes is that the reason is true. --- condor/updates/components.py | 22 ++++++++++- tests/test_update_git_guards.py | 67 +++++++++++++++++++++++++++++++++ utils/updater.py | 27 ++++++++++++- 3 files changed, 113 insertions(+), 3 deletions(-) diff --git a/condor/updates/components.py b/condor/updates/components.py index de2a09993..fc5da846f 100644 --- a/condor/updates/components.py +++ b/condor/updates/components.py @@ -630,8 +630,28 @@ async def repo_blocks(component: Component) -> list[Block]: # ref is how local work gets clobbered by commits nobody had seen yet. So a # fetch that failed is not a detail to shrug off: it is the whole basis of # the comparison below, gone. - fetched, _ = await updater.fetch(component.repo_dir) + fetched, fetch_output = await updater.fetch(component.repo_dir) if not fetched: + if updater.is_missing_remote_ref(fetch_output): + # Not an unreachable remote: the remote answered and has no branch + # of this name. Saying "unreachable" sent the reader to check a + # network that was never the problem, and `incoming-unknown` is the + # wrong shape too -- nothing is incoming, there is no upstream. + default = await updater.remote_default_branch(component.repo_dir) + switch = f" (`git switch {default}`)" if default else "" + return [ + Block( + component=component.key, + code="no-upstream-branch", + message=( + f"`{branch}` exists only in this checkout — origin has " + "no branch of that name, so there is nothing to update " + "from. Push it to set an upstream, or switch to a " + f"branch that tracks origin{switch}." + ), + resolutions=["cancel"], + ) + ] return [_incoming_unknown(component, branch, "the remote is unreachable")] ahead, dirty, incoming = await asyncio.gather( diff --git a/tests/test_update_git_guards.py b/tests/test_update_git_guards.py index 185890c0b..4f00ea7c7 100644 --- a/tests/test_update_git_guards.py +++ b/tests/test_update_git_guards.py @@ -111,3 +111,70 @@ async def test_the_remote_default_branch_is_read_not_assumed(repo, tmp_path): assert await updater.remote_default_branch(str(clone)) == "main" # No origin at all -> "cannot tell", never a guess. assert await updater.remote_default_branch(str(repo)) is None + + +# ── A branch that was never pushed is not an unreachable remote ── + + +@pytest.fixture +def cloned(tmp_path): + """A checkout with a real ``origin`` that answers — a local bare repo.""" + origin = tmp_path / "origin.git" + seed = tmp_path / "seed" + seed.mkdir() + _git("init", "-q", "-b", "main", cwd=seed) + (seed / "f.txt").write_text("1\n") + _git("add", "-A", cwd=seed) + _git("commit", "-qm", "c0", cwd=seed) + _git("clone", "-q", "--bare", str(seed), str(origin), cwd=tmp_path) + + work = tmp_path / "work" + _git("clone", "-q", str(origin), str(work), cwd=tmp_path) + return work + + +@pytest.mark.asyncio +async def test_a_local_only_branch_is_named_as_such_not_blamed_on_the_network(cloned): + """The ordinary state of anyone on a feature branch they have not pushed. + + git exits 128 both for "no such ref upstream" and for "cannot reach the + remote", so the guard reported an unpushed branch as an unreachable + remote — and sent the reader to debug a network that was fine. + """ + _git("checkout", "-q", "-b", "feat/mine", cwd=cloned) + + component = components.Component("condor", "Condor", str(cloned)) + blocks = await components.repo_blocks(component) + + assert [b.code for b in blocks] == ["no-upstream-branch"] + message = blocks[0].message + assert "feat/mine" in message + assert "unreachable" not in message.lower() + # And it says what to do about it, naming the branch that does track. + assert "main" in message + + +@pytest.mark.asyncio +async def test_a_tracked_branch_still_passes_the_guard(cloned): + """The fix must not turn a healthy checkout into a blocked one.""" + component = components.Component("condor", "Condor", str(cloned)) + assert await components.repo_blocks(component) == [] + + +@pytest.mark.asyncio +async def test_the_status_card_says_why_it_cannot_compare(cloned): + """``Failed to fetch from remote`` was the only thing the card ever said.""" + _git("checkout", "-q", "-b", "feat/mine", cwd=cloned) + + info = await updater.check_for_updates(repo_dir=str(cloned)) + + assert info["error"] and "not on origin" in info["error"] + + +def test_a_genuine_transport_failure_is_not_mistaken_for_a_missing_branch(): + assert updater.is_missing_remote_ref("fatal: couldn't find remote ref feat/x") + assert updater.is_missing_remote_ref("fatal: could not find remote ref feat/x") + assert not updater.is_missing_remote_ref( + "fatal: unable to access 'https://...': Could not resolve host: github.com" + ) + assert not updater.is_missing_remote_ref("") diff --git a/utils/updater.py b/utils/updater.py index 8e2ecfbb3..95b3fcd3e 100644 --- a/utils/updater.py +++ b/utils/updater.py @@ -181,9 +181,14 @@ async def check_for_updates(repo_dir: str = CONDOR_DIR) -> dict: result["branch"] = branch # Fetch latest from remote - rc, _ = await _run_git("fetch", "origin", branch, repo_dir=repo_dir) + rc, fetch_out = await _run_git("fetch", "origin", branch, repo_dir=repo_dir) if rc != 0: - result["error"] = "Failed to fetch from remote" + result["error"] = ( + f"`{branch}` is not on origin, so there is nothing to compare " + "against — push it, or switch to a branch that tracks origin." + if is_missing_remote_ref(fetch_out) + else "Failed to fetch from remote" + ) return result # Get local and remote commits @@ -260,6 +265,24 @@ def _lines(output: str) -> tuple[str, ...]: return tuple(line.strip() for line in (output or "").split("\n") if line.strip()) +#: What git says when the branch simply is not on the remote. Distinct from +#: every other fetch failure: the network is fine, the remote answered, and it +#: does not have this ref. Both spellings appear across git versions. +_MISSING_REMOTE_REF = ("couldn't find remote ref", "could not find remote ref") + + +def is_missing_remote_ref(output: str) -> bool: + """Whether a failed fetch means "no such branch upstream", not "no remote". + + git exits 128 for both, so the caller that cannot tell them apart reports + an unpushed local branch -- the ordinary state of anyone working on a + feature -- as an unreachable remote, and sends the operator to check their + network for a problem that is not there. + """ + lowered = (output or "").lower() + return any(marker in lowered for marker in _MISSING_REMOTE_REF) + + async def fetch(repo_dir: str = CONDOR_DIR) -> tuple[bool, str]: """Update ``origin/`` without touching the working tree.""" branch = await get_current_branch(repo_dir) From 057c5d29ce6d520048067f4a1dc357925134bae2 Mon Sep 17 00:00:00 2001 From: david-hummingbot Date: Tue, 29 Sep 2026 10:56:39 +0700 Subject: [PATCH 26/43] Recognise a local image build on the classic image store too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit H1.2 rests on `registry_has_digest`: ask the registry whether it holds *this* digest, and an image it has never seen is the operator's own build. That check needs a digest to ask about, and it never got one. `RepoDigests` is populated for a build only under the containerd image store. Under the classic overlay2 store — still the default on Docker Engine, which is where this runs — `docker build` leaves it `[]`. So `local_image_digest` returned None, `_image_facet` took the "never been pulled" arm, and preflight turned that into a hard block over the whole update, Condor's half included, coded `registry-unreachable` while the registry was answering fine in the same call. "No registry digest" and "no image" were the same answer to two different questions. `local_image_identity` separates them: `docker image inspect` fails outright when the image is absent, so the id answers "is it here" and `RepoDigests` answers "does the registry know it". Present-without-a-digest is now reported as built here — the same conclusion the containerd store reaches by the other route. Error codes move onto the facet while we are here. Preflight was deriving them with `"compose" in image.error` and filing everything else under `registry-unreachable`, which is how this failure came to name a cause that had not happened. --- condor/updates/components.py | 50 +++++++++++++++++++++++------ tests/test_image_tracks_checkout.py | 4 ++- tests/test_update_preflight.py | 39 ++++++++++++++++++---- utils/updater.py | 49 +++++++++++++++++++++++++--- 4 files changed, 120 insertions(+), 22 deletions(-) diff --git a/condor/updates/components.py b/condor/updates/components.py index fc5da846f..e5d5bca61 100644 --- a/condor/updates/components.py +++ b/condor/updates/components.py @@ -61,6 +61,14 @@ class Facet: up_to_date: bool = True detail: list[str] = field(default_factory=list) error: str | None = None + error_code: str = "" + """Which failure this is, for a surface that has to act on it. + + Set beside every ``error``. The alternative was matching on the message + text, which read ``"compose" in image.error`` and quietly filed everything + else -- an image that was never pulled, a service with no image at all -- + under ``registry-unreachable``, naming a cause that had not happened. + """ def to_wire(self) -> dict[str, Any]: return asdict(self) @@ -238,6 +246,7 @@ async def _image_facet(repo_dir: str, service: str) -> tuple[Facet, str]: kind="image", current="unknown", up_to_date=False, + error_code="docker-unavailable", error=( "Could not read the compose file — is Docker running? " "(docker compose config failed)" @@ -260,36 +269,61 @@ async def _image_facet(repo_dir: str, service: str) -> tuple[Facet, str]: kind="image", current="unknown", up_to_date=False, + error_code="no-image-declared", error=f"The {service} service declares neither an image nor a build.", ), mode, ) - local, remote = await asyncio.gather( - updater.local_image_digest(image_ref), + (local_id, local), remote = await asyncio.gather( + updater.local_image_identity(image_ref), updater.registry_image_digest(image_ref), ) - if local is None: + if local_id is None: return ( Facet( kind="image", current="unknown", available=_short_digest(remote), up_to_date=False, + error_code="image-absent", error=( - f"No local digest for {image_ref} — it has never been pulled " - "by tag, so there is nothing to compare." + f"No local copy of {image_ref} — it has never been pulled " + "or built here, so there is nothing to compare." ), ), mode, ) + + if local is None: + # Present, but carrying no registry digest. Under the classic image + # store -- still the default on Docker Engine -- that is exactly what + # `make build` leaves behind, and the check below that would have + # caught it (`registry_has_digest`) never runs, because it needs a + # digest to ask about. Reported as the operator's own image, the same + # answer the containerd store reaches by the other route, rather than + # as an error that blocks the whole update. + return ( + Facet( + kind="image", + current=_short_digest(local_id), + behind=0, + detail=[ + "Built here, or loaded from a tarball: it carries no " + "registry digest, so it is not comparable to the published " + "tag. Rebuild it yourself to pick up changes." + ], + ), + mode, + ) if remote is None: return ( Facet( kind="image", current=_short_digest(local), up_to_date=False, + error_code="registry-unreachable", error=( f"Could not reach the registry for {image_ref}; " "the running version is known, the available one is not." @@ -980,11 +1014,7 @@ async def preflight(component_keys: list[str]) -> Preflight: blocks.append( Block( component=key, - code=( - "docker-unavailable" - if "compose" in image.error - else "registry-unreachable" - ), + code=image.error_code or "registry-unreachable", message=image.error, resolutions=["cancel"], ) diff --git a/tests/test_image_tracks_checkout.py b/tests/test_image_tracks_checkout.py index 64b0cc160..2d50eaa9c 100644 --- a/tests/test_image_tracks_checkout.py +++ b/tests/test_image_tracks_checkout.py @@ -138,7 +138,9 @@ async def _service(*a, **k): monkeypatch.setattr(updater, "compose_service", _service) monkeypatch.setattr( - updater, "local_image_digest", lambda *a, **k: _val("sha256:aaa") + updater, + "local_image_identity", + lambda *a, **k: _val(("sha256:id", "sha256:aaa")), ) monkeypatch.setattr( updater, "registry_image_digest", lambda *a, **k: _val("sha256:bbb") diff --git a/tests/test_update_preflight.py b/tests/test_update_preflight.py index ad373bc01..e8dc5c03d 100644 --- a/tests/test_update_preflight.py +++ b/tests/test_update_preflight.py @@ -240,12 +240,20 @@ def test_the_real_hummingbot_api_tree_blocks_on_at_most_environment_yml(): _DEFAULT_SERVICE = {"image": "hummingbot/hummingbot-api:latest"} -def _facet(*, service=_DEFAULT_SERVICE, local=LOCAL, remote=REMOTE): - """``service=None`` stands for ``docker compose config`` having failed.""" +IMAGE_ID = "sha256:943773e318e028d8a303e7c2237586fbd0d96e3ba46b902b215dc3799264bb54" + + +def _facet(*, service=_DEFAULT_SERVICE, local=LOCAL, remote=REMOTE, image_id=IMAGE_ID): + """``service=None`` stands for ``docker compose config`` having failed. + + ``image_id=None`` is "no such image here"; ``local=None`` with an id is an + image that is present but carries no registry digest — a local build under + the classic image store. + """ with patch.multiple( "utils.updater", compose_service=AsyncMock(return_value=service), - local_image_digest=AsyncMock(return_value=local), + local_image_identity=AsyncMock(return_value=(image_id, local)), registry_image_digest=AsyncMock(return_value=remote), ): return asyncio.run(components._image_facet("/tmp/repo", "hummingbot-api")) @@ -274,12 +282,29 @@ def test_an_unreachable_registry_never_claims_up_to_date(): assert facet.available is None -def test_an_image_with_no_local_digest_is_unknown_not_behind(): - """Loaded from a tarball: there is nothing comparable to compare.""" - facet, _ = _facet(local=None) +def test_an_image_that_is_not_here_at_all_is_unknown_not_behind(): + """Nothing to compare, and nothing to run either.""" + facet, _ = _facet(local=None, image_id=None) assert facet.up_to_date is False assert facet.current == "unknown" - assert facet.error and "never been pulled" in facet.error + assert facet.error_code == "image-absent" + assert facet.error and "never been pulled or built" in facet.error + + +def test_an_image_present_without_a_registry_digest_is_the_operators_own(): + """`make build` under the classic image store leaves `RepoDigests` empty. + + That store is still the default on Docker Engine, so this is the ordinary + shape of an operator running their own build — not an error, and above all + not a reason to block the whole update. It used to raise + ``registry-unreachable`` while the registry was answering fine. + """ + facet, _ = _facet(local=None) + assert facet.error is None, "a local build is not a failure" + assert facet.up_to_date is True + assert facet.behind == 0 + assert facet.current == "sha256:943773e3" + assert facet.detail and "Built here" in facet.detail[0] def test_a_hand_added_build_key_no_longer_means_a_second_mode(): diff --git a/utils/updater.py b/utils/updater.py index 95b3fcd3e..160cd9b3b 100644 --- a/utils/updater.py +++ b/utils/updater.py @@ -896,13 +896,54 @@ async def compose_service(repo_dir: str, service: str) -> dict | None: return definition if isinstance(definition, dict) else None +async def local_image_identity(image_ref: str) -> tuple[str | None, str | None]: + """``(image id, registry digest)`` for the local copy of ``image_ref``. + + Two questions that had been collapsed into one, and they have different + answers on the two image stores Docker ships. + + ``RepoDigests[0]`` is index-level when the image was pulled by tag, which + makes it directly comparable to what the registry reports. But a *locally + built* image has one only under the containerd image store; under the + classic (overlay2) store -- still the default on Docker Engine, which is + where this runs in production -- ``RepoDigests`` is simply ``[]``. So + "no registry digest" does not mean "no image": it is exactly the shape an + operator's own ``make build`` leaves behind. + + The image id answers "is it here at all", because ``docker image inspect`` + fails outright when it is not. ``(None, None)`` therefore means absent, + and ``(id, None)`` means present with no registry identity -- built here, + or loaded from a tarball. Neither is "behind". + """ + rc, out = await _run_cmd( + "docker", + "image", + "inspect", + image_ref, + "--format", + "{{.Id}}\t{{json .RepoDigests}}", + timeout=30, + ) + if rc != 0 or not out: + return None, None + image_id, _, raw = out.partition("\t") + image_id = image_id.strip() or None + try: + digests = json.loads(raw) + except ValueError: + return image_id, None + if not isinstance(digests, list) or not digests: + return image_id, None + first = str(digests[0]) + return image_id, (first.split("@", 1)[1] if "@" in first else None) + + async def local_image_digest(image_ref: str) -> str | None: """The registry digest of the local copy of ``image_ref``, or None. - ``RepoDigests[0]`` is index-level when the image was pulled by tag, which - makes it directly comparable to what the registry reports. An image loaded - from a tarball has no RepoDigest at all -- that is "unknown", never - "behind". + Kept as the narrow question for callers that only want the comparable + digest; :func:`local_image_identity` is the one that can also say whether + the image is present at all. """ rc, out = await _run_cmd( "docker", From 02dff121ecc4dbccdd659cd69c76ee8161ac4272 Mon Sep 17 00:00:00 2001 From: david-hummingbot Date: Tue, 29 Sep 2026 11:03:25 +0700 Subject: [PATCH 27/43] Put the checkout back when stashed work cannot be replayed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `stash` is one of the ways out of `dirty-conflict`, and that block means the incoming commits touch the very file being parked — so the replay conflicting is the main line, not a corner case. `git stash pop` handles that badly for an unattended caller: it writes conflict markers into the tracked file, leaves the index unmerged, keeps the stash and exits non-zero. What that produced: an update reported as succeeded over a checkout holding a half-merged agent playbook, and a remedy — "resolve it by hand with git stash pop" — that errors with "needs merge" when followed. The docstring said a conflict aborts. Nothing aborted. `apply` and an explicit drop, because apply can be undone and pop cannot. The undo is deliberately narrow: `reset --hard` is the obvious abort and is wrong here, since these checkouts are runtime working directories and unrelated uncommitted work is expected — the preflight gates on the intersection of dirty and incoming for exactly that reason. So only the stash's own paths go back, tracked ones to HEAD and untracked ones removed, and the abort is only claimed once the unmerged set is actually empty. Files stashed with `-u` live in a third parent commit that `stash show` does not look at, so they are asked for separately. The other half was that nothing mentioned it afterwards. `git status` says nothing about stashes, the finished run scrolls away, and the next preflight reads the working tree — so parked work sat there with every surface reporting a healthy install. `stashed-work` says it is there and what the next update will do with it. --- condor/updates/components.py | 20 ++++ tests/test_conflict_resolutions.py | 159 +++++++++++++++++++++++++++++ utils/updater.py | 122 +++++++++++++++++++--- 3 files changed, 288 insertions(+), 13 deletions(-) diff --git a/condor/updates/components.py b/condor/updates/components.py index e5d5bca61..c300b5f77 100644 --- a/condor/updates/components.py +++ b/condor/updates/components.py @@ -1043,6 +1043,26 @@ async def preflight(component_keys: list[str]) -> Preflight: stale_warning = _stale_fork_warning() if stale_warning is not None: warnings.append(stale_warning) + + if await updater.has_update_stash(_table()[CONDOR].repo_dir): + # Work a previous update parked and could not replay. Nothing said + # so afterwards: the run ended, the step scrolled away, and the + # stash sat there -- invisible to the next preflight, which only + # looks at the working tree, and to `git status`, which does not + # mention stashes at all. + warnings.append( + Warning( + component=CONDOR, + code="stashed-work", + message=( + "An earlier update parked local work in a stash and it " + "is still there — `git stash list` shows it as " + "`condor /update`. This update will try to replay it " + "again, and will put the checkout back untouched if it " + "still does not apply." + ), + ) + ) warnings.append( Warning( component=CONDOR, diff --git a/tests/test_conflict_resolutions.py b/tests/test_conflict_resolutions.py index 1d15f7cf6..17d253982 100644 --- a/tests/test_conflict_resolutions.py +++ b/tests/test_conflict_resolutions.py @@ -145,3 +145,162 @@ async def test_a_stash_we_did_not_make_is_left_alone(repo): ["git", "stash", "list"], cwd=repo, capture_output=True, text=True ) assert "someone else's" in listing.stdout + + +# ── S2: a stash that cannot be replayed must leave the tree usable ── + + +@pytest.fixture +def stashed(repo, tmp_path): + """The main line of the `stash` resolution, set up exactly as it happens. + + `stash` is offered as a way out of `dirty-conflict`, and that block means + the incoming commits touch the file being parked — so the replay conflicts + in the ordinary case, not a rare one. + """ + agent = repo / "agents" / "scout" / "AGENT.md" + origin = tmp_path / "origin.git" + _git("clone", "-q", "--bare", str(repo), str(origin), cwd=tmp_path) + _git("remote", "add", "origin", str(origin), cwd=repo) + _git("fetch", "-q", "origin", cwd=repo) + _git("branch", "-q", "--set-upstream-to=origin/main", "main", cwd=repo) + + # Upstream rewrites the same file. + work = tmp_path / "work" + _git("clone", "-q", str(origin), str(work), cwd=tmp_path) + (work / "agents" / "scout" / "AGENT.md").write_text("# Scout v2\nUPSTREAM rule.\n") + _git("commit", "-aqm", "v2", cwd=work) + _git("push", "-q", "origin", "main", cwd=work) + + # The operator's own edit, and unrelated uncommitted work elsewhere — the + # runtime state these checkouts are always in. + agent.write_text("# Scout v1\nNever on Sundays.\nMY EDIT.\n") + bystander = repo / "runtime-notes.txt" + bystander.write_text("do not lose me\n") + return repo, agent, bystander + + +@pytest.mark.asyncio +async def test_a_stash_that_cannot_be_replayed_leaves_no_conflict_markers(stashed): + """`git stash pop` wrote the markers into the file and kept the stash. + + The update then reported success over a checkout holding a half-merged + agent playbook, an unmerged index, and advice — "resolve it by hand with + git stash pop" — that errors with "needs merge" when followed. + """ + repo, agent, bystander = stashed + + ok, _ = await updater.stash_paths(str(repo), ["agents/scout/AGENT.md"]) + assert ok + ok, _ = await updater.fast_forward(str(repo)) + assert ok + + restored, message = await updater.stash_pop(str(repo)) + + assert restored is False, "it genuinely could not be replayed" + text = agent.read_text() + assert "<<<<<<<" not in text and ">>>>>>>" not in text, text + assert text == "# Scout v2\nUPSTREAM rule.\n", "the tree is the update's own" + + unmerged = subprocess.run( + ["git", "diff", "--name-only", "--diff-filter=U"], + cwd=repo, + capture_output=True, + text=True, + env=ENV, + ).stdout + assert unmerged.strip() == "", f"index left unmerged: {unmerged}" + + # The work survives, and the message points at where. + assert await updater.has_update_stash(str(repo)) is True + assert "stash@{0}" in message and "nothing is half-merged" in message + + # And the abort did not reach past the stash's own paths. + assert bystander.read_text() == "do not lose me\n" + + +@pytest.mark.asyncio +async def test_a_stash_that_replays_cleanly_is_dropped(stashed, tmp_path): + """The common case still completes — and does not leave the entry behind.""" + repo, agent, _ = stashed + # Drop the edit that collides, so the fast-forward is unobstructed, and + # park something the update does not touch instead. + _git("checkout", "--", "agents/scout/AGENT.md", cwd=repo) + other = repo / "agents" / "scout" / "notes.md" + other.write_text("my notes\n") + + ok, _ = await updater.stash_paths(str(repo), ["agents/scout/notes.md"]) + assert ok + moved, output = await updater.fast_forward(str(repo)) + assert moved, output + + restored, message = await updater.stash_pop(str(repo)) + + assert restored is True, message + assert other.read_text() == "my notes\n" + assert await updater.has_update_stash(str(repo)) is False, "the entry was kept" + + +@pytest.mark.asyncio +async def test_nothing_of_ours_stashed_is_not_a_failure(repo): + assert await updater.stash_pop(str(repo)) == ( + True, + "Nothing of ours was stashed.", + ) + + +@pytest.mark.asyncio +async def test_work_left_in_a_stash_is_reported_by_the_next_preflight( + stashed, monkeypatch +): + """The stash outlived the run that made it and nothing mentioned it again. + + `git status` does not show stashes, the finished run scrolls away, and the + next preflight only looks at the working tree — so parked work could sit + there indefinitely with every surface reporting a healthy install. + """ + repo, _, _ = stashed + await updater.stash_paths(str(repo), ["agents/scout/AGENT.md"]) + + monkeypatch.setattr( + components, + "_table", + lambda: { + components.HUMMINGBOT_API: components.Component( + components.HUMMINGBOT_API, + "Hummingbot API", + str(repo / "no-such-api"), + service="hummingbot-api", + ), + components.CONDOR: components.Component( + components.CONDOR, "Condor", str(repo) + ), + }, + ) + preflight = await components.preflight([components.CONDOR]) + + codes = [w.code for w in preflight.warnings] + assert "stashed-work" in codes, codes + message = next(w.message for w in preflight.warnings if w.code == "stashed-work") + assert "condor /update" in message + + +@pytest.mark.asyncio +async def test_a_clean_checkout_gets_no_stash_warning(repo, monkeypatch): + monkeypatch.setattr( + components, + "_table", + lambda: { + components.HUMMINGBOT_API: components.Component( + components.HUMMINGBOT_API, + "Hummingbot API", + str(repo / "no-such-api"), + service="hummingbot-api", + ), + components.CONDOR: components.Component( + components.CONDOR, "Condor", str(repo) + ), + }, + ) + preflight = await components.preflight([components.CONDOR]) + assert "stashed-work" not in [w.code for w in preflight.warnings] diff --git a/utils/updater.py b/utils/updater.py index 160cd9b3b..4c04367a5 100644 --- a/utils/updater.py +++ b/utils/updater.py @@ -470,28 +470,124 @@ async def stash_paths(repo_dir: str, paths: list[str]) -> tuple[bool, str]: return True, out or "Stashed." +STASH_MESSAGE = "condor /update" + + +async def has_update_stash(repo_dir: str = CONDOR_DIR) -> bool: + """Whether work parked by a previous update is still sitting in a stash.""" + rc, listing = await _run_git("stash", "list", repo_dir=repo_dir) + return rc == 0 and STASH_MESSAGE in listing + + +async def _stash_paths_at(repo_dir: str, ref: str = "stash@{0}") -> list[str]: + """Every path a stash entry would write, tracked and untracked alike. + + ``stash show --name-only`` covers the tracked half. Files stashed with + ``-u`` live in a third parent commit it does not look at, so they need + asking for separately or the abort below would leave them behind. + """ + paths: list[str] = [] + rc, out = await _run_git("stash", "show", "--name-only", ref, repo_dir=repo_dir) + if rc == 0: + paths.extend(_lines(out)) + rc, out = await _run_git( + "ls-tree", "-r", "--name-only", f"{ref}^3", repo_dir=repo_dir + ) + if rc == 0: + paths.extend(_lines(out)) + seen: dict[str, None] = {} + for path in paths: + seen.setdefault(path, None) + return list(seen) + + +async def _undo_partial_apply(repo_dir: str, paths: list[str]) -> bool: + """Put ``paths`` back to HEAD after an apply that conflicted. Nothing else. + + ``reset --hard`` is the obvious abort and is *wrong here*: these checkouts + are runtime working directories, which is the whole reason the preflight + gates on the intersection of dirty and incoming rather than on "is the tree + clean". Unrelated uncommitted work is expected, and a hard reset would + destroy it to tidy up a failure that never touched it. + + So undo exactly the stash's own path set. A path git knows goes back to + HEAD, index and worktree both, which clears the unmerged entry and the + conflict markers with it. A path that was untracked when it was stashed has + no HEAD version to go back to, so it is removed -- the copy in the stash is + the one that survives either way. + """ + ok = True + for rel in paths: + rc, _ = await _run_git("cat-file", "-e", f"HEAD:{rel}", repo_dir=repo_dir) + if rc == 0: + rc, _ = await _run_git( + "checkout", "-f", "HEAD", "--", rel, repo_dir=repo_dir + ) + else: + rc, _ = await _run_git("rm", "-f", "--quiet", "--", rel, repo_dir=repo_dir) + if rc != 0: + target = Path(repo_dir) / rel + try: + target.unlink(missing_ok=True) + rc = 0 + except OSError: + pass + if rc != 0: + ok = False + if not ok: + return False + # Only an empty unmerged set proves the abort landed; anything left would + # be the half-merged tree this exists to prevent, reported as prevented. + rc, out = await _run_git( + "diff", "--name-only", "--diff-filter=U", repo_dir=repo_dir + ) + return rc == 0 and not out.strip() + + async def stash_pop(repo_dir: str) -> tuple[bool, str]: """Put back the work ``stash_paths`` parked, if it still applies cleanly. Stashing was never meant to be the end of the story -- the whole point of - parking work rather than discarding it is getting it back, and a clean pop - is the common case once the fast-forward has landed. What the original - caution was right about is the *failure*: a conflicting pop leaves a - half-merged tree plus a stash entry nobody was told about. So a conflict - aborts and says where the work still is, rather than leaving the operator - to discover both. + parking work rather than discarding it is getting it back, and a clean + restore is the common case once the fast-forward has landed. + + The failure is the part that needs care, and it is not rare: ``stash`` is + offered as a way out of ``dirty-conflict``, which by definition means the + incoming commits touch the very files being parked. So the apply conflicts + on the main line, not in some corner. + + ``git stash pop`` handles that badly for an unattended caller. It writes + conflict markers into the tracked file, leaves the index unmerged, keeps + the stash, and exits non-zero -- so the update ends with a checkout nobody + can build from, an agent playbook full of ``<<<<<<<``, and a suggestion to + run the command that just failed (it fails again: "needs merge"). Hence + ``apply`` and an explicit drop: apply can be undone, pop cannot. """ - _, listing = await _run_git("stash", "list", repo_dir=repo_dir) - if "condor /update" not in listing: + if not await has_update_stash(repo_dir): return True, "Nothing of ours was stashed." - rc, out = await _run_git("stash", "pop", repo_dir=repo_dir) - if rc != 0: + paths = await _stash_paths_at(repo_dir) + rc, out = await _run_git("stash", "apply", repo_dir=repo_dir) + if rc == 0: + await _run_git("stash", "drop", repo_dir=repo_dir) + return True, "Restored the work that was stashed before the update." + + undone = await _undo_partial_apply(repo_dir, paths) + named = ", ".join(paths[:5]) + ("…" if len(paths) > 5 else "") + if undone: return False, ( - f"{out}\n\nYour work is still in stash@{{0}} — nothing was lost. " - "Resolve it by hand with: git stash pop" + f"Your work does not apply on top of the update: {named}.\n\n" + "Nothing was lost and nothing is half-merged — the checkout was put " + "back the way the update left it, and your work is still in " + "stash@{0}. Bring it back when you can resolve it by hand:\n" + " git stash pop\n" + "That will reproduce the conflict, this time where you can see it." ) - return True, "Restored the work that was stashed before the update." + return False, ( + f"{out}\n\nYour work is still in stash@{{0}} — nothing was lost — but the " + "checkout could not be put back cleanly and may hold conflict markers. " + "Check `git status` before running anything from it." + ) async def move_to_local_root(repo_dir: str, rel_paths: list[str]) -> tuple[bool, str]: From ba5f875c8e5541217afe8b1e6efad070a8487bbb Mon Sep 17 00:00:00 2001 From: david-hummingbot Date: Tue, 29 Sep 2026 11:59:52 +0700 Subject: [PATCH 28/43] Prove the server fan-out by rendezvous, not by the clock MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `test_three_slow_servers_resolve_in_one_timeout_not_three` asserted `elapsed < DELAY * 1.5`, which measures the machine as much as the code. It passed run on its own and failed under a loaded full suite — red that says nothing about the property and trains people to re-run rather than look. The deterministic assertion was already sitting next to it: `max_concurrent` can only reach 3 if all three fetches were in flight at once, and a serial resolver can only ever reach 1. So the timing check was not carrying the test, only its flakiness. Every fetch now waits at a barrier until all three arrive. A concurrent resolver releases the moment the last one gets there, however loaded the machine; a serial one cannot reach the assertion at all and fails on the rendezvous timeout rather than hanging. Checked both ways: passing as written, and `assert 1 == 3` when the route's `gather` is replaced with a sequential loop. --- tests/test_web_servers_parallel_status.py | 47 +++++++++++++++++------ 1 file changed, 35 insertions(+), 12 deletions(-) diff --git a/tests/test_web_servers_parallel_status.py b/tests/test_web_servers_parallel_status.py index cbed37118..a26567bcf 100644 --- a/tests/test_web_servers_parallel_status.py +++ b/tests/test_web_servers_parallel_status.py @@ -7,7 +7,6 @@ """ import asyncio -import time import pytest from fastapi import FastAPI @@ -44,13 +43,26 @@ def get_chat_default_server(self, chat_id): return self._default +#: How long a fetch waits for its siblings before deciding they are not coming. +#: Only ever reached by a *serial* implementation, so it bounds the failure +#: rather than the success — a concurrent one releases the moment the last +#: fetch arrives, however loaded the machine is. +RENDEZVOUS_TIMEOUT = 5.0 + + class FakeSDS: - """Cache always misses; each fetch sleeps like a connect timeout would.""" + """Cache always misses; each fetch sleeps like a connect timeout would. + + ``rendezvous`` makes the fan-out provable instead of merely likely: every + fetch waits until that many are in flight at once, which a serial resolver + can never satisfy. + """ - def __init__(self, statuses, delay=DELAY, raises=()): + def __init__(self, statuses, delay=DELAY, raises=(), rendezvous=0): self._statuses = statuses self._delay = delay self._raises = set(raises) + self._barrier = asyncio.Barrier(rendezvous) if rendezvous else None self.concurrent = 0 self.max_concurrent = 0 @@ -62,7 +74,10 @@ async def get_or_fetch(self, name, data_type, **params): self.concurrent += 1 self.max_concurrent = max(self.max_concurrent, self.concurrent) try: - await asyncio.sleep(self._delay) + if self._barrier is not None: + await asyncio.wait_for(self._barrier.wait(), RENDEZVOUS_TIMEOUT) + else: + await asyncio.sleep(self._delay) if name in self._raises: raise RuntimeError("connection refused") return self._statuses[name] @@ -87,25 +102,33 @@ def three_servers(): } -def test_three_slow_servers_resolve_in_one_timeout_not_three(monkeypatch): - """Two unreachable servers must not stack their timeouts on the third.""" +def test_three_slow_servers_are_all_in_flight_at_once(monkeypatch): + """Two unreachable servers must not stack their timeouts on the third. + + Asserted by rendezvous rather than by the clock. The wall-clock form + (``elapsed < DELAY * 1.5``) measured the machine as much as the code: it + passed alone and failed under a loaded full-suite run, which is the worst + kind of red — it says nothing about the property and trains people to + re-run. Here every fetch blocks until all three have arrived, so a serial + resolver cannot reach the assertion at all and a slow one still can. + """ sds = FakeSDS( statuses={ "alpha": {"status": "online"}, "bravo": None, "charlie": None, - } + }, + rendezvous=3, ) client = build_client(monkeypatch, FakeConfigManager(three_servers()), sds) - t0 = time.monotonic() resp = client.get("/servers") - elapsed = time.monotonic() - t0 assert resp.status_code == 200 - # Serial would be ~3x DELAY; concurrent stays well under 1.5x the slowest. - assert elapsed < DELAY * 1.5, f"took {elapsed:.2f}s, expected ~{DELAY:.2f}s" - assert sds.max_concurrent == 3 + assert sds.max_concurrent == 3, ( + "the three fetches never overlapped — they were resolved one after " + "another, so the endpoint pays the sum of the connect timeouts" + ) def test_all_online_response_is_unchanged(monkeypatch): From 181728db172f603fd86029884f74ffb35b70e2a5 Mon Sep 17 00:00:00 2001 From: david-hummingbot Date: Tue, 29 Sep 2026 17:14:59 +0700 Subject: [PATCH 29/43] Check that the id the dashboard signs in as can administer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reported live: a local-only install with a pending update, where clicking the "Updates available" notification did nothing at all. Two files have to agree about one number and nothing checked that they did. `.env` decides who the dashboard *is* — local mode signs in as `ADMIN_USER_ID` with no password — while `config.yml` decides what that id may *do*. On the install in question `.env` resolved to `1883786161` and `config.yml` gave that id the role `user`, with the admin role sitting on id `1`. So every admin route answered 403, the frontend dropped the Updates tab, and the notification's `?tab=updates` link fell back to Servers without a word. The update engine was working the whole time; nothing could reach it. Nothing was wrong enough to notice. The old check asked only whether the variable was set, so `make doctor` printed a green `ADMIN_USER_ID 1883786161` — a tick on the exact value that had no rights. Two checks, because they are two different mistakes. `Admin access` names the id, the role it actually has and the key to change. `ADMIN_USER_ID (duplicate)` fires on a second assignment in `.env`, which is worth saying on its own: all three parsers that read that file take the last one, so a duplicate is silently authoritative while the first line is the one people read. --- condor/doctor.py | 76 ++++++++++++++++++++++++++++++++++++++++++ tests/test_doctor.py | 79 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 155 insertions(+) diff --git a/condor/doctor.py b/condor/doctor.py index 4b2846726..bada1abe0 100644 --- a/condor/doctor.py +++ b/condor/doctor.py @@ -176,6 +176,81 @@ def check_dependencies() -> list[Check]: _PLACEHOLDER_CREDENTIALS = {"admin", "password", "changeme", ""} +def _check_admin_can_administer(data: dict | None) -> list[Check]: + """Whether ``ADMIN_USER_ID`` is an id ``config.yml`` treats as an admin. + + Two files have to agree about one number and nothing checked that they + did. ``.env`` decides who the dashboard *is* -- local mode logs in as + ``ADMIN_USER_ID`` with no password -- while ``config.yml`` decides what + that id may *do*. Disagree and every admin route answers 403: the Updates + tab is not rendered, the "Updates available" notification links to a tab + that is not there, and clicking it lands on Servers with nothing said. + + Nothing was wrong enough to notice. The old check asked only whether the + variable was set, so an install in exactly this state reported + ``ADMIN_USER_ID`` green on the very value that had no rights. + + A duplicated key is worth naming on its own: ``.env`` is read by three + parsers and the last assignment wins in all of them, so a second line is + silently authoritative and the first is the one people read. + """ + from utils.config import ADMIN_USER_ID, LOCAL_MODE + + admin_id = str(ADMIN_USER_ID or "").strip() + if not admin_id or not isinstance(data, dict): + return [] + + duplicates: list[Check] = [] + if ENV_PATH.exists(): + try: + assignments = [ + line + for line in ENV_PATH.read_text(encoding="utf-8").splitlines() + if re.match(r"^\s*(export\s+)?ADMIN_USER_ID\s*=", line) + ] + except OSError: + assignments = [] + if len(assignments) > 1: + duplicates.append( + Check( + "ADMIN_USER_ID (duplicate)", + FAIL, + f"{len(assignments)} assignments in .env — the last one wins " + f"({admin_id}), the others are dead. Delete all but one.", + ) + ) + + users = data.get("users") or {} + role = "" + for key, record in users.items(): + if str(key) == admin_id and isinstance(record, dict): + role = str(record.get("role") or "") + break + + if role.lower() == "admin": + return duplicates + + if not role: + detail = ( + f"config.yml has no user {admin_id}, so the dashboard logs in as an " + "id with no role at all and every admin route answers 403." + ) + else: + detail = ( + f"config.yml gives user {admin_id} the role '{role}', not 'admin', " + "so every admin route answers 403 — no Updates tab, and the " + "'Updates available' notice links to a tab that is not rendered." + ) + where = "the dashboard signs in as this id" if LOCAL_MODE else "this id" + return duplicates + [ + Check( + "Admin access", + FAIL, + f"{detail} Set `users.{admin_id}.role: admin` in config.yml " f"({where}).", + ) + ] + + def _check_placeholder_credentials(data: object) -> list[Check]: if not isinstance(data, dict): return [] @@ -262,6 +337,7 @@ def check_config() -> list[Check]: data = yaml.safe_load(config_yml.read_text(encoding="utf-8")) checks.append(Check("config.yml", OK, "present and parses")) + checks.extend(_check_admin_can_administer(data)) checks.extend(_check_placeholder_credentials(data)) except Exception as e: checks.append(Check("config.yml", FAIL, f"failed to parse: {e}")) diff --git a/tests/test_doctor.py b/tests/test_doctor.py index 5d3507227..59e2cd824 100644 --- a/tests/test_doctor.py +++ b/tests/test_doctor.py @@ -534,3 +534,82 @@ def test_an_unparseable_bind_is_unknown_rather_than_public(): assert _is_public_bind("") is False assert _is_public_bind("0.0.0.0:8088") is True assert _is_public_bind("[::]:8088") is True + + +# ── The id the dashboard signs in as must be able to administer (V10) ── + + +def _admin_checks(tmp_path, monkeypatch, *, admin_id, users, env_lines=None): + import condor.doctor as doctor_module + + env = tmp_path / ".env" + env.write_text("\n".join(env_lines or [f"ADMIN_USER_ID={admin_id}"]) + "\n") + monkeypatch.setattr(doctor_module, "ENV_PATH", env) + monkeypatch.setattr("utils.config.ADMIN_USER_ID", admin_id) + monkeypatch.setattr("utils.config.LOCAL_MODE", True) + return doctor_module._check_admin_can_administer({"users": users}) + + +def test_a_matching_admin_passes_quietly(tmp_path, monkeypatch): + assert ( + _admin_checks(tmp_path, monkeypatch, admin_id=1, users={1: {"role": "admin"}}) + == [] + ) + + +def test_an_id_config_yml_calls_a_user_is_reported(tmp_path, monkeypatch): + """Two files have to agree about one number, and nothing checked that. + + `.env` decides who the dashboard *is* — local mode signs in as + `ADMIN_USER_ID` with no password — and `config.yml` decides what that id + may *do*. Disagree and every admin route answers 403: no Updates tab, and + the "Updates available" notification links to a tab that is not rendered. + The old check asked only whether the variable was set, so an install in + exactly this state reported `ADMIN_USER_ID` green. + """ + checks = _admin_checks( + tmp_path, + monkeypatch, + admin_id=777, + users={1: {"role": "admin"}, 777: {"role": "user"}}, + ) + assert [c.name for c in checks] == ["Admin access"] + detail = checks[0].detail + assert "777" in detail and "'user'" in detail + assert "role: admin" in detail, "the remedy has to name the knob" + + +def test_an_id_config_yml_has_never_heard_of_is_reported(tmp_path, monkeypatch): + checks = _admin_checks( + tmp_path, monkeypatch, admin_id=999, users={1: {"role": "admin"}} + ) + assert [c.name for c in checks] == ["Admin access"] + assert "no user 999" in checks[0].detail + + +def test_a_duplicated_assignment_is_named_on_its_own(tmp_path, monkeypatch): + """`.env` is read by three parsers and the last assignment wins in all of + them, so a second line is silently authoritative and the first is the one + people read.""" + checks = _admin_checks( + tmp_path, + monkeypatch, + admin_id=777, + users={777: {"role": "admin"}}, + env_lines=["ADMIN_USER_ID=1", "OTHER=x", "ADMIN_USER_ID=777"], + ) + assert [c.name for c in checks] == ["ADMIN_USER_ID (duplicate)"] + assert "the last one wins (777)" in checks[0].detail + + +def test_one_assignment_and_a_good_role_says_nothing(tmp_path, monkeypatch): + assert ( + _admin_checks( + tmp_path, + monkeypatch, + admin_id=777, + users={777: {"role": "admin"}}, + env_lines=["ADMIN_USER_ID=777", "OTHER=x"], + ) + == [] + ) From c16f886752fce9a568a5db4fcac1f5a44e8b868a Mon Sep 17 00:00:00 2001 From: david-hummingbot Date: Tue, 29 Sep 2026 17:14:59 +0700 Subject: [PATCH 30/43] Say why an admin-only settings tab is not there MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The silent fallback was defensible on its own — a deep link to `?tab=updates` from a seat that is not an admin renders Servers rather than an empty page. It stops being defensible once the product sends that link itself: the hourly update check posts an "Updates available" notification whose link is `?tab=updates`, addressed to `ADMIN_USER_ID`, which in local mode is the id the dashboard signs in as whether or not `config.yml` grants it the role. So the reader is told there is an update, handed a link, clicks it, and lands on Servers with the notice still sitting in the bell. Nothing happened and nothing said why. That is how this was reported: "clicking the update message doesn't do anything". Still falls back — the tab genuinely is not theirs — but now says so, names the role and the knob, and points at `make doctor`, which after the previous commit names the id it signs in as. --- frontend/src/pages/Settings.tsx | 40 ++++++++++++++++++++++++++++++++- 1 file changed, 39 insertions(+), 1 deletion(-) diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx index fbbc3cabd..6147241bc 100644 --- a/frontend/src/pages/Settings.tsx +++ b/frontend/src/pages/Settings.tsx @@ -111,9 +111,19 @@ export function Settings() { const groups = buildGroups(server, isAdmin); const tabs = groups.flatMap((g) => g.tabs); const requested = (params.get("tab") as TabKey) || "servers"; + const available = tabs.some((t) => t.key === requested); // A deep link to ?tab=admin or ?tab=updates from a seat that is not (or no // longer) an admin falls back rather than rendering an empty page. - const tab = tabs.some((t) => t.key === requested) ? requested : "servers"; + const tab = available ? requested : "servers"; + // ...but silently is the wrong way to fall back when the product itself + // sent the link. The "Updates available" notice links to ?tab=updates, so a + // non-admin seat clicks it, lands on Servers, and sees nothing happen at + // all — the tab is gone, the notice is still there, and nothing anywhere + // says why. Say why. + const deniedTab = + !available && (requested === "updates" || requested === "admin") + ? requested + : null; return (
    @@ -207,8 +217,36 @@ export function Settings() { )} {tab === "admin" && } {tab === "updates" && } + {deniedTab && }
    ); } + +/** Why a tab the reader was linked to is not on their screen. + * + * Reachable the ordinary way: the hourly update check posts an "Updates + * available" notification whose link is `?tab=updates`, and it goes to + * `ADMIN_USER_ID` — which in local mode is the id the dashboard signs in as, + * whether or not `config.yml` gives that id the admin role. When it does not, + * the click used to do nothing observable. + */ +function AdminOnlyNotice({ tab }: { tab: "updates" | "admin" }) { + const what = tab === "updates" ? "Updates" : "Admin"; + return ( +
    +

    + {what} is admin-only +

    +

    + This seat is signed in without the admin role, so the {what} tab is not + shown and its API answers 403. Give this user{" "} + role: admin in{" "} + config.yml, then + reload. make doctor{" "} + names the id it signs in as. +

    +
    + ); +} From 4f9f98a959bec2dd23df5f3da1d2f11392d732cc Mon Sep 17 00:00:00 2001 From: david-hummingbot Date: Tue, 29 Sep 2026 17:18:13 +0700 Subject: [PATCH 31/43] Print the role as config.yml spells it, not as Python repr `get_user_role` returns the enum, so the C10 message interpolated it as "this user's role is 'UserRole.USER'". That string appears nowhere in config.yml: the reader cannot search for it, and the value they are being told to change looks nothing like the one on disk. Seen on a real install while chasing an update notification that did nothing. --- condor/web/auth.py | 7 ++++++- tests/test_web_updates_routes.py | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/condor/web/auth.py b/condor/web/auth.py index 1b9ff409b..02affa482 100644 --- a/condor/web/auth.py +++ b/condor/web/auth.py @@ -153,7 +153,12 @@ def require_admin(user: WebUser) -> None: # Best effort: naming the role is a courtesy, and a gate must never # fail open (or crash) because the courtesy could not be paid. try: - role = get_config_manager().get_user_role(user.id) or "" + found = get_config_manager().get_user_role(user.id) + # ``get_user_role`` hands back the enum, and interpolating that + # renders "UserRole.USER" — a name that appears nowhere in + # config.yml, so the reader cannot search for it and the value it + # tells them to change looks nothing like the one on disk. + role = str(getattr(found, "value", found) or "") except Exception: # noqa: BLE001 role = "" detail = "Admin access required" diff --git a/tests/test_web_updates_routes.py b/tests/test_web_updates_routes.py index dcc3f691f..bf96084f8 100644 --- a/tests/test_web_updates_routes.py +++ b/tests/test_web_updates_routes.py @@ -443,3 +443,35 @@ def test_relaunch_refuses_when_there_is_nothing_to_apply(as_user, admin): assert response.status_code == 409 assert called == [], "a restart was requested with nothing pending" + + +def test_the_403_names_the_role_as_config_yml_spells_it(monkeypatch): + """`get_user_role` returns the enum, and interpolating it renders + "UserRole.USER" — a string that appears nowhere in config.yml, so the + reader cannot search for it and the value they are told to change looks + nothing like the one on disk.""" + from enum import Enum + + from fastapi import HTTPException + + from condor.web import auth as auth_module + + class UserRole(str, Enum): + USER = "user" + + class FakeCM: + def is_admin(self, _uid): + return False + + def get_user_role(self, _uid): + return UserRole.USER + + monkeypatch.setattr(auth_module, "get_config_manager", lambda: FakeCM()) + + with pytest.raises(HTTPException) as exc: + auth_module.require_admin( + auth_module.WebUser(id=7, username="u", first_name="U", role="user") + ) + + assert "'user'" in exc.value.detail + assert "UserRole" not in exc.value.detail From 1011d7e32db21bc59217e6603175947c0f0c4eaf Mon Sep 17 00:00:00 2001 From: david-hummingbot Date: Tue, 29 Sep 2026 17:32:13 +0700 Subject: [PATCH 32/43] Say when a finished update run happened MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The journal outlives the process by design — that is what lets a run interrupted by a restart still be judged at the next boot — and the panel shows the last run until someone presses Done. So it can be displaying a failure from last week, and without a time a week-old one and a two-minute-old one read identically. That is not hypothetical: a stale failed run in a test install was read as a live dependency problem, and nothing on the screen could settle it either way. `ended` is the moment that matters, falling back to `started` for a run that never reached one. Relative inline, because the question being asked is "is this still relevant"; exact on hover, for when it is not. Reuses `formatRelativeTime` rather than adding a second way to render an age. --- .../src/components/settings/UpdatesSettings.tsx | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/frontend/src/components/settings/UpdatesSettings.tsx b/frontend/src/components/settings/UpdatesSettings.tsx index acfd102cd..aed4a8607 100644 --- a/frontend/src/components/settings/UpdatesSettings.tsx +++ b/frontend/src/components/settings/UpdatesSettings.tsx @@ -14,6 +14,7 @@ import { useEffect, useState } from "react"; import { ConfirmDialog } from "@/components/agent/ConfirmDialog"; import { RELAUNCH_KEY, useRelaunch } from "@/hooks/useRelaunch"; +import { formatRelativeTime } from "@/lib/formatters"; import { type Block, type ComponentStatus, @@ -556,6 +557,7 @@ function FinishedView({ isDismissing: boolean; }) { const ok = run.state === "succeeded"; + const when = run.ended ?? run.started ?? null; // From the same query the shell's banner reads, so the two cannot disagree // about whether the relaunch has happened. const { data: relaunch } = useRelaunch(); @@ -578,6 +580,20 @@ function FinishedView({ {ok ? `Updated ${run.components.join(", ")}` : `Update of ${run.components.join(", ")} failed`} + {/* The journal outlives the process, so this panel can be showing a + run from last week — and without a time a week-old failure and a + two-minute-old one read identically. `ended` is the moment that + matters; `started` covers a run that never reached one. The exact + stamp is on hover rather than inline, because the question this + answers is "is this still relevant", not "when exactly". */} + {when !== null && ( + + {formatRelativeTime(when)} + + )} {run.error && (

    {run.error}

    From 687e29cc8e0cecf9fa2f97fe00f71d96b7881608 Mon Sep 17 00:00:00 2001 From: david-hummingbot Date: Tue, 29 Sep 2026 18:00:21 +0700 Subject: [PATCH 33/43] Bound the Chrome download so an install cannot hang on it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `make install` ran `python -c "import kaleido; kaleido.get_chrome_sync()"` with stderr sent to /dev/null. Three things went wrong at once when that stalled: no timeout, so it waited for ever; no progress, so nothing separated "slow" from "wedged"; and the only line on screen was "Setting up Chrome for chart rendering...", indefinitely. Measured in that state: 256 KB of a ~150 MB archive after thirty minutes, connection open and idle. The install could not finish and nothing said why. Every step in the *update* path got a timeout and a named failure in this branch. This is the install path with neither, reached by following the documented setup. The stall detector is the part that matters. A cold download on a slow link can legitimately run for minutes, so a ceiling generous enough not to cut that off is also generous enough to sit on a dead connection just as long — bytes arriving is what separates them. A stall names the proxy or firewall a held-open idle connection usually means; the ceiling names the knob to raise and says the link was merely slow. Also: an already-installed browser now costs nothing instead of being re-fetched on every install, and the real error is printed instead of discarded. Still always exits zero — charts are optional, and an optional renderer must not fail an install. --- Makefile | 8 ++- condor/setup_chrome.py | 141 +++++++++++++++++++++++++++++++++++++ tests/test_setup_chrome.py | 132 ++++++++++++++++++++++++++++++++++ 3 files changed, 279 insertions(+), 2 deletions(-) create mode 100644 condor/setup_chrome.py create mode 100644 tests/test_setup_chrome.py diff --git a/Makefile b/Makefile index fb00b8a17..13efb5e30 100644 --- a/Makefile +++ b/Makefile @@ -53,10 +53,14 @@ install: setup @$(MAKE) setup-chrome @-$(MAKE) doctor +# Bounded, and says which way it failed. The bare `python -c` this replaces had +# no timeout and sent stderr to /dev/null, so a stalled download sat on +# "Setting up Chrome..." for ever with nothing to read and no way to finish the +# install. See condor/setup_chrome.py; it always exits 0, because charts are +# optional and an optional renderer must not fail an install. setup-chrome: @echo "Setting up Chrome for chart rendering..." - @uv run python -c "import kaleido; kaleido.get_chrome_sync()" 2>/dev/null || \ - echo "Chrome setup skipped (not required for basic usage)" + @uv run python -m condor.setup_chrome || true doctor: @bash -c ' \ diff --git a/condor/setup_chrome.py b/condor/setup_chrome.py new file mode 100644 index 000000000..db000d808 --- /dev/null +++ b/condor/setup_chrome.py @@ -0,0 +1,141 @@ +"""Fetch the headless Chrome kaleido renders charts with, and give up if it hangs. + +``make install`` used to run this as a bare ``python -c "import kaleido; +kaleido.get_chrome_sync()"`` with stderr sent to ``/dev/null``. Three things +went wrong at once when the download stalled: there was no timeout, so it +waited for ever; there was no progress, so nothing distinguished "slow" from +"wedged"; and the only line on screen was ``Setting up Chrome for chart +rendering...``, indefinitely. Measured in that state: 256 KB of a ~150 MB +archive after thirty minutes, the connection open and idle. The install could +not finish and nothing said why. + +Everything here is best effort. Chrome is for chart *images* -- the dashboard, +the trading loops and the update flow all work without it -- so no outcome +below is fatal and the exit status is always zero. What changes is that the +operator is told which of the three things happened. + +The stall detector matters more than the ceiling. A cold download on a slow +link can legitimately run for minutes, so a timeout generous enough not to cut +that off is also generous enough to sit on a dead connection for just as long. +Bytes arriving is the signal that separates them. +""" + +from __future__ import annotations + +import os +import subprocess +import sys +import time +from pathlib import Path + +#: Overall ceiling. Generous: this is a ~150 MB archive and some hosts are far +#: from the CDN. The stall detector is what catches a wedged download early. +TIMEOUT = int(os.environ.get("CONDOR_CHROME_TIMEOUT", "900")) +#: How long the archive may not grow before we call it stuck. +STALL = int(os.environ.get("CONDOR_CHROME_STALL", "90")) +_POLL = 2.0 +_REPORT_EVERY = 15.0 + + +def chrome_path() -> Path | None: + """Where kaleido expects the browser, or ``None`` on an unsupported arch.""" + try: + from choreographer.cli._cli_utils import get_chrome_download_path + + return get_chrome_download_path() + except Exception: # noqa: BLE001 - an import failure is "cannot tell" + return None + + +def downloaded_bytes(root: Path) -> int: + """How much of the archive has landed so far. Zero when nothing has.""" + try: + return sum(p.stat().st_size for p in root.rglob("*") if p.is_file()) + except OSError: + return 0 + + +def _download_root() -> Path | None: + try: + from choreographer.cli._cli_utils import default_download_path + + return Path(default_download_path) + except Exception: # noqa: BLE001 + return None + + +def _mb(n: int) -> str: + return f"{n / 1_000_000:.1f} MB" + + +def fetch(timeout: int = TIMEOUT, stall: int = STALL) -> tuple[bool, str]: + """Download Chrome, reporting progress and refusing to wait for ever. + + Returns ``(ok, message)``. The download runs in a child process because + ``get_chrome_sync`` is a blocking call with no timeout of its own: killing + the child is the only way to stop waiting on it. + """ + installed = chrome_path() + if installed is None: + return False, "This platform has no Chrome-for-Testing build; skipped." + if installed.exists(): + return True, f"Already installed at {installed}." + + root = _download_root() + proc = subprocess.Popen( + [sys.executable, "-c", "import kaleido; kaleido.get_chrome_sync()"], + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + ) + + started = last_change = last_report = time.monotonic() + seen = downloaded_bytes(root) if root else 0 + + while proc.poll() is None: + time.sleep(_POLL) + now = time.monotonic() + current = downloaded_bytes(root) if root else 0 + if current != seen: + seen, last_change = current, now + if now - last_report >= _REPORT_EVERY and seen: + print(f" … {_mb(seen)} downloaded", flush=True) + last_report = now + if now - last_change >= stall: + proc.kill() + proc.wait() + return False, ( + f"Stalled: {_mb(seen)} downloaded, then nothing for {stall}s. " + "The connection is open but idle — usually a proxy or a " + "firewall holding the CDN. Charts render as tables until this " + "succeeds; retry with `make setup-chrome`." + ) + if now - started >= timeout: + proc.kill() + proc.wait() + return False, ( + f"Gave up after {timeout}s with {_mb(seen)} downloaded. It was " + "still making progress, so the link is just slow: raise the " + "ceiling with `CONDOR_CHROME_TIMEOUT=1800 make setup-chrome`." + ) + + output = (proc.stdout.read() if proc.stdout else "") or "" + if proc.returncode == 0: + return True, f"Installed at {installed}." + tail = "\n".join(output.strip().splitlines()[-5:]) + return False, ( + f"Chrome could not be installed (exit {proc.returncode}). Charts render " + "as tables until it is; retry with `make setup-chrome`." + + (f"\n{tail}" if tail else "") + ) + + +def main() -> int: + """Always zero: an optional renderer must never fail an install.""" + ok, message = fetch() + print(f" {'✓' if ok else '!'} {message}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_setup_chrome.py b/tests/test_setup_chrome.py new file mode 100644 index 000000000..42f446c86 --- /dev/null +++ b/tests/test_setup_chrome.py @@ -0,0 +1,132 @@ +"""`make install` must not be able to hang on an optional renderer (V11). + +The bare `python -c "import kaleido; kaleido.get_chrome_sync()"` this replaces +had no timeout and sent stderr to /dev/null. Measured when it stalled: 256 KB +of a ~150 MB archive after thirty minutes, connection open and idle, the only +line on screen "Setting up Chrome for chart rendering...". The install could +not finish and nothing said why. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from condor import setup_chrome + + +def test_an_already_installed_chrome_costs_no_download(tmp_path, monkeypatch): + """Re-running `make install` must not re-fetch 150 MB every time.""" + exe = tmp_path / "chrome" + exe.write_text("x") + monkeypatch.setattr(setup_chrome, "chrome_path", lambda: exe) + + def no_popen(*a, **kw): # pragma: no cover - reached only on regression + raise AssertionError("an installed browser must not be downloaded again") + + monkeypatch.setattr(setup_chrome.subprocess, "Popen", no_popen) + + ok, message = setup_chrome.fetch() + assert ok is True + assert "Already installed" in message + + +def test_an_unsupported_platform_is_skipped_not_attempted(monkeypatch): + monkeypatch.setattr(setup_chrome, "chrome_path", lambda: None) + ok, message = setup_chrome.fetch() + assert ok is False + assert "no Chrome-for-Testing build" in message + + +class _FakeProc: + """A download that never finishes and never writes another byte.""" + + returncode = None + stdout = None + + def __init__(self): + self.killed = False + + def poll(self): + return None if not self.killed else -9 + + def kill(self): + self.killed = True + self.returncode = -9 + + def wait(self): + return self.returncode + + +def _run_with(monkeypatch, tmp_path, proc, sizes): + """Drive `fetch` over a scripted size sequence with time under our control.""" + monkeypatch.setattr(setup_chrome, "chrome_path", lambda: tmp_path / "absent") + monkeypatch.setattr(setup_chrome, "_download_root", lambda: tmp_path) + monkeypatch.setattr(setup_chrome.subprocess, "Popen", lambda *a, **kw: proc) + monkeypatch.setattr(setup_chrome.time, "sleep", lambda _s: None) + + clock = {"t": 0.0} + seq = list(sizes) + + def fake_monotonic(): + return clock["t"] + + def fake_bytes(_root): + clock["t"] += setup_chrome._POLL + return seq.pop(0) if seq else (seq_last[0] if seq_last else 0) + + seq_last = [sizes[-1]] if sizes else [0] + monkeypatch.setattr(setup_chrome.time, "monotonic", fake_monotonic) + monkeypatch.setattr(setup_chrome, "downloaded_bytes", fake_bytes) + return setup_chrome.fetch(timeout=10_000, stall=30) + + +def test_a_download_that_stops_moving_is_given_up_on(tmp_path, monkeypatch, capsys): + """The exact failure seen: bytes arrive, then stop, and nothing ever ends.""" + proc = _FakeProc() + ok, message = _run_with(monkeypatch, tmp_path, proc, [0, 262144] + [262144] * 60) + + assert ok is False + assert proc.killed is True, "the child was left running" + assert "Stalled" in message and "0.3 MB" in message + assert "make setup-chrome" in message, "the remedy has to be nameable" + + +def test_a_slow_but_moving_download_is_not_called_stalled(tmp_path, monkeypatch): + """A cold fetch over a slow link must not be mistaken for a wedged one.""" + proc = _FakeProc() + growing = [i * 500_000 for i in range(200)] + monkeypatch.setattr(setup_chrome, "chrome_path", lambda: tmp_path / "absent") + monkeypatch.setattr(setup_chrome, "_download_root", lambda: tmp_path) + monkeypatch.setattr(setup_chrome.subprocess, "Popen", lambda *a, **kw: proc) + monkeypatch.setattr(setup_chrome.time, "sleep", lambda _s: None) + clock = {"t": 0.0} + seq = list(growing) + monkeypatch.setattr(setup_chrome.time, "monotonic", lambda: clock["t"]) + + def sizes(_root): + clock["t"] += setup_chrome._POLL + return seq.pop(0) if seq else growing[-1] + + monkeypatch.setattr(setup_chrome, "downloaded_bytes", sizes) + + ok, message = setup_chrome.fetch(timeout=120, stall=30) + assert "Stalled" not in message, message + assert ok is False and "Gave up after 120s" in message + assert "CONDOR_CHROME_TIMEOUT" in message, "say which knob to turn" + + +def test_main_never_fails_the_install(monkeypatch, capsys): + """Charts are optional; `make install` must survive every outcome.""" + monkeypatch.setattr( + setup_chrome, "fetch", lambda *a, **kw: (False, "Stalled: nothing arrived.") + ) + assert setup_chrome.main() == 0 + assert "Stalled" in capsys.readouterr().out + + +def test_downloaded_bytes_tolerates_a_missing_directory(tmp_path): + assert setup_chrome.downloaded_bytes(tmp_path / "nope") == 0 + (tmp_path / "a").write_bytes(b"x" * 10) + assert setup_chrome.downloaded_bytes(tmp_path) == 10 From 6bafe527274f0651ced43c9060ab0f361453e3b5 Mon Sep 17 00:00:00 2001 From: david-hummingbot Date: Tue, 29 Sep 2026 19:01:22 +0700 Subject: [PATCH 34/43] Keep the slashes in the remote's default branch name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `remote_default_branch` turned `refs/remotes/origin/HEAD` into a branch name with `rsplit("/", 1)[-1]`, which is right for `main` and wrong for every branch with a slash in it — `fix/x` came back as `x`. Two consequences, both quiet. `image_tracks_this_checkout` compares the current branch against this, so on a repo whose default is slashed the comparison never matched and the image was skipped with a reason that read like a deliberate finding. And `no-upstream-branch`, added earlier in this branch, prints the name as advice — so it was telling people to switch to a branch that does not exist. That is how this was noticed: the suggestion came back `git switch update-flow-hardening` for a default of `fix/update-flow-hardening`. Strip the known prefix instead, and return None when the ref is not under it rather than inventing a name from whatever is left. --- tests/test_update_git_guards.py | 28 ++++++++++++++++++++++++++++ utils/updater.py | 10 ++++++++-- 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/tests/test_update_git_guards.py b/tests/test_update_git_guards.py index 4f00ea7c7..977b3d8c3 100644 --- a/tests/test_update_git_guards.py +++ b/tests/test_update_git_guards.py @@ -178,3 +178,31 @@ def test_a_genuine_transport_failure_is_not_mistaken_for_a_missing_branch(): "fatal: unable to access 'https://...': Could not resolve host: github.com" ) assert not updater.is_missing_remote_ref("") + + +@pytest.mark.asyncio +async def test_a_default_branch_with_a_slash_keeps_its_prefix(cloned, tmp_path): + """`rsplit("/", 1)` on the ref silently renames `fix/x` to `x`. + + Branch names contain slashes routinely. Taking the last segment made + `image_tracks_this_checkout` compare `fix/x != x` and skip the image with + a wrong reason, and put a branch that does not exist into the advice + `no-upstream-branch` prints. + """ + _git("checkout", "-q", "-b", "fix/slashed", cwd=cloned) + _git("push", "-q", "origin", "fix/slashed", cwd=cloned) + # Point the remote's HEAD at the slashed branch, as `remote set-head` does. + _git( + "symbolic-ref", + "refs/remotes/origin/HEAD", + "refs/remotes/origin/fix/slashed", + cwd=cloned, + ) + + assert await updater.remote_default_branch(str(cloned)) == "fix/slashed" + + +@pytest.mark.asyncio +async def test_no_remote_head_is_unknown_not_a_guess(cloned): + _git("symbolic-ref", "-d", "refs/remotes/origin/HEAD", cwd=cloned) + assert await updater.remote_default_branch(str(cloned)) is None diff --git a/utils/updater.py b/utils/updater.py index 4c04367a5..b93d77771 100644 --- a/utils/updater.py +++ b/utils/updater.py @@ -149,8 +149,14 @@ async def remote_default_branch(repo_dir: str = CONDOR_DIR) -> str | None: ) if rc != 0 or not out: return None - # refs/remotes/origin/main -> main - return out.strip().rsplit("/", 1)[-1] or None + # refs/remotes/origin/main -> main, and refs/remotes/origin/fix/x -> fix/x. + # Not rsplit("/", 1): a branch name may contain slashes, and taking the last + # segment silently renames `fix/x` to `x` -- which made the comparison in + # image_tracks_this_checkout never match on such a default, and put a branch + # that does not exist into the advice `no-upstream-branch` prints. + prefix = "refs/remotes/origin/" + ref = out.strip() + return ref[len(prefix) :] if ref.startswith(prefix) else None async def check_for_updates(repo_dir: str = CONDOR_DIR) -> dict: From 599501754950dfd5ad8d308f9c9762a0973334dc Mon Sep 17 00:00:00 2001 From: david-hummingbot Date: Tue, 29 Sep 2026 19:05:57 +0700 Subject: [PATCH 35/43] Refuse to recreate containers that belong to another checkout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `compose_up` runs `docker compose up -d` in `HUMMINGBOT_API_DIR` directly, so it never passes the guard hummingbot-api's Makefile has on `make deploy`. The exposure is the same one. `container_name:` is fixed for every service there, so two checkouts are one stack as far as Docker is concerned: Compose matches the existing containers by name, prints `Recreate`, and the second checkout takes over the first's containers *and its postgres volume*, without an error or a warning. Condor points at a *directory*; Compose acts on a *project*, and those are not the same thing when two checkouts share a basename — which they do by default, both being called `hummingbot-api`. Compose records the project's working directory on every container it creates, so the question is answerable: a `foreign-stack` block before the update starts, and the same question again immediately before `compose_up`, because the screen the operator confirmed on may be minutes old and what is at stake is somebody's database. Nothing running, an unlabelled container, or a Docker that cannot be reached are all "no conflict" — none of them is evidence of one. --- condor/updates/components.py | 22 ++++++++++++++++++ condor/updates/run.py | 12 ++++++++++ tests/test_update_preflight.py | 42 ++++++++++++++++++++++++++++++++++ utils/updater.py | 31 +++++++++++++++++++++++++ 4 files changed, 107 insertions(+) diff --git a/condor/updates/components.py b/condor/updates/components.py index c300b5f77..e35cd67bc 100644 --- a/condor/updates/components.py +++ b/condor/updates/components.py @@ -1022,6 +1022,28 @@ async def preflight(component_keys: list[str]) -> Preflight: steps.extend(_steps_for(key, current)) if HUMMINGBOT_API in selected: + hb = known[HUMMINGBOT_API] + owner = await updater.compose_stack_owner(hb.repo_dir, HB_SERVICE) + if owner is not None: + # Recreating from here would adopt another checkout's containers + # and its postgres volume, which Compose does without complaint -- + # it matches on the fixed `container_name:` and reports `Recreate`. + # Condor points at a *directory*; Compose acts on a *project*, and + # they are not the same thing when two checkouts share a basename. + blocks.append( + Block( + component=HUMMINGBOT_API, + code="foreign-stack", + message=( + f"The {HB_SERVICE} containers on this host were " + f"deployed from {owner}, not from {hb.repo_dir}. " + "Recreating them from here would take that stack over, " + "along with its database. Point HUMMINGBOT_API_DIR at " + "the checkout that owns them, or stop that stack first." + ), + resolutions=["cancel"], + ) + ) executor_warning = await _executor_warning() if executor_warning is not None: warnings.append(executor_warning) diff --git a/condor/updates/run.py b/condor/updates/run.py index d53cf08ea..9328d2624 100644 --- a/condor/updates/run.py +++ b/condor/updates/run.py @@ -585,6 +585,18 @@ async def _update_hb_api(run: Run) -> bool: step = await _begin(run, f"{prefix}.up") if step is not None: + # Re-asked here, not just at preflight: adopting another checkout's + # containers takes its database with them, and the screen the operator + # confirmed on may be minutes old. + owner = await updater.compose_stack_owner(component.repo_dir, component.service) + if owner is not None: + message = ( + f"The {component.service} containers belong to {owner}, not to " + f"{component.repo_dir}. Refusing to recreate them from here." + ) + await _finish(run, step, FAILED, message) + await _fail(run, message) + return False ok, output = await updater.compose_up(component.repo_dir) await _finish(run, step, OK if ok else FAILED, output) if not ok: diff --git a/tests/test_update_preflight.py b/tests/test_update_preflight.py index e8dc5c03d..2fb1226c5 100644 --- a/tests/test_update_preflight.py +++ b/tests/test_update_preflight.py @@ -330,3 +330,45 @@ def test_docker_being_down_is_an_error_not_a_verdict(): @pytest.mark.parametrize("digest", ["", None, "not-a-digest"]) def test_short_digest_survives_junk(digest): assert components._short_digest(digest) in ("", "not-a-digest") + + +# ── V12: an update must not adopt another checkout's stack ── + + +def _owner_probe(monkeypatch, label, repo_dir): + """Stub `docker inspect` returning the compose working-dir label.""" + from utils import updater as u + + async def fake(*args, **kwargs): + return (0, label) if label is not None else (1, "") + + monkeypatch.setattr(u, "_run_cmd", fake) + return asyncio.run(u.compose_stack_owner(repo_dir, "hummingbot-api")) + + +def test_containers_from_another_checkout_are_reported(monkeypatch): + """`container_name:` is fixed, so Compose matches by name across projects. + + It does not treat that as an error — it prints `Recreate` and the second + checkout takes over the first's containers and its postgres volume. + Condor points at a directory; Compose acts on a project. + """ + owner = _owner_probe(monkeypatch, "/srv/other-checkout", "/srv/mine") + assert owner == "/srv/other-checkout" + + +def test_our_own_containers_are_not_a_conflict(monkeypatch): + assert _owner_probe(monkeypatch, "/srv/mine", "/srv/mine") is None + + +def test_a_trailing_slash_is_not_a_different_checkout(monkeypatch): + assert _owner_probe(monkeypatch, "/srv/mine/", "/srv/mine") is None + + +def test_nothing_running_is_not_a_conflict(monkeypatch): + assert _owner_probe(monkeypatch, None, "/srv/mine") is None + + +def test_an_unlabelled_container_is_not_a_conflict(monkeypatch): + """Docker prints `` for a label that is not set.""" + assert _owner_probe(monkeypatch, "", "/srv/mine") is None diff --git a/utils/updater.py b/utils/updater.py index b93d77771..79a637814 100644 --- a/utils/updater.py +++ b/utils/updater.py @@ -1159,6 +1159,37 @@ async def compose_pull(repo_dir: str, service: str) -> tuple[bool, str]: return True, output +async def compose_stack_owner(repo_dir: str, service: str) -> str | None: + """The checkout the running containers were deployed from, or ``None``. + + Compose records the project's working directory on every container it + creates, which is the only thing that can answer "is this stack ours". + + It has to be asked. ``container_name:`` is fixed for every service in + hummingbot-api's compose file, so two checkouts are one stack as far as + Docker is concerned: Compose matches the existing containers by name, + reports ``Recreate``, and the second checkout takes over the first one's + containers *and its postgres volume*. No error, no warning. The Makefile + gained a guard for ``make deploy``; an update never goes through the + Makefile, so it needs the same question asked here. + + ``None`` means nothing is running under that name, or Docker could not be + asked -- neither of which is evidence of a conflict. + """ + rc, out = await _run_cmd( + "docker", + "inspect", + service, + "--format", + '{{ index .Config.Labels "com.docker.compose.project.working_dir" }}', + timeout=30, + ) + owner = (out or "").strip() + if rc != 0 or not owner or owner == "": + return None + return owner if os.path.normpath(owner) != os.path.normpath(repo_dir) else None + + async def compose_up(repo_dir: str) -> tuple[bool, str]: """Recreate the stack on whatever images are now on disk.""" rc, output = await _run_cmd( From 7a8f067a7bb9a0a0e6fe219b3ab2181f4544e05d Mon Sep 17 00:00:00 2001 From: david-hummingbot Date: Tue, 29 Sep 2026 19:40:04 +0700 Subject: [PATCH 36/43] Say Docker is down, rather than blaming the image MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reported from a live dashboard: "No local digest for hummingbot/hummingbot-api:latest — it has never been pulled by tag". The image had been on disk for days and the container was running. Docker Desktop was restarting. Only one probe in this path needs the daemon, which is why nothing else gave it away. Measured with the socket gone: docker compose config rc=0 parses locally, never opens it docker buildx imagetools inspect rc=0 asks the registry directly docker image inspect rc!=0 the one that needs the daemon So `compose config` supplies a service, the registry supplies an *available* digest, and the single failure lands on the branch that concludes the image was never pulled. Everything around it looks healthy, which is exactly what makes the conclusion convincing and wrong. Splitting "no registry digest" from "no image" earlier in this branch did not help here: both of those are answers about the image, and this is not. Ask the daemon whether it is there before concluding anything about what it holds — one extra command, only on the error path, and the available digest the registry already gave us is kept rather than thrown away. --- condor/updates/components.py | 24 +++++++++++++++ tests/test_update_preflight.py | 56 ++++++++++++++++++++++++++++++++++ utils/updater.py | 25 +++++++++++++++ 3 files changed, 105 insertions(+) diff --git a/condor/updates/components.py b/condor/updates/components.py index e35cd67bc..9d024c1b1 100644 --- a/condor/updates/components.py +++ b/condor/updates/components.py @@ -281,6 +281,30 @@ async def _image_facet(repo_dir: str, service: str) -> tuple[Facet, str]: ) if local_id is None: + # "inspect failed" is two answers, and only one of them is about the + # image. `docker image inspect` is the sole probe here that needs the + # daemon -- `compose config` parses locally and `imagetools inspect` + # asks the registry -- so a daemon that is down or restarting reaches + # exactly this branch with everything else looking fine. Reported as a + # missing image, that reads as "you have never deployed", about a + # container that is running. + if not await updater.docker_running(): + return ( + Facet( + kind="image", + current="unknown", + available=_short_digest(remote), + up_to_date=False, + error_code="docker-unavailable", + error=( + "Docker is not answering, so the running image cannot " + "be read. Start Docker (or wait for it to finish " + "restarting) and check again — nothing is wrong with " + "the image." + ), + ), + mode, + ) return ( Facet( kind="image", diff --git a/tests/test_update_preflight.py b/tests/test_update_preflight.py index 2fb1226c5..9668404ee 100644 --- a/tests/test_update_preflight.py +++ b/tests/test_update_preflight.py @@ -372,3 +372,59 @@ def test_nothing_running_is_not_a_conflict(monkeypatch): def test_an_unlabelled_container_is_not_a_conflict(monkeypatch): """Docker prints `` for a label that is not set.""" assert _owner_probe(monkeypatch, "", "/srv/mine") is None + + +# ── V13: a daemon that is down is not a missing image ── + + +def _facet_with_docker(*, daemon_up, identity=(None, None)): + """`_image_facet` with the daemon's liveness and the inspect result pinned.""" + with patch.multiple( + "utils.updater", + compose_service=AsyncMock(return_value=_DEFAULT_SERVICE), + local_image_identity=AsyncMock(return_value=identity), + registry_image_digest=AsyncMock(return_value=REMOTE), + docker_running=AsyncMock(return_value=daemon_up), + ): + return asyncio.run(components._image_facet("/tmp/repo", "hummingbot-api")) + + +def test_a_stopped_daemon_is_not_reported_as_a_missing_image(): + """Reported from a live dashboard while Docker Desktop was restarting. + + `docker image inspect` is the only probe here that needs the daemon — + `compose config` merges files locally and `imagetools inspect` asks the + registry — so a daemon that is down reaches the `local is None` branch + with everything else looking healthy. It read "it has never been pulled by + tag", about an image that was sitting on disk the whole time. + """ + facet, _ = _facet_with_docker(daemon_up=False) + assert facet.error_code == "docker-unavailable" + assert "Docker is not answering" in facet.error + assert "never been pulled" not in facet.error + # The registry answered even with the daemon down, so keep what we learned. + assert facet.available == "sha256:62d70399" + + +def test_a_running_daemon_with_no_such_image_still_says_so(): + """The fix must not swallow the case it was carved out of.""" + facet, _ = _facet_with_docker(daemon_up=True) + assert facet.error_code == "image-absent" + assert "never been pulled or built" in facet.error + + +def test_the_daemon_is_not_probed_when_the_image_is_there(): + """One extra command, and only on the error path.""" + + def boom(*a, **kw): # pragma: no cover - reached only on regression + raise AssertionError("docker_running must not run on the happy path") + + with patch.multiple( + "utils.updater", + compose_service=AsyncMock(return_value=_DEFAULT_SERVICE), + local_image_identity=AsyncMock(return_value=(IMAGE_ID, LOCAL)), + registry_image_digest=AsyncMock(return_value=LOCAL), + docker_running=boom, + ): + facet, _ = asyncio.run(components._image_facet("/tmp/repo", "hummingbot-api")) + assert facet.up_to_date is True and facet.error is None diff --git a/utils/updater.py b/utils/updater.py index 79a637814..20755bb3c 100644 --- a/utils/updater.py +++ b/utils/updater.py @@ -998,6 +998,31 @@ async def compose_service(repo_dir: str, service: str) -> dict | None: return definition if isinstance(definition, dict) else None +async def docker_running() -> bool: + """Whether the Docker daemon answers at all. + + Needed because nothing else in this module can stand in for it, which is + how a stopped daemon came to be reported as a missing image. Measured with + Docker Desktop mid-restart: + + * ``docker compose config`` exits **0** -- it merges files locally and + never opens the socket, so the "is Docker running?" guard on the compose + probe does not fire; + * ``docker buildx imagetools inspect`` exits **0** -- it asks the registry + directly, so the *available* digest still resolves; + * ``docker image inspect`` is the only one that needs the daemon, so it + alone fails -- and its failure used to be read as "this image has never + been pulled". + + The result: a daemon that was merely restarting produced "it has never + been pulled by tag", about an image sitting on disk the whole time. + """ + rc, _ = await _run_cmd( + "docker", "version", "--format", "{{.Server.Version}}", timeout=20 + ) + return rc == 0 + + async def local_image_identity(image_ref: str) -> tuple[str | None, str | None]: """``(image id, registry digest)`` for the local copy of ``image_ref``. From 20e643d11727327c42901187d8adf138ce379db7 Mon Sep 17 00:00:00 2001 From: david-hummingbot Date: Tue, 29 Sep 2026 20:13:12 +0700 Subject: [PATCH 37/43] Fix seven defects found in review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All seven of Greptile's comments reproduce. Three destroy or misreport work. **The chat could not see the routines it wrote.** FEAT-115 moved the chat's write target to `/condor/routines`, but `assistant_routines` sends the chat through `discover_routines`, which reads the root library and the shared one and never that. An authored routine never reached the catalog — and the v6 migration, which lifts previously untracked routines out of `routines/`, moved existing ones into the same unread directory. The chat's own layer now shadows the general library, exactly as an agent's does. **The wrong stash was applied and dropped.** `has_update_stash` looked for our message anywhere in the list while `apply`/`drop` default to `stash@{0}`, and the stack reorders on every push. With an operator stash made after ours: theirs was applied *and dropped*, their work restored into the tree unasked, ours left parked, and the step reported "Restored the work that was stashed". Addressed by ref now, resolved by name. **`keep-mine` overwrote an existing local copy.** Edited through the product *and* in the checkout is two customizations; `shutil.move` replaced the first with the second and said both were kept. It refuses now and names both, since only the operator can say which they meant. The rest: - the update lock was acquired before `check()` and released only by the task created after it, so a git or docker that could not start left the lock held by a process that was not updating; - the relaunch banner treated a 403 like a dropped connection, so a non-admin seat polled, found the server still up, reloaded, and counted down again — a reload loop it could never end. It stops and says why, which is the half of V10 the Updates tab already got; - `keep-mine` on an *untracked* file reported failure after succeeding, because `checkout HEAD --` cannot reset a path HEAD never had. Only tracked paths are reset; moving an untracked one *was* the resolution; - the CRLF guard read the working tree, where `text=auto` promises nothing — a checkout with `core.autocrlf=true` fails it on ordinary clean files. It reads the index, which is what `text=auto` actually governs. Verified both ways: it passes clean and still catches a blob forced in as `i/crlf`. --- condor/updates/run.py | 37 ++++++---- frontend/src/components/RelaunchBanner.tsx | 34 +++++++-- routines/base.py | 16 ++++- tests/test_chat_routines_layering.py | 38 ++++++++++ tests/test_conflict_resolutions.py | 81 ++++++++++++++++++++++ tests/test_repo_portability.py | 48 +++++++------ tests/test_update_run_lock.py | 27 ++++++++ utils/updater.py | 67 +++++++++++++++--- 8 files changed, 299 insertions(+), 49 deletions(-) diff --git a/condor/updates/run.py b/condor/updates/run.py index 9328d2624..96d51f993 100644 --- a/condor/updates/run.py +++ b/condor/updates/run.py @@ -474,19 +474,30 @@ async def start( "same repo." ) - selected = [k for k in components.keys() if k in set(component_keys)] - statuses = {s.key: s for s in await components.check()} - - run = Run( - id=f"u-{int(time.time())}", - started=time.time(), - actor={"user_id": actor_user_id, "chat_id": actor_chat_id}, - components=selected, - steps=_plan(selected, statuses), - ) - _current = run - _task = asyncio.create_task(_execute(run, resolutions or {})) - return run + # From here to the task existing, nothing else will release the lock: + # `_execute`'s finally clause is the only place that does, and there is + # no `_execute` yet. `check()` shells out to git and docker, so it can + # raise for reasons that have nothing to do with this checkout -- and + # the lock would then be held by a process that is not updating, and + # not released until it exits. + try: + selected = [k for k in components.keys() if k in set(component_keys)] + statuses = {s.key: s for s in await components.check()} + + run = Run( + id=f"u-{int(time.time())}", + started=time.time(), + actor={"user_id": actor_user_id, "chat_id": actor_chat_id}, + components=selected, + steps=_plan(selected, statuses), + ) + _current = run + _task = asyncio.create_task(_execute(run, resolutions or {})) + return run + except BaseException: + _release_run_lock(_lock_handle) + _lock_handle = None + raise async def _begin(run: Run, key: str) -> Step | None: diff --git a/frontend/src/components/RelaunchBanner.tsx b/frontend/src/components/RelaunchBanner.tsx index d080563b7..bc659e90d 100644 --- a/frontend/src/components/RelaunchBanner.tsx +++ b/frontend/src/components/RelaunchBanner.tsx @@ -2,6 +2,7 @@ import { RotateCw } from "lucide-react"; import { useCallback, useEffect, useState } from "react"; import { useRelaunch } from "@/hooks/useRelaunch"; +import { errorStatus } from "@/lib/api"; import { updatesApi } from "@/lib/updates-api"; /** @@ -33,7 +34,7 @@ const COUNTDOWN_SECONDS = 5; const RECONNECT_TIMEOUT_MS = 90_000; const RECONNECT_INTERVAL_MS = 1_000; -type Phase = "counting" | "restarting" | "cancelled" | "stuck"; +type Phase = "counting" | "restarting" | "cancelled" | "stuck" | "denied"; type Relaunch = { from_commit?: string; @@ -84,10 +85,17 @@ function RelaunchStrip({ data }: { data: Relaunch }) { setPhase("restarting"); try { await updatesApi.relaunch(); - } catch { - // The request is *expected* not to answer: the process handling it tears - // down and execs, so the socket usually closes first. A failure here says - // nothing about whether the restart is happening — the reconnect decides. + } catch (err) { + // A dropped connection says nothing: the process handling this request + // tears down and execs, so the socket usually closes before the reply. + // A *403* is different — it means the restart was refused and will not + // happen. Polling then finds the same server still answering, reloads, + // and the banner starts counting again: a reload loop on a seat that + // can never end it. Say so and stop instead. + if (errorStatus(err) === 403) { + setPhase("denied"); + return; + } } waitForServer(); }, [waitForServer]); @@ -150,6 +158,20 @@ function RelaunchStrip({ data }: { data: Relaunch }) { )} + {phase === "denied" && ( + <> + + This seat cannot restart Condor. + {" "} + + The update is on disk, but applying it needs the admin role — the + request was refused. Give this user{" "} + role: admin in{" "} + config.yml, or run{" "} + make restart on the host. + + + )} {phase === "cancelled" && ( <> Condor has been updated.{" "} @@ -176,7 +198,7 @@ function RelaunchStrip({ data }: { data: Relaunch }) { )} - {(phase === "cancelled" || phase === "stuck") && ( + {(phase === "cancelled" || phase === "stuck" || phase === "denied") && ( )} - {(phase === "cancelled" || phase === "stuck" || phase === "denied") && ( + {/* + Not in `denied`. The button posts the same request to the same seat, + which will refuse it the same way — an offer that cannot be taken, under + a sentence explaining that it cannot. What that seat has to do instead + is in the message, and neither half of it is a button. + */} + {(phase === "cancelled" || phase === "stuck") && (