From fe298fd570eb8acf30a6d82d97febb2db3b72bb5 Mon Sep 17 00:00:00 2001 From: Balaji Venkatesan Date: Mon, 15 Jun 2026 05:29:01 +0530 Subject: [PATCH 01/23] Updated for UV --- .github/workflows/python-test.yml | 16 +- DEVELOPMENT.md | 42 +++ pyproject.toml | 7 +- uv.lock | 544 ++++++++++++++++++++++++++++++ 4 files changed, 600 insertions(+), 9 deletions(-) create mode 100644 DEVELOPMENT.md create mode 100644 uv.lock diff --git a/.github/workflows/python-test.yml b/.github/workflows/python-test.yml index b4eb2dc..142b4ea 100644 --- a/.github/workflows/python-test.yml +++ b/.github/workflows/python-test.yml @@ -12,16 +12,16 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout code - uses: actions/checkout@v2 - - name: Set up Python - uses: actions/setup-python@v2 + uses: actions/checkout@v4 + - name: Install uv + uses: astral-sh/setup-uv@v5 with: - python-version: '3.10' + enable-cache: true + - name: Set up Python + run: uv python install 3.10 - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install . + run: uv sync --frozen - name: Run tests run: | cd tests/release/integration - python -m unittest test_wrapper.py + uv run --project ${{ github.workspace }} python -m unittest test_wrapper.py diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md new file mode 100644 index 0000000..59b071f --- /dev/null +++ b/DEVELOPMENT.md @@ -0,0 +1,42 @@ +# Development + +This project uses [uv](https://docs.astral.sh/uv/) for dependency management and packaging. + +## Prerequisites + +Install uv (see the [official installation guide](https://docs.astral.sh/uv/getting-started/installation/)): + +```sh +# macOS / Linux +curl -LsSf https://astral.sh/uv/install.sh | sh + +# Windows (PowerShell) +powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex" +``` + +## Common tasks + +```sh +# Install dependencies (creates a virtual environment and installs dev tools) +uv sync + +# Run the tests +cd tests/release/integration +uv run python -m unittest test_wrapper.py + +# Build the distribution +uv build +``` + +## Dependency management + +```sh +# Add a runtime dependency +uv add + +# Add a development-only dependency +uv add --dev + +# Update the lockfile +uv lock +``` diff --git a/pyproject.toml b/pyproject.toml index 8813d11..4bcad45 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [tool.hatch.build] exclude = [ - ".gitignore", "tests/*", ".github/*" + ".gitignore", "tests/*", ".github/*", "DEVELOPMENT.md" ] [tool.hatch.build.targets.wheel] @@ -42,3 +42,8 @@ classifiers = [ [project.urls] Homepage = "https://digital.ai/" Documentation = "https://docs.digital.ai/release/docs/category/python-sdk" + +[dependency-groups] +dev = [ + "pytest>=8.0.0", +] diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..368eb2c --- /dev/null +++ b/uv.lock @@ -0,0 +1,544 @@ +version = 1 +revision = 1 +requires-python = ">=3.10" + +[[package]] +name = "certifi" +version = "2026.5.20" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/ce/ee2ecad540810a79593028e88299baeae54d346cc7a0d94b6199988b89b1/certifi-2026.5.20.tar.gz", hash = "sha256:69dea482ab64caa7b9f6aba1c6bf48bb6a5448d1c0f1b17ab42ad8c763a5344d", size = 135422 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/59/8c/57e832b7af6d7c5abe66eb3fbe3a3a32f4d11ea23a1aa7131371035be991/certifi-2026.5.20-py3-none-any.whl", hash = "sha256:3c52e209ba0a4ad7aebe60436a4ab349c39e1e602e8c134221e546902ad25897", size = 134134 }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/26/08/0f303cb0b529e456bb116f2d50565a482694fbb94340bf56d44677e7ed03/charset_normalizer-3.4.7-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cdd68a1fb318e290a2077696b7eb7a21a49163c455979c639bf5a5dcdc46617d", size = 315182 }, + { url = "https://files.pythonhosted.org/packages/24/47/b192933e94b546f1b1fe4df9cc1f84fcdbf2359f8d1081d46dd029b50207/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e17b8d5d6a8c47c85e68ca8379def1303fd360c3e22093a807cd34a71cd082b8", size = 209329 }, + { url = "https://files.pythonhosted.org/packages/c2/b4/01fa81c5ca6141024d89a8fc15968002b71da7f825dd14113207113fabbd/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:511ef87c8aec0783e08ac18565a16d435372bc1ac25a91e6ac7f5ef2b0bff790", size = 231230 }, + { url = "https://files.pythonhosted.org/packages/20/f7/7b991776844dfa058017e600e6e55ff01984a063290ca5622c0b63162f68/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:007d05ec7321d12a40227aae9e2bc6dca73f3cb21058999a1df9e193555a9dcc", size = 225890 }, + { url = "https://files.pythonhosted.org/packages/20/e7/bed0024a0f4ab0c8a9c64d4445f39b30c99bd1acd228291959e3de664247/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf29836da5119f3c8a8a70667b0ef5fdca3bb12f80fd06487cfa575b3909b393", size = 216930 }, + { url = "https://files.pythonhosted.org/packages/e2/ab/b18f0ab31cdd7b3ddb8bb76c4a414aeb8160c9810fdf1bc62f269a539d87/charset_normalizer-3.4.7-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:12d8baf840cc7889b37c7c770f478adea7adce3dcb3944d02ec87508e2dcf153", size = 202109 }, + { url = "https://files.pythonhosted.org/packages/82/e5/7e9440768a06dfb3075936490cb82dbf0ee20a133bf0dd8551fa096914ec/charset_normalizer-3.4.7-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d560742f3c0d62afaccf9f41fe485ed69bd7661a241f86a3ef0f0fb8b1a397af", size = 214684 }, + { url = "https://files.pythonhosted.org/packages/71/94/8c61d8da9f062fdf457c80acfa25060ec22bf1d34bbeaca4350f13bcfd07/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b14b2d9dac08e28bb8046a1a0434b1750eb221c8f5b87a68f4fa11a6f97b5e34", size = 212785 }, + { url = "https://files.pythonhosted.org/packages/66/cd/6e9889c648e72c0ab2e5967528bb83508f354d706637bc7097190c874e13/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:bc17a677b21b3502a21f66a8cc64f5bfad4df8a0b8434d661666f8ce90ac3af1", size = 203055 }, + { url = "https://files.pythonhosted.org/packages/92/2e/7a951d6a08aefb7eb8e1b54cdfb580b1365afdd9dd484dc4bee9e5d8f258/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:750e02e074872a3fad7f233b47734166440af3cdea0add3e95163110816d6752", size = 232502 }, + { url = "https://files.pythonhosted.org/packages/58/d5/abcf2d83bf8e0a1286df55cd0dc1d49af0da4282aa77e986df343e7de124/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:4e5163c14bffd570ef2affbfdd77bba66383890797df43dc8b4cc7d6f500bf53", size = 214295 }, + { url = "https://files.pythonhosted.org/packages/47/3a/7d4cd7ed54be99973a0dc176032cba5cb1f258082c31fa6df35cff46acfc/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:6ed74185b2db44f41ef35fd1617c5888e59792da9bbc9190d6c7300617182616", size = 227145 }, + { url = "https://files.pythonhosted.org/packages/1d/98/3a45bf8247889cf28262ebd3d0872edff11565b2a1e3064ccb132db3fbb0/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:94e1885b270625a9a828c9793b4d52a64445299baa1fea5a173bf1d3dd9a1a5a", size = 218884 }, + { url = "https://files.pythonhosted.org/packages/ad/80/2e8b7f8915ed5c9ef13aa828d82738e33888c485b65ebf744d615040c7ea/charset_normalizer-3.4.7-cp310-cp310-win32.whl", hash = "sha256:6785f414ae0f3c733c437e0f3929197934f526d19dfaa75e18fdb4f94c6fb374", size = 148343 }, + { url = "https://files.pythonhosted.org/packages/35/1b/3b8c8c77184af465ee9ad88b5aea46ea6b2e1f7b9dc9502891e37af21e30/charset_normalizer-3.4.7-cp310-cp310-win_amd64.whl", hash = "sha256:6696b7688f54f5af4462118f0bfa7c1621eeb87154f77fa04b9295ce7a8f2943", size = 159174 }, + { url = "https://files.pythonhosted.org/packages/be/c1/feb40dca40dbb21e0a908801782d9288c64fc8d8e562c2098e9994c8c21b/charset_normalizer-3.4.7-cp310-cp310-win_arm64.whl", hash = "sha256:66671f93accb62ed07da56613636f3641f1a12c13046ce91ffc923721f23c008", size = 147805 }, + { url = "https://files.pythonhosted.org/packages/c2/d7/b5b7020a0565c2e9fa8c09f4b5fa6232feb326b8c20081ccded47ea368fd/charset_normalizer-3.4.7-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7641bb8895e77f921102f72833904dcd9901df5d6d72a2ab8f31d04b7e51e4e7", size = 309705 }, + { url = "https://files.pythonhosted.org/packages/5a/53/58c29116c340e5456724ecd2fff4196d236b98f3da97b404bc5e51ac3493/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:202389074300232baeb53ae2569a60901f7efadd4245cf3a3bf0617d60b439d7", size = 206419 }, + { url = "https://files.pythonhosted.org/packages/b2/02/e8146dc6591a37a00e5144c63f29fb7c97a734ea8a111190783c0e60ab63/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:30b8d1d8c52a48c2c5690e152c169b673487a2a58de1ec7393196753063fcd5e", size = 227901 }, + { url = "https://files.pythonhosted.org/packages/fb/73/77486c4cd58f1267bf17db420e930c9afa1b3be3fe8c8b8ebbebc9624359/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:532bc9bf33a68613fd7d65e4b1c71a6a38d7d42604ecf239c77392e9b4e8998c", size = 222742 }, + { url = "https://files.pythonhosted.org/packages/a1/fa/f74eb381a7d94ded44739e9d94de18dc5edc9c17fb8c11f0a6890696c0a9/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2fe249cb4651fd12605b7288b24751d8bfd46d35f12a20b1ba33dea122e690df", size = 214061 }, + { url = "https://files.pythonhosted.org/packages/dc/92/42bd3cefcf7687253fb86694b45f37b733c97f59af3724f356fa92b8c344/charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:65bcd23054beab4d166035cabbc868a09c1a49d1efe458fe8e4361215df40265", size = 199239 }, + { url = "https://files.pythonhosted.org/packages/4c/3d/069e7184e2aa3b3cddc700e3dd267413dc259854adc3380421c805c6a17d/charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:08e721811161356f97b4059a9ba7bafb23ea5ee2255402c42881c214e173c6b4", size = 210173 }, + { url = "https://files.pythonhosted.org/packages/62/51/9d56feb5f2e7074c46f93e0ebdbe61f0848ee246e2f0d89f8e20b89ebb8f/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e060d01aec0a910bdccb8be71faf34e7799ce36950f8294c8bf612cba65a2c9e", size = 209841 }, + { url = "https://files.pythonhosted.org/packages/d2/59/893d8f99cc4c837dda1fe2f1139079703deb9f321aabcb032355de13b6c7/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:38c0109396c4cfc574d502df99742a45c72c08eff0a36158b6f04000043dbf38", size = 200304 }, + { url = "https://files.pythonhosted.org/packages/7d/1d/ee6f3be3464247578d1ed5c46de545ccc3d3ff933695395c402c21fa6b77/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:1c2a768fdd44ee4a9339a9b0b130049139b8ce3c01d2ce09f67f5a68048d477c", size = 229455 }, + { url = "https://files.pythonhosted.org/packages/54/bb/8fb0a946296ea96a488928bdce8ef99023998c48e4713af533e9bb98ef07/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:1a87ca9d5df6fe460483d9a5bbf2b18f620cbed41b432e2bddb686228282d10b", size = 210036 }, + { url = "https://files.pythonhosted.org/packages/9a/bc/015b2387f913749f82afd4fcba07846d05b6d784dd16123cb66860e0237d/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:d635aab80466bc95771bb78d5370e74d36d1fe31467b6b29b8b57b2a3cd7d22c", size = 224739 }, + { url = "https://files.pythonhosted.org/packages/17/ab/63133691f56baae417493cba6b7c641571a2130eb7bceba6773367ab9ec5/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ae196f021b5e7c78e918242d217db021ed2a6ace2bc6ae94c0fc596221c7f58d", size = 216277 }, + { url = "https://files.pythonhosted.org/packages/06/6d/3be70e827977f20db77c12a97e6a9f973631a45b8d186c084527e53e77a4/charset_normalizer-3.4.7-cp311-cp311-win32.whl", hash = "sha256:adb2597b428735679446b46c8badf467b4ca5f5056aae4d51a19f9570301b1ad", size = 147819 }, + { url = "https://files.pythonhosted.org/packages/20/d9/5f67790f06b735d7c7637171bbfd89882ad67201891b7275e51116ed8207/charset_normalizer-3.4.7-cp311-cp311-win_amd64.whl", hash = "sha256:8e385e4267ab76874ae30db04c627faaaf0b509e1ccc11a95b3fc3e83f855c00", size = 159281 }, + { url = "https://files.pythonhosted.org/packages/ca/83/6413f36c5a34afead88ce6f66684d943d91f233d76dd083798f9602b75ae/charset_normalizer-3.4.7-cp311-cp311-win_arm64.whl", hash = "sha256:d4a48e5b3c2a489fae013b7589308a40146ee081f6f509e047e0e096084ceca1", size = 147843 }, + { url = "https://files.pythonhosted.org/packages/0c/eb/4fc8d0a7110eb5fc9cc161723a34a8a6c200ce3b4fbf681bc86feee22308/charset_normalizer-3.4.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:eca9705049ad3c7345d574e3510665cb2cf844c2f2dcfe675332677f081cbd46", size = 311328 }, + { url = "https://files.pythonhosted.org/packages/f8/e3/0fadc706008ac9d7b9b5be6dc767c05f9d3e5df51744ce4cc9605de7b9f4/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6178f72c5508bfc5fd446a5905e698c6212932f25bcdd4b47a757a50605a90e2", size = 208061 }, + { url = "https://files.pythonhosted.org/packages/42/f0/3dd1045c47f4a4604df85ec18ad093912ae1344ac706993aff91d38773a2/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1421b502d83040e6d7fb2fb18dff63957f720da3d77b2fbd3187ceb63755d7b", size = 229031 }, + { url = "https://files.pythonhosted.org/packages/dc/67/675a46eb016118a2fbde5a277a5d15f4f69d5f3f5f338e5ee2f8948fcf43/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:edac0f1ab77644605be2cbba52e6b7f630731fc42b34cb0f634be1a6eface56a", size = 225239 }, + { url = "https://files.pythonhosted.org/packages/4b/f8/d0118a2f5f23b02cd166fa385c60f9b0d4f9194f574e2b31cef350ad7223/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5649fd1c7bade02f320a462fdefd0b4bd3ce036065836d4f42e0de958038e116", size = 216589 }, + { url = "https://files.pythonhosted.org/packages/b1/f1/6d2b0b261b6c4ceef0fcb0d17a01cc5bc53586c2d4796fa04b5c540bc13d/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:203104ed3e428044fd943bc4bf45fa73c0730391f9621e37fe39ecf477b128cb", size = 202733 }, + { url = "https://files.pythonhosted.org/packages/6f/c0/7b1f943f7e87cc3db9626ba17807d042c38645f0a1d4415c7a14afb5591f/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:298930cec56029e05497a76988377cbd7457ba864beeea92ad7e844fe74cd1f1", size = 212652 }, + { url = "https://files.pythonhosted.org/packages/38/dd/5a9ab159fe45c6e72079398f277b7d2b523e7f716acc489726115a910097/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:708838739abf24b2ceb208d0e22403dd018faeef86ddac04319a62ae884c4f15", size = 211229 }, + { url = "https://files.pythonhosted.org/packages/d5/ff/531a1cad5ca855d1c1a8b69cb71abfd6d85c0291580146fda7c82857caa1/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0f7eb884681e3938906ed0434f20c63046eacd0111c4ba96f27b76084cd679f5", size = 203552 }, + { url = "https://files.pythonhosted.org/packages/c1/4c/a5fb52d528a8ca41f7598cb619409ece30a169fbdf9cdce592e53b46c3a6/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4dc1e73c36828f982bfe79fadf5919923f8a6f4df2860804db9a98c48824ce8d", size = 230806 }, + { url = "https://files.pythonhosted.org/packages/59/7a/071feed8124111a32b316b33ae4de83d36923039ef8cf48120266844285b/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:aed52fea0513bac0ccde438c188c8a471c4e0f457c2dd20cdbf6ea7a450046c7", size = 212316 }, + { url = "https://files.pythonhosted.org/packages/fd/35/f7dba3994312d7ba508e041eaac39a36b120f32d4c8662b8814dab876431/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:fea24543955a6a729c45a73fe90e08c743f0b3334bbf3201e6c4bc1b0c7fa464", size = 227274 }, + { url = "https://files.pythonhosted.org/packages/8a/2d/a572df5c9204ab7688ec1edc895a73ebded3b023bb07364710b05dd1c9be/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bb6d88045545b26da47aa879dd4a89a71d1dce0f0e549b1abcb31dfe4a8eac49", size = 218468 }, + { url = "https://files.pythonhosted.org/packages/86/eb/890922a8b03a568ca2f336c36585a4713c55d4d67bf0f0c78924be6315ca/charset_normalizer-3.4.7-cp312-cp312-win32.whl", hash = "sha256:2257141f39fe65a3fdf38aeccae4b953e5f3b3324f4ff0daf9f15b8518666a2c", size = 148460 }, + { url = "https://files.pythonhosted.org/packages/35/d9/0e7dffa06c5ab081f75b1b786f0aefc88365825dfcd0ac544bdb7b2b6853/charset_normalizer-3.4.7-cp312-cp312-win_amd64.whl", hash = "sha256:5ed6ab538499c8644b8a3e18debabcd7ce684f3fa91cf867521a7a0279cab2d6", size = 159330 }, + { url = "https://files.pythonhosted.org/packages/9e/5d/481bcc2a7c88ea6b0878c299547843b2521ccbc40980cb406267088bc701/charset_normalizer-3.4.7-cp312-cp312-win_arm64.whl", hash = "sha256:56be790f86bfb2c98fb742ce566dfb4816e5a83384616ab59c49e0604d49c51d", size = 147828 }, + { url = "https://files.pythonhosted.org/packages/c1/3b/66777e39d3ae1ddc77ee606be4ec6d8cbd4c801f65e5a1b6f2b11b8346dd/charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063", size = 309627 }, + { url = "https://files.pythonhosted.org/packages/2e/4e/b7f84e617b4854ade48a1b7915c8ccfadeba444d2a18c291f696e37f0d3b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c", size = 207008 }, + { url = "https://files.pythonhosted.org/packages/c4/bb/ec73c0257c9e11b268f018f068f5d00aa0ef8c8b09f7753ebd5f2880e248/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66", size = 228303 }, + { url = "https://files.pythonhosted.org/packages/85/fb/32d1f5033484494619f701e719429c69b766bfc4dbc61aa9e9c8c166528b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18", size = 224282 }, + { url = "https://files.pythonhosted.org/packages/fa/07/330e3a0dda4c404d6da83b327270906e9654a24f6c546dc886a0eb0ffb23/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd", size = 215595 }, + { url = "https://files.pythonhosted.org/packages/e3/7c/fc890655786e423f02556e0216d4b8c6bcb6bdfa890160dc66bf52dee468/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215", size = 201986 }, + { url = "https://files.pythonhosted.org/packages/d8/97/bfb18b3db2aed3b90cf54dc292ad79fdd5ad65c4eae454099475cbeadd0d/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859", size = 211711 }, + { url = "https://files.pythonhosted.org/packages/6f/a5/a581c13798546a7fd557c82614a5c65a13df2157e9ad6373166d2a3e645d/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8", size = 210036 }, + { url = "https://files.pythonhosted.org/packages/8c/bf/b3ab5bcb478e4193d517644b0fb2bf5497fbceeaa7a1bc0f4d5b50953861/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5", size = 202998 }, + { url = "https://files.pythonhosted.org/packages/e7/4e/23efd79b65d314fa320ec6017b4b5834d5c12a58ba4610aa353af2e2f577/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832", size = 230056 }, + { url = "https://files.pythonhosted.org/packages/b9/9f/1e1941bc3f0e01df116e68dc37a55c4d249df5e6fa77f008841aef68264f/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6", size = 211537 }, + { url = "https://files.pythonhosted.org/packages/80/0f/088cbb3020d44428964a6c97fe1edfb1b9550396bf6d278330281e8b709c/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48", size = 226176 }, + { url = "https://files.pythonhosted.org/packages/6a/9f/130394f9bbe06f4f63e22641d32fc9b202b7e251c9aef4db044324dac493/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a", size = 217723 }, + { url = "https://files.pythonhosted.org/packages/73/55/c469897448a06e49f8fa03f6caae97074fde823f432a98f979cc42b90e69/charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e", size = 148085 }, + { url = "https://files.pythonhosted.org/packages/5d/78/1b74c5bbb3f99b77a1715c91b3e0b5bdb6fe302d95ace4f5b1bec37b0167/charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110", size = 158819 }, + { url = "https://files.pythonhosted.org/packages/68/86/46bd42279d323deb8687c4a5a811fd548cb7d1de10cf6535d099877a9a9f/charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b", size = 147915 }, + { url = "https://files.pythonhosted.org/packages/97/c8/c67cb8c70e19ef1960b97b22ed2a1567711de46c4ddf19799923adc836c2/charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0", size = 309234 }, + { url = "https://files.pythonhosted.org/packages/99/85/c091fdee33f20de70d6c8b522743b6f831a2f1cd3ff86de4c6a827c48a76/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a", size = 208042 }, + { url = "https://files.pythonhosted.org/packages/87/1c/ab2ce611b984d2fd5d86a5a8a19c1ae26acac6bad967da4967562c75114d/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b", size = 228706 }, + { url = "https://files.pythonhosted.org/packages/a8/29/2b1d2cb00bf085f59d29eb773ce58ec2d325430f8c216804a0a5cd83cbca/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41", size = 224727 }, + { url = "https://files.pythonhosted.org/packages/47/5c/032c2d5a07fe4d4855fea851209cca2b6f03ebeb6d4e3afdb3358386a684/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e", size = 215882 }, + { url = "https://files.pythonhosted.org/packages/2c/c2/356065d5a8b78ed04499cae5f339f091946a6a74f91e03476c33f0ab7100/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae", size = 200860 }, + { url = "https://files.pythonhosted.org/packages/0c/cd/a32a84217ced5039f53b29f460962abb2d4420def55afabe45b1c3c7483d/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18", size = 211564 }, + { url = "https://files.pythonhosted.org/packages/44/86/58e6f13ce26cc3b8f4a36b94a0f22ae2f00a72534520f4ae6857c4b81f89/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b", size = 211276 }, + { url = "https://files.pythonhosted.org/packages/8f/fe/d17c32dc72e17e155e06883efa84514ca375f8a528ba2546bee73fc4df81/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356", size = 201238 }, + { url = "https://files.pythonhosted.org/packages/6a/29/f33daa50b06525a237451cdb6c69da366c381a3dadcd833fa5676bc468b3/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab", size = 230189 }, + { url = "https://files.pythonhosted.org/packages/b6/6e/52c84015394a6a0bdcd435210a7e944c5f94ea1055f5cc5d56c5fe368e7b/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46", size = 211352 }, + { url = "https://files.pythonhosted.org/packages/8c/d7/4353be581b373033fb9198bf1da3cf8f09c1082561e8e922aa7b39bf9fe8/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44", size = 227024 }, + { url = "https://files.pythonhosted.org/packages/30/45/99d18aa925bd1740098ccd3060e238e21115fffbfdcb8f3ece837d0ace6c/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72", size = 217869 }, + { url = "https://files.pythonhosted.org/packages/5c/05/5ee478aa53f4bb7996482153d4bfe1b89e0f087f0ab6b294fcf92d595873/charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10", size = 148541 }, + { url = "https://files.pythonhosted.org/packages/48/77/72dcb0921b2ce86420b2d79d454c7022bf5be40202a2a07906b9f2a35c97/charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f", size = 159634 }, + { url = "https://files.pythonhosted.org/packages/c6/a3/c2369911cd72f02386e4e340770f6e158c7980267da16af8f668217abaa0/charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246", size = 148384 }, + { url = "https://files.pythonhosted.org/packages/94/09/7e8a7f73d24dba1f0035fbbf014d2c36828fc1bf9c88f84093e57d315935/charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24", size = 330133 }, + { url = "https://files.pythonhosted.org/packages/8d/da/96975ddb11f8e977f706f45cddd8540fd8242f71ecdb5d18a80723dcf62c/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79", size = 216257 }, + { url = "https://files.pythonhosted.org/packages/e5/e8/1d63bf8ef2d388e95c64b2098f45f84758f6d102a087552da1485912637b/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960", size = 234851 }, + { url = "https://files.pythonhosted.org/packages/9b/40/e5ff04233e70da2681fa43969ad6f66ca5611d7e669be0246c4c7aaf6dc8/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4", size = 233393 }, + { url = "https://files.pythonhosted.org/packages/be/c1/06c6c49d5a5450f76899992f1ee40b41d076aee9279b49cf9974d2f313d5/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e", size = 223251 }, + { url = "https://files.pythonhosted.org/packages/2b/9f/f2ff16fb050946169e3e1f82134d107e5d4ae72647ec8a1b1446c148480f/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1", size = 206609 }, + { url = "https://files.pythonhosted.org/packages/69/d5/a527c0cd8d64d2eab7459784fb4169a0ac76e5a6fc5237337982fd61347e/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44", size = 220014 }, + { url = "https://files.pythonhosted.org/packages/7e/80/8a7b8104a3e203074dc9aa2c613d4b726c0e136bad1cc734594b02867972/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e", size = 218979 }, + { url = "https://files.pythonhosted.org/packages/02/9a/b759b503d507f375b2b5c153e4d2ee0a75aa215b7f2489cf314f4541f2c0/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3", size = 209238 }, + { url = "https://files.pythonhosted.org/packages/c2/4e/0f3f5d47b86bdb79256e7290b26ac847a2832d9a4033f7eb2cd4bcf4bb5b/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0", size = 236110 }, + { url = "https://files.pythonhosted.org/packages/96/23/bce28734eb3ed2c91dcf93abeb8a5cf393a7b2749725030bb630e554fdd8/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e", size = 219824 }, + { url = "https://files.pythonhosted.org/packages/2c/6f/6e897c6984cc4d41af319b077f2f600fc8214eb2fe2d6bcb79141b882400/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb", size = 233103 }, + { url = "https://files.pythonhosted.org/packages/76/22/ef7bd0fe480a0ae9b656189ec00744b60933f68b4f42a7bb06589f6f576a/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe", size = 225194 }, + { url = "https://files.pythonhosted.org/packages/c5/a7/0e0ab3e0b5bc1219bd80a6a0d4d72ca74d9250cb2382b7c699c147e06017/charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0", size = 159827 }, + { url = "https://files.pythonhosted.org/packages/7a/1d/29d32e0fb40864b1f878c7f5a0b343ae676c6e2b271a2d55cc3a152391da/charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c", size = 174168 }, + { url = "https://files.pythonhosted.org/packages/de/32/d92444ad05c7a6e41fb2036749777c163baf7a0301a040cb672d6b2b1ae9/charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d", size = 153018 }, + { url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958 }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 }, +] + +[[package]] +name = "dataclasses-json" +version = "0.6.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "marshmallow" }, + { name = "typing-inspect" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/64/a4/f71d9cf3a5ac257c993b5ca3f93df5f7fb395c725e7f1e6479d2514173c3/dataclasses_json-0.6.7.tar.gz", hash = "sha256:b6b3e528266ea45b9535223bc53ca645f5208833c29229e847b3f26a1cc55fc0", size = 32227 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c3/be/d0d44e092656fe7a06b55e6103cbce807cdbdee17884a5367c68c9860853/dataclasses_json-0.6.7-py3-none-any.whl", hash = "sha256:0dbf33f26c8d5305befd61b39d2b3414e8a407bedc2834dea9b8d642666fb40a", size = 28686 }, +] + +[[package]] +name = "digitalai-release-sdk" +version = "26.1.0" +source = { editable = "." } +dependencies = [ + { name = "dataclasses-json" }, + { name = "kubernetes" }, + { name = "pycryptodomex" }, + { name = "python-dateutil" }, + { name = "requests" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pytest" }, +] + +[package.metadata] +requires-dist = [ + { name = "dataclasses-json", specifier = ">=0.6.7,<1.0.0" }, + { name = "kubernetes", specifier = ">=35.0.0,<36.0.0" }, + { name = "pycryptodomex", specifier = ">=3.23.0,<4.0.0" }, + { name = "python-dateutil", specifier = ">=2.9.0,<3.0.0" }, + { name = "requests", specifier = ">=2.32.5,<3.0.0" }, +] + +[package.metadata.requires-dev] +dev = [{ name = "pytest", specifier = ">=8.0.0" }] + +[[package]] +name = "durationpy" +version = "0.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9d/a4/e44218c2b394e31a6dd0d6b095c4e1f32d0be54c2a4b250032d717647bab/durationpy-0.10.tar.gz", hash = "sha256:1fa6893409a6e739c9c72334fc65cca1f355dbdd93405d30f726deb5bde42fba", size = 3335 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b0/0d/9feae160378a3553fa9a339b0e9c1a048e147a4127210e286ef18b730f03/durationpy-0.10-py3-none-any.whl", hash = "sha256:3b41e1b601234296b4fb368338fdcd3e13e0b4fb5b67345948f4f2bf9868b286", size = 3922 }, +] + +[[package]] +name = "exceptiongroup" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740 }, +] + +[[package]] +name = "idna" +version = "3.18" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455 }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484 }, +] + +[[package]] +name = "kubernetes" +version = "35.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "durationpy" }, + { name = "python-dateutil" }, + { name = "pyyaml" }, + { name = "requests" }, + { name = "requests-oauthlib" }, + { name = "six" }, + { name = "urllib3" }, + { name = "websocket-client" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2c/8f/85bf51ad4150f64e8c665daf0d9dfe9787ae92005efb9a4d1cba592bd79d/kubernetes-35.0.0.tar.gz", hash = "sha256:3d00d344944239821458b9efd484d6df9f011da367ecb155dadf9513f05f09ee", size = 1094642 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/70/05b685ea2dffcb2adbf3cdcea5d8865b7bc66f67249084cf845012a0ff13/kubernetes-35.0.0-py2.py3-none-any.whl", hash = "sha256:39e2b33b46e5834ef6c3985ebfe2047ab39135d41de51ce7641a7ca5b372a13d", size = 2017602 }, +] + +[[package]] +name = "marshmallow" +version = "3.26.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/79/de6c16cc902f4fc372236926b0ce2ab7845268dcc30fb2fbb7f71b418631/marshmallow-3.26.2.tar.gz", hash = "sha256:bbe2adb5a03e6e3571b573f42527c6fe926e17467833660bebd11593ab8dfd57", size = 222095 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/be/2f/5108cb3ee4ba6501748c4908b908e55f42a5b66245b4cfe0c99326e1ef6e/marshmallow-3.26.2-py3-none-any.whl", hash = "sha256:013fa8a3c4c276c24d26d84ce934dc964e2aa794345a0f8c7e5a7191482c8a73", size = 50964 }, +] + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963 }, +] + +[[package]] +name = "oauthlib" +version = "3.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0b/5f/19930f824ffeb0ad4372da4812c50edbd1434f678c90c2733e1188edfc63/oauthlib-3.3.1.tar.gz", hash = "sha256:0f0f8aa759826a193cf66c12ea1af1637f87b9b4622d46e866952bb022e538c9", size = 185918 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/be/9c/92789c596b8df838baa98fa71844d84283302f7604ed565dafe5a6b5041a/oauthlib-3.3.1-py3-none-any.whl", hash = "sha256:88119c938d2b8fb88561af5f6ee0eec8cc8d552b7bb1f712743136eb7523b7a1", size = 160065 }, +] + +[[package]] +name = "packaging" +version = "26.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195 }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538 }, +] + +[[package]] +name = "pycryptodomex" +version = "3.23.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c9/85/e24bf90972a30b0fcd16c73009add1d7d7cd9140c2498a68252028899e41/pycryptodomex-3.23.0.tar.gz", hash = "sha256:71909758f010c82bc99b0abf4ea12012c98962fbf0583c2164f8b84533c2e4da", size = 4922157 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2e/00/10edb04777069a42490a38c137099d4b17ba6e36a4e6e28bdc7470e9e853/pycryptodomex-3.23.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:7b37e08e3871efe2187bc1fd9320cc81d87caf19816c648f24443483005ff886", size = 2498764 }, + { url = "https://files.pythonhosted.org/packages/6b/3f/2872a9c2d3a27eac094f9ceaa5a8a483b774ae69018040ea3240d5b11154/pycryptodomex-3.23.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:91979028227543010d7b2ba2471cf1d1e398b3f183cb105ac584df0c36dac28d", size = 1643012 }, + { url = "https://files.pythonhosted.org/packages/70/af/774c2e2b4f6570fbf6a4972161adbb183aeeaa1863bde31e8706f123bf92/pycryptodomex-3.23.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6b8962204c47464d5c1c4038abeadd4514a133b28748bcd9fa5b6d62e3cec6fa", size = 2187643 }, + { url = "https://files.pythonhosted.org/packages/de/a3/71065b24cb889d537954cedc3ae5466af00a2cabcff8e29b73be047e9a19/pycryptodomex-3.23.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a33986a0066860f7fcf7c7bd2bc804fa90e434183645595ae7b33d01f3c91ed8", size = 2273762 }, + { url = "https://files.pythonhosted.org/packages/c9/0b/ff6f43b7fbef4d302c8b981fe58467b8871902cdc3eb28896b52421422cc/pycryptodomex-3.23.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c7947ab8d589e3178da3d7cdeabe14f841b391e17046954f2fbcd941705762b5", size = 2313012 }, + { url = "https://files.pythonhosted.org/packages/02/de/9d4772c0506ab6da10b41159493657105d3f8bb5c53615d19452afc6b315/pycryptodomex-3.23.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c25e30a20e1b426e1f0fa00131c516f16e474204eee1139d1603e132acffc314", size = 2186856 }, + { url = "https://files.pythonhosted.org/packages/28/ad/8b30efcd6341707a234e5eba5493700a17852ca1ac7a75daa7945fcf6427/pycryptodomex-3.23.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:da4fa650cef02db88c2b98acc5434461e027dce0ae8c22dd5a69013eaf510006", size = 2347523 }, + { url = "https://files.pythonhosted.org/packages/0f/02/16868e9f655b7670dbb0ac4f2844145cbc42251f916fc35c414ad2359849/pycryptodomex-3.23.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:58b851b9effd0d072d4ca2e4542bf2a4abcf13c82a29fd2c93ce27ee2a2e9462", size = 2272825 }, + { url = "https://files.pythonhosted.org/packages/ca/18/4ca89ac737230b52ac8ffaca42f9c6f1fd07c81a6cd821e91af79db60632/pycryptodomex-3.23.0-cp313-cp313t-win32.whl", hash = "sha256:a9d446e844f08299236780f2efa9898c818fe7e02f17263866b8550c7d5fb328", size = 1772078 }, + { url = "https://files.pythonhosted.org/packages/73/34/13e01c322db027682e00986873eca803f11c56ade9ba5bbf3225841ea2d4/pycryptodomex-3.23.0-cp313-cp313t-win_amd64.whl", hash = "sha256:bc65bdd9fc8de7a35a74cab1c898cab391a4add33a8fe740bda00f5976ca4708", size = 1803656 }, + { url = "https://files.pythonhosted.org/packages/54/68/9504c8796b1805d58f4425002bcca20f12880e6fa4dc2fc9a668705c7a08/pycryptodomex-3.23.0-cp313-cp313t-win_arm64.whl", hash = "sha256:c885da45e70139464f082018ac527fdaad26f1657a99ee13eecdce0f0ca24ab4", size = 1707172 }, + { url = "https://files.pythonhosted.org/packages/dd/9c/1a8f35daa39784ed8adf93a694e7e5dc15c23c741bbda06e1d45f8979e9e/pycryptodomex-3.23.0-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:06698f957fe1ab229a99ba2defeeae1c09af185baa909a31a5d1f9d42b1aaed6", size = 2499240 }, + { url = "https://files.pythonhosted.org/packages/7a/62/f5221a191a97157d240cf6643747558759126c76ee92f29a3f4aee3197a5/pycryptodomex-3.23.0-cp37-abi3-macosx_10_9_x86_64.whl", hash = "sha256:b2c2537863eccef2d41061e82a881dcabb04944c5c06c5aa7110b577cc487545", size = 1644042 }, + { url = "https://files.pythonhosted.org/packages/8c/fd/5a054543c8988d4ed7b612721d7e78a4b9bf36bc3c5ad45ef45c22d0060e/pycryptodomex-3.23.0-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:43c446e2ba8df8889e0e16f02211c25b4934898384c1ec1ec04d7889c0333587", size = 2186227 }, + { url = "https://files.pythonhosted.org/packages/c8/a9/8862616a85cf450d2822dbd4fff1fcaba90877907a6ff5bc2672cafe42f8/pycryptodomex-3.23.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f489c4765093fb60e2edafdf223397bc716491b2b69fe74367b70d6999257a5c", size = 2272578 }, + { url = "https://files.pythonhosted.org/packages/46/9f/bda9c49a7c1842820de674ab36c79f4fbeeee03f8ff0e4f3546c3889076b/pycryptodomex-3.23.0-cp37-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bdc69d0d3d989a1029df0eed67cc5e8e5d968f3724f4519bd03e0ec68df7543c", size = 2312166 }, + { url = "https://files.pythonhosted.org/packages/03/cc/870b9bf8ca92866ca0186534801cf8d20554ad2a76ca959538041b7a7cf4/pycryptodomex-3.23.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:6bbcb1dd0f646484939e142462d9e532482bc74475cecf9c4903d4e1cd21f003", size = 2185467 }, + { url = "https://files.pythonhosted.org/packages/96/e3/ce9348236d8e669fea5dd82a90e86be48b9c341210f44e25443162aba187/pycryptodomex-3.23.0-cp37-abi3-musllinux_1_2_i686.whl", hash = "sha256:8a4fcd42ccb04c31268d1efeecfccfd1249612b4de6374205376b8f280321744", size = 2346104 }, + { url = "https://files.pythonhosted.org/packages/a5/e9/e869bcee87beb89040263c416a8a50204f7f7a83ac11897646c9e71e0daf/pycryptodomex-3.23.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:55ccbe27f049743a4caf4f4221b166560d3438d0b1e5ab929e07ae1702a4d6fd", size = 2271038 }, + { url = "https://files.pythonhosted.org/packages/8d/67/09ee8500dd22614af5fbaa51a4aee6e342b5fa8aecf0a6cb9cbf52fa6d45/pycryptodomex-3.23.0-cp37-abi3-win32.whl", hash = "sha256:189afbc87f0b9f158386bf051f720e20fa6145975f1e76369303d0f31d1a8d7c", size = 1771969 }, + { url = "https://files.pythonhosted.org/packages/69/96/11f36f71a865dd6df03716d33bd07a67e9d20f6b8d39820470b766af323c/pycryptodomex-3.23.0-cp37-abi3-win_amd64.whl", hash = "sha256:52e5ca58c3a0b0bd5e100a9fbc8015059b05cffc6c66ce9d98b4b45e023443b9", size = 1803124 }, + { url = "https://files.pythonhosted.org/packages/f9/93/45c1cdcbeb182ccd2e144c693eaa097763b08b38cded279f0053ed53c553/pycryptodomex-3.23.0-cp37-abi3-win_arm64.whl", hash = "sha256:02d87b80778c171445d67e23d1caef279bf4b25c3597050ccd2e13970b57fd51", size = 1707161 }, + { url = "https://files.pythonhosted.org/packages/f3/b8/3e76d948c3c4ac71335bbe75dac53e154b40b0f8f1f022dfa295257a0c96/pycryptodomex-3.23.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:ebfff755c360d674306e5891c564a274a47953562b42fb74a5c25b8fc1fb1cb5", size = 1627695 }, + { url = "https://files.pythonhosted.org/packages/6a/cf/80f4297a4820dfdfd1c88cf6c4666a200f204b3488103d027b5edd9176ec/pycryptodomex-3.23.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:eca54f4bb349d45afc17e3011ed4264ef1cc9e266699874cdd1349c504e64798", size = 1675772 }, + { url = "https://files.pythonhosted.org/packages/d1/42/1e969ee0ad19fe3134b0e1b856c39bd0b70d47a4d0e81c2a8b05727394c9/pycryptodomex-3.23.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4f2596e643d4365e14d0879dc5aafe6355616c61c2176009270f3048f6d9a61f", size = 1668083 }, + { url = "https://files.pythonhosted.org/packages/6e/c3/1de4f7631fea8a992a44ba632aa40e0008764c0fb9bf2854b0acf78c2cf2/pycryptodomex-3.23.0-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fdfac7cda115bca3a5abb2f9e43bc2fb66c2b65ab074913643803ca7083a79ea", size = 1706056 }, + { url = "https://files.pythonhosted.org/packages/f2/5f/af7da8e6f1e42b52f44a24d08b8e4c726207434e2593732d39e7af5e7256/pycryptodomex-3.23.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:14c37aaece158d0ace436f76a7bb19093db3b4deade9797abfc39ec6cd6cc2fe", size = 1806478 }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151 }, +] + +[[package]] +name = "pytest" +version = "9.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/84/0e/b5858858d74958632c49b72cb25a3976ff9f632397626715be71c89d3971/pytest-9.1.0.tar.gz", hash = "sha256:41dd9148c08072446394cefd3d79701701335a9f4cae69ba92e39f6c7f5c061c", size = 1634181 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8b/5a/ba30a81239b909821b3153e303e7def45178bf353da4f72380e6c5e8793b/pytest-9.1.0-py3-none-any.whl", hash = "sha256:8ebb0e7888bdf2bdfc602ec51f8f62d50200af37356c74e503c79a94f5c81f32", size = 386453 }, +] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892 }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/a0/39350dd17dd6d6c6507025c0e53aef67a9293a6d37d3511f23ea510d5800/pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b", size = 184227 }, + { url = "https://files.pythonhosted.org/packages/05/14/52d505b5c59ce73244f59c7a50ecf47093ce4765f116cdb98286a71eeca2/pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956", size = 174019 }, + { url = "https://files.pythonhosted.org/packages/43/f7/0e6a5ae5599c838c696adb4e6330a59f463265bfa1e116cfd1fbb0abaaae/pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8", size = 740646 }, + { url = "https://files.pythonhosted.org/packages/2f/3a/61b9db1d28f00f8fd0ae760459a5c4bf1b941baf714e207b6eb0657d2578/pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198", size = 840793 }, + { url = "https://files.pythonhosted.org/packages/7a/1e/7acc4f0e74c4b3d9531e24739e0ab832a5edf40e64fbae1a9c01941cabd7/pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b", size = 770293 }, + { url = "https://files.pythonhosted.org/packages/8b/ef/abd085f06853af0cd59fa5f913d61a8eab65d7639ff2a658d18a25d6a89d/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0", size = 732872 }, + { url = "https://files.pythonhosted.org/packages/1f/15/2bc9c8faf6450a8b3c9fc5448ed869c599c0a74ba2669772b1f3a0040180/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69", size = 758828 }, + { url = "https://files.pythonhosted.org/packages/a3/00/531e92e88c00f4333ce359e50c19b8d1de9fe8d581b1534e35ccfbc5f393/pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e", size = 142415 }, + { url = "https://files.pythonhosted.org/packages/2a/fa/926c003379b19fca39dd4634818b00dec6c62d87faf628d1394e137354d4/pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c", size = 158561 }, + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826 }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577 }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556 }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114 }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638 }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463 }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986 }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543 }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763 }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063 }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973 }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116 }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011 }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870 }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089 }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181 }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658 }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003 }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344 }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669 }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252 }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081 }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159 }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626 }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613 }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115 }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427 }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090 }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246 }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814 }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809 }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454 }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355 }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175 }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228 }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194 }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429 }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912 }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108 }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641 }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901 }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132 }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261 }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272 }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923 }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062 }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341 }, +] + +[[package]] +name = "requests" +version = "2.34.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075 }, +] + +[[package]] +name = "requests-oauthlib" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "oauthlib" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/42/f2/05f29bc3913aea15eb670be136045bf5c5bbf4b99ecb839da9b422bb2c85/requests-oauthlib-2.0.0.tar.gz", hash = "sha256:b3dffaebd884d8cd778494369603a9e7b58d29111bf6b41bdc2dcd87203af4e9", size = 55650 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/5d/63d4ae3b9daea098d5d6f5da83984853c1bbacd5dc826764b249fe119d24/requests_oauthlib-2.0.0-py2.py3-none-any.whl", hash = "sha256:7dd8a5c40426b779b0868c404bdef9768deccf22749cde15852df527e6269b36", size = 24179 }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050 }, +] + +[[package]] +name = "tomli" +version = "2.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704 }, + { url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454 }, + { url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561 }, + { url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824 }, + { url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227 }, + { url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859 }, + { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204 }, + { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084 }, + { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285 }, + { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924 }, + { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018 }, + { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948 }, + { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341 }, + { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159 }, + { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290 }, + { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141 }, + { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847 }, + { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088 }, + { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866 }, + { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887 }, + { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704 }, + { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628 }, + { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180 }, + { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674 }, + { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976 }, + { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755 }, + { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265 }, + { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726 }, + { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859 }, + { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713 }, + { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084 }, + { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973 }, + { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223 }, + { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973 }, + { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082 }, + { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490 }, + { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263 }, + { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736 }, + { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717 }, + { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461 }, + { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855 }, + { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144 }, + { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683 }, + { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196 }, + { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393 }, + { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583 }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614 }, +] + +[[package]] +name = "typing-inspect" +version = "0.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mypy-extensions" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/dc/74/1789779d91f1961fa9438e9a8710cdae6bd138c80d7303996933d117264a/typing_inspect-0.9.0.tar.gz", hash = "sha256:b23fc42ff6f6ef6954e4852c1fb512cdd18dbea03134f91f856a95ccc9461f78", size = 13825 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/65/f3/107a22063bf27bdccf2024833d3445f4eea42b2e598abfbd46f6a63b6cb0/typing_inspect-0.9.0-py3-none-any.whl", hash = "sha256:9ee6fc59062311ef8547596ab6b955e1b8aa46242d854bfc78f4f6b0eff35f9f", size = 8827 }, +] + +[[package]] +name = "urllib3" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087 }, +] + +[[package]] +name = "websocket-client" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2c/41/aa4bf9664e4cda14c3b39865b12251e8e7d239f4cd0e3cc1b6c2ccde25c1/websocket_client-1.9.0.tar.gz", hash = "sha256:9e813624b6eb619999a97dc7958469217c3176312b3a16a4bd1bc7e08a46ec98", size = 70576 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/34/db/b10e48aa8fff7407e67470363eac595018441cf32d5e1001567a7aeba5d2/websocket_client-1.9.0-py3-none-any.whl", hash = "sha256:af248a825037ef591efbf6ed20cc5faa03d3b47b9e5a2230a529eeee1c1fc3ef", size = 82616 }, +] From 8e7ff14cce34bda94a415da253f522889e98ae27 Mon Sep 17 00:00:00 2001 From: Balaji Venkatesan Date: Mon, 15 Jun 2026 10:13:52 +0530 Subject: [PATCH 02/23] Updated for md file --- ROBUSTNESS_CHANGES.md | 96 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 96 insertions(+) create mode 100644 ROBUSTNESS_CHANGES.md diff --git a/ROBUSTNESS_CHANGES.md b/ROBUSTNESS_CHANGES.md new file mode 100644 index 0000000..1c38884 --- /dev/null +++ b/ROBUSTNESS_CHANGES.md @@ -0,0 +1,96 @@ +# Robustness Improvements — `digitalai/release/integration` + +This document describes the robustness fixes applied across the integration SDK +package and the accompanying test updates. The behaviour exercised by the sample +input context (a `containerExamples.Hello` task) is preserved — the changes +harden failure paths, networking, and diagnostics without altering the happy +path. + +## Sample input context used for verification + +```json +{ + "release": { + "id": "Applications/Folder1f65c7220b394afbb941154342fd9fc6/Release31de09e95c8e4ebb95aaed29a8082d0b", + "automatedTaskAsUser": { "username": null, "password": null } + }, + "task": { + "id": "Applications/Folder1f65c7220b394afbb941154342fd9fc6/Release31de09e95c8e4ebb95aaed29a8082d0b/Phase723a601c78804f7dbcaa8b05b83708f5/Task3a35b67b42b6428b854857fba470b39a", + "type": "containerExamples.Hello", + "properties": [ + { "name": "capabilities", "value": ["remote"], "kind": "SET_OF_STRING", "category": "input", "password": false }, + { "name": "yourName", "value": "World", "kind": "STRING", "category": "input", "password": false }, + { "name": "greeting", "value": null, "kind": "STRING", "category": "output", "password": false } + ] + } +} +``` + +Expected output context: `{"exitCode": 0, "jobErrorMessage": "", "outputProperties": {"greeting": "Hello World"}, "reportingRecords": []}`. + +--- + +## `wrapper.py` + +| # | Change | Reason | +|---|--------|--------| +| 1 | **HTTP timeouts added** to the `requests.get` fetch and all callback POSTs (`HTTP_CONNECT_TIMEOUT` / `HTTP_READ_TIMEOUT`, env-overridable, default 10s/60s). | Without a timeout a hung or unreachable server stalls the runner **forever**, blocking the task slot. | +| 2 | **Single shared `urllib3.PoolManager`** (`_http_pool`) instead of constructing a new `PoolManager()` on every request. | Creating a fresh pool per call defeats connection pooling/keep-alive and leaks sockets. | +| 3 | **Callback HTTP status is now checked.** New `_post_callback()` helper raises on HTTP status `>= 400`. | Previously `urllib3` does not raise on 4xx/5xx, so a failed delivery was silently treated as success and never retried. | +| 4 | **`execute_task` `finally` guarded.** It no longer calls `dai_task_object.get_output_context()` blindly; if the task object is `None` or has no output context, it reports a failure context instead. | If construction or `execute_task` failed before producing an output context, the `finally` block raised a *second* exception that masked the original error. | +| 5 | **Removed dead status check** (`if response.status_code != 200`) after `raise_for_status()`. | `raise_for_status()` already raises on non-2xx; the follow-up check was dead code and would have wrongly rejected `201`/`204`. | +| 6 | **Task type format validated** before `task_type.split(".")[1]`. | A malformed `type` (no `.`) previously raised an opaque `IndexError`; now it raises a clear `ValueError`. | +| 7 | **`find_class_file` hardened** — skips files that can't be read/parsed (`SyntaxError`, `UnicodeDecodeError`, `OSError`) and reads them as UTF-8. | One unreadable or non-UTF-8 `.py` file anywhere under the CWD previously aborted the entire class search. | +| 8 | **`Optional[...]` type hints** for module globals (`input_context`, `dai_task_object`) and return-type hints added. | The previous `: InputContext = None` annotations lied to type checkers/IDEs. | +| 9 | Removed commented-out `#dai_logger.info(...)` dead lines; simplified redundant `not x or len(x) == 0` to `not x`; documented the intentional double-base64 decode of fetch/callback URLs. | Readability and maintainability. | + +## `masked_io.py` + +| # | Change | Reason | +|---|--------|--------| +| 1 | `write()` now **returns the number of characters written** (`len(s)`). | `TextIOBase.write` is contractually required to return an int; libraries (e.g. `print`, logging) may rely on it, and returning `None` can raise `TypeError`. | +| 2 | Secrets coerced with `str(secret)` before `replace`. | Defensive: a non-string secret value would otherwise raise inside `str.replace`. | + +## `k8s.py` + +| # | Change | Reason | +|---|--------|--------| +| 1 | `split_secret_resource_data` now **raises `ValueError`** on empty or malformed input instead of silently returning `("", "", "")`. | The blank tuple only surfaced later as a confusing Kubernetes API error (`read_namespaced_secret("", "")`); failing fast with a clear message is far easier to diagnose. | + +## `watcher.py` + +| # | Change | Reason | +|---|--------|--------| +| 1 | **Validates `INPUT_CONTEXT_SECRET` / `RUNNER_NAMESPACE`** before building the field selector. | If unset, the old code produced `metadata.name=None` and watched the wrong/empty namespace silently. | +| 2 | Watch stream is **always stopped** via `try/finally: w.stop()`; guards `secret.data` being `None`. | Prevents leaking the long-lived streaming connection and a `NoneType` crash on secrets without data. | + +## `base_task.py` + +| # | Change | Reason | +|---|--------|--------| +| 1 | `get_task_user()` returns `None` when there is no release context (instead of raising `AttributeError`); return type is now `Optional[...]`. | Tasks without a "Run as user" context could crash with an opaque attribute error. | +| 2 | `_validate_api_credentials()` handles a missing user safely and still raises the existing clear `ValueError`. | Same root cause — a clearer, intentional error message instead of `AttributeError`. | + +--- + +## Tests + +### `tests/release/integration/test_base_task.py` — rewritten +- **Fixed a broken import.** The file imported `_find_folder_id` / `_phase_id_from` from `base_task`, which no longer exist (id parsing was refactored into `ids.Ids`). The module failed to even collect (`ImportError`). +- Re-pointed id tests at the current `Ids` API and added coverage for `segment_name` / `parent_id` / `is_root`. +- Added `TestBaseTaskOutput`: success/error exit codes, output-property validation, input-property guard, and the new `get_task_user()` / credential-validation behaviour. + +### `tests/release/integration/test_wrapper.py` — rewritten for robustness +- Now **self-contained**: writes the sample input context itself and removes any stale `output.json` in `setUp`. +- Runs the wrapper with an explicit `cwd` and `env` (no longer depends on ambient working directory / global `os.environ` mutation), and uses `sys.executable` instead of bare `python`. +- **Asserts the subprocess exit code** and that `output.json` was produced (the old test ignored the return code, so a crashing wrapper could pass), then compares against the expected output context. + +### `tests/release/integration/input.json` +- Updated to the sample `containerExamples.Hello` input context. + +## Verification + +``` +$ python -m pytest tests/release/integration/test_base_task.py tests/release/integration/test_wrapper.py +14 passed +``` From eb076cbca197a3d1187dd0e7576470b63c5ec389 Mon Sep 17 00:00:00 2001 From: Balaji Venkatesan Date: Mon, 15 Jun 2026 10:39:07 +0530 Subject: [PATCH 03/23] Updated md file --- ROBUSTNESS_CHANGES.md | 25 ++++++++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/ROBUSTNESS_CHANGES.md b/ROBUSTNESS_CHANGES.md index 1c38884..e71de28 100644 --- a/ROBUSTNESS_CHANGES.md +++ b/ROBUSTNESS_CHANGES.md @@ -86,11 +86,30 @@ Expected output context: `{"exitCode": 0, "jobErrorMessage": "", "outputProperti - **Asserts the subprocess exit code** and that `output.json` was produced (the old test ignored the return code, so a crashing wrapper could pass), then compares against the expected output context. ### `tests/release/integration/input.json` -- Updated to the sample `containerExamples.Hello` input context. +- Updated to the sample `containerExamples.Hello` input context (with `automatedTaskAsUser` set to `admin`/`admin`). + +### `tests/release/integration/test_wrapper_k8s.py` — new +End-to-end coverage of the **Kubernetes execution path** (input read from a +Secret, result written to a Secret and pushed to a callback URL) using a fully +mocked Kubernetes client and callback transport — no cluster or network needed. +A blank `session-key` selects the NoOp encryptor so the test stays independent of +AES key material; helpers reproduce the real Secret base64 (and double-base64 +URL) encoding. + +Cases (11): +- **Input from Secret:** decodes the input context, sets `callback_url`, and registers the "Run as user" password for masking. +- **Fetch-URL fallback:** empty `input` triggers an HTTP fetch — and asserts a `timeout` is passed (regression guard for the robustness fix). +- **Missing fetch URL → `ValueError`.** +- **Secret read failure propagates** out of `get_task_details` (so `run()` reports failure). +- **Result written to Secret + callback pushed** to the correctly decoded URL. +- **Result > 1Mb** skips the Secret write but still pushes the callback. +- **Secret write failure is swallowed and logged** (`update_output_context` never raises) and no callback is attempted. +- **Callback retry:** first push fails → `retry_push_result_infinitely` re-reads the Secret and succeeds. +- **`should_retry_callback_request` matrix:** size × output-file presence (3 cases). ## Verification ``` -$ python -m pytest tests/release/integration/test_base_task.py tests/release/integration/test_wrapper.py -14 passed +$ python -m pytest tests/release/integration/ +23 passed ``` From 731604b8e428ab60a28a610ad1c7e86ac0c45524 Mon Sep 17 00:00:00 2001 From: Balaji Venkatesan Date: Mon, 15 Jun 2026 10:42:48 +0530 Subject: [PATCH 04/23] Updated the md file --- ROBUSTNESS_CHANGES.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/ROBUSTNESS_CHANGES.md b/ROBUSTNESS_CHANGES.md index e71de28..228ff81 100644 --- a/ROBUSTNESS_CHANGES.md +++ b/ROBUSTNESS_CHANGES.md @@ -79,6 +79,7 @@ Expected output context: `{"exitCode": 0, "jobErrorMessage": "", "outputProperti - **Fixed a broken import.** The file imported `_find_folder_id` / `_phase_id_from` from `base_task`, which no longer exist (id parsing was refactored into `ids.Ids`). The module failed to even collect (`ImportError`). - Re-pointed id tests at the current `Ids` API and added coverage for `segment_name` / `parent_id` / `is_root`. - Added `TestBaseTaskOutput`: success/error exit codes, output-property validation, input-property guard, and the new `get_task_user()` / credential-validation behaviour. +- Added `TestAutomatedTaskAsUser` for the **"Run as user" (`automatedTaskAsUser`) username/password**: `get_task_user()` returns the populated credentials; `get_release_api_client()` passes `(server_url, username, password)` to `ReleaseAPIClient` (mocked); and it raises `ValueError` for missing password, missing username, blank credentials, or missing server URL. ### `tests/release/integration/test_wrapper.py` — rewritten for robustness - Now **self-contained**: writes the sample input context itself and removes any stale `output.json` in `setUp`. @@ -111,5 +112,5 @@ Cases (11): ``` $ python -m pytest tests/release/integration/ -23 passed +31 passed ``` From 08ee806d876ab9019af112d986b88e22a495636f Mon Sep 17 00:00:00 2001 From: Balaji Venkatesan Date: Mon, 15 Jun 2026 10:56:43 +0530 Subject: [PATCH 05/23] Updated for robustness and test case --- digitalai/release/integration/base_task.py | 29 +- digitalai/release/integration/ids.py | 52 ++++ digitalai/release/integration/k8s.py | 15 +- digitalai/release/integration/masked_io.py | 7 +- digitalai/release/integration/watcher.py | 44 +-- digitalai/release/integration/wrapper.py | 97 ++++-- tests/release/integration/input.json | 39 +-- tests/release/integration/test_base_task.py | 165 ++++++++++ tests/release/integration/test_wrapper.py | 73 ++++- tests/release/integration/test_wrapper_k8s.py | 283 ++++++++++++++++++ 10 files changed, 695 insertions(+), 109 deletions(-) create mode 100644 digitalai/release/integration/ids.py create mode 100644 tests/release/integration/test_base_task.py create mode 100644 tests/release/integration/test_wrapper_k8s.py diff --git a/digitalai/release/integration/base_task.py b/digitalai/release/integration/base_task.py index 64e17b0..f80ad8a 100644 --- a/digitalai/release/integration/base_task.py +++ b/digitalai/release/integration/base_task.py @@ -1,11 +1,11 @@ -import logging import sys from abc import ABC, abstractmethod -from typing import Any, Dict +from typing import Any, Dict, Optional from .input_context import AutomatedTaskAsUserContext from .output_context import OutputContext from .exceptions import AbortException +from .ids import Ids from .logger import dai_logger from digitalai.release.release_api_client import ReleaseAPIClient @@ -128,10 +128,13 @@ def get_release_server_url(self) -> str: """ return self.release_server_url - def get_task_user(self) -> AutomatedTaskAsUserContext: + def get_task_user(self) -> Optional[AutomatedTaskAsUserContext]: """ - Returns the user details that are executing the task. + Returns the user details that are executing the task, or ``None`` when no + release context is available. """ + if not self.release_context: + return None return self.release_context.automated_task_as_user def get_release_id(self) -> str: @@ -146,6 +149,19 @@ def get_task_id(self) -> str: """ return self.task_id + def get_phase_id(self) -> str: + """ + Returns the Phase ID of the task, derived from the task id. + """ + return Ids.phase_id_from(self.get_task_id()) + + def get_folder_id(self) -> str: + """ + Returns the ID of the folder that contains the release, derived from the + release id. + """ + return Ids.find_folder_id(self.get_release_id()) + def get_release_api_client(self) -> ReleaseAPIClient: """ Returns a ReleaseAPIClient object with default configuration based on the task. @@ -159,10 +175,11 @@ def _validate_api_credentials(self) -> None: """ Validates that the necessary credentials are available for connecting to the Release API. """ + task_user = self.get_task_user() if not all([ self.get_release_server_url(), - self.get_task_user().username, - self.get_task_user().password + task_user and task_user.username, + task_user and task_user.password ]): raise ValueError( "Cannot connect to Release API without server URL, username, or password. " diff --git a/digitalai/release/integration/ids.py b/digitalai/release/integration/ids.py new file mode 100644 index 0000000..8ce1576 --- /dev/null +++ b/digitalai/release/integration/ids.py @@ -0,0 +1,52 @@ +""" +Helpers for parsing Release object ids. + +Release object ids are slash-separated paths, e.g. + Applications/Folder.../Release.../Phase.../Task... +The server derives the enclosing phase/folder by walking up that path (see +com.xebialabs.xlrelease.repository.Ids). A task only receives its own task and +release ids, so we reproduce the same walk to resolve the phase and folder. +""" + +_ID_SEPARATOR = '/' +_PHASE_PREFIX = 'Phase' +_FOLDER_PREFIX = 'Folder' + + +class Ids: + """Path-based parsing of Release object ids (mirrors the server's Ids).""" + + @staticmethod + def segment_name(object_id: str) -> str: + """Return the last path segment of an id (Ids.getName).""" + if _ID_SEPARATOR not in object_id: + return object_id + return object_id[object_id.rfind(_ID_SEPARATOR) + 1:] + + @staticmethod + def parent_id(object_id: str) -> str: + """Return the id with its last path segment removed (Ids.getParentId).""" + return object_id[:object_id.rfind(_ID_SEPARATOR)] + + @staticmethod + def is_root(object_id: str) -> bool: + """True when the id has no parent, i.e. no separator (Ids.isRoot).""" + return _ID_SEPARATOR not in object_id + + @staticmethod + def phase_id_from(object_id: str) -> str: + """Return the enclosing phase id of ``object_id`` (Ids.phaseIdFrom).""" + ancestry = object_id + while not Ids.segment_name(ancestry).startswith(_PHASE_PREFIX): + if Ids.is_root(ancestry): + raise ValueError(f"No phase found in id '{object_id}'") + ancestry = Ids.parent_id(ancestry) + return ancestry + + @staticmethod + def find_folder_id(object_id: str) -> str: + """Return the enclosing folder id of ``object_id`` (Ids.findFolderId).""" + parent = object_id + while not Ids.segment_name(parent).startswith(_FOLDER_PREFIX) and not Ids.is_root(parent): + parent = Ids.parent_id(parent) + return parent diff --git a/digitalai/release/integration/k8s.py b/digitalai/release/integration/k8s.py index a89761e..09f11a3 100644 --- a/digitalai/release/integration/k8s.py +++ b/digitalai/release/integration/k8s.py @@ -43,7 +43,20 @@ def get_client(): def split_secret_resource_data(secret_entry: str) -> tuple: + """ + Split a ``namespace:name:key`` secret reference into its three parts. + + Raises: + ValueError: if ``secret_entry`` is empty or not in the expected + ``namespace:name:key`` form. Returning blanks silently here only + surfaces later as a confusing Kubernetes API error. + """ + if not secret_entry: + raise ValueError("Secret resource reference is empty") split = secret_entry.split(":") if len(split) != 3: - return "", "", "" + raise ValueError( + f"Invalid secret resource reference '{secret_entry}', " + f"expected format 'namespace:name:key'" + ) return tuple(split) diff --git a/digitalai/release/integration/masked_io.py b/digitalai/release/integration/masked_io.py index a4b2ff6..ce47226 100644 --- a/digitalai/release/integration/masked_io.py +++ b/digitalai/release/integration/masked_io.py @@ -45,9 +45,14 @@ def write(self, s): Args: s (str): The string to write. + + Returns: + int: The number of characters of the original string written, as + required by the ``TextIOBase.write`` contract. """ d = s for secret in self.secrets: if secret: - d = d.replace(secret, '********') + d = d.replace(str(secret), '********') self.buffer.write(d) + return len(s) diff --git a/digitalai/release/integration/watcher.py b/digitalai/release/integration/watcher.py index a60a7a1..f6ea3c1 100644 --- a/digitalai/release/integration/watcher.py +++ b/digitalai/release/integration/watcher.py @@ -24,24 +24,34 @@ def start_input_context_watcher(on_input_context_update_func): def start_input_secret_watcher(on_input_context_update_func, stop): dai_logger.info("Input secret watcher started") - kubernetes_client = k8s.get_client() - field_selector = "metadata.name=" + os.getenv("INPUT_CONTEXT_SECRET") + secret_name = os.getenv("INPUT_CONTEXT_SECRET") namespace = os.getenv("RUNNER_NAMESPACE") + if not secret_name or not namespace: + raise ValueError( + "INPUT_CONTEXT_SECRET and RUNNER_NAMESPACE must be set to watch the input context secret" + ) + + kubernetes_client = k8s.get_client() + field_selector = "metadata.name=" + secret_name old_session_key = None w = watch.Watch() - for event in w.stream(kubernetes_client.list_namespaced_secret, namespace, field_selector=field_selector): - secret = event['object'] - new_session_key = secret.data.get("session-key") - - # Checking if 'session-key' field has changed - if old_session_key and old_session_key != new_session_key: - dai_logger.info("Detected input context value change") - on_input_context_update_func() - - # Set old session-key value - old_session_key = new_session_key - - # Check if the watcher should be stopped - if stop.is_set(): - break + try: + for event in w.stream(kubernetes_client.list_namespaced_secret, namespace, field_selector=field_selector): + secret = event['object'] + new_session_key = secret.data.get("session-key") if secret.data else None + + # Checking if 'session-key' field has changed + if old_session_key and old_session_key != new_session_key: + dai_logger.info("Detected input context value change") + on_input_context_update_func() + + # Set old session-key value + old_session_key = new_session_key + + # Check if the watcher should be stopped + if stop.is_set(): + break + finally: + # Always release the underlying streaming connection. + w.stop() diff --git a/digitalai/release/integration/wrapper.py b/digitalai/release/integration/wrapper.py index 52761eb..c832397 100644 --- a/digitalai/release/integration/wrapper.py +++ b/digitalai/release/integration/wrapper.py @@ -8,6 +8,7 @@ import signal import sys import time +from typing import Any, Dict, Optional, Tuple import requests import urllib3 @@ -37,10 +38,20 @@ runner_namespace: str = os.getenv('RUNNER_NAMESPACE', '') execution_mode: str = os.getenv('EXECUTOR_EXECUTION_MODE', '') -input_context: InputContext = None +input_context: Optional[InputContext] = None size_of_1Mb = 1024 * 1024 +# HTTP timeouts (seconds). A missing timeout lets a hung server stall the runner forever. +HTTP_CONNECT_TIMEOUT = float(os.getenv('HTTP_CONNECT_TIMEOUT', '10')) +HTTP_READ_TIMEOUT = float(os.getenv('HTTP_READ_TIMEOUT', '60')) + +# A single connection pool reused across all callback requests (instead of a fresh +# PoolManager per call, which defeats connection pooling). +_http_pool: urllib3.PoolManager = urllib3.PoolManager() +_urllib3_timeout = urllib3.Timeout(connect=HTTP_CONNECT_TIMEOUT, read=HTTP_READ_TIMEOUT) + + # Create the encryptor def get_encryptor(): if base64_session_key: @@ -51,7 +62,7 @@ def get_encryptor(): # Initialize the global task object -dai_task_object: BaseTask = None +dai_task_object: Optional[BaseTask] = None def abort_handler(signum, frame): @@ -73,7 +84,26 @@ def abort_handler(signum, frame): signal.signal(signal.SIGTERM, abort_handler) -def get_task_details(): +def _post_callback(url: str, encrypted_json) -> urllib3.HTTPResponse: + """ + POST the encrypted result to the callback URL using the shared connection pool. + + Raises an exception on transport errors *and* on HTTP error status codes + (>= 400), so that the caller's retry logic is triggered in both cases. + """ + response = _http_pool.request( + "POST", + url, + headers={'Content-Type': 'application/json'}, + body=encrypted_json, + timeout=_urllib3_timeout, + ) + if response.status >= 400: + raise RuntimeError(f"Callback request failed with HTTP status {response.status}") + return response + + +def get_task_details() -> Tuple[Dict[str, Any], str, str]: """ Get the task details by reading the input context file or fetching from secret, decrypting the contents using the encryptor, and parsing the JSON data into an InputContext object. Then, set the secrets for the masked standard output @@ -84,40 +114,35 @@ def get_task_details(): dai_logger.info("Reading input context from file") with open(input_context_file) as data_input: input_content = data_input.read() - #dai_logger.info("Successfully loaded input context from file") else: k8s_client = k8s.get_client() dai_logger.info("Reading input context from secret") - secret =k8s_client.read_namespaced_secret(input_context_secret, runner_namespace) - #dai_logger.info("Successfully loaded input context from secret") + secret = k8s_client.read_namespaced_secret(input_context_secret, runner_namespace) global base64_session_key, callback_url base64_session_key = base64.b64decode(secret.data["session-key"]) callback_url = base64.b64decode(secret.data["url"]) input_content = secret.data["input"] - if not input_content or len(input_content) == 0: + if not input_content: fetch_url_base64 = secret.data["fetchUrl"] - if not fetch_url_base64 or len(fetch_url_base64) == 0: + if not fetch_url_base64: raise ValueError("Cannot find fetch URL for task") + # The fetch URL is double base64 encoded in the secret. fetch_url_bytes = base64.b64decode(fetch_url_base64) fetch_url = base64.b64decode(fetch_url_bytes).decode("UTF-8") try: - response = requests.get(fetch_url) + response = requests.get(fetch_url, timeout=(HTTP_CONNECT_TIMEOUT, HTTP_READ_TIMEOUT)) response.raise_for_status() except requests.exceptions.RequestException as e: dai_logger.error("Failed to fetch data.", exc_info=True) raise e - if response.status_code != 200: - raise ValueError(f"Failed to fetch data, server returned status: {response.status_code}") - input_content = response.content else: input_content = base64.b64decode(input_content) decrypted_json = get_encryptor().decrypt(input_content) - #dai_logger.info("Successfully decrypted input context") global input_context input_context = InputContext.from_dict(json.loads(decrypted_json)) secrets = input_context.task.secrets() @@ -156,8 +181,7 @@ def update_output_context(output_context: OutputContext): dai_logger.info("Pushing result using HTTP") url = base64.b64decode(callback_url).decode("UTF-8") try: - urllib3.PoolManager().request("POST", url, headers={'Content-Type': 'application/json'}, - body=encrypted_json) + _post_callback(url, encrypted_json) except Exception: if should_retry_callback_request(encrypted_json): dai_logger.error("Cannot finish Callback request.", exc_info=True) @@ -180,29 +204,25 @@ def retry_push_result_infinitely(encrypted_json): backoff_factor = 2.0 while True: - try: - # If we can't read the secret, we should fail fast - secret = k8s.get_client().read_namespaced_secret(input_context_secret, runner_namespace) - except Exception: - raise + # If we can't read the secret we should fail fast (let the exception propagate). + secret = k8s.get_client().read_namespaced_secret(input_context_secret, runner_namespace) try: - callback_url = base64.b64decode(secret.data["url"]) - url = base64.b64decode(callback_url).decode("UTF-8") - response = urllib3.PoolManager().request("POST", url, headers={'Content-Type': 'application/json'}, body=encrypted_json) - return response + current_callback_url = base64.b64decode(secret.data["url"]) + url = base64.b64decode(current_callback_url).decode("UTF-8") + return _post_callback(url, encrypted_json) except Exception as e: dai_logger.warning(f"Cannot finish retried Callback request: {e}. Retrying in {retry_delay} seconds...") time.sleep(retry_delay) retry_delay = min(retry_delay * backoff_factor, max_backoff) -def should_retry_callback_request(encrypted_data): +def should_retry_callback_request(encrypted_data) -> bool: """ Checks if callback request should be retried on failure. It should be retried when result is too big for Secret and Output File handler is not used. """ - return len(encrypted_data) >= size_of_1Mb and len(input_context_file) == 0 + return len(encrypted_data) >= size_of_1Mb and not input_context_file def execute_task(task_object: BaseTask): @@ -219,7 +239,14 @@ def execute_task(task_object: BaseTask): except Exception: dai_logger.error("Unexpected error occurred.", exc_info=True) finally: - update_output_context(dai_task_object.get_output_context()) + # Guard against a task object that was never constructed or that failed + # before producing an output context, so the finally block does not raise + # a second exception that masks the original one. + if dai_task_object is not None and dai_task_object.get_output_context() is not None: + update_output_context(dai_task_object.get_output_context()) + else: + dai_logger.error("No output context available to report task result") + update_output_context(OutputContext(1, "Task produced no output context", {}, [])) def find_class_file(root_dir, class_name): @@ -227,11 +254,15 @@ def find_class_file(root_dir, class_name): for filename in files: if filename.endswith('.py'): filepath = os.path.join(root, filename) - with open(filepath) as file: - node = ast.parse(file.read()) - classes = [n.name for n in node.body if isinstance(n, ast.ClassDef)] - if class_name in classes: - return filepath + try: + with open(filepath, encoding="utf-8") as file: + node = ast.parse(file.read()) + except (SyntaxError, UnicodeDecodeError, OSError): + # Skip files that cannot be read or parsed instead of aborting the whole search. + continue + classes = [n.name for n in node.body if isinstance(n, ast.ClassDef)] + if class_name in classes: + return filepath return None @@ -240,6 +271,8 @@ def run(): # Get task details, parse the script file to get the task class, import the module, # create an instance of the task class, and execute the task task_props, task_type, script_path = get_task_details() + if "." not in task_type: + raise ValueError(f"Invalid task type '{task_type}', expected format '.'") task_class_name = task_type.split(".")[1] if script_path: diff --git a/tests/release/integration/input.json b/tests/release/integration/input.json index 6bdc493..647bf7a 100644 --- a/tests/release/integration/input.json +++ b/tests/release/integration/input.json @@ -1,38 +1 @@ -{ - "release": { - "id": "Applications/Folder061e/Releaseddb8", - "automatedTaskAsUser": { - "username": "admin", - "password": "admin" - } - }, - "task": { - "id": "Applications/Folder061e/Releaseddb8/Phase5a05/Task65e6", - "type": "example.Hello", - "properties": [ - { - "name": "capabilities", - "value": [ - "remote" - ], - "kind": "SET_OF_STRING", - "category": "input", - "password": false - }, - { - "name": "yourName", - "value": "World", - "kind": "STRING", - "category": "input", - "password": false - }, - { - "name": "greeting", - "value": null, - "kind": "STRING", - "category": "output", - "password": false - } - ] - } -} \ No newline at end of file +{"release": {"id": "Applications/Folder1f65c7220b394afbb941154342fd9fc6/Release31de09e95c8e4ebb95aaed29a8082d0b", "automatedTaskAsUser": {"username": "admin", "password": "admin"}}, "task": {"id": "Applications/Folder1f65c7220b394afbb941154342fd9fc6/Release31de09e95c8e4ebb95aaed29a8082d0b/Phase723a601c78804f7dbcaa8b05b83708f5/Task3a35b67b42b6428b854857fba470b39a", "type": "containerExamples.Hello", "properties": [{"name": "capabilities", "value": ["remote"], "kind": "SET_OF_STRING", "category": "input", "password": false}, {"name": "yourName", "value": "World", "kind": "STRING", "category": "input", "password": false}, {"name": "greeting", "value": null, "kind": "STRING", "category": "output", "password": false}]}} \ No newline at end of file diff --git a/tests/release/integration/test_base_task.py b/tests/release/integration/test_base_task.py new file mode 100644 index 0000000..042b151 --- /dev/null +++ b/tests/release/integration/test_base_task.py @@ -0,0 +1,165 @@ +import unittest +from types import SimpleNamespace +from unittest import mock + +from digitalai.release.integration.base_task import BaseTask +from digitalai.release.integration.ids import Ids +from digitalai.release.integration.input_context import ( + AutomatedTaskAsUserContext, + ReleaseContext, +) + +# Realistic ids, matching the sample input context. +FOLDER_ID = 'Applications/Folder1f65c7220b394afbb941154342fd9fc6' +RELEASE_ID = f'{FOLDER_ID}/Release31de09e95c8e4ebb95aaed29a8082d0b' +PHASE_ID = f'{RELEASE_ID}/Phase723a601c78804f7dbcaa8b05b83708f5' +TASK_ID = f'{PHASE_ID}/Task3a35b67b42b6428b854857fba470b39a' + + +class _StubTask(BaseTask): + """Minimal concrete task so BaseTask can be instantiated in tests.""" + + def execute(self) -> None: # pragma: no cover - never run + pass + + +class TestIdParsing(unittest.TestCase): + + def test_phase_id_from_task_id(self): + self.assertEqual(Ids.phase_id_from(TASK_ID), PHASE_ID) + + def test_find_folder_id_from_release_id(self): + self.assertEqual(Ids.find_folder_id(RELEASE_ID), FOLDER_ID) + + def test_phase_id_from_raises_when_absent(self): + with self.assertRaises(ValueError): + Ids.phase_id_from('Applications/Folder1/Release1') + + def test_segment_name_and_parent_id(self): + self.assertEqual(Ids.segment_name(TASK_ID), 'Task3a35b67b42b6428b854857fba470b39a') + self.assertEqual(Ids.parent_id(TASK_ID), PHASE_ID) + self.assertTrue(Ids.is_root('Applications')) + self.assertFalse(Ids.is_root(RELEASE_ID)) + + +class TestBaseTaskIds(unittest.TestCase): + + def _task(self): + task = _StubTask() + task.task_id = TASK_ID + task.release_context = SimpleNamespace(id=RELEASE_ID) + return task + + def test_get_phase_id_derives_from_task_id(self): + self.assertEqual(self._task().get_phase_id(), PHASE_ID) + + def test_get_folder_id_derives_from_release_id(self): + self.assertEqual(self._task().get_folder_id(), FOLDER_ID) + + +class TestBaseTaskOutput(unittest.TestCase): + """Robustness coverage for output handling and credential validation.""" + + def _task(self): + task = _StubTask() + task.task_id = TASK_ID + return task + + def test_execute_task_sets_success_output(self): + task = self._task() + task.execute_task() + ctx = task.get_output_context() + self.assertEqual(ctx.exit_code, 0) + self.assertEqual(ctx.job_error_message, "") + + def test_execute_task_captures_unexpected_error(self): + class _Boom(_StubTask): + def execute(self) -> None: + raise RuntimeError("boom") + + task = _Boom() + task.execute_task() + ctx = task.get_output_context() + self.assertEqual(ctx.exit_code, 1) + self.assertEqual(ctx.job_error_message, "boom") + + def test_set_output_property_rejects_empty_name(self): + task = self._task() + task.execute_task() + with self.assertRaises(ValueError): + task.set_output_property("", "value") + + def test_set_output_property_rejects_unsupported_type(self): + task = self._task() + task.execute_task() + with self.assertRaises(ValueError): + task.set_output_property("name", object()) + + def test_get_input_properties_requires_value(self): + task = self._task() + with self.assertRaises(ValueError): + task.get_input_properties() + + def test_get_task_user_returns_none_without_release_context(self): + task = self._task() + self.assertIsNone(task.get_task_user()) + + def test_validate_api_credentials_raises_without_user(self): + task = self._task() + task.release_server_url = "http://localhost:5516" + with self.assertRaises(ValueError): + task.get_release_api_client() + + +class TestAutomatedTaskAsUser(unittest.TestCase): + """Coverage for the 'Run as user' (automatedTaskAsUser) username/password.""" + + SERVER_URL = "http://localhost:5516" + + def _task(self, username, password): + task = _StubTask() + task.task_id = TASK_ID + task.release_server_url = self.SERVER_URL + task.release_context = ReleaseContext( + id=RELEASE_ID, + automated_task_as_user=AutomatedTaskAsUserContext(username=username, password=password), + ) + return task + + def test_get_task_user_returns_credentials(self): + task = self._task("admin", "secret") + user = task.get_task_user() + self.assertEqual(user.username, "admin") + self.assertEqual(user.password, "secret") + + def test_get_release_api_client_passes_credentials(self): + task = self._task("admin", "secret") + with mock.patch("digitalai.release.integration.base_task.ReleaseAPIClient") as fake_client: + client = task.get_release_api_client() + fake_client.assert_called_once_with(self.SERVER_URL, "admin", "secret") + self.assertIs(client, fake_client.return_value) + + def test_get_release_api_client_raises_when_password_missing(self): + task = self._task("admin", None) + with self.assertRaises(ValueError): + task.get_release_api_client() + + def test_get_release_api_client_raises_when_username_missing(self): + task = self._task(None, "secret") + with self.assertRaises(ValueError): + task.get_release_api_client() + + def test_get_release_api_client_raises_when_credentials_blank(self): + task = self._task("", "") + with self.assertRaises(ValueError): + task.get_release_api_client() + + def test_get_release_api_client_raises_without_server_url(self): + task = self._task("admin", "secret") + task.release_server_url = None + with self.assertRaises(ValueError): + task.get_release_api_client() + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/release/integration/test_wrapper.py b/tests/release/integration/test_wrapper.py index fac4da0..64f96a1 100644 --- a/tests/release/integration/test_wrapper.py +++ b/tests/release/integration/test_wrapper.py @@ -1,33 +1,78 @@ import json import os import subprocess +import sys import unittest +# Directory that holds the test fixtures (input.json, hello.py, src/...). +THIS_DIR = os.path.dirname(os.path.abspath(__file__)) + +# Sample input context for a "Hello" container task (matches input.json). +SAMPLE_INPUT_CONTEXT = { + "release": { + "id": "Applications/Folder1f65c7220b394afbb941154342fd9fc6/Release31de09e95c8e4ebb95aaed29a8082d0b", + "automatedTaskAsUser": {"username": "admin", "password": "admin"}, + }, + "task": { + "id": "Applications/Folder1f65c7220b394afbb941154342fd9fc6/Release31de09e95c8e4ebb95aaed29a8082d0b/Phase723a601c78804f7dbcaa8b05b83708f5/Task3a35b67b42b6428b854857fba470b39a", + "type": "containerExamples.Hello", + "properties": [ + {"name": "capabilities", "value": ["remote"], "kind": "SET_OF_STRING", "category": "input", "password": False}, + {"name": "yourName", "value": "World", "kind": "STRING", "category": "input", "password": False}, + {"name": "greeting", "value": None, "kind": "STRING", "category": "output", "password": False}, + ], + }, +} + +EXPECTED_OUTPUT = { + "exitCode": 0, + "jobErrorMessage": "", + "outputProperties": {"greeting": "Hello World"}, + "reportingRecords": [], +} + class TestWrapper(unittest.TestCase): + """End-to-end test that runs the wrapper against a sample input context.""" + + def setUp(self): + self.input_path = os.path.join(THIS_DIR, "input.json") + self.output_path = os.path.join(THIS_DIR, "output.json") + # Write the sample input context so the test is self-contained. + with open(self.input_path, "w") as f: + json.dump(SAMPLE_INPUT_CONTEXT, f) + # Start from a clean slate so a stale file can never mask a failure. + if os.path.exists(self.output_path): + os.remove(self.output_path) def test_wrapper(self): """ - This test method sets the environment variables INPUT_LOCATION and OUTPUT_LOCATION, - then runs the integration wrapper script using subprocess.run. - The wrapper script will generate the output.json. - It then opens and reads the contents of the expected_output.json and output.json files, - and compares the contents using self.assertEqual to check if they are equal. + Runs the wrapper as a subprocess with the sample input context and verifies + that it exits successfully and produces the expected output context. """ + env = dict(os.environ) + env["INPUT_LOCATION"] = "input.json" + env["OUTPUT_LOCATION"] = "output.json" + env["RELEASE_URL"] = "http://localhost:5516" - os.environ['INPUT_LOCATION'] = "input.json" - os.environ['OUTPUT_LOCATION'] = "output.json" - os.environ['RELEASE_URL'] = "http://localhost:5516" - - subprocess.run(["python", "-m", "digitalai.release.integration.wrapper"]) + result = subprocess.run( + [sys.executable, "-m", "digitalai.release.integration.wrapper"], + cwd=THIS_DIR, + env=env, + capture_output=True, + text=True, + ) - with open('expected_output.json', 'r') as json_file: - expected_output = json.load(json_file) + self.assertEqual( + result.returncode, 0, + msg=f"wrapper exited with {result.returncode}\nstdout:\n{result.stdout}\nstderr:\n{result.stderr}", + ) + self.assertTrue(os.path.exists(self.output_path), "wrapper did not produce output.json") - with open('output.json', 'r') as json_file: + with open(self.output_path, "r") as json_file: actual_output = json.load(json_file) - self.assertEqual(expected_output, actual_output) + self.assertEqual(EXPECTED_OUTPUT, actual_output) if __name__ == '__main__': diff --git a/tests/release/integration/test_wrapper_k8s.py b/tests/release/integration/test_wrapper_k8s.py new file mode 100644 index 0000000..01f8299 --- /dev/null +++ b/tests/release/integration/test_wrapper_k8s.py @@ -0,0 +1,283 @@ +""" +Tests for the wrapper's Kubernetes execution path. + +When the runner executes inside Kubernetes there is no INPUT_LOCATION file; the +input context is read from a Secret, and the result is written back to a Secret +and/or pushed to a callback URL. These tests exercise that path with a fully +mocked Kubernetes client and callback transport, so no real cluster or network +is required. + +Secret values in Kubernetes are base64-encoded strings, and the wrapper applies +its own (double) base64 encoding to the callback/fetch URLs. The helpers below +mirror that encoding so the fakes look exactly like a real Secret. +""" + +import base64 +import json +import unittest +from unittest import mock + +import digitalai.release.integration.wrapper as wrapper +from digitalai.release.integration import k8s +from digitalai.release.integration.output_context import OutputContext + +# Sample input context (Hello task) reused from the file-based wrapper test. +SAMPLE_INPUT_CONTEXT = { + "release": { + "id": "Applications/Folder1f65c7220b394afbb941154342fd9fc6/Release31de09e95c8e4ebb95aaed29a8082d0b", + "automatedTaskAsUser": {"username": "admin", "password": "admin"}, + }, + "task": { + "id": "Applications/Folder1f65c7220b394afbb941154342fd9fc6/Release31de09e95c8e4ebb95aaed29a8082d0b/Phase723a601c78804f7dbcaa8b05b83708f5/Task3a35b67b42b6428b854857fba470b39a", + "type": "containerExamples.Hello", + "properties": [ + {"name": "capabilities", "value": ["remote"], "kind": "SET_OF_STRING", "category": "input", "password": False}, + {"name": "yourName", "value": "World", "kind": "STRING", "category": "input", "password": False}, + {"name": "greeting", "value": None, "kind": "STRING", "category": "output", "password": False}, + ], + }, +} + + +def _b64(value) -> str: + """base64-encode a str/bytes the way a Kubernetes Secret stores its values.""" + if isinstance(value, str): + value = value.encode("UTF-8") + return base64.b64encode(value).decode("UTF-8") + + +def _double_b64_url(url: str) -> str: + """The wrapper expects callback/fetch URLs to be double base64-encoded.""" + return _b64(_b64(url)) + + +class FakeSecret: + """Minimal stand-in for a kubernetes V1Secret (only ``.data`` is used).""" + + def __init__(self, data): + self.data = dict(data) + + +class FakeCoreV1Api: + """Records secret reads/replacements so assertions can inspect them.""" + + def __init__(self, secrets): + self.secrets = secrets # name -> FakeSecret + self.replaced = [] # list of (name, namespace, body) + + def read_namespaced_secret(self, name, namespace): + return self.secrets[name] + + def replace_namespaced_secret(self, name, namespace, body): + self.replaced.append((name, namespace, body)) + self.secrets[name] = body + + +class _WrapperK8sTestBase(unittest.TestCase): + """Shared setup: patch the wrapper's module globals and the k8s client.""" + + INPUT_SECRET = "input-context-secret" + NAMESPACE = "runner-ns" + CALLBACK_URL = "http://release-server/callback" + + def _patch_global(self, name, value): + patcher = mock.patch.object(wrapper, name, value) + patcher.start() + self.addCleanup(patcher.stop) + + def _install_fake_client(self, secrets): + fake = FakeCoreV1Api(secrets) + patcher = mock.patch.object(k8s, "get_client", return_value=fake) + patcher.start() + self.addCleanup(patcher.stop) + return fake + + def setUp(self): + # Force the Kubernetes branch: no input/output files. + self._patch_global("input_context_file", "") + self._patch_global("output_context_file", "") + self._patch_global("input_context_secret", self.INPUT_SECRET) + self._patch_global("runner_namespace", self.NAMESPACE) + # A blank session-key decodes to b"" -> NoOp encryptor (plaintext I/O), + # which keeps the test independent of AES key material. + self._patch_global("base64_session_key", "") + + +class TestGetTaskDetailsFromSecret(_WrapperK8sTestBase): + + def test_reads_input_context_from_secret(self): + input_json = json.dumps(SAMPLE_INPUT_CONTEXT) + secret = FakeSecret({ + "session-key": _b64(""), # -> NoOp encryptor + "url": _double_b64_url(self.CALLBACK_URL), + "input": _b64(input_json), + }) + self._install_fake_client({self.INPUT_SECRET: secret}) + + task_properties, task_type, script_path = wrapper.get_task_details() + + self.assertEqual(task_type, "containerExamples.Hello") + self.assertEqual(task_properties["yourName"], "World") + self.assertEqual(script_path, "") + # callback_url global is set to the (single-decoded) callback bytes. + self.assertEqual(base64.b64decode(wrapper.callback_url).decode("UTF-8"), self.CALLBACK_URL) + # The "Run as user" password is registered as a secret to be masked. + self.assertIn("admin", wrapper.masked_std_out.secrets) + + def test_reads_input_context_via_fetch_url_when_input_empty(self): + input_json = json.dumps(SAMPLE_INPUT_CONTEXT) + fetch_url = "http://blob-store/large-input" + secret = FakeSecret({ + "session-key": _b64(""), + "url": _double_b64_url(self.CALLBACK_URL), + "input": "", # empty -> use fetchUrl + "fetchUrl": _double_b64_url(fetch_url), + }) + self._install_fake_client({self.INPUT_SECRET: secret}) + + fake_response = mock.Mock() + fake_response.content = input_json.encode("UTF-8") + fake_response.status_code = 200 + fake_response.raise_for_status = mock.Mock() + + with mock.patch.object(wrapper.requests, "get", return_value=fake_response) as fake_get: + task_properties, task_type, _ = wrapper.get_task_details() + + fake_get.assert_called_once() + self.assertEqual(fake_get.call_args.args[0], fetch_url) + # A timeout must be supplied so a hung blob store cannot stall the runner. + self.assertIn("timeout", fake_get.call_args.kwargs) + fake_response.raise_for_status.assert_called_once() + self.assertEqual(task_type, "containerExamples.Hello") + self.assertEqual(task_properties["yourName"], "World") + + def test_missing_fetch_url_raises(self): + secret = FakeSecret({ + "session-key": _b64(""), + "url": _double_b64_url(self.CALLBACK_URL), + "input": "", + "fetchUrl": "", + }) + self._install_fake_client({self.INPUT_SECRET: secret}) + + with self.assertRaises(ValueError): + wrapper.get_task_details() + + def test_secret_read_failure_propagates(self): + # get_task_details does not swallow k8s errors: a failure to read the + # input secret must surface so run() can report the task as failed. + fake = mock.Mock() + fake.read_namespaced_secret.side_effect = RuntimeError("k8s unavailable") + patcher = mock.patch.object(k8s, "get_client", return_value=fake) + patcher.start() + self.addCleanup(patcher.stop) + + with self.assertRaises(RuntimeError): + wrapper.get_task_details() + + +class TestUpdateOutputContextToSecret(_WrapperK8sTestBase): + + RESULT_SECRET_NAME = "result-secret" + RESULT_KEY = "result" + + def setUp(self): + super().setUp() + self._patch_global("result_secret_key", f"{self.NAMESPACE}:{self.RESULT_SECRET_NAME}:{self.RESULT_KEY}") + # callback_url global as set by get_task_details: single base64 of the URL. + self._patch_global("callback_url", _b64(self.CALLBACK_URL).encode("UTF-8")) + + def test_writes_result_to_secret_and_pushes_callback(self): + result_secret = FakeSecret({self.RESULT_KEY: ""}) + fake = self._install_fake_client({self.RESULT_SECRET_NAME: result_secret}) + + output = OutputContext(0, "", {"greeting": "Hello World"}, []) + + with mock.patch.object(wrapper, "_post_callback") as fake_post: + wrapper.update_output_context(output) + + # Result written back to the secret (NoOp encryptor -> plaintext JSON). + self.assertEqual(len(fake.replaced), 1) + name, namespace, body = fake.replaced[0] + self.assertEqual(name, self.RESULT_SECRET_NAME) + self.assertEqual(namespace, self.NAMESPACE) + stored = json.loads(body.data[self.RESULT_KEY]) + self.assertEqual(stored["outputProperties"], {"greeting": "Hello World"}) + + # Callback pushed to the decoded URL with the encrypted body. + fake_post.assert_called_once() + pushed_url, pushed_body = fake_post.call_args.args + self.assertEqual(pushed_url, self.CALLBACK_URL) + self.assertEqual(json.loads(pushed_body)["exitCode"], 0) + + def test_result_too_large_skips_secret_but_still_pushes_callback(self): + result_secret = FakeSecret({self.RESULT_KEY: ""}) + fake = self._install_fake_client({self.RESULT_SECRET_NAME: result_secret}) + + # > 1Mb of output so it cannot be stored in a Secret. + big_value = "x" * (wrapper.size_of_1Mb + 1024) + output = OutputContext(0, "", {"big": big_value}, []) + + with mock.patch.object(wrapper, "_post_callback") as fake_post: + wrapper.update_output_context(output) + + # Secret write skipped because the payload is too big. + self.assertEqual(fake.replaced, []) + # Callback is still attempted. + fake_post.assert_called_once() + + def test_secret_write_failure_is_swallowed_and_logged(self): + # update_output_context must never raise: a failure to write the result + # secret is logged and swallowed so the runner exits cleanly. Because the + # failure happens before the callback step, no callback is attempted. + fake = mock.Mock() + fake.read_namespaced_secret.side_effect = RuntimeError("k8s write unavailable") + patcher = mock.patch.object(k8s, "get_client", return_value=fake) + patcher.start() + self.addCleanup(patcher.stop) + + output = OutputContext(0, "", {"greeting": "Hello World"}, []) + + with mock.patch.object(wrapper, "_post_callback") as fake_post: + # Must not raise. + wrapper.update_output_context(output) + + fake_post.assert_not_called() + + def test_callback_retries_when_too_big_and_no_output_file(self): + result_secret = FakeSecret({self.RESULT_KEY: ""}) + # The retry path re-reads the input-context secret for a fresh URL. + input_secret = FakeSecret({"url": _double_b64_url(self.CALLBACK_URL)}) + self._install_fake_client({ + self.RESULT_SECRET_NAME: result_secret, + self.INPUT_SECRET: input_secret, + }) + + big_value = "x" * (wrapper.size_of_1Mb + 1024) + output = OutputContext(0, "", {"big": big_value}, []) + + # First push fails; should_retry_callback_request() is True (too big, no + # output file) so retry_push_result_infinitely is invoked and succeeds. + with mock.patch.object(wrapper, "_post_callback", side_effect=[RuntimeError("boom"), mock.Mock()]) as fake_post: + wrapper.update_output_context(output) + + self.assertEqual(fake_post.call_count, 2) + + +class TestShouldRetryCallbackRequest(unittest.TestCase): + + def test_retries_when_too_big_and_no_output_file(self): + with mock.patch.object(wrapper, "input_context_file", ""): + self.assertTrue(wrapper.should_retry_callback_request("x" * (wrapper.size_of_1Mb + 1))) + + def test_no_retry_when_small(self): + with mock.patch.object(wrapper, "input_context_file", ""): + self.assertFalse(wrapper.should_retry_callback_request("small")) + + def test_no_retry_when_output_file_present(self): + with mock.patch.object(wrapper, "input_context_file", "output.json"): + self.assertFalse(wrapper.should_retry_callback_request("x" * (wrapper.size_of_1Mb + 1))) + + +if __name__ == "__main__": + unittest.main() From 201b7134006f164be2543c0c24c41e17528423ed Mon Sep 17 00:00:00 2001 From: Balaji Venkatesan Date: Mon, 15 Jun 2026 11:04:38 +0530 Subject: [PATCH 06/23] Updated test case --- .github/workflows/python-test.yml | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/.github/workflows/python-test.yml b/.github/workflows/python-test.yml index 142b4ea..bec03ce 100644 --- a/.github/workflows/python-test.yml +++ b/.github/workflows/python-test.yml @@ -3,6 +3,7 @@ name: Python Test on: push: branches: + - main - master - release/* pull_request: @@ -22,6 +23,4 @@ jobs: - name: Install dependencies run: uv sync --frozen - name: Run tests - run: | - cd tests/release/integration - uv run --project ${{ github.workspace }} python -m unittest test_wrapper.py + run: uv run --project ${{ github.workspace }} python -m pytest tests/release/integration -v From 4c37fdf41114f17b6d7ea06001f5088377b4387e Mon Sep 17 00:00:00 2001 From: Balaji Venkatesan Date: Mon, 15 Jun 2026 11:41:39 +0530 Subject: [PATCH 07/23] Updated the base task --- digitalai/release/integration/base_task.py | 51 ++++++++++++++++----- tests/release/integration/test_base_task.py | 14 ++++++ 2 files changed, 54 insertions(+), 11 deletions(-) diff --git a/digitalai/release/integration/base_task.py b/digitalai/release/integration/base_task.py index f80ad8a..9021d37 100644 --- a/digitalai/release/integration/base_task.py +++ b/digitalai/release/integration/base_task.py @@ -162,24 +162,53 @@ def get_folder_id(self) -> str: """ return Ids.find_folder_id(self.get_release_id()) - def get_release_api_client(self) -> ReleaseAPIClient: + def get_release_api_client(self, + server_address: str = None, + username: str = None, + password: str = None, + personal_access_token: str = None, + **kwargs) -> ReleaseAPIClient: """ - Returns a ReleaseAPIClient object with default configuration based on the task. - """ - self._validate_api_credentials() - return ReleaseAPIClient(self.get_release_server_url(), - self.get_task_user().username, - self.get_task_user().password) + Returns a ReleaseAPIClient object. + + All arguments are optional. When omitted, the client is configured from the + task context (server URL and the 'Run as user' credentials). Any argument + that is provided overrides the corresponding task default. - def _validate_api_credentials(self) -> None: + :param server_address: Optional Release server URL. Defaults to the task's server URL. + :param username: Optional username. Defaults to the task user's username. + :param password: Optional password. Defaults to the task user's password. + :param personal_access_token: Optional personal access token for authentication. + :param kwargs: Additional session parameters (e.g., headers, timeout). + """ + task_user = self.get_task_user() + server_address = server_address or self.get_release_server_url() + + if personal_access_token: + if not server_address: + raise ValueError( + "Cannot connect to Release API without server URL. " + "Make sure that the release server URL is available." + ) + return ReleaseAPIClient(server_address, + personal_access_token=personal_access_token, + **kwargs) + + username = username or (task_user and task_user.username) + password = password or (task_user and task_user.password) + self._validate_api_credentials(server_address, username, password) + return ReleaseAPIClient(server_address, username, password, **kwargs) + + def _validate_api_credentials(self, server_address: str = None, + username: str = None, password: str = None) -> None: """ Validates that the necessary credentials are available for connecting to the Release API. """ task_user = self.get_task_user() if not all([ - self.get_release_server_url(), - task_user and task_user.username, - task_user and task_user.password + server_address or self.get_release_server_url(), + username or (task_user and task_user.username), + password or (task_user and task_user.password) ]): raise ValueError( "Cannot connect to Release API without server URL, username, or password. " diff --git a/tests/release/integration/test_base_task.py b/tests/release/integration/test_base_task.py index 042b151..f5c3172 100644 --- a/tests/release/integration/test_base_task.py +++ b/tests/release/integration/test_base_task.py @@ -3,6 +3,7 @@ from unittest import mock from digitalai.release.integration.base_task import BaseTask +from digitalai.release.integration.exceptions import AbortException from digitalai.release.integration.ids import Ids from digitalai.release.integration.input_context import ( AutomatedTaskAsUserContext, @@ -83,6 +84,19 @@ def execute(self) -> None: self.assertEqual(ctx.exit_code, 1) self.assertEqual(ctx.job_error_message, "boom") + def test_execute_task_handles_abort(self): + class _Abort(_StubTask): + def execute(self) -> None: + raise AbortException() + + task = _Abort() + with self.assertRaises(SystemExit) as cm: + task.execute_task() + self.assertEqual(cm.exception.code, 1) + ctx = task.get_output_context() + self.assertEqual(ctx.exit_code, 1) + self.assertEqual(ctx.job_error_message, "Abort requested") + def test_set_output_property_rejects_empty_name(self): task = self._task() task.execute_task() From 3cf4fee311ecd271aee8a307e97fe3c86abaae4f Mon Sep 17 00:00:00 2001 From: Balaji Venkatesan Date: Mon, 15 Jun 2026 11:53:54 +0530 Subject: [PATCH 08/23] Updated toml file --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 4bcad45..03003cf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [tool.hatch.build] exclude = [ - ".gitignore", "tests/*", ".github/*", "DEVELOPMENT.md" + ".gitignore", "tests/*", ".github/*", "DEVELOPMENT.md", "ROBUSTNESS_CHANGES.md", "uv.lock" ] [tool.hatch.build.targets.wheel] From 7612e0779ea3e856b6a00dc2e5a0d07f24f3fdda Mon Sep 17 00:00:00 2001 From: Balaji Venkatesan Date: Mon, 15 Jun 2026 12:54:28 +0530 Subject: [PATCH 09/23] Updated the wrapper --- digitalai/release/integration/wrapper.py | 25 ++++++++----------- tests/release/integration/test_wrapper_k8s.py | 2 +- 2 files changed, 12 insertions(+), 15 deletions(-) diff --git a/digitalai/release/integration/wrapper.py b/digitalai/release/integration/wrapper.py index c832397..a9898c5 100644 --- a/digitalai/release/integration/wrapper.py +++ b/digitalai/release/integration/wrapper.py @@ -11,7 +11,6 @@ from typing import Any, Dict, Optional, Tuple import requests -import urllib3 from digitalai.release.integration import k8s, watcher from .base_task import BaseTask @@ -46,10 +45,10 @@ HTTP_CONNECT_TIMEOUT = float(os.getenv('HTTP_CONNECT_TIMEOUT', '10')) HTTP_READ_TIMEOUT = float(os.getenv('HTTP_READ_TIMEOUT', '60')) -# A single connection pool reused across all callback requests (instead of a fresh -# PoolManager per call, which defeats connection pooling). -_http_pool: urllib3.PoolManager = urllib3.PoolManager() -_urllib3_timeout = urllib3.Timeout(connect=HTTP_CONNECT_TIMEOUT, read=HTTP_READ_TIMEOUT) +# A single Session reused across all callback requests so the underlying urllib3 +# connection pool is shared (instead of opening a fresh connection per call). +_http_session: requests.Session = requests.Session() +_HTTP_TIMEOUT = (HTTP_CONNECT_TIMEOUT, HTTP_READ_TIMEOUT) # Create the encryptor @@ -84,22 +83,20 @@ def abort_handler(signum, frame): signal.signal(signal.SIGTERM, abort_handler) -def _post_callback(url: str, encrypted_json) -> urllib3.HTTPResponse: +def _post_callback(url: str, encrypted_json) -> requests.Response: """ - POST the encrypted result to the callback URL using the shared connection pool. + POST the encrypted result to the callback URL using the shared Session. Raises an exception on transport errors *and* on HTTP error status codes (>= 400), so that the caller's retry logic is triggered in both cases. """ - response = _http_pool.request( - "POST", + response = _http_session.post( url, headers={'Content-Type': 'application/json'}, - body=encrypted_json, - timeout=_urllib3_timeout, + data=encrypted_json, + timeout=_HTTP_TIMEOUT, ) - if response.status >= 400: - raise RuntimeError(f"Callback request failed with HTTP status {response.status}") + response.raise_for_status() return response @@ -132,7 +129,7 @@ def get_task_details() -> Tuple[Dict[str, Any], str, str]: fetch_url_bytes = base64.b64decode(fetch_url_base64) fetch_url = base64.b64decode(fetch_url_bytes).decode("UTF-8") try: - response = requests.get(fetch_url, timeout=(HTTP_CONNECT_TIMEOUT, HTTP_READ_TIMEOUT)) + response = _http_session.get(fetch_url, timeout=_HTTP_TIMEOUT) response.raise_for_status() except requests.exceptions.RequestException as e: dai_logger.error("Failed to fetch data.", exc_info=True) diff --git a/tests/release/integration/test_wrapper_k8s.py b/tests/release/integration/test_wrapper_k8s.py index 01f8299..78c3f7e 100644 --- a/tests/release/integration/test_wrapper_k8s.py +++ b/tests/release/integration/test_wrapper_k8s.py @@ -140,7 +140,7 @@ def test_reads_input_context_via_fetch_url_when_input_empty(self): fake_response.status_code = 200 fake_response.raise_for_status = mock.Mock() - with mock.patch.object(wrapper.requests, "get", return_value=fake_response) as fake_get: + with mock.patch.object(wrapper._http_session, "get", return_value=fake_response) as fake_get: task_properties, task_type, _ = wrapper.get_task_details() fake_get.assert_called_once() From 719fa1552a87182e1302b7d5af353d59456959ef Mon Sep 17 00:00:00 2001 From: Balaji Venkatesan Date: Mon, 15 Jun 2026 13:00:11 +0530 Subject: [PATCH 10/23] Updated test case --- tests/release/integration/input.json | 2 +- tests/release/integration/test_wrapper.py | 43 ++++++++++++++++++----- 2 files changed, 35 insertions(+), 10 deletions(-) diff --git a/tests/release/integration/input.json b/tests/release/integration/input.json index 647bf7a..16bb5f4 100644 --- a/tests/release/integration/input.json +++ b/tests/release/integration/input.json @@ -1 +1 @@ -{"release": {"id": "Applications/Folder1f65c7220b394afbb941154342fd9fc6/Release31de09e95c8e4ebb95aaed29a8082d0b", "automatedTaskAsUser": {"username": "admin", "password": "admin"}}, "task": {"id": "Applications/Folder1f65c7220b394afbb941154342fd9fc6/Release31de09e95c8e4ebb95aaed29a8082d0b/Phase723a601c78804f7dbcaa8b05b83708f5/Task3a35b67b42b6428b854857fba470b39a", "type": "containerExamples.Hello", "properties": [{"name": "capabilities", "value": ["remote"], "kind": "SET_OF_STRING", "category": "input", "password": false}, {"name": "yourName", "value": "World", "kind": "STRING", "category": "input", "password": false}, {"name": "greeting", "value": null, "kind": "STRING", "category": "output", "password": false}]}} \ No newline at end of file +{"release": {"id": "Applications/Folder1f65c7220b394afbb941154342fd9fc6/Release31de09e95c8e4ebb95aaed29a8082d0b", "automatedTaskAsUser": {"username": "admin", "password": "admin"}}, "task": {"id": "Applications/Folder1f65c7220b394afbb941154342fd9fc6/Release31de09e95c8e4ebb95aaed29a8082d0b/Phase723a601c78804f7dbcaa8b05b83708f5/Task3a35b67b42b6428b854857fba470b39a", "type": "containerExamples.Hello1", "properties": [{"name": "capabilities", "value": ["remote"], "kind": "SET_OF_STRING", "category": "input", "password": false}, {"name": "yourName", "value": "World", "kind": "STRING", "category": "input", "password": false}, {"name": "greeting", "value": null, "kind": "STRING", "category": "output", "password": false}, {"name": "scriptLocation", "value": "sample/hello.py", "kind": "STRING", "category": "input", "password": false}]}} \ No newline at end of file diff --git a/tests/release/integration/test_wrapper.py b/tests/release/integration/test_wrapper.py index 64f96a1..2e224f3 100644 --- a/tests/release/integration/test_wrapper.py +++ b/tests/release/integration/test_wrapper.py @@ -24,6 +24,20 @@ }, } +# Same task, but with an explicit scriptLocation so the wrapper loads the class via +# the `if script_path:` branch (importlib) instead of walking the tree. Points at the +# src/sample/hello.py fixture, which defines the Hello1 class. +SAMPLE_INPUT_CONTEXT_WITH_SCRIPT = { + "release": SAMPLE_INPUT_CONTEXT["release"], + "task": { + "id": SAMPLE_INPUT_CONTEXT["task"]["id"], + "type": "containerExamples.Hello1", + "properties": SAMPLE_INPUT_CONTEXT["task"]["properties"] + [ + {"name": "scriptLocation", "value": "sample/hello.py", "kind": "STRING", "category": "input", "password": False}, + ], + }, +} + EXPECTED_OUTPUT = { "exitCode": 0, "jobErrorMessage": "", @@ -38,18 +52,15 @@ class TestWrapper(unittest.TestCase): def setUp(self): self.input_path = os.path.join(THIS_DIR, "input.json") self.output_path = os.path.join(THIS_DIR, "output.json") - # Write the sample input context so the test is self-contained. - with open(self.input_path, "w") as f: - json.dump(SAMPLE_INPUT_CONTEXT, f) # Start from a clean slate so a stale file can never mask a failure. if os.path.exists(self.output_path): os.remove(self.output_path) - def test_wrapper(self): - """ - Runs the wrapper as a subprocess with the sample input context and verifies - that it exits successfully and produces the expected output context. - """ + def _run_wrapper(self, input_context): + """Write the given input context, run the wrapper as a subprocess, and return the parsed output.""" + with open(self.input_path, "w") as f: + json.dump(input_context, f) + env = dict(os.environ) env["INPUT_LOCATION"] = "input.json" env["OUTPUT_LOCATION"] = "output.json" @@ -70,8 +81,22 @@ def test_wrapper(self): self.assertTrue(os.path.exists(self.output_path), "wrapper did not produce output.json") with open(self.output_path, "r") as json_file: - actual_output = json.load(json_file) + return json.load(json_file) + def test_wrapper(self): + """ + Runs the wrapper with a sample input context that has no scriptLocation, so the + task class is resolved via the find_class_file fallback (the `else` branch of run()). + """ + actual_output = self._run_wrapper(SAMPLE_INPUT_CONTEXT) + self.assertEqual(EXPECTED_OUTPUT, actual_output) + + def test_wrapper_with_script_location(self): + """ + Runs the wrapper with a scriptLocation set, so the task class is loaded via + importlib (the `if script_path:` branch of run()). + """ + actual_output = self._run_wrapper(SAMPLE_INPUT_CONTEXT_WITH_SCRIPT) self.assertEqual(EXPECTED_OUTPUT, actual_output) From 58520697328a272bf120852a3da50d86ec891af4 Mon Sep 17 00:00:00 2001 From: Balaji Venkatesan Date: Tue, 16 Jun 2026 09:38:48 +0530 Subject: [PATCH 11/23] Updated Readme --- README.md | 14 +++-- ROBUSTNESS_CHANGES.md | 116 ------------------------------------------ pyproject.toml | 6 +-- 3 files changed, 12 insertions(+), 124 deletions(-) delete mode 100644 ROBUSTNESS_CHANGES.md diff --git a/README.md b/README.md index fd8f44c..73ddfb4 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,8 @@ The **Digital.ai Release Python SDK** (`digitalai-release-sdk`) provides a set o - Define custom tasks using the `BaseTask` abstract class. - Easily manage input and output properties. - Interact with the Digital.ai Release environment seamlessly. -- Simplified API client for efficient communication with Release API. +- Simplified API client for efficient communication with Release API, with support for username/password or personal access token authentication. +- Built-in helpers to resolve Release entity IDs (release, phase, task, and folder). ## Installation @@ -45,13 +46,16 @@ class Hello(BaseTask): ## Changelog -### Version 26.1.0 +### Version 26.3.0 (Beta) + +#### 🚀 Features + +- `get_release_api_client()` now supports optional credentials/server URL and `requests` library arguments. +- Added `get_phase_id()` and `get_folder_id()` helper methods to `BaseTask`. #### 🛠️ Enhancements -- Updated minimum Python version requirement to **3.10**. -- Updated dependency versions to enhance compatibility and security. -- Added support for the **scriptLocation** hidden property to explicitly define the task script path, improving performance and file organization. +- Improved stability and error handling for API requests and Kubernetes tasks. --- diff --git a/ROBUSTNESS_CHANGES.md b/ROBUSTNESS_CHANGES.md deleted file mode 100644 index 228ff81..0000000 --- a/ROBUSTNESS_CHANGES.md +++ /dev/null @@ -1,116 +0,0 @@ -# Robustness Improvements — `digitalai/release/integration` - -This document describes the robustness fixes applied across the integration SDK -package and the accompanying test updates. The behaviour exercised by the sample -input context (a `containerExamples.Hello` task) is preserved — the changes -harden failure paths, networking, and diagnostics without altering the happy -path. - -## Sample input context used for verification - -```json -{ - "release": { - "id": "Applications/Folder1f65c7220b394afbb941154342fd9fc6/Release31de09e95c8e4ebb95aaed29a8082d0b", - "automatedTaskAsUser": { "username": null, "password": null } - }, - "task": { - "id": "Applications/Folder1f65c7220b394afbb941154342fd9fc6/Release31de09e95c8e4ebb95aaed29a8082d0b/Phase723a601c78804f7dbcaa8b05b83708f5/Task3a35b67b42b6428b854857fba470b39a", - "type": "containerExamples.Hello", - "properties": [ - { "name": "capabilities", "value": ["remote"], "kind": "SET_OF_STRING", "category": "input", "password": false }, - { "name": "yourName", "value": "World", "kind": "STRING", "category": "input", "password": false }, - { "name": "greeting", "value": null, "kind": "STRING", "category": "output", "password": false } - ] - } -} -``` - -Expected output context: `{"exitCode": 0, "jobErrorMessage": "", "outputProperties": {"greeting": "Hello World"}, "reportingRecords": []}`. - ---- - -## `wrapper.py` - -| # | Change | Reason | -|---|--------|--------| -| 1 | **HTTP timeouts added** to the `requests.get` fetch and all callback POSTs (`HTTP_CONNECT_TIMEOUT` / `HTTP_READ_TIMEOUT`, env-overridable, default 10s/60s). | Without a timeout a hung or unreachable server stalls the runner **forever**, blocking the task slot. | -| 2 | **Single shared `urllib3.PoolManager`** (`_http_pool`) instead of constructing a new `PoolManager()` on every request. | Creating a fresh pool per call defeats connection pooling/keep-alive and leaks sockets. | -| 3 | **Callback HTTP status is now checked.** New `_post_callback()` helper raises on HTTP status `>= 400`. | Previously `urllib3` does not raise on 4xx/5xx, so a failed delivery was silently treated as success and never retried. | -| 4 | **`execute_task` `finally` guarded.** It no longer calls `dai_task_object.get_output_context()` blindly; if the task object is `None` or has no output context, it reports a failure context instead. | If construction or `execute_task` failed before producing an output context, the `finally` block raised a *second* exception that masked the original error. | -| 5 | **Removed dead status check** (`if response.status_code != 200`) after `raise_for_status()`. | `raise_for_status()` already raises on non-2xx; the follow-up check was dead code and would have wrongly rejected `201`/`204`. | -| 6 | **Task type format validated** before `task_type.split(".")[1]`. | A malformed `type` (no `.`) previously raised an opaque `IndexError`; now it raises a clear `ValueError`. | -| 7 | **`find_class_file` hardened** — skips files that can't be read/parsed (`SyntaxError`, `UnicodeDecodeError`, `OSError`) and reads them as UTF-8. | One unreadable or non-UTF-8 `.py` file anywhere under the CWD previously aborted the entire class search. | -| 8 | **`Optional[...]` type hints** for module globals (`input_context`, `dai_task_object`) and return-type hints added. | The previous `: InputContext = None` annotations lied to type checkers/IDEs. | -| 9 | Removed commented-out `#dai_logger.info(...)` dead lines; simplified redundant `not x or len(x) == 0` to `not x`; documented the intentional double-base64 decode of fetch/callback URLs. | Readability and maintainability. | - -## `masked_io.py` - -| # | Change | Reason | -|---|--------|--------| -| 1 | `write()` now **returns the number of characters written** (`len(s)`). | `TextIOBase.write` is contractually required to return an int; libraries (e.g. `print`, logging) may rely on it, and returning `None` can raise `TypeError`. | -| 2 | Secrets coerced with `str(secret)` before `replace`. | Defensive: a non-string secret value would otherwise raise inside `str.replace`. | - -## `k8s.py` - -| # | Change | Reason | -|---|--------|--------| -| 1 | `split_secret_resource_data` now **raises `ValueError`** on empty or malformed input instead of silently returning `("", "", "")`. | The blank tuple only surfaced later as a confusing Kubernetes API error (`read_namespaced_secret("", "")`); failing fast with a clear message is far easier to diagnose. | - -## `watcher.py` - -| # | Change | Reason | -|---|--------|--------| -| 1 | **Validates `INPUT_CONTEXT_SECRET` / `RUNNER_NAMESPACE`** before building the field selector. | If unset, the old code produced `metadata.name=None` and watched the wrong/empty namespace silently. | -| 2 | Watch stream is **always stopped** via `try/finally: w.stop()`; guards `secret.data` being `None`. | Prevents leaking the long-lived streaming connection and a `NoneType` crash on secrets without data. | - -## `base_task.py` - -| # | Change | Reason | -|---|--------|--------| -| 1 | `get_task_user()` returns `None` when there is no release context (instead of raising `AttributeError`); return type is now `Optional[...]`. | Tasks without a "Run as user" context could crash with an opaque attribute error. | -| 2 | `_validate_api_credentials()` handles a missing user safely and still raises the existing clear `ValueError`. | Same root cause — a clearer, intentional error message instead of `AttributeError`. | - ---- - -## Tests - -### `tests/release/integration/test_base_task.py` — rewritten -- **Fixed a broken import.** The file imported `_find_folder_id` / `_phase_id_from` from `base_task`, which no longer exist (id parsing was refactored into `ids.Ids`). The module failed to even collect (`ImportError`). -- Re-pointed id tests at the current `Ids` API and added coverage for `segment_name` / `parent_id` / `is_root`. -- Added `TestBaseTaskOutput`: success/error exit codes, output-property validation, input-property guard, and the new `get_task_user()` / credential-validation behaviour. -- Added `TestAutomatedTaskAsUser` for the **"Run as user" (`automatedTaskAsUser`) username/password**: `get_task_user()` returns the populated credentials; `get_release_api_client()` passes `(server_url, username, password)` to `ReleaseAPIClient` (mocked); and it raises `ValueError` for missing password, missing username, blank credentials, or missing server URL. - -### `tests/release/integration/test_wrapper.py` — rewritten for robustness -- Now **self-contained**: writes the sample input context itself and removes any stale `output.json` in `setUp`. -- Runs the wrapper with an explicit `cwd` and `env` (no longer depends on ambient working directory / global `os.environ` mutation), and uses `sys.executable` instead of bare `python`. -- **Asserts the subprocess exit code** and that `output.json` was produced (the old test ignored the return code, so a crashing wrapper could pass), then compares against the expected output context. - -### `tests/release/integration/input.json` -- Updated to the sample `containerExamples.Hello` input context (with `automatedTaskAsUser` set to `admin`/`admin`). - -### `tests/release/integration/test_wrapper_k8s.py` — new -End-to-end coverage of the **Kubernetes execution path** (input read from a -Secret, result written to a Secret and pushed to a callback URL) using a fully -mocked Kubernetes client and callback transport — no cluster or network needed. -A blank `session-key` selects the NoOp encryptor so the test stays independent of -AES key material; helpers reproduce the real Secret base64 (and double-base64 -URL) encoding. - -Cases (11): -- **Input from Secret:** decodes the input context, sets `callback_url`, and registers the "Run as user" password for masking. -- **Fetch-URL fallback:** empty `input` triggers an HTTP fetch — and asserts a `timeout` is passed (regression guard for the robustness fix). -- **Missing fetch URL → `ValueError`.** -- **Secret read failure propagates** out of `get_task_details` (so `run()` reports failure). -- **Result written to Secret + callback pushed** to the correctly decoded URL. -- **Result > 1Mb** skips the Secret write but still pushes the callback. -- **Secret write failure is swallowed and logged** (`update_output_context` never raises) and no callback is attempted. -- **Callback retry:** first push fails → `retry_push_result_infinitely` re-reads the Secret and succeeds. -- **`should_retry_callback_request` matrix:** size × output-file presence (3 cases). - -## Verification - -``` -$ python -m pytest tests/release/integration/ -31 passed -``` diff --git a/pyproject.toml b/pyproject.toml index 03003cf..c8c1a03 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [tool.hatch.build] exclude = [ - ".gitignore", "tests/*", ".github/*", "DEVELOPMENT.md", "ROBUSTNESS_CHANGES.md", "uv.lock" + ".gitignore", "tests/*", ".github/*", "DEVELOPMENT.md", "uv.lock" ] [tool.hatch.build.targets.wheel] @@ -12,7 +12,7 @@ packages = ["digitalai"] [project] name = "digitalai_release_sdk" -version = "26.1.0" +version = "26.3.0b1" authors = [ { name="Digital.ai", email="pypi-devops@digital.ai" }, ] @@ -27,7 +27,7 @@ dependencies = [ 'kubernetes>=35.0.0, <36.0.0' ] classifiers = [ - "Development Status :: 5 - Production/Stable", + "Development Status :: 4 - Beta", "Intended Audience :: Developers", "Topic :: Software Development :: Libraries", "Programming Language :: Python :: 3", From 4213557cef37b42bfbd195e63930dec9af3655d7 Mon Sep 17 00:00:00 2001 From: Balaji Venkatesan Date: Tue, 16 Jun 2026 10:09:10 +0530 Subject: [PATCH 12/23] Updated license --- LICENSE | 22 ++++++++++++--- pyproject.toml | 75 +++++++++++++++++++++++++++++++++----------------- 2 files changed, 67 insertions(+), 30 deletions(-) diff --git a/LICENSE b/LICENSE index 2134e88..c1bcccd 100644 --- a/LICENSE +++ b/LICENSE @@ -1,7 +1,21 @@ -Copyright 2023 Digital.ai +MIT License -Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: +Copyright (c) 2026 Digital.ai -The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. \ No newline at end of file +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/pyproject.toml b/pyproject.toml index c8c1a03..8fbb00d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,31 +1,12 @@ -[build-system] -requires = ["hatchling"] -build-backend = "hatchling.build" - -[tool.hatch.build] -exclude = [ - ".gitignore", "tests/*", ".github/*", "DEVELOPMENT.md", "uv.lock" -] - -[tool.hatch.build.targets.wheel] -packages = ["digitalai"] - [project] name = "digitalai_release_sdk" version = "26.3.0b1" -authors = [ - { name="Digital.ai", email="pypi-devops@digital.ai" }, -] description = "Digital.ai Release SDK" readme = "README.md" +license = "MIT" +license-files = ["LICENSE"] +authors = [{ name = "Digital.ai", email = "pypi-devops@digital.ai" }] requires-python = ">=3.10" -dependencies = [ - 'dataclasses-json>=0.6.7, <1.0.0', - 'pycryptodomex>=3.23.0, <4.0.0', - 'python-dateutil>=2.9.0, <3.0.0', - 'requests>=2.32.5, <3.0.0', - 'kubernetes>=35.0.0, <36.0.0' -] classifiers = [ "Development Status :: 4 - Beta", "Intended Audience :: Developers", @@ -35,15 +16,57 @@ classifiers = [ "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", - "License :: OSI Approved :: MIT License", "Operating System :: OS Independent" ] +dependencies = [ + "dataclasses-json>=0.6.7, <1.0.0", + "pycryptodomex>=3.23.0, <4.0.0", + "python-dateutil>=2.9.0, <3.0.0", + "requests>=2.32.5, <3.0.0", + "kubernetes>=35.0.0, <36.0.0", +] + +[project.optional-dependencies] +dev = [ + "pytest>=8.0.0", +] [project.urls] Homepage = "https://digital.ai/" Documentation = "https://docs.digital.ai/release/docs/category/python-sdk" -[dependency-groups] -dev = [ - "pytest>=8.0.0", +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +# Files never shipped in any build artifact +[tool.hatch.build] +exclude = [ + "**/__pycache__", + "**/*.py[cod]", + ".coverage", + ".mypy_cache", + ".pytest_cache", + ".ruff_cache", + "tests", + "docs", + "DEVELOPMENT.md", + "uv.lock", + ".gitignore", + ".github", +] + +# Wheel ships only the digitalai package tree +[tool.hatch.build.targets.wheel] +packages = ["digitalai"] + +# Source distribution: package code + project metadata only (no tests) +[tool.hatch.build.targets.sdist] +include = [ + "/digitalai", + "/README.md", + "/pyproject.toml", ] + +[tool.pytest.ini_options] +testpaths = ["tests"] From 77c55a1b54af1d02af1e498a094ed2de0ad3783e Mon Sep 17 00:00:00 2001 From: Balaji Venkatesan Date: Tue, 16 Jun 2026 12:21:02 +0530 Subject: [PATCH 13/23] Updated development md file --- DEVELOPMENT.md | 33 +++++++++++++++++++++++++++------ 1 file changed, 27 insertions(+), 6 deletions(-) diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 59b071f..60b7022 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -1,6 +1,6 @@ # Development -This project uses [uv](https://docs.astral.sh/uv/) for dependency management and packaging. +This project uses [`uv`](https://docs.astral.sh/uv/) for dependency management and packaging. ## Prerequisites @@ -14,18 +14,39 @@ curl -LsSf https://astral.sh/uv/install.sh | sh powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex" ``` -## Common tasks +## Set up a dev environment ```sh -# Install dependencies (creates a virtual environment and installs dev tools) -uv sync +# Installs runtime + dev dependencies into a managed virtual environment +uv sync --extra dev +``` + +## Build + +The wheel packages the `digitalai` tree. + +```sh +# Build both sdist and wheel into ./dist +uv build + +# Or build a single artifact +uv build --wheel +uv build --sdist +``` + +## Run tests +```sh # Run the tests cd tests/release/integration uv run python -m unittest test_wrapper.py +``` -# Build the distribution -uv build +## Publish to PyPI + +```sh +# Publish the distribution (uploads everything in dist/ to PyPI) +uv publish --token ``` ## Dependency management From 3703d45a4716994af041e5c9da580bcc753f8a64 Mon Sep 17 00:00:00 2001 From: Balaji Venkatesan Date: Tue, 16 Jun 2026 23:19:04 +0530 Subject: [PATCH 14/23] Update for refactoring dependency --- README.md | 37 ++- digitalai/release/integration/__init__.py | 1 + .../release/integration/api_base_task.py | 296 ++++++++++++++++++ digitalai/release/integration/base_task.py | 10 +- digitalai/release/release_api_client.py | 96 ------ pyproject.toml | 3 +- .../release/integration/test_api_base_task.py | 234 ++++++++++++++ tests/release/integration/test_base_task.py | 2 +- tests/release/test_release_api_client.py | 73 ----- uv.lock | 168 +++++++++- 10 files changed, 739 insertions(+), 181 deletions(-) create mode 100644 digitalai/release/integration/api_base_task.py delete mode 100644 digitalai/release/release_api_client.py create mode 100644 tests/release/integration/test_api_base_task.py delete mode 100644 tests/release/test_release_api_client.py diff --git a/README.md b/README.md index 73ddfb4..0985cd2 100644 --- a/README.md +++ b/README.md @@ -4,10 +4,9 @@ The **Digital.ai Release Python SDK** (`digitalai-release-sdk`) provides a set o ## Features - Define custom tasks using the `BaseTask` abstract class. +- Subclass `ApiBaseTask` to get every Release v1 API as a cached property (`releaseApi`, `phaseApi`, `taskApi`, ...), all sharing one pre-configured client built from the task's "Run as user" context. - Easily manage input and output properties. - Interact with the Digital.ai Release environment seamlessly. -- Simplified API client for efficient communication with Release API, with support for username/password or personal access token authentication. -- Built-in helpers to resolve Release entity IDs (release, phase, task, and folder). ## Installation @@ -17,6 +16,8 @@ Install the SDK using `pip`: pip install digitalai-release-sdk ``` +> **Note:** The SDK depends on [`digitalai-release-api-client`](https://pypi.org/project/digitalai-release-api-client/), which is installed automatically. + ## Getting Started ### Example Task: `hello.py` @@ -44,12 +45,44 @@ class Hello(BaseTask): self.set_output_property('greeting', greeting) ``` +### Example Task using the Release API: `ApiBaseTask` + +Subclass `ApiBaseTask` to call the Release v1 REST API without building a client +yourself. Every API is exposed as a lazily created, cached property, all sharing +a single client built from the task's "Run as user" context: + +```python +from digitalai.release.integration.api_base_task import ApiBaseTask + + +class ShowVersion(ApiBaseTask): + + def execute(self) -> None: + release = self.releaseApi.getRelease(self.get_release_id()) + self.add_comment(f"Working on {release.title}") +``` + ## Changelog ### Version 26.3.0 (Beta) +#### ⚠️ Breaking Changes + +- **`ReleaseAPIClient` moved to the standalone [`digitalai-release-api-client`](https://pypi.org/project/digitalai-release-api-client/) package** so the API client can be used on its own. The SDK now depends on it and installs it automatically — only the import path changes (class names, method signatures, and behavior are unchanged): + + ```python + # ❌ Old — bundled inside the SDK + from digitalai.release.release_api_client import ReleaseAPIClient + + # ✅ New — provided by digitalai-release-api-client + from com.xebialabs.xlrelease.release_api_client import ReleaseAPIClient + ``` + + `BaseTask.get_release_api_client()` still returns a `ReleaseAPIClient` exactly as before. + #### 🚀 Features +- Added the `ApiBaseTask` base class, exposing every Release v1 API as a lazily created, cached property. - `get_release_api_client()` now supports optional credentials/server URL and `requests` library arguments. - Added `get_phase_id()` and `get_folder_id()` helper methods to `BaseTask`. diff --git a/digitalai/release/integration/__init__.py b/digitalai/release/integration/__init__.py index 12ef7ad..dd7a247 100644 --- a/digitalai/release/integration/__init__.py +++ b/digitalai/release/integration/__init__.py @@ -1,4 +1,5 @@ from .base_task import BaseTask +from .api_base_task import ApiBaseTask from .input_context import InputContext from .output_context import OutputContext from .exceptions import AbortException diff --git a/digitalai/release/integration/api_base_task.py b/digitalai/release/integration/api_base_task.py new file mode 100644 index 0000000..1c28dd8 --- /dev/null +++ b/digitalai/release/integration/api_base_task.py @@ -0,0 +1,296 @@ +from typing import Dict, Type, TypeVar + +from digitalai.release.integration.base_task import BaseTask + +from com.xebialabs.xlrelease.release_api_client import ReleaseAPIClient +from com.xebialabs.xlrelease.domain.folder import Folder +from com.xebialabs.xlrelease.domain.phase import Phase +from com.xebialabs.xlrelease.domain.release import Release +from com.xebialabs.xlrelease.domain.task import Task + +from com.xebialabs.xlrelease.api.v1.activity_logs_api import ActivityLogsApi +from com.xebialabs.xlrelease.api.v1.application_api import ApplicationApi +from com.xebialabs.xlrelease.api.v1.archive_api import ArchiveApi +from com.xebialabs.xlrelease.api.v1.attachment_api import AttachmentApi +from com.xebialabs.xlrelease.api.v1.category_api import CategoryApi +from com.xebialabs.xlrelease.api.v1.configuration_api import ConfigurationApi +from com.xebialabs.xlrelease.api.v1.delivery_api import DeliveryApi +from com.xebialabs.xlrelease.api.v1.delivery_pattern_api import DeliveryPatternApi +from com.xebialabs.xlrelease.api.v1.dsl_api import DslApi +from com.xebialabs.xlrelease.api.v1.environment_api import EnvironmentApi +from com.xebialabs.xlrelease.api.v1.environment_label_api import EnvironmentLabelApi +from com.xebialabs.xlrelease.api.v1.environment_reservation_api import ( + EnvironmentReservationApi, +) +from com.xebialabs.xlrelease.api.v1.environment_stage_api import EnvironmentStageApi +from com.xebialabs.xlrelease.api.v1.folder_api import FolderApi +from com.xebialabs.xlrelease.api.v1.folder_versioning_api import FolderVersioningApi +from com.xebialabs.xlrelease.api.v1.permissions_api import PermissionsApi +from com.xebialabs.xlrelease.api.v1.phase_api import PhaseApi +from com.xebialabs.xlrelease.api.v1.release_api import ReleaseApi +from com.xebialabs.xlrelease.api.v1.report_api import ReportApi +from com.xebialabs.xlrelease.api.v1.risk_api import RiskApi +from com.xebialabs.xlrelease.api.v1.roles_api import RolesApi +from com.xebialabs.xlrelease.api.v1.search_api import SearchApi +from com.xebialabs.xlrelease.api.v1.settings_api import SettingsApi +from com.xebialabs.xlrelease.api.v1.task_api import TaskApi +from com.xebialabs.xlrelease.api.v1.task_reporting_api import TaskReportingApi +from com.xebialabs.xlrelease.api.v1.team_api import TeamApi +from com.xebialabs.xlrelease.api.v1.template_api import TemplateApi +from com.xebialabs.xlrelease.api.v1.triggers_api import TriggersApi +from com.xebialabs.xlrelease.api.v1.user_api import UserApi +from com.xebialabs.xlrelease.api.v1.variable_api import VariableApi + +T = TypeVar("T") + + +class ApiBaseTask(BaseTask): + """ + Base class for Release container tasks that need the v1 REST API. + + Subclass this instead of :class:`BaseTask` to get a ready-to-use, lazily + created instance of every ``com.xebialabs.xlrelease.api.v1`` wrapper as a + property (``releaseApi``, ``phaseApi``, ``taskApi``, ...). All wrappers + share a single, pre-configured :class:`ReleaseAPIClient` built from the + task's "Run as user" context (credentials + server URL), so the client and + each API object are created only once and only when first accessed. + + Example:: + + class MyTask(ApiBaseTask): + def execute(self) -> None: + release = self.releaseApi.getRelease(self.get_release_id()) + self.add_comment(f"Working on {release.title}") + """ + + def __init__(self): + super().__init__() + self._api_client: ReleaseAPIClient = None + self._api_instances: Dict[Type, object] = {} + + # -- client / instance management --------------------------------------- + + @property + def apiClient(self) -> ReleaseAPIClient: + """The shared :class:`ReleaseAPIClient`, created on first access.""" + if self._api_client is None: + self._api_client = self.get_release_api_client() + return self._api_client + + def _api(self, api_class: Type[T]) -> T: + """Return a cached instance of ``api_class``, creating it on first use.""" + instance = self._api_instances.get(api_class) + if instance is None: + instance = api_class(self.apiClient) + self._api_instances[api_class] = instance + return instance + + def reset_api_clients(self) -> None: + """Drop the cached client and API instances (e.g. to re-authenticate).""" + self._api_client = None + self._api_instances = {} + + # -- API wrappers ------------------------------------------------------- + + @property + def activityLogsApi(self) -> ActivityLogsApi: + return self._api(ActivityLogsApi) + + @property + def applicationApi(self) -> ApplicationApi: + return self._api(ApplicationApi) + + @property + def archiveApi(self) -> ArchiveApi: + return self._api(ArchiveApi) + + @property + def attachmentApi(self) -> AttachmentApi: + return self._api(AttachmentApi) + + @property + def categoryApi(self) -> CategoryApi: + return self._api(CategoryApi) + + @property + def configurationApi(self) -> ConfigurationApi: + return self._api(ConfigurationApi) + + @property + def deliveryApi(self) -> DeliveryApi: + return self._api(DeliveryApi) + + @property + def deliveryPatternApi(self) -> DeliveryPatternApi: + return self._api(DeliveryPatternApi) + + @property + def dslApi(self) -> DslApi: + return self._api(DslApi) + + @property + def environmentApi(self) -> EnvironmentApi: + return self._api(EnvironmentApi) + + @property + def environmentLabelApi(self) -> EnvironmentLabelApi: + return self._api(EnvironmentLabelApi) + + @property + def environmentReservationApi(self) -> EnvironmentReservationApi: + return self._api(EnvironmentReservationApi) + + @property + def environmentStageApi(self) -> EnvironmentStageApi: + return self._api(EnvironmentStageApi) + + @property + def folderApi(self) -> FolderApi: + return self._api(FolderApi) + + @property + def folderVersioningApi(self) -> FolderVersioningApi: + return self._api(FolderVersioningApi) + + @property + def permissionsApi(self) -> PermissionsApi: + return self._api(PermissionsApi) + + @property + def phaseApi(self) -> PhaseApi: + return self._api(PhaseApi) + + @property + def releaseApi(self) -> ReleaseApi: + return self._api(ReleaseApi) + + @property + def reportApi(self) -> ReportApi: + return self._api(ReportApi) + + @property + def riskApi(self) -> RiskApi: + return self._api(RiskApi) + + @property + def rolesApi(self) -> RolesApi: + return self._api(RolesApi) + + @property + def searchApi(self) -> SearchApi: + return self._api(SearchApi) + + @property + def settingsApi(self) -> SettingsApi: + return self._api(SettingsApi) + + @property + def taskApi(self) -> TaskApi: + return self._api(TaskApi) + + @property + def taskReportingApi(self) -> TaskReportingApi: + return self._api(TaskReportingApi) + + @property + def teamApi(self) -> TeamApi: + return self._api(TeamApi) + + @property + def templateApi(self) -> TemplateApi: + return self._api(TemplateApi) + + @property + def triggersApi(self) -> TriggersApi: + return self._api(TriggersApi) + + @property + def userApi(self) -> UserApi: + return self._api(UserApi) + + @property + def variableApi(self) -> VariableApi: + return self._api(VariableApi) + + # -- current-context helpers -------------------------------------------- + # Convenience methods that resolve the release object the task is running + # in, mirroring the helpers the Jython script API offers on the server + # (XLReleaseApi.py). Each derives the relevant id from the task's context + # and fetches the object through the matching API wrapper above. + + def getCurrentTask(self) -> Task: + """ + Return the task that is running this code. + + Fetches the task via ``taskApi`` using the task's own id. + """ + return self.taskApi.getTask(self.get_task_id()) + + def getCurrentPhase(self) -> Phase: + """ + Return the phase that contains this task. + + The phase id is derived from the task id, then fetched via ``phaseApi``. + """ + return self.phaseApi.getPhase(self.get_phase_id()) + + def getCurrentRelease(self) -> Release: + """ + Return the release this task belongs to. + + Fetches the release via ``releaseApi`` using the task's release id, so + the caller does not need to know or substitute the id itself. + """ + return self.releaseApi.getRelease(self.get_release_id()) + + def getCurrentFolder(self) -> Folder: + """ + Return the folder that contains the current release. + + The folder id is derived from the release id, then fetched via + ``folderApi``. + """ + return self.folderApi.getFolder(self.get_folder_id()) + + def getTasksByTitle(self, taskTitle: str, phaseTitle: str | None = None, + releaseId: str | None = None) -> list[Task]: + """ + Find tasks by title. + + :param taskTitle: the task title to search for. + :param phaseTitle: optional phase title to scope the search; searches the + whole release when omitted. + :param releaseId: optional release to search; the current release when + omitted. + :return: the matching tasks. + """ + return self.taskApi.searchTasksByTitle( + taskTitle, releaseId or self.get_release_id(), phaseTitle) + + def getPhasesByTitle(self, phaseTitle: str, + releaseId: str | None = None) -> list[Phase]: + """ + Find phases by title. + + :param phaseTitle: the phase title to search for. + :param releaseId: optional release to search; the current release when + omitted. + :return: the matching phases. + """ + return self.phaseApi.searchPhasesByTitle( + phaseTitle, releaseId or self.get_release_id()) + + def getReleasesByTitle(self, releaseTitle: str) -> list[Release]: + """ + Find releases by title. + + :param releaseTitle: the release title to search for. + :return: the matching releases. + """ + return self.releaseApi.searchReleasesByTitle(releaseTitle) + + def getVersion(self) -> str | None: + """ + Return the version of this Digital.ai Release instance. + """ + return self.settingsApi.getInstanceInformation().get('version') diff --git a/digitalai/release/integration/base_task.py b/digitalai/release/integration/base_task.py index 9021d37..fab392b 100644 --- a/digitalai/release/integration/base_task.py +++ b/digitalai/release/integration/base_task.py @@ -7,7 +7,7 @@ from .exceptions import AbortException from .ids import Ids from .logger import dai_logger -from digitalai.release.release_api_client import ReleaseAPIClient +from com.xebialabs.xlrelease.release_api_client import ReleaseAPIClient class BaseTask(ABC): @@ -167,6 +167,7 @@ def get_release_api_client(self, username: str = None, password: str = None, personal_access_token: str = None, + timeout: float | tuple[float, float] | None = None, **kwargs) -> ReleaseAPIClient: """ Returns a ReleaseAPIClient object. @@ -179,7 +180,9 @@ def get_release_api_client(self, :param username: Optional username. Defaults to the task user's username. :param password: Optional password. Defaults to the task user's password. :param personal_access_token: Optional personal access token for authentication. - :param kwargs: Additional session parameters (e.g., headers, timeout). + :param timeout: Optional default timeout (in seconds) applied to every request. + Accepts a single float or a (connect, read) tuple. Can be overridden per call. + :param kwargs: Additional session parameters (e.g., headers). """ task_user = self.get_task_user() server_address = server_address or self.get_release_server_url() @@ -192,12 +195,13 @@ def get_release_api_client(self, ) return ReleaseAPIClient(server_address, personal_access_token=personal_access_token, + timeout=timeout, **kwargs) username = username or (task_user and task_user.username) password = password or (task_user and task_user.password) self._validate_api_credentials(server_address, username, password) - return ReleaseAPIClient(server_address, username, password, **kwargs) + return ReleaseAPIClient(server_address, username, password, timeout=timeout, **kwargs) def _validate_api_credentials(self, server_address: str = None, username: str = None, password: str = None) -> None: diff --git a/digitalai/release/release_api_client.py b/digitalai/release/release_api_client.py deleted file mode 100644 index 8969057..0000000 --- a/digitalai/release/release_api_client.py +++ /dev/null @@ -1,96 +0,0 @@ -import requests - - -class ReleaseAPIClient: - """ - A client for interacting with the Release API. - Supports authentication via username/password or personal access token. - """ - - def __init__(self, server_address, username=None, password=None, personal_access_token=None, **kwargs): - """ - Initializes the API client. - - :param server_address: Base URL of the Release API server. - :param username: Optional username for basic authentication. - :param password: Optional password for basic authentication. - :param personal_access_token: Optional personal access token for authentication. - :param kwargs: Additional session parameters (e.g., headers, timeout). - """ - if not server_address: - raise ValueError("server_address must not be empty.") - - self.server_address = server_address.rstrip('/') # Remove trailing slash if present - self.session = requests.Session() - self.session.headers.update({"Accept": "application/json"}) - - # Set authentication method - if username and password: - self.session.auth = (username, password) - elif personal_access_token: - self.session.headers.update({"x-release-personal-token": personal_access_token}) - else: - raise ValueError("Either username and password or a personal access token must be provided.") - - # Apply additional session configurations - for key, value in kwargs.items(): - if key == 'headers': - self.session.headers.update(value) # Merge custom headers - elif hasattr(self.session, key) and key != 'auth': # Skip 'auth' key - setattr(self.session, key, value) - - def _request(self, method, endpoint, params=None, json=None, data=None, **kwargs): - """ - Internal method to send an HTTP request. - - :param method: HTTP method (GET, POST, PUT, DELETE, PATCH). - :param endpoint: API endpoint (relative path). - :param params: Optional query parameters. - :param json: Optional JSON payload. - :param data: Optional raw data payload. - :param kwargs: Additional request options. - :return: Response object. - """ - if not endpoint: - raise ValueError("Endpoint must not be empty.") - - kwargs.pop('auth', None) # Remove 'auth' key if present to avoid conflicts - url = f"{self.server_address}/{endpoint.lstrip('/')}" # Construct full URL - - response = self.session.request( - method, url, params=params, data=data, json=json, **kwargs - ) - - return response - - def get(self, endpoint, params=None, **kwargs): - """Sends a GET request to the specified endpoint.""" - return self._request("GET", endpoint, params=params, **kwargs) - - def post(self, endpoint, json=None, data=None, **kwargs): - """Sends a POST request to the specified endpoint.""" - return self._request("POST", endpoint, data=data, json=json, **kwargs) - - def put(self, endpoint, json=None, data=None, **kwargs): - """Sends a PUT request to the specified endpoint.""" - return self._request("PUT", endpoint, data=data, json=json, **kwargs) - - def delete(self, endpoint, params=None, **kwargs): - """Sends a DELETE request to the specified endpoint.""" - return self._request("DELETE", endpoint, params=params, **kwargs) - - def patch(self, endpoint, json=None, data=None, **kwargs): - """Sends a PATCH request to the specified endpoint.""" - return self._request("PATCH", endpoint, data=data, json=json, **kwargs) - - def close(self): - """Closes the session.""" - self.session.close() - - def __enter__(self): - """Enables the use of 'with' statements for automatic resource management.""" - return self - - def __exit__(self, exc_type, exc_value, traceback): - """Ensures the session is closed when exiting a 'with' block.""" - self.close() \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 8fbb00d..e5f47c4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "digitalai_release_sdk" -version = "26.3.0b1" +version = "26.3.0b2" description = "Digital.ai Release SDK" readme = "README.md" license = "MIT" @@ -19,6 +19,7 @@ classifiers = [ "Operating System :: OS Independent" ] dependencies = [ + "digitalai-release-api-client==26.3.0b2", "dataclasses-json>=0.6.7, <1.0.0", "pycryptodomex>=3.23.0, <4.0.0", "python-dateutil>=2.9.0, <3.0.0", diff --git a/tests/release/integration/test_api_base_task.py b/tests/release/integration/test_api_base_task.py new file mode 100644 index 0000000..5c66374 --- /dev/null +++ b/tests/release/integration/test_api_base_task.py @@ -0,0 +1,234 @@ +import unittest +from types import SimpleNamespace +from unittest.mock import MagicMock + +from digitalai.release.integration.input_context import ( + AutomatedTaskAsUserContext, + ReleaseContext, +) +from digitalai.release.integration.api_base_task import ApiBaseTask + +from com.xebialabs.xlrelease.release_api_client import ReleaseAPIClient +from com.xebialabs.xlrelease.api.v1.activity_logs_api import ActivityLogsApi +from com.xebialabs.xlrelease.api.v1.application_api import ApplicationApi +from com.xebialabs.xlrelease.api.v1.archive_api import ArchiveApi +from com.xebialabs.xlrelease.api.v1.attachment_api import AttachmentApi +from com.xebialabs.xlrelease.api.v1.category_api import CategoryApi +from com.xebialabs.xlrelease.api.v1.configuration_api import ConfigurationApi +from com.xebialabs.xlrelease.api.v1.delivery_api import DeliveryApi +from com.xebialabs.xlrelease.api.v1.delivery_pattern_api import DeliveryPatternApi +from com.xebialabs.xlrelease.api.v1.dsl_api import DslApi +from com.xebialabs.xlrelease.api.v1.environment_api import EnvironmentApi +from com.xebialabs.xlrelease.api.v1.environment_label_api import EnvironmentLabelApi +from com.xebialabs.xlrelease.api.v1.environment_reservation_api import ( + EnvironmentReservationApi, +) +from com.xebialabs.xlrelease.api.v1.environment_stage_api import EnvironmentStageApi +from com.xebialabs.xlrelease.api.v1.folder_api import FolderApi +from com.xebialabs.xlrelease.api.v1.folder_versioning_api import FolderVersioningApi +from com.xebialabs.xlrelease.api.v1.permissions_api import PermissionsApi +from com.xebialabs.xlrelease.api.v1.phase_api import PhaseApi +from com.xebialabs.xlrelease.api.v1.release_api import ReleaseApi +from com.xebialabs.xlrelease.api.v1.report_api import ReportApi +from com.xebialabs.xlrelease.api.v1.risk_api import RiskApi +from com.xebialabs.xlrelease.api.v1.roles_api import RolesApi +from com.xebialabs.xlrelease.api.v1.search_api import SearchApi +from com.xebialabs.xlrelease.api.v1.settings_api import SettingsApi +from com.xebialabs.xlrelease.api.v1.task_api import TaskApi +from com.xebialabs.xlrelease.api.v1.task_reporting_api import TaskReportingApi +from com.xebialabs.xlrelease.api.v1.team_api import TeamApi +from com.xebialabs.xlrelease.api.v1.template_api import TemplateApi +from com.xebialabs.xlrelease.api.v1.triggers_api import TriggersApi +from com.xebialabs.xlrelease.api.v1.user_api import UserApi +from com.xebialabs.xlrelease.api.v1.variable_api import VariableApi + + +# Mapping of every ApiBaseTask property name to the wrapper class it must return. +API_PROPERTIES = { + "activityLogsApi": ActivityLogsApi, + "applicationApi": ApplicationApi, + "archiveApi": ArchiveApi, + "attachmentApi": AttachmentApi, + "categoryApi": CategoryApi, + "configurationApi": ConfigurationApi, + "deliveryApi": DeliveryApi, + "deliveryPatternApi": DeliveryPatternApi, + "dslApi": DslApi, + "environmentApi": EnvironmentApi, + "environmentLabelApi": EnvironmentLabelApi, + "environmentReservationApi": EnvironmentReservationApi, + "environmentStageApi": EnvironmentStageApi, + "folderApi": FolderApi, + "folderVersioningApi": FolderVersioningApi, + "permissionsApi": PermissionsApi, + "phaseApi": PhaseApi, + "releaseApi": ReleaseApi, + "reportApi": ReportApi, + "riskApi": RiskApi, + "rolesApi": RolesApi, + "searchApi": SearchApi, + "settingsApi": SettingsApi, + "taskApi": TaskApi, + "taskReportingApi": TaskReportingApi, + "teamApi": TeamApi, + "templateApi": TemplateApi, + "triggersApi": TriggersApi, + "userApi": UserApi, + "variableApi": VariableApi, +} + + +class _SampleApiTask(ApiBaseTask): + """Concrete ApiBaseTask used to exercise the base behaviour in tests.""" + + def execute(self) -> None: # pragma: no cover - never executed by the tests + pass + + +class TestApiBaseTask(unittest.TestCase): + """Integration tests for the ApiBaseTask base class.""" + + def setUp(self): + # Configure the task context the way the Release runtime would, so that + # get_release_api_client() builds a client from the "Run as user" details. + self.task = _SampleApiTask() + self.task.release_server_url = "http://localhost:5516" + self.task.release_context = ReleaseContext( + id="Applications/Release0000000000000000000000000000", + automated_task_as_user=AutomatedTaskAsUserContext( + username="admin", password="admin" + ), + ) + + def test_api_client_is_lazy_and_cached(self): + """apiClient is built on first access and the same instance is reused.""" + self.assertIsNone(self.task._api_client) + client = self.task.apiClient + self.assertIsInstance(client, ReleaseAPIClient) + self.assertIs(self.task.apiClient, client) + + def test_all_api_properties_return_correct_type(self): + """Every API property returns an instance of its wrapper class.""" + for name, api_class in API_PROPERTIES.items(): + with self.subTest(api=name): + instance = getattr(self.task, name) + self.assertIsInstance(instance, api_class) + + def test_api_properties_are_cached(self): + """Accessing a property twice returns the very same cached instance.""" + for name in API_PROPERTIES: + with self.subTest(api=name): + first = getattr(self.task, name) + second = getattr(self.task, name) + self.assertIs(first, second) + + def test_all_apis_share_single_client(self): + """All wrappers are built on the one shared apiClient.""" + client = self.task.apiClient + for name in API_PROPERTIES: + with self.subTest(api=name): + self.assertIs(getattr(self.task, name).api, client) + + def test_reset_api_clients_clears_caches(self): + """reset_api_clients drops the client and forces fresh instances.""" + old_client = self.task.apiClient + old_release_api = self.task.releaseApi + + self.task.reset_api_clients() + self.assertIsNone(self.task._api_client) + self.assertEqual(self.task._api_instances, {}) + + self.assertIsNot(self.task.apiClient, old_client) + self.assertIsNot(self.task.releaseApi, old_release_api) + + def test_wired_client_performs_live_call(self): + """A property built by ApiBaseTask can talk to the live server.""" + info = self.task.settingsApi.getInstanceInformation() + self.assertIsInstance(info, dict) + self.assertIn("version", info) + print(f"ApiBaseTask wired client instance information: {info}") + + +# Realistic ids, matching the documented sample input context. The phase and +# folder ids are substrings the helpers derive from the task / release ids. +FOLDER_ID = "Applications/Folder1f65c7220b394afbb941154342fd9fc6" +RELEASE_ID = f"{FOLDER_ID}/Release31de09e95c8e4ebb95aaed29a8082d0b" +PHASE_ID = f"{RELEASE_ID}/Phase723a601c78804f7dbcaa8b05b83708f5" +TASK_ID = f"{PHASE_ID}/Task3a35b67b42b6428b854857fba470b39a" + + +class TestApiBaseTaskContextHelpers(unittest.TestCase): + """Tests for the getCurrent*/...ByTitle/getVersion convenience helpers.""" + + def _stub_task(self, release_id=RELEASE_ID, task_id=TASK_ID): + """Build an ApiBaseTask with every API wrapper replaced by a MagicMock. + + Returns the task plus the dict of stub APIs keyed by property name. The + class-level property overrides are removed again after the test. + """ + task = _SampleApiTask() + task.task_id = task_id + task.release_context = SimpleNamespace(id=release_id) + task._api_instances = {} + + apis = {name: MagicMock(name=name) for name in ( + "releaseApi", "taskApi", "phaseApi", "folderApi", "settingsApi")} + for name, stub in apis.items(): + setattr(type(task), name, property(lambda self, s=stub: s)) + self.addCleanup( + lambda: [delattr(type(task), name) for name in apis]) + return task, apis + + def test_get_current_release_and_task(self): + task, apis = self._stub_task() + apis["releaseApi"].getRelease.return_value = SimpleNamespace(title="My Release") + apis["taskApi"].getTask.return_value = SimpleNamespace(title="My Task") + + self.assertEqual(task.getCurrentRelease().title, "My Release") + self.assertEqual(task.getCurrentTask().title, "My Task") + apis["releaseApi"].getRelease.assert_called_once_with(RELEASE_ID) + apis["taskApi"].getTask.assert_called_once_with(TASK_ID) + + def test_get_current_phase_and_folder_derive_ids(self): + task, apis = self._stub_task() + apis["phaseApi"].getPhase.return_value = SimpleNamespace(title="My Phase") + apis["folderApi"].getFolder.return_value = SimpleNamespace(title="My Folder") + + self.assertEqual(task.getCurrentPhase().title, "My Phase") + self.assertEqual(task.getCurrentFolder().title, "My Folder") + apis["phaseApi"].getPhase.assert_called_once_with(PHASE_ID) + apis["folderApi"].getFolder.assert_called_once_with(FOLDER_ID) + + def test_search_helpers_default_to_current_release(self): + task, apis = self._stub_task() + apis["taskApi"].searchTasksByTitle.return_value = ["t"] + apis["phaseApi"].searchPhasesByTitle.return_value = ["p"] + apis["releaseApi"].searchReleasesByTitle.return_value = ["r"] + + self.assertEqual(task.getTasksByTitle("Deploy"), ["t"]) + self.assertEqual(task.getPhasesByTitle("Prod"), ["p"]) + self.assertEqual(task.getReleasesByTitle("Nightly"), ["r"]) + # taskApi signature is (taskTitle, releaseId, phaseTitle). + apis["taskApi"].searchTasksByTitle.assert_called_once_with("Deploy", RELEASE_ID, None) + apis["phaseApi"].searchPhasesByTitle.assert_called_once_with("Prod", RELEASE_ID) + apis["releaseApi"].searchReleasesByTitle.assert_called_once_with("Nightly") + + def test_search_helpers_honor_explicit_arguments(self): + task, apis = self._stub_task() + apis["taskApi"].searchTasksByTitle.return_value = [] + + task.getTasksByTitle("Deploy", "Prod", "Release/Other") + + apis["taskApi"].searchTasksByTitle.assert_called_once_with( + "Deploy", "Release/Other", "Prod") + + def test_get_version(self): + task, apis = self._stub_task() + apis["settingsApi"].getInstanceInformation.return_value = { + "product": "Digital.ai Release", "edition": "enterprise", "version": "25.3.0"} + + self.assertEqual(task.getVersion(), "25.3.0") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/release/integration/test_base_task.py b/tests/release/integration/test_base_task.py index f5c3172..a2b5dd8 100644 --- a/tests/release/integration/test_base_task.py +++ b/tests/release/integration/test_base_task.py @@ -150,7 +150,7 @@ def test_get_release_api_client_passes_credentials(self): task = self._task("admin", "secret") with mock.patch("digitalai.release.integration.base_task.ReleaseAPIClient") as fake_client: client = task.get_release_api_client() - fake_client.assert_called_once_with(self.SERVER_URL, "admin", "secret") + fake_client.assert_called_once_with(self.SERVER_URL, "admin", "secret", timeout=None) self.assertIs(client, fake_client.return_value) def test_get_release_api_client_raises_when_password_missing(self): diff --git a/tests/release/test_release_api_client.py b/tests/release/test_release_api_client.py deleted file mode 100644 index c47ba03..0000000 --- a/tests/release/test_release_api_client.py +++ /dev/null @@ -1,73 +0,0 @@ -import unittest - -from digitalai.release.release_api_client import ReleaseAPIClient - - -class TestReleaseAPIClient(unittest.TestCase): - - @classmethod - def setUpClass(cls): - """Set up the API client before running tests.""" - cls.client = ReleaseAPIClient("http://localhost:5516", "admin", "admin") - cls.global_variable_id = None # Store ID at the class level - - @classmethod - def tearDownClass(cls): - """Close the API client session after all tests.""" - cls.client.close() - - def test_01_create_global_variable(self): - """Test creating a new global variable.""" - global_variable = { - "id": None, - "key": "global.testVar", - "type": "xlrelease.StringVariable", - "requiresValue": "false", - "showOnReleaseStart": "false", - "value": "test value" - } - response = self.client.post("/api/v1/config/Configuration/variables/global", json=global_variable) - self.assertEqual(response.status_code, 200, f"Unexpected status code: {response.status_code}") - - # Store ID in class attribute - TestReleaseAPIClient.global_variable_id = response.json().get("id") - print(f"Created global variable ID: {TestReleaseAPIClient.global_variable_id}") - - def test_02_update_global_variable(self): - """Test updating an existing global variable.""" - if not TestReleaseAPIClient.global_variable_id: - self.skipTest("Global variable ID is not set. Run test_01_create_global_variable first.") - - updated_variable = { - "id": TestReleaseAPIClient.global_variable_id, - "key": "global.testVar", - "type": "xlrelease.StringVariable", - "requiresValue": "false", - "showOnReleaseStart": "false", - "value": "updated test value" - } - - response = self.client.put(f"/api/v1/config/{TestReleaseAPIClient.global_variable_id}", json=updated_variable) - self.assertEqual(response.status_code, 200, f"Unexpected status code: {response.status_code}") - print("Global variable updated successfully.") - - def test_03_get_global_variable(self): - """Test retrieving the global variable.""" - if not TestReleaseAPIClient.global_variable_id: - self.skipTest("Global variable ID is not set. Run test_01_create_global_variable first.") - - response = self.client.get(f"/api/v1/config/{TestReleaseAPIClient.global_variable_id}") - self.assertEqual(response.status_code, 200, f"Unexpected status code: {response.status_code}") - print(f"Retrieved global variable: {response.json()}") - - def test_04_delete_global_variable(self): - """Test deleting the global variable.""" - if not TestReleaseAPIClient.global_variable_id: - self.skipTest("Global variable ID is not set. Run test_01_create_global_variable first.") - - response = self.client.delete(f"/api/v1/config/{TestReleaseAPIClient.global_variable_id}") - self.assertEqual(response.status_code, 204, f"Unexpected status code: {response.status_code}") - print("Global variable deleted successfully.") - -if __name__ == "__main__": - unittest.main() diff --git a/uv.lock b/uv.lock index 368eb2c..b7907df 100644 --- a/uv.lock +++ b/uv.lock @@ -2,6 +2,15 @@ version = 1 revision = 1 requires-python = ">=3.10" +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643 }, +] + [[package]] name = "certifi" version = "2026.5.20" @@ -138,19 +147,33 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c3/be/d0d44e092656fe7a06b55e6103cbce807cdbdee17884a5367c68c9860853/dataclasses_json-0.6.7-py3-none-any.whl", hash = "sha256:0dbf33f26c8d5305befd61b39d2b3414e8a407bedc2834dea9b8d642666fb40a", size = 28686 }, ] +[[package]] +name = "digitalai-release-api-client" +version = "26.3.0b2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ee/c2/fb583d54188fc5cbd7869a75995fcf5a23e7ab80feb5064fab95d48610ce/digitalai_release_api_client-26.3.0b2.tar.gz", hash = "sha256:566c8852a161449c115b711f2b1cb898abefe138ec1b51b93469940f6719bcba", size = 39420 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ca/48/aa3e6769a8c8eac149617d03439f2c3a792dc5e1c4491000347dc962bfc7/digitalai_release_api_client-26.3.0b2-py3-none-any.whl", hash = "sha256:ba343e979249b447fb481d45b1903d299716d0aab4e81bfc0dc79bd992a0354d", size = 68035 }, +] + [[package]] name = "digitalai-release-sdk" -version = "26.1.0" +version = "26.3.0b2" source = { editable = "." } dependencies = [ { name = "dataclasses-json" }, + { name = "digitalai-release-api-client" }, { name = "kubernetes" }, { name = "pycryptodomex" }, { name = "python-dateutil" }, { name = "requests" }, ] -[package.dev-dependencies] +[package.optional-dependencies] dev = [ { name = "pytest" }, ] @@ -158,14 +181,14 @@ dev = [ [package.metadata] requires-dist = [ { name = "dataclasses-json", specifier = ">=0.6.7,<1.0.0" }, + { name = "digitalai-release-api-client", specifier = "==26.3.0b2" }, { name = "kubernetes", specifier = ">=35.0.0,<36.0.0" }, { name = "pycryptodomex", specifier = ">=3.23.0,<4.0.0" }, + { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0.0" }, { name = "python-dateutil", specifier = ">=2.9.0,<3.0.0" }, { name = "requests", specifier = ">=2.32.5,<3.0.0" }, ] - -[package.metadata.requires-dev] -dev = [{ name = "pytest", specifier = ">=8.0.0" }] +provides-extras = ["dev"] [[package]] name = "durationpy" @@ -309,6 +332,129 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f2/5f/af7da8e6f1e42b52f44a24d08b8e4c726207434e2593732d39e7af5e7256/pycryptodomex-3.23.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:14c37aaece158d0ace436f76a7bb19093db3b4deade9797abfc39ec6cd6cc2fe", size = 1806478 }, ] +[[package]] +name = "pydantic" +version = "2.13.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262 }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/08/f1ba952f1c8ae5581c70fa9c6da89f247b83e3dd8c09c035d5d7931fc23d/pydantic_core-2.46.4-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:a396dcc17e5a0b164dbe026896245a4fa9ff402edca1dff0be3d53a517f74de4", size = 2113146 }, + { url = "https://files.pythonhosted.org/packages/56/c6/65f646c7ff09bd257f660434adb45c4dfcbbcebcc030562fecf6f5bf887d/pydantic_core-2.46.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:da4b951fe36dc7c3a1ccb4e3cd1747c3542b8c9ceede8fc86cae054e764485f5", size = 1949769 }, + { url = "https://files.pythonhosted.org/packages/64/ba/bfb1d928fd5b49e1258935ff104ae356e9fd89384a55bf9f847e9193ad40/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bb63e0198ca18aad131c089b9204c23079c3afa95487e561f4c522d519e55aba", size = 1974958 }, + { url = "https://files.pythonhosted.org/packages/4e/74/76223bfb117b64af743c9b6670d1364516f5c0604f96b48f3272f6af6cc6/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f47286a97f0bc9b8859519809077b91b2cefe4ae47fcbf5e466a009c1c5d742b", size = 2042118 }, + { url = "https://files.pythonhosted.org/packages/cb/7b/848732968bc8f48f3187542f08358b9d842db564147b256669426ebb1652/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:905a0ed8ea6f2d61c1738835f99b699348d7857379083e5fc497fa0c967a407c", size = 2222876 }, + { url = "https://files.pythonhosted.org/packages/b5/2f/e90b63ee2e14bd8d3db8f705a6d75d64e6ee1b7c2c8833747ce706e1e0ce/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ea793e075b70290d89d8142074262885d3f7da19634845135751bd6344f73b50", size = 2286703 }, + { url = "https://files.pythonhosted.org/packages/ba/1e/acc4d70f88a0a277e4a1fa77ebb985ceabaf900430f875bf9338e11c9420/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:395aebd9183f9d112f569aeb5b2214d1a10a33bec8456447f7fbdfa51d38d4cd", size = 2092042 }, + { url = "https://files.pythonhosted.org/packages/a9/da/0a422b57bf8504102bf3c4ccea9c41bab5a5cee6a54650acf8faf67f5a24/pydantic_core-2.46.4-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:b078afbc25f3a1436c7a1d2cd3e322497ee99615ba97c563566fdf46aff1ee01", size = 2117231 }, + { url = "https://files.pythonhosted.org/packages/bd/2a/2ac13c3af305843e23c5078c53d135656b3f05a2fd78cb7bbbb12e97b473/pydantic_core-2.46.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f747929cf940cddb5b3668a390056ddd5ba2e5010615ea2dcf4f9c4f3ab8791d", size = 2168388 }, + { url = "https://files.pythonhosted.org/packages/72/04/2beacf7e1607e93eefe4aed1b4709f079b905fb77530179d4f7c71745f22/pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:daa27d92c36f24388fe3ad306b174781c747627f134452e4f128ea00ce1fe8c4", size = 2184769 }, + { url = "https://files.pythonhosted.org/packages/9e/29/d2b9fd9f539133548eaf622c06a4ce176cb46ac59f32d0359c4abc0de047/pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:19e51f073cd3df251856a8a4189fbdf1de4012c3ebacfb1884f94f1eb406079f", size = 2319312 }, + { url = "https://files.pythonhosted.org/packages/7c/af/0f7a5b85fec6075bea96e3ef9187de38fccced0de92c1e7feda8d5cc7bb9/pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:c1747f85cee84c26985853c6f3d9bd3e75da5212912443fa111c113b9c246f39", size = 2361817 }, + { url = "https://files.pythonhosted.org/packages/25/a4/73363fec545fd3ec025490bdda2743c56d0dd5b6266b1a53bbe9e4265375/pydantic_core-2.46.4-cp310-cp310-win32.whl", hash = "sha256:2f84c03c8607173d16b5a854ec68a2f9079ae03237a54fb506d13af47e1d018d", size = 1987085 }, + { url = "https://files.pythonhosted.org/packages/01/aa/62f082da2c91fac1c234bc9ee0066257ce83f0604abd72e4c9d5991f2d84/pydantic_core-2.46.4-cp310-cp310-win_amd64.whl", hash = "sha256:8358a950c8909158e3df31538a7e4edc2d7265a7c54b47f0864d9e5bae9dcebf", size = 2074311 }, + { url = "https://files.pythonhosted.org/packages/5c/fa/6d7708d2cfc1a832acb6aeb0cd16e801902df8a0f583bb3b4b527fde022e/pydantic_core-2.46.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:0e96592440881c74a213e5ad528e2b24d3d4f940de2766bed9010ab1d9e51594", size = 2111872 }, + { url = "https://files.pythonhosted.org/packages/ae/6f/aa064a3e74b5745afbdf250594f38e7ead05e2d651bcb35994b9417a0d4d/pydantic_core-2.46.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e0d65b8c354be7fb5f720c3caa8bc940bc2d20ce749c8e06135f07f8ed95dd7c", size = 1948255 }, + { url = "https://files.pythonhosted.org/packages/43/3a/41114a9f7569b84b4d84e7a018c57c56347dac30c0d4a872946ec4e36c46/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bfb192b3f4b9e8a89b6277b6ce787564f62cfd272055f6e685726b111dc7826", size = 1972827 }, + { url = "https://files.pythonhosted.org/packages/ef/25/1ab42e8048fe551934d9884e8d64daa7e990ad386f310a15981aeb6a5b08/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9037063db01f09b09e237c282b6792bd4da634b5402c4e7f0c61effed7701a04", size = 2041051 }, + { url = "https://files.pythonhosted.org/packages/94/c2/1a934597ddf08da410385b3b7aae91956a5a76c635effef456074fad7e88/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fc010ab034c8c7452522748bf937df58020d256ccae0874463d1f4d01758af8e", size = 2221314 }, + { url = "https://files.pythonhosted.org/packages/02/6d/9e8ad178c9c4df27ad3c8f25d1fe2a7ab0d2ba0559fad4aee5d3d1f16771/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c5dac79fa1614d1e06ca695109c6105923bd9c7d1d6c918d4e637b7e6b32fd3", size = 2285146 }, + { url = "https://files.pythonhosted.org/packages/80/50/540cd3aeefc041beb111125c4bff779831a2111fc6b15a9138cda277d32c/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f9fa868638bf362d3d138ea55829cefb3d5f4b0d7f142234382a15e2485dbec4", size = 2089685 }, + { url = "https://files.pythonhosted.org/packages/6b/a4/b440ad35f05f6a38f89fa0f149accb3f0e02be94ca5e15f3c449a61b4bc9/pydantic_core-2.46.4-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:17299feefe090f2caa5b8e37222bb5f663e4935a8bfa6931d4102e5df1a9f398", size = 2115420 }, + { url = "https://files.pythonhosted.org/packages/99/61/de4f55db8dfd57bfdfa9a12ec90fe1b57c4f41062f7ca86f08586b3e0ac0/pydantic_core-2.46.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4c63ebc82684aa89d9a3bcbd13d515b3be44250dc68dd3bd81526c1cb31286c3", size = 2165122 }, + { url = "https://files.pythonhosted.org/packages/f7/52/7c529d7bdb2d1068bd52f51fe32572c8301f9a4febf1948f10639f1436f5/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:aaa2a54443eff1950ba5ddc6b6ccda0d9c84a364276a62f969bdf2a390650848", size = 2182573 }, + { url = "https://files.pythonhosted.org/packages/37/b3/7c40325848ba78247f2812dcf9c7274e38cd801820ca6dd9fe63bcfb0eb4/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:18e5ceec2ab67e6d5f1a9085e5a24c9c4e2ac4545730bfe668680bca05e555f3", size = 2317139 }, + { url = "https://files.pythonhosted.org/packages/d9/37/f913f81a657c865b75da6c0dbed79876073c2a43b5bd9edbe8da785e4d49/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:a0f62d0a58f4e7da165457e995725421e0064f2255d8eccebc49f41bbc23b109", size = 2360433 }, + { url = "https://files.pythonhosted.org/packages/c4/67/6acaa1be2567f9256b056d8477158cac7240813956ce86e49deae8e173b4/pydantic_core-2.46.4-cp311-cp311-win32.whl", hash = "sha256:041bde0a48fd37cf71cab1c9d56d3e8625a3793fef1f7dd232b3ff37e978ecda", size = 1985513 }, + { url = "https://files.pythonhosted.org/packages/aa/e6/c505f83dfeda9a2e5c995cfd872949e4d05e12f7feb3dca72f633daefa94/pydantic_core-2.46.4-cp311-cp311-win_amd64.whl", hash = "sha256:6f2eeda33a839975441c86a4119e1383c50b47faf0cbb5176985565c6bb02c33", size = 2071114 }, + { url = "https://files.pythonhosted.org/packages/0f/da/7a263a96d965d9d0df5e8de8a475f33495451117035b09acb110288c381f/pydantic_core-2.46.4-cp311-cp311-win_arm64.whl", hash = "sha256:14f4c5d6db102bd796a627bbb3a17b4cf4574b9ae861d8b7c9a9661c6dd3362d", size = 2044298 }, + { url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158 }, + { url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724 }, + { url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742 }, + { url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418 }, + { url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274 }, + { url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940 }, + { url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516 }, + { url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854 }, + { url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306 }, + { url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044 }, + { url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133 }, + { url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464 }, + { url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823 }, + { url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919 }, + { url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604 }, + { url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306 }, + { url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906 }, + { url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802 }, + { url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446 }, + { url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757 }, + { url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275 }, + { url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467 }, + { url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417 }, + { url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782 }, + { url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782 }, + { url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334 }, + { url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986 }, + { url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693 }, + { url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819 }, + { url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411 }, + { url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079 }, + { url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179 }, + { url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926 }, + { url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785 }, + { url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733 }, + { url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534 }, + { url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732 }, + { url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627 }, + { url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141 }, + { url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325 }, + { url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990 }, + { url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978 }, + { url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354 }, + { url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238 }, + { url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251 }, + { url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593 }, + { url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226 }, + { url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605 }, + { url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777 }, + { url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641 }, + { url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404 }, + { url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219 }, + { url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594 }, + { url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542 }, + { url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146 }, + { url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309 }, + { url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736 }, + { url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575 }, + { url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624 }, + { url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325 }, + { url = "https://files.pythonhosted.org/packages/11/cb/428de0385b6c8d44b716feba566abfacfbd23ee3c4439faa789a1456242f/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:0c563b08bca408dc7f65f700633d8442fffb2421fc47b8101377e9fd65051ff0", size = 2112782 }, + { url = "https://files.pythonhosted.org/packages/0b/b5/6a17bdadd0fc1f170adfd05a20d37c832f52b117b4d9131da1f41bb097ce/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:db06ffe51636ffe9ca531fe9023dd64bdd794be8754cb5df57c5498ae5b518a7", size = 1952146 }, + { url = "https://files.pythonhosted.org/packages/2a/dc/03734d80e362cd43ef65428e9de77c730ce7f2f11c60d2b1e1b39f0fbf99/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:133878133d271ade3d41d1bfb2a45ec38dbdbda40bc065921c6b04e4630127e2", size = 2134492 }, + { url = "https://files.pythonhosted.org/packages/de/df/5e5ffc085ed07cc22d298134d3d911c63e91f6a0eb91fe646750a3209910/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9bc519fbf2b7578398853d815009ae5e4d4603d12f4e3f91da8c06852d3da3e9", size = 2156604 }, + { url = "https://files.pythonhosted.org/packages/81/44/6e112a4253e56f5705467cbab7ab5e91ee7398ba3d56d358635958893d3e/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:c7a7bd4e39e8e4c12c39cd480356842b6a8a06e41b23a55a5e3e191718838ddf", size = 2183828 }, + { url = "https://files.pythonhosted.org/packages/ac/ad/5565071e937d8e752842ac241463944c9eb14c87e2d269f2658a5bd05e98/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:d396ec2b979760aaf3218e76c24e65bd0aca24983298653b3a9d7a45f9e47b30", size = 2310000 }, + { url = "https://files.pythonhosted.org/packages/4f/c3/66883a5cec183e7fba4d024b4cbbe61851a63750ef606b0afecc46d1f2bf/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:86e1a4418c6cd97d60c95c71164158eaf7324fae7b0923264016baa993eba6fc", size = 2361286 }, + { url = "https://files.pythonhosted.org/packages/4b/2d/69abac8f838090bbecd5df894befb2c2619e7996a98ddb949db9f3b93225/pydantic_core-2.46.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d51026d73fcfd93610abc7b27789c26b313920fcfb20e27462d74a7f8b06e983", size = 2193071 }, +] + [[package]] name = "pygments" version = "2.20.0" @@ -525,6 +671,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/65/f3/107a22063bf27bdccf2024833d3445f4eea42b2e598abfbd46f6a63b6cb0/typing_inspect-0.9.0-py3-none-any.whl", hash = "sha256:9ee6fc59062311ef8547596ab6b955e1b8aa46242d854bfc78f4f6b0eff35f9f", size = 8827 }, ] +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611 }, +] + [[package]] name = "urllib3" version = "2.7.0" From 36fed7b988bf88789b4be831149ceb24d8e8da50 Mon Sep 17 00:00:00 2001 From: Balaji Venkatesan Date: Tue, 16 Jun 2026 23:22:56 +0530 Subject: [PATCH 15/23] Updated workflow --- .github/workflows/python-test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/python-test.yml b/.github/workflows/python-test.yml index bec03ce..e75afe1 100644 --- a/.github/workflows/python-test.yml +++ b/.github/workflows/python-test.yml @@ -21,6 +21,6 @@ jobs: - name: Set up Python run: uv python install 3.10 - name: Install dependencies - run: uv sync --frozen + run: uv sync --frozen --extra dev - name: Run tests run: uv run --project ${{ github.workspace }} python -m pytest tests/release/integration -v From 93a613f61f9ad251b320f2dfa3167f0e04d01bad Mon Sep 17 00:00:00 2001 From: Balaji Venkatesan Date: Tue, 16 Jun 2026 23:39:59 +0530 Subject: [PATCH 16/23] Updated toml --- pyproject.toml | 4 ++-- uv.lock | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index e5f47c4..2fe4620 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "digitalai_release_sdk" -version = "26.3.0b2" +version = "26.3.0b3" description = "Digital.ai Release SDK" readme = "README.md" license = "MIT" @@ -19,7 +19,7 @@ classifiers = [ "Operating System :: OS Independent" ] dependencies = [ - "digitalai-release-api-client==26.3.0b2", + "digitalai-release-api-client==26.3.0b3", "dataclasses-json>=0.6.7, <1.0.0", "pycryptodomex>=3.23.0, <4.0.0", "python-dateutil>=2.9.0, <3.0.0", diff --git a/uv.lock b/uv.lock index b7907df..f976105 100644 --- a/uv.lock +++ b/uv.lock @@ -149,15 +149,15 @@ wheels = [ [[package]] name = "digitalai-release-api-client" -version = "26.3.0b2" +version = "26.3.0b3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pydantic" }, { name = "requests" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ee/c2/fb583d54188fc5cbd7869a75995fcf5a23e7ab80feb5064fab95d48610ce/digitalai_release_api_client-26.3.0b2.tar.gz", hash = "sha256:566c8852a161449c115b711f2b1cb898abefe138ec1b51b93469940f6719bcba", size = 39420 } +sdist = { url = "https://files.pythonhosted.org/packages/a6/c4/007f2c5acf64c3239e9a4e2854f3711cf5bbc9822a49f20172c0048b12c7/digitalai_release_api_client-26.3.0b3.tar.gz", hash = "sha256:2dfedc8a5c9a7c4a24385b50552605121fd75d5d035d6d871fd2f271e1904b4d", size = 39460 } wheels = [ - { url = "https://files.pythonhosted.org/packages/ca/48/aa3e6769a8c8eac149617d03439f2c3a792dc5e1c4491000347dc962bfc7/digitalai_release_api_client-26.3.0b2-py3-none-any.whl", hash = "sha256:ba343e979249b447fb481d45b1903d299716d0aab4e81bfc0dc79bd992a0354d", size = 68035 }, + { url = "https://files.pythonhosted.org/packages/b5/7d/6a74dbedfbdc55a483a9883b09f75901474d9709477528e851b35ebe9894/digitalai_release_api_client-26.3.0b3-py3-none-any.whl", hash = "sha256:1bc6a037e83e3537ac492549959b6149e19be07f552784dc86b2abe923a5a2c0", size = 68076 }, ] [[package]] @@ -181,7 +181,7 @@ dev = [ [package.metadata] requires-dist = [ { name = "dataclasses-json", specifier = ">=0.6.7,<1.0.0" }, - { name = "digitalai-release-api-client", specifier = "==26.3.0b2" }, + { name = "digitalai-release-api-client", specifier = "==26.3.0b3" }, { name = "kubernetes", specifier = ">=35.0.0,<36.0.0" }, { name = "pycryptodomex", specifier = ">=3.23.0,<4.0.0" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0.0" }, From c0a85a321e20a812a9e0d757d1aa81ba53b68657 Mon Sep 17 00:00:00 2001 From: Balaji Venkatesan Date: Tue, 16 Jun 2026 23:48:54 +0530 Subject: [PATCH 17/23] Updated test case --- .../release/integration/test_api_base_task.py | 24 ++++++++++++++++++- uv.lock | 2 +- 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/tests/release/integration/test_api_base_task.py b/tests/release/integration/test_api_base_task.py index 5c66374..5c2495b 100644 --- a/tests/release/integration/test_api_base_task.py +++ b/tests/release/integration/test_api_base_task.py @@ -1,3 +1,4 @@ +import socket import unittest from types import SimpleNamespace from unittest.mock import MagicMock @@ -78,6 +79,23 @@ } +# The live-call test below talks to a real Release server. Locally that is the +# developer's instance on localhost:5516; CI has no such server, so the test is +# skipped automatically when the port is not reachable. +LIVE_SERVER_HOST = "localhost" +LIVE_SERVER_PORT = 5516 +LIVE_SERVER_URL = f"http://{LIVE_SERVER_HOST}:{LIVE_SERVER_PORT}" + + +def _server_reachable(host: str, port: int, timeout: float = 0.5) -> bool: + """Return True if a TCP connection to host:port succeeds within the timeout.""" + try: + with socket.create_connection((host, port), timeout=timeout): + return True + except OSError: + return False + + class _SampleApiTask(ApiBaseTask): """Concrete ApiBaseTask used to exercise the base behaviour in tests.""" @@ -92,7 +110,7 @@ def setUp(self): # Configure the task context the way the Release runtime would, so that # get_release_api_client() builds a client from the "Run as user" details. self.task = _SampleApiTask() - self.task.release_server_url = "http://localhost:5516" + self.task.release_server_url = LIVE_SERVER_URL self.task.release_context = ReleaseContext( id="Applications/Release0000000000000000000000000000", automated_task_as_user=AutomatedTaskAsUserContext( @@ -141,6 +159,10 @@ def test_reset_api_clients_clears_caches(self): self.assertIsNot(self.task.apiClient, old_client) self.assertIsNot(self.task.releaseApi, old_release_api) + @unittest.skipUnless( + _server_reachable(LIVE_SERVER_HOST, LIVE_SERVER_PORT), + f"live Release server not available on {LIVE_SERVER_HOST}:{LIVE_SERVER_PORT}", + ) def test_wired_client_performs_live_call(self): """A property built by ApiBaseTask can talk to the live server.""" info = self.task.settingsApi.getInstanceInformation() diff --git a/uv.lock b/uv.lock index f976105..5fc7042 100644 --- a/uv.lock +++ b/uv.lock @@ -162,7 +162,7 @@ wheels = [ [[package]] name = "digitalai-release-sdk" -version = "26.3.0b2" +version = "26.3.0b3" source = { editable = "." } dependencies = [ { name = "dataclasses-json" }, From f2cd12d05e60f586aa50a544248d0ab747ff460c Mon Sep 17 00:00:00 2001 From: Balaji Venkatesan Date: Wed, 17 Jun 2026 11:25:38 +0530 Subject: [PATCH 18/23] Update for ReleaseAPIClient --- .github/workflows/python-test.yml | 2 +- README.md | 14 ------- .../release/integration/api_base_task.py | 2 +- digitalai/release/integration/base_task.py | 2 +- digitalai/release/release_api_client.py | 17 ++++++++ .../release/integration/test_api_base_task.py | 2 +- tests/release/test_release_api_client.py | 39 +++++++++++++++++++ 7 files changed, 60 insertions(+), 18 deletions(-) create mode 100644 digitalai/release/release_api_client.py create mode 100644 tests/release/test_release_api_client.py diff --git a/.github/workflows/python-test.yml b/.github/workflows/python-test.yml index e75afe1..449ffd7 100644 --- a/.github/workflows/python-test.yml +++ b/.github/workflows/python-test.yml @@ -23,4 +23,4 @@ jobs: - name: Install dependencies run: uv sync --frozen --extra dev - name: Run tests - run: uv run --project ${{ github.workspace }} python -m pytest tests/release/integration -v + run: uv run --project ${{ github.workspace }} python -m pytest tests -v diff --git a/README.md b/README.md index 0985cd2..e4b4d14 100644 --- a/README.md +++ b/README.md @@ -66,20 +66,6 @@ class ShowVersion(ApiBaseTask): ### Version 26.3.0 (Beta) -#### ⚠️ Breaking Changes - -- **`ReleaseAPIClient` moved to the standalone [`digitalai-release-api-client`](https://pypi.org/project/digitalai-release-api-client/) package** so the API client can be used on its own. The SDK now depends on it and installs it automatically — only the import path changes (class names, method signatures, and behavior are unchanged): - - ```python - # ❌ Old — bundled inside the SDK - from digitalai.release.release_api_client import ReleaseAPIClient - - # ✅ New — provided by digitalai-release-api-client - from com.xebialabs.xlrelease.release_api_client import ReleaseAPIClient - ``` - - `BaseTask.get_release_api_client()` still returns a `ReleaseAPIClient` exactly as before. - #### 🚀 Features - Added the `ApiBaseTask` base class, exposing every Release v1 API as a lazily created, cached property. diff --git a/digitalai/release/integration/api_base_task.py b/digitalai/release/integration/api_base_task.py index 1c28dd8..21e890b 100644 --- a/digitalai/release/integration/api_base_task.py +++ b/digitalai/release/integration/api_base_task.py @@ -2,7 +2,7 @@ from digitalai.release.integration.base_task import BaseTask -from com.xebialabs.xlrelease.release_api_client import ReleaseAPIClient +from digitalai.release.release_api_client import ReleaseAPIClient from com.xebialabs.xlrelease.domain.folder import Folder from com.xebialabs.xlrelease.domain.phase import Phase from com.xebialabs.xlrelease.domain.release import Release diff --git a/digitalai/release/integration/base_task.py b/digitalai/release/integration/base_task.py index fab392b..8164774 100644 --- a/digitalai/release/integration/base_task.py +++ b/digitalai/release/integration/base_task.py @@ -7,7 +7,7 @@ from .exceptions import AbortException from .ids import Ids from .logger import dai_logger -from com.xebialabs.xlrelease.release_api_client import ReleaseAPIClient +from digitalai.release.release_api_client import ReleaseAPIClient class BaseTask(ABC): diff --git a/digitalai/release/release_api_client.py b/digitalai/release/release_api_client.py new file mode 100644 index 0000000..1701c75 --- /dev/null +++ b/digitalai/release/release_api_client.py @@ -0,0 +1,17 @@ +from com.xebialabs.xlrelease.release_api_client import ( + ReleaseAPIClient as _ReleaseAPIClient, +) + + +class ReleaseAPIClient(_ReleaseAPIClient): + """ + Backwards-compatible import path for ``ReleaseAPIClient``. + + The implementation now lives in the standalone + ``digitalai-release-api-client`` package at + ``com.xebialabs.xlrelease.release_api_client``. This thin subclass keeps the + original ``digitalai.release.release_api_client.ReleaseAPIClient`` import + path working, so existing integrations continue to run unchanged. Class + name, constructor signature, methods, and behavior are identical to the + base class. + """ diff --git a/tests/release/integration/test_api_base_task.py b/tests/release/integration/test_api_base_task.py index 5c2495b..b22e26b 100644 --- a/tests/release/integration/test_api_base_task.py +++ b/tests/release/integration/test_api_base_task.py @@ -9,7 +9,7 @@ ) from digitalai.release.integration.api_base_task import ApiBaseTask -from com.xebialabs.xlrelease.release_api_client import ReleaseAPIClient +from digitalai.release.release_api_client import ReleaseAPIClient from com.xebialabs.xlrelease.api.v1.activity_logs_api import ActivityLogsApi from com.xebialabs.xlrelease.api.v1.application_api import ApplicationApi from com.xebialabs.xlrelease.api.v1.archive_api import ArchiveApi diff --git a/tests/release/test_release_api_client.py b/tests/release/test_release_api_client.py new file mode 100644 index 0000000..ee5d2bc --- /dev/null +++ b/tests/release/test_release_api_client.py @@ -0,0 +1,39 @@ +import unittest + +from com.xebialabs.xlrelease.release_api_client import ( + ReleaseAPIClient as ApiClientReleaseAPIClient, +) +from digitalai.release.release_api_client import ReleaseAPIClient + + +class TestReleaseAPIClientBackwardCompatibility(unittest.TestCase): + """The old ``digitalai.release.release_api_client`` import path must keep + working as a drop-in for the standalone api-client implementation.""" + + def test_is_subclass_of_api_client_class(self): + """The shim extends the standalone api-client class.""" + self.assertTrue(issubclass(ReleaseAPIClient, ApiClientReleaseAPIClient)) + + def test_instance_is_recognized_as_api_client_class(self): + """Instances created via the old path are instances of both classes.""" + client = ReleaseAPIClient("http://localhost:5516", "admin", "admin") + try: + self.assertIsInstance(client, ReleaseAPIClient) + self.assertIsInstance(client, ApiClientReleaseAPIClient) + finally: + client.close() + + def test_constructor_behaves_like_base(self): + """Construction, URL normalization, and auth match the base class.""" + with ReleaseAPIClient("http://localhost:5516/", "admin", "secret") as client: + self.assertEqual(client.server_address, "http://localhost:5516") + self.assertEqual(client.session.auth, ("admin", "secret")) + + def test_constructor_requires_credentials(self): + """The base validation still applies through the shim.""" + with self.assertRaises(ValueError): + ReleaseAPIClient("http://localhost:5516") + + +if __name__ == "__main__": + unittest.main() From 6b2033b76a495743ea39c91a0e2e3d4f130deefc Mon Sep 17 00:00:00 2001 From: Balaji Venkatesan Date: Wed, 17 Jun 2026 13:10:38 +0530 Subject: [PATCH 19/23] Updated Readme --- README.md | 26 ++++++++++++-------------- 1 file changed, 12 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index e4b4d14..b4691ad 100644 --- a/README.md +++ b/README.md @@ -62,6 +62,18 @@ class ShowVersion(ApiBaseTask): self.add_comment(f"Working on {release.title}") ``` +## 🔗 Related Resources + +- **[Digital.ai Python SDK Documentation](https://docs.digital.ai/release/docs/how-to/overview-python-sdk)**: + Comprehensive guide to using the Python SDK and building custom tasks. + +- **[SDK Template Project for integration plugins](https://github.com/digital-ai/release-integration-template-python)**: + A starting point for building custom integrations using Digital.ai Release and Python. + +- **[Digital.ai Release Python SDK](https://pypi.org/project/digitalai-release-sdk/)**: + The official SDK package for integrating with Digital.ai Release on Pypi. + + ## Changelog ### Version 26.3.0 (Beta) @@ -76,17 +88,3 @@ class ShowVersion(ApiBaseTask): - Improved stability and error handling for API requests and Kubernetes tasks. ---- - -## 🔗 Related Resources - -- 🧪 **Python Template Project**: [release-integration-template-python](https://github.com/digital-ai/release-integration-template-python) - A starting point for building custom integrations using Digital.ai Release and Python. - -- 📘 **Official Documentation**: [Digital.ai Release Python SDK Docs](https://docs.digital.ai/release/docs/category/python-sdk) - Comprehensive guide to using the Python SDK and building custom tasks. - -- 📦 **Digital.ai Release Python SDK**: [digitalai-release-sdk on PyPI](https://pypi.org/project/digitalai-release-sdk/) - The official SDK package for integrating with Digital.ai Release. - - From 736a612a999f1c2505798a128c0baa8c52fc7555 Mon Sep 17 00:00:00 2001 From: Balaji Venkatesan Date: Wed, 17 Jun 2026 15:07:29 +0530 Subject: [PATCH 20/23] Updated Readme --- README.md | 2 +- pyproject.toml | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index b4691ad..e053419 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ The **Digital.ai Release Python SDK** (`digitalai-release-sdk`) provides a set o ## Features - Define custom tasks using the `BaseTask` abstract class. -- Subclass `ApiBaseTask` to get every Release v1 API as a cached property (`releaseApi`, `phaseApi`, `taskApi`, ...), all sharing one pre-configured client built from the task's "Run as user" context. +- Subclass `ApiBaseTask` to access the Release APIs (`releaseApi`, `phaseApi`, `taskApi`, ...) through a ready-to-use client. - Easily manage input and output properties. - Interact with the Digital.ai Release environment seamlessly. diff --git a/pyproject.toml b/pyproject.toml index 2fe4620..651be14 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "digitalai_release_sdk" -version = "26.3.0b3" +version = "26.3.0b4" description = "Digital.ai Release SDK" readme = "README.md" license = "MIT" @@ -19,7 +19,7 @@ classifiers = [ "Operating System :: OS Independent" ] dependencies = [ - "digitalai-release-api-client==26.3.0b3", + "digitalai-release-api-client==26.3.0b4", "dataclasses-json>=0.6.7, <1.0.0", "pycryptodomex>=3.23.0, <4.0.0", "python-dateutil>=2.9.0, <3.0.0", From 48094eba6b7e0a63ec1aeb3e7d43ffabfa2b7dd1 Mon Sep 17 00:00:00 2001 From: Balaji Venkatesan Date: Thu, 18 Jun 2026 08:10:22 +0530 Subject: [PATCH 21/23] Updated docs --- .../release/integration/api_base_task.py | 234 ++++++- docs/README.md | 29 + docs/classes/api_base_task.md | 331 ++++++++++ docs/classes/base_task.md | 311 +++++++++ docs/examples.md | 276 ++++++++ .../integration/test_api_base_task_live.py | 610 ++++++++++++++++++ uv.lock | 10 +- 7 files changed, 1795 insertions(+), 6 deletions(-) create mode 100644 docs/README.md create mode 100644 docs/classes/api_base_task.md create mode 100644 docs/classes/base_task.md create mode 100644 docs/examples.md create mode 100644 tests/release/integration/test_api_base_task_live.py diff --git a/digitalai/release/integration/api_base_task.py b/digitalai/release/integration/api_base_task.py index 21e890b..ad6a1f4 100644 --- a/digitalai/release/integration/api_base_task.py +++ b/digitalai/release/integration/api_base_task.py @@ -1,4 +1,4 @@ -from typing import Dict, Type, TypeVar +from typing import Any, Dict, Type, TypeVar from digitalai.release.integration.base_task import BaseTask @@ -7,6 +7,7 @@ from com.xebialabs.xlrelease.domain.phase import Phase from com.xebialabs.xlrelease.domain.release import Release from com.xebialabs.xlrelease.domain.task import Task +from com.xebialabs.xlrelease.domain.variable import Variable from com.xebialabs.xlrelease.api.v1.activity_logs_api import ActivityLogsApi from com.xebialabs.xlrelease.api.v1.application_api import ApplicationApi @@ -252,6 +253,237 @@ def getCurrentFolder(self) -> Folder: """ return self.folderApi.getFolder(self.get_folder_id()) + def getReleaseVariable(self, name: str) -> Any: + """ + Return the value of a variable in the current release by name. + + The Python3 equivalent of the Jython script global + ``releaseVariables[name]``. Pass the bare variable name (e.g. + ``"JenkinsBuildNumber"``). The variable is looked up by its ``key`` via + ``releaseApi.getVariables`` and its stored value returned as-is. + + :param name: the variable name (e.g. ``"JenkinsBuildNumber"``). + :return: the variable's value. + :raises KeyError: if the release has no variable with that name. + """ + variables = self.releaseApi.getVariables(self.get_release_id()) + variable = next((v for v in variables if v.key == name), None) + if variable is None: + raise KeyError( + f"No variable named {name} in the current release; " + f"available: {sorted(v.key for v in variables)}") + return variable.value + + def setReleaseVariable(self, name: str, value: Any) -> Variable: + """ + Set the value of a variable in the current release by name. + + The Python3 equivalent of the Jython script assignment + ``releaseVariables[name] = value``. Pass the bare variable name (e.g. + ``"JenkinsBuildNumber"``). The variable is looked up by its ``key`` in + the current release; if it exists its value is persisted via + ``releaseApi.updateVariable``, otherwise a new variable is created via + ``releaseApi.createVariable``. The new variable's type is inferred from + ``value`` (see :meth:`_variable_type_for_value`). + + :param name: the variable name (e.g. ``"JenkinsBuildNumber"``). + :param value: the new value to assign. + :return: the updated (or newly created) variable. + """ + release_id = self.get_release_id() + variables = self.releaseApi.getVariables(release_id) + variable = next((v for v in variables if v.key == name), None) + if variable is None: + return self.releaseApi.createVariable( + release_id, self._new_variable(name, value)) + variable.value = self._coerce_value(value) + return self.releaseApi.updateVariable(variable.id, variable) + + def getFolderVariable(self, name: str) -> Any: + """ + Return the value of a variable in the current folder by name. + + Like :meth:`getReleaseVariable`, but scoped to the folder that contains + the current release. Inherited variables (from parent folders and global + variables) are included, mirroring what a release actually resolves. + + Folder variables are stored with a ``folder.`` prefix, which is + required here: pass the fully qualified name (e.g. ``"folder.foo"``). + + :param name: the variable name, including the ``folder.`` prefix. + :return: the variable's value. + :raises ValueError: if ``name`` does not start with ``folder.``. + :raises KeyError: if no such variable is visible to the folder. + """ + key = self._folder_key(name) + variables = self.folderApi.listVariables(self.get_folder_id()) + variable = next((v for v in variables if v.key == key), None) + if variable is None: + raise KeyError( + f"No variable named {key} visible to the current folder; " + f"available: {sorted(v.key for v in variables)}") + return variable.value + + def setFolderVariable(self, name: str, value: Any) -> Variable: + """ + Set the value of a variable owned by the current folder by name. + + Only variables the folder owns are matched: an inherited variable + (defined on a parent folder or as a global variable) is not. If the + folder owns the variable its value is persisted via + ``folderApi.updateVariable``; otherwise a new folder-owned variable is + created via ``folderApi.createVariable``. The new variable's type is + inferred from ``value`` (see :meth:`_variable_type_for_value`). Creating + a variable whose name matches an inherited one yields a folder-owned + variable that shadows the inherited value. + + The ``folder.`` prefix is required (see :meth:`getFolderVariable`). + + :param name: the variable name, including the ``folder.`` prefix. + :param value: the new value to assign. + :return: the updated (or newly created) variable. + :raises ValueError: if ``name`` does not start with ``folder.``. + """ + folder_id = self.get_folder_id() + key = self._folder_key(name) + variables = self.folderApi.listVariables(folder_id, folderOnly=True) + variable = next((v for v in variables if v.key == key), None) + if variable is None: + return self.folderApi.createVariable( + folder_id, self._new_variable(key, value)) + variable.value = self._coerce_value(value) + return self.folderApi.updateVariable(folder_id, variable.id, variable) + + def getGlobalVariable(self, name: str) -> Any: + """ + Return the value of a global variable by name. + + Global variables are stored with a ``global.`` prefix, which is + required here: pass the fully qualified name (e.g. ``"global.foo"``). + + :param name: the global variable name, including the ``global.`` prefix. + :return: the variable's value. + :raises ValueError: if ``name`` does not start with ``global.``. + :raises KeyError: if no global variable with that name exists. + """ + key = self._global_key(name) + variables = self.configurationApi.getGlobalVariables() + variable = next((v for v in variables if v.key == key), None) + if variable is None: + raise KeyError( + f"No global variable named {key}; " + f"available: {sorted(v.key for v in variables)}") + return variable.value + + def setGlobalVariable(self, name: str, value: Any) -> Variable: + """ + Set the value of a global variable by name. + + The ``global.`` prefix is required (see :meth:`getGlobalVariable`). If + the variable exists its value is persisted via + ``configurationApi.updateGlobalVariable``; otherwise a new global + variable is created via ``configurationApi.addGlobalVariable``, with its + type inferred from ``value`` (see :meth:`_variable_type_for_value`). The + task's run-as user must hold the permission to edit global variables. + + :param name: the global variable name, including the ``global.`` prefix. + :param value: the new value to assign. + :return: the updated (or newly created) variable. + :raises ValueError: if ``name`` does not start with ``global.``. + """ + key = self._global_key(name) + variables = self.configurationApi.getGlobalVariables() + variable = next((v for v in variables if v.key == key), None) + if variable is None: + return self.configurationApi.addGlobalVariable( + self._new_variable(key, value)) + variable.value = self._coerce_value(value) + return self.configurationApi.updateGlobalVariable(variable.id, variable) + + @staticmethod + def _global_key(name: str) -> str: + """ + Validate and return the stored ``key`` of a global variable. + + The ``global.`` prefix is required: ``name`` is returned unchanged, but + a :class:`ValueError` is raised when it is missing. + """ + if not name.startswith("global."): + raise ValueError( + f"Global variable name must include the 'global.' prefix; " + f"got {name!r}.") + return name + + @staticmethod + def _folder_key(name: str) -> str: + """ + Validate and return the stored ``key`` of a folder variable. + + The ``folder.`` prefix is required: ``name`` is returned unchanged, but + a :class:`ValueError` is raised when it is missing. + """ + if not name.startswith("folder."): + raise ValueError( + f"Folder variable name must include the 'folder.' prefix; " + f"got {name!r}.") + return name + + @staticmethod + def _coerce_value(value: Any) -> Any: + """ + Return ``value`` in a JSON-serializable form for a variable payload. + + Sets and tuples (natural Python types for a set-of-string variable) are + converted to lists; everything else is passed through unchanged. + """ + if isinstance(value, (set, tuple)): + return list(value) + return value + + @staticmethod + def _variable_type_for_value(value: Any) -> str: + """ + Infer the Release variable ``type`` to use for a new variable from the + Python type of ``value``. + + ==================== ====================================== + Python value Variable type + ==================== ====================================== + ``bool`` ``xlrelease.BooleanVariable`` + ``int`` ``xlrelease.IntegerVariable`` + ``dict`` ``xlrelease.MapStringStringVariable`` + ``list``/``set``/ ``xlrelease.SetStringVariable`` + ``tuple`` + anything else ``xlrelease.StringVariable`` + ==================== ====================================== + + ``bool`` is checked before ``int`` because ``bool`` is a subclass of + ``int`` in Python. + """ + if isinstance(value, bool): + return "xlrelease.BooleanVariable" + if isinstance(value, int): + return "xlrelease.IntegerVariable" + if isinstance(value, dict): + return "xlrelease.MapStringStringVariable" + if isinstance(value, (list, set, tuple)): + return "xlrelease.SetStringVariable" + return "xlrelease.StringVariable" + + @classmethod + def _new_variable(cls, key: str, value: Any) -> Variable: + """ + Build a :class:`Variable` to create for ``key``/``value``, inferring the + ``type`` from ``value`` and coercing the value to a serializable form. + """ + return Variable( + type=cls._variable_type_for_value(value), + key=key, + value=cls._coerce_value(value), + requiresValue=False, + showOnReleaseStart=False, + ) + def getTasksByTitle(self, taskTitle: str, phaseTitle: str | None = None, releaseId: str | None = None) -> list[Task]: """ diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..7f36f13 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,29 @@ +# Digital.ai Release Python SDK — Documentation + +Reference documentation for the **Digital.ai Release Python SDK** +(`digitalai-release-sdk`) — the toolkit for building container-based +integration tasks for Digital.ai Release. You define a task by subclassing a +base class and implementing `execute`; the SDK provides built-in helpers to read +inputs, write outputs, report progress, and call the Release REST API. + +## Examples + +New here? See **[Examples](examples.md)** for task-oriented, copy-pasteable +snippets covering defining tasks, handling inputs/outputs, and calling the +Release API. + +## Base Classes + +Subclass one of these to build a task. `ApiBaseTask` extends `BaseTask`, adding +ready-to-use Release API access. + +| Name | Description | Documentation | +|------|-------------|---------------| +| `BaseTask` | Abstract base class for a task that can be executed. Implement `execute` and use the input/output and reporting helpers. | [classes/base_task.md](classes/base_task.md) | +| `ApiBaseTask` | Extends `BaseTask` with a ready-to-use, cached client and every `com.xebialabs.xlrelease.api.v1` wrapper exposed as a property. | [classes/api_base_task.md](classes/api_base_task.md) | + +## Related + +- **[Release API Python Client](https://pypi.org/project/digitalai-release-api-client/)** — the API wrappers (`releaseApi`, `taskApi`, …) and domain models (`Task`, `Release`, `Variable`, …) returned by [`ApiBaseTask`](classes/api_base_task.md) are provided by this dependency, installed automatically with the SDK. +- **[Python SDK Documentation](https://docs.digital.ai/release/docs/how-to/overview-python-sdk)** — guide to using the SDK and building custom tasks. +- **[Integration Template Project](https://github.com/digital-ai/release-integration-template-python)** — a starting point for building custom integrations. diff --git a/docs/classes/api_base_task.md b/docs/classes/api_base_task.md new file mode 100644 index 0000000..9fbee35 --- /dev/null +++ b/docs/classes/api_base_task.md @@ -0,0 +1,331 @@ +[🏠 Docs Home](../README.md) › [Base Classes](../README.md#base-classes) › **ApiBaseTask** + +# ApiBaseTask + +> Base class for Release container tasks that need the v1 REST API. + +| | | +|---|---| +| **Class** | `ApiBaseTask` | +| **Extends** | [`BaseTask`](base_task.md) | +| **Module** | `digitalai.release.integration.api_base_task` | +| **Source** | [`digitalai/release/integration/api_base_task.py`](../../digitalai/release/integration/api_base_task.py) | +| **Properties** | 31 | +| **Methods** | 16 | + +Subclass `ApiBaseTask` instead of [`BaseTask`](base_task.md) to get a +ready-to-use, lazily created instance of every `com.xebialabs.xlrelease.api.v1` +wrapper as a property (`releaseApi`, `phaseApi`, `taskApi`, …). All wrappers +share a single, pre-configured `ReleaseAPIClient` built from the task's "Run as +user" context (credentials + server URL), so the client and each API object are +created only once, and only when first accessed. + +Because it extends `BaseTask`, every method and attribute documented in +[BaseTask](base_task.md) is also available here. + +```python +from digitalai.release.integration.api_base_task import ApiBaseTask + + +class MyTask(ApiBaseTask): + def execute(self) -> None: + release = self.releaseApi.getRelease(self.get_release_id()) + self.add_comment(f"Working on {release.title}") +``` + +> **Note:** The API wrappers and the domain models they return (`Task`, `Phase`, +> `Release`, `Folder`, `Variable`, …) are provided by the +> [`digitalai-release-api-client`](https://pypi.org/project/digitalai-release-api-client/) +> package, which the SDK depends on. + +--- + +## API Properties + +Each property returns a cached API wrapper, created on first access and bound to +the shared [`apiClient`](#apiclient). See the API client reference for the +methods each wrapper exposes. + +| Property | Type | Description | +|----------|------|-------------| +| [`apiClient`](#apiclient) | `ReleaseAPIClient` | The shared client, created on first access from the task context. | +| `activityLogsApi` | `ActivityLogsApi` | Operations on activity logs. | +| `applicationApi` | `ApplicationApi` | Operations on applications. | +| `archiveApi` | `ArchiveApi` | Operations on archived releases. | +| `attachmentApi` | `AttachmentApi` | Operations on attachments. | +| `categoryApi` | `CategoryApi` | Operations on categories. | +| `configurationApi` | `ConfigurationApi` | Operations on shared configurations and global variables. | +| `deliveryApi` | `DeliveryApi` | Operations on deliveries. | +| `deliveryPatternApi` | `DeliveryPatternApi` | Operations on delivery patterns. | +| `dslApi` | `DslApi` | Operations with release DSL. | +| `environmentApi` | `EnvironmentApi` | Operations on environments. | +| `environmentLabelApi` | `EnvironmentLabelApi` | Operations on environment labels. | +| `environmentReservationApi` | `EnvironmentReservationApi` | Operations on environment reservations. | +| `environmentStageApi` | `EnvironmentStageApi` | Operations on environment stages. | +| `folderApi` | `FolderApi` | Operations on folders. | +| `folderVersioningApi` | `FolderVersioningApi` | Operations to store and synchronize folder contents. | +| `permissionsApi` | `PermissionsApi` | Operations on permissions. | +| `phaseApi` | `PhaseApi` | Operations on phases. | +| `releaseApi` | `ReleaseApi` | Operations on releases. | +| `reportApi` | `ReportApi` | Operations on reports. | +| `riskApi` | `RiskApi` | Operations on risk. | +| `rolesApi` | `RolesApi` | Operations on roles. | +| `searchApi` | `SearchApi` | Search operations across releases and templates. | +| `settingsApi` | `SettingsApi` | Operations on global settings. | +| `taskApi` | `TaskApi` | Operations on tasks. | +| `taskReportingApi` | `TaskReportingApi` | Operations on task reporting records. | +| `teamApi` | `TeamApi` | Operations on teams across releases, templates, and folders. | +| `templateApi` | `TemplateApi` | Operations on templates. | +| `triggersApi` | `TriggersApi` | Operations on triggers. | +| `userApi` | `UserApi` | Operations on users. | +| `variableApi` | `VariableApi` | Operations on variables across releases, templates, and folders. | + +### `apiClient` + +The shared `ReleaseAPIClient`, created on first access. Built from the task's +"Run as user" context (server URL + credentials) via +[`get_release_api_client`](base_task.md#get_release_api_client). Every API +wrapper property is bound to this same client. + +**Type:** `ReleaseAPIClient` + +[↑ API properties](#api-properties) + +--- + +## Method Index + +| Method | Returns | Description | +|--------|---------|-------------| +| [`getCurrentTask`](#getcurrenttask) | `Task` | Returns the task that is running this code. | +| [`getCurrentPhase`](#getcurrentphase) | `Phase` | Returns the phase that contains this task. | +| [`getCurrentRelease`](#getcurrentrelease) | `Release` | Returns the release this task belongs to. | +| [`getCurrentFolder`](#getcurrentfolder) | `Folder` | Returns the folder that contains the current release. | +| [`getReleaseVariable`](#getreleasevariable) | `Any` | Returns the value of a variable in the current release by name. | +| [`setReleaseVariable`](#setreleasevariable) | `Variable` | Sets the value of a variable in the current release by name. | +| [`getFolderVariable`](#getfoldervariable) | `Any` | Returns the value of a variable in the current folder by name. | +| [`setFolderVariable`](#setfoldervariable) | `Variable` | Sets the value of a variable owned by the current folder by name. | +| [`getGlobalVariable`](#getglobalvariable) | `Any` | Returns the value of a global variable by name. | +| [`setGlobalVariable`](#setglobalvariable) | `Variable` | Sets the value of a global variable by name. | +| [`getTasksByTitle`](#gettasksbytitle) | `list[Task]` | Finds tasks by title. | +| [`getPhasesByTitle`](#getphasesbytitle) | `list[Phase]` | Finds phases by title. | +| [`getReleasesByTitle`](#getreleasesbytitle) | `list[Release]` | Finds releases by title. | +| [`getVersion`](#getversion) | `str \| None` | Returns the version of this Digital.ai Release instance. | +| [`reset_api_clients`](#reset_api_clients) | `None` | Drops the cached client and API instances. | + +--- + +## Methods + +### `getCurrentTask` + +Returns the task that is running this code. Fetches the task via `taskApi` using +the task's own id. + +_No parameters._ + +**Returns:** `Task` — the current task. + +[↑ Method index](#method-index) + +### `getCurrentPhase` + +Returns the phase that contains this task. The phase id is derived from the task +id, then fetched via `phaseApi`. + +_No parameters._ + +**Returns:** `Phase` — the current phase. + +[↑ Method index](#method-index) + +### `getCurrentRelease` + +Returns the release this task belongs to. Fetches the release via `releaseApi` +using the task's release id, so the caller does not need to know or substitute +the id itself. + +_No parameters._ + +**Returns:** `Release` — the current release. + +[↑ Method index](#method-index) + +### `getCurrentFolder` + +Returns the folder that contains the current release. The folder id is derived +from the release id, then fetched via `folderApi`. + +_No parameters._ + +**Returns:** `Folder` — the current folder. + +[↑ Method index](#method-index) + +### `getReleaseVariable` + +Returns the value of a variable in the current release by name. The Python3 +equivalent of the Jython script global `releaseVariables[name]`. Pass the bare +variable name; the variable is looked up by its `key` and its stored value is +returned as-is. + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `name` | `str` | _required_ | the variable name (e.g. `"JenkinsBuildNumber"`). | + +**Returns:** `Any` — the variable's value. + +**Raises:** `KeyError` — if the release has no variable with that name. + +[↑ Method index](#method-index) + +### `setReleaseVariable` + +Sets the value of a variable in the current release by name. The Python3 +equivalent of the Jython script assignment `releaseVariables[name] = value`. If +the variable exists its value is updated; otherwise a new variable is created, +with its type inferred from `value`. + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `name` | `str` | _required_ | the variable name (e.g. `"JenkinsBuildNumber"`). | +| `value` | `Any` | _required_ | the new value to assign. | + +**Returns:** `Variable` — the updated (or newly created) variable. + +[↑ Method index](#method-index) + +### `getFolderVariable` + +Returns the value of a variable in the current folder by name. Like +[`getReleaseVariable`](#getreleasevariable), but scoped to the folder that +contains the current release. Inherited variables (from parent folders and +global variables) are included. The `folder.` prefix is required — pass the +fully qualified name (e.g. `"folder.foo"`). + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `name` | `str` | _required_ | the variable name, including the `folder.` prefix. | + +**Returns:** `Any` — the variable's value. + +**Raises:** `ValueError` — if `name` does not start with `folder.`; `KeyError` — if no such variable is visible to the folder. + +[↑ Method index](#method-index) + +### `setFolderVariable` + +Sets the value of a variable owned by the current folder by name. Only variables +the folder owns are matched: an inherited variable (defined on a parent folder +or as a global variable) is not. If the folder owns the variable its value is +updated; otherwise a new folder-owned variable is created (which shadows any +inherited value of the same name). The `folder.` prefix is required. + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `name` | `str` | _required_ | the variable name, including the `folder.` prefix. | +| `value` | `Any` | _required_ | the new value to assign. | + +**Returns:** `Variable` — the updated (or newly created) variable. + +**Raises:** `ValueError` — if `name` does not start with `folder.`. + +[↑ Method index](#method-index) + +### `getGlobalVariable` + +Returns the value of a global variable by name. Global variables are stored with +a `global.` prefix, which is required here — pass the fully qualified name (e.g. +`"global.foo"`). + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `name` | `str` | _required_ | the global variable name, including the `global.` prefix. | + +**Returns:** `Any` — the variable's value. + +**Raises:** `ValueError` — if `name` does not start with `global.`; `KeyError` — if no global variable with that name exists. + +[↑ Method index](#method-index) + +### `setGlobalVariable` + +Sets the value of a global variable by name. The `global.` prefix is required. +If the variable exists its value is updated; otherwise a new global variable is +created, with its type inferred from `value`. The task's run-as user must hold +the permission to edit global variables. + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `name` | `str` | _required_ | the global variable name, including the `global.` prefix. | +| `value` | `Any` | _required_ | the new value to assign. | + +**Returns:** `Variable` — the updated (or newly created) variable. + +**Raises:** `ValueError` — if `name` does not start with `global.`. + +[↑ Method index](#method-index) + +### `getTasksByTitle` + +Finds tasks by title. + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `taskTitle` | `str` | _required_ | the task title to search for. | +| `phaseTitle` | `str \| None` | `None` | optional phase title to scope the search; searches the whole release when omitted. | +| `releaseId` | `str \| None` | `None` | optional release to search; the current release when omitted. | + +**Returns:** `list[Task]` — the matching tasks. + +[↑ Method index](#method-index) + +### `getPhasesByTitle` + +Finds phases by title. + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `phaseTitle` | `str` | _required_ | the phase title to search for. | +| `releaseId` | `str \| None` | `None` | optional release to search; the current release when omitted. | + +**Returns:** `list[Phase]` — the matching phases. + +[↑ Method index](#method-index) + +### `getReleasesByTitle` + +Finds releases by title. + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `releaseTitle` | `str` | _required_ | the release title to search for. | + +**Returns:** `list[Release]` — the matching releases. + +[↑ Method index](#method-index) + +### `getVersion` + +Returns the version of this Digital.ai Release instance. + +_No parameters._ + +**Returns:** `str \| None` — the instance version, or `None` if unavailable. + +[↑ Method index](#method-index) + +### `reset_api_clients` + +Drops the cached client and API instances (e.g. to re-authenticate). The next +access to [`apiClient`](#apiclient) or any API wrapper property rebuilds them. + +_No parameters._ + +**Returns:** _None._ + +[↑ Method index](#method-index) + +--- + +[🏠 Docs Home](../README.md) · [Base Classes](../README.md#base-classes) · [Examples](../examples.md) diff --git a/docs/classes/base_task.md b/docs/classes/base_task.md new file mode 100644 index 0000000..7acfdfe --- /dev/null +++ b/docs/classes/base_task.md @@ -0,0 +1,311 @@ +[🏠 Docs Home](../README.md) › [Base Classes](../README.md#base-classes) › **BaseTask** + +# BaseTask + +> An abstract base class representing a task that can be executed. + +| | | +|---|---| +| **Class** | `BaseTask` | +| **Module** | `digitalai.release.integration.base_task` | +| **Source** | [`digitalai/release/integration/base_task.py`](../../digitalai/release/integration/base_task.py) | +| **Methods** | 18 | + +Subclass `BaseTask` to build a container-based integration task. Implement the +abstract [`execute`](#execute) method with your task logic; the Release runtime +calls [`execute_task`](#execute_task), which wraps `execute` with error handling +and exit-code management. Inside `execute` you read the task's input through +[`get_input_properties`](#get_input_properties) (or the `input_properties` +attribute) and report results through `set_output_property`, `add_comment`, +`set_status_line`, and `add_reporting_record`. + +For tasks that need to call the Release v1 REST API, subclass +[`ApiBaseTask`](api_base_task.md) instead — it extends `BaseTask` and exposes a +ready-to-use API client. + +```python +from digitalai.release.integration import BaseTask + + +class Hello(BaseTask): + + def execute(self) -> None: + name = self.input_properties.get("yourName") + if not name: + raise ValueError("The 'yourName' field cannot be empty") + greeting = f"Hello {name}" + self.add_comment(greeting) + self.set_output_property("greeting", greeting) +``` + +--- + +## Method Index + +| Method | Returns | Description | +|--------|---------|-------------| +| [`execute`](#execute) | `None` | Abstract method holding the task's main logic; implemented by subclasses. | +| [`execute_task`](#execute_task) | `None` | Runs `execute` with error handling and exit-code management; called by the runtime. | +| [`abort`](#abort) | `None` | Sets the exit code to 1 and exits the program. | +| [`get_input_properties`](#get_input_properties) | `dict[str, Any]` | Returns the task's input properties. | +| [`set_output_property`](#set_output_property) | `None` | Sets a named output property of the task. | +| [`get_output_properties`](#get_output_properties) | `dict[str, Any]` | Returns the output properties of the task. | +| [`get_output_context`](#get_output_context) | `OutputContext` | Returns the task's `OutputContext`. | +| [`set_exit_code`](#set_exit_code) | `None` | Sets the task's exit code. | +| [`set_error_message`](#set_error_message) | `None` | Sets the task's error message. | +| [`add_comment`](#add_comment) | `None` | Logs a comment shown in the task's comment section in the UI. | +| [`set_status_line`](#set_status_line) | `None` | Sets the status line of the task. | +| [`add_reporting_record`](#add_reporting_record) | `None` | Adds a reporting record to the output context. | +| [`get_release_server_url`](#get_release_server_url) | `str` | Returns the Release server URL of the task. | +| [`get_task_user`](#get_task_user) | `AutomatedTaskAsUserContext \| None` | Returns the "Run as user" details, or `None`. | +| [`get_release_id`](#get_release_id) | `str` | Returns the release id of the task. | +| [`get_task_id`](#get_task_id) | `str` | Returns the task id. | +| [`get_phase_id`](#get_phase_id) | `str` | Returns the phase id, derived from the task id. | +| [`get_folder_id`](#get_folder_id) | `str` | Returns the id of the folder that contains the release. | +| [`get_release_api_client`](#get_release_api_client) | `ReleaseAPIClient` | Builds a `ReleaseAPIClient`, by default from the task context. | + +--- + +## Methods + +### `execute` + +**Abstract** — must be implemented by subclasses. Holds the main logic of the +task. The Release runtime invokes it through [`execute_task`](#execute_task), so +you normally do not call it yourself. + +_No parameters._ + +**Returns:** _None._ + +```python +class MyTask(BaseTask): + def execute(self) -> None: + ... # your task logic +``` + +[↑ Method index](#method-index) + +### `execute_task` + +Executes the task by calling [`execute`](#execute). If an `AbortException` is +raised during execution, the task's exit code is set to `1` and the program +exits with status `1`. If any other exception is raised, the exit code is set to +`1` and its message is stored as the error message. This is the entry point the +runtime calls. + +_No parameters._ + +**Returns:** _None._ + +[↑ Method index](#method-index) + +### `abort` + +Sets the task's exit code to `1` and exits the program with a status code of `1`. + +_No parameters._ + +**Returns:** _None._ + +[↑ Method index](#method-index) + +### `get_input_properties` + +Returns the input properties dictionary of the task. + +_No parameters._ + +**Returns:** `dict[str, Any]` — the task's input properties. + +**Raises:** `ValueError` — if the input properties have not been set. + +[↑ Method index](#method-index) + +### `set_output_property` + +Sets the name and value of an output property of the task. + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `name` | `str` | _required_ | the output property name. Cannot be empty. | +| `value` | `Any` | _required_ | the value to store. Accepted types are `str`, `int`, `list`, `dict`, `bool`. | + +**Returns:** _None._ + +**Raises:** `ValueError` — if `name` is empty, or `value` is not one of the accepted data types. + +[↑ Method index](#method-index) + +### `get_output_properties` + +Returns the output properties dictionary of the task's `OutputContext`. + +_No parameters._ + +**Returns:** `dict[str, Any]` — the output properties set so far. + +[↑ Method index](#method-index) + +### `get_output_context` + +Returns the `OutputContext` object associated with the task. + +_No parameters._ + +**Returns:** `OutputContext` — the task's output context. + +[↑ Method index](#method-index) + +### `set_exit_code` + +Sets the exit code of the task's `OutputContext`. + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `value` | `int` | _required_ | the exit code (`0` for success, non-zero for failure). | + +**Returns:** _None._ + +[↑ Method index](#method-index) + +### `set_error_message` + +Sets the error message of the task's `OutputContext`. + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `value` | `str` | _required_ | the error message to record. | + +**Returns:** _None._ + +[↑ Method index](#method-index) + +### `add_comment` + +Logs a comment for the task. The comment is shown in the task's comment section +in the Release UI. + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `comment` | `str` | _required_ | the comment text. | + +**Returns:** _None._ + +[↑ Method index](#method-index) + +### `set_status_line` + +Sets the status line of the task. + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `status_line` | `str` | _required_ | the status text to display. | + +**Returns:** _None._ + +[↑ Method index](#method-index) + +### `add_reporting_record` + +Adds a reporting record to the task's `OutputContext`. + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `reporting_record` | `Any` | _required_ | the reporting record to append. | + +**Returns:** _None._ + +[↑ Method index](#method-index) + +### `get_release_server_url` + +Returns the Release server URL of the associated task. + +_No parameters._ + +**Returns:** `str` — the Release server URL. + +[↑ Method index](#method-index) + +### `get_task_user` + +Returns the user details that are executing the task (the "Run as user" +context), or `None` when no release context is available. + +_No parameters._ + +**Returns:** `AutomatedTaskAsUserContext \| None` — the task user context, or `None`. + +[↑ Method index](#method-index) + +### `get_release_id` + +Returns the release id of the task. + +_No parameters._ + +**Returns:** `str` — the release identifier. + +[↑ Method index](#method-index) + +### `get_task_id` + +Returns the task id. + +_No parameters._ + +**Returns:** `str` — the task identifier. + +[↑ Method index](#method-index) + +### `get_phase_id` + +Returns the phase id of the task, derived from the task id. + +_No parameters._ + +**Returns:** `str` — the phase identifier. + +[↑ Method index](#method-index) + +### `get_folder_id` + +Returns the id of the folder that contains the release, derived from the release +id. + +_No parameters._ + +**Returns:** `str` — the folder identifier. + +[↑ Method index](#method-index) + +### `get_release_api_client` + +Returns a `ReleaseAPIClient`. All arguments are optional: when omitted, the +client is configured from the task context (server URL and the "Run as user" +credentials). Any argument that is provided overrides the corresponding task +default. + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `server_address` | `str` | `None` | Release server URL. Defaults to the task's server URL. | +| `username` | `str` | `None` | username. Defaults to the task user's username. | +| `password` | `str` | `None` | password. Defaults to the task user's password. | +| `personal_access_token` | `str` | `None` | personal access token for authentication. When set, it is used instead of username/password. | +| `timeout` | `float \| tuple[float, float] \| None` | `None` | default timeout (in seconds) applied to every request. Accepts a single float or a `(connect, read)` tuple. Can be overridden per call. | +| `**kwargs` | `Any` | — | additional session parameters (e.g. `headers`, `verify`, `proxies`). | + +**Returns:** `ReleaseAPIClient` — a configured client. + +**Raises:** `ValueError` — if the server URL, username, or password cannot be resolved (e.g. the "Run as user" property is not set on the release). + +> **Tip:** if your task needs the API, subclass [`ApiBaseTask`](api_base_task.md) +> instead of calling this method directly — it builds and caches the client and +> every API wrapper for you. + +[↑ Method index](#method-index) + +--- + +[🏠 Docs Home](../README.md) · [Base Classes](../README.md#base-classes) · [Examples](../examples.md) diff --git a/docs/examples.md b/docs/examples.md new file mode 100644 index 0000000..c75201e --- /dev/null +++ b/docs/examples.md @@ -0,0 +1,276 @@ +[🏠 Docs Home](README.md) › **Examples** + +# Examples + +Task-oriented usage examples for the **Digital.ai Release Python SDK**. Each +snippet defines a task by subclassing [`BaseTask`](classes/base_task.md) or +[`ApiBaseTask`](classes/api_base_task.md) and implementing `execute`. The +Release runtime instantiates your task and calls `execute_task`, which runs +`execute` and handles errors and exit codes for you. + +## Contents + +- [Define a task with BaseTask](#define-a-task-with-basetask) +- [Read inputs and write outputs](#read-inputs-and-write-outputs) +- [Comments, status line, and reporting](#comments-status-line-and-reporting) +- [Fail or abort a task](#fail-or-abort-a-task) +- [Call the Release API with ApiBaseTask](#call-the-release-api-with-apibasetask) +- [Work with the current task, phase, release, and folder](#work-with-the-current-task-phase-release-and-folder) +- [Manage variables](#manage-variables) +- [Search by title](#search-by-title) +- [Build a client manually](#build-a-client-manually) + +--- + +## Define a task with BaseTask + +Subclass [`BaseTask`](classes/base_task.md) and implement the abstract +`execute` method. Input fields configured on the task are available through the +`input_properties` attribute (or [`get_input_properties`](classes/base_task.md#get_input_properties)). + +**Classes:** [`BaseTask`](classes/base_task.md) + +```python +from digitalai.release.integration import BaseTask + + +class Hello(BaseTask): + + def execute(self) -> None: + name = self.input_properties.get("yourName") + if not name: + raise ValueError("The 'yourName' field cannot be empty") + + greeting = f"Hello {name}" + self.add_comment(greeting) + self.set_output_property("greeting", greeting) +``` + +[↑ Contents](#contents) + +## Read inputs and write outputs + +Read configured input fields and publish results as output properties. Output +values must be one of `str`, `int`, `list`, `dict`, or `bool`. Output properties +are returned to Release and can be consumed by downstream tasks. + +**Classes:** [`BaseTask`](classes/base_task.md) + +```python +from digitalai.release.integration import BaseTask + + +class BuildSummary(BaseTask): + + def execute(self) -> None: + inputs = self.get_input_properties() + version = inputs["version"] + artifacts = inputs.get("artifacts", []) + + self.set_output_property("version", version) + self.set_output_property("artifactCount", len(artifacts)) + self.set_output_property("succeeded", True) +``` + +[↑ Contents](#contents) + +## Comments, status line, and reporting + +Surface progress to the Release UI: `add_comment` writes to the task's comment +section, `set_status_line` updates the one-line status, and +`add_reporting_record` attaches a reporting record to the output context. + +**Classes:** [`BaseTask`](classes/base_task.md) + +```python +from digitalai.release.integration import BaseTask + + +class Deploy(BaseTask): + + def execute(self) -> None: + self.set_status_line("Deploying…") + self.add_comment("Starting deployment to **prod**") + + # ... do the work ... + + self.set_status_line("Deployed") + self.add_comment("Deployment finished successfully") +``` + +[↑ Contents](#contents) + +## Fail or abort a task + +Raising an exception from `execute` fails the task: `execute_task` records the +message and sets a non-zero exit code. To stop immediately, call `abort`, which +sets the exit code to `1` and exits the process. + +**Classes:** [`BaseTask`](classes/base_task.md) + +```python +from digitalai.release.integration import BaseTask + + +class GuardedTask(BaseTask): + + def execute(self) -> None: + if not self.input_properties.get("confirmed"): + # Fails the task with this message shown in Release + raise ValueError("Task was not confirmed") + + if self.input_properties.get("cancel"): + # Stop right away + self.abort() + + self.add_comment("Proceeding") +``` + +[↑ Contents](#contents) + +## Call the Release API with ApiBaseTask + +Subclass [`ApiBaseTask`](classes/api_base_task.md) to call the Release v1 REST +API without building a client yourself. Every API is exposed as a lazily +created, cached property (`releaseApi`, `taskApi`, …), all sharing a single +client built from the task's "Run as user" context. + +**Classes:** [`ApiBaseTask`](classes/api_base_task.md) + +```python +from digitalai.release.integration.api_base_task import ApiBaseTask + + +class ShowVersion(ApiBaseTask): + + def execute(self) -> None: + release = self.releaseApi.getRelease(self.get_release_id()) + self.add_comment(f"Working on {release.title}") + + version = self.getVersion() + self.set_output_property("releaseVersion", version) +``` + +[↑ Contents](#contents) + +## Work with the current task, phase, release, and folder + +The current-context helpers resolve the objects the task is running in, so you +do not have to derive ids yourself. + +**Classes:** [`ApiBaseTask`](classes/api_base_task.md) + +```python +from digitalai.release.integration.api_base_task import ApiBaseTask + + +class Context(ApiBaseTask): + + def execute(self) -> None: + task = self.getCurrentTask() + phase = self.getCurrentPhase() + release = self.getCurrentRelease() + folder = self.getCurrentFolder() + + self.add_comment( + f"Task '{task.title}' in phase '{phase.title}' " + f"of release '{release.title}' (folder '{folder.title}')" + ) +``` + +[↑ Contents](#contents) + +## Manage variables + +Read and write release, folder, and global variables by name. Folder and global +names must include their `folder.` / `global.` prefix. When a variable does not +exist yet, the `set…` helpers create it, inferring the variable type from the +value. + +**Classes:** [`ApiBaseTask`](classes/api_base_task.md) + +```python +from digitalai.release.integration.api_base_task import ApiBaseTask + + +class Variables(ApiBaseTask): + + def execute(self) -> None: + # Release variables (bare name) + build = self.getReleaseVariable("JenkinsBuildNumber") + self.setReleaseVariable("JenkinsBuildNumber", build + 1) + + # Folder variables (require the 'folder.' prefix) + owner = self.getFolderVariable("folder.owner") + self.setFolderVariable("folder.owner", owner) + + # Global variables (require the 'global.' prefix) + self.setGlobalVariable("global.lastRunBy", "release-bot") +``` + +[↑ Contents](#contents) + +## Search by title + +Find tasks, phases, and releases by title. + +**Classes:** [`ApiBaseTask`](classes/api_base_task.md) + +```python +from digitalai.release.integration.api_base_task import ApiBaseTask + + +class Search(ApiBaseTask): + + def execute(self) -> None: + # Tasks in the current release, optionally scoped to a phase + deploys = self.getTasksByTitle("Deploy", phaseTitle="Production") + + # Phases in the current release + phases = self.getPhasesByTitle("Production") + + # Releases by title across the instance + releases = self.getReleasesByTitle("Nightly") + + self.add_comment( + f"Found {len(deploys)} tasks, {len(phases)} phases, " + f"{len(releases)} releases" + ) +``` + +[↑ Contents](#contents) + +## Build a client manually + +You can also build a `ReleaseAPIClient` directly from a `BaseTask` with +[`get_release_api_client`](classes/base_task.md#get_release_api_client). With no +arguments it uses the task context; pass arguments to override the server URL or +credentials (e.g. to authenticate with a personal access token, or to call a +different server). + +**Classes:** [`BaseTask`](classes/base_task.md) + +```python +from digitalai.release.integration import BaseTask + + +class CustomClient(BaseTask): + + def execute(self) -> None: + # Default: server URL + 'Run as user' credentials from the task context + client = self.get_release_api_client() + + # Override: a different server with a personal access token and a timeout + other = self.get_release_api_client( + server_address="https://release.example.com", + personal_access_token="your-token-here", + timeout=(5, 30), # (connect, read) seconds + ) + ... +``` + +[↑ Contents](#contents) + +--- + +[🏠 Docs Home](README.md) · [Base Classes](README.md#base-classes) diff --git a/tests/release/integration/test_api_base_task_live.py b/tests/release/integration/test_api_base_task_live.py new file mode 100644 index 0000000..1409947 --- /dev/null +++ b/tests/release/integration/test_api_base_task_live.py @@ -0,0 +1,610 @@ +""" +Live integration tests for :class:`ApiBaseTask`. + +These tests run against a *real* Digital.ai Release server (by default the +developer instance on ``localhost:5516`` with ``admin``/``admin``). They build +an ``ApiBaseTask`` wired exactly the way the Release runtime wires it -- a task +context carrying the release id, the task id and the "Run as user" credentials +-- and then exercise the convenience helpers (``getCurrent*``, ``get*ByTitle``, +the variable getters/setters and ``getVersion``) end to end. + +The suite is fully self-contained: + +* ``setUpClass`` first verifies the server is reachable, authentication works + and the API responds within a bounded timeout. If any of those checks fail + the whole suite is skipped (CI has no Release server). +* It then creates every prerequisite object -- a folder, a template (moved into + that folder), a phase, a task, and release/folder/global variables -- and + spins up a release from the template so the task context points at real ids. +* ``tearDownClass`` removes everything again; deleting the folder cascades to + the release and template, so the server is returned to its original state. +""" + +import socket +import unittest +import uuid + +from digitalai.release.release_api_client import ReleaseAPIClient +from digitalai.release.integration.api_base_task import ApiBaseTask +from digitalai.release.integration.input_context import ( + AutomatedTaskAsUserContext, + ReleaseContext, +) + +from com.xebialabs.xlrelease.api.v1.configuration_api import ConfigurationApi +from com.xebialabs.xlrelease.api.v1.folder_api import FolderApi +from com.xebialabs.xlrelease.api.v1.phase_api import PhaseApi +from com.xebialabs.xlrelease.api.v1.release_api import ReleaseApi +from com.xebialabs.xlrelease.api.v1.task_api import TaskApi +from com.xebialabs.xlrelease.api.v1.template_api import TemplateApi +from com.xebialabs.xlrelease.domain.folder import Folder +from com.xebialabs.xlrelease.domain.forms import CreateRelease +from com.xebialabs.xlrelease.domain.phase import Phase +from com.xebialabs.xlrelease.domain.release import Release +from com.xebialabs.xlrelease.domain.task import Task +from com.xebialabs.xlrelease.domain.variable import Variable + + +# Live server connection details. Locally this is the developer's instance; +# CI has no such server, so the suite skips itself when it is not reachable. +LIVE_SERVER_HOST = "localhost" +LIVE_SERVER_PORT = 5516 +LIVE_SERVER_URL = f"http://{LIVE_SERVER_HOST}:{LIVE_SERVER_PORT}" +LIVE_SERVER_USER = "admin" +LIVE_SERVER_PASSWORD = "admin" + +# Bounded timeout (connect, read) so an unresponsive server skips rather than +# hangs the suite. +LIVE_SERVER_TIMEOUT = (3.05, 10) + + +def _server_reachable(host: str, port: int, timeout: float = 0.5) -> bool: + """Return True if a TCP connection to host:port succeeds within the timeout.""" + try: + with socket.create_connection((host, port), timeout=timeout): + return True + except OSError: + return False + + +class _LiveApiTask(ApiBaseTask): + """Concrete ApiBaseTask used to drive the helpers against the live server.""" + + def execute(self) -> None: # pragma: no cover - never executed by the tests + pass + + +@unittest.skipUnless( + _server_reachable(LIVE_SERVER_HOST, LIVE_SERVER_PORT), + f"live Release server not available on {LIVE_SERVER_HOST}:{LIVE_SERVER_PORT}", +) +class TestApiBaseTaskLive(unittest.TestCase): + """Live integration tests for the ApiBaseTask convenience helpers.""" + + # Populated by setUpClass; defaults keep tearDownClass safe if setup aborts. + client = None + uid = None + folder_id = None + folder_title = None + template_id = None + release_id = None + release_title = None + task_id = None + global_var_id = None + global_var_name = None + # Global variables created by the "create when missing" tests; tracked here so + # _cleanup can delete them (they live outside the folder, so the folder-delete + # cascade does not remove them). + created_global_var_ids = [] + task = None + + # -- lifecycle ---------------------------------------------------------- + + @classmethod + def setUpClass(cls): + uid = uuid.uuid4().hex[:10] + cls.uid = uid + cls.created_global_var_ids = [] + cls.client = ReleaseAPIClient( + LIVE_SERVER_URL, + LIVE_SERVER_USER, + LIVE_SERVER_PASSWORD, + timeout=LIVE_SERVER_TIMEOUT, + ) + + # Skip gate: verify authentication works and the API responds. Any + # failure here (auth error, timeout, unreachable) skips the whole suite. + folder_api = FolderApi(cls.client) + try: + root_folders = folder_api.listRoot() + if not root_folders: + raise unittest.SkipTest("No root folders available on the server.") + except unittest.SkipTest: + raise + except Exception as exc: # noqa: BLE001 - any failure means "skip" + raise unittest.SkipTest( + f"Release server authentication/API check failed: {exc}" + ) + + try: + cls._create_test_data(uid, root_folders[0].id) + except Exception: + # Clean up whatever was created before re-raising; unittest does not + # call tearDownClass when setUpClass fails. + cls._cleanup() + raise + + # Build the ApiBaseTask exactly as the Release runtime would: server URL + # + "Run as user" credentials + the real release and task ids. + cls.task = _LiveApiTask() + cls.task.release_server_url = LIVE_SERVER_URL + cls.task.release_context = ReleaseContext( + id=cls.release_id, + automated_task_as_user=AutomatedTaskAsUserContext( + username=LIVE_SERVER_USER, password=LIVE_SERVER_PASSWORD + ), + ) + cls.task.task_id = cls.task_id + print(f"Live ApiBaseTask wired to release {cls.release_id}") + + @classmethod + def _create_test_data(cls, uid: str, root_id: str): + """Create the folder/template/release/variables the helpers operate on.""" + template_api = TemplateApi(cls.client) + folder_api = FolderApi(cls.client) + phase_api = PhaseApi(cls.client) + task_api = TaskApi(cls.client) + config_api = ConfigurationApi(cls.client) + + # Folder (under root) that will contain the release. + cls.folder_title = f"AbtLiveFolder_{uid}" + folder = folder_api.addFolder(root_id, Folder(title=cls.folder_title)) + cls.folder_id = folder.id + print(f"Created folder: {cls.folder_id}") + + # Template at root with a phase, a manual task and a release variable. + template_title = f"AbtLiveTemplate_{uid}" + payload = { + "id": f"Applications/Release_abt_live_{uid}", + "title": template_title, + "type": "xlrelease.Release", + "status": "TEMPLATE", + "scheduledStartDate": "2026-06-01T00:00:00+00:00", + "dueDate": "2026-07-01T00:00:00+00:00", + } + response = cls.client.post("/api/v1/templates", json=payload) + response.raise_for_status() + root_template = Release.from_dict(response.json()) + + phase = phase_api.addPhase( + root_template.id, Phase(title="Test Phase", type="xlrelease.Phase") + ) + task_api.addTask(phase.id, Task(title="Manual Task", type="xlrelease.Task")) + template_api.createVariable( + root_template.id, + Variable( + type="xlrelease.StringVariable", + key="relVar", + value="release-value", + requiresValue=False, + showOnReleaseStart=False, + ), + ) + # A set (set-of-string) release variable; its value round-trips as a list. + template_api.createVariable( + root_template.id, + Variable( + type="xlrelease.SetStringVariable", + key="relSetVar", + value=["Apples", "Pears"], + requiresValue=False, + showOnReleaseStart=False, + ), + ) + # A key/value-map release variable; its value round-trips as a dict. + template_api.createVariable( + root_template.id, + Variable( + type="xlrelease.MapStringStringVariable", + key="relMapVar", + value={"env": "dev", "region": "us"}, + requiresValue=False, + showOnReleaseStart=False, + ), + ) + + # Move the template into the folder so releases created from it live + # inside the folder (required for getCurrentFolder to resolve). + folder_api.moveTemplate(cls.folder_id, root_template.id) + moved = [ + t for t in folder_api.getTemplates(cls.folder_id) + if t.title == template_title + ] + if not moved: + raise RuntimeError("Template not found in folder after move.") + cls.template_id = moved[0].id + print(f"Created template in folder: {cls.template_id}") + + # A folder-owned variable. + folder_api.createVariable( + cls.folder_id, + Variable( + type="xlrelease.StringVariable", + key="folder.folderVar", + value="folder-value", + requiresValue=False, + showOnReleaseStart=False, + ), + ) + + # A global variable (lives outside the folder; deleted explicitly later). + cls.global_var_name = f"abtLiveGlobal_{uid}" + global_var = config_api.addGlobalVariable( + Variable( + type="xlrelease.StringVariable", + key=f"global.{cls.global_var_name}", + value="global-value", + requiresValue=False, + showOnReleaseStart=False, + ) + ) + cls.global_var_id = global_var.id + print(f"Created global variable: {cls.global_var_id}") + + # Create the release from the template; it inherits the phase, task and + # release variable, and lives inside the folder. + cls.release_title = f"AbtLiveRelease_{uid}" + release = template_api.create( + cls.template_id, CreateRelease(releaseTitle=cls.release_title) + ) + cls.release_id = release.id + print(f"Created release: {cls.release_id}") + + # Resolve the task id of the release's manual task -> the task context. + tasks = task_api.searchTasksByTitle("Manual Task", cls.release_id) + if not tasks: + raise RuntimeError("Manual Task not found in created release.") + cls.task_id = tasks[0].id + print(f"Resolved task id: {cls.task_id}") + + @classmethod + def _cleanup(cls): + """Best-effort removal of every object created by the suite.""" + client = cls.client + if client is None: + return + + if cls.release_id: + release_api = ReleaseApi(client) + try: + release_api.abort(cls.release_id, "Aborting for test cleanup") + except Exception: + pass + try: + release_api.delete(cls.release_id) + except Exception: + pass + + if cls.template_id: + try: + TemplateApi(client).deleteTemplate(cls.template_id) + except Exception: + pass + + if cls.global_var_id: + try: + ConfigurationApi(client).deleteGlobalVariable(cls.global_var_id) + except Exception: + pass + + # Global variables created by the "create when missing" tests. + for var_id in cls.created_global_var_ids: + try: + ConfigurationApi(client).deleteGlobalVariable(var_id) + except Exception: + pass + + # Deleting the folder cascades to anything still inside it (release, + # template), so this is the catch-all that restores the server state. + if cls.folder_id: + try: + FolderApi(client).delete(cls.folder_id) + except Exception: + pass + + @classmethod + def tearDownClass(cls): + cls._cleanup() + + # -- getVersion --------------------------------------------------------- + + def test_01_get_version(self): + """getVersion returns the server's version string.""" + version = self.task.getVersion() + self.assertIsInstance(version, str) + self.assertTrue(version) + print(f"getVersion -> {version}") + + # -- getCurrent* -------------------------------------------------------- + + def test_02_get_current_release(self): + """getCurrentRelease returns the release the task belongs to.""" + release = self.task.getCurrentRelease() + self.assertEqual(release.id, self.release_id) + self.assertEqual(release.title, self.release_title) + print(f"getCurrentRelease -> {release.title}") + + def test_03_get_current_task(self): + """getCurrentTask returns the task running the code.""" + task = self.task.getCurrentTask() + self.assertEqual(task.id, self.task_id) + self.assertEqual(task.title, "Manual Task") + print(f"getCurrentTask -> {task.title}") + + def test_04_get_current_phase(self): + """getCurrentPhase returns the phase that contains the task.""" + phase = self.task.getCurrentPhase() + self.assertEqual(phase.id, self.task.get_phase_id()) + self.assertEqual(phase.title, "Test Phase") + print(f"getCurrentPhase -> {phase.title}") + + def test_05_get_current_folder(self): + """getCurrentFolder returns the folder that contains the release.""" + folder = self.task.getCurrentFolder() + self.assertEqual(folder.id, self.folder_id) + self.assertEqual(folder.title, self.folder_title) + print(f"getCurrentFolder -> {folder.title}") + + # -- release variables -------------------------------------------------- + + def test_06_get_release_variable(self): + """getReleaseVariable returns the value of a current-release variable.""" + value = self.task.getReleaseVariable("relVar") + self.assertEqual(value, "release-value") + print(f"getReleaseVariable('relVar') -> {value}") + + def test_07_set_release_variable(self): + """setReleaseVariable persists a new value, readable back via the getter.""" + updated = self.task.setReleaseVariable("relVar", "release-value-updated") + self.assertEqual(updated.value, "release-value-updated") + self.assertEqual( + self.task.getReleaseVariable("relVar"), "release-value-updated" + ) + print("setReleaseVariable('relVar') -> release-value-updated") + + def test_08_get_release_variable_missing_raises(self): + """getReleaseVariable raises KeyError for an unknown variable name.""" + with self.assertRaises(KeyError): + self.task.getReleaseVariable("doesNotExist") + print("getReleaseVariable(unknown) correctly raised KeyError") + + # -- release variables: set (set-of-string) ----------------------------- + + def test_08a_get_release_set_variable(self): + """getReleaseVariable returns a set variable's value as a list.""" + value = self.task.getReleaseVariable("relSetVar") + # A set is unordered on the server, so compare membership, not order. + self.assertEqual(set(value), {"Apples", "Pears"}) + print(f"getReleaseVariable('relSetVar') -> {value}") + + def test_08b_set_release_set_variable(self): + """setReleaseVariable persists a new set value, readable back via the getter.""" + new_value = ["Oranges", "Bananas", "Grapes"] + updated = self.task.setReleaseVariable("relSetVar", new_value) + self.assertEqual(set(updated.value), set(new_value)) + self.assertEqual( + set(self.task.getReleaseVariable("relSetVar")), set(new_value) + ) + print(f"setReleaseVariable('relSetVar') -> {new_value}") + + # -- release variables: key/value map ----------------------------------- + + def test_08c_get_release_map_variable(self): + """getReleaseVariable returns a key/value-map variable's value as a dict.""" + value = self.task.getReleaseVariable("relMapVar") + self.assertEqual(value, {"env": "dev", "region": "us"}) + print(f"getReleaseVariable('relMapVar') -> {value}") + + def test_08d_set_release_map_variable(self): + """setReleaseVariable persists a new map value, readable back via the getter.""" + new_value = {"env": "prod", "region": "eu", "tier": "gold"} + updated = self.task.setReleaseVariable("relMapVar", new_value) + self.assertEqual(updated.value, new_value) + self.assertEqual(self.task.getReleaseVariable("relMapVar"), new_value) + print(f"setReleaseVariable('relMapVar') -> {new_value}") + + # -- release variables: create when missing ----------------------------- + + def test_08e_set_release_variable_creates_when_missing(self): + """setReleaseVariable creates a string variable when the key is unknown.""" + created = self.task.setReleaseVariable("relNewVar", "created-value") + self.assertEqual(created.key, "relNewVar") + self.assertEqual(created.type, "xlrelease.StringVariable") + self.assertEqual(created.value, "created-value") + self.assertEqual(self.task.getReleaseVariable("relNewVar"), "created-value") + print("setReleaseVariable('relNewVar') created -> created-value") + + def test_08f_set_release_set_variable_creates_when_missing(self): + """setReleaseVariable creates a set variable when the key is unknown.""" + new_value = ["red", "green", "blue"] + created = self.task.setReleaseVariable("relNewSetVar", new_value) + self.assertEqual(created.type, "xlrelease.SetStringVariable") + self.assertEqual(set(created.value), set(new_value)) + self.assertEqual( + set(self.task.getReleaseVariable("relNewSetVar")), set(new_value) + ) + print(f"setReleaseVariable('relNewSetVar') created -> {new_value}") + + def test_08g_set_release_map_variable_creates_when_missing(self): + """setReleaseVariable creates a map variable when the key is unknown.""" + new_value = {"stage": "qa", "owner": "team-a"} + created = self.task.setReleaseVariable("relNewMapVar", new_value) + self.assertEqual(created.type, "xlrelease.MapStringStringVariable") + self.assertEqual(created.value, new_value) + self.assertEqual(self.task.getReleaseVariable("relNewMapVar"), new_value) + print(f"setReleaseVariable('relNewMapVar') created -> {new_value}") + + # -- folder variables --------------------------------------------------- + + def test_09_get_folder_variable(self): + """getFolderVariable resolves a folder variable by its prefixed name.""" + self.assertEqual( + self.task.getFolderVariable("folder.folderVar"), "folder-value" + ) + print("getFolderVariable('folder.folderVar') -> folder-value") + + def test_09a_get_folder_variable_requires_prefix(self): + """getFolderVariable raises ValueError when the folder. prefix is missing.""" + with self.assertRaises(ValueError): + self.task.getFolderVariable("folderVar") + print("getFolderVariable('folderVar') correctly raised ValueError") + + def test_10_set_folder_variable(self): + """setFolderVariable persists a new value on the folder-owned variable.""" + updated = self.task.setFolderVariable( + "folder.folderVar", "folder-value-updated" + ) + self.assertEqual(updated.value, "folder-value-updated") + self.assertEqual( + self.task.getFolderVariable("folder.folderVar"), "folder-value-updated" + ) + print("setFolderVariable('folder.folderVar') -> folder-value-updated") + + def test_10_set_folder_variable_requires_prefix(self): + """setFolderVariable raises ValueError when the folder. prefix is missing.""" + with self.assertRaises(ValueError): + self.task.setFolderVariable("folderVar", "nope") + print("setFolderVariable('folderVar') correctly raised ValueError") + + # -- folder variables: create when missing ------------------------------ + + def test_10a_set_folder_variable_creates_when_missing(self): + """setFolderVariable creates a folder-owned variable when the key is unknown.""" + created = self.task.setFolderVariable("folder.folderNewVar", "folder-created") + self.assertEqual(created.key, "folder.folderNewVar") + self.assertEqual(created.type, "xlrelease.StringVariable") + self.assertEqual(created.value, "folder-created") + self.assertEqual( + self.task.getFolderVariable("folder.folderNewVar"), "folder-created" + ) + print("setFolderVariable('folder.folderNewVar') created -> folder-created") + + def test_10b_set_folder_set_variable_creates_when_missing(self): + """setFolderVariable creates a set variable when the key is unknown.""" + new_value = ["alpha", "beta"] + created = self.task.setFolderVariable("folder.folderNewSetVar", new_value) + self.assertEqual(created.type, "xlrelease.SetStringVariable") + self.assertEqual(set(created.value), set(new_value)) + self.assertEqual( + set(self.task.getFolderVariable("folder.folderNewSetVar")), set(new_value) + ) + print(f"setFolderVariable('folder.folderNewSetVar') created -> {new_value}") + + def test_10c_set_folder_map_variable_creates_when_missing(self): + """setFolderVariable creates a map variable when the key is unknown.""" + new_value = {"team": "infra", "tier": "silver"} + created = self.task.setFolderVariable("folder.folderNewMapVar", new_value) + self.assertEqual(created.type, "xlrelease.MapStringStringVariable") + self.assertEqual(created.value, new_value) + self.assertEqual( + self.task.getFolderVariable("folder.folderNewMapVar"), new_value + ) + print(f"setFolderVariable('folder.folderNewMapVar') created -> {new_value}") + + # -- global variables --------------------------------------------------- + + def test_11_get_global_variable(self): + """getGlobalVariable resolves a global variable by its prefixed name.""" + self.assertEqual( + self.task.getGlobalVariable(f"global.{self.global_var_name}"), + "global-value", + ) + print(f"getGlobalVariable('global.{self.global_var_name}') -> global-value") + + def test_11a_get_global_variable_requires_prefix(self): + """getGlobalVariable raises ValueError when the global. prefix is missing.""" + with self.assertRaises(ValueError): + self.task.getGlobalVariable(self.global_var_name) + print("getGlobalVariable(unprefixed) correctly raised ValueError") + + def test_12_set_global_variable(self): + """setGlobalVariable persists a new value on the global variable.""" + key = f"global.{self.global_var_name}" + updated = self.task.setGlobalVariable(key, "global-value-updated") + self.assertEqual(updated.value, "global-value-updated") + self.assertEqual(self.task.getGlobalVariable(key), "global-value-updated") + print(f"setGlobalVariable('{key}') -> global-value-updated") + + def test_12_set_global_variable_requires_prefix(self): + """setGlobalVariable raises ValueError when the global. prefix is missing.""" + with self.assertRaises(ValueError): + self.task.setGlobalVariable(self.global_var_name, "nope") + print("setGlobalVariable(unprefixed) correctly raised ValueError") + + # -- global variables: create when missing ------------------------------ + + def test_12a_set_global_variable_creates_when_missing(self): + """setGlobalVariable creates a global variable when the key is unknown.""" + name = f"global.abtLiveGlobalNew_{self.uid}" + created = self.task.setGlobalVariable(name, "global-created") + self.created_global_var_ids.append(created.id) + self.assertEqual(created.key, name) + self.assertEqual(created.type, "xlrelease.StringVariable") + self.assertEqual(created.value, "global-created") + self.assertEqual(self.task.getGlobalVariable(name), "global-created") + print(f"setGlobalVariable('{name}') created -> global-created") + + def test_12b_set_global_set_variable_creates_when_missing(self): + """setGlobalVariable creates a set variable when the key is unknown.""" + name = f"global.abtLiveGlobalNewSet_{self.uid}" + new_value = ["x", "y", "z"] + created = self.task.setGlobalVariable(name, new_value) + self.created_global_var_ids.append(created.id) + self.assertEqual(created.type, "xlrelease.SetStringVariable") + self.assertEqual(set(created.value), set(new_value)) + self.assertEqual(set(self.task.getGlobalVariable(name)), set(new_value)) + print(f"setGlobalVariable('{name}') created -> {new_value}") + + def test_12c_set_global_map_variable_creates_when_missing(self): + """setGlobalVariable creates a map variable when the key is unknown.""" + name = f"global.abtLiveGlobalNewMap_{self.uid}" + new_value = {"region": "ap", "tier": "bronze"} + created = self.task.setGlobalVariable(name, new_value) + self.created_global_var_ids.append(created.id) + self.assertEqual(created.type, "xlrelease.MapStringStringVariable") + self.assertEqual(created.value, new_value) + self.assertEqual(self.task.getGlobalVariable(name), new_value) + print(f"setGlobalVariable('{name}') created -> {new_value}") + + # -- ...ByTitle --------------------------------------------------------- + + def test_13_get_phases_by_title(self): + """getPhasesByTitle finds the phase by title in the current release.""" + phases = self.task.getPhasesByTitle("Test Phase") + self.assertGreaterEqual(len(phases), 1) + self.assertTrue(all(p.title == "Test Phase" for p in phases)) + print(f"getPhasesByTitle('Test Phase') -> {len(phases)} phase(s)") + + def test_14_get_tasks_by_title(self): + """getTasksByTitle finds the task by title, optionally scoped to a phase.""" + tasks = self.task.getTasksByTitle("Manual Task") + self.assertGreaterEqual(len(tasks), 1) + self.assertTrue(any(t.id == self.task_id for t in tasks)) + + scoped = self.task.getTasksByTitle("Manual Task", "Test Phase") + self.assertGreaterEqual(len(scoped), 1) + print( + f"getTasksByTitle('Manual Task') -> {len(tasks)} task(s), " + f"scoped to phase -> {len(scoped)} task(s)" + ) + + def test_15_get_releases_by_title(self): + """getReleasesByTitle finds the current release by its title.""" + releases = self.task.getReleasesByTitle(self.release_title) + self.assertGreaterEqual(len(releases), 1) + self.assertTrue(any(r.id == self.release_id for r in releases)) + print(f"getReleasesByTitle('{self.release_title}') -> {len(releases)} release(s)") + + +if __name__ == "__main__": + unittest.main() diff --git a/uv.lock b/uv.lock index 5fc7042..1ff7be8 100644 --- a/uv.lock +++ b/uv.lock @@ -149,20 +149,20 @@ wheels = [ [[package]] name = "digitalai-release-api-client" -version = "26.3.0b3" +version = "26.3.0b4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pydantic" }, { name = "requests" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a6/c4/007f2c5acf64c3239e9a4e2854f3711cf5bbc9822a49f20172c0048b12c7/digitalai_release_api_client-26.3.0b3.tar.gz", hash = "sha256:2dfedc8a5c9a7c4a24385b50552605121fd75d5d035d6d871fd2f271e1904b4d", size = 39460 } +sdist = { url = "https://files.pythonhosted.org/packages/04/4a/d3921dfed14fb5a5b82a03d50e3f13275558423f5faca3d713e1ffeffdfc/digitalai_release_api_client-26.3.0b4.tar.gz", hash = "sha256:323e7cfdaef76bf01398e2d3877d0d06d2772948346a2b49b43df43efbc73ed9", size = 39114 } wheels = [ - { url = "https://files.pythonhosted.org/packages/b5/7d/6a74dbedfbdc55a483a9883b09f75901474d9709477528e851b35ebe9894/digitalai_release_api_client-26.3.0b3-py3-none-any.whl", hash = "sha256:1bc6a037e83e3537ac492549959b6149e19be07f552784dc86b2abe923a5a2c0", size = 68076 }, + { url = "https://files.pythonhosted.org/packages/f0/58/553b7861cf9eecea7a7cf8f62db2438439099efb79816447ed1c93e09d13/digitalai_release_api_client-26.3.0b4-py3-none-any.whl", hash = "sha256:28f23e157ab689af7c9d02310f4b4fd427dcc7b6b2caf7c41ec53971a5706a14", size = 67602 }, ] [[package]] name = "digitalai-release-sdk" -version = "26.3.0b3" +version = "26.3.0b4" source = { editable = "." } dependencies = [ { name = "dataclasses-json" }, @@ -181,7 +181,7 @@ dev = [ [package.metadata] requires-dist = [ { name = "dataclasses-json", specifier = ">=0.6.7,<1.0.0" }, - { name = "digitalai-release-api-client", specifier = "==26.3.0b3" }, + { name = "digitalai-release-api-client", specifier = "==26.3.0b4" }, { name = "kubernetes", specifier = ">=35.0.0,<36.0.0" }, { name = "pycryptodomex", specifier = ">=3.23.0,<4.0.0" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0.0" }, From 6f77a874483eb2356b4180b195b263c879821141 Mon Sep 17 00:00:00 2001 From: Balaji Venkatesan Date: Sun, 21 Jun 2026 17:49:00 +0530 Subject: [PATCH 22/23] Update toml --- pyproject.toml | 4 ++-- uv.lock | 10 +++++----- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 651be14..00043b4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "digitalai_release_sdk" -version = "26.3.0b4" +version = "26.3.0b5" description = "Digital.ai Release SDK" readme = "README.md" license = "MIT" @@ -19,7 +19,7 @@ classifiers = [ "Operating System :: OS Independent" ] dependencies = [ - "digitalai-release-api-client==26.3.0b4", + "digitalai-release-api-client==26.3.0b5", "dataclasses-json>=0.6.7, <1.0.0", "pycryptodomex>=3.23.0, <4.0.0", "python-dateutil>=2.9.0, <3.0.0", diff --git a/uv.lock b/uv.lock index 1ff7be8..fbdf82e 100644 --- a/uv.lock +++ b/uv.lock @@ -149,20 +149,20 @@ wheels = [ [[package]] name = "digitalai-release-api-client" -version = "26.3.0b4" +version = "26.3.0b5" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pydantic" }, { name = "requests" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/04/4a/d3921dfed14fb5a5b82a03d50e3f13275558423f5faca3d713e1ffeffdfc/digitalai_release_api_client-26.3.0b4.tar.gz", hash = "sha256:323e7cfdaef76bf01398e2d3877d0d06d2772948346a2b49b43df43efbc73ed9", size = 39114 } +sdist = { url = "https://files.pythonhosted.org/packages/b1/09/b64a2257ddc36d7c4dae1533f92eb63e95e86b32e7362fd21a2c153aede4/digitalai_release_api_client-26.3.0b5.tar.gz", hash = "sha256:7f8c8058e6694a2210b29f394c93250738236c84b20ab7d77edb56b1e65604f9", size = 39161 } wheels = [ - { url = "https://files.pythonhosted.org/packages/f0/58/553b7861cf9eecea7a7cf8f62db2438439099efb79816447ed1c93e09d13/digitalai_release_api_client-26.3.0b4-py3-none-any.whl", hash = "sha256:28f23e157ab689af7c9d02310f4b4fd427dcc7b6b2caf7c41ec53971a5706a14", size = 67602 }, + { url = "https://files.pythonhosted.org/packages/5b/d5/9f2d1cc01207108125b9f10d2af1c5c476385fa2998ac433e79e5990c3f8/digitalai_release_api_client-26.3.0b5-py3-none-any.whl", hash = "sha256:76ac7caa97adc15bc9cd54b03222bde10d4f36d6e4dedb3280c4ba05719c6cc7", size = 67601 }, ] [[package]] name = "digitalai-release-sdk" -version = "26.3.0b4" +version = "26.3.0b5" source = { editable = "." } dependencies = [ { name = "dataclasses-json" }, @@ -181,7 +181,7 @@ dev = [ [package.metadata] requires-dist = [ { name = "dataclasses-json", specifier = ">=0.6.7,<1.0.0" }, - { name = "digitalai-release-api-client", specifier = "==26.3.0b4" }, + { name = "digitalai-release-api-client", specifier = "==26.3.0b5" }, { name = "kubernetes", specifier = ">=35.0.0,<36.0.0" }, { name = "pycryptodomex", specifier = ">=3.23.0,<4.0.0" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0.0" }, From 6959c0d621c8e28ff331e46c6fdc1271c5b6f899 Mon Sep 17 00:00:00 2001 From: Balaji Venkatesan Date: Mon, 22 Jun 2026 07:30:24 +0530 Subject: [PATCH 23/23] Updated for variable api --- .../release/integration/api_base_task.py | 58 ++++++++++++++++-- docs/classes/api_base_task.md | 17 ++++-- .../release/integration/test_api_base_task.py | 60 ++++++++++++++++++- .../integration/test_api_base_task_live.py | 31 ++++++++++ 4 files changed, 156 insertions(+), 10 deletions(-) diff --git a/digitalai/release/integration/api_base_task.py b/digitalai/release/integration/api_base_task.py index ad6a1f4..63bf3d9 100644 --- a/digitalai/release/integration/api_base_task.py +++ b/digitalai/release/integration/api_base_task.py @@ -262,16 +262,27 @@ def getReleaseVariable(self, name: str) -> Any: ``"JenkinsBuildNumber"``). The variable is looked up by its ``key`` via ``releaseApi.getVariables`` and its stored value returned as-is. + A reference variable holds no literal value -- the server resolves it + from its value provider -- so its value is read from the server-resolved + value map (``releaseApi.getVariableValues``) instead, matching Jython. + + Password variables never return their secret: the server masks the value + as ``********``. + :param name: the variable name (e.g. ``"JenkinsBuildNumber"``). - :return: the variable's value. + :return: the variable's value (masked as ``********`` for password variables). :raises KeyError: if the release has no variable with that name. """ - variables = self.releaseApi.getVariables(self.get_release_id()) + release_id = self.get_release_id() + variables = self.releaseApi.getVariables(release_id) variable = next((v for v in variables if v.key == name), None) if variable is None: raise KeyError( f"No variable named {name} in the current release; " f"available: {sorted(v.key for v in variables)}") + if self._is_reference_variable(variable): + return self._resolve_from_values( + variable, self.releaseApi.getVariableValues(release_id)) return variable.value def setReleaseVariable(self, name: str, value: Any) -> Variable: @@ -312,16 +323,23 @@ def getFolderVariable(self, name: str) -> Any: :param name: the variable name, including the ``folder.`` prefix. :return: the variable's value. + Password variables never return their secret: the server masks the value + as ``********``. + :raises ValueError: if ``name`` does not start with ``folder.``. :raises KeyError: if no such variable is visible to the folder. """ key = self._folder_key(name) - variables = self.folderApi.listVariables(self.get_folder_id()) + folder_id = self.get_folder_id() + variables = self.folderApi.listVariables(folder_id) variable = next((v for v in variables if v.key == key), None) if variable is None: raise KeyError( f"No variable named {key} visible to the current folder; " f"available: {sorted(v.key for v in variables)}") + if self._is_reference_variable(variable): + return self._resolve_from_values( + variable, self.folderApi.listVariableValues(folder_id)) return variable.value def setFolderVariable(self, name: str, value: Any) -> Variable: @@ -361,8 +379,11 @@ def getGlobalVariable(self, name: str) -> Any: Global variables are stored with a ``global.`` prefix, which is required here: pass the fully qualified name (e.g. ``"global.foo"``). + Password variables never return their secret: the server masks the value + as ``********``. + :param name: the global variable name, including the ``global.`` prefix. - :return: the variable's value. + :return: the variable's value (masked as ``********`` for password variables). :raises ValueError: if ``name`` does not start with ``global.``. :raises KeyError: if no global variable with that name exists. """ @@ -373,6 +394,9 @@ def getGlobalVariable(self, name: str) -> Any: raise KeyError( f"No global variable named {key}; " f"available: {sorted(v.key for v in variables)}") + if self._is_reference_variable(variable): + return self._resolve_from_values( + variable, self.configurationApi.getGlobalVariableValues()) return variable.value def setGlobalVariable(self, name: str, value: Any) -> Variable: @@ -400,6 +424,32 @@ def setGlobalVariable(self, name: str, value: Any) -> Variable: variable.value = self._coerce_value(value) return self.configurationApi.updateGlobalVariable(variable.id, variable) + @staticmethod + def _is_reference_variable(variable: Variable) -> bool: + """ + Return ``True`` if ``variable`` is a reference variable. + + A reference variable (``xlrelease.ReferenceVariable``) does not store a + literal value; the server resolves it from a value provider, so its + ``value`` comes back empty and the resolved value must be read from the + scope's variable-value map instead. Detected by type, with the presence + of a ``valueProvider`` as a fallback signal. + """ + vtype = getattr(variable, "type", None) or "" + return (vtype.endswith("ReferenceVariable") + or getattr(variable, "valueProvider", None) is not None) + + @staticmethod + def _resolve_from_values(variable: Variable, values: Dict[str, Any]) -> Any: + """ + Return ``variable``'s server-resolved value from a ``${key}: value`` map. + + The variable-value endpoints key entries by the interpolation token + ``${key}``. Falls back to the variable's stored ``value`` when the token + is absent from the map. + """ + return values.get(f"${{{variable.key}}}", variable.value) + @staticmethod def _global_key(name: str) -> str: """ diff --git a/docs/classes/api_base_task.md b/docs/classes/api_base_task.md index 9fbee35..332cbe0 100644 --- a/docs/classes/api_base_task.md +++ b/docs/classes/api_base_task.md @@ -167,13 +167,16 @@ _No parameters._ Returns the value of a variable in the current release by name. The Python3 equivalent of the Jython script global `releaseVariables[name]`. Pass the bare variable name; the variable is looked up by its `key` and its stored value is -returned as-is. +returned as-is. A **reference variable** holds no literal value — the server +resolves it from its value provider — so its value is read from the +server-resolved value map instead, matching the Jython behaviour. **Password +variables** never return their secret — the server masks the value as `********`. | Parameter | Type | Default | Description | |-----------|------|---------|-------------| | `name` | `str` | _required_ | the variable name (e.g. `"JenkinsBuildNumber"`). | -**Returns:** `Any` — the variable's value. +**Returns:** `Any` — the variable's value (server-resolved for reference variables; `********` for password variables). **Raises:** `KeyError` — if the release has no variable with that name. @@ -200,8 +203,10 @@ with its type inferred from `value`. Returns the value of a variable in the current folder by name. Like [`getReleaseVariable`](#getreleasevariable), but scoped to the folder that contains the current release. Inherited variables (from parent folders and -global variables) are included. The `folder.` prefix is required — pass the -fully qualified name (e.g. `"folder.foo"`). +global variables) are included. Reference variables are resolved from the +server-resolved value map (see [`getReleaseVariable`](#getreleasevariable)), and +password variables come back masked as `********`. The `folder.` prefix is +required — pass the fully qualified name (e.g. `"folder.foo"`). | Parameter | Type | Default | Description | |-----------|------|---------|-------------| @@ -236,7 +241,9 @@ inherited value of the same name). The `folder.` prefix is required. Returns the value of a global variable by name. Global variables are stored with a `global.` prefix, which is required here — pass the fully qualified name (e.g. -`"global.foo"`). +`"global.foo"`). Reference variables are resolved from the server-resolved value +map (see [`getReleaseVariable`](#getreleasevariable)), and password variables +come back masked as `********`. | Parameter | Type | Default | Description | |-----------|------|---------|-------------| diff --git a/tests/release/integration/test_api_base_task.py b/tests/release/integration/test_api_base_task.py index b22e26b..464c73e 100644 --- a/tests/release/integration/test_api_base_task.py +++ b/tests/release/integration/test_api_base_task.py @@ -194,7 +194,8 @@ def _stub_task(self, release_id=RELEASE_ID, task_id=TASK_ID): task._api_instances = {} apis = {name: MagicMock(name=name) for name in ( - "releaseApi", "taskApi", "phaseApi", "folderApi", "settingsApi")} + "releaseApi", "taskApi", "phaseApi", "folderApi", "settingsApi", + "configurationApi")} for name, stub in apis.items(): setattr(type(task), name, property(lambda self, s=stub: s)) self.addCleanup( @@ -251,6 +252,63 @@ def test_get_version(self): self.assertEqual(task.getVersion(), "25.3.0") + # -- reference-variable resolution -------------------------------------- + + def test_get_release_variable_returns_stored_value_for_plain_variable(self): + """A non-reference variable returns its stored value without resolving.""" + task, apis = self._stub_task() + apis["releaseApi"].getVariables.return_value = [ + SimpleNamespace(key="relVar", value="hello", type="xlrelease.StringVariable")] + + self.assertEqual(task.getReleaseVariable("relVar"), "hello") + # No need to consult the resolved-value map for a literal value. + apis["releaseApi"].getVariableValues.assert_not_called() + + def test_get_release_variable_resolves_reference(self): + """A reference variable's value comes from the server-resolved map.""" + task, apis = self._stub_task() + # The reference variable itself carries no literal value. + apis["releaseApi"].getVariables.return_value = [ + SimpleNamespace(key="refVar", value="", type="xlrelease.ReferenceVariable")] + apis["releaseApi"].getVariableValues.return_value = {"${refVar}": "resolved-value"} + + self.assertEqual(task.getReleaseVariable("refVar"), "resolved-value") + apis["releaseApi"].getVariableValues.assert_called_once_with(RELEASE_ID) + + def test_get_folder_variable_resolves_reference(self): + """A reference folder variable resolves via listVariableValues.""" + task, apis = self._stub_task() + apis["folderApi"].listVariables.return_value = [ + SimpleNamespace(key="folder.refVar", value="", + type="xlrelease.ReferenceVariable")] + apis["folderApi"].listVariableValues.return_value = { + "${folder.refVar}": "folder-resolved"} + + self.assertEqual(task.getFolderVariable("folder.refVar"), "folder-resolved") + apis["folderApi"].listVariableValues.assert_called_once_with(FOLDER_ID) + + def test_get_global_variable_resolves_reference(self): + """A reference global variable resolves via getGlobalVariableValues.""" + task, apis = self._stub_task() + apis["configurationApi"].getGlobalVariables.return_value = [ + SimpleNamespace(key="global.refVar", value="", + type="xlrelease.ReferenceVariable")] + apis["configurationApi"].getGlobalVariableValues.return_value = { + "${global.refVar}": "global-resolved"} + + self.assertEqual(task.getGlobalVariable("global.refVar"), "global-resolved") + apis["configurationApi"].getGlobalVariableValues.assert_called_once_with() + + def test_reference_resolution_falls_back_to_stored_value(self): + """When the resolved map lacks the token, the stored value is returned.""" + task, apis = self._stub_task() + apis["releaseApi"].getVariables.return_value = [ + SimpleNamespace(key="refVar", value="stored", + type="xlrelease.ReferenceVariable")] + apis["releaseApi"].getVariableValues.return_value = {} + + self.assertEqual(task.getReleaseVariable("refVar"), "stored") + if __name__ == "__main__": unittest.main() diff --git a/tests/release/integration/test_api_base_task_live.py b/tests/release/integration/test_api_base_task_live.py index 1409947..42f59ff 100644 --- a/tests/release/integration/test_api_base_task_live.py +++ b/tests/release/integration/test_api_base_task_live.py @@ -212,6 +212,19 @@ def _create_test_data(cls, uid: str, root_id: str): showOnReleaseStart=False, ), ) + # A reference release variable. It holds no literal value -- the server + # resolves it from a value provider -- so getReleaseVariable must read + # its value from the server-resolved value map rather than `.value`. + template_api.createVariable( + root_template.id, + Variable( + type="xlrelease.ReferenceVariable", + key="relRefVar", + referencedType="xlrelease.StringVariable", + requiresValue=False, + showOnReleaseStart=False, + ), + ) # Move the template into the folder so releases created from it live # inside the folder (required for getCurrentFolder to resolve). @@ -413,6 +426,24 @@ def test_08d_set_release_map_variable(self): self.assertEqual(self.task.getReleaseVariable("relMapVar"), new_value) print(f"setReleaseVariable('relMapVar') -> {new_value}") + # -- release variables: reference --------------------------------------- + + def test_08h_get_release_reference_variable(self): + """getReleaseVariable returns a reference variable's server-resolved value. + + A reference variable carries no literal value (``.value`` is empty); the + getter must read it from the resolved value map. The reference here has + no real provider target, so it resolves to an empty string -- but the + getter must return exactly what the resolved map holds for ``${key}``, + proving it delegates resolution to the server rather than returning the + raw (empty) ``.value``. + """ + resolved = self.task.releaseApi.getVariableValues(self.release_id) + expected = resolved.get("${relRefVar}") + self.assertIsNotNone(expected, "reference variable missing from value map") + self.assertEqual(self.task.getReleaseVariable("relRefVar"), expected) + print(f"getReleaseVariable('relRefVar') -> {expected!r} (server-resolved)") + # -- release variables: create when missing ----------------------------- def test_08e_set_release_variable_creates_when_missing(self):