From 61ffc649d5b8da04e05a02d29d6d795c2d41540d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Apr 2026 08:05:32 +0000 Subject: [PATCH 1/3] Patch CVEs in pygments, pyasn1, pillow, black - pygments 2.17.2 -> 2.20.0 (CVE-2026-4539) - pyasn1 0.6.2 -> 0.6.3 (CVE-2026-30922) - pillow 12.1.1 -> 12.2.0 (CVE-2026-40192) - black ^23.1.0 -> ^26.3.1 (CVE-2026-32274, dev dep) --- poetry.lock | 339 ++++++++++++++++++++++++++++------------------- pyproject.toml | 2 +- requirements.txt | 6 +- 3 files changed, 206 insertions(+), 141 deletions(-) diff --git a/poetry.lock b/poetry.lock index 9d7e4d4b..d0d3b616 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.2.0 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.3.3 and should not be changed by hand. [[package]] name = "about-time" @@ -30,50 +30,56 @@ grapheme = "0.6.0" [[package]] name = "black" -version = "23.12.1" +version = "26.3.1" description = "The uncompromising code formatter." optional = false -python-versions = ">=3.8" +python-versions = ">=3.10" groups = ["dev"] files = [ - {file = "black-23.12.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e0aaf6041986767a5e0ce663c7a2f0e9eaf21e6ff87a5f95cbf3675bfd4c41d2"}, - {file = "black-23.12.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c88b3711d12905b74206227109272673edce0cb29f27e1385f33b0163c414bba"}, - {file = "black-23.12.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a920b569dc6b3472513ba6ddea21f440d4b4c699494d2e972a1753cdc25df7b0"}, - {file = "black-23.12.1-cp310-cp310-win_amd64.whl", hash = "sha256:3fa4be75ef2a6b96ea8d92b1587dd8cb3a35c7e3d51f0738ced0781c3aa3a5a3"}, - {file = "black-23.12.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:8d4df77958a622f9b5a4c96edb4b8c0034f8434032ab11077ec6c56ae9f384ba"}, - {file = "black-23.12.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:602cfb1196dc692424c70b6507593a2b29aac0547c1be9a1d1365f0d964c353b"}, - {file = "black-23.12.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9c4352800f14be5b4864016882cdba10755bd50805c95f728011bcb47a4afd59"}, - {file = "black-23.12.1-cp311-cp311-win_amd64.whl", hash = "sha256:0808494f2b2df923ffc5723ed3c7b096bd76341f6213989759287611e9837d50"}, - {file = "black-23.12.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:25e57fd232a6d6ff3f4478a6fd0580838e47c93c83eaf1ccc92d4faf27112c4e"}, - {file = "black-23.12.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2d9e13db441c509a3763a7a3d9a49ccc1b4e974a47be4e08ade2a228876500ec"}, - {file = "black-23.12.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6d1bd9c210f8b109b1762ec9fd36592fdd528485aadb3f5849b2740ef17e674e"}, - {file = "black-23.12.1-cp312-cp312-win_amd64.whl", hash = "sha256:ae76c22bde5cbb6bfd211ec343ded2163bba7883c7bc77f6b756a1049436fbb9"}, - {file = "black-23.12.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:1fa88a0f74e50e4487477bc0bb900c6781dbddfdfa32691e780bf854c3b4a47f"}, - {file = "black-23.12.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:a4d6a9668e45ad99d2f8ec70d5c8c04ef4f32f648ef39048d010b0689832ec6d"}, - {file = "black-23.12.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b18fb2ae6c4bb63eebe5be6bd869ba2f14fd0259bda7d18a46b764d8fb86298a"}, - {file = "black-23.12.1-cp38-cp38-win_amd64.whl", hash = "sha256:c04b6d9d20e9c13f43eee8ea87d44156b8505ca8a3c878773f68b4e4812a421e"}, - {file = "black-23.12.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:3e1b38b3135fd4c025c28c55ddfc236b05af657828a8a6abe5deec419a0b7055"}, - {file = "black-23.12.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:4f0031eaa7b921db76decd73636ef3a12c942ed367d8c3841a0739412b260a54"}, - {file = "black-23.12.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:97e56155c6b737854e60a9ab1c598ff2533d57e7506d97af5481141671abf3ea"}, - {file = "black-23.12.1-cp39-cp39-win_amd64.whl", hash = "sha256:dd15245c8b68fe2b6bd0f32c1556509d11bb33aec9b5d0866dd8e2ed3dba09c2"}, - {file = "black-23.12.1-py3-none-any.whl", hash = "sha256:78baad24af0f033958cad29731e27363183e140962595def56423e626f4bee3e"}, - {file = "black-23.12.1.tar.gz", hash = "sha256:4ce3ef14ebe8d9509188014d96af1c456a910d5b5cbf434a09fef7e024b3d0d5"}, + {file = "black-26.3.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:86a8b5035fce64f5dcd1b794cf8ec4d31fe458cf6ce3986a30deb434df82a1d2"}, + {file = "black-26.3.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5602bdb96d52d2d0672f24f6ffe5218795736dd34807fd0fd55ccd6bf206168b"}, + {file = "black-26.3.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c54a4a82e291a1fee5137371ab488866b7c86a3305af4026bdd4dc78642e1ac"}, + {file = "black-26.3.1-cp310-cp310-win_amd64.whl", hash = "sha256:6e131579c243c98f35bce64a7e08e87fb2d610544754675d4a0e73a070a5aa3a"}, + {file = "black-26.3.1-cp310-cp310-win_arm64.whl", hash = "sha256:5ed0ca58586c8d9a487352a96b15272b7fa55d139fc8496b519e78023a8dab0a"}, + {file = "black-26.3.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:28ef38aee69e4b12fda8dba75e21f9b4f979b490c8ac0baa7cb505369ac9e1ff"}, + {file = "black-26.3.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:bf9bf162ed91a26f1adba8efda0b573bc6924ec1408a52cc6f82cb73ec2b142c"}, + {file = "black-26.3.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:474c27574d6d7037c1bc875a81d9be0a9a4f9ee95e62800dab3cfaadbf75acd5"}, + {file = "black-26.3.1-cp311-cp311-win_amd64.whl", hash = "sha256:5e9d0d86df21f2e1677cc4bd090cd0e446278bcbbe49bf3659c308c3e402843e"}, + {file = "black-26.3.1-cp311-cp311-win_arm64.whl", hash = "sha256:9a5e9f45e5d5e1c5b5c29b3bd4265dcc90e8b92cf4534520896ed77f791f4da5"}, + {file = "black-26.3.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b5e6f89631eb88a7302d416594a32faeee9fb8fb848290da9d0a5f2903519fc1"}, + {file = "black-26.3.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:41cd2012d35b47d589cb8a16faf8a32ef7a336f56356babd9fcf70939ad1897f"}, + {file = "black-26.3.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f76ff19ec5297dd8e66eb64deda23631e642c9393ab592826fd4bdc97a4bce7"}, + {file = "black-26.3.1-cp312-cp312-win_amd64.whl", hash = "sha256:ddb113db38838eb9f043623ba274cfaf7d51d5b0c22ecb30afe58b1bb8322983"}, + {file = "black-26.3.1-cp312-cp312-win_arm64.whl", hash = "sha256:dfdd51fc3e64ea4f35873d1b3fb25326773d55d2329ff8449139ebaad7357efb"}, + {file = "black-26.3.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:855822d90f884905362f602880ed8b5df1b7e3ee7d0db2502d4388a954cc8c54"}, + {file = "black-26.3.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8a33d657f3276328ce00e4d37fe70361e1ec7614da5d7b6e78de5426cb56332f"}, + {file = "black-26.3.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f1cd08e99d2f9317292a311dfe578fd2a24b15dbce97792f9c4d752275c1fa56"}, + {file = "black-26.3.1-cp313-cp313-win_amd64.whl", hash = "sha256:c7e72339f841b5a237ff14f7d3880ddd0fc7f98a1199e8c4327f9a4f478c1839"}, + {file = "black-26.3.1-cp313-cp313-win_arm64.whl", hash = "sha256:afc622538b430aa4c8c853f7f63bc582b3b8030fd8c80b70fb5fa5b834e575c2"}, + {file = "black-26.3.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2d6bfaf7fd0993b420bed691f20f9492d53ce9a2bcccea4b797d34e947318a78"}, + {file = "black-26.3.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f89f2ab047c76a9c03f78d0d66ca519e389519902fa27e7a91117ef7611c0568"}, + {file = "black-26.3.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b07fc0dab849d24a80a29cfab8d8a19187d1c4685d8a5e6385a5ce323c1f015f"}, + {file = "black-26.3.1-cp314-cp314-win_amd64.whl", hash = "sha256:0126ae5b7c09957da2bdbd91a9ba1207453feada9e9fe51992848658c6c8e01c"}, + {file = "black-26.3.1-cp314-cp314-win_arm64.whl", hash = "sha256:92c0ec1f2cc149551a2b7b47efc32c866406b6891b0ee4625e95967c8f4acfb1"}, + {file = "black-26.3.1-py3-none-any.whl", hash = "sha256:2bd5aa94fc267d38bb21a70d7410a89f1a1d318841855f698746f8e7f51acd1b"}, + {file = "black-26.3.1.tar.gz", hash = "sha256:2c50f5063a9641c7eed7795014ba37b0f5fa227f3d408b968936e24bc0566b07"}, ] [package.dependencies] click = ">=8.0.0" mypy-extensions = ">=0.4.3" packaging = ">=22.0" -pathspec = ">=0.9.0" +pathspec = ">=1.0.0" platformdirs = ">=2" +pytokens = ">=0.4.0,<0.5.0" tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} typing-extensions = {version = ">=4.0.1", markers = "python_version < \"3.11\""} [package.extras] colorama = ["colorama (>=0.4.3)"] -d = ["aiohttp (>=3.7.4) ; sys_platform != \"win32\" or implementation_name != \"pypy\"", "aiohttp (>=3.7.4,!=3.9.0) ; sys_platform == \"win32\" and implementation_name == \"pypy\""] +d = ["aiohttp (>=3.10)"] jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"] -uvloop = ["uvloop (>=0.15.2)"] +uvloop = ["uvloop (>=0.15.2) ; sys_platform != \"win32\"", "winloop (>=0.5.0) ; sys_platform == \"win32\""] [[package]] name = "boto3" @@ -377,7 +383,7 @@ files = [ [package.dependencies] python-dateutil = "*" pytz = "*" -regex = "<2019.02.19 || >2019.02.19,<2021.8.27 || >2021.8.27" +regex = "<2019.2.19 || >2019.2.19,<2021.8.27 || >2021.8.27" tzlocal = "*" [package.extras] @@ -722,7 +728,7 @@ files = [ ] [package.dependencies] -certifi = ">=14.05.14" +certifi = ">=14.5.14" google-auth = ">=1.0.1" python-dateutil = ">=2.5.3" pyyaml = ">=5.4.1" @@ -1021,115 +1027,120 @@ xml = ["lxml (>=4.9.2)"] [[package]] name = "pathspec" -version = "0.12.1" +version = "1.1.1" description = "Utility library for gitignore style pattern matching of file paths." optional = false -python-versions = ">=3.8" +python-versions = ">=3.9" groups = ["dev"] files = [ - {file = "pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08"}, - {file = "pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712"}, + {file = "pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189"}, + {file = "pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a"}, ] +[package.extras] +hyperscan = ["hyperscan (>=0.7)"] +optional = ["typing-extensions (>=4)"] +re2 = ["google-re2 (>=1.1)"] + [[package]] name = "pillow" -version = "12.1.1" +version = "12.2.0" description = "Python Imaging Library (fork)" optional = false python-versions = ">=3.10" groups = ["main"] files = [ - {file = "pillow-12.1.1-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:1f1625b72740fdda5d77b4def688eb8fd6490975d06b909fd19f13f391e077e0"}, - {file = "pillow-12.1.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:178aa072084bd88ec759052feca8e56cbb14a60b39322b99a049e58090479713"}, - {file = "pillow-12.1.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b66e95d05ba806247aaa1561f080abc7975daf715c30780ff92a20e4ec546e1b"}, - {file = "pillow-12.1.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:89c7e895002bbe49cdc5426150377cbbc04767d7547ed145473f496dfa40408b"}, - {file = "pillow-12.1.1-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a5cbdcddad0af3da87cb16b60d23648bc3b51967eb07223e9fed77a82b457c4"}, - {file = "pillow-12.1.1-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9f51079765661884a486727f0729d29054242f74b46186026582b4e4769918e4"}, - {file = "pillow-12.1.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:99c1506ea77c11531d75e3a412832a13a71c7ebc8192ab9e4b2e355555920e3e"}, - {file = "pillow-12.1.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:36341d06738a9f66c8287cf8b876d24b18db9bd8740fa0672c74e259ad408cff"}, - {file = "pillow-12.1.1-cp310-cp310-win32.whl", hash = "sha256:6c52f062424c523d6c4db85518774cc3d50f5539dd6eed32b8f6229b26f24d40"}, - {file = "pillow-12.1.1-cp310-cp310-win_amd64.whl", hash = "sha256:c6008de247150668a705a6338156efb92334113421ceecf7438a12c9a12dab23"}, - {file = "pillow-12.1.1-cp310-cp310-win_arm64.whl", hash = "sha256:1a9b0ee305220b392e1124a764ee4265bd063e54a751a6b62eff69992f457fa9"}, - {file = "pillow-12.1.1-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:e879bb6cd5c73848ef3b2b48b8af9ff08c5b71ecda8048b7dd22d8a33f60be32"}, - {file = "pillow-12.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:365b10bb9417dd4498c0e3b128018c4a624dc11c7b97d8cc54effe3b096f4c38"}, - {file = "pillow-12.1.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d4ce8e329c93845720cd2014659ca67eac35f6433fd3050393d85f3ecef0dad5"}, - {file = "pillow-12.1.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc354a04072b765eccf2204f588a7a532c9511e8b9c7f900e1b64e3e33487090"}, - {file = "pillow-12.1.1-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7e7976bf1910a8116b523b9f9f58bf410f3e8aa330cd9a2bb2953f9266ab49af"}, - {file = "pillow-12.1.1-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:597bd9c8419bc7c6af5604e55847789b69123bbe25d65cc6ad3012b4f3c98d8b"}, - {file = "pillow-12.1.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2c1fc0f2ca5f96a3c8407e41cca26a16e46b21060fe6d5b099d2cb01412222f5"}, - {file = "pillow-12.1.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:578510d88c6229d735855e1f278aa305270438d36a05031dfaae5067cc8eb04d"}, - {file = "pillow-12.1.1-cp311-cp311-win32.whl", hash = "sha256:7311c0a0dcadb89b36b7025dfd8326ecfa36964e29913074d47382706e516a7c"}, - {file = "pillow-12.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:fbfa2a7c10cc2623f412753cddf391c7f971c52ca40a3f65dc5039b2939e8563"}, - {file = "pillow-12.1.1-cp311-cp311-win_arm64.whl", hash = "sha256:b81b5e3511211631b3f672a595e3221252c90af017e399056d0faabb9538aa80"}, - {file = "pillow-12.1.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ab323b787d6e18b3d91a72fc99b1a2c28651e4358749842b8f8dfacd28ef2052"}, - {file = "pillow-12.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:adebb5bee0f0af4909c30db0d890c773d1a92ffe83da908e2e9e720f8edf3984"}, - {file = "pillow-12.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bb66b7cc26f50977108790e2456b7921e773f23db5630261102233eb355a3b79"}, - {file = "pillow-12.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aee2810642b2898bb187ced9b349e95d2a7272930796e022efaf12e99dccd293"}, - {file = "pillow-12.1.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a0b1cd6232e2b618adcc54d9882e4e662a089d5768cd188f7c245b4c8c44a397"}, - {file = "pillow-12.1.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7aac39bcf8d4770d089588a2e1dd111cbaa42df5a94be3114222057d68336bd0"}, - {file = "pillow-12.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ab174cd7d29a62dd139c44bf74b698039328f45cb03b4596c43473a46656b2f3"}, - {file = "pillow-12.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:339ffdcb7cbeaa08221cd401d517d4b1fe7a9ed5d400e4a8039719238620ca35"}, - {file = "pillow-12.1.1-cp312-cp312-win32.whl", hash = "sha256:5d1f9575a12bed9e9eedd9a4972834b08c97a352bd17955ccdebfeca5913fa0a"}, - {file = "pillow-12.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:21329ec8c96c6e979cd0dfd29406c40c1d52521a90544463057d2aaa937d66a6"}, - {file = "pillow-12.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:af9a332e572978f0218686636610555ae3defd1633597be015ed50289a03c523"}, - {file = "pillow-12.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:d242e8ac078781f1de88bf823d70c1a9b3c7950a44cdf4b7c012e22ccbcd8e4e"}, - {file = "pillow-12.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:02f84dfad02693676692746df05b89cf25597560db2857363a208e393429f5e9"}, - {file = "pillow-12.1.1-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:e65498daf4b583091ccbb2556c7000abf0f3349fcd57ef7adc9a84a394ed29f6"}, - {file = "pillow-12.1.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6c6db3b84c87d48d0088943bf33440e0c42370b99b1c2a7989216f7b42eede60"}, - {file = "pillow-12.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8b7e5304e34942bf62e15184219a7b5ad4ff7f3bb5cca4d984f37df1a0e1aee2"}, - {file = "pillow-12.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:18e5bddd742a44b7e6b1e773ab5db102bd7a94c32555ba656e76d319d19c3850"}, - {file = "pillow-12.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc44ef1f3de4f45b50ccf9136999d71abb99dca7706bc75d222ed350b9fd2289"}, - {file = "pillow-12.1.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5a8eb7ed8d4198bccbd07058416eeec51686b498e784eda166395a23eb99138e"}, - {file = "pillow-12.1.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:47b94983da0c642de92ced1702c5b6c292a84bd3a8e1d1702ff923f183594717"}, - {file = "pillow-12.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:518a48c2aab7ce596d3bf79d0e275661b846e86e4d0e7dec34712c30fe07f02a"}, - {file = "pillow-12.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a550ae29b95c6dc13cf69e2c9dc5747f814c54eeb2e32d683e5e93af56caa029"}, - {file = "pillow-12.1.1-cp313-cp313-win32.whl", hash = "sha256:a003d7422449f6d1e3a34e3dd4110c22148336918ddbfc6a32581cd54b2e0b2b"}, - {file = "pillow-12.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:344cf1e3dab3be4b1fa08e449323d98a2a3f819ad20f4b22e77a0ede31f0faa1"}, - {file = "pillow-12.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:5c0dd1636633e7e6a0afe7bf6a51a14992b7f8e60de5789018ebbdfae55b040a"}, - {file = "pillow-12.1.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0330d233c1a0ead844fc097a7d16c0abff4c12e856c0b325f231820fee1f39da"}, - {file = "pillow-12.1.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5dae5f21afb91322f2ff791895ddd8889e5e947ff59f71b46041c8ce6db790bc"}, - {file = "pillow-12.1.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2e0c664be47252947d870ac0d327fea7e63985a08794758aa8af5b6cb6ec0c9c"}, - {file = "pillow-12.1.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:691ab2ac363b8217f7d31b3497108fb1f50faab2f75dfb03284ec2f217e87bf8"}, - {file = "pillow-12.1.1-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9e8064fb1cc019296958595f6db671fba95209e3ceb0c4734c9baf97de04b20"}, - {file = "pillow-12.1.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:472a8d7ded663e6162dafdf20015c486a7009483ca671cece7a9279b512fcb13"}, - {file = "pillow-12.1.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:89b54027a766529136a06cfebeecb3a04900397a3590fd252160b888479517bf"}, - {file = "pillow-12.1.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:86172b0831b82ce4f7877f280055892b31179e1576aa00d0df3bb1bbf8c3e524"}, - {file = "pillow-12.1.1-cp313-cp313t-win32.whl", hash = "sha256:44ce27545b6efcf0fdbdceb31c9a5bdea9333e664cda58a7e674bb74608b3986"}, - {file = "pillow-12.1.1-cp313-cp313t-win_amd64.whl", hash = "sha256:a285e3eb7a5a45a2ff504e31f4a8d1b12ef62e84e5411c6804a42197c1cf586c"}, - {file = "pillow-12.1.1-cp313-cp313t-win_arm64.whl", hash = "sha256:cc7d296b5ea4d29e6570dabeaed58d31c3fea35a633a69679fb03d7664f43fb3"}, - {file = "pillow-12.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:417423db963cb4be8bac3fc1204fe61610f6abeed1580a7a2cbb2fbda20f12af"}, - {file = "pillow-12.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:b957b71c6b2387610f556a7eb0828afbe40b4a98036fc0d2acfa5a44a0c2036f"}, - {file = "pillow-12.1.1-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:097690ba1f2efdeb165a20469d59d8bb03c55fb6621eb2041a060ae8ea3e9642"}, - {file = "pillow-12.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2815a87ab27848db0321fb78c7f0b2c8649dee134b7f2b80c6a45c6831d75ccd"}, - {file = "pillow-12.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f7ed2c6543bad5a7d5530eb9e78c53132f93dfa44a28492db88b41cdab885202"}, - {file = "pillow-12.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:652a2c9ccfb556235b2b501a3a7cf3742148cd22e04b5625c5fe057ea3e3191f"}, - {file = "pillow-12.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d6e4571eedf43af33d0fc233a382a76e849badbccdf1ac438841308652a08e1f"}, - {file = "pillow-12.1.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b574c51cf7d5d62e9be37ba446224b59a2da26dc4c1bb2ecbe936a4fb1a7cb7f"}, - {file = "pillow-12.1.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a37691702ed687799de29a518d63d4682d9016932db66d4e90c345831b02fb4e"}, - {file = "pillow-12.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f95c00d5d6700b2b890479664a06e754974848afaae5e21beb4d83c106923fd0"}, - {file = "pillow-12.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:559b38da23606e68681337ad74622c4dbba02254fc9cb4488a305dd5975c7eeb"}, - {file = "pillow-12.1.1-cp314-cp314-win32.whl", hash = "sha256:03edcc34d688572014ff223c125a3f77fb08091e4607e7745002fc214070b35f"}, - {file = "pillow-12.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:50480dcd74fa63b8e78235957d302d98d98d82ccbfac4c7e12108ba9ecbdba15"}, - {file = "pillow-12.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:5cb1785d97b0c3d1d1a16bc1d710c4a0049daefc4935f3a8f31f827f4d3d2e7f"}, - {file = "pillow-12.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:1f90cff8aa76835cba5769f0b3121a22bd4eb9e6884cfe338216e557a9a548b8"}, - {file = "pillow-12.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1f1be78ce9466a7ee64bfda57bdba0f7cc499d9794d518b854816c41bf0aa4e9"}, - {file = "pillow-12.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:42fc1f4677106188ad9a55562bbade416f8b55456f522430fadab3cef7cd4e60"}, - {file = "pillow-12.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:98edb152429ab62a1818039744d8fbb3ccab98a7c29fc3d5fcef158f3f1f68b7"}, - {file = "pillow-12.1.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d470ab1178551dd17fdba0fef463359c41aaa613cdcd7ff8373f54be629f9f8f"}, - {file = "pillow-12.1.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6408a7b064595afcab0a49393a413732a35788f2a5092fdc6266952ed67de586"}, - {file = "pillow-12.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5d8c41325b382c07799a3682c1c258469ea2ff97103c53717b7893862d0c98ce"}, - {file = "pillow-12.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c7697918b5be27424e9ce568193efd13d925c4481dd364e43f5dff72d33e10f8"}, - {file = "pillow-12.1.1-cp314-cp314t-win32.whl", hash = "sha256:d2912fd8114fc5545aa3a4b5576512f64c55a03f3ebcca4c10194d593d43ea36"}, - {file = "pillow-12.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:4ceb838d4bd9dab43e06c363cab2eebf63846d6a4aeaea283bbdfd8f1a8ed58b"}, - {file = "pillow-12.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:7b03048319bfc6170e93bd60728a1af51d3dd7704935feb228c4d4faab35d334"}, - {file = "pillow-12.1.1-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:600fd103672b925fe62ed08e0d874ea34d692474df6f4bf7ebe148b30f89f39f"}, - {file = "pillow-12.1.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:665e1b916b043cef294bc54d47bf02d87e13f769bc4bc5fa225a24b3a6c5aca9"}, - {file = "pillow-12.1.1-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:495c302af3aad1ca67420ddd5c7bd480c8867ad173528767d906428057a11f0e"}, - {file = "pillow-12.1.1-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8fd420ef0c52c88b5a035a0886f367748c72147b2b8f384c9d12656678dfdfa9"}, - {file = "pillow-12.1.1-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f975aa7ef9684ce7e2c18a3aa8f8e2106ce1e46b94ab713d156b2898811651d3"}, - {file = "pillow-12.1.1-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8089c852a56c2966cf18835db62d9b34fef7ba74c726ad943928d494fa7f4735"}, - {file = "pillow-12.1.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:cb9bb857b2d057c6dfc72ac5f3b44836924ba15721882ef103cecb40d002d80e"}, - {file = "pillow-12.1.1.tar.gz", hash = "sha256:9ad8fa5937ab05218e2b6a4cff30295ad35afd2f83ac592e68c0d871bb0fdbc4"}, + {file = "pillow-12.2.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:a4e8f36e677d3336f35089648c8955c51c6d386a13cf6ee9c189c5f5bd713a9f"}, + {file = "pillow-12.2.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2e589959f10d9824d39b350472b92f0ce3b443c0a3442ebf41c40cb8361c5b97"}, + {file = "pillow-12.2.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a52edc8bfff4429aaabdf4d9ee0daadbbf8562364f940937b941f87a4290f5ff"}, + {file = "pillow-12.2.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:975385f4776fafde056abb318f612ef6285b10a1f12b8570f3647ad0d74b48ec"}, + {file = "pillow-12.2.0-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bd9c0c7a0c681a347b3194c500cb1e6ca9cab053ea4d82a5cf45b6b754560136"}, + {file = "pillow-12.2.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:88d387ff40b3ff7c274947ed3125dedf5262ec6919d83946753b5f3d7c67ea4c"}, + {file = "pillow-12.2.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:51c4167c34b0d8ba05b547a3bb23578d0ba17b80a5593f93bd8ecb123dd336a3"}, + {file = "pillow-12.2.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:34c0d99ecccea270c04882cb3b86e7b57296079c9a4aff88cb3b33563d95afaa"}, + {file = "pillow-12.2.0-cp310-cp310-win32.whl", hash = "sha256:b85f66ae9eb53e860a873b858b789217ba505e5e405a24b85c0464822fe88032"}, + {file = "pillow-12.2.0-cp310-cp310-win_amd64.whl", hash = "sha256:673aa32138f3e7531ccdbca7b3901dba9b70940a19ccecc6a37c77d5fdeb05b5"}, + {file = "pillow-12.2.0-cp310-cp310-win_arm64.whl", hash = "sha256:3e080565d8d7c671db5802eedfb438e5565ffa40115216eabb8cd52d0ecce024"}, + {file = "pillow-12.2.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:8be29e59487a79f173507c30ddf57e733a357f67881430449bb32614075a40ab"}, + {file = "pillow-12.2.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:71cde9a1e1551df7d34a25462fc60325e8a11a82cc2e2f54578e5e9a1e153d65"}, + {file = "pillow-12.2.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f490f9368b6fc026f021db16d7ec2fbf7d89e2edb42e8ec09d2c60505f5729c7"}, + {file = "pillow-12.2.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8bd7903a5f2a4545f6fd5935c90058b89d30045568985a71c79f5fd6edf9b91e"}, + {file = "pillow-12.2.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3997232e10d2920a68d25191392e3a4487d8183039e1c74c2297f00ed1c50705"}, + {file = "pillow-12.2.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e74473c875d78b8e9d5da2a70f7099549f9eb37ded4e2f6a463e60125bccd176"}, + {file = "pillow-12.2.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:56a3f9c60a13133a98ecff6197af34d7824de9b7b38c3654861a725c970c197b"}, + {file = "pillow-12.2.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:90e6f81de50ad6b534cab6e5aef77ff6e37722b2f5d908686f4a5c9eba17a909"}, + {file = "pillow-12.2.0-cp311-cp311-win32.whl", hash = "sha256:8c984051042858021a54926eb597d6ee3012393ce9c181814115df4c60b9a808"}, + {file = "pillow-12.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:6e6b2a0c538fc200b38ff9eb6628228b77908c319a005815f2dde585a0664b60"}, + {file = "pillow-12.2.0-cp311-cp311-win_arm64.whl", hash = "sha256:9a8a34cc89c67a65ea7437ce257cea81a9dad65b29805f3ecee8c8fe8ff25ffe"}, + {file = "pillow-12.2.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2d192a155bbcec180f8564f693e6fd9bccff5a7af9b32e2e4bf8c9c69dbad6b5"}, + {file = "pillow-12.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f3f40b3c5a968281fd507d519e444c35f0ff171237f4fdde090dd60699458421"}, + {file = "pillow-12.2.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:03e7e372d5240cc23e9f07deca4d775c0817bffc641b01e9c3af208dbd300987"}, + {file = "pillow-12.2.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b86024e52a1b269467a802258c25521e6d742349d760728092e1bc2d135b4d76"}, + {file = "pillow-12.2.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7371b48c4fa448d20d2714c9a1f775a81155050d383333e0a6c15b1123dda005"}, + {file = "pillow-12.2.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:62f5409336adb0663b7caa0da5c7d9e7bdbaae9ce761d34669420c2a801b2780"}, + {file = "pillow-12.2.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:01afa7cf67f74f09523699b4e88c73fb55c13346d212a59a2db1f86b0a63e8c5"}, + {file = "pillow-12.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc3d34d4a8fbec3e88a79b92e5465e0f9b842b628675850d860b8bd300b159f5"}, + {file = "pillow-12.2.0-cp312-cp312-win32.whl", hash = "sha256:58f62cc0f00fd29e64b29f4fd923ffdb3859c9f9e6105bfc37ba1d08994e8940"}, + {file = "pillow-12.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:7f84204dee22a783350679a0333981df803dac21a0190d706a50475e361c93f5"}, + {file = "pillow-12.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:af73337013e0b3b46f175e79492d96845b16126ddf79c438d7ea7ff27783a414"}, + {file = "pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:8297651f5b5679c19968abefd6bb84d95fe30ef712eb1b2d9b2d31ca61267f4c"}, + {file = "pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:50d8520da2a6ce0af445fa6d648c4273c3eeefbc32d7ce049f22e8b5c3daecc2"}, + {file = "pillow-12.2.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:766cef22385fa1091258ad7e6216792b156dc16d8d3fa607e7545b2b72061f1c"}, + {file = "pillow-12.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5d2fd0fa6b5d9d1de415060363433f28da8b1526c1c129020435e186794b3795"}, + {file = "pillow-12.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:56b25336f502b6ed02e889f4ece894a72612fe885889a6e8c4c80239ff6e5f5f"}, + {file = "pillow-12.2.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f1c943e96e85df3d3478f7b691f229887e143f81fedab9b20205349ab04d73ed"}, + {file = "pillow-12.2.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:03f6fab9219220f041c74aeaa2939ff0062bd5c364ba9ce037197f4c6d498cd9"}, + {file = "pillow-12.2.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5cdfebd752ec52bf5bb4e35d9c64b40826bc5b40a13df7c3cda20a2c03a0f5ed"}, + {file = "pillow-12.2.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eedf4b74eda2b5a4b2b2fb4c006d6295df3bf29e459e198c90ea48e130dc75c3"}, + {file = "pillow-12.2.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:00a2865911330191c0b818c59103b58a5e697cae67042366970a6b6f1b20b7f9"}, + {file = "pillow-12.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1e1757442ed87f4912397c6d35a0db6a7b52592156014706f17658ff58bbf795"}, + {file = "pillow-12.2.0-cp313-cp313-win32.whl", hash = "sha256:144748b3af2d1b358d41286056d0003f47cb339b8c43a9ea42f5fea4d8c66b6e"}, + {file = "pillow-12.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:390ede346628ccc626e5730107cde16c42d3836b89662a115a921f28440e6a3b"}, + {file = "pillow-12.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:8023abc91fba39036dbce14a7d6535632f99c0b857807cbbbf21ecc9f4717f06"}, + {file = "pillow-12.2.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:042db20a421b9bafecc4b84a8b6e444686bd9d836c7fd24542db3e7df7baad9b"}, + {file = "pillow-12.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:dd025009355c926a84a612fecf58bb315a3f6814b17ead51a8e48d3823d9087f"}, + {file = "pillow-12.2.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:88ddbc66737e277852913bd1e07c150cc7bb124539f94c4e2df5344494e0a612"}, + {file = "pillow-12.2.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d362d1878f00c142b7e1a16e6e5e780f02be8195123f164edf7eddd911eefe7c"}, + {file = "pillow-12.2.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c727a6d53cb0018aadd8018c2b938376af27914a68a492f59dfcaca650d5eea"}, + {file = "pillow-12.2.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:efd8c21c98c5cc60653bcb311bef2ce0401642b7ce9d09e03a7da87c878289d4"}, + {file = "pillow-12.2.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9f08483a632889536b8139663db60f6724bfcb443c96f1b18855860d7d5c0fd4"}, + {file = "pillow-12.2.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dac8d77255a37e81a2efcbd1fc05f1c15ee82200e6c240d7e127e25e365c39ea"}, + {file = "pillow-12.2.0-cp313-cp313t-win32.whl", hash = "sha256:ee3120ae9dff32f121610bb08e4313be87e03efeadfc6c0d18f89127e24d0c24"}, + {file = "pillow-12.2.0-cp313-cp313t-win_amd64.whl", hash = "sha256:325ca0528c6788d2a6c3d40e3568639398137346c3d6e66bb61db96b96511c98"}, + {file = "pillow-12.2.0-cp313-cp313t-win_arm64.whl", hash = "sha256:2e5a76d03a6c6dcef67edabda7a52494afa4035021a79c8558e14af25313d453"}, + {file = "pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:3adc9215e8be0448ed6e814966ecf3d9952f0ea40eb14e89a102b87f450660d8"}, + {file = "pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:6a9adfc6d24b10f89588096364cc726174118c62130c817c2837c60cf08a392b"}, + {file = "pillow-12.2.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:6a6e67ea2e6feda684ed370f9a1c52e7a243631c025ba42149a2cc5934dec295"}, + {file = "pillow-12.2.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2bb4a8d594eacdfc59d9e5ad972aa8afdd48d584ffd5f13a937a664c3e7db0ed"}, + {file = "pillow-12.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:80b2da48193b2f33ed0c32c38140f9d3186583ce7d516526d462645fd98660ae"}, + {file = "pillow-12.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:22db17c68434de69d8ecfc2fe821569195c0c373b25cccb9cbdacf2c6e53c601"}, + {file = "pillow-12.2.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7b14cc0106cd9aecda615dd6903840a058b4700fcb817687d0ee4fc8b6e389be"}, + {file = "pillow-12.2.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cbeb542b2ebc6fcdacabf8aca8c1a97c9b3ad3927d46b8723f9d4f033288a0f"}, + {file = "pillow-12.2.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4bfd07bc812fbd20395212969e41931001fd59eb55a60658b0e5710872e95286"}, + {file = "pillow-12.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9aba9a17b623ef750a4d11b742cbafffeb48a869821252b30ee21b5e91392c50"}, + {file = "pillow-12.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:deede7c263feb25dba4e82ea23058a235dcc2fe1f6021025dc71f2b618e26104"}, + {file = "pillow-12.2.0-cp314-cp314-win32.whl", hash = "sha256:632ff19b2778e43162304d50da0181ce24ac5bb8180122cbe1bf4673428328c7"}, + {file = "pillow-12.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:4e6c62e9d237e9b65fac06857d511e90d8461a32adcc1b9065ea0c0fa3a28150"}, + {file = "pillow-12.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:b1c1fbd8a5a1af3412a0810d060a78b5136ec0836c8a4ef9aa11807f2a22f4e1"}, + {file = "pillow-12.2.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:57850958fe9c751670e49b2cecf6294acc99e562531f4bd317fa5ddee2068463"}, + {file = "pillow-12.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d5d38f1411c0ed9f97bcb49b7bd59b6b7c314e0e27420e34d99d844b9ce3b6f3"}, + {file = "pillow-12.2.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5c0a9f29ca8e79f09de89293f82fc9b0270bb4af1d58bc98f540cc4aedf03166"}, + {file = "pillow-12.2.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1610dd6c61621ae1cf811bef44d77e149ce3f7b95afe66a4512f8c59f25d9ebe"}, + {file = "pillow-12.2.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a34329707af4f73cf1782a36cd2289c0368880654a2c11f027bcee9052d35dd"}, + {file = "pillow-12.2.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e9c4f5b3c546fa3458a29ab22646c1c6c787ea8f5ef51300e5a60300736905e"}, + {file = "pillow-12.2.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fb043ee2f06b41473269765c2feae53fc2e2fbf96e5e22ca94fb5ad677856f06"}, + {file = "pillow-12.2.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f278f034eb75b4e8a13a54a876cc4a5ab39173d2cdd93a638e1b467fc545ac43"}, + {file = "pillow-12.2.0-cp314-cp314t-win32.whl", hash = "sha256:6bb77b2dcb06b20f9f4b4a8454caa581cd4dd0643a08bacf821216a16d9c8354"}, + {file = "pillow-12.2.0-cp314-cp314t-win_amd64.whl", hash = "sha256:6562ace0d3fb5f20ed7290f1f929cae41b25ae29528f2af1722966a0a02e2aa1"}, + {file = "pillow-12.2.0-cp314-cp314t-win_arm64.whl", hash = "sha256:aa88ccfe4e32d362816319ed727a004423aab09c5cea43c01a4b435643fa34eb"}, + {file = "pillow-12.2.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0538bd5e05efec03ae613fd89c4ce0368ecd2ba239cc25b9f9be7ed426b0af1f"}, + {file = "pillow-12.2.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:394167b21da716608eac917c60aa9b969421b5dcbbe02ae7f013e7b85811c69d"}, + {file = "pillow-12.2.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5d04bfa02cc2d23b497d1e90a0f927070043f6cbf303e738300532379a4b4e0f"}, + {file = "pillow-12.2.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0c838a5125cee37e68edec915651521191cef1e6aa336b855f495766e77a366e"}, + {file = "pillow-12.2.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a6c9fa44005fa37a91ebfc95d081e8079757d2e904b27103f4f5fa6f0bf78c0"}, + {file = "pillow-12.2.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:25373b66e0dd5905ed63fa3cae13c82fbddf3079f2c8bf15c6fb6a35586324c1"}, + {file = "pillow-12.2.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:bfa9c230d2fe991bed5318a5f119bd6780cda2915cca595393649fc118ab895e"}, + {file = "pillow-12.2.0.tar.gz", hash = "sha256:a830b1a40919539d07806aa58e1b114df53ddd43213d9c8b75847eee6c0182b5"}, ] [package.extras] @@ -1218,14 +1229,14 @@ zipp = ">=3.20.1,<4.0.0" [[package]] name = "pyasn1" -version = "0.6.2" +version = "0.6.3" description = "Pure-Python implementation of ASN.1 types and DER/BER/CER codecs (X.208)" optional = false python-versions = ">=3.8" groups = ["main"] files = [ - {file = "pyasn1-0.6.2-py3-none-any.whl", hash = "sha256:1eb26d860996a18e9b6ed05e7aae0e9fc21619fcee6af91cca9bad4fbea224bf"}, - {file = "pyasn1-0.6.2.tar.gz", hash = "sha256:9b59a2b25ba7e4f8197db7686c09fb33e658b98339fadb826e9512629017833b"}, + {file = "pyasn1-0.6.3-py3-none-any.whl", hash = "sha256:a80184d120f0864a52a073acc6fc642847d0be408e7c7252f31390c0f4eadcde"}, + {file = "pyasn1-0.6.3.tar.gz", hash = "sha256:697a8ecd6d98891189184ca1fa05d1bb00e2f84b5977c481452050549c8a72cf"}, ] [[package]] @@ -1322,18 +1333,17 @@ files = [ [[package]] name = "pygments" -version = "2.17.2" +version = "2.20.0" description = "Pygments is a syntax highlighting package written in Python." optional = false -python-versions = ">=3.7" +python-versions = ">=3.9" groups = ["main"] files = [ - {file = "pygments-2.17.2-py3-none-any.whl", hash = "sha256:b27c2826c47d0f3219f29554824c30c5e8945175d888647acd804ddd04af846c"}, - {file = "pygments-2.17.2.tar.gz", hash = "sha256:da46cec9fd2de5be3a8a784f434e4c4ab670b4ff54d605c4c2717e9d49c4c367"}, + {file = "pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176"}, + {file = "pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f"}, ] [package.extras] -plugins = ["importlib-metadata ; python_version < \"3.8\""] windows-terminal = ["colorama (>=0.4.6)"] [[package]] @@ -1408,6 +1418,61 @@ files = [ [package.dependencies] six = ">=1.5" +[[package]] +name = "pytokens" +version = "0.4.1" +description = "A Fast, spec compliant Python 3.14+ tokenizer that runs on older Pythons." +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "pytokens-0.4.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2a44ed93ea23415c54f3face3b65ef2b844d96aeb3455b8a69b3df6beab6acc5"}, + {file = "pytokens-0.4.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:add8bf86b71a5d9fb5b89f023a80b791e04fba57960aa790cc6125f7f1d39dfe"}, + {file = "pytokens-0.4.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:670d286910b531c7b7e3c0b453fd8156f250adb140146d234a82219459b9640c"}, + {file = "pytokens-0.4.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:4e691d7f5186bd2842c14813f79f8884bb03f5995f0575272009982c5ac6c0f7"}, + {file = "pytokens-0.4.1-cp310-cp310-win_amd64.whl", hash = "sha256:27b83ad28825978742beef057bfe406ad6ed524b2d28c252c5de7b4a6dd48fa2"}, + {file = "pytokens-0.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d70e77c55ae8380c91c0c18dea05951482e263982911fc7410b1ffd1dadd3440"}, + {file = "pytokens-0.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a58d057208cb9075c144950d789511220b07636dd2e4708d5645d24de666bdc"}, + {file = "pytokens-0.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b49750419d300e2b5a3813cf229d4e5a4c728dae470bcc89867a9ad6f25a722d"}, + {file = "pytokens-0.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d9907d61f15bf7261d7e775bd5d7ee4d2930e04424bab1972591918497623a16"}, + {file = "pytokens-0.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:ee44d0f85b803321710f9239f335aafe16553b39106384cef8e6de40cb4ef2f6"}, + {file = "pytokens-0.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:140709331e846b728475786df8aeb27d24f48cbcf7bcd449f8de75cae7a45083"}, + {file = "pytokens-0.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6d6c4268598f762bc8e91f5dbf2ab2f61f7b95bdc07953b602db879b3c8c18e1"}, + {file = "pytokens-0.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:24afde1f53d95348b5a0eb19488661147285ca4dd7ed752bbc3e1c6242a304d1"}, + {file = "pytokens-0.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5ad948d085ed6c16413eb5fec6b3e02fa00dc29a2534f088d3302c47eb59adf9"}, + {file = "pytokens-0.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:3f901fe783e06e48e8cbdc82d631fca8f118333798193e026a50ce1b3757ea68"}, + {file = "pytokens-0.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8bdb9d0ce90cbf99c525e75a2fa415144fd570a1ba987380190e8b786bc6ef9b"}, + {file = "pytokens-0.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5502408cab1cb18e128570f8d598981c68a50d0cbd7c61312a90507cd3a1276f"}, + {file = "pytokens-0.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:29d1d8fb1030af4d231789959f21821ab6325e463f0503a61d204343c9b355d1"}, + {file = "pytokens-0.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:970b08dd6b86058b6dc07efe9e98414f5102974716232d10f32ff39701e841c4"}, + {file = "pytokens-0.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:9bd7d7f544d362576be74f9d5901a22f317efc20046efe2034dced238cbbfe78"}, + {file = "pytokens-0.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4a14d5f5fc78ce85e426aa159489e2d5961acf0e47575e08f35584009178e321"}, + {file = "pytokens-0.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97f50fd18543be72da51dd505e2ed20d2228c74e0464e4262e4899797803d7fa"}, + {file = "pytokens-0.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dc74c035f9bfca0255c1af77ddd2d6ae8419012805453e4b0e7513e17904545d"}, + {file = "pytokens-0.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f66a6bbe741bd431f6d741e617e0f39ec7257ca1f89089593479347cc4d13324"}, + {file = "pytokens-0.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:b35d7e5ad269804f6697727702da3c517bb8a5228afa450ab0fa787732055fc9"}, + {file = "pytokens-0.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:8fcb9ba3709ff77e77f1c7022ff11d13553f3c30299a9fe246a166903e9091eb"}, + {file = "pytokens-0.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:79fc6b8699564e1f9b521582c35435f1bd32dd06822322ec44afdeba666d8cb3"}, + {file = "pytokens-0.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d31b97b3de0f61571a124a00ffe9a81fb9939146c122c11060725bd5aea79975"}, + {file = "pytokens-0.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:967cf6e3fd4adf7de8fc73cd3043754ae79c36475c1c11d514fc72cf5490094a"}, + {file = "pytokens-0.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:584c80c24b078eec1e227079d56dc22ff755e0ba8654d8383b2c549107528918"}, + {file = "pytokens-0.4.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:da5baeaf7116dced9c6bb76dc31ba04a2dc3695f3d9f74741d7910122b456edc"}, + {file = "pytokens-0.4.1-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:11edda0942da80ff58c4408407616a310adecae1ddd22eef8c692fe266fa5009"}, + {file = "pytokens-0.4.1-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0fc71786e629cef478cbf29d7ea1923299181d0699dbe7c3c0f4a583811d9fc1"}, + {file = "pytokens-0.4.1-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:dcafc12c30dbaf1e2af0490978352e0c4041a7cde31f4f81435c2a5e8b9cabb6"}, + {file = "pytokens-0.4.1-cp38-cp38-win_amd64.whl", hash = "sha256:42f144f3aafa5d92bad964d471a581651e28b24434d184871bd02e3a0d956037"}, + {file = "pytokens-0.4.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:34bcc734bd2f2d5fe3b34e7b3c0116bfb2397f2d9666139988e7a3eb5f7400e3"}, + {file = "pytokens-0.4.1-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:941d4343bf27b605e9213b26bfa1c4bf197c9c599a9627eb7305b0defcfe40c1"}, + {file = "pytokens-0.4.1-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3ad72b851e781478366288743198101e5eb34a414f1d5627cdd585ca3b25f1db"}, + {file = "pytokens-0.4.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:682fa37ff4d8e95f7df6fe6fe6a431e8ed8e788023c6bcc0f0880a12eab80ad1"}, + {file = "pytokens-0.4.1-cp39-cp39-win_amd64.whl", hash = "sha256:30f51edd9bb7f85c748979384165601d028b84f7bd13fe14d3e065304093916a"}, + {file = "pytokens-0.4.1-py3-none-any.whl", hash = "sha256:26cef14744a8385f35d0e095dc8b3a7583f6c953c2e3d269c7f82484bf5ad2de"}, + {file = "pytokens-0.4.1.tar.gz", hash = "sha256:292052fe80923aae2260c073f822ceba21f3872ced9a68bb7953b348e561179a"}, +] + +[package.extras] +dev = ["black", "build", "mypy", "pytest", "pytest-cov", "setuptools", "tox", "twine", "wheel"] + [[package]] name = "pytz" version = "2024.1" @@ -1672,10 +1737,10 @@ files = [ ] [package.dependencies] -botocore = ">=1.37.4,<2.0a.0" +botocore = ">=1.37.4,<2.0a0" [package.extras] -crt = ["botocore[crt] (>=1.37.4,<2.0a.0)"] +crt = ["botocore[crt] (>=1.37.4,<2.0a0)"] [[package]] name = "setuptools" @@ -1941,4 +2006,4 @@ type = ["pytest-mypy"] [metadata] lock-version = "2.1" python-versions = ">=3.10,<=3.12.9" -content-hash = "208202e34bd4ccec962f07fba78a4804318866ae6b0d9e20c05f5d769b6e5f0c" +content-hash = "1e6376d22318aa872174e6c4d353c8af3815c458e9de4ae95a5c6aac64893898" diff --git a/pyproject.toml b/pyproject.toml index 377b36d2..e2d25e6c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -45,7 +45,7 @@ tenacity = "^9.0.0" [tool.poetry.group.dev.dependencies] mypy = "^1.18.2" -black = "^23.1.0" +black = "^26.3.1" isort = "^5.12.0" flake8 = "^6.0.0" types-pyyaml = "^6.0.12.8" diff --git a/requirements.txt b/requirements.txt index bfcf7b36..6ab94719 100644 --- a/requirements.txt +++ b/requirements.txt @@ -24,13 +24,13 @@ numpy==1.26.4 ; python_version >= "3.10" and python_full_version < "3.13" oauthlib==3.2.2 ; python_version >= "3.10" and python_full_version < "3.13" packaging==24.0 ; python_version >= "3.10" and python_full_version < "3.13" pandas==2.2.2 ; python_version >= "3.10" and python_full_version < "3.13" -pillow==12.1.1 ; python_version >= "3.10" and python_full_version < "3.13" +pillow==12.2.0 ; python_version >= "3.10" and python_full_version < "3.13" prometheus-api-client==0.5.3 ; python_version >= "3.10" and python_full_version < "3.13" prometrix==0.2.11; python_version >= "3.10" and python_full_version < "3.13" pyasn1-modules==0.4.2 ; python_version >= "3.10" and python_full_version < "3.13" -pyasn1==0.6.2 ; python_version >= "3.10" and python_full_version < "3.13" +pyasn1==0.6.3 ; python_version >= "3.10" and python_full_version < "3.13" pydantic==1.10.15 ; python_version >= "3.10" and python_full_version < "3.13" -pygments==2.17.2 ; python_version >= "3.10" and python_full_version < "3.13" +pygments==2.20.0 ; python_version >= "3.10" and python_full_version < "3.13" pyparsing==3.1.2 ; python_version >= "3.10" and python_full_version < "3.13" python-dateutil==2.9.0.post0 ; python_version >= "3.10" and python_full_version < "3.13" pytz==2024.1 ; python_version >= "3.10" and python_full_version < "3.13" From 07fb42f433f3996cdd1b92ca9347c8dd2286c977 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Apr 2026 11:03:56 +0000 Subject: [PATCH 2/3] Apply black 26.3.1 auto-formatting Reformat 40 files to match the new style enforced by black 26.3.1 (version bumped in the previous commit to fix CVE-2026-32274). Also align the pre-commit hook rev to 26.3.1 so local hooks and CI use the same formatter version. --- .pre-commit-config.yaml | 2 +- enforcer/dal/robusta_config.py | 3 +- enforcer/dal/supabase_dal.py | 40 ++---- enforcer/enforcer_main.py | 105 ++++++++-------- enforcer/env_vars.py | 13 +- enforcer/metrics.py | 21 ++-- enforcer/model.py | 5 +- enforcer/params_utils.py | 1 + enforcer/patch_manager.py | 79 ++++++------ .../resources/kubernetes_resource_loader.py | 18 +-- enforcer/resources/owner_store.py | 41 +++--- enforcer/resources/recommendation_store.py | 14 +-- robusta_krr/core/abstract/metrics.py | 3 +- robusta_krr/core/abstract/strategies.py | 4 +- .../core/integrations/kubernetes/__init__.py | 105 ++++++++-------- .../core/integrations/openshift/token.py | 4 +- .../core/integrations/prometheus/loader.py | 8 +- .../integrations/prometheus/metrics/memory.py | 1 + .../metrics_service/base_metric_service.py | 12 +- .../metrics_service/mimir_metrics_service.py | 1 + .../prometheus_metrics_service.py | 72 +++++------ .../prometheus/prometheus_utils.py | 4 +- robusta_krr/core/models/allocations.py | 39 +++--- robusta_krr/core/models/config.py | 20 +-- robusta_krr/core/models/objects.py | 14 ++- robusta_krr/core/models/result.py | 10 +- robusta_krr/core/runner.py | 117 ++++++++---------- robusta_krr/formatters/csv_raw.py | 6 +- robusta_krr/formatters/html.py | 1 + robusta_krr/formatters/table.py | 9 +- robusta_krr/main.py | 4 +- robusta_krr/strategies/__init__.py | 2 +- robusta_krr/strategies/simple.py | 8 +- robusta_krr/utils/intro.py | 11 +- robusta_krr/utils/patch.py | 7 +- robusta_krr/utils/version.py | 4 +- tests/formatters/test_csv_formatter.py | 4 +- tests/test_app_imports.py | 2 + tests/test_grouped_jobs.py | 114 +++++++++-------- tests/test_grouped_jobs_metrics_logic.py | 96 +++++++------- tests/test_krr.py | 71 ++++++----- 41 files changed, 556 insertions(+), 539 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index a51d2344..94caaab8 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/ambv/black - rev: 23.1.0 + rev: 26.3.1 hooks: - id: black language_version: python3 diff --git a/enforcer/dal/robusta_config.py b/enforcer/dal/robusta_config.py index 9b058b54..a5675be2 100644 --- a/enforcer/dal/robusta_config.py +++ b/enforcer/dal/robusta_config.py @@ -6,9 +6,10 @@ class RobustaConfig(BaseModel): sinks_config: List[Dict[str, Dict]] global_config: dict + class RobustaToken(BaseModel): store_url: str api_key: str account_id: str email: str - password: str \ No newline at end of file + password: str diff --git a/enforcer/dal/supabase_dal.py b/enforcer/dal/supabase_dal.py index 221214a9..8d24876a 100644 --- a/enforcer/dal/supabase_dal.py +++ b/enforcer/dal/supabase_dal.py @@ -45,9 +45,7 @@ def __init__(self): if not self.enabled: logging.info("Not connecting to Robusta platform - robusta token not provided") return - logging.info( - f"Initializing Robusta platform connection for account {self.account_id} cluster {self.cluster}" - ) + logging.info(f"Initializing Robusta platform connection for account {self.account_id} cluster {self.cluster}") options = ClientOptions(postgrest_client_timeout=SUPABASE_TIMEOUT_SECONDS) self.client = create_client(self.url, self.api_key, options) self.user_id = self.sign_in() @@ -67,9 +65,7 @@ def execute_with_retry(_self): message = exc.message or "" if exc.code == "PGRST301" or "expired" in message.lower(): # JWT expired. Sign in again and retry the query - logging.error( - "JWT token expired/invalid, signing in to Supabase again" - ) + logging.error("JWT token expired/invalid, signing in to Supabase again") self.sign_in() # update the session to the new one, after re-sign in _self.session = self.client.postgrest.session @@ -81,7 +77,7 @@ def execute_with_retry(_self): SyncQueryRequestBuilder.execute = execute_with_retry @staticmethod - def __load_robusta_config() -> (Optional[RobustaToken],Optional[str]): + def __load_robusta_config() -> (Optional[RobustaToken], Optional[str]): config_file_path = ROBUSTA_CONFIG_PATH env_ui_token = os.environ.get("ROBUSTA_UI_TOKEN") cluster_name = os.environ.get("CLUSTER_NAME") @@ -92,9 +88,7 @@ def __load_robusta_config() -> (Optional[RobustaToken],Optional[str]): decoded = base64.b64decode(env_ui_token) return RobustaToken(**json.loads(decoded)), cluster_name except binascii.Error: - raise Exception( - "binascii.Error encountered. The Robusta UI token is not a valid base64." - ) + raise Exception("binascii.Error encountered. The Robusta UI token is not a valid base64.") except json.JSONDecodeError: raise Exception( "json.JSONDecodeError encountered. The Robusta UI token could not be parsed as JSON after being base64 decoded." @@ -112,10 +106,7 @@ def __load_robusta_config() -> (Optional[RobustaToken],Optional[str]): if "robusta_sink" in conf.keys(): token = conf["robusta_sink"].get("token") if not token: - raise Exception( - "No robusta token provided.\n" - "Please set a valid Robusta UI token.\n " - ) + raise Exception("No robusta token provided.\n" "Please set a valid Robusta UI token.\n ") env_replacement_token = get_env_replacement(token) if env_replacement_token: token = env_replacement_token @@ -131,9 +122,7 @@ def __load_robusta_config() -> (Optional[RobustaToken],Optional[str]): decoded = base64.b64decode(token) return RobustaToken(**json.loads(decoded)), config.global_config.get("cluster_name") except binascii.Error: - raise Exception( - "binascii.Error encountered. The robusta token provided is not a valid base64." - ) + raise Exception("binascii.Error encountered. The robusta token provided is not a valid base64.") except json.JSONDecodeError: raise Exception( "json.JSONDecodeError encountered. The Robusta token provided could not be parsed as JSON after being base64 decoded." @@ -167,12 +156,8 @@ def __init_config(self) -> bool: def sign_in(self) -> str: logging.info("Supabase DAL login") - res = self.client.auth.sign_in_with_password( - {"email": self.email, "password": self.password} - ) - self.client.auth.set_session( - res.session.access_token, res.session.refresh_token - ) + res = self.client.auth.sign_in_with_password({"email": self.email, "password": self.password}) + self.client.auth.set_session(res.session.access_token, res.session.refresh_token) self.client.postgrest.auth(res.session.access_token) return res.user.id @@ -200,7 +185,7 @@ def get_latest_krr_scan(self, current_scan_id: Optional[str]) -> (Optional[str], latest_scan_data = sorted_scans[0] else: latest_scan_data = scans_meta_response.data[0] - + latest_scan_id = latest_scan_data["scan_id"] if latest_scan_id == current_scan_id: @@ -211,7 +196,9 @@ def get_latest_krr_scan(self, current_scan_id: Optional[str]) -> (Optional[str], scan_datetime = datetime.fromisoformat(scan_start) max_age = timedelta(hours=SCAN_AGE_HOURS_THRESHOLD) if datetime.now(timezone.utc) - scan_datetime > max_age: - logging.warning(f"Latest scan {latest_scan_id} is too old (started {scan_start}). No fresh KRR scan available.") + logging.warning( + f"Latest scan {latest_scan_id} is too old (started {scan_start}). No fresh KRR scan available." + ) return None, None scans_results_response = ( @@ -229,6 +216,3 @@ def get_latest_krr_scan(self, current_scan_id: Optional[str]) -> (Optional[str], except Exception: logging.exception("Supabase error while retrieving krr scan data") return None, None - - - diff --git a/enforcer/enforcer_main.py b/enforcer/enforcer_main.py index de26e05e..4f9a3d8e 100644 --- a/enforcer/enforcer_main.py +++ b/enforcer/enforcer_main.py @@ -35,44 +35,43 @@ # Configure logging logger = logging.getLogger() logHandler = logging.StreamHandler(sys.stdout) -formatter = logging.Formatter('%(asctime)s - %(levelname)s - %(message)s') +formatter = logging.Formatter("%(asctime)s - %(levelname)s - %(message)s") logHandler.setFormatter(formatter) logger.addHandler(logHandler) logger.setLevel(os.environ.get("LOG_LEVEL", "INFO")) # Define the mention pattern regex -MENTION_PATTERN = re.compile(r'@[\w.-]+') +MENTION_PATTERN = re.compile(r"@[\w.-]+") ENFORCE = "enforce" IGNORE = "ignore" app = FastAPI( title="KRR Enforcer mutation webhook", description="A KRR recommendations mutating webhook server for Kubernetes", - version="1.0.0" + version="1.0.0", ) dal = SupabaseDal() recommendation_store = RecommendationStore(dal) owner_store = OwnerStore() + class AdmissionReview(BaseModel): apiVersion: str kind: str request: Dict[str, Any] + def admission_allowed(request: AdmissionReview) -> Dict[str, Any]: - return \ - { - "apiVersion": "admission.k8s.io/v1", - "kind": "AdmissionReview", - "response": { - "uid": request.request.get('uid'), - "allowed": True - } + return { + "apiVersion": "admission.k8s.io/v1", + "kind": "AdmissionReview", + "response": {"uid": request.request.get("uid"), "allowed": True}, } + def enforce_pod(pod: Dict[str, Any]) -> bool: - mode = pod.get('metadata', {}).get('annotations', {}).get("admission.robusta.dev/krr-mutation-mode", None) + mode = pod.get("metadata", {}).get("annotations", {}).get("admission.robusta.dev/krr-mutation-mode", None) if mode == ENFORCE: return True elif mode == IGNORE: @@ -85,10 +84,10 @@ def enforce_pod(pod: Dict[str, Any]) -> bool: async def mutate(request: AdmissionReview): """ Handle mutating webhook requests from Kubernetes. - + Args: request (AdmissionReview): The admission review request from Kubernetes - + Returns: dict: Admission review response """ @@ -96,19 +95,18 @@ async def mutate(request: AdmissionReview): try: logging.debug("Admission request received %s", request) # Extract the object being reviewed - object_to_review = request.request.get('object', {}) - kind = request.request.get('kind', {}).get('kind') + object_to_review = request.request.get("object", {}) + kind = request.request.get("kind", {}).get("kind") if kind == "ReplicaSet": # use create/delete admission requests, to track new/removed replica sets owners owner_store.handle_rs_admission(request.request) - operation = request.request.get('operation', 'UNKNOWN') + operation = request.request.get("operation", "UNKNOWN") replicaset_admissions.labels(operation=operation).inc() - admission_duration.labels(kind='ReplicaSet').observe(time.time() - start_time) + admission_duration.labels(kind="ReplicaSet").observe(time.time() - start_time) # Update rs_owners size metric rs_owners_size.set(owner_store.get_rs_owners_count()) return admission_allowed(request) - if kind != "Pod": logger.warning(f"Received unexpected resource mutation: {kind}") return admission_allowed(request) @@ -144,12 +142,12 @@ async def mutate(request: AdmissionReview): logger.debug("Pod Recommendations %s", recommendations) patches = [] - + containers = object_to_review.get("spec", {}).get("containers", []) for i, container in enumerate(containers): container_name = container.get("name") patches.extend(patch_container_resources(i, container, recommendations.get(container_name))) - + # Record metrics for Pod mutation was_mutated = len(patches) > 0 reason = "success" if was_mutated else "no_changes_needed" @@ -166,41 +164,39 @@ async def mutate(request: AdmissionReview): response["patchType"] = "JSONPatch" response["patch"] = base64.b64encode(json.dumps(patches).encode()).decode() - return { - "apiVersion": "admission.k8s.io/v1", - "kind": "AdmissionReview", - "response": response - } - + return {"apiVersion": "admission.k8s.io/v1", "kind": "AdmissionReview", "response": response} + except Exception as e: logger.exception("Error processing webhook request") # Record failure metric for Pod requests - if request.request.get('kind', {}).get('kind') == "Pod": + if request.request.get("kind", {}).get("kind") == "Pod": pod_admission_mutations.labels(mutated="false", reason="processing_error").inc() admission_duration.labels(kind="Pod").observe(time.time() - start_time) raise HTTPException(status_code=500, detail=str(e)) + @app.get("/health") async def health_check(): """ Health check endpoint. - + Returns: dict: Health status """ owner_store.finalize_owner_initialization() # Init loading owners from api server, after accepting api requests return {"status": "healthy"} + @app.get("/recommendations/{namespace}/{kind}/{name}") async def get_recommendations(namespace: str, kind: str, name: str): """ Get recommendations for a workload. - + Args: namespace: Kubernetes namespace kind: Workload kind (e.g., Deployment, StatefulSet) name: Workload name - + Returns: dict: Recommendations per container or 404 if not found """ @@ -208,41 +204,39 @@ async def get_recommendations(namespace: str, kind: str, name: str): recommendations: WorkloadRecommendation = recommendation_store.get_recommendations( name=name, namespace=namespace, kind=kind ) - + if not recommendations: raise HTTPException(status_code=404, detail="No recommendations found for this workload") - + result = {} for container_name, container_recommendation in recommendations.container_recommendations.items(): result[container_name] = { - "cpu": { - "request": container_recommendation.cpu.request, - "limit": container_recommendation.cpu.limit - } if container_recommendation.cpu else None, - "memory": { - "request": container_recommendation.memory.request, - "limit": container_recommendation.memory.limit - } if container_recommendation.memory else None + "cpu": ( + {"request": container_recommendation.cpu.request, "limit": container_recommendation.cpu.limit} + if container_recommendation.cpu + else None + ), + "memory": ( + {"request": container_recommendation.memory.request, "limit": container_recommendation.memory.limit} + if container_recommendation.memory + else None + ), } - - return { - "namespace": namespace, - "kind": kind, - "name": name, - "containers": result - } - + + return {"namespace": namespace, "kind": kind, "name": name, "containers": result} + except HTTPException: raise except Exception as e: logger.exception("Error retrieving recommendations") raise HTTPException(status_code=500, detail=str(e)) + @app.get("/metrics") async def metrics(): """ Prometheus metrics endpoint. - + Returns: Response: Prometheus metrics in text format """ @@ -250,7 +244,16 @@ async def metrics(): rs_owners_size.set(owner_store.get_rs_owners_count()) return Response(generate_latest(), media_type=CONTENT_TYPE_LATEST) + if __name__ == "__main__": import uvicorn + logger.info("Starting Kubernetes Webhook server on 8443...") - uvicorn.run(app, host="0.0.0.0", port=8443, ssl_keyfile=ENFORCER_SSL_KEY_FILE, ssl_certfile=ENFORCER_SSL_CERT_FILE, log_level="warning") \ No newline at end of file + uvicorn.run( + app, + host="0.0.0.0", + port=8443, + ssl_keyfile=ENFORCER_SSL_KEY_FILE, + ssl_certfile=ENFORCER_SSL_CERT_FILE, + log_level="warning", + ) diff --git a/enforcer/env_vars.py b/enforcer/env_vars.py index af6b0749..9291e903 100644 --- a/enforcer/env_vars.py +++ b/enforcer/env_vars.py @@ -1,8 +1,6 @@ import os -ROBUSTA_CONFIG_PATH = os.environ.get( - "ROBUSTA_CONFIG_PATH", "/etc/robusta/config/active_playbooks.yaml" -) +ROBUSTA_CONFIG_PATH = os.environ.get("ROBUSTA_CONFIG_PATH", "/etc/robusta/config/active_playbooks.yaml") ROBUSTA_ACCOUNT_ID = os.environ.get("ROBUSTA_ACCOUNT_ID", "") STORE_URL = os.environ.get("STORE_URL", "") STORE_API_KEY = os.environ.get("STORE_API_KEY", "") @@ -18,10 +16,13 @@ KRR_MUTATION_MODE_DEFAULT = os.environ.get("KRR_MUTATION_MODE_DEFAULT", "enforce") REPLICA_SET_CLEANUP_INTERVAL = int(os.environ.get("REPLICA_SET_CLEANUP_INTERVAL", 600)) REPLICA_SET_DELETION_WAIT = int(os.environ.get("REPLICA_SET_DELETION_WAIT", 600)) -SCAN_AGE_HOURS_THRESHOLD = int(os.environ.get("SCAN_AGE_HOURS_THRESHOLD", 360)) # 15 days +SCAN_AGE_HOURS_THRESHOLD = int(os.environ.get("SCAN_AGE_HOURS_THRESHOLD", 360)) # 15 days ENFORCER_SSL_KEY_FILE = os.environ.get("ENFORCER_SSL_KEY_FILE", "") ENFORCER_SSL_CERT_FILE = os.environ.get("ENFORCER_SSL_CERT_FILE", "") -EXCLUDED_CONTAINERS = [container_name.strip() for container_name - in os.environ.get("EXCLUDED_CONTAINERS", "").split(",") if container_name.strip()] +EXCLUDED_CONTAINERS = [ + container_name.strip() + for container_name in os.environ.get("EXCLUDED_CONTAINERS", "").split(",") + if container_name.strip() +] diff --git a/enforcer/metrics.py b/enforcer/metrics.py index 5a756d36..a204a7b1 100644 --- a/enforcer/metrics.py +++ b/enforcer/metrics.py @@ -2,24 +2,17 @@ # Prometheus metrics pod_admission_mutations = Counter( - 'krr_pod_admission_mutations_total', - 'Total pod admission mutations', - ['mutated', 'reason'] # labels: 'true' or 'false', reason for success/failure + "krr_pod_admission_mutations_total", + "Total pod admission mutations", + ["mutated", "reason"], # labels: 'true' or 'false', reason for success/failure ) replicaset_admissions = Counter( - 'krr_replicaset_admissions_total', - 'Total replicaset admissions', - ['operation'] # labels: CREATE, DELETE, etc. + "krr_replicaset_admissions_total", "Total replicaset admissions", ["operation"] # labels: CREATE, DELETE, etc. ) -rs_owners_size = Gauge( - 'krr_rs_owners_map_size', - 'Current size of the rs_owners map' -) +rs_owners_size = Gauge("krr_rs_owners_map_size", "Current size of the rs_owners map") admission_duration = Histogram( - 'krr_admission_duration_seconds', - 'Duration of admission operations', - ['kind'] # labels: Pod, ReplicaSet -) \ No newline at end of file + "krr_admission_duration_seconds", "Duration of admission operations", ["kind"] # labels: Pod, ReplicaSet +) diff --git a/enforcer/model.py b/enforcer/model.py index 51ff2511..7d527297 100644 --- a/enforcer/model.py +++ b/enforcer/model.py @@ -9,6 +9,7 @@ class PodOwner(BaseModel): name: str namespace: str + class RsOwner(BaseModel): rs_name: str namespace: str @@ -16,6 +17,7 @@ class RsOwner(BaseModel): owner_kind: str deletion_ts: Optional[float] = None + class Resources(BaseModel): request: float limit: Optional[float] @@ -62,6 +64,5 @@ class WorkloadRecommendation(BaseModel): def get(self, container: str) -> Optional[ContainerRecommendation]: return self.container_recommendations.get(container, None) - def add(self, container: str, recommendation: ContainerRecommendation): - self.container_recommendations[container] = recommendation \ No newline at end of file + self.container_recommendations[container] = recommendation diff --git a/enforcer/params_utils.py b/enforcer/params_utils.py index c7fdf0c4..d8758760 100644 --- a/enforcer/params_utils.py +++ b/enforcer/params_utils.py @@ -5,6 +5,7 @@ from pydantic.types import SecretStr + def get_env_replacement(value: str) -> Optional[str]: env_values = re.findall(r"{{[ ]*env\.(.*)[ ]*}}", value) if env_values: diff --git a/enforcer/patch_manager.py b/enforcer/patch_manager.py index a9807737..c9ec4909 100644 --- a/enforcer/patch_manager.py +++ b/enforcer/patch_manager.py @@ -38,9 +38,9 @@ def to_cpu_num(cpu_str: Optional[str]) -> Optional[float]: return None # Handle millicpu format (e.g., "100m", "1500m") - if cpu_str.endswith('m'): + if cpu_str.endswith("m"): return float(cpu_str[:-1]) / 1000.0 - if cpu_str.endswith('k'): + if cpu_str.endswith("k"): return float(cpu_str[:-1]) * 1000.0 # Handle regular float/int format (e.g., "0.5", "1", "2.5") @@ -77,29 +77,29 @@ def to_mem_bytes(mem_str: Optional[str]) -> Optional[int]: # Binary (base 1024) suffixes binary_suffixes = { - 'Ki': 1024, - 'Mi': 1024 ** 2, - 'Gi': 1024 ** 3, - 'Ti': 1024 ** 4, - 'Pi': 1024 ** 5, - 'Ei': 1024 ** 6, + "Ki": 1024, + "Mi": 1024**2, + "Gi": 1024**3, + "Ti": 1024**4, + "Pi": 1024**5, + "Ei": 1024**6, } # Decimal (base 1000) suffixes decimal_suffixes = { - 'k': 1000, - 'M': 1000 ** 2, - 'G': 1000 ** 3, - 'T': 1000 ** 4, - 'P': 1000 ** 5, - 'E': 1000 ** 6, + "k": 1000, + "M": 1000**2, + "G": 1000**3, + "T": 1000**4, + "P": 1000**5, + "E": 1000**6, } # Check binary suffixes first (more common in K8s) for suffix, multiplier in binary_suffixes.items(): if mem_str.endswith(suffix): try: - return int(float(mem_str[:-len(suffix)]) * multiplier) + return int(float(mem_str[: -len(suffix)]) * multiplier) except ValueError: logger.warning(f"Invalid memory string format: {mem_str}") return None @@ -108,7 +108,7 @@ def to_mem_bytes(mem_str: Optional[str]) -> Optional[int]: for suffix, multiplier in decimal_suffixes.items(): if mem_str.endswith(suffix): try: - return int(float(mem_str[:-len(suffix)]) * multiplier) + return int(float(mem_str[: -len(suffix)]) * multiplier) except ValueError: logger.warning(f"Invalid memory string format: {mem_str}") return None @@ -174,63 +174,64 @@ def get_updated_resources(resources: Dict[str, Any], recommendation: ContainerRe return resources + def validate_resources(resources: Dict[str, Any]) -> bool: """ Validate that resource requests and limits are valid. - + Rules: 1. If request is defined, it must be > 0 2. If both request and limit are defined, limit >= request - + Args: resources: Resource dict with requests/limits (K8s format with string values) - + Returns: True if valid, False if invalid """ requests = resources.get(REQ, {}) limits = resources.get(LIM, {}) - + # Validate CPU cpu_req_str = requests.get(CPU) cpu_lim_str = limits.get(CPU) - + cpu_req = to_cpu_num(cpu_req_str) if cpu_req_str else None cpu_lim = to_cpu_num(cpu_lim_str) if cpu_lim_str else None - + # Rule 1: CPU request must be > 0 if defined if cpu_req is not None and cpu_req <= 0: logger.warning(f"Invalid CPU request: {cpu_req_str} (must be > 0)") return False - + # Rule 2: CPU limit >= request if both defined if cpu_req is not None and cpu_lim is not None and cpu_lim < cpu_req: logger.warning(f"Invalid CPU: limit {cpu_lim_str} < request {cpu_req_str}") return False - + # Validate Memory mem_req_str = requests.get(MEM) mem_lim_str = limits.get(MEM) - + mem_req = to_mem_bytes(mem_req_str) if mem_req_str else None mem_lim = to_mem_bytes(mem_lim_str) if mem_lim_str else None - + # Rule 1: Memory request must be > 0 if defined if mem_req is not None and mem_req <= 0: logger.warning(f"Invalid memory request: {mem_req_str} (must be > 0)") return False - + # Rule 2: Memory limit >= request if both defined if mem_req is not None and mem_lim is not None and mem_lim < mem_req: logger.warning(f"Invalid memory: limit {mem_lim_str} < request {mem_req_str}") return False - + return True + def patch_container_resources( - container_index: int, - container: Dict[str, Any], - recommendation: Optional[ContainerRecommendation]) -> List[Dict[str, Any]]: + container_index: int, container: Dict[str, Any], recommendation: Optional[ContainerRecommendation] +) -> List[Dict[str, Any]]: """ Validate container resources and return patches if needed. @@ -248,15 +249,17 @@ def patch_container_resources( return patches had_resources = "resources" in container - resources = copy.deepcopy(container.get('resources', {})) - updated_resources = get_updated_resources(container.get('resources', {}), recommendation) + resources = copy.deepcopy(container.get("resources", {})) + updated_resources = get_updated_resources(container.get("resources", {}), recommendation) if resources != updated_resources: if validate_resources(updated_resources): - patches.append({ - "op": "replace" if had_resources else "add", - "path": f"/spec/containers/{container_index}/resources", - "value": updated_resources - }) + patches.append( + { + "op": "replace" if had_resources else "add", + "path": f"/spec/containers/{container_index}/resources", + "value": updated_resources, + } + ) return patches diff --git a/enforcer/resources/kubernetes_resource_loader.py b/enforcer/resources/kubernetes_resource_loader.py index 1d93a8bb..7540b765 100644 --- a/enforcer/resources/kubernetes_resource_loader.py +++ b/enforcer/resources/kubernetes_resource_loader.py @@ -36,19 +36,21 @@ def load_replicasets() -> List[RsOwner]: if controllers: rs_owner = controllers[0] - cluster_rs.append(RsOwner( - rs_name=replicaset.metadata.name, - namespace=replicaset.metadata.namespace, - owner_name=rs_owner.name, - owner_kind=rs_owner.kind, - )) + cluster_rs.append( + RsOwner( + rs_name=replicaset.metadata.name, + namespace=replicaset.metadata.namespace, + owner_name=rs_owner.name, + owner_kind=rs_owner.kind, + ) + ) continue_ref = replicasets.metadata._continue if not continue_ref: break - + if batch_num == DISCOVERY_MAX_BATCHES - 1: replicas_limit = DISCOVERY_MAX_BATCHES * DISCOVERY_BATCH_SIZE logging.warning(f"Reached replicas loading limit: {replicas_limit}.") - + return cluster_rs diff --git a/enforcer/resources/owner_store.py b/enforcer/resources/owner_store.py index ad8dd939..2d7b6547 100644 --- a/enforcer/resources/owner_store.py +++ b/enforcer/resources/owner_store.py @@ -28,28 +28,28 @@ def finalize_owner_initialization(self): """Initialize rs_owners on-demand, thread-safe, only once.""" if self._owners_loaded.is_set(): return # Already loaded - + # Try to acquire the loading lock without blocking if not self._loading_in_progress.acquire(blocking=False): # Another thread is loading, just return return - + try: if self._owners_loaded.is_set(): return - + replica_sets_owners: List[RsOwner] = KubernetesResourceLoader.load_replicasets() loaded_owners: Dict[str, RsOwner] = {} for owner in replica_sets_owners: loaded_owners[self._rs_key(owner.rs_name, owner.namespace)] = owner - + with self._rs_owners_lock: self.rs_owners.update(loaded_owners) rs_owners_size.set(len(self.rs_owners)) - + self._owners_loaded.set() logging.info(f"Loaded {len(loaded_owners)} ReplicaSet owners") - + except Exception: logging.exception(f"Failed to load ReplicaSet owners") finally: @@ -67,9 +67,7 @@ def get_pod_owner(self, pod: Dict[str, Any]) -> Optional[PodOwner]: try: if not owner_references: # pod has no owner, standalone pod. Return the pod - return PodOwner( - kind="Pod", namespace=namespace, name=self.get_pod_name(pod) - ) + return PodOwner(kind="Pod", namespace=namespace, name=self.get_pod_name(pod)) # get only owners with controller == true controllers = [owner for owner in owner_references if owner.get("controller", False)] @@ -82,11 +80,15 @@ def get_pod_owner(self, pod: Dict[str, Any]) -> Optional[PodOwner]: if controller_kind == "ReplicaSet": with self._rs_owners_lock: rs_owner = self.rs_owners.get(self._rs_key(controller.get("name"), namespace), None) - return PodOwner( - name=rs_owner.owner_name, - namespace=rs_owner.namespace, - kind=rs_owner.owner_kind, - ) if rs_owner else None + return ( + PodOwner( + name=rs_owner.owner_name, + namespace=rs_owner.namespace, + kind=rs_owner.owner_kind, + ) + if rs_owner + else None + ) else: # Pod owner is a k8s workload: Job, StatefulSet, DaemonSet return PodOwner(kind=controller_kind, name=controller.get("name"), namespace=namespace) except Exception: @@ -125,17 +127,18 @@ def _add_rs_owner(self, rs_create_request: Dict[str, Any]): else: logging.warning(f"No owner references for {rs_create_request}") - def _cleanup_deleted_replica_sets(self): current_time = time.time() with self._rs_owners_lock: # Delete rs owners that were deleted more than REPLICA_SET_DELETION_WAIT seconds ago keys_to_delete = [ - key for key, rs_owner in self.rs_owners.items() - if rs_owner.deletion_ts is not None and (current_time - rs_owner.deletion_ts) >= REPLICA_SET_DELETION_WAIT + key + for key, rs_owner in self.rs_owners.items() + if rs_owner.deletion_ts is not None + and (current_time - rs_owner.deletion_ts) >= REPLICA_SET_DELETION_WAIT ] - + for key in keys_to_delete: del self.rs_owners[key] @@ -153,4 +156,4 @@ def get_rs_owners_count(self) -> int: def stop(self): self._stop_event.set() - self._cleanup_thread.join() \ No newline at end of file + self._cleanup_thread.join() diff --git a/enforcer/resources/recommendation_store.py b/enforcer/resources/recommendation_store.py index 994780ff..0a6253b5 100644 --- a/enforcer/resources/recommendation_store.py +++ b/enforcer/resources/recommendation_store.py @@ -21,8 +21,9 @@ def __init__(self, dal: SupabaseDal): self._reload_thread = threading.Thread(target=self._periodic_reload, daemon=True) self._reload_thread.start() - - def _load_recommendations(self, current_stored_scan: Optional[str]) -> Tuple[Optional[str], Optional[Dict[str, WorkloadRecommendation]]]: + def _load_recommendations( + self, current_stored_scan: Optional[str] + ) -> Tuple[Optional[str], Optional[Dict[str, WorkloadRecommendation]]]: latest_scan_id, latest_scan = self.dal.get_latest_krr_scan(current_stored_scan) if not latest_scan: @@ -33,10 +34,10 @@ def _load_recommendations(self, current_stored_scan: Optional[str]) -> Tuple[Opt for container_recommendation in latest_scan: try: store_key = self._store_key( - name=container_recommendation["name"], - namespace=container_recommendation["namespace"], - kind=container_recommendation["kind"], - ) + name=container_recommendation["name"], + namespace=container_recommendation["namespace"], + kind=container_recommendation["kind"], + ) recommendation = ContainerRecommendation.build(container_recommendation) if recommendation: # if a valid recommendation was created, connect it to the workload @@ -77,4 +78,3 @@ def stop(self): def get_recommendations(self, name: str, namespace: str, kind: str) -> Optional[WorkloadRecommendation]: with self._recommendations_lock: return self.recommendations.get(self._store_key(name, namespace, kind)) - diff --git a/robusta_krr/core/abstract/metrics.py b/robusta_krr/core/abstract/metrics.py index 3b6f19c5..41881719 100644 --- a/robusta_krr/core/abstract/metrics.py +++ b/robusta_krr/core/abstract/metrics.py @@ -17,5 +17,4 @@ class BaseMetric(ABC): @abstractmethod async def load_data( self, object: K8sObjectData, period: datetime.timedelta, step: datetime.timedelta - ) -> PodsTimeData: - ... + ) -> PodsTimeData: ... diff --git a/robusta_krr/core/abstract/strategies.py b/robusta_krr/core/abstract/strategies.py index b63b2cc7..2ff675b1 100644 --- a/robusta_krr/core/abstract/strategies.py +++ b/robusta_krr/core/abstract/strategies.py @@ -27,9 +27,7 @@ class ResourceRecommendation(pd.BaseModel): request: Optional[float] limit: Optional[float] - info: Optional[str] = pd.Field( - None, description="Additional information about the recommendation." - ) + info: Optional[str] = pd.Field(None, description="Additional information about the recommendation.") @classmethod def undefined(cls: type[SelfRR], info: Optional[str] = None) -> SelfRR: diff --git a/robusta_krr/core/integrations/kubernetes/__init__.py b/robusta_krr/core/integrations/kubernetes/__init__.py index 1398c62b..0ede135c 100644 --- a/robusta_krr/core/integrations/kubernetes/__init__.py +++ b/robusta_krr/core/integrations/kubernetes/__init__.py @@ -26,6 +26,7 @@ class LightweightJobInfo: """Lightweight job object containing only the fields needed for GroupedJob processing.""" + def __init__(self, name: str, namespace: str): self.name = name self.namespace = namespace @@ -40,7 +41,7 @@ def __init__(self, name: str, namespace: str): class ClusterLoader: - def __init__(self, cluster: Optional[str]=None): + def __init__(self, cluster: Optional[str] = None): self.cluster = cluster # This executor will be running requests to Kubernetes API self.executor = ThreadPoolExecutor(settings.max_workers) @@ -86,7 +87,7 @@ def namespaces(self) -> Union[list[str], Literal["*"]]: if expand_list: logger.info("found regex pattern in provided namespace argument, expanding namespace list") - all_ns = [ ns.metadata.name for ns in self.core.list_namespace().items ] + all_ns = [ns.metadata.name for ns in self.core.list_namespace().items] for expand_ns in expand_list: for ns in all_ns: if expand_ns.fullmatch(ns) and ns not in self.__namespaces: @@ -156,9 +157,9 @@ async def list_pods(self, object: K8sObjectData) -> list[PodData]: selector = f"batch.kubernetes.io/controller-uid in ({','.join(ownered_jobs_uids)})" elif object.kind == "GroupedJob": - if not hasattr(object._api_resource, '_label_filter') or not object._api_resource._label_filter: + if not hasattr(object._api_resource, "_label_filter") or not object._api_resource._label_filter: return [] - + # Use the label+value filter to get pods ret: V1PodList = await loop.run_in_executor( self.executor, @@ -166,9 +167,9 @@ async def list_pods(self, object: K8sObjectData) -> list[PodData]: namespace=object.namespace, label_selector=object._api_resource._label_filter ), ) - + # Apply the job grouping limit to pod results - limited_pods = ret.items[:settings.job_grouping_limit] + limited_pods = ret.items[: settings.job_grouping_limit] return [PodData(name=pod.metadata.name, deleted=False) for pod in limited_pods] else: @@ -209,14 +210,15 @@ def _build_selector_query(selector: Any) -> Union[str, None]: label_filters += [ ClusterLoader._get_match_expression_filter(expression) for expression in selector.match_expressions ] - + # normally the kubernetes API client renames matchLabels to match_labels in python # but for CRDs like ArgoRollouts that renaming doesn't happen and we have selector={'matchLabels': {'app': 'test-app'}} if getattr(selector, "matchLabels", None): label_filters += [f"{label[0]}={label[1]}" for label in getattr(selector, "matchLabels").items()] if getattr(selector, "matchExpressions", None): label_filters += [ - ClusterLoader._get_match_expression_filter(expression) for expression in getattr(selector, "matchExpressions").items() + ClusterLoader._get_match_expression_filter(expression) + for expression in getattr(selector, "matchExpressions").items() ] if label_filters == []: @@ -241,7 +243,7 @@ def __build_scannable_object( if item.metadata.labels: if type(item.metadata.labels) is ObjectLikeDict: labels = item.metadata.labels.__dict__ - else: + else: labels = item.metadata.labels if item.metadata.annotations: @@ -259,7 +261,7 @@ def __build_scannable_object( allocations=ResourceAllocations.from_container(container), hpa=self.__hpa_list.get((namespace, kind, name)), labels=labels, - annotations= annotations + annotations=annotations, ) obj._api_resource = item return obj @@ -315,19 +317,16 @@ async def _list_namespaced_or_global_objects_batched( limit=limit, _continue=continue_ref, ), - ) ] + ) + ] gathered_results = await asyncio.gather(*requests) - - result = [ - item - for request_result in gathered_results - for item in request_result.items - ] + + result = [item for request_result in gathered_results for item in request_result.items] next_continue_ref = None if gathered_results: - next_continue_ref = getattr(gathered_results[0].metadata, '_continue', None) + next_continue_ref = getattr(gathered_results[0].metadata, "_continue", None) return result, next_continue_ref @@ -335,6 +334,7 @@ async def _list_namespaced_or_global_objects_batched( if e.status == 410 and e.body: # Continue token expired import json + try: error_body = json.loads(e.body) new_continue_token = error_body.get("metadata", {}).get("continue") @@ -346,10 +346,7 @@ async def _list_namespaced_or_global_objects_batched( raise async def _list_namespaced_or_global_objects( - self, - kind: KindLiteral, - all_namespaces_request: Callable, - namespaced_request: Callable + self, kind: KindLiteral, all_namespaces_request: Callable, namespaced_request: Callable ) -> list[Any]: logger.debug(f"Listing {kind}s in {self.cluster}") loop = asyncio.get_running_loop() @@ -377,11 +374,7 @@ async def _list_namespaced_or_global_objects( for namespace in self.namespaces ] - result = [ - item - for request_result in await asyncio.gather(*requests) - for item in request_result.items - ] + result = [item for request_result in await asyncio.gather(*requests) for item in request_result.items] logger.debug(f"Found {len(result)} {kind} in {self.cluster}") return result @@ -400,7 +393,7 @@ async def _list_scannable_objects( if not self.__kind_available[kind]: return [] - + result = [] try: for item in await self._list_namespaced_or_global_objects(kind, all_namespaces_request, namespaced_request): @@ -542,12 +535,11 @@ def _list_all_daemon_set(self) -> list[K8sObjectData]: extract_containers=lambda item: item.spec.template.spec.containers, ) - async def _list_all_jobs(self) -> list[K8sObjectData]: """List all jobs using batched loading with 500 batch size.""" if not self._should_list_resource("Job"): return [] - + namespaces = self.namespaces if self.namespaces != "*" else ["*"] all_jobs = [] try: @@ -572,7 +564,7 @@ async def _list_all_jobs(self) -> list[K8sObjectData]: if not jobs_batch: # no more jobs to batch do not count empty batches break - + batch_count += 1 for job in jobs_batch: if self._is_job_owned_by_cronjob(job): @@ -583,10 +575,10 @@ async def _list_all_jobs(self) -> list[K8sObjectData]: all_jobs.append(self.__build_scannable_object(job, container, "Job")) if not continue_ref: break - + logger.debug("Found %d regular jobs", len(all_jobs)) return all_jobs - + except Exception as e: logger.error( "Failed to run jobs discovery", @@ -607,13 +599,13 @@ async def _list_all_groupedjobs(self) -> list[K8sObjectData]: if not settings.job_grouping_labels: logger.debug("No job grouping labels configured, skipping GroupedJob listing") return [] - + if not self._should_list_resource("GroupedJob"): logger.debug("Skipping GroupedJob in cluster") return [] - + logger.debug("Listing GroupedJobs with grouping labels: %s", settings.job_grouping_labels) - + grouped_jobs = defaultdict(list) grouped_jobs_template = {} # Store only ONE full job as template per group - needed for class K8sObjectData continue_ref: Optional[str] = None @@ -632,7 +624,7 @@ async def _list_all_groupedjobs(self) -> list[K8sObjectData]: limit=settings.discovery_job_batch_size, continue_ref=continue_ref, ) - + continue_ref = next_continue_ref if not jobs_batch and continue_ref: @@ -645,7 +637,11 @@ async def _list_all_groupedjobs(self) -> list[K8sObjectData]: batch_count += 1 for job in jobs_batch: - if not job.metadata.labels or self._is_job_owned_by_cronjob(job) or not self._is_job_grouped(job): + if ( + not job.metadata.labels + or self._is_job_owned_by_cronjob(job) + or not self._is_job_grouped(job) + ): continue for label_name in settings.job_grouping_labels: if label_name not in job.metadata.labels: @@ -654,8 +650,7 @@ async def _list_all_groupedjobs(self) -> list[K8sObjectData]: label_value = job.metadata.labels[label_name] group_key = f"{label_name}={label_value}" lightweight_job = LightweightJobInfo( - name=job.metadata.name, - namespace=job.metadata.namespace + name=job.metadata.name, namespace=job.metadata.namespace ) # Store lightweight job info only for grouped jobs grouped_jobs[group_key].append(lightweight_job) @@ -664,50 +659,48 @@ async def _list_all_groupedjobs(self) -> list[K8sObjectData]: grouped_jobs_template[group_key] = job if not continue_ref: break - + except Exception as e: logger.error( "Failed to run grouped jobs discovery", exc_info=True, ) raise - + result = [] for group_name, jobs in grouped_jobs.items(): template_job = grouped_jobs_template[group_name] - + jobs_by_namespace = defaultdict(list) for job in jobs: jobs_by_namespace[job.namespace].append(job) - + for namespace, namespace_jobs in jobs_by_namespace.items(): - limited_jobs = namespace_jobs[:settings.job_grouping_limit] - + limited_jobs = namespace_jobs[: settings.job_grouping_limit] + container_names = set() for container in template_job.spec.template.spec.containers: container_names.add(container.name) - + for container_name in container_names: template_container = None for container in template_job.spec.template.spec.containers: if container.name == container_name: template_container = container break - + if template_container: grouped_job = self.__build_scannable_object( - item=template_job, - container=template_container, - kind="GroupedJob" + item=template_job, container=template_container, kind="GroupedJob" ) - + grouped_job.name = group_name grouped_job.namespace = namespace grouped_job._api_resource._grouped_jobs = limited_jobs grouped_job._api_resource._label_filter = group_name - + result.append(grouped_job) - + logger.debug("Found %d GroupedJob groups", len(result)) return result @@ -743,6 +736,7 @@ async def __list_hpa_v2(self) -> dict[HPAKey, HPAData]: all_namespaces_request=self.autoscaling_v2.list_horizontal_pod_autoscaler_for_all_namespaces, namespaced_request=self.autoscaling_v2.list_namespaced_horizontal_pod_autoscaler, ) + def __get_metric(hpa: V2HorizontalPodAutoscaler, metric_name: str) -> Optional[float]: return next( ( @@ -752,6 +746,7 @@ def __get_metric(hpa: V2HorizontalPodAutoscaler, metric_name: str) -> Optional[f ), None, ) + return { ( hpa.metadata.namespace, @@ -865,7 +860,7 @@ async def list_scannable_objects(self, clusters: Optional[list[str]]) -> list[K8 if self.cluster_loaders == {}: logger.error("Could not load any cluster.") return - + return [ object for cluster_loader in self.cluster_loaders.values() diff --git a/robusta_krr/core/integrations/openshift/token.py b/robusta_krr/core/integrations/openshift/token.py index 54a599f2..1cca328a 100644 --- a/robusta_krr/core/integrations/openshift/token.py +++ b/robusta_krr/core/integrations/openshift/token.py @@ -3,7 +3,7 @@ from robusta_krr.core.models.config import settings # NOTE: This one should be mounted if openshift is enabled (done by Robusta Runner) -TOKEN_LOCATION = '/var/run/secrets/kubernetes.io/serviceaccount/token' +TOKEN_LOCATION = "/var/run/secrets/kubernetes.io/serviceaccount/token" def load_token() -> Optional[str]: @@ -11,7 +11,7 @@ def load_token() -> Optional[str]: return None try: - with open(TOKEN_LOCATION, 'r') as file: + with open(TOKEN_LOCATION, "r") as file: return file.read() except FileNotFoundError: return None diff --git a/robusta_krr/core/integrations/prometheus/loader.py b/robusta_krr/core/integrations/prometheus/loader.py index cf0c1554..87c39829 100644 --- a/robusta_krr/core/integrations/prometheus/loader.py +++ b/robusta_krr/core/integrations/prometheus/loader.py @@ -23,6 +23,7 @@ logger = logging.getLogger("krr") + class PrometheusMetricsLoader: def __init__(self, *, cluster: Optional[str] = None) -> None: """ @@ -56,7 +57,12 @@ def get_metrics_service( metrics_to_check = [PrometheusMetricsService] else: logger.info("No Prometheus URL is specified, trying to auto-detect a metrics service") - metrics_to_check = [VictoriaMetricsService, ThanosMetricsService, MimirMetricsService, PrometheusMetricsService] + metrics_to_check = [ + VictoriaMetricsService, + ThanosMetricsService, + MimirMetricsService, + PrometheusMetricsService, + ] for metric_service_class in metrics_to_check: service_name = metric_service_class.name() diff --git a/robusta_krr/core/integrations/prometheus/metrics/memory.py b/robusta_krr/core/integrations/prometheus/metrics/memory.py index 85dfba6b..0cbd7e40 100644 --- a/robusta_krr/core/integrations/prometheus/metrics/memory.py +++ b/robusta_krr/core/integrations/prometheus/metrics/memory.py @@ -70,6 +70,7 @@ def get_query(self, object: K8sObjectData, duration: str, step: str) -> str: ) """ + # TODO: Need to battle test if this one is correct. class MaxOOMKilledMemoryLoader(PrometheusMetric): """ diff --git a/robusta_krr/core/integrations/prometheus/metrics_service/base_metric_service.py b/robusta_krr/core/integrations/prometheus/metrics_service/base_metric_service.py index 714a9f5c..6e352388 100644 --- a/robusta_krr/core/integrations/prometheus/metrics_service/base_metric_service.py +++ b/robusta_krr/core/integrations/prometheus/metrics_service/base_metric_service.py @@ -24,8 +24,7 @@ def __init__( self.executor = executor @abc.abstractmethod - def check_connection(self): - ... + def check_connection(self): ... @classmethod def name(cls) -> str: @@ -33,12 +32,10 @@ def name(cls) -> str: return classname.replace("MetricsService", "") if classname != MetricsService.__name__ else classname @abc.abstractmethod - def get_cluster_names(self) -> Optional[List[str]]: - ... + def get_cluster_names(self) -> Optional[List[str]]: ... @abc.abstractmethod - async def get_cluster_summary(self) -> Dict[str, Any]: - ... + async def get_cluster_summary(self) -> Dict[str, Any]: ... @abc.abstractmethod async def gather_data( @@ -47,8 +44,7 @@ async def gather_data( LoaderClass: type[PrometheusMetric], period: datetime.timedelta, step: datetime.timedelta = datetime.timedelta(minutes=30), - ) -> PodsTimeData: - ... + ) -> PodsTimeData: ... def get_prometheus_cluster_label(self) -> str: """ diff --git a/robusta_krr/core/integrations/prometheus/metrics_service/mimir_metrics_service.py b/robusta_krr/core/integrations/prometheus/metrics_service/mimir_metrics_service.py index ea3af57c..e11d705c 100644 --- a/robusta_krr/core/integrations/prometheus/metrics_service/mimir_metrics_service.py +++ b/robusta_krr/core/integrations/prometheus/metrics_service/mimir_metrics_service.py @@ -7,6 +7,7 @@ from .prometheus_metrics_service import PrometheusMetricsService + class MimirMetricsDiscovery(MetricsServiceDiscovery): def find_metrics_url(self, *, api_client: Optional[ApiClient] = None) -> Optional[str]: """ diff --git a/robusta_krr/core/integrations/prometheus/metrics_service/prometheus_metrics_service.py b/robusta_krr/core/integrations/prometheus/metrics_service/prometheus_metrics_service.py index 8f4561ff..bc19dcab 100644 --- a/robusta_krr/core/integrations/prometheus/metrics_service/prometheus_metrics_service.py +++ b/robusta_krr/core/integrations/prometheus/metrics_service/prometheus_metrics_service.py @@ -21,7 +21,7 @@ from ..prometheus_utils import ClusterNotSpecifiedException, generate_prometheus_config from .base_metric_service import MetricsService -PROM_REFRESH_CREDS_SEC = int(os.environ.get("PROM_REFRESH_CREDS_SEC", "600")) # 10 minutes +PROM_REFRESH_CREDS_SEC = int(os.environ.get("PROM_REFRESH_CREDS_SEC", "600")) # 10 minutes logger = logging.getLogger("krr") @@ -114,10 +114,12 @@ def __init__( def get_prometheus(self): now = datetime.utcnow() - if (not self.prometheus + if ( + not self.prometheus or not self._last_init_at - or now - self._last_init_at >= timedelta(seconds=PROM_REFRESH_CREDS_SEC)): - self.prom_config = generate_prometheus_config(url=self.url, headers=self.headers, metrics_service=self) # type: ignore + or now - self._last_init_at >= timedelta(seconds=PROM_REFRESH_CREDS_SEC) + ): + self.prom_config = generate_prometheus_config(url=self.url, headers=self.headers, metrics_service=self) # type: ignore self.prometheus = get_custom_prometheus_connect(self.prom_config) self._last_init_at = now return self.prometheus @@ -229,23 +231,25 @@ async def gather_data( return data async def query_and_validate(self, prom_query) -> Any: - result = await self.query(prom_query) - if len(result) != 1: - logger.warning(f"Error: Expected exactly one result from Prometheus query but instead got {len(result)}. {prom_query}") - return None + result = await self.query(prom_query) + if len(result) != 1: + logger.warning( + f"Error: Expected exactly one result from Prometheus query but instead got {len(result)}. {prom_query}" + ) + return None - result_value = result[0].get("value") + result_value = result[0].get("value") - # Verify that the "value" list has exactly two elements (timestamp and value) - if not result_value: - logger.warning(f"Error: Missing value in Prometheus result. {prom_query}") - return None + # Verify that the "value" list has exactly two elements (timestamp and value) + if not result_value: + logger.warning(f"Error: Missing value in Prometheus result. {prom_query}") + return None - if len(result_value) != 2: - logger.warning(f"Error: Prometheus result values are not of expected size. {prom_query}") - return None + if len(result_value) != 2: + logger.warning(f"Error: Prometheus result values are not of expected size. {prom_query}") + return None - return result_value[1] + return result_value[1] async def get_cluster_summary(self) -> Dict[str, Any]: cluster_label = self.get_prometheus_cluster_label() @@ -273,7 +277,7 @@ async def get_cluster_summary(self) -> Dict[str, Any]: "cluster_memory": float(cluster_memory_result), "cluster_cpu": float(cluster_cpu_result), "kube_system_mem_req": float(kube_system_mem_result), - "kube_system_cpu_req": float(kube_system_cpu_result) + "kube_system_cpu_req": float(kube_system_cpu_result), } except Exception as e: @@ -301,32 +305,28 @@ async def load_pods(self, object: K8sObjectData, period: timedelta) -> list[PodD pod_owner_kind: str cluster_label = self.get_prometheus_cluster_label() if object.kind in ["Deployment", "Rollout"]: - replicasets = await self.query( - f""" + replicasets = await self.query(f""" kube_replicaset_owner{{ owner_name="{object.name}", owner_kind="{object.kind}", namespace="{object.namespace}" {cluster_label} }}[{period_literal}] - """ - ) + """) pod_owners = {replicaset["metric"]["replicaset"] for replicaset in replicasets} pod_owner_kind = "ReplicaSet" del replicasets elif object.kind == "DeploymentConfig": - replication_controllers = await self.query( - f""" + replication_controllers = await self.query(f""" kube_replicationcontroller_owner{{ owner_name="{object.name}", owner_kind="{object.kind}", namespace="{object.namespace}" {cluster_label} }}[{period_literal}] - """ - ) + """) pod_owners = { repl_controller["metric"]["replicationcontroller"] for repl_controller in replication_controllers } @@ -335,22 +335,20 @@ async def load_pods(self, object: K8sObjectData, period: timedelta) -> list[PodD del replication_controllers elif object.kind == "CronJob": - jobs = await self.query( - f""" + jobs = await self.query(f""" kube_job_owner{{ owner_name="{object.name}", owner_kind="{object.kind}", namespace="{object.namespace}" {cluster_label} }}[{period_literal}] - """ - ) + """) pod_owners = {job["metric"]["job_name"] for job in jobs} pod_owner_kind = "Job" del jobs elif object.kind == "GroupedJob": - if hasattr(object._api_resource, '_grouped_jobs'): + if hasattr(object._api_resource, "_grouped_jobs"): pod_owners = [job.name for job in object._api_resource._grouped_jobs] pod_owner_kind = "Job" else: @@ -364,8 +362,7 @@ async def load_pods(self, object: K8sObjectData, period: timedelta) -> list[PodD batch_size = int(os.environ.get("KRR_OWNER_BATCH_SIZE", 100)) for owner_group in batched(pod_owners, batch_size): owners_regex = "|".join(owner_group) - related_pods_result_item = await self.query( - f""" + related_pods_result_item = await self.query(f""" last_over_time( kube_pod_owner{{ owner_name=~"{owners_regex}", @@ -374,8 +371,7 @@ async def load_pods(self, object: K8sObjectData, period: timedelta) -> list[PodD {cluster_label} }}[{period_literal}] ) - """ - ) + """) related_pods_result.extend(related_pods_result_item) if related_pods_result == []: return [] @@ -388,16 +384,14 @@ async def load_pods(self, object: K8sObjectData, period: timedelta) -> list[PodD for pod_group in batched(related_pods, 100): group_regex = "|".join(pod_group) - pods_status_result = await self.query( - f""" + pods_status_result = await self.query(f""" kube_pod_status_phase{{ phase="Running", {related_pod_label}=~"{group_regex}", namespace="{object.namespace}" {cluster_label} }} == 1 - """ - ) + """) current_pods_set |= {pod["metric"][related_pod_label] for pod in pods_status_result} del pods_status_result diff --git a/robusta_krr/core/integrations/prometheus/prometheus_utils.py b/robusta_krr/core/integrations/prometheus/prometheus_utils.py index 0a44f67b..d4e3f1e9 100644 --- a/robusta_krr/core/integrations/prometheus/prometheus_utils.py +++ b/robusta_krr/core/integrations/prometheus/prometheus_utils.py @@ -47,7 +47,9 @@ def generate_prometheus_config( # we need at least one parameter from credentials, but we should use whatever we can from settings (this has higher precedence) credentials = credentials.get_frozen_credentials() access_key = settings.eks_access_key if settings.eks_access_key else credentials.access_key - secret_key = settings.eks_secret_key.get_secret_value() if settings.eks_secret_key else credentials.secret_key + secret_key = ( + settings.eks_secret_key.get_secret_value() if settings.eks_secret_key else credentials.secret_key + ) token = credentials.token service_name = settings.eks_service_name if settings.eks_secret_key else "aps" diff --git a/robusta_krr/core/models/allocations.py b/robusta_krr/core/models/allocations.py index 0a8d0c5c..b118ab77 100644 --- a/robusta_krr/core/models/allocations.py +++ b/robusta_krr/core/models/allocations.py @@ -28,6 +28,7 @@ class ResourceType(str, enum.Enum): NONE_LITERAL = "unset" NAN_LITERAL = "?" + def format_recommendation_value(value: RecommendationValue) -> str: if value is None: return NONE_LITERAL @@ -36,6 +37,7 @@ def format_recommendation_value(value: RecommendationValue) -> str: else: return resource_units.format(value) + def format_diff(allocated, recommended, selector, multiplier=1, colored=False) -> str: if recommended is None or isinstance(recommended.value, str) or selector != "requests": return "" @@ -46,9 +48,10 @@ def format_diff(allocated, recommended, selector, multiplier=1, colored=False) - if colored: diff_sign = "[green]+[/green]" if diff_val >= 0 else "[red]-[/red]" else: - diff_sign = "+" if diff_val >= 0 else "-" + diff_sign = "+" if diff_val >= 0 else "-" return f"{diff_sign}{format_recommendation_value(abs(diff_val) * multiplier)}" - + + class ResourceAllocations(pd.BaseModel): requests: dict[ResourceType, RecommendationValue] limits: dict[ResourceType, RecommendationValue] @@ -88,19 +91,27 @@ def from_container(cls: type[Self], container: V1Container) -> Self: return cls( requests={ - ResourceType.CPU: container.resources.requests.get("cpu") - if container.resources and container.resources.requests - else None, - ResourceType.Memory: container.resources.requests.get("memory") - if container.resources and container.resources.requests - else None, + ResourceType.CPU: ( + container.resources.requests.get("cpu") + if container.resources and container.resources.requests + else None + ), + ResourceType.Memory: ( + container.resources.requests.get("memory") + if container.resources and container.resources.requests + else None + ), }, limits={ - ResourceType.CPU: container.resources.limits.get("cpu") - if container.resources and container.resources.limits - else None, - ResourceType.Memory: container.resources.limits.get("memory") - if container.resources and container.resources.limits - else None, + ResourceType.CPU: ( + container.resources.limits.get("cpu") + if container.resources and container.resources.limits + else None + ), + ResourceType.Memory: ( + container.resources.limits.get("memory") + if container.resources and container.resources.limits + else None + ), }, ) diff --git a/robusta_krr/core/models/config.py b/robusta_krr/core/models/config.py index 3e762597..a771c3c9 100644 --- a/robusta_krr/core/models/config.py +++ b/robusta_krr/core/models/config.py @@ -52,14 +52,20 @@ class Config(pd.BaseSettings): # Threading settings max_workers: int = pd.Field(6, ge=1) - + # Discovery settings discovery_job_batch_size: int = pd.Field(5000, ge=1, description="Batch size for Kubernetes job API calls") - discovery_job_max_batches: int = pd.Field(100, ge=1, description="Maximum number of job batches to process to prevent infinite loops") - + discovery_job_max_batches: int = pd.Field( + 100, ge=1, description="Maximum number of job batches to process to prevent infinite loops" + ) + # Job grouping settings - job_grouping_labels: Union[list[str], str, None] = pd.Field(None, description="Label name(s) to use for grouping jobs into GroupedJob workload type") - job_grouping_limit: int = pd.Field(500, ge=1, description="Maximum number of jobs/pods to query per GroupedJob group") + job_grouping_labels: Union[list[str], str, None] = pd.Field( + None, description="Label name(s) to use for grouping jobs into GroupedJob workload type" + ) + job_grouping_limit: int = pd.Field( + 500, ge=1, description="Maximum number of jobs/pods to query per GroupedJob group" + ) # Logging Settings format: str @@ -73,7 +79,7 @@ class Config(pd.BaseSettings): publish_scan_url: Optional[str] = pd.Field(None) start_time: Optional[str] = pd.Field(None) scan_id: Optional[str] = pd.Field(None) - named_sinks: Optional[list[str]] = pd.Field(None) + named_sinks: Optional[list[str]] = pd.Field(None) # Output Settings file_output: Optional[str] = pd.Field(None) @@ -144,7 +150,7 @@ def validate_job_grouping_labels(cls, v: Union[list[str], str, None]) -> Union[l return None if isinstance(v, str): # Split comma-separated string into list - return [label.strip() for label in v.split(',')] + return [label.strip() for label in v.split(",")] return v def create_strategy(self) -> AnyStrategy: diff --git a/robusta_krr/core/models/objects.py b/robusta_krr/core/models/objects.py index 286e98b7..c24375bb 100644 --- a/robusta_krr/core/models/objects.py +++ b/robusta_krr/core/models/objects.py @@ -8,7 +8,17 @@ from robusta_krr.utils.batched import batched from kubernetes.client.models import V1LabelSelector -KindLiteral = Literal["Deployment", "DaemonSet", "StatefulSet", "Job", "CronJob", "Rollout", "DeploymentConfig", "StrimziPodSet", "GroupedJob"] +KindLiteral = Literal[ + "Deployment", + "DaemonSet", + "StatefulSet", + "Job", + "CronJob", + "Rollout", + "DeploymentConfig", + "StrimziPodSet", + "GroupedJob", +] class PodData(pd.BaseModel): @@ -77,7 +87,7 @@ def selector(self) -> V1LabelSelector: if self._api_resource is None: raise ValueError("api_resource is not set") - if self.kind == 'CronJob': + if self.kind == "CronJob": return self._api_resource.spec.job_template.spec.selector else: return self._api_resource.spec.selector diff --git a/robusta_krr/core/models/result.py b/robusta_krr/core/models/result.py index 827f8690..b451fc3d 100644 --- a/robusta_krr/core/models/result.py +++ b/robusta_krr/core/models/result.py @@ -40,7 +40,7 @@ def calculate(cls, object: K8sObjectData, recommendation: ResourceAllocations) - current_severity = Severity.calculate(current, recommended, resource_type) - #TODO: consider... changing field after model created doesn't validate it. + # TODO: consider... changing field after model created doesn't validate it. getattr(recommendation_processed, selector)[resource_type] = Recommendation( value=recommended, severity=current_severity ) @@ -105,11 +105,5 @@ def score_letter(self) -> str: return ( "F" if self.score < 30 - else "D" - if self.score < 55 - else "C" - if self.score < 70 - else "B" - if self.score < 90 - else "A" + else "D" if self.score < 55 else "C" if self.score < 70 else "B" if self.score < 90 else "A" ) diff --git a/robusta_krr/core/runner.py b/robusta_krr/core/runner.py index 024ab70a..23a63fd4 100644 --- a/robusta_krr/core/runner.py +++ b/robusta_krr/core/runner.py @@ -110,7 +110,9 @@ def _process_result(self, result: Result) -> None: Formatter = settings.Formatter - self._send_result(settings.publish_scan_url, settings.start_time, settings.scan_id, settings.named_sinks, result) + self._send_result( + settings.publish_scan_url, settings.start_time, settings.scan_id, settings.named_sinks, result + ) formatted = result.format(Formatter) rich = getattr(Formatter, "__rich_console__", False) @@ -139,30 +141,31 @@ def _process_result(self, result: Result) -> None: console.print(formatted) if settings.azureblob_output: - self._upload_to_azure_blob(file_name, settings.azureblob_output) + self._upload_to_azure_blob(file_name, settings.azureblob_output) if settings.teams_webhook: storage_account, container = self._extract_storage_info_from_sas(settings.azureblob_output) - self._notify_teams(settings.teams_webhook, storage_account, container) + self._notify_teams(settings.teams_webhook, storage_account, container) os.remove(file_name) if settings.slack_output: client = WebClient(os.environ["SLACK_BOT_TOKEN"]) warnings.filterwarnings("ignore", category=UserWarning) - + # Upload file without specifying channel result = client.files_upload_v2( title="KRR Report", file_uploads=[{"file": f"./{file_name}", "filename": file_name, "title": "KRR Report"}], ) file_permalink = result["file"]["permalink"] - + # Post message with file link to channel - slack_title = settings.slack_title if settings.slack_title else f'Kubernetes Resource Report for {(" ".join(settings.namespaces))}' - client.chat_postMessage( - channel=settings.slack_output, - text=f'{slack_title}\n{file_permalink}' + slack_title = ( + settings.slack_title + if settings.slack_title + else f'Kubernetes Resource Report for {(" ".join(settings.namespaces))}' ) - + client.chat_postMessage(channel=settings.slack_output, text=f"{slack_title}\n{file_permalink}") + os.remove(file_name) def _upload_to_azure_blob(self, file_name: str, base_sas_url: str): @@ -186,8 +189,8 @@ def _upload_to_azure_blob(self, file_name: str, base_sas_url: str): elif file_name.endswith(".html"): headers["Content-Type"] = "text/html" - base_url = base_sas_url.rstrip('/') - url_part, query_part = base_url.split('?', 1) + base_url = base_sas_url.rstrip("/") + url_part, query_part = base_url.split("?", 1) full_sas_url = f"{url_part}/{file_name}?{query_part}" response = requests.put(full_sas_url, headers=headers, data=file_data) @@ -204,7 +207,7 @@ def _notify_teams(self, webhook_url: str, storage_account: str, container: str): """Send notification to Teams with configurable webhook URL.""" try: azure_portal_url = self._build_azure_portal_url(storage_account, container) - + adaptive_card = { "type": "message", "attachments": [ @@ -220,52 +223,33 @@ def _notify_teams(self, webhook_url: str, storage_account: str, container: str): "text": "📊 KRR Report Generated", "weight": "Bolder", "size": "Medium", - "color": "Good" + "color": "Good", }, { "type": "TextBlock", "text": f"Kubernetes Resource Report for {(' '.join(settings.namespaces))} has been generated and uploaded to Azure Blob Storage.", "wrap": True, - "spacing": "Medium" + "spacing": "Medium", }, { "type": "FactSet", "facts": [ - { - "title": "Namespaces:", - "value": ' '.join(settings.namespaces) - }, - { - "title": "Format:", - "value": settings.format - }, - { - "title": "Storage Account:", - "value": storage_account - }, - { - "title": "Container:", - "value": container - }, - { - "title": "Generated:", - "value": datetime.now().strftime("%Y-%m-%d %H:%M:%S") - } - ] - } + {"title": "Namespaces:", "value": " ".join(settings.namespaces)}, + {"title": "Format:", "value": settings.format}, + {"title": "Storage Account:", "value": storage_account}, + {"title": "Container:", "value": container}, + {"title": "Generated:", "value": datetime.now().strftime("%Y-%m-%d %H:%M:%S")}, + ], + }, ], "actions": [ - { - "type": "Action.OpenUrl", - "title": "View in Azure Storage", - "url": azure_portal_url - } - ] - } + {"type": "Action.OpenUrl", "title": "View in Azure Storage", "url": azure_portal_url} + ], + }, } - ] + ], } - + response = requests.post(webhook_url, json=adaptive_card) if response.status_code == 202: logger.info("Successfully notified Microsoft Teams about the report generation.") @@ -281,14 +265,14 @@ def _extract_storage_info_from_sas(self, sas_url: str) -> tuple[str, str]: """ try: parsed = urlparse(sas_url) - storage_account = parsed.hostname.split('.')[0] # Extract the storage account name from the hostname - container = parsed.path.strip('/').split('/')[0] # Extract the first part of the path as the container name + storage_account = parsed.hostname.split(".")[0] # Extract the storage account name from the hostname + container = parsed.path.strip("/").split("/")[0] # Extract the first part of the path as the container name return storage_account, container except Exception as e: logger.error(f"Failed to extract storage info from SAS URL: {e}") raise ValueError("Invalid SAS URL format. Please provide a valid Azure Blob Storage SAS URL.") from e - + def _build_azure_portal_url(self, storage_account: str, container: str) -> str: """ Builds the Azure portal URL to view the specified storage account and container. @@ -296,7 +280,9 @@ def _build_azure_portal_url(self, storage_account: str, container: str) -> str: if not settings.azure_subscription_id or not settings.azure_resource_group: # Return a generic Azure portal link if specific info is missing - logger.warning("Azure subscription ID or resource group not provided. Azure portal link will not be specific.") + logger.warning( + "Azure subscription ID or resource group not provided. Azure portal link will not be specific." + ) return f"https://portal.azure.com/#view/Microsoft_Azure_Storage/ContainerMenuBlade/~/overview/storageAccountId/%2Fsubscriptions%2F{settings.azure_subscription_id}%2FresourceGroups%2F{settings.azure_resource_group}%2Fproviders%2FMicrosoft.Storage%2FstorageAccounts%2F{storage_account}/path/{container}" def __get_resource_minimal(self, resource: ResourceType) -> float: @@ -453,7 +439,7 @@ async def _collect_result(self) -> Result: with ProgressBar(title="Calculating Recommendation") as self.__progressbar: workloads = await self._k8s_loader.list_scannable_objects(clusters) if not clusters or len(clusters) == 1: - cluster_name = clusters[0] if clusters else None # its none if krr is running inside cluster + cluster_name = clusters[0] if clusters else None # its none if krr is running inside cluster prometheus_loader = self._get_prometheus_loader(cluster_name) cluster_summary = await prometheus_loader.get_cluster_summary() else: @@ -480,7 +466,7 @@ async def _collect_result(self) -> Result: name=str(self._strategy).lower(), settings=self._strategy.settings.dict(), ), - clusterSummary=cluster_summary + clusterSummary=cluster_summary, ) async def run(self) -> int: @@ -520,21 +506,24 @@ async def run(self) -> int: else: return 0 # Exit with success - def _send_result(self, url: str, start_time: datetime, scan_id: str,named_sinks: Optional[List[str]], result: Result): + def _send_result( + self, url: str, start_time: datetime, scan_id: str, named_sinks: Optional[List[str]], result: Result + ): result_dict = json.loads(result.json(indent=2)) _send_scan_payload(url, scan_id, start_time, result_dict, named_sinks, is_error=False) + def publish_input_error(url: str, scan_id: str, start_time: str, error: str, named_sinks: Optional[List[str]]): _send_scan_payload(url, scan_id, start_time, error, named_sinks, is_error=True) + def publish_error(error: str): - _send_scan_payload(settings.publish_scan_url, settings.scan_id, settings.start_time, error, settings.named_sinks, is_error=True) + _send_scan_payload( + settings.publish_scan_url, settings.scan_id, settings.start_time, error, settings.named_sinks, is_error=True + ) + -@retry( - stop=stop_after_attempt(3), - wait=wait_exponential(multiplier=1, min=1, max=8), - reraise=True -) +@retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=1, max=8), reraise=True) def _post_scan_request(url: str, headers: dict, payload: dict, scan_id: str, is_error: bool): logger_msg = "Sending error scan" if is_error else "Sending scan" logger.info(f"{logger_msg} for scan_id={scan_id} to url={url}") @@ -550,10 +539,12 @@ def _send_scan_payload( start_time: Union[str, datetime], result_data: Union[str, dict], named_sinks: Optional[List[str]], - is_error: bool = False + is_error: bool = False, ): if not url or not scan_id or not start_time: - logger.debug(f"Missing required parameters: url={bool(url)}, scan_id={bool(scan_id)}, start_time={bool(start_time)}") + logger.debug( + f"Missing required parameters: url={bool(url)}, scan_id={bool(scan_id)}, start_time={bool(start_time)}" + ) return logger.debug(f"Preparing to send scan payload. scan_id={scan_id}, to sink {named_sinks}, is_error={is_error}") @@ -571,7 +562,7 @@ def _send_scan_payload( "scan_type": "krr", "scan_id": scan_id, "start_time": start_time, - } + }, } if named_sinks: action_request["sinks"] = named_sinks @@ -581,4 +572,4 @@ def _send_scan_payload( except requests.exceptions.RequestException as e: logger.error(f"scan_id={scan_id} | All retry attempts failed due to RequestException: {e}", exc_info=True) except Exception as e: - logger.error(f"scan_id={scan_id} | Unexpected error after retries: {e}", exc_info=True) \ No newline at end of file + logger.error(f"scan_id={scan_id} | Unexpected error after retries: {e}", exc_info=True) diff --git a/robusta_krr/formatters/csv_raw.py b/robusta_krr/formatters/csv_raw.py index c88d1fa7..942bbd6a 100644 --- a/robusta_krr/formatters/csv_raw.py +++ b/robusta_krr/formatters/csv_raw.py @@ -21,10 +21,10 @@ SEVERITY_HEADER = "Severity" RESOURCE_REQUESTS_CURRENT_HEADER = "{resource_name} Requests Current" -RESOURCE_REQUESTS_RECOMMENDED_HEADER = '{resource_name} Requests Recommended' +RESOURCE_REQUESTS_RECOMMENDED_HEADER = "{resource_name} Requests Recommended" RESOURCE_LIMITS_CURRENT_HEADER = "{resource_name} Limits Current" -RESOURCE_LIMITS_RECOMMENDED_HEADER = '{resource_name} Limits Recommended' +RESOURCE_LIMITS_RECOMMENDED_HEADER = "{resource_name} Limits Recommended" def _format_value(val: Union[float, int]) -> str: @@ -37,7 +37,7 @@ def _format_value(val: Union[float, int]) -> str: elif isinstance(val, str): return NAN_LITERAL else: - raise ValueError(f'unknown value: {val}') + raise ValueError(f"unknown value: {val}") def _format_request_current(item: ResourceScan, resource: ResourceType, selector: str) -> str: diff --git a/robusta_krr/formatters/html.py b/robusta_krr/formatters/html.py index a028d969..1d2dc578 100644 --- a/robusta_krr/formatters/html.py +++ b/robusta_krr/formatters/html.py @@ -4,6 +4,7 @@ from robusta_krr.core.models.result import Result from .table import table + @formatters.register("html") def html(result: Result) -> str: console = Console(record=True) diff --git a/robusta_krr/formatters/table.py b/robusta_krr/formatters/table.py index 7d027541..85d20f03 100644 --- a/robusta_krr/formatters/table.py +++ b/robusta_krr/formatters/table.py @@ -4,12 +4,17 @@ from rich.table import Table from robusta_krr.core.abstract import formatters -from robusta_krr.core.models.allocations import RecommendationValue, format_recommendation_value, format_diff, NONE_LITERAL, NAN_LITERAL +from robusta_krr.core.models.allocations import ( + RecommendationValue, + format_recommendation_value, + format_diff, + NONE_LITERAL, + NAN_LITERAL, +) from robusta_krr.core.models.result import ResourceScan, ResourceType, Result from robusta_krr.core.models.config import settings from robusta_krr.utils import resource_units - DEFAULT_INFO_COLOR = "grey27" INFO_COLORS: dict[str, str] = { "OOMKill detected": "dark_red", diff --git a/robusta_krr/main.py b/robusta_krr/main.py index 6eda717c..c9da3009 100644 --- a/robusta_krr/main.py +++ b/robusta_krr/main.py @@ -408,11 +408,11 @@ def run_strategy( start_time=start_time, scan_id=scan_id, named_sinks=named_sinks, - ) + ) Config.set_config(config) except ValidationError as e: logger.exception("Error occured while parsing arguments") - publish_input_error( publish_scan_url, scan_id, start_time, str(e), named_sinks) + publish_input_error(publish_scan_url, scan_id, start_time, str(e), named_sinks) else: runner = Runner() exit_code = asyncio.run(runner.run()) diff --git a/robusta_krr/strategies/__init__.py b/robusta_krr/strategies/__init__.py index 8b9752b4..bb4e6569 100644 --- a/robusta_krr/strategies/__init__.py +++ b/robusta_krr/strategies/__init__.py @@ -1,2 +1,2 @@ from .simple import SimpleStrategy -from .simple_limit import SimpleLimitStrategy \ No newline at end of file +from .simple_limit import SimpleLimitStrategy diff --git a/robusta_krr/strategies/simple.py b/robusta_krr/strategies/simple.py index fa9cd777..e36cca65 100644 --- a/robusta_krr/strategies/simple.py +++ b/robusta_krr/strategies/simple.py @@ -71,7 +71,7 @@ def history_range_enough(self, history_range: tuple[timedelta, timedelta]) -> bo class SimpleStrategy(BaseStrategy[SimpleStrategySettings]): - + display_name = "simple" rich_console = True @@ -99,17 +99,17 @@ def description(self): All parameters can be customized. For example: `krr simple --cpu_percentile=90 --memory_buffer_percentage=15 --history_duration=24 --timeframe_duration=0.5` """) - + if not self.settings.allow_hpa: s += "\n" + textwrap.dedent(f"""\ This strategy does not work with objects with HPA defined (Horizontal Pod Autoscaler). If HPA is defined for CPU or Memory, the strategy will return "?" for that resource. You can override this behaviour by passing the --allow-hpa flag - """) + """) s += "\nLearn more: [underline]https://github.com/robusta-dev/krr#algorithm[/underline]" return s - + def __calculate_cpu_proposal( self, history_data: MetricsPodData, object_data: K8sObjectData ) -> ResourceRecommendation: diff --git a/robusta_krr/utils/intro.py b/robusta_krr/utils/intro.py index c231773d..95298498 100644 --- a/robusta_krr/utils/intro.py +++ b/robusta_krr/utils/intro.py @@ -4,9 +4,8 @@ from .version import get_version - -ONLINE_LINK = 'https://api.robusta.dev/krr/intro' -LOCAL_LINK = './intro.txt' +ONLINE_LINK = "https://api.robusta.dev/krr/intro" +LOCAL_LINK = "./intro.txt" TIMEOUT = 0.5 @@ -17,11 +16,11 @@ def fetch_intro_message() -> str: response = requests.get(ONLINE_LINK, params={"version": get_version()}, timeout=TIMEOUT) response.raise_for_status() # Raises an error for bad responses result = response.json() - return result['message'] + return result["message"] except Exception as e1: # If there's any error, fallback to local file try: - with open(LOCAL_LINK, 'r') as file: + with open(LOCAL_LINK, "r") as file: return file.read() except Exception as e2: return ( @@ -39,4 +38,4 @@ async def load_intro_message() -> str: return await loop.run_in_executor(pool, fetch_intro_message) -__all__ = ['load_intro_message'] +__all__ = ["load_intro_message"] diff --git a/robusta_krr/utils/patch.py b/robusta_krr/utils/patch.py index e12114f8..d32c01bb 100644 --- a/robusta_krr/utils/patch.py +++ b/robusta_krr/utils/patch.py @@ -2,9 +2,10 @@ from kubernetes.client.models.v1_pod_failure_policy_rule import V1PodFailurePolicyRule + def create_monkey_patches(): """ - The python kubernetes client will throw exceptions for specific fields that were not allowed to be None on older versions of kubernetes. + The python kubernetes client will throw exceptions for specific fields that were not allowed to be None on older versions of kubernetes. """ logger = logging.getLogger("krr") logger.debug("Creating kubernetes python cli monkey patches") @@ -12,4 +13,6 @@ def create_monkey_patches(): def patched_setter_pod_failure_policy(self, on_pod_conditions): self._on_pod_conditions = on_pod_conditions - V1PodFailurePolicyRule.on_pod_conditions = V1PodFailurePolicyRule.on_pod_conditions.setter(patched_setter_pod_failure_policy) + V1PodFailurePolicyRule.on_pod_conditions = V1PodFailurePolicyRule.on_pod_conditions.setter( + patched_setter_pod_failure_policy + ) diff --git a/robusta_krr/utils/version.py b/robusta_krr/utils/version.py index 47f81c55..3bc04ed8 100644 --- a/robusta_krr/utils/version.py +++ b/robusta_krr/utils/version.py @@ -14,7 +14,7 @@ def get_version() -> str: # the version string was patched by a release - return __version__ which will be correct if robusta_krr.__version__ != "dev": return robusta_krr.__version__ - + # we are running from an unreleased dev version try: # Get the latest git tag @@ -28,7 +28,7 @@ def get_version() -> str: dirty = "-dirty" if status else "" return f"{tag}-{branch}{dirty}" - + except Exception: return robusta_krr.__version__ diff --git a/tests/formatters/test_csv_formatter.py b/tests/formatters/test_csv_formatter.py index 150b2aa5..87f5b60f 100644 --- a/tests/formatters/test_csv_formatter.py +++ b/tests/formatters/test_csv_formatter.py @@ -235,7 +235,7 @@ def test_csv_headers(override_config: dict[str, Any], expected_headers: list[str "Old Pods": "1", "Type": "Deployment", "Container": "mock-container-1", - 'Severity': 'CRITICAL', + "Severity": "CRITICAL", "CPU Diff": "-87m", "CPU Requests": "(-43m) 50m -> 6m", "CPU Limits": "2.0 -> ?", @@ -271,7 +271,7 @@ def test_csv_headers(override_config: dict[str, Any], expected_headers: list[str "Old Pods": "1", "Type": "Deployment", "Container": "mock-container-1", - 'Severity': 'CRITICAL', + "Severity": "CRITICAL", "CPU Diff": "-87m", "CPU Requests": "(-43m) 50m -> 6m", "CPU Limits": "2.0 -> ?", diff --git a/tests/test_app_imports.py b/tests/test_app_imports.py index fe40bda1..4ffd6a0c 100644 --- a/tests/test_app_imports.py +++ b/tests/test_app_imports.py @@ -32,6 +32,8 @@ "# DO NOT ADD ANY CODE ABOVE THIS\n", "# ADDING IMPORTS BEFORE ADDING THE CUSTOM CERTS MIGHT INIT HTTP CLIENTS THAT DOESN'T RESPECT THE CUSTOM CERT\n", ] + + @pytest.mark.parametrize( "file_path,file_name,expected_lines", [ diff --git a/tests/test_grouped_jobs.py b/tests/test_grouped_jobs.py index 6d882691..9c802a65 100644 --- a/tests/test_grouped_jobs.py +++ b/tests/test_grouped_jobs.py @@ -27,15 +27,16 @@ def mock_kubernetes_loader(mock_config): loader = ClusterLoader() loader.batch = MagicMock() loader.core = MagicMock() - + # Mock executor to return a proper Future from concurrent.futures import Future + mock_future = Future() mock_future.set_result(None) # Set a dummy result loader.executor = MagicMock() loader.executor.submit.return_value = mock_future - - loader._ClusterLoader__hpa_list = {} # type: ignore # needed for mock + + loader._ClusterLoader__hpa_list = {} # type: ignore # needed for mock return loader @@ -46,7 +47,7 @@ def create_mock_job(name: str, namespace: str, labels: dict): job.metadata.namespace = namespace job.metadata.labels = labels job.metadata.owner_references = [] - + # Create a mock container with a proper name container = MagicMock() container.name = "main-container" @@ -57,7 +58,7 @@ def create_mock_job(name: str, namespace: str, labels: dict): @pytest.mark.asyncio async def test_list_all_groupedjobs_with_limit(mock_kubernetes_loader, mock_config): """Test that _list_all_groupedjobs respects the job_grouping_limit""" - + # Create mock jobs - more than the limit (3) mock_jobs = [ create_mock_job("job-1", "default", {"app": "frontend"}), @@ -70,7 +71,7 @@ async def test_list_all_groupedjobs_with_limit(mock_kubernetes_loader, mock_conf create_mock_job("job-8", "default", {"app": "backend"}), create_mock_job("job-9", "default", {"app": "backend"}), # This should be excluded ] - + # Mock the _list_namespaced_or_global_objects_batched method async def mock_batched_method(*args, **kwargs): # Create mock response objects that have the expected structure @@ -79,46 +80,47 @@ async def mock_batched_method(*args, **kwargs): mock_response.metadata = MagicMock() mock_response.metadata._continue = None return (mock_jobs, None) # Return (jobs, continue_token) + mock_kubernetes_loader._list_namespaced_or_global_objects_batched = mock_batched_method - + # Mock the __build_scannable_object method def mock_build_scannable_object(item, container, kind): obj = MagicMock() obj._api_resource = MagicMock() obj.container = container.name return obj - + mock_kubernetes_loader._ClusterLoader__build_scannable_object = mock_build_scannable_object - + # Patch the settings to use our mock config with patch("robusta_krr.core.integrations.kubernetes.settings", mock_config): # Call the method result = await mock_kubernetes_loader._list_all_groupedjobs() - + # Verify we got 2 objects (1 frontend + 1 backend, one per unique container name) assert len(result) == 2 - + # Group results by name to verify grouping frontend_objects = [g for g in result if g.name == "app=frontend"] backend_objects = [g for g in result if g.name == "app=backend"] - + # Verify we got 1 frontend object (one per unique container name) assert len(frontend_objects) == 1 assert frontend_objects[0].namespace == "default" assert frontend_objects[0].container == "main-container" - - # Verify we got 1 backend object + + # Verify we got 1 backend object assert len(backend_objects) == 1 assert backend_objects[0].namespace == "default" assert backend_objects[0].container == "main-container" - + # Verify all objects in each group have lightweight job info frontend_grouped_jobs = frontend_objects[0]._api_resource._grouped_jobs assert len(frontend_grouped_jobs) == 3 assert frontend_grouped_jobs[0].name == "job-1" assert frontend_grouped_jobs[1].name == "job-2" assert frontend_grouped_jobs[2].name == "job-3" - + backend_grouped_jobs = backend_objects[0]._api_resource._grouped_jobs assert len(backend_grouped_jobs) == 3 assert backend_grouped_jobs[0].name == "job-6" @@ -129,7 +131,7 @@ def mock_build_scannable_object(item, container, kind): @pytest.mark.asyncio async def test_list_all_groupedjobs_with_different_namespaces(mock_kubernetes_loader, mock_config): """Test that GroupedJob objects are created separately for different namespaces""" - + # Create mock jobs in different namespaces mock_jobs = [ create_mock_job("job-1", "namespace-1", {"app": "frontend"}), @@ -137,7 +139,7 @@ async def test_list_all_groupedjobs_with_different_namespaces(mock_kubernetes_lo create_mock_job("job-3", "namespace-2", {"app": "frontend"}), create_mock_job("job-4", "namespace-2", {"app": "frontend"}), ] - + async def mock_batched_method(*args, **kwargs): # Create mock response objects that have the expected structure mock_response = MagicMock() @@ -145,34 +147,35 @@ async def mock_batched_method(*args, **kwargs): mock_response.metadata = MagicMock() mock_response.metadata._continue = None return (mock_jobs, None) # Return (jobs, continue_token) + mock_kubernetes_loader._list_namespaced_or_global_objects_batched = mock_batched_method - + def mock_build_scannable_object(item, container, kind): obj = MagicMock() obj._api_resource = MagicMock() obj.container = container.name return obj - + mock_kubernetes_loader._ClusterLoader__build_scannable_object = mock_build_scannable_object - + # Patch the settings to use our mock config with patch("robusta_krr.core.integrations.kubernetes.settings", mock_config): # Call the method result = await mock_kubernetes_loader._list_all_groupedjobs() - + # Verify we got 2 objects (1 per namespace, one per unique container name) assert len(result) == 2 - + # Group results by namespace ns1_objects = [g for g in result if g.namespace == "namespace-1"] ns2_objects = [g for g in result if g.namespace == "namespace-2"] - + # Check namespace-1 objects assert len(ns1_objects) == 1 assert ns1_objects[0].name == "app=frontend" assert ns1_objects[0].container == "main-container" assert len(ns1_objects[0]._api_resource._grouped_jobs) == 2 - + # Check namespace-2 objects assert len(ns2_objects) == 1 assert ns2_objects[0].name == "app=frontend" @@ -183,16 +186,16 @@ def mock_build_scannable_object(item, container, kind): @pytest.mark.asyncio async def test_list_all_groupedjobs_with_cronjob_owner_reference(mock_kubernetes_loader, mock_config): """Test that jobs with CronJob owner references are excluded""" - + # Create mock jobs - one with CronJob owner, one without mock_jobs = [ create_mock_job("job-1", "default", {"app": "frontend"}), create_mock_job("job-2", "default", {"app": "frontend"}), ] - + # Add CronJob owner reference to the second job mock_jobs[1].metadata.owner_references = [MagicMock(kind="CronJob")] - + async def mock_batched_method(*args, **kwargs): # Create mock response objects that have the expected structure mock_response = MagicMock() @@ -200,21 +203,22 @@ async def mock_batched_method(*args, **kwargs): mock_response.metadata = MagicMock() mock_response.metadata._continue = None return (mock_jobs, None) # Return (jobs, continue_token) + mock_kubernetes_loader._list_namespaced_or_global_objects_batched = mock_batched_method - + def mock_build_scannable_object(item, container, kind): obj = MagicMock() obj._api_resource = MagicMock() obj.container = container.name # Set the actual container name return obj - + mock_kubernetes_loader._ClusterLoader__build_scannable_object = mock_build_scannable_object - + # Patch the settings to use our mock config with patch("robusta_krr.core.integrations.kubernetes.settings", mock_config): # Call the method result = await mock_kubernetes_loader._list_all_groupedjobs() - + # Verify we got 1 object (only the job without CronJob owner) assert len(result) == 1 obj = result[0] @@ -226,11 +230,11 @@ def mock_build_scannable_object(item, container, kind): @pytest.mark.asyncio async def test_list_all_groupedjobs_no_grouping_labels(mock_kubernetes_loader): """Test that no GroupedJob objects are created when no grouping labels are configured""" - + # Mock config with no grouping labels mock_config_no_labels = MagicMock(spec=Config) mock_config_no_labels.job_grouping_labels = None - + with patch("robusta_krr.core.integrations.kubernetes.settings", mock_config_no_labels): result = await mock_kubernetes_loader._list_all_groupedjobs() assert len(result) == 0 @@ -239,14 +243,14 @@ async def test_list_all_groupedjobs_no_grouping_labels(mock_kubernetes_loader): @pytest.mark.asyncio async def test_list_all_groupedjobs_multiple_labels(mock_kubernetes_loader, mock_config): """Test that jobs with different grouping labels create separate groups""" - + # Create mock jobs with different labels mock_jobs = [ create_mock_job("job-1", "default", {"app": "frontend"}), create_mock_job("job-2", "default", {"team": "backend"}), create_mock_job("job-3", "default", {"app": "api"}), ] - + async def mock_batched_method(*args, **kwargs): # Create mock response objects that have the expected structure mock_response = MagicMock() @@ -254,29 +258,30 @@ async def mock_batched_method(*args, **kwargs): mock_response.metadata = MagicMock() mock_response.metadata._continue = None return (mock_jobs, None) # Return (jobs, continue_token) + mock_kubernetes_loader._list_namespaced_or_global_objects_batched = mock_batched_method - + def mock_build_scannable_object(item, container, kind): obj = MagicMock() obj._api_resource = MagicMock() obj.container = container.name return obj - + mock_kubernetes_loader._ClusterLoader__build_scannable_object = mock_build_scannable_object - + # Patch the settings to use our mock config with patch("robusta_krr.core.integrations.kubernetes.settings", mock_config): # Call the method result = await mock_kubernetes_loader._list_all_groupedjobs() - + # Verify we got 3 objects (one for each label+value combination, one per unique container name) assert len(result) == 3 - + group_names = {g.name for g in result} assert "app=frontend" in group_names assert "team=backend" in group_names assert "app=api" in group_names - + # Verify all objects have the same container name assert all(obj.container == "main-container" for obj in result) @@ -284,13 +289,13 @@ def mock_build_scannable_object(item, container, kind): @pytest.mark.asyncio async def test_list_all_groupedjobs_job_in_multiple_groups(mock_kubernetes_loader, mock_config): """Test that a job with multiple grouping labels is added to all matching groups""" - + # Create a job that matches multiple grouping labels mock_jobs = [ create_mock_job("job-1", "default", {"app": "frontend", "team": "web"}), create_mock_job("job-2", "default", {"app": "backend", "team": "api"}), ] - + async def mock_batched_method(*args, **kwargs): # Create mock response objects that have the expected structure mock_response = MagicMock() @@ -298,47 +303,48 @@ async def mock_batched_method(*args, **kwargs): mock_response.metadata = MagicMock() mock_response.metadata._continue = None return (mock_jobs, None) # Return (jobs, continue_token) + mock_kubernetes_loader._list_namespaced_or_global_objects_batched = mock_batched_method - + def mock_build_scannable_object(item, container, kind): obj = MagicMock() obj._api_resource = MagicMock() obj.container = container.name return obj - + mock_kubernetes_loader._ClusterLoader__build_scannable_object = mock_build_scannable_object - + # Patch the settings to use our mock config with patch("robusta_krr.core.integrations.kubernetes.settings", mock_config): # Call the method result = await mock_kubernetes_loader._list_all_groupedjobs() - + # Verify we got 4 objects (2 jobs × 2 labels each = 4 groups) assert len(result) == 4 - + group_names = {g.name for g in result} assert "app=frontend" in group_names assert "app=backend" in group_names assert "team=web" in group_names assert "team=api" in group_names - + # Find each group and verify it contains the correct job frontend_group = next(g for g in result if g.name == "app=frontend") backend_group = next(g for g in result if g.name == "app=backend") web_group = next(g for g in result if g.name == "team=web") api_group = next(g for g in result if g.name == "team=api") - + # Verify job-1 is in both app=frontend and team=web groups assert len(frontend_group._api_resource._grouped_jobs) == 1 assert frontend_group._api_resource._grouped_jobs[0].name == "job-1" - + assert len(web_group._api_resource._grouped_jobs) == 1 assert web_group._api_resource._grouped_jobs[0].name == "job-1" - + # Verify job-2 is in both app=backend and team=api groups assert len(backend_group._api_resource._grouped_jobs) == 1 assert backend_group._api_resource._grouped_jobs[0].name == "job-2" - + assert len(api_group._api_resource._grouped_jobs) == 1 assert api_group._api_resource._grouped_jobs[0].name == "job-2" diff --git a/tests/test_grouped_jobs_metrics_logic.py b/tests/test_grouped_jobs_metrics_logic.py index e8e534f2..c4880d7a 100644 --- a/tests/test_grouped_jobs_metrics_logic.py +++ b/tests/test_grouped_jobs_metrics_logic.py @@ -7,14 +7,14 @@ def test_grouped_job_extracts_job_names(): """Test that GroupedJob objects correctly expose job names for metrics queries""" - + # Create a mock GroupedJob object grouped_job = MagicMock() grouped_job.kind = "GroupedJob" grouped_job.name = "app=frontend" grouped_job.namespace = "default" grouped_job.container = "main-container" - + # Mock the API resource with lightweight job info grouped_job._api_resource = MagicMock() grouped_job._api_resource._grouped_jobs = [ @@ -23,27 +23,27 @@ def test_grouped_job_extracts_job_names(): LightweightJobInfo(name="job-3", namespace="default"), ] grouped_job._api_resource._label_filter = "app=frontend" - + # Test the logic that would be used in PrometheusMetricsService.load_pods if grouped_job.kind == "GroupedJob": - if hasattr(grouped_job._api_resource, '_grouped_jobs'): + if hasattr(grouped_job._api_resource, "_grouped_jobs"): pod_owners = [job.name for job in grouped_job._api_resource._grouped_jobs] pod_owner_kind = "Job" else: pod_owners = [grouped_job.name] pod_owner_kind = grouped_job.kind - + # Verify the extracted job names assert pod_owners == ["job-1", "job-2", "job-3"] assert pod_owner_kind == "Job" - + # Verify the namespace assert grouped_job.namespace == "default" def test_grouped_job_with_different_namespaces(): """Test that GroupedJob objects in different namespaces are handled correctly""" - + # Create grouped jobs in different namespaces grouped_job_ns1 = MagicMock() grouped_job_ns1.kind = "GroupedJob" @@ -53,7 +53,7 @@ def test_grouped_job_with_different_namespaces(): grouped_job_ns1._api_resource._grouped_jobs = [ LightweightJobInfo(name="job-1", namespace="namespace-1"), ] - + grouped_job_ns2 = MagicMock() grouped_job_ns2.kind = "GroupedJob" grouped_job_ns2.name = "app=frontend" @@ -62,17 +62,17 @@ def test_grouped_job_with_different_namespaces(): grouped_job_ns2._api_resource._grouped_jobs = [ LightweightJobInfo(name="job-2", namespace="namespace-2"), ] - + # Test the logic for both namespaces for grouped_job in [grouped_job_ns1, grouped_job_ns2]: if grouped_job.kind == "GroupedJob": - if hasattr(grouped_job._api_resource, '_grouped_jobs'): + if hasattr(grouped_job._api_resource, "_grouped_jobs"): pod_owners = [job.name for job in grouped_job._api_resource._grouped_jobs] pod_owner_kind = "Job" else: pod_owners = [grouped_job.name] pod_owner_kind = grouped_job.kind - + # Verify namespace-specific results if grouped_job.namespace == "namespace-1": assert pod_owners == ["job-1"] @@ -84,13 +84,13 @@ def test_grouped_job_with_different_namespaces(): def test_grouped_job_prometheus_query_construction(): """Test that the Prometheus query is constructed correctly for GroupedJob""" - + # Create a mock GroupedJob object grouped_job = MagicMock() grouped_job.kind = "GroupedJob" grouped_job.name = "app=frontend" grouped_job.namespace = "default" - + # Mock the API resource with lightweight job info grouped_job._api_resource = MagicMock() grouped_job._api_resource._grouped_jobs = [ @@ -98,20 +98,20 @@ def test_grouped_job_prometheus_query_construction(): LightweightJobInfo(name="job-2", namespace="default"), LightweightJobInfo(name="job-3", namespace="default"), ] - + # Simulate the logic from PrometheusMetricsService.load_pods if grouped_job.kind == "GroupedJob": - if hasattr(grouped_job._api_resource, '_grouped_jobs'): + if hasattr(grouped_job._api_resource, "_grouped_jobs"): pod_owners = [job.name for job in grouped_job._api_resource._grouped_jobs] pod_owner_kind = "Job" else: pod_owners = [grouped_job.name] pod_owner_kind = grouped_job.kind - + # Construct the Prometheus query (simplified version) owners_regex = "|".join(pod_owners) cluster_label = "" # Simplified for testing - + expected_query = f""" last_over_time( kube_pod_owner{{ @@ -122,7 +122,7 @@ def test_grouped_job_prometheus_query_construction(): }}[1h] ) """ - + # Verify the query contains the expected elements assert "job-1|job-2|job-3" in expected_query assert 'owner_kind="Job"' in expected_query @@ -132,36 +132,36 @@ def test_grouped_job_prometheus_query_construction(): def test_grouped_job_batched_queries(): """Test that batched queries are handled correctly for many jobs""" - + # Create a grouped job with many lightweight jobs grouped_job = MagicMock() grouped_job.kind = "GroupedJob" grouped_job.name = "app=frontend" grouped_job.namespace = "default" - + # Create 150 jobs (more than typical batch size of 100) many_jobs = [LightweightJobInfo(name=f"job-{i}", namespace="default") for i in range(150)] grouped_job._api_resource = MagicMock() grouped_job._api_resource._grouped_jobs = many_jobs - + # Extract job names if grouped_job.kind == "GroupedJob": - if hasattr(grouped_job._api_resource, '_grouped_jobs'): + if hasattr(grouped_job._api_resource, "_grouped_jobs"): pod_owners = [job.name for job in grouped_job._api_resource._grouped_jobs] pod_owner_kind = "Job" - + # Simulate batching logic batch_size = 100 batches = [] for i in range(0, len(pod_owners), batch_size): - batch = pod_owners[i:i + batch_size] + batch = pod_owners[i : i + batch_size] batches.append(batch) - + # Verify batching assert len(batches) == 2 # 150 jobs split into 2 batches assert len(batches[0]) == 100 # First batch has 100 jobs - assert len(batches[1]) == 50 # Second batch has 50 jobs - + assert len(batches[1]) == 50 # Second batch has 50 jobs + # Verify all job names are included all_batched_jobs = [job for batch in batches for job in batch] assert len(all_batched_jobs) == 150 @@ -170,28 +170,28 @@ def test_grouped_job_batched_queries(): def test_grouped_job_fallback_logic(): """Test the fallback logic when _grouped_jobs is not available""" - + # Create a GroupedJob without _grouped_jobs grouped_job = MagicMock() grouped_job.kind = "GroupedJob" grouped_job.name = "app=frontend" grouped_job.namespace = "default" - + # Create a mock API resource that doesn't have _grouped_jobs api_resource = MagicMock() # Explicitly remove the _grouped_jobs attribute del api_resource._grouped_jobs grouped_job._api_resource = api_resource - + # Test the fallback logic if grouped_job.kind == "GroupedJob": - if hasattr(grouped_job._api_resource, '_grouped_jobs'): + if hasattr(grouped_job._api_resource, "_grouped_jobs"): pod_owners = [job.name for job in grouped_job._api_resource._grouped_jobs] pod_owner_kind = "Job" else: pod_owners = [grouped_job.name] pod_owner_kind = grouped_job.kind - + # Verify fallback behavior assert pod_owners == ["app=frontend"] assert pod_owner_kind == "GroupedJob" @@ -199,34 +199,34 @@ def test_grouped_job_fallback_logic(): def test_lightweight_job_info_structure(): """Test that LightweightJobInfo has the correct structure""" - + # Create a LightweightJobInfo instance job_info = LightweightJobInfo(name="test-job", namespace="test-namespace") - + # Verify the structure assert job_info.name == "test-job" assert job_info.namespace == "test-namespace" - + # Verify it's a simple data class - assert hasattr(job_info, 'name') - assert hasattr(job_info, 'namespace') - + assert hasattr(job_info, "name") + assert hasattr(job_info, "namespace") + # Test that it can be used in list comprehensions job_infos = [ LightweightJobInfo(name="job-1", namespace="default"), LightweightJobInfo(name="job-2", namespace="default"), ] - + job_names = [job.name for job in job_infos] assert job_names == ["job-1", "job-2"] - + namespaces = [job.namespace for job in job_infos] assert namespaces == ["default", "default"] def test_grouped_job_multiple_groups_metrics_extraction(): """Test that jobs appearing in multiple groups work correctly for metrics extraction""" - + # Create a job that appears in multiple groups grouped_job_app = MagicMock() grouped_job_app.kind = "GroupedJob" @@ -237,7 +237,7 @@ def test_grouped_job_multiple_groups_metrics_extraction(): LightweightJobInfo(name="job-1", namespace="default"), LightweightJobInfo(name="job-2", namespace="default"), ] - + grouped_job_team = MagicMock() grouped_job_team.kind = "GroupedJob" grouped_job_team.name = "team=web" @@ -247,30 +247,30 @@ def test_grouped_job_multiple_groups_metrics_extraction(): LightweightJobInfo(name="job-1", namespace="default"), # Same job appears in both groups LightweightJobInfo(name="job-3", namespace="default"), ] - + # Test metrics extraction for both groups for grouped_job in [grouped_job_app, grouped_job_team]: if grouped_job.kind == "GroupedJob": - if hasattr(grouped_job._api_resource, '_grouped_jobs'): + if hasattr(grouped_job._api_resource, "_grouped_jobs"): pod_owners = [job.name for job in grouped_job._api_resource._grouped_jobs] pod_owner_kind = "Job" else: pod_owners = [grouped_job.name] pod_owner_kind = grouped_job.kind - + # Verify the extracted job names if grouped_job.name == "app=frontend": assert pod_owners == ["job-1", "job-2"] elif grouped_job.name == "team=web": assert pod_owners == ["job-1", "job-3"] - + assert pod_owner_kind == "Job" assert grouped_job.namespace == "default" - + # Verify that job-1 appears in both groups (this is the key behavior we're testing) app_job_names = [job.name for job in grouped_job_app._api_resource._grouped_jobs] team_job_names = [job.name for job in grouped_job_team._api_resource._grouped_jobs] - + assert "job-1" in app_job_names assert "job-1" in team_job_names assert len(app_job_names) == 2 diff --git a/tests/test_krr.py b/tests/test_krr.py index 90ea2af5..818fbeed 100644 --- a/tests/test_krr.py +++ b/tests/test_krr.py @@ -39,41 +39,46 @@ def test_output_formats(format: str, output: str): except AssertionError as e: raise e from result.exception + @pytest.mark.parametrize( - "setting_namespaces,cluster_all_ns,expected",[ - ( - # default settings - "*", - ["kube-system", "robusta-frontend", "robusta-backend", "infra-grafana"], - "*" - ), - ( - # list of namespace provided from arguments without regex pattern - ["robusta-krr", "kube-system"], - ["kube-system", "robusta-frontend", "robusta-backend", "robusta-krr"], - ["robusta-krr", "kube-system"] - ), - ( - # list of namespace provided from arguments with regex pattern and will not duplicating in final result - ["robusta-.*", "robusta-frontend"], - ["kube-system", "robusta-frontend", "robusta-backend", "robusta-krr"], - ["robusta-frontend", "robusta-backend", "robusta-krr"] - ), - ( - # namespace provided with regex pattern and will match for some namespaces - [".*end$"], - ["kube-system", "robusta-frontend", "robusta-backend", "robusta-krr"], - ["robusta-frontend", "robusta-backend"] - ) - ] - ) + "setting_namespaces,cluster_all_ns,expected", + [ + ( + # default settings + "*", + ["kube-system", "robusta-frontend", "robusta-backend", "infra-grafana"], + "*", + ), + ( + # list of namespace provided from arguments without regex pattern + ["robusta-krr", "kube-system"], + ["kube-system", "robusta-frontend", "robusta-backend", "robusta-krr"], + ["robusta-krr", "kube-system"], + ), + ( + # list of namespace provided from arguments with regex pattern and will not duplicating in final result + ["robusta-.*", "robusta-frontend"], + ["kube-system", "robusta-frontend", "robusta-backend", "robusta-krr"], + ["robusta-frontend", "robusta-backend", "robusta-krr"], + ), + ( + # namespace provided with regex pattern and will match for some namespaces + [".*end$"], + ["kube-system", "robusta-frontend", "robusta-backend", "robusta-krr"], + ["robusta-frontend", "robusta-backend"], + ), + ], +) def test_cluster_namespace_list( - setting_namespaces: Union[Literal["*"], list[str]], - cluster_all_ns: list[str], - expected: Union[Literal["*"], list[str]], - ): + setting_namespaces: Union[Literal["*"], list[str]], + cluster_all_ns: list[str], + expected: Union[Literal["*"], list[str]], +): cluster = ClusterLoader() with patch("robusta_krr.core.models.config.settings.namespaces", setting_namespaces): - with patch.object(cluster.core, "list_namespace", return_value=MagicMock( - items=[MagicMock(**{"metadata.name": m}) for m in cluster_all_ns])): + with patch.object( + cluster.core, + "list_namespace", + return_value=MagicMock(items=[MagicMock(**{"metadata.name": m}) for m in cluster_all_ns]), + ): assert sorted(cluster.namespaces) == sorted(expected) From 8085db4a5415e72ccab4036b81e890d79cd0e162 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Apr 2026 11:58:48 +0000 Subject: [PATCH 3/3] Clean up lint findings exposed by black 26 reformat - Ignore E704 in flake8: black 26 collapses abstract method stubs (def foo(): ...) onto one line; E704 conflicts with that style. - Drop unused imports RecommendationValue and NAN_LITERAL in formatters/table.py. - Replace unnecessary f-string with plain triple-quoted string in strategies/simple.py (F541). --- .flake8 | 2 +- robusta_krr/formatters/table.py | 2 -- robusta_krr/strategies/simple.py | 2 +- 3 files changed, 2 insertions(+), 4 deletions(-) diff --git a/.flake8 b/.flake8 index 60991418..7cfc09e5 100644 --- a/.flake8 +++ b/.flake8 @@ -12,4 +12,4 @@ exclude = .git, .mypy_cache, src/robusta/integrations/kubernetes/autogenerated, src/robusta/integrations/kubernetes/custom_models.py -ignore = E501, W503, E203 +ignore = E501, W503, E203, E704 diff --git a/robusta_krr/formatters/table.py b/robusta_krr/formatters/table.py index 85d20f03..1c6b998f 100644 --- a/robusta_krr/formatters/table.py +++ b/robusta_krr/formatters/table.py @@ -5,11 +5,9 @@ from robusta_krr.core.abstract import formatters from robusta_krr.core.models.allocations import ( - RecommendationValue, format_recommendation_value, format_diff, NONE_LITERAL, - NAN_LITERAL, ) from robusta_krr.core.models.result import ResourceScan, ResourceType, Result from robusta_krr.core.models.config import settings diff --git a/robusta_krr/strategies/simple.py b/robusta_krr/strategies/simple.py index e36cca65..65626f45 100644 --- a/robusta_krr/strategies/simple.py +++ b/robusta_krr/strategies/simple.py @@ -101,7 +101,7 @@ def description(self): """) if not self.settings.allow_hpa: - s += "\n" + textwrap.dedent(f"""\ + s += "\n" + textwrap.dedent("""\ This strategy does not work with objects with HPA defined (Horizontal Pod Autoscaler). If HPA is defined for CPU or Memory, the strategy will return "?" for that resource. You can override this behaviour by passing the --allow-hpa flag