jenkinsapi-0.3.23/.codacy.yaml0000644000000000000000000000003413615410400013053 0ustar00--- exclude_paths: - "**" jenkinsapi-0.3.23/.python-version0000644000000000000000000000000513615410400013652 0ustar003.13 jenkinsapi-0.3.23/CHANGELOG.md0000644000000000000000000000544713615410400012475 0ustar00# Changelog ## 0.3.20 - chore: drop Python 3.9 support, require Python 3.10+ - packaging: raise minimum requests version to 2.32.5 to avoid vulnerable installs - node class: add ability to add, modify, and delete node labels - drop Python 3.9 support; require Python 3.10+ ## 0.3.13 - docs: fix doc and add autobuild - feature: add launch method to Node class (#849) - convert to using ruff for linting (#895) - update harden runner (#894) - change url to pull jenkins.war file (#893) - Make sure to filter warnings to errors (#868) - Revert "dependabot" (#888) - dependabot (#887) - makefile: update for uv compatibility (#886) - update pre-commit versions (#885) - remove deprecated setup (#884) - housekeeping: readme, license, pyproject.toml (#881) - update install commands to use uv (#882) - ci: update java to 21 (#883) - convert to uv and pyproject.toml (#879) - ci: update checkout to v4 (#880) - ci: remove apt-get update, install, and pip upgrade as unnecessary (#878) - docs: fix build (#876) - add support for 3.12 and 3.13 (#877) - Correctly specify python version (#858) - Merge pull request #874 from clintonsteiner/fixSeveralCiIssues - ci: add in missing plugins - ci: harden runner - set to audit for now - ci: add mirrors to harden whitelist - test_build: fix failing tests - ci: tests fail because jenkins requires java 17 - setup in github action - makefile: fix py.test according to pytest documentation - ci: fix issue causing runner to crash in setup - Merge pull request #853 from clintonsteiner/updateReadme - Readme Updates: Remove redundant words, move install and example to top - [pre-commit.ci] auto fixes from pre-commit.com hooks - fix: jenkinsapi crashing jenkins_is_unavaiable - [pre-commit.ci] auto fixes from pre-commit.com hooks - Support for using unicode characters in update_config - Decode content using the encoding specified in the response - Fixing plugin dependencies - Fix console scripts in pyproject.toml - Added Python typing hints to most of the code - Bump version - Move utils into jenkinsapi.utils module - Remove blank line - Update Build object docs - Add pyproject.toml - Rename Travis and Tox files - Flake8 fixes - [pre-commit.ci] auto fixes from pre-commit.com hooks - Do not call Jenkins site when testing plugins - Download Jenkins silently and improve plugin downloads - Fix failing tests and commented ones not working - Add flake8 to test requirements - Install test dependencies - Install krb and pytest-cov in workflow - Add Github action - Pyflake8 fixes - Add pre-commit.ci configuration - Add debug logs in Queue.block_until_building (#745) - Fixing retrying logic by introducing a max_retries param (#739) - Add quiet period parameter to job invoke (#758) - useCrumbs -> use_crumbs #755 (#756) - job.py: manage scm data for multibranch pipelines (#742) ## 0.3.11 - Oct 31, 2019 jenkinsapi-0.3.23/Makefile0000644000000000000000000000346513615410400012322 0ustar00.PHONY: test lint coverage dist clean docker-build docker-run docker-clean docker-logs test-systests test-docker test-all clean: rm -rf dist/ build/ *.egg-info jenkinsapi_tests/systests/localinstance_files test: uv run pytest -sv jenkinsapi_tests -m "not docker" lint: uv run ruff check jenkinsapi/ uv run ruff format --check jenkinsapi/ dist: uv build coverage: uv run pytest -sv --cov=jenkinsapi --cov-report=term-missing --cov-report=xml jenkinsapi_tests -m "not docker" # Docker image configuration DOCKER_IMAGE ?= jenkinsapi-systest:latest DOCKER_PORT ?= 8080 CONTAINER_NAME ?= jenkinsapi-systest # Build the Docker image docker-build: @echo "Building Docker image $(DOCKER_IMAGE)..." docker build -t $(DOCKER_IMAGE) docker/ @echo "Docker image built successfully" # Run Jenkins Docker container docker-run: docker-build @echo "Starting Jenkins container on port $(DOCKER_PORT)..." docker run \ --name $(CONTAINER_NAME) \ -d \ -p $(DOCKER_PORT):8080 \ $(DOCKER_IMAGE) @echo "Jenkins running at http://localhost:$(DOCKER_PORT)" # Stop and remove the container docker-clean: docker stop $(CONTAINER_NAME) 2>/dev/null || true docker rm $(CONTAINER_NAME) 2>/dev/null || true # View Jenkins logs docker-logs: docker logs -f $(CONTAINER_NAME) # Use all available CPUs by default via pytest-xdist auto detection. # Override with: make test-systests NUM_WORKERS=4 NUM_WORKERS ?= auto # Run system tests in parallel (uses all CPUs by default) test-systests: uv run pytest jenkinsapi_tests/systests/ -n $(NUM_WORKERS) -v # Run Docker integration unit tests test-docker: uv run pytest jenkinsapi_tests/unittests/test_docker_jenkins.py -m docker -v # Run all tests (unit + system) test-all: uv run pytest jenkinsapi_tests -m "not docker" -n auto -q uv run pytest jenkinsapi_tests/systests/ -n $(NUM_WORKERS) -v jenkinsapi-0.3.23/TODO0000644000000000000000000000025013615410400011337 0ustar00TODO: * Clean up the fingerprint code * Clean up the resultset and results code * Add support for Jenkins 2.x features * Improve speed for large Jenkins installations jenkinsapi-0.3.23/uv.lock0000644000000000000000000070552113615410400012170 0ustar00version = 1 revision = 3 requires-python = ">=3.10" resolution-markers = [ "python_full_version >= '3.12'", "python_full_version == '3.11.*'", "python_full_version < '3.11'", ] [[package]] name = "accessible-pygments" version = "0.0.5" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pygments" }, ] sdist = { url = "https://files.pythonhosted.org/packages/bc/c1/bbac6a50d02774f91572938964c582fff4270eee73ab822a4aeea4d8b11b/accessible_pygments-0.0.5.tar.gz", hash = "sha256:40918d3e6a2b619ad424cb91e556bd3bd8865443d9f22f1dcdf79e33c8046872", size = 1377899, upload-time = "2024-05-10T11:23:10.216Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/8d/3f/95338030883d8c8b91223b4e21744b04d11b161a3ef117295d8241f50ab4/accessible_pygments-0.0.5-py3-none-any.whl", hash = "sha256:88ae3211e68a1d0b011504b2ffc1691feafce124b845bd072ab6f9f66f34d4b7", size = 1395903, upload-time = "2024-05-10T11:23:08.421Z" }, ] [[package]] name = "alabaster" version = "1.0.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/a6/f8/d9c74d0daf3f742840fd818d69cfae176fa332022fd44e3469487d5a9420/alabaster-1.0.0.tar.gz", hash = "sha256:c00dca57bca26fa62a6d7d0a9fcce65f3e026e9bfe33e9c538fd3fbb2144fd9e", size = 24210, upload-time = "2024-07-26T18:15:03.762Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/7e/b3/6b4067be973ae96ba0d615946e314c5ae35f9f993eca561b356540bb0c2b/alabaster-1.0.0-py3-none-any.whl", hash = "sha256:fc6786402dc3fcb2de3cabd5fe455a2db534b371124f1f21de8731783dec828b", size = 13929, upload-time = "2024-07-26T18:15:02.05Z" }, ] [[package]] name = "babel" version = "2.18.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/7d/b2/51899539b6ceeeb420d40ed3cd4b7a40519404f9baf3d4ac99dc413a834b/babel-2.18.0.tar.gz", hash = "sha256:b80b99a14bd085fcacfa15c9165f651fbb3406e66cc603abf11c5750937c992d", size = 9959554, upload-time = "2026-02-01T12:30:56.078Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/77/f5/21d2de20e8b8b0408f0681956ca2c69f1320a3848ac50e6e7f39c6159675/babel-2.18.0-py3-none-any.whl", hash = "sha256:e2b422b277c2b9a9630c1d7903c2a00d0830c409c59ac8cae9081c92f1aeba35", size = 10196845, upload-time = "2026-02-01T12:30:53.445Z" }, ] [[package]] name = "beautifulsoup4" version = "4.14.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "soupsieve" }, { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/c3/b0/1c6a16426d389813b48d95e26898aff79abbde42ad353958ad95cc8c9b21/beautifulsoup4-4.14.3.tar.gz", hash = "sha256:6292b1c5186d356bba669ef9f7f051757099565ad9ada5dd630bd9de5fa7fb86", size = 627737, upload-time = "2025-11-30T15:08:26.084Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/1a/39/47f9197bdd44df24d67ac8893641e16f386c984a0619ef2ee4c51fbbc019/beautifulsoup4-4.14.3-py3-none-any.whl", hash = "sha256:0918bfe44902e6ad8d57732ba310582e98da931428d231a5ecb9e7c703a735bb", size = 107721, upload-time = "2025-11-30T15:08:24.087Z" }, ] [[package]] name = "certifi" version = "2026.2.25" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029, upload-time = "2026-02-25T02:54:17.342Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" }, ] [[package]] name = "cffi" version = "2.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pycparser", marker = "implementation_name != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/93/d7/516d984057745a6cd96575eea814fe1edd6646ee6efd552fb7b0921dec83/cffi-2.0.0-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44", size = 184283, upload-time = "2025-09-08T23:22:08.01Z" }, { url = "https://files.pythonhosted.org/packages/9e/84/ad6a0b408daa859246f57c03efd28e5dd1b33c21737c2db84cae8c237aa5/cffi-2.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f73b96c41e3b2adedc34a7356e64c8eb96e03a3782b535e043a986276ce12a49", size = 180504, upload-time = "2025-09-08T23:22:10.637Z" }, { url = "https://files.pythonhosted.org/packages/50/bd/b1a6362b80628111e6653c961f987faa55262b4002fcec42308cad1db680/cffi-2.0.0-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:53f77cbe57044e88bbd5ed26ac1d0514d2acf0591dd6bb02a3ae37f76811b80c", size = 208811, upload-time = "2025-09-08T23:22:12.267Z" }, { url = "https://files.pythonhosted.org/packages/4f/27/6933a8b2562d7bd1fb595074cf99cc81fc3789f6a6c05cdabb46284a3188/cffi-2.0.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3e837e369566884707ddaf85fc1744b47575005c0a229de3327f8f9a20f4efeb", size = 216402, upload-time = "2025-09-08T23:22:13.455Z" }, { url = "https://files.pythonhosted.org/packages/05/eb/b86f2a2645b62adcfff53b0dd97e8dfafb5c8aa864bd0d9a2c2049a0d551/cffi-2.0.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:5eda85d6d1879e692d546a078b44251cdd08dd1cfb98dfb77b670c97cee49ea0", size = 203217, upload-time = "2025-09-08T23:22:14.596Z" }, { url = "https://files.pythonhosted.org/packages/9f/e0/6cbe77a53acf5acc7c08cc186c9928864bd7c005f9efd0d126884858a5fe/cffi-2.0.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9332088d75dc3241c702d852d4671613136d90fa6881da7d770a483fd05248b4", size = 203079, upload-time = "2025-09-08T23:22:15.769Z" }, { url = "https://files.pythonhosted.org/packages/98/29/9b366e70e243eb3d14a5cb488dfd3a0b6b2f1fb001a203f653b93ccfac88/cffi-2.0.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc7de24befaeae77ba923797c7c87834c73648a05a4bde34b3b7e5588973a453", size = 216475, upload-time = "2025-09-08T23:22:17.427Z" }, { url = "https://files.pythonhosted.org/packages/21/7a/13b24e70d2f90a322f2900c5d8e1f14fa7e2a6b3332b7309ba7b2ba51a5a/cffi-2.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cf364028c016c03078a23b503f02058f1814320a56ad535686f90565636a9495", size = 218829, upload-time = "2025-09-08T23:22:19.069Z" }, { url = "https://files.pythonhosted.org/packages/60/99/c9dc110974c59cc981b1f5b66e1d8af8af764e00f0293266824d9c4254bc/cffi-2.0.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e11e82b744887154b182fd3e7e8512418446501191994dbf9c9fc1f32cc8efd5", size = 211211, upload-time = "2025-09-08T23:22:20.588Z" }, { url = "https://files.pythonhosted.org/packages/49/72/ff2d12dbf21aca1b32a40ed792ee6b40f6dc3a9cf1644bd7ef6e95e0ac5e/cffi-2.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8ea985900c5c95ce9db1745f7933eeef5d314f0565b27625d9a10ec9881e1bfb", size = 218036, upload-time = "2025-09-08T23:22:22.143Z" }, { url = "https://files.pythonhosted.org/packages/e2/cc/027d7fb82e58c48ea717149b03bcadcbdc293553edb283af792bd4bcbb3f/cffi-2.0.0-cp310-cp310-win32.whl", hash = "sha256:1f72fb8906754ac8a2cc3f9f5aaa298070652a0ffae577e0ea9bd480dc3c931a", size = 172184, upload-time = "2025-09-08T23:22:23.328Z" }, { url = "https://files.pythonhosted.org/packages/33/fa/072dd15ae27fbb4e06b437eb6e944e75b068deb09e2a2826039e49ee2045/cffi-2.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:b18a3ed7d5b3bd8d9ef7a8cb226502c6bf8308df1525e1cc676c3680e7176739", size = 182790, upload-time = "2025-09-08T23:22:24.752Z" }, { url = "https://files.pythonhosted.org/packages/12/4a/3dfd5f7850cbf0d06dc84ba9aa00db766b52ca38d8b86e3a38314d52498c/cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe", size = 184344, upload-time = "2025-09-08T23:22:26.456Z" }, { url = "https://files.pythonhosted.org/packages/4f/8b/f0e4c441227ba756aafbe78f117485b25bb26b1c059d01f137fa6d14896b/cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c", size = 180560, upload-time = "2025-09-08T23:22:28.197Z" }, { url = "https://files.pythonhosted.org/packages/b1/b7/1200d354378ef52ec227395d95c2576330fd22a869f7a70e88e1447eb234/cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92", size = 209613, upload-time = "2025-09-08T23:22:29.475Z" }, { url = "https://files.pythonhosted.org/packages/b8/56/6033f5e86e8cc9bb629f0077ba71679508bdf54a9a5e112a3c0b91870332/cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93", size = 216476, upload-time = "2025-09-08T23:22:31.063Z" }, { url = "https://files.pythonhosted.org/packages/dc/7f/55fecd70f7ece178db2f26128ec41430d8720f2d12ca97bf8f0a628207d5/cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5", size = 203374, upload-time = "2025-09-08T23:22:32.507Z" }, { url = "https://files.pythonhosted.org/packages/84/ef/a7b77c8bdc0f77adc3b46888f1ad54be8f3b7821697a7b89126e829e676a/cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664", size = 202597, upload-time = "2025-09-08T23:22:34.132Z" }, { url = "https://files.pythonhosted.org/packages/d7/91/500d892b2bf36529a75b77958edfcd5ad8e2ce4064ce2ecfeab2125d72d1/cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26", size = 215574, upload-time = "2025-09-08T23:22:35.443Z" }, { url = "https://files.pythonhosted.org/packages/44/64/58f6255b62b101093d5df22dcb752596066c7e89dd725e0afaed242a61be/cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9", size = 218971, upload-time = "2025-09-08T23:22:36.805Z" }, { url = "https://files.pythonhosted.org/packages/ab/49/fa72cebe2fd8a55fbe14956f9970fe8eb1ac59e5df042f603ef7c8ba0adc/cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414", size = 211972, upload-time = "2025-09-08T23:22:38.436Z" }, { url = "https://files.pythonhosted.org/packages/0b/28/dd0967a76aab36731b6ebfe64dec4e981aff7e0608f60c2d46b46982607d/cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743", size = 217078, upload-time = "2025-09-08T23:22:39.776Z" }, { url = "https://files.pythonhosted.org/packages/2b/c0/015b25184413d7ab0a410775fdb4a50fca20f5589b5dab1dbbfa3baad8ce/cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5", size = 172076, upload-time = "2025-09-08T23:22:40.95Z" }, { url = "https://files.pythonhosted.org/packages/ae/8f/dc5531155e7070361eb1b7e4c1a9d896d0cb21c49f807a6c03fd63fc877e/cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5", size = 182820, upload-time = "2025-09-08T23:22:42.463Z" }, { url = "https://files.pythonhosted.org/packages/95/5c/1b493356429f9aecfd56bc171285a4c4ac8697f76e9bbbbb105e537853a1/cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d", size = 177635, upload-time = "2025-09-08T23:22:43.623Z" }, { url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" }, { url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" }, { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" }, { url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" }, { url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" }, { url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" }, { url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" }, { url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" }, { url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" }, { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" }, { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" }, { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" }, { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" }, { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" }, { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" }, { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" }, { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" }, { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" }, { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" }, { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" }, { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" }, { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" }, { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" }, { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" }, { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" }, { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" }, { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" }, { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" }, { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" }, { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" }, { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" }, { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" }, { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" }, { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" }, { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" }, { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" }, { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" }, { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" }, { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" }, { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" }, { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" }, { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" }, { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" }, { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" }, { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" }, { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, ] [[package]] name = "charset-normalizer" version = "3.4.6" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/7b/60/e3bec1881450851b087e301bedc3daa9377a4d45f1c26aa90b0b235e38aa/charset_normalizer-3.4.6.tar.gz", hash = "sha256:1ae6b62897110aa7c79ea2f5dd38d1abca6db663687c0b1ad9aed6f6bae3d9d6", size = 143363, upload-time = "2026-03-15T18:53:25.478Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/e6/8c/2c56124c6dc53a774d435f985b5973bc592f42d437be58c0c92d65ae7296/charset_normalizer-3.4.6-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:2e1d8ca8611099001949d1cdfaefc510cf0f212484fe7c565f735b68c78c3c95", size = 298751, upload-time = "2026-03-15T18:50:00.003Z" }, { url = "https://files.pythonhosted.org/packages/86/2a/2a7db6b314b966a3bcad8c731c0719c60b931b931de7ae9f34b2839289ee/charset_normalizer-3.4.6-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e25369dc110d58ddf29b949377a93e0716d72a24f62bad72b2b39f155949c1fd", size = 200027, upload-time = "2026-03-15T18:50:01.702Z" }, { url = "https://files.pythonhosted.org/packages/68/f2/0fe775c74ae25e2a3b07b01538fc162737b3e3f795bada3bc26f4d4d495c/charset_normalizer-3.4.6-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:259695e2ccc253feb2a016303543d691825e920917e31f894ca1a687982b1de4", size = 220741, upload-time = "2026-03-15T18:50:03.194Z" }, { url = "https://files.pythonhosted.org/packages/10/98/8085596e41f00b27dd6aa1e68413d1ddda7e605f34dd546833c61fddd709/charset_normalizer-3.4.6-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:dda86aba335c902b6149a02a55b38e96287157e609200811837678214ba2b1db", size = 215802, upload-time = "2026-03-15T18:50:05.859Z" }, { url = "https://files.pythonhosted.org/packages/fd/ce/865e4e09b041bad659d682bbd98b47fb490b8e124f9398c9448065f64fee/charset_normalizer-3.4.6-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:51fb3c322c81d20567019778cb5a4a6f2dc1c200b886bc0d636238e364848c89", size = 207908, upload-time = "2026-03-15T18:50:07.676Z" }, { url = "https://files.pythonhosted.org/packages/a8/54/8c757f1f7349262898c2f169e0d562b39dcb977503f18fdf0814e923db78/charset_normalizer-3.4.6-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:4482481cb0572180b6fd976a4d5c72a30263e98564da68b86ec91f0fe35e8565", size = 194357, upload-time = "2026-03-15T18:50:09.327Z" }, { url = "https://files.pythonhosted.org/packages/6f/29/e88f2fac9218907fc7a70722b393d1bbe8334c61fe9c46640dba349b6e66/charset_normalizer-3.4.6-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:39f5068d35621da2881271e5c3205125cc456f54e9030d3f723288c873a71bf9", size = 205610, upload-time = "2026-03-15T18:50:10.732Z" }, { url = "https://files.pythonhosted.org/packages/4c/c5/21d7bb0cb415287178450171d130bed9d664211fdd59731ed2c34267b07d/charset_normalizer-3.4.6-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:8bea55c4eef25b0b19a0337dc4e3f9a15b00d569c77211fa8cde38684f234fb7", size = 203512, upload-time = "2026-03-15T18:50:12.535Z" }, { url = "https://files.pythonhosted.org/packages/a4/be/ce52f3c7fdb35cc987ad38a53ebcef52eec498f4fb6c66ecfe62cfe57ba2/charset_normalizer-3.4.6-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:f0cdaecd4c953bfae0b6bb64910aaaca5a424ad9c72d85cb88417bb9814f7550", size = 195398, upload-time = "2026-03-15T18:50:14.236Z" }, { url = "https://files.pythonhosted.org/packages/81/a0/3ab5dd39d4859a3555e5dadfc8a9fa7f8352f8c183d1a65c90264517da0e/charset_normalizer-3.4.6-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:150b8ce8e830eb7ccb029ec9ca36022f756986aaaa7956aad6d9ec90089338c0", size = 221772, upload-time = "2026-03-15T18:50:15.581Z" }, { url = "https://files.pythonhosted.org/packages/04/6e/6a4e41a97ba6b2fa87f849c41e4d229449a586be85053c4d90135fe82d26/charset_normalizer-3.4.6-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:e68c14b04827dd76dcbd1aeea9e604e3e4b78322d8faf2f8132c7138efa340a8", size = 205759, upload-time = "2026-03-15T18:50:17.047Z" }, { url = "https://files.pythonhosted.org/packages/db/3b/34a712a5ee64a6957bf355b01dc17b12de457638d436fdb05d01e463cd1c/charset_normalizer-3.4.6-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:3778fd7d7cd04ae8f54651f4a7a0bd6e39a0cf20f801720a4c21d80e9b7ad6b0", size = 216938, upload-time = "2026-03-15T18:50:18.44Z" }, { url = "https://files.pythonhosted.org/packages/cb/05/5bd1e12da9ab18790af05c61aafd01a60f489778179b621ac2a305243c62/charset_normalizer-3.4.6-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:dad6e0f2e481fffdcf776d10ebee25e0ef89f16d691f1e5dee4b586375fdc64b", size = 210138, upload-time = "2026-03-15T18:50:19.852Z" }, { url = "https://files.pythonhosted.org/packages/bd/8e/3cb9e2d998ff6b21c0a1860343cb7b83eba9cdb66b91410e18fc4969d6ab/charset_normalizer-3.4.6-cp310-cp310-win32.whl", hash = "sha256:74a2e659c7ecbc73562e2a15e05039f1e22c75b7c7618b4b574a3ea9118d1557", size = 144137, upload-time = "2026-03-15T18:50:21.505Z" }, { url = "https://files.pythonhosted.org/packages/d8/8f/78f5489ffadb0db3eb7aff53d31c24531d33eb545f0c6f6567c25f49a5ff/charset_normalizer-3.4.6-cp310-cp310-win_amd64.whl", hash = "sha256:aa9cccf4a44b9b62d8ba8b4dd06c649ba683e4bf04eea606d2e94cfc2d6ff4d6", size = 154244, upload-time = "2026-03-15T18:50:22.81Z" }, { url = "https://files.pythonhosted.org/packages/e4/74/e472659dffb0cadb2f411282d2d76c60da1fc94076d7fffed4ae8a93ec01/charset_normalizer-3.4.6-cp310-cp310-win_arm64.whl", hash = "sha256:e985a16ff513596f217cee86c21371b8cd011c0f6f056d0920aa2d926c544058", size = 143312, upload-time = "2026-03-15T18:50:24.074Z" }, { url = "https://files.pythonhosted.org/packages/62/28/ff6f234e628a2de61c458be2779cb182bc03f6eec12200d4a525bbfc9741/charset_normalizer-3.4.6-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:82060f995ab5003a2d6e0f4ad29065b7672b6593c8c63559beefe5b443242c3e", size = 293582, upload-time = "2026-03-15T18:50:25.454Z" }, { url = "https://files.pythonhosted.org/packages/1c/b7/b1a117e5385cbdb3205f6055403c2a2a220c5ea80b8716c324eaf75c5c95/charset_normalizer-3.4.6-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:60c74963d8350241a79cb8feea80e54d518f72c26db618862a8f53e5023deaf9", size = 197240, upload-time = "2026-03-15T18:50:27.196Z" }, { url = "https://files.pythonhosted.org/packages/a1/5f/2574f0f09f3c3bc1b2f992e20bce6546cb1f17e111c5be07308dc5427956/charset_normalizer-3.4.6-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f6e4333fb15c83f7d1482a76d45a0818897b3d33f00efd215528ff7c51b8e35d", size = 217363, upload-time = "2026-03-15T18:50:28.601Z" }, { url = "https://files.pythonhosted.org/packages/4a/d1/0ae20ad77bc949ddd39b51bf383b6ca932f2916074c95cad34ae465ab71f/charset_normalizer-3.4.6-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bc72863f4d9aba2e8fd9085e63548a324ba706d2ea2c83b260da08a59b9482de", size = 212994, upload-time = "2026-03-15T18:50:30.102Z" }, { url = "https://files.pythonhosted.org/packages/60/ac/3233d262a310c1b12633536a07cde5ddd16985e6e7e238e9f3f9423d8eb9/charset_normalizer-3.4.6-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9cc4fc6c196d6a8b76629a70ddfcd4635a6898756e2d9cac5565cf0654605d73", size = 204697, upload-time = "2026-03-15T18:50:31.654Z" }, { url = "https://files.pythonhosted.org/packages/25/3c/8a18fc411f085b82303cfb7154eed5bd49c77035eb7608d049468b53f87c/charset_normalizer-3.4.6-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:0c173ce3a681f309f31b87125fecec7a5d1347261ea11ebbb856fa6006b23c8c", size = 191673, upload-time = "2026-03-15T18:50:33.433Z" }, { url = "https://files.pythonhosted.org/packages/ff/a7/11cfe61d6c5c5c7438d6ba40919d0306ed83c9ab957f3d4da2277ff67836/charset_normalizer-3.4.6-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c907cdc8109f6c619e6254212e794d6548373cc40e1ec75e6e3823d9135d29cc", size = 201120, upload-time = "2026-03-15T18:50:35.105Z" }, { url = "https://files.pythonhosted.org/packages/b5/10/cf491fa1abd47c02f69687046b896c950b92b6cd7337a27e6548adbec8e4/charset_normalizer-3.4.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:404a1e552cf5b675a87f0651f8b79f5f1e6fd100ee88dc612f89aa16abd4486f", size = 200911, upload-time = "2026-03-15T18:50:36.819Z" }, { url = "https://files.pythonhosted.org/packages/28/70/039796160b48b18ed466fde0af84c1b090c4e288fae26cd674ad04a2d703/charset_normalizer-3.4.6-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:e3c701e954abf6fc03a49f7c579cc80c2c6cc52525340ca3186c41d3f33482ef", size = 192516, upload-time = "2026-03-15T18:50:38.228Z" }, { url = "https://files.pythonhosted.org/packages/ff/34/c56f3223393d6ff3124b9e78f7de738047c2d6bc40a4f16ac0c9d7a1cb3c/charset_normalizer-3.4.6-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:7a6967aaf043bceabab5412ed6bd6bd26603dae84d5cb75bf8d9a74a4959d398", size = 218795, upload-time = "2026-03-15T18:50:39.664Z" }, { url = "https://files.pythonhosted.org/packages/e8/3b/ce2d4f86c5282191a041fdc5a4ce18f1c6bd40a5bd1f74cf8625f08d51c1/charset_normalizer-3.4.6-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:5feb91325bbceade6afab43eb3b508c63ee53579fe896c77137ded51c6b6958e", size = 201833, upload-time = "2026-03-15T18:50:41.552Z" }, { url = "https://files.pythonhosted.org/packages/3b/9b/b6a9f76b0fd7c5b5ec58b228ff7e85095370282150f0bd50b3126f5506d6/charset_normalizer-3.4.6-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:f820f24b09e3e779fe84c3c456cb4108a7aa639b0d1f02c28046e11bfcd088ed", size = 213920, upload-time = "2026-03-15T18:50:43.33Z" }, { url = "https://files.pythonhosted.org/packages/ae/98/7bc23513a33d8172365ed30ee3a3b3fe1ece14a395e5fc94129541fc6003/charset_normalizer-3.4.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b35b200d6a71b9839a46b9b7fff66b6638bb52fc9658aa58796b0326595d3021", size = 206951, upload-time = "2026-03-15T18:50:44.789Z" }, { url = "https://files.pythonhosted.org/packages/32/73/c0b86f3d1458468e11aec870e6b3feac931facbe105a894b552b0e518e79/charset_normalizer-3.4.6-cp311-cp311-win32.whl", hash = "sha256:9ca4c0b502ab399ef89248a2c84c54954f77a070f28e546a85e91da627d1301e", size = 143703, upload-time = "2026-03-15T18:50:46.103Z" }, { url = "https://files.pythonhosted.org/packages/c6/e3/76f2facfe8eddee0bbd38d2594e709033338eae44ebf1738bcefe0a06185/charset_normalizer-3.4.6-cp311-cp311-win_amd64.whl", hash = "sha256:a9e68c9d88823b274cf1e72f28cb5dc89c990edf430b0bfd3e2fb0785bfeabf4", size = 153857, upload-time = "2026-03-15T18:50:47.563Z" }, { url = "https://files.pythonhosted.org/packages/e2/dc/9abe19c9b27e6cd3636036b9d1b387b78c40dedbf0b47f9366737684b4b0/charset_normalizer-3.4.6-cp311-cp311-win_arm64.whl", hash = "sha256:97d0235baafca5f2b09cf332cc275f021e694e8362c6bb9c96fc9a0eb74fc316", size = 142751, upload-time = "2026-03-15T18:50:49.234Z" }, { url = "https://files.pythonhosted.org/packages/e5/62/c0815c992c9545347aeea7859b50dc9044d147e2e7278329c6e02ac9a616/charset_normalizer-3.4.6-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:2ef7fedc7a6ecbe99969cd09632516738a97eeb8bd7258bf8a0f23114c057dab", size = 295154, upload-time = "2026-03-15T18:50:50.88Z" }, { url = "https://files.pythonhosted.org/packages/a8/37/bdca6613c2e3c58c7421891d80cc3efa1d32e882f7c4a7ee6039c3fc951a/charset_normalizer-3.4.6-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a4ea868bc28109052790eb2b52a9ab33f3aa7adc02f96673526ff47419490e21", size = 199191, upload-time = "2026-03-15T18:50:52.658Z" }, { url = "https://files.pythonhosted.org/packages/6c/92/9934d1bbd69f7f398b38c5dae1cbf9cc672e7c34a4adf7b17c0a9c17d15d/charset_normalizer-3.4.6-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:836ab36280f21fc1a03c99cd05c6b7af70d2697e374c7af0b61ed271401a72a2", size = 218674, upload-time = "2026-03-15T18:50:54.102Z" }, { url = "https://files.pythonhosted.org/packages/af/90/25f6ab406659286be929fd89ab0e78e38aa183fc374e03aa3c12d730af8a/charset_normalizer-3.4.6-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f1ce721c8a7dfec21fcbdfe04e8f68174183cf4e8188e0645e92aa23985c57ff", size = 215259, upload-time = "2026-03-15T18:50:55.616Z" }, { url = "https://files.pythonhosted.org/packages/4e/ef/79a463eb0fff7f96afa04c1d4c51f8fc85426f918db467854bfb6a569ce3/charset_normalizer-3.4.6-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e28d62a8fc7a1fa411c43bd65e346f3bce9716dc51b897fbe930c5987b402d5", size = 207276, upload-time = "2026-03-15T18:50:57.054Z" }, { url = "https://files.pythonhosted.org/packages/f7/72/d0426afec4b71dc159fa6b4e68f868cd5a3ecd918fec5813a15d292a7d10/charset_normalizer-3.4.6-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:530d548084c4a9f7a16ed4a294d459b4f229db50df689bfe92027452452943a0", size = 195161, upload-time = "2026-03-15T18:50:58.686Z" }, { url = "https://files.pythonhosted.org/packages/bf/18/c82b06a68bfcb6ce55e508225d210c7e6a4ea122bfc0748892f3dc4e8e11/charset_normalizer-3.4.6-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:30f445ae60aad5e1f8bdbb3108e39f6fbc09f4ea16c815c66578878325f8f15a", size = 203452, upload-time = "2026-03-15T18:51:00.196Z" }, { url = "https://files.pythonhosted.org/packages/44/d6/0c25979b92f8adafdbb946160348d8d44aa60ce99afdc27df524379875cb/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ac2393c73378fea4e52aa56285a3d64be50f1a12395afef9cce47772f60334c2", size = 202272, upload-time = "2026-03-15T18:51:01.703Z" }, { url = "https://files.pythonhosted.org/packages/2e/3d/7fea3e8fe84136bebbac715dd1221cc25c173c57a699c030ab9b8900cbb7/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:90ca27cd8da8118b18a52d5f547859cc1f8354a00cd1e8e5120df3e30d6279e5", size = 195622, upload-time = "2026-03-15T18:51:03.526Z" }, { url = "https://files.pythonhosted.org/packages/57/8a/d6f7fd5cb96c58ef2f681424fbca01264461336d2a7fc875e4446b1f1346/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8e5a94886bedca0f9b78fecd6afb6629142fd2605aa70a125d49f4edc6037ee6", size = 220056, upload-time = "2026-03-15T18:51:05.269Z" }, { url = "https://files.pythonhosted.org/packages/16/50/478cdda782c8c9c3fb5da3cc72dd7f331f031e7f1363a893cdd6ca0f8de0/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:695f5c2823691a25f17bc5d5ffe79fa90972cc34b002ac6c843bb8a1720e950d", size = 203751, upload-time = "2026-03-15T18:51:06.858Z" }, { url = "https://files.pythonhosted.org/packages/75/fc/cc2fcac943939c8e4d8791abfa139f685e5150cae9f94b60f12520feaa9b/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:231d4da14bcd9301310faf492051bee27df11f2bc7549bc0bb41fef11b82daa2", size = 216563, upload-time = "2026-03-15T18:51:08.564Z" }, { url = "https://files.pythonhosted.org/packages/a8/b7/a4add1d9a5f68f3d037261aecca83abdb0ab15960a3591d340e829b37298/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a056d1ad2633548ca18ffa2f85c202cfb48b68615129143915b8dc72a806a923", size = 209265, upload-time = "2026-03-15T18:51:10.312Z" }, { url = "https://files.pythonhosted.org/packages/6c/18/c094561b5d64a24277707698e54b7f67bd17a4f857bbfbb1072bba07c8bf/charset_normalizer-3.4.6-cp312-cp312-win32.whl", hash = "sha256:c2274ca724536f173122f36c98ce188fd24ce3dad886ec2b7af859518ce008a4", size = 144229, upload-time = "2026-03-15T18:51:11.694Z" }, { url = "https://files.pythonhosted.org/packages/ab/20/0567efb3a8fd481b8f34f739ebddc098ed062a59fed41a8d193a61939e8f/charset_normalizer-3.4.6-cp312-cp312-win_amd64.whl", hash = "sha256:c8ae56368f8cc97c7e40a7ee18e1cedaf8e780cd8bc5ed5ac8b81f238614facb", size = 154277, upload-time = "2026-03-15T18:51:13.004Z" }, { url = "https://files.pythonhosted.org/packages/15/57/28d79b44b51933119e21f65479d0864a8d5893e494cf5daab15df0247c17/charset_normalizer-3.4.6-cp312-cp312-win_arm64.whl", hash = "sha256:899d28f422116b08be5118ef350c292b36fc15ec2daeb9ea987c89281c7bb5c4", size = 142817, upload-time = "2026-03-15T18:51:14.408Z" }, { url = "https://files.pythonhosted.org/packages/1e/1d/4fdabeef4e231153b6ed7567602f3b68265ec4e5b76d6024cf647d43d981/charset_normalizer-3.4.6-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:11afb56037cbc4b1555a34dd69151e8e069bee82e613a73bef6e714ce733585f", size = 294823, upload-time = "2026-03-15T18:51:15.755Z" }, { url = "https://files.pythonhosted.org/packages/47/7b/20e809b89c69d37be748d98e84dce6820bf663cf19cf6b942c951a3e8f41/charset_normalizer-3.4.6-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:423fb7e748a08f854a08a222b983f4df1912b1daedce51a72bd24fe8f26a1843", size = 198527, upload-time = "2026-03-15T18:51:17.177Z" }, { url = "https://files.pythonhosted.org/packages/37/a6/4f8d27527d59c039dce6f7622593cdcd3d70a8504d87d09eb11e9fdc6062/charset_normalizer-3.4.6-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d73beaac5e90173ac3deb9928a74763a6d230f494e4bfb422c217a0ad8e629bf", size = 218388, upload-time = "2026-03-15T18:51:18.934Z" }, { url = "https://files.pythonhosted.org/packages/f6/9b/4770ccb3e491a9bacf1c46cc8b812214fe367c86a96353ccc6daf87b01ec/charset_normalizer-3.4.6-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d60377dce4511655582e300dc1e5a5f24ba0cb229005a1d5c8d0cb72bb758ab8", size = 214563, upload-time = "2026-03-15T18:51:20.374Z" }, { url = "https://files.pythonhosted.org/packages/2b/58/a199d245894b12db0b957d627516c78e055adc3a0d978bc7f65ddaf7c399/charset_normalizer-3.4.6-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:530e8cebeea0d76bdcf93357aa5e41336f48c3dc709ac52da2bb167c5b8271d9", size = 206587, upload-time = "2026-03-15T18:51:21.807Z" }, { url = "https://files.pythonhosted.org/packages/7e/70/3def227f1ec56f5c69dfc8392b8bd63b11a18ca8178d9211d7cc5e5e4f27/charset_normalizer-3.4.6-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:a26611d9987b230566f24a0a125f17fe0de6a6aff9f25c9f564aaa2721a5fb88", size = 194724, upload-time = "2026-03-15T18:51:23.508Z" }, { url = "https://files.pythonhosted.org/packages/58/ab/9318352e220c05efd31c2779a23b50969dc94b985a2efa643ed9077bfca5/charset_normalizer-3.4.6-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:34315ff4fc374b285ad7f4a0bf7dcbfe769e1b104230d40f49f700d4ab6bbd84", size = 202956, upload-time = "2026-03-15T18:51:25.239Z" }, { url = "https://files.pythonhosted.org/packages/75/13/f3550a3ac25b70f87ac98c40d3199a8503676c2f1620efbf8d42095cfc40/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5f8ddd609f9e1af8c7bd6e2aca279c931aefecd148a14402d4e368f3171769fd", size = 201923, upload-time = "2026-03-15T18:51:26.682Z" }, { url = "https://files.pythonhosted.org/packages/1b/db/c5c643b912740b45e8eec21de1bbab8e7fc085944d37e1e709d3dcd9d72f/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:80d0a5615143c0b3225e5e3ef22c8d5d51f3f72ce0ea6fb84c943546c7b25b6c", size = 195366, upload-time = "2026-03-15T18:51:28.129Z" }, { url = "https://files.pythonhosted.org/packages/5a/67/3b1c62744f9b2448443e0eb160d8b001c849ec3fef591e012eda6484787c/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:92734d4d8d187a354a556626c221cd1a892a4e0802ccb2af432a1d85ec012194", size = 219752, upload-time = "2026-03-15T18:51:29.556Z" }, { url = "https://files.pythonhosted.org/packages/f6/98/32ffbaf7f0366ffb0445930b87d103f6b406bc2c271563644bde8a2b1093/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:613f19aa6e082cf96e17e3ffd89383343d0d589abda756b7764cf78361fd41dc", size = 203296, upload-time = "2026-03-15T18:51:30.921Z" }, { url = "https://files.pythonhosted.org/packages/41/12/5d308c1bbe60cabb0c5ef511574a647067e2a1f631bc8634fcafaccd8293/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:2b1a63e8224e401cafe7739f77efd3f9e7f5f2026bda4aead8e59afab537784f", size = 215956, upload-time = "2026-03-15T18:51:32.399Z" }, { url = "https://files.pythonhosted.org/packages/53/e9/5f85f6c5e20669dbe56b165c67b0260547dea97dba7e187938833d791687/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6cceb5473417d28edd20c6c984ab6fee6c6267d38d906823ebfe20b03d607dc2", size = 208652, upload-time = "2026-03-15T18:51:34.214Z" }, { url = "https://files.pythonhosted.org/packages/f1/11/897052ea6af56df3eef3ca94edafee410ca699ca0c7b87960ad19932c55e/charset_normalizer-3.4.6-cp313-cp313-win32.whl", hash = "sha256:d7de2637729c67d67cf87614b566626057e95c303bc0a55ffe391f5205e7003d", size = 143940, upload-time = "2026-03-15T18:51:36.15Z" }, { url = "https://files.pythonhosted.org/packages/a1/5c/724b6b363603e419829f561c854b87ed7c7e31231a7908708ac086cdf3e2/charset_normalizer-3.4.6-cp313-cp313-win_amd64.whl", hash = "sha256:572d7c822caf521f0525ba1bce1a622a0b85cf47ffbdae6c9c19e3b5ac3c4389", size = 154101, upload-time = "2026-03-15T18:51:37.876Z" }, { url = "https://files.pythonhosted.org/packages/01/a5/7abf15b4c0968e47020f9ca0935fb3274deb87cb288cd187cad92e8cdffd/charset_normalizer-3.4.6-cp313-cp313-win_arm64.whl", hash = "sha256:a4474d924a47185a06411e0064b803c68be044be2d60e50e8bddcc2649957c1f", size = 143109, upload-time = "2026-03-15T18:51:39.565Z" }, { url = "https://files.pythonhosted.org/packages/25/6f/ffe1e1259f384594063ea1869bfb6be5cdb8bc81020fc36c3636bc8302a1/charset_normalizer-3.4.6-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:9cc6e6d9e571d2f863fa77700701dae73ed5f78881efc8b3f9a4398772ff53e8", size = 294458, upload-time = "2026-03-15T18:51:41.134Z" }, { url = "https://files.pythonhosted.org/packages/56/60/09bb6c13a8c1016c2ed5c6a6488e4ffef506461aa5161662bd7636936fb1/charset_normalizer-3.4.6-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef5960d965e67165d75b7c7ffc60a83ec5abfc5c11b764ec13ea54fbef8b4421", size = 199277, upload-time = "2026-03-15T18:51:42.953Z" }, { url = "https://files.pythonhosted.org/packages/00/50/dcfbb72a5138bbefdc3332e8d81a23494bf67998b4b100703fd15fa52d81/charset_normalizer-3.4.6-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b3694e3f87f8ac7ce279d4355645b3c878d24d1424581b46282f24b92f5a4ae2", size = 218758, upload-time = "2026-03-15T18:51:44.339Z" }, { url = "https://files.pythonhosted.org/packages/03/b3/d79a9a191bb75f5aa81f3aaaa387ef29ce7cb7a9e5074ba8ea095cc073c2/charset_normalizer-3.4.6-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5d11595abf8dd942a77883a39d81433739b287b6aa71620f15164f8096221b30", size = 215299, upload-time = "2026-03-15T18:51:45.871Z" }, { url = "https://files.pythonhosted.org/packages/76/7e/bc8911719f7084f72fd545f647601ea3532363927f807d296a8c88a62c0d/charset_normalizer-3.4.6-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7bda6eebafd42133efdca535b04ccb338ab29467b3f7bf79569883676fc628db", size = 206811, upload-time = "2026-03-15T18:51:47.308Z" }, { url = "https://files.pythonhosted.org/packages/e2/40/c430b969d41dda0c465aa36cc7c2c068afb67177bef50905ac371b28ccc7/charset_normalizer-3.4.6-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:bbc8c8650c6e51041ad1be191742b8b421d05bbd3410f43fa2a00c8db87678e8", size = 193706, upload-time = "2026-03-15T18:51:48.849Z" }, { url = "https://files.pythonhosted.org/packages/48/15/e35e0590af254f7df984de1323640ef375df5761f615b6225ba8deb9799a/charset_normalizer-3.4.6-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:22c6f0c2fbc31e76c3b8a86fba1a56eda6166e238c29cdd3d14befdb4a4e4815", size = 202706, upload-time = "2026-03-15T18:51:50.257Z" }, { url = "https://files.pythonhosted.org/packages/5e/bd/f736f7b9cc5e93a18b794a50346bb16fbfd6b37f99e8f306f7951d27c17c/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7edbed096e4a4798710ed6bc75dcaa2a21b68b6c356553ac4823c3658d53743a", size = 202497, upload-time = "2026-03-15T18:51:52.012Z" }, { url = "https://files.pythonhosted.org/packages/9d/ba/2cc9e3e7dfdf7760a6ed8da7446d22536f3d0ce114ac63dee2a5a3599e62/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:7f9019c9cb613f084481bd6a100b12e1547cf2efe362d873c2e31e4035a6fa43", size = 193511, upload-time = "2026-03-15T18:51:53.723Z" }, { url = "https://files.pythonhosted.org/packages/9e/cb/5be49b5f776e5613be07298c80e1b02a2d900f7a7de807230595c85a8b2e/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:58c948d0d086229efc484fe2f30c2d382c86720f55cd9bc33591774348ad44e0", size = 220133, upload-time = "2026-03-15T18:51:55.333Z" }, { url = "https://files.pythonhosted.org/packages/83/43/99f1b5dad345accb322c80c7821071554f791a95ee50c1c90041c157ae99/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:419a9d91bd238052642a51938af8ac05da5b3343becde08d5cdeab9046df9ee1", size = 203035, upload-time = "2026-03-15T18:51:56.736Z" }, { url = "https://files.pythonhosted.org/packages/87/9a/62c2cb6a531483b55dddff1a68b3d891a8b498f3ca555fbcf2978e804d9d/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:5273b9f0b5835ff0350c0828faea623c68bfa65b792720c453e22b25cc72930f", size = 216321, upload-time = "2026-03-15T18:51:58.17Z" }, { url = "https://files.pythonhosted.org/packages/6e/79/94a010ff81e3aec7c293eb82c28f930918e517bc144c9906a060844462eb/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:0e901eb1049fdb80f5bd11ed5ea1e498ec423102f7a9b9e4645d5b8204ff2815", size = 208973, upload-time = "2026-03-15T18:51:59.998Z" }, { url = "https://files.pythonhosted.org/packages/2a/57/4ecff6d4ec8585342f0c71bc03efaa99cb7468f7c91a57b105bcd561cea8/charset_normalizer-3.4.6-cp314-cp314-win32.whl", hash = "sha256:b4ff1d35e8c5bd078be89349b6f3a845128e685e751b6ea1169cf2160b344c4d", size = 144610, upload-time = "2026-03-15T18:52:02.213Z" }, { url = "https://files.pythonhosted.org/packages/80/94/8434a02d9d7f168c25767c64671fead8d599744a05d6a6c877144c754246/charset_normalizer-3.4.6-cp314-cp314-win_amd64.whl", hash = "sha256:74119174722c4349af9708993118581686f343adc1c8c9c007d59be90d077f3f", size = 154962, upload-time = "2026-03-15T18:52:03.658Z" }, { url = "https://files.pythonhosted.org/packages/46/4c/48f2cdbfd923026503dfd67ccea45c94fd8fe988d9056b468579c66ed62b/charset_normalizer-3.4.6-cp314-cp314-win_arm64.whl", hash = "sha256:e5bcc1a1ae744e0bb59641171ae53743760130600da8db48cbb6e4918e186e4e", size = 143595, upload-time = "2026-03-15T18:52:05.123Z" }, { url = "https://files.pythonhosted.org/packages/31/93/8878be7569f87b14f1d52032946131bcb6ebbd8af3e20446bc04053dc3f1/charset_normalizer-3.4.6-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:ad8faf8df23f0378c6d527d8b0b15ea4a2e23c89376877c598c4870d1b2c7866", size = 314828, upload-time = "2026-03-15T18:52:06.831Z" }, { url = "https://files.pythonhosted.org/packages/06/b6/fae511ca98aac69ecc35cde828b0a3d146325dd03d99655ad38fc2cc3293/charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f5ea69428fa1b49573eef0cc44a1d43bebd45ad0c611eb7d7eac760c7ae771bc", size = 208138, upload-time = "2026-03-15T18:52:08.239Z" }, { url = "https://files.pythonhosted.org/packages/54/57/64caf6e1bf07274a1e0b7c160a55ee9e8c9ec32c46846ce59b9c333f7008/charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:06a7e86163334edfc5d20fe104db92fcd666e5a5df0977cb5680a506fe26cc8e", size = 224679, upload-time = "2026-03-15T18:52:10.043Z" }, { url = "https://files.pythonhosted.org/packages/aa/cb/9ff5a25b9273ef160861b41f6937f86fae18b0792fe0a8e75e06acb08f1d/charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e1f6e2f00a6b8edb562826e4632e26d063ac10307e80f7461f7de3ad8ef3f077", size = 223475, upload-time = "2026-03-15T18:52:11.854Z" }, { url = "https://files.pythonhosted.org/packages/fc/97/440635fc093b8d7347502a377031f9605a1039c958f3cd18dcacffb37743/charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:95b52c68d64c1878818687a473a10547b3292e82b6f6fe483808fb1468e2f52f", size = 215230, upload-time = "2026-03-15T18:52:13.325Z" }, { url = "https://files.pythonhosted.org/packages/cd/24/afff630feb571a13f07c8539fbb502d2ab494019492aaffc78ef41f1d1d0/charset_normalizer-3.4.6-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:7504e9b7dc05f99a9bbb4525c67a2c155073b44d720470a148b34166a69c054e", size = 199045, upload-time = "2026-03-15T18:52:14.752Z" }, { url = "https://files.pythonhosted.org/packages/e5/17/d1399ecdaf7e0498c327433e7eefdd862b41236a7e484355b8e0e5ebd64b/charset_normalizer-3.4.6-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:172985e4ff804a7ad08eebec0a1640ece87ba5041d565fff23c8f99c1f389484", size = 211658, upload-time = "2026-03-15T18:52:16.278Z" }, { url = "https://files.pythonhosted.org/packages/b5/38/16baa0affb957b3d880e5ac2144caf3f9d7de7bc4a91842e447fbb5e8b67/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:4be9f4830ba8741527693848403e2c457c16e499100963ec711b1c6f2049b7c7", size = 210769, upload-time = "2026-03-15T18:52:17.782Z" }, { url = "https://files.pythonhosted.org/packages/05/34/c531bc6ac4c21da9ddfddb3107be2287188b3ea4b53b70fc58f2a77ac8d8/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:79090741d842f564b1b2827c0b82d846405b744d31e84f18d7a7b41c20e473ff", size = 201328, upload-time = "2026-03-15T18:52:19.553Z" }, { url = "https://files.pythonhosted.org/packages/fa/73/a5a1e9ca5f234519c1953608a03fe109c306b97fdfb25f09182babad51a7/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:87725cfb1a4f1f8c2fc9890ae2f42094120f4b44db9360be5d99a4c6b0e03a9e", size = 225302, upload-time = "2026-03-15T18:52:21.043Z" }, { url = "https://files.pythonhosted.org/packages/ba/f6/cd782923d112d296294dea4bcc7af5a7ae0f86ab79f8fefbda5526b6cfc0/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:fcce033e4021347d80ed9c66dcf1e7b1546319834b74445f561d2e2221de5659", size = 211127, upload-time = "2026-03-15T18:52:22.491Z" }, { url = "https://files.pythonhosted.org/packages/0e/c5/0b6898950627af7d6103a449b22320372c24c6feda91aa24e201a478d161/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:ca0276464d148c72defa8bb4390cce01b4a0e425f3b50d1435aa6d7a18107602", size = 222840, upload-time = "2026-03-15T18:52:24.113Z" }, { url = "https://files.pythonhosted.org/packages/7d/25/c4bba773bef442cbdc06111d40daa3de5050a676fa26e85090fc54dd12f0/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:197c1a244a274bb016dd8b79204850144ef77fe81c5b797dc389327adb552407", size = 216890, upload-time = "2026-03-15T18:52:25.541Z" }, { url = "https://files.pythonhosted.org/packages/35/1a/05dacadb0978da72ee287b0143097db12f2e7e8d3ffc4647da07a383b0b7/charset_normalizer-3.4.6-cp314-cp314t-win32.whl", hash = "sha256:2a24157fa36980478dd1770b585c0f30d19e18f4fb0c47c13aa568f871718579", size = 155379, upload-time = "2026-03-15T18:52:27.05Z" }, { url = "https://files.pythonhosted.org/packages/5d/7a/d269d834cb3a76291651256f3b9a5945e81d0a49ab9f4a498964e83c0416/charset_normalizer-3.4.6-cp314-cp314t-win_amd64.whl", hash = "sha256:cd5e2801c89992ed8c0a3f0293ae83c159a60d9a5d685005383ef4caca77f2c4", size = 169043, upload-time = "2026-03-15T18:52:28.502Z" }, { url = "https://files.pythonhosted.org/packages/23/06/28b29fba521a37a8932c6a84192175c34d49f84a6d4773fa63d05f9aff22/charset_normalizer-3.4.6-cp314-cp314t-win_arm64.whl", hash = "sha256:47955475ac79cc504ef2704b192364e51d0d473ad452caedd0002605f780101c", size = 148523, upload-time = "2026-03-15T18:52:29.956Z" }, { url = "https://files.pythonhosted.org/packages/2a/68/687187c7e26cb24ccbd88e5069f5ef00eba804d36dde11d99aad0838ab45/charset_normalizer-3.4.6-py3-none-any.whl", hash = "sha256:947cf925bc916d90adba35a64c82aace04fa39b46b52d4630ece166655905a69", size = 61455, upload-time = "2026-03-15T18:53:23.833Z" }, ] [[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, upload-time = "2022-10-25T02:36:22.414Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] [[package]] name = "coverage" version = "7.13.5" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/9d/e0/70553e3000e345daff267cec284ce4cbf3fc141b6da229ac52775b5428f1/coverage-7.13.5.tar.gz", hash = "sha256:c81f6515c4c40141f83f502b07bbfa5c240ba25bbe73da7b33f1e5b6120ff179", size = 915967, upload-time = "2026-03-17T10:33:18.341Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/69/33/e8c48488c29a73fd089f9d71f9653c1be7478f2ad6b5bc870db11a55d23d/coverage-7.13.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e0723d2c96324561b9aa76fb982406e11d93cdb388a7a7da2b16e04719cf7ca5", size = 219255, upload-time = "2026-03-17T10:29:51.081Z" }, { url = "https://files.pythonhosted.org/packages/da/bd/b0ebe9f677d7f4b74a3e115eec7ddd4bcf892074963a00d91e8b164a6386/coverage-7.13.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:52f444e86475992506b32d4e5ca55c24fc88d73bcbda0e9745095b28ef4dc0cf", size = 219772, upload-time = "2026-03-17T10:29:52.867Z" }, { url = "https://files.pythonhosted.org/packages/48/cc/5cb9502f4e01972f54eedd48218bb203fe81e294be606a2bc93970208013/coverage-7.13.5-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:704de6328e3d612a8f6c07000a878ff38181ec3263d5a11da1db294fa6a9bdf8", size = 246532, upload-time = "2026-03-17T10:29:54.688Z" }, { url = "https://files.pythonhosted.org/packages/7d/d8/3217636d86c7e7b12e126e4f30ef1581047da73140614523af7495ed5f2d/coverage-7.13.5-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a1a6d79a14e1ec1832cabc833898636ad5f3754a678ef8bb4908515208bf84f4", size = 248333, upload-time = "2026-03-17T10:29:56.221Z" }, { url = "https://files.pythonhosted.org/packages/2b/30/2002ac6729ba2d4357438e2ed3c447ad8562866c8c63fc16f6dfc33afe56/coverage-7.13.5-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:79060214983769c7ba3f0cee10b54c97609dca4d478fa1aa32b914480fd5738d", size = 250211, upload-time = "2026-03-17T10:29:57.938Z" }, { url = "https://files.pythonhosted.org/packages/6c/85/552496626d6b9359eb0e2f86f920037c9cbfba09b24d914c6e1528155f7d/coverage-7.13.5-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:356e76b46783a98c2a2fe81ec79df4883a1e62895ea952968fb253c114e7f930", size = 252125, upload-time = "2026-03-17T10:29:59.388Z" }, { url = "https://files.pythonhosted.org/packages/44/21/40256eabdcbccdb6acf6b381b3016a154399a75fe39d406f790ae84d1f3c/coverage-7.13.5-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0cef0cdec915d11254a7f549c1170afecce708d30610c6abdded1f74e581666d", size = 247219, upload-time = "2026-03-17T10:30:01.199Z" }, { url = "https://files.pythonhosted.org/packages/b1/e8/96e2a6c3f21a0ea77d7830b254a1542d0328acc8d7bdf6a284ba7e529f77/coverage-7.13.5-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:dc022073d063b25a402454e5712ef9e007113e3a676b96c5f29b2bda29352f40", size = 248248, upload-time = "2026-03-17T10:30:03.317Z" }, { url = "https://files.pythonhosted.org/packages/da/ba/8477f549e554827da390ec659f3c38e4b6d95470f4daafc2d8ff94eaa9c2/coverage-7.13.5-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:9b74db26dfea4f4e50d48a4602207cd1e78be33182bc9cbf22da94f332f99878", size = 246254, upload-time = "2026-03-17T10:30:04.832Z" }, { url = "https://files.pythonhosted.org/packages/55/59/bc22aef0e6aa179d5b1b001e8b3654785e9adf27ef24c93dc4228ebd5d68/coverage-7.13.5-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:ad146744ca4fd09b50c482650e3c1b1f4dfa1d4792e0a04a369c7f23336f0400", size = 250067, upload-time = "2026-03-17T10:30:06.535Z" }, { url = "https://files.pythonhosted.org/packages/de/1b/c6a023a160806a5137dca53468fd97530d6acad24a22003b1578a9c2e429/coverage-7.13.5-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:c555b48be1853fe3997c11c4bd521cdd9a9612352de01fa4508f16ec341e6fe0", size = 246521, upload-time = "2026-03-17T10:30:08.486Z" }, { url = "https://files.pythonhosted.org/packages/2d/3f/3532c85a55aa2f899fa17c186f831cfa1aa434d88ff792a709636f64130e/coverage-7.13.5-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:7034b5c56a58ae5e85f23949d52c14aca2cfc6848a31764995b7de88f13a1ea0", size = 247126, upload-time = "2026-03-17T10:30:09.966Z" }, { url = "https://files.pythonhosted.org/packages/aa/2e/b9d56af4a24ef45dfbcda88e06870cb7d57b2b0bfa3a888d79b4c8debd76/coverage-7.13.5-cp310-cp310-win32.whl", hash = "sha256:eb7fdf1ef130660e7415e0253a01a7d5a88c9c4d158bcf75cbbd922fd65a5b58", size = 221860, upload-time = "2026-03-17T10:30:11.393Z" }, { url = "https://files.pythonhosted.org/packages/9f/cc/d938417e7a4d7f0433ad4edee8bb2acdc60dc7ac5af19e2a07a048ecbee3/coverage-7.13.5-cp310-cp310-win_amd64.whl", hash = "sha256:3e1bb5f6c78feeb1be3475789b14a0f0a5b47d505bfc7267126ccbd50289999e", size = 222788, upload-time = "2026-03-17T10:30:12.886Z" }, { url = "https://files.pythonhosted.org/packages/4b/37/d24c8f8220ff07b839b2c043ea4903a33b0f455abe673ae3c03bbdb7f212/coverage-7.13.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:66a80c616f80181f4d643b0f9e709d97bcea413ecd9631e1dedc7401c8e6695d", size = 219381, upload-time = "2026-03-17T10:30:14.68Z" }, { url = "https://files.pythonhosted.org/packages/35/8b/cd129b0ca4afe886a6ce9d183c44d8301acbd4ef248622e7c49a23145605/coverage-7.13.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:145ede53ccbafb297c1c9287f788d1bc3efd6c900da23bf6931b09eafc931587", size = 219880, upload-time = "2026-03-17T10:30:16.231Z" }, { url = "https://files.pythonhosted.org/packages/55/2f/e0e5b237bffdb5d6c530ce87cc1d413a5b7d7dfd60fb067ad6d254c35c76/coverage-7.13.5-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0672854dc733c342fa3e957e0605256d2bf5934feeac328da9e0b5449634a642", size = 250303, upload-time = "2026-03-17T10:30:17.748Z" }, { url = "https://files.pythonhosted.org/packages/92/be/b1afb692be85b947f3401375851484496134c5554e67e822c35f28bf2fbc/coverage-7.13.5-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ec10e2a42b41c923c2209b846126c6582db5e43a33157e9870ba9fb70dc7854b", size = 252218, upload-time = "2026-03-17T10:30:19.804Z" }, { url = "https://files.pythonhosted.org/packages/da/69/2f47bb6fa1b8d1e3e5d0c4be8ccb4313c63d742476a619418f85740d597b/coverage-7.13.5-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be3d4bbad9d4b037791794ddeedd7d64a56f5933a2c1373e18e9e568b9141686", size = 254326, upload-time = "2026-03-17T10:30:21.321Z" }, { url = "https://files.pythonhosted.org/packages/d5/d0/79db81da58965bd29dabc8f4ad2a2af70611a57cba9d1ec006f072f30a54/coverage-7.13.5-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4d2afbc5cc54d286bfb54541aa50b64cdb07a718227168c87b9e2fb8f25e1743", size = 256267, upload-time = "2026-03-17T10:30:23.094Z" }, { url = "https://files.pythonhosted.org/packages/e5/32/d0d7cc8168f91ddab44c0ce4806b969df5f5fdfdbb568eaca2dbc2a04936/coverage-7.13.5-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3ad050321264c49c2fa67bb599100456fc51d004b82534f379d16445da40fb75", size = 250430, upload-time = "2026-03-17T10:30:25.311Z" }, { url = "https://files.pythonhosted.org/packages/4d/06/a055311d891ddbe231cd69fdd20ea4be6e3603ffebddf8704b8ca8e10a3c/coverage-7.13.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7300c8a6d13335b29bb76d7651c66af6bd8658517c43499f110ddc6717bfc209", size = 252017, upload-time = "2026-03-17T10:30:27.284Z" }, { url = "https://files.pythonhosted.org/packages/d6/f6/d0fd2d21e29a657b5f77a2fe7082e1568158340dceb941954f776dce1b7b/coverage-7.13.5-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:eb07647a5738b89baab047f14edd18ded523de60f3b30e75c2acc826f79c839a", size = 250080, upload-time = "2026-03-17T10:30:29.481Z" }, { url = "https://files.pythonhosted.org/packages/4e/ab/0d7fb2efc2e9a5eb7ddcc6e722f834a69b454b7e6e5888c3a8567ecffb31/coverage-7.13.5-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:9adb6688e3b53adffefd4a52d72cbd8b02602bfb8f74dcd862337182fd4d1a4e", size = 253843, upload-time = "2026-03-17T10:30:31.301Z" }, { url = "https://files.pythonhosted.org/packages/ba/6f/7467b917bbf5408610178f62a49c0ed4377bb16c1657f689cc61470da8ce/coverage-7.13.5-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7c8d4bc913dd70b93488d6c496c77f3aff5ea99a07e36a18f865bca55adef8bd", size = 249802, upload-time = "2026-03-17T10:30:33.358Z" }, { url = "https://files.pythonhosted.org/packages/75/2c/1172fb689df92135f5bfbbd69fc83017a76d24ea2e2f3a1154007e2fb9f8/coverage-7.13.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0e3c426ffc4cd952f54ee9ffbdd10345709ecc78a3ecfd796a57236bfad0b9b8", size = 250707, upload-time = "2026-03-17T10:30:35.2Z" }, { url = "https://files.pythonhosted.org/packages/67/21/9ac389377380a07884e3b48ba7a620fcd9dbfaf1d40565facdc6b36ec9ef/coverage-7.13.5-cp311-cp311-win32.whl", hash = "sha256:259b69bb83ad9894c4b25be2528139eecba9a82646ebdda2d9db1ba28424a6bf", size = 221880, upload-time = "2026-03-17T10:30:36.775Z" }, { url = "https://files.pythonhosted.org/packages/af/7f/4cd8a92531253f9d7c1bbecd9fa1b472907fb54446ca768c59b531248dc5/coverage-7.13.5-cp311-cp311-win_amd64.whl", hash = "sha256:258354455f4e86e3e9d0d17571d522e13b4e1e19bf0f8596bcf9476d61e7d8a9", size = 222816, upload-time = "2026-03-17T10:30:38.891Z" }, { url = "https://files.pythonhosted.org/packages/12/a6/1d3f6155fb0010ca68eba7fe48ca6c9da7385058b77a95848710ecf189b1/coverage-7.13.5-cp311-cp311-win_arm64.whl", hash = "sha256:bff95879c33ec8da99fc9b6fe345ddb5be6414b41d6d1ad1c8f188d26f36e028", size = 221483, upload-time = "2026-03-17T10:30:40.463Z" }, { url = "https://files.pythonhosted.org/packages/a0/c3/a396306ba7db865bf96fc1fb3b7fd29bcbf3d829df642e77b13555163cd6/coverage-7.13.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:460cf0114c5016fa841214ff5564aa4864f11948da9440bc97e21ad1f4ba1e01", size = 219554, upload-time = "2026-03-17T10:30:42.208Z" }, { url = "https://files.pythonhosted.org/packages/a6/16/a68a19e5384e93f811dccc51034b1fd0b865841c390e3c931dcc4699e035/coverage-7.13.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0e223ce4b4ed47f065bfb123687686512e37629be25cc63728557ae7db261422", size = 219908, upload-time = "2026-03-17T10:30:43.906Z" }, { url = "https://files.pythonhosted.org/packages/29/72/20b917c6793af3a5ceb7fb9c50033f3ec7865f2911a1416b34a7cfa0813b/coverage-7.13.5-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6e3370441f4513c6252bf042b9c36d22491142385049243253c7e48398a15a9f", size = 251419, upload-time = "2026-03-17T10:30:45.545Z" }, { url = "https://files.pythonhosted.org/packages/8c/49/cd14b789536ac6a4778c453c6a2338bc0a2fb60c5a5a41b4008328b9acc1/coverage-7.13.5-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:03ccc709a17a1de074fb1d11f217342fb0d2b1582ed544f554fc9fc3f07e95f5", size = 254159, upload-time = "2026-03-17T10:30:47.204Z" }, { url = "https://files.pythonhosted.org/packages/9d/00/7b0edcfe64e2ed4c0340dac14a52ad0f4c9bd0b8b5e531af7d55b703db7c/coverage-7.13.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3f4818d065964db3c1c66dc0fbdac5ac692ecbc875555e13374fdbe7eedb4376", size = 255270, upload-time = "2026-03-17T10:30:48.812Z" }, { url = "https://files.pythonhosted.org/packages/93/89/7ffc4ba0f5d0a55c1e84ea7cee39c9fc06af7b170513d83fbf3bbefce280/coverage-7.13.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:012d5319e66e9d5a218834642d6c35d265515a62f01157a45bcc036ecf947256", size = 257538, upload-time = "2026-03-17T10:30:50.77Z" }, { url = "https://files.pythonhosted.org/packages/81/bd/73ddf85f93f7e6fa83e77ccecb6162d9415c79007b4bc124008a4995e4a7/coverage-7.13.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8dd02af98971bdb956363e4827d34425cb3df19ee550ef92855b0acb9c7ce51c", size = 251821, upload-time = "2026-03-17T10:30:52.5Z" }, { url = "https://files.pythonhosted.org/packages/a0/81/278aff4e8dec4926a0bcb9486320752811f543a3ce5b602cc7a29978d073/coverage-7.13.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f08fd75c50a760c7eb068ae823777268daaf16a80b918fa58eea888f8e3919f5", size = 253191, upload-time = "2026-03-17T10:30:54.543Z" }, { url = "https://files.pythonhosted.org/packages/70/ee/fe1621488e2e0a58d7e94c4800f0d96f79671553488d401a612bebae324b/coverage-7.13.5-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:843ea8643cf967d1ac7e8ecd4bb00c99135adf4816c0c0593fdcc47b597fcf09", size = 251337, upload-time = "2026-03-17T10:30:56.663Z" }, { url = "https://files.pythonhosted.org/packages/37/a6/f79fb37aa104b562207cc23cb5711ab6793608e246cae1e93f26b2236ed9/coverage-7.13.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:9d44d7aa963820b1b971dbecd90bfe5fe8f81cff79787eb6cca15750bd2f79b9", size = 255404, upload-time = "2026-03-17T10:30:58.427Z" }, { url = "https://files.pythonhosted.org/packages/75/f0/ed15262a58ec81ce457ceb717b7f78752a1713556b19081b76e90896e8d4/coverage-7.13.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:7132bed4bd7b836200c591410ae7d97bf7ae8be6fc87d160b2bd881df929e7bf", size = 250903, upload-time = "2026-03-17T10:31:00.093Z" }, { url = "https://files.pythonhosted.org/packages/0f/e9/9129958f20e7e9d4d56d51d42ccf708d15cac355ff4ac6e736e97a9393d2/coverage-7.13.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a698e363641b98843c517817db75373c83254781426e94ada3197cabbc2c919c", size = 252780, upload-time = "2026-03-17T10:31:01.916Z" }, { url = "https://files.pythonhosted.org/packages/a4/d7/0ad9b15812d81272db94379fe4c6df8fd17781cc7671fdfa30c76ba5ff7b/coverage-7.13.5-cp312-cp312-win32.whl", hash = "sha256:bdba0a6b8812e8c7df002d908a9a2ea3c36e92611b5708633c50869e6d922fdf", size = 222093, upload-time = "2026-03-17T10:31:03.642Z" }, { url = "https://files.pythonhosted.org/packages/29/3d/821a9a5799fac2556bcf0bd37a70d1d11fa9e49784b6d22e92e8b2f85f18/coverage-7.13.5-cp312-cp312-win_amd64.whl", hash = "sha256:d2c87e0c473a10bffe991502eac389220533024c8082ec1ce849f4218dded810", size = 222900, upload-time = "2026-03-17T10:31:05.651Z" }, { url = "https://files.pythonhosted.org/packages/d4/fa/2238c2ad08e35cf4f020ea721f717e09ec3152aea75d191a7faf3ef009a8/coverage-7.13.5-cp312-cp312-win_arm64.whl", hash = "sha256:bf69236a9a81bdca3bff53796237aab096cdbf8d78a66ad61e992d9dac7eb2de", size = 221515, upload-time = "2026-03-17T10:31:07.293Z" }, { url = "https://files.pythonhosted.org/packages/74/8c/74fedc9663dcf168b0a059d4ea756ecae4da77a489048f94b5f512a8d0b3/coverage-7.13.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5ec4af212df513e399cf11610cc27063f1586419e814755ab362e50a85ea69c1", size = 219576, upload-time = "2026-03-17T10:31:09.045Z" }, { url = "https://files.pythonhosted.org/packages/0c/c9/44fb661c55062f0818a6ffd2685c67aa30816200d5f2817543717d4b92eb/coverage-7.13.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:941617e518602e2d64942c88ec8499f7fbd49d3f6c4327d3a71d43a1973032f3", size = 219942, upload-time = "2026-03-17T10:31:10.708Z" }, { url = "https://files.pythonhosted.org/packages/5f/13/93419671cee82b780bab7ea96b67c8ef448f5f295f36bf5031154ec9a790/coverage-7.13.5-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:da305e9937617ee95c2e39d8ff9f040e0487cbf1ac174f777ed5eddd7a7c1f26", size = 250935, upload-time = "2026-03-17T10:31:12.392Z" }, { url = "https://files.pythonhosted.org/packages/ac/68/1666e3a4462f8202d836920114fa7a5ee9275d1fa45366d336c551a162dd/coverage-7.13.5-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:78e696e1cc714e57e8b25760b33a8b1026b7048d270140d25dafe1b0a1ee05a3", size = 253541, upload-time = "2026-03-17T10:31:14.247Z" }, { url = "https://files.pythonhosted.org/packages/4e/5e/3ee3b835647be646dcf3c65a7c6c18f87c27326a858f72ab22c12730773d/coverage-7.13.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:02ca0eed225b2ff301c474aeeeae27d26e2537942aa0f87491d3e147e784a82b", size = 254780, upload-time = "2026-03-17T10:31:16.193Z" }, { url = "https://files.pythonhosted.org/packages/44/b3/cb5bd1a04cfcc49ede6cd8409d80bee17661167686741e041abc7ee1b9a9/coverage-7.13.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:04690832cbea4e4663d9149e05dba142546ca05cb1848816760e7f58285c970a", size = 256912, upload-time = "2026-03-17T10:31:17.89Z" }, { url = "https://files.pythonhosted.org/packages/1b/66/c1dceb7b9714473800b075f5c8a84f4588f887a90eb8645282031676e242/coverage-7.13.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0590e44dd2745c696a778f7bab6aa95256de2cbc8b8cff4f7db8ff09813d6969", size = 251165, upload-time = "2026-03-17T10:31:19.605Z" }, { url = "https://files.pythonhosted.org/packages/b7/62/5502b73b97aa2e53ea22a39cf8649ff44827bef76d90bf638777daa27a9d/coverage-7.13.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d7cfad2d6d81dd298ab6b89fe72c3b7b05ec7544bdda3b707ddaecff8d25c161", size = 252908, upload-time = "2026-03-17T10:31:21.312Z" }, { url = "https://files.pythonhosted.org/packages/7d/37/7792c2d69854397ca77a55c4646e5897c467928b0e27f2d235d83b5d08c6/coverage-7.13.5-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:e092b9499de38ae0fbfbc603a74660eb6ff3e869e507b50d85a13b6db9863e15", size = 250873, upload-time = "2026-03-17T10:31:23.565Z" }, { url = "https://files.pythonhosted.org/packages/a3/23/bc866fb6163be52a8a9e5d708ba0d3b1283c12158cefca0a8bbb6e247a43/coverage-7.13.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:48c39bc4a04d983a54a705a6389512883d4a3b9862991b3617d547940e9f52b1", size = 255030, upload-time = "2026-03-17T10:31:25.58Z" }, { url = "https://files.pythonhosted.org/packages/7d/8b/ef67e1c222ef49860701d346b8bbb70881bef283bd5f6cbba68a39a086c7/coverage-7.13.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2d3807015f138ffea1ed9afeeb8624fd781703f2858b62a8dd8da5a0994c57b6", size = 250694, upload-time = "2026-03-17T10:31:27.316Z" }, { url = "https://files.pythonhosted.org/packages/46/0d/866d1f74f0acddbb906db212e096dee77a8e2158ca5e6bb44729f9d93298/coverage-7.13.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ee2aa19e03161671ec964004fb74b2257805d9710bf14a5c704558b9d8dbaf17", size = 252469, upload-time = "2026-03-17T10:31:29.472Z" }, { url = "https://files.pythonhosted.org/packages/7a/f5/be742fec31118f02ce42b21c6af187ad6a344fed546b56ca60caacc6a9a0/coverage-7.13.5-cp313-cp313-win32.whl", hash = "sha256:ce1998c0483007608c8382f4ff50164bfc5bd07a2246dd272aa4043b75e61e85", size = 222112, upload-time = "2026-03-17T10:31:31.526Z" }, { url = "https://files.pythonhosted.org/packages/66/40/7732d648ab9d069a46e686043241f01206348e2bbf128daea85be4d6414b/coverage-7.13.5-cp313-cp313-win_amd64.whl", hash = "sha256:631efb83f01569670a5e866ceb80fe483e7c159fac6f167e6571522636104a0b", size = 222923, upload-time = "2026-03-17T10:31:33.633Z" }, { url = "https://files.pythonhosted.org/packages/48/af/fea819c12a095781f6ccd504890aaddaf88b8fab263c4940e82c7b770124/coverage-7.13.5-cp313-cp313-win_arm64.whl", hash = "sha256:f4cd16206ad171cbc2470dbea9103cf9a7607d5fe8c242fdf1edf36174020664", size = 221540, upload-time = "2026-03-17T10:31:35.445Z" }, { url = "https://files.pythonhosted.org/packages/23/d2/17879af479df7fbbd44bd528a31692a48f6b25055d16482fdf5cdb633805/coverage-7.13.5-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0428cbef5783ad91fe240f673cc1f76b25e74bbfe1a13115e4aa30d3f538162d", size = 220262, upload-time = "2026-03-17T10:31:37.184Z" }, { url = "https://files.pythonhosted.org/packages/5b/4c/d20e554f988c8f91d6a02c5118f9abbbf73a8768a3048cb4962230d5743f/coverage-7.13.5-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e0b216a19534b2427cc201a26c25da4a48633f29a487c61258643e89d28200c0", size = 220617, upload-time = "2026-03-17T10:31:39.245Z" }, { url = "https://files.pythonhosted.org/packages/29/9c/f9f5277b95184f764b24e7231e166dfdb5780a46d408a2ac665969416d61/coverage-7.13.5-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:972a9cd27894afe4bc2b1480107054e062df08e671df7c2f18c205e805ccd806", size = 261912, upload-time = "2026-03-17T10:31:41.324Z" }, { url = "https://files.pythonhosted.org/packages/d5/f6/7f1ab39393eeb50cfe4747ae8ef0e4fc564b989225aa1152e13a180d74f8/coverage-7.13.5-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4b59148601efcd2bac8c4dbf1f0ad6391693ccf7a74b8205781751637076aee3", size = 263987, upload-time = "2026-03-17T10:31:43.724Z" }, { url = "https://files.pythonhosted.org/packages/a0/d7/62c084fb489ed9c6fbdf57e006752e7c516ea46fd690e5ed8b8617c7d52e/coverage-7.13.5-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:505d7083c8b0c87a8fa8c07370c285847c1f77739b22e299ad75a6af6c32c5c9", size = 266416, upload-time = "2026-03-17T10:31:45.769Z" }, { url = "https://files.pythonhosted.org/packages/a9/f6/df63d8660e1a0bff6125947afda112a0502736f470d62ca68b288ea762d8/coverage-7.13.5-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:60365289c3741e4db327e7baff2a4aaacf22f788e80fa4683393891b70a89fbd", size = 267558, upload-time = "2026-03-17T10:31:48.293Z" }, { url = "https://files.pythonhosted.org/packages/5b/02/353ca81d36779bd108f6d384425f7139ac3c58c750dcfaafe5d0bee6436b/coverage-7.13.5-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1b88c69c8ef5d4b6fe7dea66d6636056a0f6a7527c440e890cf9259011f5e606", size = 261163, upload-time = "2026-03-17T10:31:50.125Z" }, { url = "https://files.pythonhosted.org/packages/2c/16/2e79106d5749bcaf3aee6d309123548e3276517cd7851faa8da213bc61bf/coverage-7.13.5-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5b13955d31d1633cf9376908089b7cebe7d15ddad7aeaabcbe969a595a97e95e", size = 263981, upload-time = "2026-03-17T10:31:51.961Z" }, { url = "https://files.pythonhosted.org/packages/29/c7/c29e0c59ffa6942030ae6f50b88ae49988e7e8da06de7ecdbf49c6d4feae/coverage-7.13.5-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:f70c9ab2595c56f81a89620e22899eea8b212a4041bd728ac6f4a28bf5d3ddd0", size = 261604, upload-time = "2026-03-17T10:31:53.872Z" }, { url = "https://files.pythonhosted.org/packages/40/48/097cdc3db342f34006a308ab41c3a7c11c3f0d84750d340f45d88a782e00/coverage-7.13.5-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:084b84a8c63e8d6fc7e3931b316a9bcafca1458d753c539db82d31ed20091a87", size = 265321, upload-time = "2026-03-17T10:31:55.997Z" }, { url = "https://files.pythonhosted.org/packages/bb/1f/4994af354689e14fd03a75f8ec85a9a68d94e0188bbdab3fc1516b55e512/coverage-7.13.5-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:ad14385487393e386e2ea988b09d62dd42c397662ac2dabc3832d71253eee479", size = 260502, upload-time = "2026-03-17T10:31:58.308Z" }, { url = "https://files.pythonhosted.org/packages/22/c6/9bb9ef55903e628033560885f5c31aa227e46878118b63ab15dc7ba87797/coverage-7.13.5-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:7f2c47b36fe7709a6e83bfadf4eefb90bd25fbe4014d715224c4316f808e59a2", size = 262688, upload-time = "2026-03-17T10:32:00.141Z" }, { url = "https://files.pythonhosted.org/packages/14/4f/f5df9007e50b15e53e01edea486814783a7f019893733d9e4d6caad75557/coverage-7.13.5-cp313-cp313t-win32.whl", hash = "sha256:67e9bc5449801fad0e5dff329499fb090ba4c5800b86805c80617b4e29809b2a", size = 222788, upload-time = "2026-03-17T10:32:02.246Z" }, { url = "https://files.pythonhosted.org/packages/e1/98/aa7fccaa97d0f3192bec013c4e6fd6d294a6ed44b640e6bb61f479e00ed5/coverage-7.13.5-cp313-cp313t-win_amd64.whl", hash = "sha256:da86cdcf10d2519e10cabb8ac2de03da1bcb6e4853790b7fbd48523332e3a819", size = 223851, upload-time = "2026-03-17T10:32:04.416Z" }, { url = "https://files.pythonhosted.org/packages/3d/8b/e5c469f7352651e5f013198e9e21f97510b23de957dd06a84071683b4b60/coverage-7.13.5-cp313-cp313t-win_arm64.whl", hash = "sha256:0ecf12ecb326fe2c339d93fc131816f3a7367d223db37817208905c89bded911", size = 222104, upload-time = "2026-03-17T10:32:06.65Z" }, { url = "https://files.pythonhosted.org/packages/8e/77/39703f0d1d4b478bfd30191d3c14f53caf596fac00efb3f8f6ee23646439/coverage-7.13.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fbabfaceaeb587e16f7008f7795cd80d20ec548dc7f94fbb0d4ec2e038ce563f", size = 219621, upload-time = "2026-03-17T10:32:08.589Z" }, { url = "https://files.pythonhosted.org/packages/e2/3e/51dff36d99ae14639a133d9b164d63e628532e2974d8b1edb99dd1ebc733/coverage-7.13.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9bb2a28101a443669a423b665939381084412b81c3f8c0fcfbac57f4e30b5b8e", size = 219953, upload-time = "2026-03-17T10:32:10.507Z" }, { url = "https://files.pythonhosted.org/packages/6a/6c/1f1917b01eb647c2f2adc9962bd66c79eb978951cab61bdc1acab3290c07/coverage-7.13.5-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:bd3a2fbc1c6cccb3c5106140d87cc6a8715110373ef42b63cf5aea29df8c217a", size = 250992, upload-time = "2026-03-17T10:32:12.41Z" }, { url = "https://files.pythonhosted.org/packages/22/e5/06b1f88f42a5a99df42ce61208bdec3bddb3d261412874280a19796fc09c/coverage-7.13.5-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6c36ddb64ed9d7e496028d1d00dfec3e428e0aabf4006583bb1839958d280510", size = 253503, upload-time = "2026-03-17T10:32:14.449Z" }, { url = "https://files.pythonhosted.org/packages/80/28/2a148a51e5907e504fa7b85490277734e6771d8844ebcc48764a15e28155/coverage-7.13.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:380e8e9084d8eb38db3a9176a1a4f3c0082c3806fa0dc882d1d87abc3c789247", size = 254852, upload-time = "2026-03-17T10:32:16.56Z" }, { url = "https://files.pythonhosted.org/packages/61/77/50e8d3d85cc0b7ebe09f30f151d670e302c7ff4a1bf6243f71dd8b0981fa/coverage-7.13.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e808af52a0513762df4d945ea164a24b37f2f518cbe97e03deaa0ee66139b4d6", size = 257161, upload-time = "2026-03-17T10:32:19.004Z" }, { url = "https://files.pythonhosted.org/packages/3b/c4/b5fd1d4b7bf8d0e75d997afd3925c59ba629fc8616f1b3aae7605132e256/coverage-7.13.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e301d30dd7e95ae068671d746ba8c34e945a82682e62918e41b2679acd2051a0", size = 251021, upload-time = "2026-03-17T10:32:21.344Z" }, { url = "https://files.pythonhosted.org/packages/f8/66/6ea21f910e92d69ef0b1c3346ea5922a51bad4446c9126db2ae96ee24c4c/coverage-7.13.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:800bc829053c80d240a687ceeb927a94fd108bbdc68dfbe505d0d75ab578a882", size = 252858, upload-time = "2026-03-17T10:32:23.506Z" }, { url = "https://files.pythonhosted.org/packages/9e/ea/879c83cb5d61aa2a35fb80e72715e92672daef8191b84911a643f533840c/coverage-7.13.5-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:0b67af5492adb31940ee418a5a655c28e48165da5afab8c7fa6fd72a142f8740", size = 250823, upload-time = "2026-03-17T10:32:25.516Z" }, { url = "https://files.pythonhosted.org/packages/8a/fb/616d95d3adb88b9803b275580bdeee8bd1b69a886d057652521f83d7322f/coverage-7.13.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:c9136ff29c3a91e25b1d1552b5308e53a1e0653a23e53b6366d7c2dcbbaf8a16", size = 255099, upload-time = "2026-03-17T10:32:27.944Z" }, { url = "https://files.pythonhosted.org/packages/1c/93/25e6917c90ec1c9a56b0b26f6cad6408e5f13bb6b35d484a0d75c9cf000d/coverage-7.13.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:cff784eef7f0b8f6cb28804fbddcfa99f89efe4cc35fb5627e3ac58f91ed3ac0", size = 250638, upload-time = "2026-03-17T10:32:29.914Z" }, { url = "https://files.pythonhosted.org/packages/fc/7b/dc1776b0464145a929deed214aef9fb1493f159b59ff3c7eeeedf91eddd0/coverage-7.13.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:68a4953be99b17ac3c23b6efbc8a38330d99680c9458927491d18700ef23ded0", size = 252295, upload-time = "2026-03-17T10:32:31.981Z" }, { url = "https://files.pythonhosted.org/packages/ea/fb/99cbbc56a26e07762a2740713f3c8f9f3f3106e3a3dd8cc4474954bccd34/coverage-7.13.5-cp314-cp314-win32.whl", hash = "sha256:35a31f2b1578185fbe6aa2e74cea1b1d0bbf4c552774247d9160d29b80ed56cc", size = 222360, upload-time = "2026-03-17T10:32:34.233Z" }, { url = "https://files.pythonhosted.org/packages/8d/b7/4758d4f73fb536347cc5e4ad63662f9d60ba9118cb6785e9616b2ce5d7fa/coverage-7.13.5-cp314-cp314-win_amd64.whl", hash = "sha256:2aa055ae1857258f9e0045be26a6d62bdb47a72448b62d7b55f4820f361a2633", size = 223174, upload-time = "2026-03-17T10:32:36.369Z" }, { url = "https://files.pythonhosted.org/packages/2c/f2/24d84e1dfe70f8ac9fdf30d338239860d0d1d5da0bda528959d0ebc9da28/coverage-7.13.5-cp314-cp314-win_arm64.whl", hash = "sha256:1b11eef33edeae9d142f9b4358edb76273b3bfd30bc3df9a4f95d0e49caf94e8", size = 221739, upload-time = "2026-03-17T10:32:38.736Z" }, { url = "https://files.pythonhosted.org/packages/60/5b/4a168591057b3668c2428bff25dd3ebc21b629d666d90bcdfa0217940e84/coverage-7.13.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:10a0c37f0b646eaff7cce1874c31d1f1ccb297688d4c747291f4f4c70741cc8b", size = 220351, upload-time = "2026-03-17T10:32:41.196Z" }, { url = "https://files.pythonhosted.org/packages/f5/21/1fd5c4dbfe4a58b6b99649125635df46decdfd4a784c3cd6d410d303e370/coverage-7.13.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b5db73ba3c41c7008037fa731ad5459fc3944cb7452fc0aa9f822ad3533c583c", size = 220612, upload-time = "2026-03-17T10:32:43.204Z" }, { url = "https://files.pythonhosted.org/packages/d6/fe/2a924b3055a5e7e4512655a9d4609781b0d62334fa0140c3e742926834e2/coverage-7.13.5-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:750db93a81e3e5a9831b534be7b1229df848b2e125a604fe6651e48aa070e5f9", size = 261985, upload-time = "2026-03-17T10:32:45.514Z" }, { url = "https://files.pythonhosted.org/packages/d7/0d/c8928f2bd518c45990fe1a2ab8db42e914ef9b726c975facc4282578c3eb/coverage-7.13.5-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9ddb4f4a5479f2539644be484da179b653273bca1a323947d48ab107b3ed1f29", size = 264107, upload-time = "2026-03-17T10:32:47.971Z" }, { url = "https://files.pythonhosted.org/packages/ef/ae/4ae35bbd9a0af9d820362751f0766582833c211224b38665c0f8de3d487f/coverage-7.13.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d8a7a2049c14f413163e2bdabd37e41179b1d1ccb10ffc6ccc4b7a718429c607", size = 266513, upload-time = "2026-03-17T10:32:50.1Z" }, { url = "https://files.pythonhosted.org/packages/9c/20/d326174c55af36f74eac6ae781612d9492f060ce8244b570bb9d50d9d609/coverage-7.13.5-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1c85e0b6c05c592ea6d8768a66a254bfb3874b53774b12d4c89c481eb78cb90", size = 267650, upload-time = "2026-03-17T10:32:52.391Z" }, { url = "https://files.pythonhosted.org/packages/7a/5e/31484d62cbd0eabd3412e30d74386ece4a0837d4f6c3040a653878bfc019/coverage-7.13.5-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:777c4d1eff1b67876139d24288aaf1817f6c03d6bae9c5cc8d27b83bcfe38fe3", size = 261089, upload-time = "2026-03-17T10:32:54.544Z" }, { url = "https://files.pythonhosted.org/packages/e9/d8/49a72d6de146eebb0b7e48cc0f4bc2c0dd858e3d4790ab2b39a2872b62bd/coverage-7.13.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6697e29b93707167687543480a40f0db8f356e86d9f67ddf2e37e2dfd91a9dab", size = 263982, upload-time = "2026-03-17T10:32:56.803Z" }, { url = "https://files.pythonhosted.org/packages/06/3b/0351f1bd566e6e4dd39e978efe7958bde1d32f879e85589de147654f57bb/coverage-7.13.5-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:8fdf453a942c3e4d99bd80088141c4c6960bb232c409d9c3558e2dbaa3998562", size = 261579, upload-time = "2026-03-17T10:32:59.466Z" }, { url = "https://files.pythonhosted.org/packages/5d/ce/796a2a2f4017f554d7810f5c573449b35b1e46788424a548d4d19201b222/coverage-7.13.5-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:32ca0c0114c9834a43f045a87dcebd69d108d8ffb666957ea65aa132f50332e2", size = 265316, upload-time = "2026-03-17T10:33:01.847Z" }, { url = "https://files.pythonhosted.org/packages/3d/16/d5ae91455541d1a78bc90abf495be600588aff8f6db5c8b0dae739fa39c9/coverage-7.13.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:8769751c10f339021e2638cd354e13adeac54004d1941119b2c96fe5276d45ea", size = 260427, upload-time = "2026-03-17T10:33:03.945Z" }, { url = "https://files.pythonhosted.org/packages/48/11/07f413dba62db21fb3fad5d0de013a50e073cc4e2dc4306e770360f6dfc8/coverage-7.13.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cec2d83125531bd153175354055cdb7a09987af08a9430bd173c937c6d0fba2a", size = 262745, upload-time = "2026-03-17T10:33:06.285Z" }, { url = "https://files.pythonhosted.org/packages/91/15/d792371332eb4663115becf4bad47e047d16234b1aff687b1b18c58d60ae/coverage-7.13.5-cp314-cp314t-win32.whl", hash = "sha256:0cd9ed7a8b181775459296e402ca4fb27db1279740a24e93b3b41942ebe4b215", size = 223146, upload-time = "2026-03-17T10:33:08.756Z" }, { url = "https://files.pythonhosted.org/packages/db/51/37221f59a111dca5e85be7dbf09696323b5b9f13ff65e0641d535ed06ea8/coverage-7.13.5-cp314-cp314t-win_amd64.whl", hash = "sha256:301e3b7dfefecaca37c9f1aa6f0049b7d4ab8dd933742b607765d757aca77d43", size = 224254, upload-time = "2026-03-17T10:33:11.174Z" }, { url = "https://files.pythonhosted.org/packages/54/83/6acacc889de8987441aa7d5adfbdbf33d288dad28704a67e574f1df9bcbb/coverage-7.13.5-cp314-cp314t-win_arm64.whl", hash = "sha256:9dacc2ad679b292709e0f5fc1ac74a6d4d5562e424058962c7bb0c658ad25e45", size = 222276, upload-time = "2026-03-17T10:33:13.466Z" }, { url = "https://files.pythonhosted.org/packages/9e/ee/a4cf96b8ce1e566ed238f0659ac2d3f007ed1d14b181bcb684e19561a69a/coverage-7.13.5-py3-none-any.whl", hash = "sha256:34b02417cf070e173989b3db962f7ed56d2f644307b2cf9d5a0f258e13084a61", size = 211346, upload-time = "2026-03-17T10:33:15.691Z" }, ] [package.optional-dependencies] toml = [ { name = "tomli", marker = "python_full_version <= '3.11'" }, ] [[package]] name = "cryptography" version = "46.0.7" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/47/93/ac8f3d5ff04d54bc814e961a43ae5b0b146154c89c61b47bb07557679b18/cryptography-46.0.7.tar.gz", hash = "sha256:e4cfd68c5f3e0bfdad0d38e023239b96a2fe84146481852dffbcca442c245aa5", size = 750652, upload-time = "2026-04-08T01:57:54.692Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/0b/5d/4a8f770695d73be252331e60e526291e3df0c9b27556a90a6b47bccca4c2/cryptography-46.0.7-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:ea42cbe97209df307fdc3b155f1b6fa2577c0defa8f1f7d3be7d31d189108ad4", size = 7179869, upload-time = "2026-04-08T01:56:17.157Z" }, { url = "https://files.pythonhosted.org/packages/5f/45/6d80dc379b0bbc1f9d1e429f42e4cb9e1d319c7a8201beffd967c516ea01/cryptography-46.0.7-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b36a4695e29fe69215d75960b22577197aca3f7a25b9cf9d165dcfe9d80bc325", size = 4275492, upload-time = "2026-04-08T01:56:19.36Z" }, { url = "https://files.pythonhosted.org/packages/4a/9a/1765afe9f572e239c3469f2cb429f3ba7b31878c893b246b4b2994ffe2fe/cryptography-46.0.7-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5ad9ef796328c5e3c4ceed237a183f5d41d21150f972455a9d926593a1dcb308", size = 4426670, upload-time = "2026-04-08T01:56:21.415Z" }, { url = "https://files.pythonhosted.org/packages/8f/3e/af9246aaf23cd4ee060699adab1e47ced3f5f7e7a8ffdd339f817b446462/cryptography-46.0.7-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:73510b83623e080a2c35c62c15298096e2a5dc8d51c3b4e1740211839d0dea77", size = 4280275, upload-time = "2026-04-08T01:56:23.539Z" }, { url = "https://files.pythonhosted.org/packages/0f/54/6bbbfc5efe86f9d71041827b793c24811a017c6ac0fd12883e4caa86b8ed/cryptography-46.0.7-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:cbd5fb06b62bd0721e1170273d3f4d5a277044c47ca27ee257025146c34cbdd1", size = 4928402, upload-time = "2026-04-08T01:56:25.624Z" }, { url = "https://files.pythonhosted.org/packages/2d/cf/054b9d8220f81509939599c8bdbc0c408dbd2bdd41688616a20731371fe0/cryptography-46.0.7-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:420b1e4109cc95f0e5700eed79908cef9268265c773d3a66f7af1eef53d409ef", size = 4459985, upload-time = "2026-04-08T01:56:27.309Z" }, { url = "https://files.pythonhosted.org/packages/f9/46/4e4e9c6040fb01c7467d47217d2f882daddeb8828f7df800cb806d8a2288/cryptography-46.0.7-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:24402210aa54baae71d99441d15bb5a1919c195398a87b563df84468160a65de", size = 3990652, upload-time = "2026-04-08T01:56:29.095Z" }, { url = "https://files.pythonhosted.org/packages/36/5f/313586c3be5a2fbe87e4c9a254207b860155a8e1f3cca99f9910008e7d08/cryptography-46.0.7-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:8a469028a86f12eb7d2fe97162d0634026d92a21f3ae0ac87ed1c4a447886c83", size = 4279805, upload-time = "2026-04-08T01:56:30.928Z" }, { url = "https://files.pythonhosted.org/packages/69/33/60dfc4595f334a2082749673386a4d05e4f0cf4df8248e63b2c3437585f2/cryptography-46.0.7-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:9694078c5d44c157ef3162e3bf3946510b857df5a3955458381d1c7cfc143ddb", size = 4892883, upload-time = "2026-04-08T01:56:32.614Z" }, { url = "https://files.pythonhosted.org/packages/c7/0b/333ddab4270c4f5b972f980adef4faa66951a4aaf646ca067af597f15563/cryptography-46.0.7-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:42a1e5f98abb6391717978baf9f90dc28a743b7d9be7f0751a6f56a75d14065b", size = 4459756, upload-time = "2026-04-08T01:56:34.306Z" }, { url = "https://files.pythonhosted.org/packages/d2/14/633913398b43b75f1234834170947957c6b623d1701ffc7a9600da907e89/cryptography-46.0.7-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:91bbcb08347344f810cbe49065914fe048949648f6bd5c2519f34619142bbe85", size = 4410244, upload-time = "2026-04-08T01:56:35.977Z" }, { url = "https://files.pythonhosted.org/packages/10/f2/19ceb3b3dc14009373432af0c13f46aa08e3ce334ec6eff13492e1812ccd/cryptography-46.0.7-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:5d1c02a14ceb9148cc7816249f64f623fbfee39e8c03b3650d842ad3f34d637e", size = 4674868, upload-time = "2026-04-08T01:56:38.034Z" }, { url = "https://files.pythonhosted.org/packages/1a/bb/a5c213c19ee94b15dfccc48f363738633a493812687f5567addbcbba9f6f/cryptography-46.0.7-cp311-abi3-win32.whl", hash = "sha256:d23c8ca48e44ee015cd0a54aeccdf9f09004eba9fc96f38c911011d9ff1bd457", size = 3026504, upload-time = "2026-04-08T01:56:39.666Z" }, { url = "https://files.pythonhosted.org/packages/2b/02/7788f9fefa1d060ca68717c3901ae7fffa21ee087a90b7f23c7a603c32ae/cryptography-46.0.7-cp311-abi3-win_amd64.whl", hash = "sha256:397655da831414d165029da9bc483bed2fe0e75dde6a1523ec2fe63f3c46046b", size = 3488363, upload-time = "2026-04-08T01:56:41.893Z" }, { url = "https://files.pythonhosted.org/packages/7b/56/15619b210e689c5403bb0540e4cb7dbf11a6bf42e483b7644e471a2812b3/cryptography-46.0.7-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:d151173275e1728cf7839aaa80c34fe550c04ddb27b34f48c232193df8db5842", size = 7119671, upload-time = "2026-04-08T01:56:44Z" }, { url = "https://files.pythonhosted.org/packages/74/66/e3ce040721b0b5599e175ba91ab08884c75928fbeb74597dd10ef13505d2/cryptography-46.0.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:db0f493b9181c7820c8134437eb8b0b4792085d37dbb24da050476ccb664e59c", size = 4268551, upload-time = "2026-04-08T01:56:46.071Z" }, { url = "https://files.pythonhosted.org/packages/03/11/5e395f961d6868269835dee1bafec6a1ac176505a167f68b7d8818431068/cryptography-46.0.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ebd6daf519b9f189f85c479427bbd6e9c9037862cf8fe89ee35503bd209ed902", size = 4408887, upload-time = "2026-04-08T01:56:47.718Z" }, { url = "https://files.pythonhosted.org/packages/40/53/8ed1cf4c3b9c8e611e7122fb56f1c32d09e1fff0f1d77e78d9ff7c82653e/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:b7b412817be92117ec5ed95f880defe9cf18a832e8cafacf0a22337dc1981b4d", size = 4271354, upload-time = "2026-04-08T01:56:49.312Z" }, { url = "https://files.pythonhosted.org/packages/50/46/cf71e26025c2e767c5609162c866a78e8a2915bbcfa408b7ca495c6140c4/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:fbfd0e5f273877695cb93baf14b185f4878128b250cc9f8e617ea0c025dfb022", size = 4905845, upload-time = "2026-04-08T01:56:50.916Z" }, { url = "https://files.pythonhosted.org/packages/c0/ea/01276740375bac6249d0a971ebdf6b4dc9ead0ee0a34ef3b5a88c1a9b0d4/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:ffca7aa1d00cf7d6469b988c581598f2259e46215e0140af408966a24cf086ce", size = 4444641, upload-time = "2026-04-08T01:56:52.882Z" }, { url = "https://files.pythonhosted.org/packages/3d/4c/7d258f169ae71230f25d9f3d06caabcff8c3baf0978e2b7d65e0acac3827/cryptography-46.0.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:60627cf07e0d9274338521205899337c5d18249db56865f943cbe753aa96f40f", size = 3967749, upload-time = "2026-04-08T01:56:54.597Z" }, { url = "https://files.pythonhosted.org/packages/b5/2a/2ea0767cad19e71b3530e4cad9605d0b5e338b6a1e72c37c9c1ceb86c333/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:80406c3065e2c55d7f49a9550fe0c49b3f12e5bfff5dedb727e319e1afb9bf99", size = 4270942, upload-time = "2026-04-08T01:56:56.416Z" }, { url = "https://files.pythonhosted.org/packages/41/3d/fe14df95a83319af25717677e956567a105bb6ab25641acaa093db79975d/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:c5b1ccd1239f48b7151a65bc6dd54bcfcc15e028c8ac126d3fada09db0e07ef1", size = 4871079, upload-time = "2026-04-08T01:56:58.31Z" }, { url = "https://files.pythonhosted.org/packages/9c/59/4a479e0f36f8f378d397f4eab4c850b4ffb79a2f0d58704b8fa0703ddc11/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:d5f7520159cd9c2154eb61eb67548ca05c5774d39e9c2c4339fd793fe7d097b2", size = 4443999, upload-time = "2026-04-08T01:57:00.508Z" }, { url = "https://files.pythonhosted.org/packages/28/17/b59a741645822ec6d04732b43c5d35e4ef58be7bfa84a81e5ae6f05a1d33/cryptography-46.0.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fcd8eac50d9138c1d7fc53a653ba60a2bee81a505f9f8850b6b2888555a45d0e", size = 4399191, upload-time = "2026-04-08T01:57:02.654Z" }, { url = "https://files.pythonhosted.org/packages/59/6a/bb2e166d6d0e0955f1e9ff70f10ec4b2824c9cfcdb4da772c7dd69cc7d80/cryptography-46.0.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:65814c60f8cc400c63131584e3e1fad01235edba2614b61fbfbfa954082db0ee", size = 4655782, upload-time = "2026-04-08T01:57:04.592Z" }, { url = "https://files.pythonhosted.org/packages/95/b6/3da51d48415bcb63b00dc17c2eff3a651b7c4fed484308d0f19b30e8cb2c/cryptography-46.0.7-cp314-cp314t-win32.whl", hash = "sha256:fdd1736fed309b4300346f88f74cd120c27c56852c3838cab416e7a166f67298", size = 3002227, upload-time = "2026-04-08T01:57:06.91Z" }, { url = "https://files.pythonhosted.org/packages/32/a8/9f0e4ed57ec9cebe506e58db11ae472972ecb0c659e4d52bbaee80ca340a/cryptography-46.0.7-cp314-cp314t-win_amd64.whl", hash = "sha256:e06acf3c99be55aa3b516397fe42f5855597f430add9c17fa46bf2e0fb34c9bb", size = 3475332, upload-time = "2026-04-08T01:57:08.807Z" }, { url = "https://files.pythonhosted.org/packages/a7/7f/cd42fc3614386bc0c12f0cb3c4ae1fc2bbca5c9662dfed031514911d513d/cryptography-46.0.7-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:462ad5cb1c148a22b2e3bcc5ad52504dff325d17daf5df8d88c17dda1f75f2a4", size = 7165618, upload-time = "2026-04-08T01:57:10.645Z" }, { url = "https://files.pythonhosted.org/packages/a5/d0/36a49f0262d2319139d2829f773f1b97ef8aef7f97e6e5bd21455e5a8fb5/cryptography-46.0.7-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:84d4cced91f0f159a7ddacad249cc077e63195c36aac40b4150e7a57e84fffe7", size = 4270628, upload-time = "2026-04-08T01:57:12.885Z" }, { url = "https://files.pythonhosted.org/packages/8a/6c/1a42450f464dda6ffbe578a911f773e54dd48c10f9895a23a7e88b3e7db5/cryptography-46.0.7-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:128c5edfe5e5938b86b03941e94fac9ee793a94452ad1365c9fc3f4f62216832", size = 4415405, upload-time = "2026-04-08T01:57:14.923Z" }, { url = "https://files.pythonhosted.org/packages/9a/92/4ed714dbe93a066dc1f4b4581a464d2d7dbec9046f7c8b7016f5286329e2/cryptography-46.0.7-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:5e51be372b26ef4ba3de3c167cd3d1022934bc838ae9eaad7e644986d2a3d163", size = 4272715, upload-time = "2026-04-08T01:57:16.638Z" }, { url = "https://files.pythonhosted.org/packages/b7/e6/a26b84096eddd51494bba19111f8fffe976f6a09f132706f8f1bf03f51f7/cryptography-46.0.7-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:cdf1a610ef82abb396451862739e3fc93b071c844399e15b90726ef7470eeaf2", size = 4918400, upload-time = "2026-04-08T01:57:19.021Z" }, { url = "https://files.pythonhosted.org/packages/c7/08/ffd537b605568a148543ac3c2b239708ae0bd635064bab41359252ef88ed/cryptography-46.0.7-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1d25aee46d0c6f1a501adcddb2d2fee4b979381346a78558ed13e50aa8a59067", size = 4450634, upload-time = "2026-04-08T01:57:21.185Z" }, { url = "https://files.pythonhosted.org/packages/16/01/0cd51dd86ab5b9befe0d031e276510491976c3a80e9f6e31810cce46c4ad/cryptography-46.0.7-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:cdfbe22376065ffcf8be74dc9a909f032df19bc58a699456a21712d6e5eabfd0", size = 3985233, upload-time = "2026-04-08T01:57:22.862Z" }, { url = "https://files.pythonhosted.org/packages/92/49/819d6ed3a7d9349c2939f81b500a738cb733ab62fbecdbc1e38e83d45e12/cryptography-46.0.7-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:abad9dac36cbf55de6eb49badd4016806b3165d396f64925bf2999bcb67837ba", size = 4271955, upload-time = "2026-04-08T01:57:24.814Z" }, { url = "https://files.pythonhosted.org/packages/80/07/ad9b3c56ebb95ed2473d46df0847357e01583f4c52a85754d1a55e29e4d0/cryptography-46.0.7-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:935ce7e3cfdb53e3536119a542b839bb94ec1ad081013e9ab9b7cfd478b05006", size = 4879888, upload-time = "2026-04-08T01:57:26.88Z" }, { url = "https://files.pythonhosted.org/packages/b8/c7/201d3d58f30c4c2bdbe9b03844c291feb77c20511cc3586daf7edc12a47b/cryptography-46.0.7-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:35719dc79d4730d30f1c2b6474bd6acda36ae2dfae1e3c16f2051f215df33ce0", size = 4449961, upload-time = "2026-04-08T01:57:29.068Z" }, { url = "https://files.pythonhosted.org/packages/a5/ef/649750cbf96f3033c3c976e112265c33906f8e462291a33d77f90356548c/cryptography-46.0.7-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7bbc6ccf49d05ac8f7d7b5e2e2c33830d4fe2061def88210a126d130d7f71a85", size = 4401696, upload-time = "2026-04-08T01:57:31.029Z" }, { url = "https://files.pythonhosted.org/packages/41/52/a8908dcb1a389a459a29008c29966c1d552588d4ae6d43f3a1a4512e0ebe/cryptography-46.0.7-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a1529d614f44b863a7b480c6d000fe93b59acee9c82ffa027cfadc77521a9f5e", size = 4664256, upload-time = "2026-04-08T01:57:33.144Z" }, { url = "https://files.pythonhosted.org/packages/4b/fa/f0ab06238e899cc3fb332623f337a7364f36f4bb3f2534c2bb95a35b132c/cryptography-46.0.7-cp38-abi3-win32.whl", hash = "sha256:f247c8c1a1fb45e12586afbb436ef21ff1e80670b2861a90353d9b025583d246", size = 3013001, upload-time = "2026-04-08T01:57:34.933Z" }, { url = "https://files.pythonhosted.org/packages/d2/f1/00ce3bde3ca542d1acd8f8cfa38e446840945aa6363f9b74746394b14127/cryptography-46.0.7-cp38-abi3-win_amd64.whl", hash = "sha256:506c4ff91eff4f82bdac7633318a526b1d1309fc07ca76a3ad182cb5b686d6d3", size = 3472985, upload-time = "2026-04-08T01:57:36.714Z" }, { url = "https://files.pythonhosted.org/packages/63/0c/dca8abb64e7ca4f6b2978769f6fea5ad06686a190cec381f0a796fdcaaba/cryptography-46.0.7-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:fc9ab8856ae6cf7c9358430e49b368f3108f050031442eaeb6b9d87e4dcf4e4f", size = 3476879, upload-time = "2026-04-08T01:57:38.664Z" }, { url = "https://files.pythonhosted.org/packages/3a/ea/075aac6a84b7c271578d81a2f9968acb6e273002408729f2ddff517fed4a/cryptography-46.0.7-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:d3b99c535a9de0adced13d159c5a9cf65c325601aa30f4be08afd680643e9c15", size = 4219700, upload-time = "2026-04-08T01:57:40.625Z" }, { url = "https://files.pythonhosted.org/packages/6c/7b/1c55db7242b5e5612b29fc7a630e91ee7a6e3c8e7bf5406d22e206875fbd/cryptography-46.0.7-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:d02c738dacda7dc2a74d1b2b3177042009d5cab7c7079db74afc19e56ca1b455", size = 4385982, upload-time = "2026-04-08T01:57:42.725Z" }, { url = "https://files.pythonhosted.org/packages/cb/da/9870eec4b69c63ef5925bf7d8342b7e13bc2ee3d47791461c4e49ca212f4/cryptography-46.0.7-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:04959522f938493042d595a736e7dbdff6eb6cc2339c11465b3ff89343b65f65", size = 4219115, upload-time = "2026-04-08T01:57:44.939Z" }, { url = "https://files.pythonhosted.org/packages/f4/72/05aa5832b82dd341969e9a734d1812a6aadb088d9eb6f0430fc337cc5a8f/cryptography-46.0.7-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:3986ac1dee6def53797289999eabe84798ad7817f3e97779b5061a95b0ee4968", size = 4385479, upload-time = "2026-04-08T01:57:46.86Z" }, { url = "https://files.pythonhosted.org/packages/20/2a/1b016902351a523aa2bd446b50a5bc1175d7a7d1cf90fe2ef904f9b84ebc/cryptography-46.0.7-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:258514877e15963bd43b558917bc9f54cf7cf866c38aa576ebf47a77ddbc43a4", size = 3412829, upload-time = "2026-04-08T01:57:48.874Z" }, ] [[package]] name = "decorator" version = "5.2.1" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/43/fa/6d96a0978d19e17b68d634497769987b16c8f4cd0a7a05048bec693caa6b/decorator-5.2.1.tar.gz", hash = "sha256:65f266143752f734b0a7cc83c46f4618af75b8c5911b00ccb61d0ac9b6da0360", size = 56711, upload-time = "2025-02-24T04:41:34.073Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/4e/8c/f3147f5c4b73e7550fe5f9352eaa956ae838d5c51eb58e7a25b9f3e2643b/decorator-5.2.1-py3-none-any.whl", hash = "sha256:d316bb415a2d9e2d2b3abcc4084c6502fc09240e292cd76a76afc106a1c8e04a", size = 9190, upload-time = "2025-02-24T04:41:32.565Z" }, ] [[package]] name = "docker" version = "7.1.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pywin32", marker = "sys_platform == 'win32'" }, { name = "requests" }, { name = "urllib3" }, ] sdist = { url = "https://files.pythonhosted.org/packages/91/9b/4a2ea29aeba62471211598dac5d96825bb49348fa07e906ea930394a83ce/docker-7.1.0.tar.gz", hash = "sha256:ad8c70e6e3f8926cb8a92619b832b4ea5299e2831c14284663184e200546fa6c", size = 117834, upload-time = "2024-05-23T11:13:57.216Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/e3/26/57c6fb270950d476074c087527a558ccb6f4436657314bfb6cdf484114c4/docker-7.1.0-py3-none-any.whl", hash = "sha256:c96b93b7f0a746f9e77d325bcfb87422a3d8bd4f03136ae8a85b37f1898d5fc0", size = 147774, upload-time = "2024-05-23T11:13:55.01Z" }, ] [[package]] name = "docutils" version = "0.21.2" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version < '3.11'", ] sdist = { url = "https://files.pythonhosted.org/packages/ae/ed/aefcc8cd0ba62a0560c3c18c33925362d46c6075480bfa4df87b28e169a9/docutils-0.21.2.tar.gz", hash = "sha256:3a6b18732edf182daa3cd12775bbb338cf5691468f91eeeb109deff6ebfa986f", size = 2204444, upload-time = "2024-04-23T18:57:18.24Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/8f/d7/9322c609343d929e75e7e5e6255e614fcc67572cfd083959cdef3b7aad79/docutils-0.21.2-py3-none-any.whl", hash = "sha256:dafca5b9e384f0e419294eb4d2ff9fa826435bf15f15b7bd45723e8ad76811b2", size = 587408, upload-time = "2024-04-23T18:57:14.835Z" }, ] [[package]] name = "docutils" version = "0.22.4" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version >= '3.12'", "python_full_version == '3.11.*'", ] sdist = { url = "https://files.pythonhosted.org/packages/ae/b6/03bb70946330e88ffec97aefd3ea75ba575cb2e762061e0e62a213befee8/docutils-0.22.4.tar.gz", hash = "sha256:4db53b1fde9abecbb74d91230d32ab626d94f6badfc575d6db9194a49df29968", size = 2291750, upload-time = "2025-12-18T19:00:26.443Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/02/10/5da547df7a391dcde17f59520a231527b8571e6f46fc8efb02ccb370ab12/docutils-0.22.4-py3-none-any.whl", hash = "sha256:d0013f540772d1420576855455d050a2180186c91c15779301ac2ccb3eeb68de", size = 633196, upload-time = "2025-12-18T19:00:18.077Z" }, ] [[package]] name = "exceptiongroup" version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, ] [[package]] name = "execnet" version = "2.1.2" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/bf/89/780e11f9588d9e7128a3f87788354c7946a9cbb1401ad38a48c4db9a4f07/execnet-2.1.2.tar.gz", hash = "sha256:63d83bfdd9a23e35b9c6a3261412324f964c2ec8dcd8d3c6916ee9373e0befcd", size = 166622, upload-time = "2025-11-12T09:56:37.75Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/ab/84/02fc1827e8cdded4aa65baef11296a9bbe595c474f0d6d758af082d849fd/execnet-2.1.2-py3-none-any.whl", hash = "sha256:67fba928dd5a544b783f6056f449e5e3931a5c378b128bc18501f7ea79e296ec", size = 40708, upload-time = "2025-11-12T09:56:36.333Z" }, ] [[package]] name = "furo" version = "2025.12.19" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "accessible-pygments" }, { name = "beautifulsoup4" }, { name = "pygments" }, { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, { name = "sphinx", version = "9.0.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, { name = "sphinx", version = "9.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, { name = "sphinx-basic-ng" }, ] sdist = { url = "https://files.pythonhosted.org/packages/ec/20/5f5ad4da6a5a27c80f2ed2ee9aee3f9e36c66e56e21c00fde467b2f8f88f/furo-2025.12.19.tar.gz", hash = "sha256:188d1f942037d8b37cd3985b955839fea62baa1730087dc29d157677c857e2a7", size = 1661473, upload-time = "2025-12-19T17:34:40.889Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/f4/b2/50e9b292b5cac13e9e81272c7171301abc753a60460d21505b606e15cf21/furo-2025.12.19-py3-none-any.whl", hash = "sha256:bb0ead5309f9500130665a26bee87693c41ce4dbdff864dbfb6b0dae4673d24f", size = 339262, upload-time = "2025-12-19T17:34:38.905Z" }, ] [[package]] name = "gssapi" version = "1.11.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "decorator" }, ] sdist = { url = "https://files.pythonhosted.org/packages/23/52/c1e90623c259a42ab0587078bb04f959867b970add46ff66750ead8fc7c5/gssapi-1.11.1.tar.gz", hash = "sha256:2049ee4b1d0c363163a1344b7282a363f9f4094e51d2c36de0cf01d4735e0ae2", size = 95233, upload-time = "2026-01-26T21:01:39.463Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/d8/d8/dfd7632e42f3028b27fdae1dea0fd967bcee1d9164e0a9fa3946490e6c7a/gssapi-1.11.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:126352502e15dc42f786a4635e5fb4dc8ae4bbc89354e85ab094c478a9e49beb", size = 680076, upload-time = "2026-01-26T21:01:12.11Z" }, { url = "https://files.pythonhosted.org/packages/0b/25/e668f8bfebdaf132b29a26bbc4cc50c5a624a83c5271e83e69c62c2ac5d3/gssapi-1.11.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:25b3bcf75a0dd5638f02f939a9c40d1c907682ccca69ea1d05ea81ad58ea1022", size = 687299, upload-time = "2026-01-26T21:01:13.933Z" }, { url = "https://files.pythonhosted.org/packages/48/75/3cc18f2d084d19fbba38dc684588cf5f674c647e754f9cf1625bd78c39f8/gssapi-1.11.1-cp311-abi3-macosx_10_9_x86_64.whl", hash = "sha256:2298e5909a8f2d27c29352885a24e4026cfd3fa24fc38d4a0a3743fa5a3e7667", size = 611712, upload-time = "2026-01-26T21:01:19.626Z" }, { url = "https://files.pythonhosted.org/packages/81/f9/ac0f8c43c209d56c89655f80cd4ae43379f88370d01a7e11f264f081eef5/gssapi-1.11.1-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:5b60b1f8d8d3e36c025bd3494105de1dfccee578e8de001f423cc094468e3022", size = 642782, upload-time = "2026-01-26T21:01:21.462Z" }, { url = "https://files.pythonhosted.org/packages/7b/79/9148636b75ca5741ddc9c57c4b256adec422e3a89bca14306d53b48caac9/gssapi-1.11.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:82fba401e9514ad21749b8d8954e2de1c617b0a73204c8598ee84630e23c5810", size = 714379, upload-time = "2026-01-26T21:01:25.961Z" }, { url = "https://files.pythonhosted.org/packages/ca/77/f34fd81bbccf2e682073964a1b1b0a383e70d02946e472f78881d50cec6f/gssapi-1.11.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:cb0250f27d288d4217d7f606d3b68ecb9a10fce9391106129cada96434a685b0", size = 734103, upload-time = "2026-01-26T21:01:27.3Z" }, ] [[package]] name = "idna" version = "3.11" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, ] [[package]] name = "imagesize" version = "2.0.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/6c/e6/7bf14eeb8f8b7251141944835abd42eb20a658d89084b7e1f3e5fe394090/imagesize-2.0.0.tar.gz", hash = "sha256:8e8358c4a05c304f1fccf7ff96f036e7243a189e9e42e90851993c558cfe9ee3", size = 1773045, upload-time = "2026-03-03T14:18:29.941Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/5f/53/fb7122b71361a0d121b669dcf3d31244ef75badbbb724af388948de543e2/imagesize-2.0.0-py2.py3-none-any.whl", hash = "sha256:5667c5bbb57ab3f1fa4bc366f4fbc971db3d5ed011fd2715fd8001f782718d96", size = 9441, upload-time = "2026-03-03T14:18:27.892Z" }, ] [[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, upload-time = "2025-10-18T21:55:43.219Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, ] [[package]] name = "jenkinsapi" version = "0.3.23" source = { editable = "." } dependencies = [ { name = "pytz" }, { name = "requests" }, ] [package.dev-dependencies] dev = [ { name = "docker" }, { name = "mock" }, { name = "pytest" }, { name = "pytest-cov" }, { name = "pytest-mock" }, { name = "pytest-timeout" }, { name = "pytest-xdist" }, { name = "requests-kerberos" }, { name = "ruff" }, ] docs = [ { name = "docutils", version = "0.21.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, { name = "docutils", version = "0.22.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, { name = "furo" }, { name = "myst-parser", version = "4.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, { name = "myst-parser", version = "5.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, { name = "pygments" }, { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, { name = "sphinx", version = "9.0.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, { name = "sphinx", version = "9.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, ] [package.metadata] requires-dist = [ { name = "pytz", specifier = ">=2014.4" }, { name = "requests", specifier = ">=2.33.0" }, ] [package.metadata.requires-dev] dev = [ { name = "docker", specifier = ">=6.0.0" }, { name = "mock", specifier = ">=5.1.0" }, { name = "pytest", specifier = ">=8.3.4" }, { name = "pytest-cov", specifier = ">=4.0.0" }, { name = "pytest-mock", specifier = ">=3.14.0" }, { name = "pytest-timeout", specifier = ">=2.3.0" }, { name = "pytest-xdist", specifier = ">=3.5.0" }, { name = "requests-kerberos", specifier = ">=0.15.0" }, { name = "ruff", specifier = ">=0.9.6" }, ] docs = [ { name = "docutils", specifier = ">=0.20.1" }, { name = "furo", specifier = ">=2024.8.6" }, { name = "myst-parser", specifier = ">=3.0.1" }, { name = "pygments", specifier = ">=2.20.0" }, { name = "sphinx", specifier = ">=7.1.2" }, ] [[package]] name = "jinja2" version = "3.1.6" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "markupsafe" }, ] sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, ] [[package]] name = "krb5" version = "0.9.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/15/15/55a01be5f1816fe6d7d36fec4c6b2cb6f5264d289a015322562c582a81b7/krb5-0.9.0.tar.gz", hash = "sha256:4cdd2c85ff4770108edaf48fedf19888cf956ff374e2e97e40f8412b048caee6", size = 236761, upload-time = "2025-11-25T18:53:46.997Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/7d/ed/473605378e398642d8f8262544774b6673178325e0166fe56ec7a7884d0a/krb5-0.9.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b1bde451ddb3a1c064be1da967fa32b1976a06ca43969e9d80ed8b26506ac4e3", size = 1021611, upload-time = "2025-11-25T18:53:36.057Z" }, { url = "https://files.pythonhosted.org/packages/b4/96/00a11ef3118690cf3b3c6554127e26c16006525bf34b0e22d6b27dca3dd9/krb5-0.9.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:6e612e763304bfe4f6845f581030502327da2d0a19efccc840d7c051448ee209", size = 1016809, upload-time = "2025-11-25T18:53:37.41Z" }, { url = "https://files.pythonhosted.org/packages/1b/4e/0a35ae4821ca5f0491844d9343c816883b3218a265a4a95ecec66e76f239/krb5-0.9.0-cp311-abi3-macosx_10_9_x86_64.whl", hash = "sha256:7a021e869833bfed44ed4c9dfefd25813fab40a381ad4482b79ea36744545f62", size = 913483, upload-time = "2025-11-25T18:53:38.966Z" }, { url = "https://files.pythonhosted.org/packages/41/32/d2a9d977d23425777c0c5722947e6f0bc80882c7de15038bd02f9269c01a/krb5-0.9.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:2bca06e7ce1551f3eb7f674508bc34d9f44fa1c9056f24120878ec9ab8e430cb", size = 939459, upload-time = "2025-11-25T18:53:40.597Z" }, { url = "https://files.pythonhosted.org/packages/7d/b9/fd0079f9208738bc09cf99208fc92cdf7d8b6a2a363af9a73256efa76399/krb5-0.9.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:ea2ee73bb5aa7818ce50895fb29c276c7fe478c6eda121a5b66b0cc45fe2d42e", size = 1090101, upload-time = "2025-11-25T18:53:41.937Z" }, { url = "https://files.pythonhosted.org/packages/ca/9f/df4e24995e0fea7792e8ab152124c65ac845e00ddfb4dd8f7d22907d122b/krb5-0.9.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f9da4558a47ec105a1a2c185bb4b2d1dd90b7c021e3116895171f913ef048d03", size = 1101333, upload-time = "2025-11-25T18:53:43.177Z" }, ] [[package]] name = "markdown-it-py" version = "3.0.0" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version < '3.11'", ] dependencies = [ { name = "mdurl", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/38/71/3b932df36c1a044d397a1f92d1cf91ee0a503d91e470cbd670aa66b07ed0/markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb", size = 74596, upload-time = "2023-06-03T06:41:14.443Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/42/d7/1ec15b46af6af88f19b8e5ffea08fa375d433c998b8a7639e76935c14f1f/markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1", size = 87528, upload-time = "2023-06-03T06:41:11.019Z" }, ] [[package]] name = "markdown-it-py" version = "4.0.0" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version >= '3.12'", "python_full_version == '3.11.*'", ] dependencies = [ { name = "mdurl", marker = "python_full_version >= '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" }, ] [[package]] name = "markupsafe" version = "3.0.3" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/e8/4b/3541d44f3937ba468b75da9eebcae497dcf67adb65caa16760b0a6807ebb/markupsafe-3.0.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2f981d352f04553a7171b8e44369f2af4055f888dfb147d55e42d29e29e74559", size = 11631, upload-time = "2025-09-27T18:36:05.558Z" }, { url = "https://files.pythonhosted.org/packages/98/1b/fbd8eed11021cabd9226c37342fa6ca4e8a98d8188a8d9b66740494960e4/markupsafe-3.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e1c1493fb6e50ab01d20a22826e57520f1284df32f2d8601fdd90b6304601419", size = 12057, upload-time = "2025-09-27T18:36:07.165Z" }, { url = "https://files.pythonhosted.org/packages/40/01/e560d658dc0bb8ab762670ece35281dec7b6c1b33f5fbc09ebb57a185519/markupsafe-3.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ba88449deb3de88bd40044603fafffb7bc2b055d626a330323a9ed736661695", size = 22050, upload-time = "2025-09-27T18:36:08.005Z" }, { url = "https://files.pythonhosted.org/packages/af/cd/ce6e848bbf2c32314c9b237839119c5a564a59725b53157c856e90937b7a/markupsafe-3.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f42d0984e947b8adf7dd6dde396e720934d12c506ce84eea8476409563607591", size = 20681, upload-time = "2025-09-27T18:36:08.881Z" }, { url = "https://files.pythonhosted.org/packages/c9/2a/b5c12c809f1c3045c4d580b035a743d12fcde53cf685dbc44660826308da/markupsafe-3.0.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c0c0b3ade1c0b13b936d7970b1d37a57acde9199dc2aecc4c336773e1d86049c", size = 20705, upload-time = "2025-09-27T18:36:10.131Z" }, { url = "https://files.pythonhosted.org/packages/cf/e3/9427a68c82728d0a88c50f890d0fc072a1484de2f3ac1ad0bfc1a7214fd5/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0303439a41979d9e74d18ff5e2dd8c43ed6c6001fd40e5bf2e43f7bd9bbc523f", size = 21524, upload-time = "2025-09-27T18:36:11.324Z" }, { url = "https://files.pythonhosted.org/packages/bc/36/23578f29e9e582a4d0278e009b38081dbe363c5e7165113fad546918a232/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:d2ee202e79d8ed691ceebae8e0486bd9a2cd4794cec4824e1c99b6f5009502f6", size = 20282, upload-time = "2025-09-27T18:36:12.573Z" }, { url = "https://files.pythonhosted.org/packages/56/21/dca11354e756ebd03e036bd8ad58d6d7168c80ce1fe5e75218e4945cbab7/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:177b5253b2834fe3678cb4a5f0059808258584c559193998be2601324fdeafb1", size = 20745, upload-time = "2025-09-27T18:36:13.504Z" }, { url = "https://files.pythonhosted.org/packages/87/99/faba9369a7ad6e4d10b6a5fbf71fa2a188fe4a593b15f0963b73859a1bbd/markupsafe-3.0.3-cp310-cp310-win32.whl", hash = "sha256:2a15a08b17dd94c53a1da0438822d70ebcd13f8c3a95abe3a9ef9f11a94830aa", size = 14571, upload-time = "2025-09-27T18:36:14.779Z" }, { url = "https://files.pythonhosted.org/packages/d6/25/55dc3ab959917602c96985cb1253efaa4ff42f71194bddeb61eb7278b8be/markupsafe-3.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:c4ffb7ebf07cfe8931028e3e4c85f0357459a3f9f9490886198848f4fa002ec8", size = 15056, upload-time = "2025-09-27T18:36:16.125Z" }, { url = "https://files.pythonhosted.org/packages/d0/9e/0a02226640c255d1da0b8d12e24ac2aa6734da68bff14c05dd53b94a0fc3/markupsafe-3.0.3-cp310-cp310-win_arm64.whl", hash = "sha256:e2103a929dfa2fcaf9bb4e7c091983a49c9ac3b19c9061b6d5427dd7d14d81a1", size = 13932, upload-time = "2025-09-27T18:36:17.311Z" }, { url = "https://files.pythonhosted.org/packages/08/db/fefacb2136439fc8dd20e797950e749aa1f4997ed584c62cfb8ef7c2be0e/markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad", size = 11631, upload-time = "2025-09-27T18:36:18.185Z" }, { url = "https://files.pythonhosted.org/packages/e1/2e/5898933336b61975ce9dc04decbc0a7f2fee78c30353c5efba7f2d6ff27a/markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a", size = 12058, upload-time = "2025-09-27T18:36:19.444Z" }, { url = "https://files.pythonhosted.org/packages/1d/09/adf2df3699d87d1d8184038df46a9c80d78c0148492323f4693df54e17bb/markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50", size = 24287, upload-time = "2025-09-27T18:36:20.768Z" }, { url = "https://files.pythonhosted.org/packages/30/ac/0273f6fcb5f42e314c6d8cd99effae6a5354604d461b8d392b5ec9530a54/markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf", size = 22940, upload-time = "2025-09-27T18:36:22.249Z" }, { url = "https://files.pythonhosted.org/packages/19/ae/31c1be199ef767124c042c6c3e904da327a2f7f0cd63a0337e1eca2967a8/markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f", size = 21887, upload-time = "2025-09-27T18:36:23.535Z" }, { url = "https://files.pythonhosted.org/packages/b2/76/7edcab99d5349a4532a459e1fe64f0b0467a3365056ae550d3bcf3f79e1e/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a", size = 23692, upload-time = "2025-09-27T18:36:24.823Z" }, { url = "https://files.pythonhosted.org/packages/a4/28/6e74cdd26d7514849143d69f0bf2399f929c37dc2b31e6829fd2045b2765/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115", size = 21471, upload-time = "2025-09-27T18:36:25.95Z" }, { url = "https://files.pythonhosted.org/packages/62/7e/a145f36a5c2945673e590850a6f8014318d5577ed7e5920a4b3448e0865d/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a", size = 22923, upload-time = "2025-09-27T18:36:27.109Z" }, { url = "https://files.pythonhosted.org/packages/0f/62/d9c46a7f5c9adbeeeda52f5b8d802e1094e9717705a645efc71b0913a0a8/markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19", size = 14572, upload-time = "2025-09-27T18:36:28.045Z" }, { url = "https://files.pythonhosted.org/packages/83/8a/4414c03d3f891739326e1783338e48fb49781cc915b2e0ee052aa490d586/markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01", size = 15077, upload-time = "2025-09-27T18:36:29.025Z" }, { url = "https://files.pythonhosted.org/packages/35/73/893072b42e6862f319b5207adc9ae06070f095b358655f077f69a35601f0/markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c", size = 13876, upload-time = "2025-09-27T18:36:29.954Z" }, { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, ] [[package]] name = "mdit-py-plugins" version = "0.5.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "markdown-it-py", version = "3.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, { name = "markdown-it-py", version = "4.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/b2/fd/a756d36c0bfba5f6e39a1cdbdbfdd448dc02692467d83816dff4592a1ebc/mdit_py_plugins-0.5.0.tar.gz", hash = "sha256:f4918cb50119f50446560513a8e311d574ff6aaed72606ddae6d35716fe809c6", size = 44655, upload-time = "2025-08-11T07:25:49.083Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/fb/86/dd6e5db36df29e76c7a7699123569a4a18c1623ce68d826ed96c62643cae/mdit_py_plugins-0.5.0-py3-none-any.whl", hash = "sha256:07a08422fc1936a5d26d146759e9155ea466e842f5ab2f7d2266dd084c8dab1f", size = 57205, upload-time = "2025-08-11T07:25:47.597Z" }, ] [[package]] name = "mdurl" version = "0.1.2" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, ] [[package]] name = "mock" version = "5.2.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/07/8c/14c2ae915e5f9dca5a22edd68b35be94400719ccfa068a03e0fb63d0f6f6/mock-5.2.0.tar.gz", hash = "sha256:4e460e818629b4b173f32d08bf30d3af8123afbb8e04bb5707a1fd4799e503f0", size = 92796, upload-time = "2025-03-03T12:31:42.911Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/bd/d9/617e6af809bf3a1d468e0d58c3997b1dc219a9a9202e650d30c2fc85d481/mock-5.2.0-py3-none-any.whl", hash = "sha256:7ba87f72ca0e915175596069dbbcc7c75af7b5e9b9bc107ad6349ede0819982f", size = 31617, upload-time = "2025-03-03T12:31:41.518Z" }, ] [[package]] name = "myst-parser" version = "4.0.1" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version < '3.11'", ] dependencies = [ { name = "docutils", version = "0.21.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, { name = "jinja2", marker = "python_full_version < '3.11'" }, { name = "markdown-it-py", version = "3.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, { name = "mdit-py-plugins", marker = "python_full_version < '3.11'" }, { name = "pyyaml", marker = "python_full_version < '3.11'" }, { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/66/a5/9626ba4f73555b3735ad86247a8077d4603aa8628537687c839ab08bfe44/myst_parser-4.0.1.tar.gz", hash = "sha256:5cfea715e4f3574138aecbf7d54132296bfd72bb614d31168f48c477a830a7c4", size = 93985, upload-time = "2025-02-12T10:53:03.833Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/5f/df/76d0321c3797b54b60fef9ec3bd6f4cfd124b9e422182156a1dd418722cf/myst_parser-4.0.1-py3-none-any.whl", hash = "sha256:9134e88959ec3b5780aedf8a99680ea242869d012e8821db3126d427edc9c95d", size = 84579, upload-time = "2025-02-12T10:53:02.078Z" }, ] [[package]] name = "myst-parser" version = "5.0.0" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version >= '3.12'", "python_full_version == '3.11.*'", ] dependencies = [ { name = "docutils", version = "0.22.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, { name = "jinja2", marker = "python_full_version >= '3.11'" }, { name = "markdown-it-py", version = "4.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, { name = "mdit-py-plugins", marker = "python_full_version >= '3.11'" }, { name = "pyyaml", marker = "python_full_version >= '3.11'" }, { name = "sphinx", version = "9.0.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, { name = "sphinx", version = "9.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/33/fa/7b45eef11b7971f0beb29d27b7bfe0d747d063aa29e170d9edd004733c8a/myst_parser-5.0.0.tar.gz", hash = "sha256:f6f231452c56e8baa662cc352c548158f6a16fcbd6e3800fc594978002b94f3a", size = 98535, upload-time = "2026-01-15T09:08:18.036Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/d3/ac/686789b9145413f1a61878c407210e41bfdb097976864e0913078b24098c/myst_parser-5.0.0-py3-none-any.whl", hash = "sha256:ab31e516024918296e169139072b81592336f2fef55b8986aa31c9f04b5f7211", size = 84533, upload-time = "2026-01-15T09:08:16.788Z" }, ] [[package]] name = "packaging" version = "26.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, ] [[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, upload-time = "2025-05-15T12:30:07.975Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, ] [[package]] name = "pycparser" version = "3.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, ] [[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, upload-time = "2026-03-29T13:29:33.898Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, ] [[package]] name = "pyspnego" version = "0.12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cryptography" }, { name = "sspilib", marker = "sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/7d/84/58577bd1b14293650879de0579ec263a1d8350f1d6d227226cf776b5a6a6/pyspnego-0.12.1.tar.gz", hash = "sha256:ff4fb6df38202a012ea2a0f43091ae9680878443f0ea61c9ea0e2e8152a4b810", size = 226027, upload-time = "2026-03-02T20:16:09.74Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/dd/9f/4da6a1b9611af2397289b77d6fd08f5fe8f1f34dabc3bf85b0638d655e64/pyspnego-0.12.1-py3-none-any.whl", hash = "sha256:7237cb47985ccf5da512106ddb2731e4f9cefec00991f76c054488eb95fb1a2d", size = 130247, upload-time = "2026-03-02T20:16:08.331Z" }, ] [package.optional-dependencies] kerberos = [ { name = "gssapi", marker = "sys_platform != 'win32'" }, { name = "krb5", marker = "sys_platform != 'win32'" }, ] [[package]] name = "pytest" version = "9.0.3" 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/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, ] [[package]] name = "pytest-cov" version = "7.1.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "coverage", extra = ["toml"] }, { name = "pluggy" }, { name = "pytest" }, ] sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" }, ] [[package]] name = "pytest-mock" version = "3.15.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pytest" }, ] sdist = { url = "https://files.pythonhosted.org/packages/68/14/eb014d26be205d38ad5ad20d9a80f7d201472e08167f0bb4361e251084a9/pytest_mock-3.15.1.tar.gz", hash = "sha256:1849a238f6f396da19762269de72cb1814ab44416fa73a8686deac10b0d87a0f", size = 34036, upload-time = "2025-09-16T16:37:27.081Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/5a/cc/06253936f4a7fa2e0f48dfe6d851d9c56df896a9ab09ac019d70b760619c/pytest_mock-3.15.1-py3-none-any.whl", hash = "sha256:0a25e2eb88fe5168d535041d09a4529a188176ae608a6d249ee65abc0949630d", size = 10095, upload-time = "2025-09-16T16:37:25.734Z" }, ] [[package]] name = "pytest-timeout" version = "2.4.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pytest" }, ] sdist = { url = "https://files.pythonhosted.org/packages/ac/82/4c9ecabab13363e72d880f2fb504c5f750433b2b6f16e99f4ec21ada284c/pytest_timeout-2.4.0.tar.gz", hash = "sha256:7e68e90b01f9eff71332b25001f85c75495fc4e3a836701876183c4bcfd0540a", size = 17973, upload-time = "2025-05-05T19:44:34.99Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/fa/b6/3127540ecdf1464a00e5a01ee60a1b09175f6913f0644ac748494d9c4b21/pytest_timeout-2.4.0-py3-none-any.whl", hash = "sha256:c42667e5cdadb151aeb5b26d114aff6bdf5a907f176a007a30b940d3d865b5c2", size = 14382, upload-time = "2025-05-05T19:44:33.502Z" }, ] [[package]] name = "pytest-xdist" version = "3.8.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "execnet" }, { name = "pytest" }, ] sdist = { url = "https://files.pythonhosted.org/packages/78/b4/439b179d1ff526791eb921115fca8e44e596a13efeda518b9d845a619450/pytest_xdist-3.8.0.tar.gz", hash = "sha256:7e578125ec9bc6050861aa93f2d59f1d8d085595d6551c2c90b6f4fad8d3a9f1", size = 88069, upload-time = "2025-07-01T13:30:59.346Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/ca/31/d4e37e9e550c2b92a9cbc2e4d0b7420a27224968580b5a447f420847c975/pytest_xdist-3.8.0-py3-none-any.whl", hash = "sha256:202ca578cfeb7370784a8c33d6d05bc6e13b4f25b5053c30a152269fd10f0b88", size = 46396, upload-time = "2025-07-01T13:30:56.632Z" }, ] [[package]] name = "pytz" version = "2026.1.post1" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/56/db/b8721d71d945e6a8ac63c0fc900b2067181dbb50805958d4d4661cf7d277/pytz-2026.1.post1.tar.gz", hash = "sha256:3378dde6a0c3d26719182142c56e60c7f9af7e968076f31aae569d72a0358ee1", size = 321088, upload-time = "2026-03-03T07:47:50.683Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/10/99/781fe0c827be2742bcc775efefccb3b048a3a9c6ce9aec0cbf4a101677e5/pytz-2026.1.post1-py2.py3-none-any.whl", hash = "sha256:f2fd16142fda348286a75e1a524be810bb05d444e5a081f37f7affc635035f7a", size = 510489, upload-time = "2026-03-03T07:47:49.167Z" }, ] [[package]] name = "pywin32" version = "311" source = { registry = "https://pypi.org/simple" } wheels = [ { url = "https://files.pythonhosted.org/packages/7b/40/44efbb0dfbd33aca6a6483191dae0716070ed99e2ecb0c53683f400a0b4f/pywin32-311-cp310-cp310-win32.whl", hash = "sha256:d03ff496d2a0cd4a5893504789d4a15399133fe82517455e78bad62efbb7f0a3", size = 8760432, upload-time = "2025-07-14T20:13:05.9Z" }, { url = "https://files.pythonhosted.org/packages/5e/bf/360243b1e953bd254a82f12653974be395ba880e7ec23e3731d9f73921cc/pywin32-311-cp310-cp310-win_amd64.whl", hash = "sha256:797c2772017851984b97180b0bebe4b620bb86328e8a884bb626156295a63b3b", size = 9590103, upload-time = "2025-07-14T20:13:07.698Z" }, { url = "https://files.pythonhosted.org/packages/57/38/d290720e6f138086fb3d5ffe0b6caa019a791dd57866940c82e4eeaf2012/pywin32-311-cp310-cp310-win_arm64.whl", hash = "sha256:0502d1facf1fed4839a9a51ccbcc63d952cf318f78ffc00a7e78528ac27d7a2b", size = 8778557, upload-time = "2025-07-14T20:13:11.11Z" }, { url = "https://files.pythonhosted.org/packages/7c/af/449a6a91e5d6db51420875c54f6aff7c97a86a3b13a0b4f1a5c13b988de3/pywin32-311-cp311-cp311-win32.whl", hash = "sha256:184eb5e436dea364dcd3d2316d577d625c0351bf237c4e9a5fabbcfa5a58b151", size = 8697031, upload-time = "2025-07-14T20:13:13.266Z" }, { url = "https://files.pythonhosted.org/packages/51/8f/9bb81dd5bb77d22243d33c8397f09377056d5c687aa6d4042bea7fbf8364/pywin32-311-cp311-cp311-win_amd64.whl", hash = "sha256:3ce80b34b22b17ccbd937a6e78e7225d80c52f5ab9940fe0506a1a16f3dab503", size = 9508308, upload-time = "2025-07-14T20:13:15.147Z" }, { url = "https://files.pythonhosted.org/packages/44/7b/9c2ab54f74a138c491aba1b1cd0795ba61f144c711daea84a88b63dc0f6c/pywin32-311-cp311-cp311-win_arm64.whl", hash = "sha256:a733f1388e1a842abb67ffa8e7aad0e70ac519e09b0f6a784e65a136ec7cefd2", size = 8703930, upload-time = "2025-07-14T20:13:16.945Z" }, { url = "https://files.pythonhosted.org/packages/e7/ab/01ea1943d4eba0f850c3c61e78e8dd59757ff815ff3ccd0a84de5f541f42/pywin32-311-cp312-cp312-win32.whl", hash = "sha256:750ec6e621af2b948540032557b10a2d43b0cee2ae9758c54154d711cc852d31", size = 8706543, upload-time = "2025-07-14T20:13:20.765Z" }, { url = "https://files.pythonhosted.org/packages/d1/a8/a0e8d07d4d051ec7502cd58b291ec98dcc0c3fff027caad0470b72cfcc2f/pywin32-311-cp312-cp312-win_amd64.whl", hash = "sha256:b8c095edad5c211ff31c05223658e71bf7116daa0ecf3ad85f3201ea3190d067", size = 9495040, upload-time = "2025-07-14T20:13:22.543Z" }, { url = "https://files.pythonhosted.org/packages/ba/3a/2ae996277b4b50f17d61f0603efd8253cb2d79cc7ae159468007b586396d/pywin32-311-cp312-cp312-win_arm64.whl", hash = "sha256:e286f46a9a39c4a18b319c28f59b61de793654af2f395c102b4f819e584b5852", size = 8710102, upload-time = "2025-07-14T20:13:24.682Z" }, { url = "https://files.pythonhosted.org/packages/a5/be/3fd5de0979fcb3994bfee0d65ed8ca9506a8a1260651b86174f6a86f52b3/pywin32-311-cp313-cp313-win32.whl", hash = "sha256:f95ba5a847cba10dd8c4d8fefa9f2a6cf283b8b88ed6178fa8a6c1ab16054d0d", size = 8705700, upload-time = "2025-07-14T20:13:26.471Z" }, { url = "https://files.pythonhosted.org/packages/e3/28/e0a1909523c6890208295a29e05c2adb2126364e289826c0a8bc7297bd5c/pywin32-311-cp313-cp313-win_amd64.whl", hash = "sha256:718a38f7e5b058e76aee1c56ddd06908116d35147e133427e59a3983f703a20d", size = 9494700, upload-time = "2025-07-14T20:13:28.243Z" }, { url = "https://files.pythonhosted.org/packages/04/bf/90339ac0f55726dce7d794e6d79a18a91265bdf3aa70b6b9ca52f35e022a/pywin32-311-cp313-cp313-win_arm64.whl", hash = "sha256:7b4075d959648406202d92a2310cb990fea19b535c7f4a78d3f5e10b926eeb8a", size = 8709318, upload-time = "2025-07-14T20:13:30.348Z" }, { url = "https://files.pythonhosted.org/packages/c9/31/097f2e132c4f16d99a22bfb777e0fd88bd8e1c634304e102f313af69ace5/pywin32-311-cp314-cp314-win32.whl", hash = "sha256:b7a2c10b93f8986666d0c803ee19b5990885872a7de910fc460f9b0c2fbf92ee", size = 8840714, upload-time = "2025-07-14T20:13:32.449Z" }, { url = "https://files.pythonhosted.org/packages/90/4b/07c77d8ba0e01349358082713400435347df8426208171ce297da32c313d/pywin32-311-cp314-cp314-win_amd64.whl", hash = "sha256:3aca44c046bd2ed8c90de9cb8427f581c479e594e99b5c0bb19b29c10fd6cb87", size = 9656800, upload-time = "2025-07-14T20:13:34.312Z" }, { url = "https://files.pythonhosted.org/packages/c0/d2/21af5c535501a7233e734b8af901574572da66fcc254cb35d0609c9080dd/pywin32-311-cp314-cp314-win_arm64.whl", hash = "sha256:a508e2d9025764a8270f93111a970e1d0fbfc33f4153b388bb649b7eec4f9b42", size = 8932540, upload-time = "2025-07-14T20:13:36.379Z" }, ] [[package]] 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, upload-time = "2025-09-25T21:33:16.546Z" } 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, upload-time = "2025-09-25T21:31:46.04Z" }, { 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, upload-time = "2025-09-25T21:31:47.706Z" }, { 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, upload-time = "2025-09-25T21:31:49.21Z" }, { 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, upload-time = "2025-09-25T21:31:50.735Z" }, { 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, upload-time = "2025-09-25T21:31:51.828Z" }, { 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, upload-time = "2025-09-25T21:31:53.282Z" }, { 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, upload-time = "2025-09-25T21:31:54.807Z" }, { url = "https://files.pythonhosted.org/packages/a3/00/531e92e88c00f4333ce359e50c19b8d1de9fe8d581b1534e35ccfbc5f393/pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e", size = 142415, upload-time = "2025-09-25T21:31:55.885Z" }, { url = "https://files.pythonhosted.org/packages/2a/fa/926c003379b19fca39dd4634818b00dec6c62d87faf628d1394e137354d4/pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c", size = 158561, upload-time = "2025-09-25T21:31:57.406Z" }, { 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, upload-time = "2025-09-25T21:31:58.655Z" }, { 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, upload-time = "2025-09-25T21:32:00.088Z" }, { 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, upload-time = "2025-09-25T21:32:01.31Z" }, { 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, upload-time = "2025-09-25T21:32:03.376Z" }, { 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, upload-time = "2025-09-25T21:32:04.553Z" }, { 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, upload-time = "2025-09-25T21:32:06.152Z" }, { 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, upload-time = "2025-09-25T21:32:07.367Z" }, { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, { 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, upload-time = "2025-09-25T21:32:11.445Z" }, { 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, upload-time = "2025-09-25T21:32:12.492Z" }, { 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, upload-time = "2025-09-25T21:32:13.652Z" }, { 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, upload-time = "2025-09-25T21:32:15.21Z" }, { 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, upload-time = "2025-09-25T21:32:16.431Z" }, { 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, upload-time = "2025-09-25T21:32:17.56Z" }, { 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, upload-time = "2025-09-25T21:32:18.834Z" }, { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, { 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, upload-time = "2025-09-25T21:32:23.673Z" }, { 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, upload-time = "2025-09-25T21:32:25.149Z" }, { 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, upload-time = "2025-09-25T21:32:26.575Z" }, { 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, upload-time = "2025-09-25T21:32:27.727Z" }, { 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, upload-time = "2025-09-25T21:32:28.878Z" }, { 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, upload-time = "2025-09-25T21:32:30.178Z" }, { 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, upload-time = "2025-09-25T21:32:31.353Z" }, { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, ] [[package]] name = "requests" version = "2.33.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "certifi" }, { name = "charset-normalizer" }, { name = "idna" }, { name = "urllib3" }, ] sdist = { url = "https://files.pythonhosted.org/packages/5f/a4/98b9c7c6428a668bf7e42ebb7c79d576a1c3c1e3ae2d47e674b468388871/requests-2.33.1.tar.gz", hash = "sha256:18817f8c57c6263968bc123d237e3b8b08ac046f5456bd1e307ee8f4250d3517", size = 134120, upload-time = "2026-03-30T16:09:15.531Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/d7/8e/7540e8a2036f79a125c1d2ebadf69ed7901608859186c856fa0388ef4197/requests-2.33.1-py3-none-any.whl", hash = "sha256:4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a", size = 64947, upload-time = "2026-03-30T16:09:13.83Z" }, ] [[package]] name = "requests-kerberos" version = "0.15.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cryptography" }, { name = "pyspnego", extra = ["kerberos"] }, { name = "requests" }, ] sdist = { url = "https://files.pythonhosted.org/packages/63/78/bedf4c6788a4502f8c8b6485a9a00b3006aaed34ebbccecc1b2265a3bc9f/requests_kerberos-0.15.0.tar.gz", hash = "sha256:437512e424413d8113181d696e56694ffa4259eb9a5fc4e803926963864eaf4e", size = 24410, upload-time = "2024-06-03T22:53:11.146Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/dd/3b/ecf902be8375f30f0d7829a8bc56795cd7b0f2599280cf73f988a2999322/requests_kerberos-0.15.0-py2.py3-none-any.whl", hash = "sha256:ba9b0980b8489c93bfb13854fd118834e576d6700bfea3745cb2e62278cd16a6", size = 12169, upload-time = "2024-06-03T22:53:09.67Z" }, ] [[package]] name = "roman-numerals" version = "4.1.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/ae/f9/41dc953bbeb056c17d5f7a519f50fdf010bd0553be2d630bc69d1e022703/roman_numerals-4.1.0.tar.gz", hash = "sha256:1af8b147eb1405d5839e78aeb93131690495fe9da5c91856cb33ad55a7f1e5b2", size = 9077, upload-time = "2025-12-17T18:25:34.381Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/04/54/6f679c435d28e0a568d8e8a7c0a93a09010818634c3c3907fc98d8983770/roman_numerals-4.1.0-py3-none-any.whl", hash = "sha256:647ba99caddc2cc1e55a51e4360689115551bf4476d90e8162cf8c345fe233c7", size = 7676, upload-time = "2025-12-17T18:25:33.098Z" }, ] [[package]] name = "ruff" version = "0.15.10" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/e7/d9/aa3f7d59a10ef6b14fe3431706f854dbf03c5976be614a9796d36326810c/ruff-0.15.10.tar.gz", hash = "sha256:d1f86e67ebfdef88e00faefa1552b5e510e1d35f3be7d423dc7e84e63788c94e", size = 4631728, upload-time = "2026-04-09T14:06:09.884Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/eb/00/a1c2fdc9939b2c03691edbda290afcd297f1f389196172826b03d6b6a595/ruff-0.15.10-py3-none-linux_armv6l.whl", hash = "sha256:0744e31482f8f7d0d10a11fcbf897af272fefdfcb10f5af907b18c2813ff4d5f", size = 10563362, upload-time = "2026-04-09T14:06:21.189Z" }, { url = "https://files.pythonhosted.org/packages/5c/15/006990029aea0bebe9d33c73c3e28c80c391ebdba408d1b08496f00d422d/ruff-0.15.10-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b1e7c16ea0ff5a53b7c2df52d947e685973049be1cdfe2b59a9c43601897b22e", size = 10951122, upload-time = "2026-04-09T14:06:02.236Z" }, { url = "https://files.pythonhosted.org/packages/f2/c0/4ac978fe874d0618c7da647862afe697b281c2806f13ce904ad652fa87e4/ruff-0.15.10-py3-none-macosx_11_0_arm64.whl", hash = "sha256:93cc06a19e5155b4441dd72808fdf84290d84ad8a39ca3b0f994363ade4cebb1", size = 10314005, upload-time = "2026-04-09T14:06:00.026Z" }, { url = "https://files.pythonhosted.org/packages/da/73/c209138a5c98c0d321266372fc4e33ad43d506d7e5dd817dd89b60a8548f/ruff-0.15.10-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:83e1dd04312997c99ea6965df66a14fb4f03ba978564574ffc68b0d61fd3989e", size = 10643450, upload-time = "2026-04-09T14:05:42.137Z" }, { url = "https://files.pythonhosted.org/packages/ec/76/0deec355d8ec10709653635b1f90856735302cb8e149acfdf6f82a5feb70/ruff-0.15.10-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8154d43684e4333360fedd11aaa40b1b08a4e37d8ffa9d95fee6fa5b37b6fab1", size = 10379597, upload-time = "2026-04-09T14:05:49.984Z" }, { url = "https://files.pythonhosted.org/packages/dc/be/86bba8fc8798c081e28a4b3bb6d143ccad3fd5f6f024f02002b8f08a9fa3/ruff-0.15.10-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8ab88715f3a6deb6bde6c227f3a123410bec7b855c3ae331b4c006189e895cef", size = 11146645, upload-time = "2026-04-09T14:06:12.246Z" }, { url = "https://files.pythonhosted.org/packages/a8/89/140025e65911b281c57be1d385ba1d932c2366ca88ae6663685aed8d4881/ruff-0.15.10-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a768ff5969b4f44c349d48edf4ab4f91eddb27fd9d77799598e130fb628aa158", size = 12030289, upload-time = "2026-04-09T14:06:04.776Z" }, { url = "https://files.pythonhosted.org/packages/88/de/ddacca9545a5e01332567db01d44bd8cf725f2db3b3d61a80550b48308ea/ruff-0.15.10-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0ee3ef42dab7078bda5ff6a1bcba8539e9857deb447132ad5566a038674540d0", size = 11496266, upload-time = "2026-04-09T14:05:55.485Z" }, { url = "https://files.pythonhosted.org/packages/bc/bb/7ddb00a83760ff4a83c4e2fc231fd63937cc7317c10c82f583302e0f6586/ruff-0.15.10-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51cb8cc943e891ba99989dd92d61e29b1d231e14811db9be6440ecf25d5c1609", size = 11256418, upload-time = "2026-04-09T14:05:57.69Z" }, { url = "https://files.pythonhosted.org/packages/dc/8d/55de0d35aacf6cd50b6ee91ee0f291672080021896543776f4170fc5c454/ruff-0.15.10-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:e59c9bdc056a320fb9ea1700a8d591718b8faf78af065484e801258d3a76bc3f", size = 11288416, upload-time = "2026-04-09T14:05:44.695Z" }, { url = "https://files.pythonhosted.org/packages/68/cf/9438b1a27426ec46a80e0a718093c7f958ef72f43eb3111862949ead3cc1/ruff-0.15.10-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:136c00ca2f47b0018b073f28cb5c1506642a830ea941a60354b0e8bc8076b151", size = 10621053, upload-time = "2026-04-09T14:05:52.782Z" }, { url = "https://files.pythonhosted.org/packages/4c/50/e29be6e2c135e9cd4cb15fbade49d6a2717e009dff3766dd080fcb82e251/ruff-0.15.10-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8b80a2f3c9c8a950d6237f2ca12b206bccff626139be9fa005f14feb881a1ae8", size = 10378302, upload-time = "2026-04-09T14:06:14.361Z" }, { url = "https://files.pythonhosted.org/packages/18/2f/e0b36a6f99c51bb89f3a30239bc7bf97e87a37ae80aa2d6542d6e5150364/ruff-0.15.10-py3-none-musllinux_1_2_i686.whl", hash = "sha256:e3e53c588164dc025b671c9df2462429d60357ea91af7e92e9d56c565a9f1b07", size = 10850074, upload-time = "2026-04-09T14:06:16.581Z" }, { url = "https://files.pythonhosted.org/packages/11/08/874da392558ce087a0f9b709dc6ec0d60cbc694c1c772dab8d5f31efe8cb/ruff-0.15.10-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:b0c52744cf9f143a393e284125d2576140b68264a93c6716464e129a3e9adb48", size = 11358051, upload-time = "2026-04-09T14:06:18.948Z" }, { url = "https://files.pythonhosted.org/packages/e4/46/602938f030adfa043e67112b73821024dc79f3ab4df5474c25fa4c1d2d14/ruff-0.15.10-py3-none-win32.whl", hash = "sha256:d4272e87e801e9a27a2e8df7b21011c909d9ddd82f4f3281d269b6ba19789ca5", size = 10588964, upload-time = "2026-04-09T14:06:07.14Z" }, { url = "https://files.pythonhosted.org/packages/25/b6/261225b875d7a13b33a6d02508c39c28450b2041bb01d0f7f1a83d569512/ruff-0.15.10-py3-none-win_amd64.whl", hash = "sha256:28cb32d53203242d403d819fd6983152489b12e4a3ae44993543d6fe62ab42ed", size = 11745044, upload-time = "2026-04-09T14:05:39.473Z" }, { url = "https://files.pythonhosted.org/packages/58/ed/dea90a65b7d9e69888890fb14c90d7f51bf0c1e82ad800aeb0160e4bacfd/ruff-0.15.10-py3-none-win_arm64.whl", hash = "sha256:601d1610a9e1f1c2165a4f561eeaa2e2ea1e97f3287c5aa258d3dab8b57c6188", size = 11035607, upload-time = "2026-04-09T14:05:47.593Z" }, ] [[package]] name = "snowballstemmer" version = "3.0.1" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/75/a7/9810d872919697c9d01295633f5d574fb416d47e535f258272ca1f01f447/snowballstemmer-3.0.1.tar.gz", hash = "sha256:6d5eeeec8e9f84d4d56b847692bacf79bc2c8e90c7f80ca4444ff8b6f2e52895", size = 105575, upload-time = "2025-05-09T16:34:51.843Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/c8/78/3565d011c61f5a43488987ee32b6f3f656e7f107ac2782dd57bdd7d91d9a/snowballstemmer-3.0.1-py3-none-any.whl", hash = "sha256:6cd7b3897da8d6c9ffb968a6781fa6532dce9c3618a4b127d920dab764a19064", size = 103274, upload-time = "2025-05-09T16:34:50.371Z" }, ] [[package]] name = "soupsieve" version = "2.8.3" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/7b/ae/2d9c981590ed9999a0d91755b47fc74f74de286b0f5cee14c9269041e6c4/soupsieve-2.8.3.tar.gz", hash = "sha256:3267f1eeea4251fb42728b6dfb746edc9acaffc4a45b27e19450b676586e8349", size = 118627, upload-time = "2026-01-20T04:27:02.457Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/46/2c/1462b1d0a634697ae9e55b3cecdcb64788e8b7d63f54d923fcd0bb140aed/soupsieve-2.8.3-py3-none-any.whl", hash = "sha256:ed64f2ba4eebeab06cc4962affce381647455978ffc1e36bb79a545b91f45a95", size = 37016, upload-time = "2026-01-20T04:27:01.012Z" }, ] [[package]] name = "sphinx" version = "8.1.3" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version < '3.11'", ] dependencies = [ { name = "alabaster", marker = "python_full_version < '3.11'" }, { name = "babel", marker = "python_full_version < '3.11'" }, { name = "colorama", marker = "python_full_version < '3.11' and sys_platform == 'win32'" }, { name = "docutils", version = "0.21.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, { name = "imagesize", marker = "python_full_version < '3.11'" }, { name = "jinja2", marker = "python_full_version < '3.11'" }, { name = "packaging", marker = "python_full_version < '3.11'" }, { name = "pygments", marker = "python_full_version < '3.11'" }, { name = "requests", marker = "python_full_version < '3.11'" }, { name = "snowballstemmer", marker = "python_full_version < '3.11'" }, { name = "sphinxcontrib-applehelp", marker = "python_full_version < '3.11'" }, { name = "sphinxcontrib-devhelp", marker = "python_full_version < '3.11'" }, { name = "sphinxcontrib-htmlhelp", marker = "python_full_version < '3.11'" }, { name = "sphinxcontrib-jsmath", marker = "python_full_version < '3.11'" }, { name = "sphinxcontrib-qthelp", marker = "python_full_version < '3.11'" }, { name = "sphinxcontrib-serializinghtml", marker = "python_full_version < '3.11'" }, { name = "tomli", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/be0b61178fe2cdcb67e2a92fc9ebb488e3c51c4f74a36a7824c0adf23425/sphinx-8.1.3.tar.gz", hash = "sha256:43c1911eecb0d3e161ad78611bc905d1ad0e523e4ddc202a58a821773dc4c927", size = 8184611, upload-time = "2024-10-13T20:27:13.93Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/26/60/1ddff83a56d33aaf6f10ec8ce84b4c007d9368b21008876fceda7e7381ef/sphinx-8.1.3-py3-none-any.whl", hash = "sha256:09719015511837b76bf6e03e42eb7595ac8c2e41eeb9c29c5b755c6b677992a2", size = 3487125, upload-time = "2024-10-13T20:27:10.448Z" }, ] [[package]] name = "sphinx" version = "9.0.4" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version == '3.11.*'", ] dependencies = [ { name = "alabaster", marker = "python_full_version == '3.11.*'" }, { name = "babel", marker = "python_full_version == '3.11.*'" }, { name = "colorama", marker = "python_full_version == '3.11.*' and sys_platform == 'win32'" }, { name = "docutils", version = "0.22.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, { name = "imagesize", marker = "python_full_version == '3.11.*'" }, { name = "jinja2", marker = "python_full_version == '3.11.*'" }, { name = "packaging", marker = "python_full_version == '3.11.*'" }, { name = "pygments", marker = "python_full_version == '3.11.*'" }, { name = "requests", marker = "python_full_version == '3.11.*'" }, { name = "roman-numerals", marker = "python_full_version == '3.11.*'" }, { name = "snowballstemmer", marker = "python_full_version == '3.11.*'" }, { name = "sphinxcontrib-applehelp", marker = "python_full_version == '3.11.*'" }, { name = "sphinxcontrib-devhelp", marker = "python_full_version == '3.11.*'" }, { name = "sphinxcontrib-htmlhelp", marker = "python_full_version == '3.11.*'" }, { name = "sphinxcontrib-jsmath", marker = "python_full_version == '3.11.*'" }, { name = "sphinxcontrib-qthelp", marker = "python_full_version == '3.11.*'" }, { name = "sphinxcontrib-serializinghtml", marker = "python_full_version == '3.11.*'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/42/50/a8c6ccc36d5eacdfd7913ddccd15a9cee03ecafc5ee2bc40e1f168d85022/sphinx-9.0.4.tar.gz", hash = "sha256:594ef59d042972abbc581d8baa577404abe4e6c3b04ef61bd7fc2acbd51f3fa3", size = 8710502, upload-time = "2025-12-04T07:45:27.343Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/c6/3f/4bbd76424c393caead2e1eb89777f575dee5c8653e2d4b6afd7a564f5974/sphinx-9.0.4-py3-none-any.whl", hash = "sha256:5bebc595a5e943ea248b99c13814c1c5e10b3ece718976824ffa7959ff95fffb", size = 3917713, upload-time = "2025-12-04T07:45:24.944Z" }, ] [[package]] name = "sphinx" version = "9.1.0" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version >= '3.12'", ] dependencies = [ { name = "alabaster", marker = "python_full_version >= '3.12'" }, { name = "babel", marker = "python_full_version >= '3.12'" }, { name = "colorama", marker = "python_full_version >= '3.12' and sys_platform == 'win32'" }, { name = "docutils", version = "0.22.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, { name = "imagesize", marker = "python_full_version >= '3.12'" }, { name = "jinja2", marker = "python_full_version >= '3.12'" }, { name = "packaging", marker = "python_full_version >= '3.12'" }, { name = "pygments", marker = "python_full_version >= '3.12'" }, { name = "requests", marker = "python_full_version >= '3.12'" }, { name = "roman-numerals", marker = "python_full_version >= '3.12'" }, { name = "snowballstemmer", marker = "python_full_version >= '3.12'" }, { name = "sphinxcontrib-applehelp", marker = "python_full_version >= '3.12'" }, { name = "sphinxcontrib-devhelp", marker = "python_full_version >= '3.12'" }, { name = "sphinxcontrib-htmlhelp", marker = "python_full_version >= '3.12'" }, { name = "sphinxcontrib-jsmath", marker = "python_full_version >= '3.12'" }, { name = "sphinxcontrib-qthelp", marker = "python_full_version >= '3.12'" }, { name = "sphinxcontrib-serializinghtml", marker = "python_full_version >= '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/cd/bd/f08eb0f4eed5c83f1ba2a3bd18f7745a2b1525fad70660a1c00224ec468a/sphinx-9.1.0.tar.gz", hash = "sha256:7741722357dd75f8190766926071fed3bdc211c74dd2d7d4df5404da95930ddb", size = 8718324, upload-time = "2025-12-31T15:09:27.646Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/73/f7/b1884cb3188ab181fc81fa00c266699dab600f927a964df02ec3d5d1916a/sphinx-9.1.0-py3-none-any.whl", hash = "sha256:c84fdd4e782504495fe4f2c0b3413d6c2bf388589bb352d439b2a3bb99991978", size = 3921742, upload-time = "2025-12-31T15:09:25.561Z" }, ] [[package]] name = "sphinx-basic-ng" version = "1.0.0b2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, { name = "sphinx", version = "9.0.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, { name = "sphinx", version = "9.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/98/0b/a866924ded68efec7a1759587a4e478aec7559d8165fac8b2ad1c0e774d6/sphinx_basic_ng-1.0.0b2.tar.gz", hash = "sha256:9ec55a47c90c8c002b5960c57492ec3021f5193cb26cebc2dc4ea226848651c9", size = 20736, upload-time = "2023-07-08T18:40:54.166Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/3c/dd/018ce05c532a22007ac58d4f45232514cd9d6dd0ee1dc374e309db830983/sphinx_basic_ng-1.0.0b2-py3-none-any.whl", hash = "sha256:eb09aedbabfb650607e9b4b68c9d240b90b1e1be221d6ad71d61c52e29f7932b", size = 22496, upload-time = "2023-07-08T18:40:52.659Z" }, ] [[package]] name = "sphinxcontrib-applehelp" version = "2.0.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/ba/6e/b837e84a1a704953c62ef8776d45c3e8d759876b4a84fe14eba2859106fe/sphinxcontrib_applehelp-2.0.0.tar.gz", hash = "sha256:2f29ef331735ce958efa4734873f084941970894c6090408b079c61b2e1c06d1", size = 20053, upload-time = "2024-07-29T01:09:00.465Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/5d/85/9ebeae2f76e9e77b952f4b274c27238156eae7979c5421fba91a28f4970d/sphinxcontrib_applehelp-2.0.0-py3-none-any.whl", hash = "sha256:4cd3f0ec4ac5dd9c17ec65e9ab272c9b867ea77425228e68ecf08d6b28ddbdb5", size = 119300, upload-time = "2024-07-29T01:08:58.99Z" }, ] [[package]] name = "sphinxcontrib-devhelp" version = "2.0.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/f6/d2/5beee64d3e4e747f316bae86b55943f51e82bb86ecd325883ef65741e7da/sphinxcontrib_devhelp-2.0.0.tar.gz", hash = "sha256:411f5d96d445d1d73bb5d52133377b4248ec79db5c793ce7dbe59e074b4dd1ad", size = 12967, upload-time = "2024-07-29T01:09:23.417Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/35/7a/987e583882f985fe4d7323774889ec58049171828b58c2217e7f79cdf44e/sphinxcontrib_devhelp-2.0.0-py3-none-any.whl", hash = "sha256:aefb8b83854e4b0998877524d1029fd3e6879210422ee3780459e28a1f03a8a2", size = 82530, upload-time = "2024-07-29T01:09:21.945Z" }, ] [[package]] name = "sphinxcontrib-htmlhelp" version = "2.1.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/43/93/983afd9aa001e5201eab16b5a444ed5b9b0a7a010541e0ddfbbfd0b2470c/sphinxcontrib_htmlhelp-2.1.0.tar.gz", hash = "sha256:c9e2916ace8aad64cc13a0d233ee22317f2b9025b9cf3295249fa985cc7082e9", size = 22617, upload-time = "2024-07-29T01:09:37.889Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/0a/7b/18a8c0bcec9182c05a0b3ec2a776bba4ead82750a55ff798e8d406dae604/sphinxcontrib_htmlhelp-2.1.0-py3-none-any.whl", hash = "sha256:166759820b47002d22914d64a075ce08f4c46818e17cfc9470a9786b759b19f8", size = 98705, upload-time = "2024-07-29T01:09:36.407Z" }, ] [[package]] name = "sphinxcontrib-jsmath" version = "1.0.1" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/b2/e8/9ed3830aeed71f17c026a07a5097edcf44b692850ef215b161b8ad875729/sphinxcontrib-jsmath-1.0.1.tar.gz", hash = "sha256:a9925e4a4587247ed2191a22df5f6970656cb8ca2bd6284309578f2153e0c4b8", size = 5787, upload-time = "2019-01-21T16:10:16.347Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/c2/42/4c8646762ee83602e3fb3fbe774c2fac12f317deb0b5dbeeedd2d3ba4b77/sphinxcontrib_jsmath-1.0.1-py2.py3-none-any.whl", hash = "sha256:2ec2eaebfb78f3f2078e73666b1415417a116cc848b72e5172e596c871103178", size = 5071, upload-time = "2019-01-21T16:10:14.333Z" }, ] [[package]] name = "sphinxcontrib-qthelp" version = "2.0.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/68/bc/9104308fc285eb3e0b31b67688235db556cd5b0ef31d96f30e45f2e51cae/sphinxcontrib_qthelp-2.0.0.tar.gz", hash = "sha256:4fe7d0ac8fc171045be623aba3e2a8f613f8682731f9153bb2e40ece16b9bbab", size = 17165, upload-time = "2024-07-29T01:09:56.435Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/27/83/859ecdd180cacc13b1f7e857abf8582a64552ea7a061057a6c716e790fce/sphinxcontrib_qthelp-2.0.0-py3-none-any.whl", hash = "sha256:b18a828cdba941ccd6ee8445dbe72ffa3ef8cbe7505d8cd1fa0d42d3f2d5f3eb", size = 88743, upload-time = "2024-07-29T01:09:54.885Z" }, ] [[package]] name = "sphinxcontrib-serializinghtml" version = "2.0.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/3b/44/6716b257b0aa6bfd51a1b31665d1c205fb12cb5ad56de752dfa15657de2f/sphinxcontrib_serializinghtml-2.0.0.tar.gz", hash = "sha256:e9d912827f872c029017a53f0ef2180b327c3f7fd23c87229f7a8e8b70031d4d", size = 16080, upload-time = "2024-07-29T01:10:09.332Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/52/a7/d2782e4e3f77c8450f727ba74a8f12756d5ba823d81b941f1b04da9d033a/sphinxcontrib_serializinghtml-2.0.0-py3-none-any.whl", hash = "sha256:6e2cb0eef194e10c27ec0023bfeb25badbbb5868244cf5bc5bdc04e4464bf331", size = 92072, upload-time = "2024-07-29T01:10:08.203Z" }, ] [[package]] name = "sspilib" version = "0.5.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/a7/e6/d0d74b18bed8c16949fddc0401005c072947ae7bf1bab982ed28f9ebc2d8/sspilib-0.5.0.tar.gz", hash = "sha256:b62f7f2602aa1add0505eee2417e2df24421224cb411e53bf3ae42a71b62fe98", size = 59920, upload-time = "2025-12-03T00:31:05.564Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/6d/cb/7cc967b48d182cb012229ccc9f9e3fd5e245b7f0c80667297ddded580877/sspilib-0.5.0-cp310-cp310-win32.whl", hash = "sha256:8dab68e994d24a08f854d36ac96409b3b8cc03fdebc590925f76f9d733c3a902", size = 475882, upload-time = "2025-12-03T00:30:23.403Z" }, { url = "https://files.pythonhosted.org/packages/7b/0d/7746ade4e3c4dba6c6d9b2afe3c3903f4bcab2da6b300b5d81afee089196/sspilib-0.5.0-cp310-cp310-win_amd64.whl", hash = "sha256:9e1947df07110ee1861009fc117bd089a7710403f3f5c488fb52a6e00b7c5b84", size = 563068, upload-time = "2025-12-03T00:30:24.605Z" }, { url = "https://files.pythonhosted.org/packages/8d/fb/6821418037e9d78179153e770e2b0280956f28f4bf51069dcbcc0348505d/sspilib-0.5.0-cp310-cp310-win_arm64.whl", hash = "sha256:28d0eb944f7ff70bc99fe729d06fa230aec1649c5bc216809e359cd0c77d4840", size = 486370, upload-time = "2025-12-03T00:30:26.21Z" }, { url = "https://files.pythonhosted.org/packages/ce/1b/dd9066491168933b0f7ab6e396ac58cc024c8954e95264c38e3dc9363d7c/sspilib-0.5.0-cp311-abi3-win32.whl", hash = "sha256:fcb57b41b3200ef2e6e8846e2a13799d20b35b796267f2f75cc65e3883e8eeb6", size = 451246, upload-time = "2025-12-03T00:30:36.685Z" }, { url = "https://files.pythonhosted.org/packages/17/6a/a11abf90172ff580ac2f9ade3496d868e05e851c4ecf487dd5baeb966b1d/sspilib-0.5.0-cp311-abi3-win_amd64.whl", hash = "sha256:ca2a21a4e90db563c2cec639c66b3a29ea53129a0c55ff1e4154a02937f6bd45", size = 540777, upload-time = "2025-12-03T00:30:38.44Z" }, { url = "https://files.pythonhosted.org/packages/45/05/983876d281b9e7926f1c9126e72de8bd5928b1de45433163f54d4e217502/sspilib-0.5.0-cp311-abi3-win_arm64.whl", hash = "sha256:6893bad16f122fc3c4bd908461b9728694465c05ca97c22f7e2094791c4ee3cb", size = 470353, upload-time = "2025-12-03T00:30:39.63Z" }, { url = "https://files.pythonhosted.org/packages/43/f8/34e8e86883054b961c2eb88a5b42b89b2bf975723b1acca090966c2d03ff/sspilib-0.5.0-cp314-cp314t-win32.whl", hash = "sha256:9dad272abf3f4cf0bf95d495075d2987f6ba1fb300f8d603661ccac07d11272f", size = 567408, upload-time = "2025-12-03T00:30:48.723Z" }, { url = "https://files.pythonhosted.org/packages/c4/d8/8c4ba75f925fd9651cb855c47e0e67931a175d6fd41e569193a8d58133ac/sspilib-0.5.0-cp314-cp314t-win_amd64.whl", hash = "sha256:7d7724d5dbb31f68e62465863dfb862fe2793281ce40d0c8f2dc60c8f07998f2", size = 690291, upload-time = "2025-12-03T00:30:49.929Z" }, { url = "https://files.pythonhosted.org/packages/74/c3/07af17b6fcc2b02af294a8817e30441a502880a04c8d60be2d71e0a1eacc/sspilib-0.5.0-cp314-cp314t-win_arm64.whl", hash = "sha256:8ce23ec740dee025136370ed4ae64b7d1535368321049ef960012a57c93ebe15", size = 534304, upload-time = "2025-12-03T00:30:51.348Z" }, ] [[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, upload-time = "2026-03-25T20:22:03.828Z" } 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, upload-time = "2026-03-25T20:21:10.473Z" }, { 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, upload-time = "2026-03-25T20:21:12.036Z" }, { 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, upload-time = "2026-03-25T20:21:13.098Z" }, { 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, upload-time = "2026-03-25T20:21:14.569Z" }, { 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, upload-time = "2026-03-25T20:21:15.712Z" }, { 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, upload-time = "2026-03-25T20:21:17.001Z" }, { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" }, { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" }, { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" }, { 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, upload-time = "2026-03-25T20:21:21.626Z" }, { 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, upload-time = "2026-03-25T20:21:23.002Z" }, { 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, upload-time = "2026-03-25T20:21:24.04Z" }, { 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, upload-time = "2026-03-25T20:21:25.177Z" }, { 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, upload-time = "2026-03-25T20:21:26.364Z" }, { 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, upload-time = "2026-03-25T20:21:27.46Z" }, { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" }, { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" }, { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" }, { 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, upload-time = "2026-03-25T20:21:31.65Z" }, { 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, upload-time = "2026-03-25T20:21:33.028Z" }, { 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, upload-time = "2026-03-25T20:21:34.51Z" }, { 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, upload-time = "2026-03-25T20:21:36.012Z" }, { 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, upload-time = "2026-03-25T20:21:37.136Z" }, { 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, upload-time = "2026-03-25T20:21:38.298Z" }, { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" }, { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" }, { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" }, { 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, upload-time = "2026-03-25T20:21:42.23Z" }, { 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, upload-time = "2026-03-25T20:21:43.386Z" }, { 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, upload-time = "2026-03-25T20:21:44.474Z" }, { 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, upload-time = "2026-03-25T20:21:45.62Z" }, { 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, upload-time = "2026-03-25T20:21:46.937Z" }, { 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, upload-time = "2026-03-25T20:21:48.467Z" }, { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" }, { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" }, { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" }, { 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, upload-time = "2026-03-25T20:21:52.543Z" }, { 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, upload-time = "2026-03-25T20:21:53.674Z" }, { 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, upload-time = "2026-03-25T20:21:55.129Z" }, { 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, upload-time = "2026-03-25T20:21:56.228Z" }, { 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, upload-time = "2026-03-25T20:21:57.653Z" }, { 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, upload-time = "2026-03-25T20:21:59.089Z" }, { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" }, { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" }, { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" }, { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, ] [[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, upload-time = "2025-08-25T13:49:26.313Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, ] [[package]] name = "urllib3" version = "2.6.3" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, ] jenkinsapi-0.3.23/examples/__init__.py0000644000000000000000000000000013615410400014567 0ustar00jenkinsapi-0.3.23/examples/addjob.xml0000644000000000000000000000101613615410400014433 0ustar00 false true false false false false jenkinsapi-0.3.23/examples/how_to/add_command.py0000644000000000000000000000213413615410400016567 0ustar00""" This example shows how to add new command to "Shell" build step """ import xml.etree.ElementTree as et from jenkinsapi.jenkins import Jenkins J = Jenkins("http://localhost:8080") EMPTY_JOB_CONFIG = """ jkkjjk false true false false false false """ jobname = "foo_job" new_job = J.create_job(jobname, EMPTY_JOB_CONFIG) new_conf = new_job.get_config() root = et.fromstring(new_conf.strip()) builders = root.find("builders") shell = et.SubElement(builders, "hudson.tasks.Shell") command = et.SubElement(shell, "command") command.text = "ls" print(et.tostring(root)) J[jobname].update_config(et.tostring(root)) jenkinsapi-0.3.23/examples/how_to/create_a_job.py0000644000000000000000000000101713615410400016735 0ustar00""" This example shows how to create job from XML file and how to delete job """ from pathlib import Path from jenkinsapi.jenkins import Jenkins jenkins = Jenkins("http://localhost:8080") job_name = "foo_job2" xml = Path("examples/addjob.xml").read_text() print(xml) job = jenkins.create_job(jobname=job_name, xml=xml) # Get job from Jenkins by job name my_job = jenkins[job_name] print(my_job) # Delete job using method in Jenkins class # # Another way is to use: # # del jenkins[job_name] jenkins.delete_job(job_name) jenkinsapi-0.3.23/examples/how_to/create_credentials.py0000644000000000000000000000376613615410400020175 0ustar00""" This example shows how to create credentials """ import logging from jenkinsapi.jenkins import Jenkins from jenkinsapi.credential import UsernamePasswordCredential, SSHKeyCredential log_level = getattr(logging, "DEBUG") logging.basicConfig(level=log_level) logger = logging.getLogger() jenkins_url = "http://localhost:8080/" jenkins = Jenkins(jenkins_url) # Get a list of all global credentials creds = jenkins.credentials logging.info(jenkins.credentials.keys()) # Create username and password credential creds_description1 = "My_username_credential" cred_dict = { "description": creds_description1, "userName": "userName", "password": "password", } creds[creds_description1] = UsernamePasswordCredential(cred_dict) # Create ssh key credential that uses private key as a value # In jenkins credential dialog you need to paste credential # In your code it is adviced to read it from file # For simplicity of this example reading key from file is not shown here def get_private_key_from_file(): return "-----BEGIN RSA PRIVATE KEY-----" my_private_key = get_private_key_from_file() creds_description2 = "My_ssh_cred1" cred_dict = { "description": creds_description2, "userName": "userName", "passphrase": "", "private_key": my_private_key, } creds[creds_description2] = SSHKeyCredential(cred_dict) # Create ssh key credential that uses private key from path on Jenkins server my_private_key = "/home/jenkins/.ssh/special_key" creds_description3 = "My_ssh_cred2" cred_dict = { "description": creds_description3, "userName": "userName", "passphrase": "", "private_key": my_private_key, } creds[creds_description3] = SSHKeyCredential(cred_dict) # Remove credentials # We use credential description to find specific credential. This is the only # way to get specific credential from Jenkins via REST API del creds[creds_description1] del creds[creds_description2] del creds[creds_description3] # Remove all credentials for cred_descr in creds.keys(): del creds[cred_descr] jenkinsapi-0.3.23/examples/how_to/create_nested_views.py0000644000000000000000000000413113615410400020362 0ustar00""" How to create nested views using NestedViews Jenkins plugin This example requires NestedViews plugin to be installed in Jenkins You need to have at least one job in your Jenkins to see views """ import logging from pathlib import Path from jenkinsapi.views import Views from jenkinsapi.jenkins import Jenkins log_level = getattr(logging, "DEBUG") logging.basicConfig(level=log_level) logger = logging.getLogger() jenkins_url = "http://127.0.0.1:8080/" jenkins = Jenkins(jenkins_url) job_name = "foo_job2" xml = Path("examples/addjob.xml").read_text() j = jenkins.create_job(jobname=job_name, xml=xml) # Create ListView in main view logger.info("Attempting to create new nested view") top_view = jenkins.views.create("TopView", Views.NESTED_VIEW) logger.info("top_view is %s", top_view) if top_view is None: logger.error("View was not created") else: logger.info("View has been created") print("top_view.views=", top_view.views.keys()) logger.info("Attempting to create view inside nested view") sub_view = top_view.views.create("SubView") if sub_view is None: logger.info("View was not created") else: logger.error("View has been created") logger.info("Attempting to delete sub_view") del top_view.views["SubView"] if "SubView" in top_view.views: logger.error("SubView was not deleted") else: logger.info("SubView has been deleted") # Another way of creating sub view # This way sub view will have jobs in it logger.info("Attempting to create view with jobs inside nested view") top_view.views["SubView"] = job_name if "SubView" not in top_view.views: logger.error("View was not created") else: logger.info("View has been created") logger.info("Attempting to delete sub_view") del top_view.views["SubView"] if "SubView" in top_view.views: logger.error("SubView was not deleted") else: logger.info("SubView has been deleted") logger.info("Attempting to delete top view") del jenkins.views["TopView"] if "TopView" not in jenkins.views: logger.info("View has been deleted") else: logger.error("View was not deleted") # Delete job that we created jenkins.delete_job(job_name) jenkinsapi-0.3.23/examples/how_to/create_slave.py0000644000000000000000000000453013615410400017000 0ustar00""" How to create slaves/nodes """ import logging import requests from jenkinsapi.jenkins import Jenkins from jenkinsapi.utils.requester import Requester requests.packages.urllib3.disable_warnings() log_level = getattr(logging, "DEBUG") logging.basicConfig(level=log_level) logger = logging.getLogger() jenkins_url = "http://localhost:8080/" username = "default_user" # In case Jenkins requires authentication password = "default_password" jenkins = Jenkins( jenkins_url, requester=Requester( username, password, baseurl=jenkins_url, ssl_verify=False ), ) # Create JNLP(Java Webstart) slave node_dict = { "num_executors": 1, # Number of executors "node_description": "Test JNLP Node", # Just a user friendly text "remote_fs": "/tmp", # Remote workspace location "labels": "my_new_node", # Space separated labels string "exclusive": True, # Only run jobs assigned to it } new_jnlp_node = jenkins.nodes.create_node("My new webstart node", node_dict) node_dict = { "num_executors": 1, "node_description": "Test SSH Node", "remote_fs": "/tmp", "labels": "new_node", "exclusive": True, "host": "localhost", # Remote hostname "port": 22, # Remote post, usually 22 "credential_description": "localhost cred", # Credential to use # [Mandatory for SSH node!] # (see Credentials example) "jvm_options": "-Xmx2000M", # JVM parameters "java_path": "/bin/java", # Path to java "prefix_start_slave_cmd": "", "suffix_start_slave_cmd": "", "max_num_retries": 0, "retry_wait_time": 0, "retention": "OnDemand", # Change to 'Always' for # immediate slave launch "ondemand_delay": 1, "ondemand_idle_delay": 5, "env": [ # Environment variables {"key": "TEST", "value": "VALUE"}, {"key": "TEST2", "value": "value2"}, ], } new_ssh_node = jenkins.nodes.create_node("My new SSH node", node_dict) # Take this slave offline if new_ssh_node.is_online(): new_ssh_node.toggle_temporarily_offline() # Take this slave back online new_ssh_node.toggle_temporarily_offline() # Get a list of all slave names slave_names = jenkins.nodes.keys() # Get Node object my_node = jenkins.nodes["My new SSH node"] # Take this slave offline my_node.set_offline() # Delete slaves del jenkins.nodes["My new webstart node"] del jenkins.nodes["My new SSH node"] jenkinsapi-0.3.23/examples/how_to/create_views.py0000644000000000000000000000454413615410400017030 0ustar00""" How to create views """ import logging from pathlib import Path from jenkinsapi.jenkins import Jenkins logging.basicConfig(level=logging.INFO) logger = logging.getLogger() jenkins_url = "http://localhost:8080/" jenkins = Jenkins(jenkins_url, lazy=True) # Create ListView in main view logger.info("Attempting to create new view") test_view_name = "SimpleListView" # Views object appears as a dictionary of views if test_view_name not in jenkins.views: new_view = jenkins.views.create(test_view_name) if new_view is None: logger.error("View %s was not created", test_view_name) else: logger.info( "View %s has been created: %s", new_view.name, new_view.baseurl ) else: logger.info("View %s already exists", test_view_name) # No error is raised if view already exists logger.info("Attempting to create view that already exists") my_view = jenkins.views.create(test_view_name) logger.info("Create job and assign it to a view") job_name = "foo_job2" xml = Path("examples/addjob.xml").read_text() my_job = jenkins.create_job(jobname=job_name, xml=xml) # add_job supports two parameters: job_name and job object # passing job object will remove verification calls to Jenkins my_view.add_job(job_name, my_job) assert len(my_view) == 1 logger.info("Attempting to delete view that already exists") del jenkins.views[test_view_name] if test_view_name in jenkins.views: logger.error("View was not deleted") else: logger.info("View has been deleted") # No error will be raised when attempting to remove non-existing view logger.info("Attempting to delete view that does not exist") del jenkins.views[test_view_name] # Create CategorizedJobsView config = """ .dev. Development .hml. Homologation """ view = jenkins.views.create( "My categorized jobs view", jenkins.views.CATEGORIZED_VIEW, config=config ) jenkinsapi-0.3.23/examples/how_to/delete_all_the_nodes_except_master.py0000644000000000000000000000060013615410400023402 0ustar00""" How to delete slaves/nodes """ import logging from jenkinsapi.jenkins import Jenkins logging.basicConfig() j = Jenkins("http://localhost:8080") for node_id, _ in j.get_nodes().items(): if node_id != "master": print(node_id) j.delete_node(node_id) # Alternative way - this method will not delete 'master' for node in j.nodes.keys(): del j.nodes[node] jenkinsapi-0.3.23/examples/how_to/get_config.py0000644000000000000000000000041213615410400016442 0ustar00""" An example of how to use JenkinsAPI to fetch the config XML of a job. """ from jenkinsapi.jenkins import Jenkins jenkins = Jenkins("http://localhost:8080") jobName = jenkins.keys()[0] # get the first job config = jenkins[jobName].get_config() print(config) jenkinsapi-0.3.23/examples/how_to/get_plugin_information.py0000644000000000000000000000035313615410400021104 0ustar00""" Get information about currently installed plugins """ from jenkinsapi.jenkins import Jenkins plugin_name = "subversion" jenkins = Jenkins("http://localhost:8080") plugin = jenkins.get_plugins()[plugin_name] print(repr(plugin)) jenkinsapi-0.3.23/examples/how_to/get_version_info_from_last_good_build.py0000644000000000000000000000036313615410400024137 0ustar00""" Extract version information from the latest build. """ from jenkinsapi.jenkins import Jenkins job_name = "foo" jenkins = Jenkins("http://localhost:8080") job = jenkins[job_name] lgb = job.get_last_good_build() print(lgb.get_revision()) jenkinsapi-0.3.23/examples/how_to/query_a_build.py0000644000000000000000000000046513615410400017172 0ustar00""" How to get build from job and query that build """ from jenkinsapi.jenkins import Jenkins jenkins = Jenkins("http://localhost:8080") # Print all jobs in Jenkins print(jenkins.items()) job = jenkins.get_job("foo") build = job.get_last_build() print(build) mjn = build.get_master_job_name() print(mjn) jenkinsapi-0.3.23/examples/how_to/readme.rst0000644000000000000000000000313113615410400015754 0ustar00"How To..." examples ==================== This directory contains a set of examples or recipes for common tasks in JenkinsAPI. ========================================= ================================================== Example Description ----------------------------------------- -------------------------------------------------- add_command.py create new job and then add shell build step to it create_a_job.py create new job create_credentials.py create new credential create_nested_views.py create nested views using Nested Views Jenkins plugin create_slave.py create jnlp ans ssh slave create_views.py create views, assign and delete jobs in views delete_all_the_nodes.py delete all slaves except master get_config.py get job configuration XML get_plugin_infomation.py show information about plugin get_version_info_from_last_good_build.py get Git revision from last successful build query_a_build.py get build information search_artifact_by_regexp.py search for job artifacts using regular expression search_artifacts.py search for artifacts start_parameterized_build.py start a build with parameters use_crumbs.py how to work with CSRF protection enabled in Jenkins ========================================= ================================================== jenkinsapi-0.3.23/examples/how_to/search_artifact_by_regexp.py0000644000000000000000000000050213615410400021524 0ustar00""" Search for job artifacts using regexp """ import re from jenkinsapi.api import search_artifact_by_regexp jenkinsurl = "http://localhost:8080" jobid = "foo" artifact_regexp = re.compile(r"test1\.txt") # A file name I want. result = search_artifact_by_regexp(jenkinsurl, jobid, artifact_regexp) print((repr(result))) jenkinsapi-0.3.23/examples/how_to/search_artifacts.py0000644000000000000000000000045113615410400017646 0ustar00""" Search for job artifacts """ from jenkinsapi.api import search_artifacts jenkinsurl = "http://localhost:8080" jobid = "foo" # I need a build that contains all of these artifact_ids = ["test1.txt", "test2.txt"] result = search_artifacts(jenkinsurl, jobid, artifact_ids) print((repr(result))) jenkinsapi-0.3.23/examples/how_to/start_parameterized_build.py0000644000000000000000000000106713615410400021575 0ustar00""" Start a Parameterized Build """ from jenkinsapi.jenkins import Jenkins jenkins = Jenkins("http://localhost:8080") params = {"VERSION": "1.2.3", "PYTHON_VER": "2.7"} # This will start the job in non-blocking manner jenkins.build_job("foo", params) # This will start the job and will return a QueueItem object which # can be used to get build results job = jenkins["foo"] qi = job.invoke(build_params=params) # Block this script until build is finished if qi.is_queued() or qi.is_running(): qi.block_until_complete() build = qi.get_build() print(build) jenkinsapi-0.3.23/examples/how_to/use_crumbs.py0000644000000000000000000000044613615410400016514 0ustar00""" Example of using CrumbRequester - when CSRF protection is enabled in Jenkins """ from jenkinsapi.jenkins import Jenkins jenkins = Jenkins( "http://localhost:8080", username="admin", password="password", use_crumb=True, ) for job_name in jenkins.jobs: print(job_name) jenkinsapi-0.3.23/examples/low_level/copy_a_job.py0000644000000000000000000000121713615410400017137 0ustar00""" A lower-level implementation of copying a job in Jenkins """ import requests from pathlib import Path from jenkinsapi.jenkins import Jenkins from jenkinsapi_tests.test_utils.random_strings import random_string J = Jenkins("http://localhost:8080") jobName = random_string() jobName2 = "%s_2" % jobName url = "http://localhost:8080/createItem?from=%s&name=%s&mode=copy" % ( jobName, jobName2, ) xml = Path("examples/addjob.xml").read_text() j = J.create_job(jobname=jobName, xml=xml) h = {"Content-Type": "application/x-www-form-urlencoded"} response = requests.post(url, data="dysjsjsjs", headers=h) print(response.text.encode("UTF-8")) jenkinsapi-0.3.23/examples/low_level/create_a_view_low_level.py0000644000000000000000000000110013615410400021667 0ustar00""" A low level example: This is how JenkinsAPI creates views """ import json import requests url = "http://localhost:8080/createView" str_view_name = "blahblah123" params = {} # {'name': str_view_name} headers = {"Content-Type": "application/x-www-form-urlencoded"} data = { "name": str_view_name, "mode": "hudson.model.ListView", "Submit": "OK", "json": json.dumps( {"name": str_view_name, "mode": "hudson.model.ListView"} ), } # Try 1 result = requests.post(url, params=params, data=data, headers=headers) print(result.text.encode("UTF-8")) jenkinsapi-0.3.23/examples/low_level/example_param_build.py0000644000000000000000000000101013615410400021014 0ustar00import json import requests def foo(): """ A low level example of how JenkinsAPI runs a parameterized build """ toJson = {"parameter": [{"name": "B", "value": "xyz"}]} url = "http://localhost:8080/job/ddd/build" # url = 'http://localhost:8000' headers = {"Content-Type": "application/x-www-form-urlencoded"} form = {"json": json.dumps(toJson)} response = requests.post(url, data=form, headers=headers) print(response.text.encode("UTF-8")) if __name__ == "__main__": foo() jenkinsapi-0.3.23/examples/low_level/login_with_auth.py0000644000000000000000000000031713615410400020217 0ustar00""" A lower level example of how we login with authentication """ from jenkinsapi import jenkins J = jenkins.Jenkins("http://localhost:8080", username="sal", password="foobar") J.poll() print(J.items()) jenkinsapi-0.3.23/examples/low_level/post_watcher.py0000644000000000000000000000261513615410400017540 0ustar00""" Save this file as server.py >>> python server.py 0.0.0.0 8001 serving on 0.0.0.0:8001 or simply >>> python server.py Serving on localhost:8000 You can use this to test GET and POST methods. """ import http.server as SimpleHTTPServer import socketserver import logging import cgi PORT = 8081 # <-- change this to be the actual port you want to run on INTERFACE = "localhost" class ServerHandler(SimpleHTTPServer.SimpleHTTPRequestHandler): def do_GET(self): logging.warning("======= GET STARTED =======") logging.warning(self.headers) SimpleHTTPServer.SimpleHTTPRequestHandler.do_GET(self) def do_POST(self): logging.warning("======= POST STARTED =======") logging.warning(self.headers) form = cgi.FieldStorage( fp=self.rfile, headers=self.headers, environ={ "REQUEST_METHOD": "POST", "CONTENT_TYPE": self.headers["Content-Type"], }, ) logging.warning("======= POST VALUES =======") for item in form.list: logging.warning(item) logging.warning("\n") SimpleHTTPServer.SimpleHTTPRequestHandler.do_GET(self) Handler = ServerHandler httpd = socketserver.TCPServer(("", PORT), Handler) print( "Serving at: http://%(interface)s:%(port)s" % dict(interface=INTERFACE or "localhost", port=PORT) ) httpd.serve_forever() jenkinsapi-0.3.23/examples/low_level/readme.rst0000644000000000000000000000057413615410400016455 0ustar00Low-Level Examples ================== These examoples are intended to explain how JenkinsAPI performs certain functions. While developing JenkinsAPI I created a number of small scripts like these in order to figure out the correct way to communicate. Ive retained a number of these as they provide some insights into how the various interfaces that Jenkins provides can be used. jenkinsapi-0.3.23/jenkinsapi/.gitignore0000644000000000000000000000001513615410400014771 0ustar00/__pycache__ jenkinsapi-0.3.23/jenkinsapi/__init__.py0000644000000000000000000000377413615410400015131 0ustar00""" About this library ================== Jenkins is the market leading continuous integration system, originally created by Kohsuke Kawaguchi. This API makes Jenkins even easier to use by providing an easy to use conventional python interface. Jenkins (and It's predecessor Hudson) are fantastic projects - but they are somewhat Java-centric. Thankfully the designers have provided an excellent and complete REST interface. This library wraps up that interface as more conventional python objects in order to make most Jenkins oriented tasks simpler. This library can help you: * Query the test-results of a completed build * Get a objects representing the latest builds of a job * Search for artifacts by simple criteria * Block until jobs are complete * Install artifacts to custom-specified directory structures * username/password auth support for jenkins instances with auth turned on * Ability to search for builds by subversion revision * Ability to add/remove/query jenkins slaves Installing JenkinsAPI ===================== pip install jenkinsapi Project Authors =============== * Salim Fadhley (sal@stodge.org) * Ramon van Alteren (ramon@vanalteren.nl) * Ruslan Lutsenko (ruslan.lutcenko@gmail.com) * Aleksey Maksimov * Clinton Steiner Current code lives on github: https://github.com/pycontribs/jenkinsapi """ from importlib.metadata import version from jenkinsapi import ( # Modules command_line, utils, # Files api, artifact, build, config, constants, custom_exceptions, fingerprint, executors, executor, jenkins, jenkinsbase, job, node, result_set, result, view, ) __all__ = [ "command_line", "utils", "api", "artifact", "build", "config", "constants", "custom_exceptions", "executors", "executor", "fingerprint", "jenkins", "jenkinsbase", "job", "node", "result_set", "result", "view", ] __docformat__ = "epytext" __version__ = version("jenkinsapi") jenkinsapi-0.3.23/jenkinsapi/api.py0000644000000000000000000002264713615410400014143 0ustar00""" This module is a collection of helpful, high-level functions for automating common tasks. Many of these functions were designed to be exposed to the command-line, hence they have simple string arguments. """ import os import re import time import logging from typing import List, Dict from urllib.parse import urlparse from jenkinsapi import constants from jenkinsapi.artifact import Artifact from jenkinsapi.jenkins import Jenkins from jenkinsapi.view import View from jenkinsapi.job import Job from jenkinsapi.build import Build from jenkinsapi.custom_exceptions import ArtifactsMissing, TimeOut, BadURL from jenkinsapi.result_set import ResultSet log: logging.Logger = logging.getLogger(__name__) def get_latest_test_results( jenkinsurl: str, jobname: str, username: str = "", password: str = "", ssl_verify: bool = True, ) -> ResultSet: """ A convenience function to fetch down the very latest test results from a jenkins job. """ latestbuild: Build = get_latest_build( jenkinsurl, jobname, username=username, password=password, ssl_verify=ssl_verify, ) return latestbuild.get_resultset() def get_latest_build( jenkinsurl: str, jobname: str, username: str = "", password: str = "", ssl_verify: bool = True, ) -> Build: """ A convenience function to fetch down the very latest test results from a jenkins job. """ jenkinsci: Jenkins = Jenkins( jenkinsurl, username=username, password=password, ssl_verify=ssl_verify ) job: Job = jenkinsci[jobname] return job.get_last_build() def get_latest_complete_build( jenkinsurl: str, jobname: str, username: str = "", password: str = "", ssl_verify: bool = True, ) -> Build: """ A convenience function to fetch down the very latest test results from a jenkins job. """ jenkinsci: Jenkins = Jenkins( jenkinsurl, username=username, password=password, ssl_verify=ssl_verify ) job: Job = jenkinsci[jobname] return job.get_last_completed_build() def get_build( jenkinsurl: str, jobname: str, build_no: int, username: str = "", password: str = "", ssl_verify: bool = True, ) -> Build: """ A convenience function to fetch down the test results from a jenkins job by build number. """ jenkinsci = Jenkins( jenkinsurl, username=username, password=password, ssl_verify=ssl_verify ) job = jenkinsci[jobname] return job.get_build(build_no) def get_artifacts( jenkinsurl: str, jobname: str, build_no: int, username: str = "", password: str = "", ssl_verify: bool = True, ): """ Find all the artifacts for the latest build of a job. """ jenkinsci = Jenkins( jenkinsurl, username=username, password=password, ssl_verify=ssl_verify ) job = jenkinsci[jobname] if build_no: build = job.get_build(build_no) else: build = job.get_last_good_build() artifacts = build.get_artifact_dict() log.info( msg=f"Found {len(artifacts.keys())} \ artifacts in '{jobname}[{build_no}]" ) return artifacts def search_artifacts( jenkinsurl: str, jobname: str, artifact_ids=None, username: str = "", password: str = "", ssl_verify: bool = True, ): """ Search the entire history of a jenkins job for a list of artifact names. If same_build is true then ensure that all artifacts come from the same build of the job """ if not artifact_ids: return [] jenkinsci = Jenkins( jenkinsurl, username=username, password=password, ssl_verify=ssl_verify ) job = jenkinsci[jobname] build_ids = job.get_build_ids() missing_artifacts = set() for build_id in build_ids: build = job.get_build(build_id) artifacts = build.get_artifact_dict() if set(artifact_ids).issubset(set(artifacts.keys())): return dict((a, artifacts[a]) for a in artifact_ids) missing_artifacts = set(artifact_ids) - set(artifacts.keys()) log.debug( msg="Artifacts %s missing from %s #%i" % (", ".join(missing_artifacts), jobname, build_id) ) raise ArtifactsMissing(missing_artifacts) def grab_artifact( jenkinsurl: str, jobname: str, artifactid, targetdir: str, username: str = "", password: str = "", strict_validation: bool = True, ssl_verify: bool = True, ) -> None: """ Convenience method to find the latest good version of an artifact and save it to a target directory. Directory is made automatically if not exists. """ artifacts = get_artifacts( jenkinsurl, jobname, artifactid, username=username, password=password, ssl_verify=ssl_verify, ) artifact = artifacts[artifactid] if not os.path.exists(targetdir): os.makedirs(targetdir) artifact.save_to_dir(targetdir, strict_validation) def block_until_complete( jenkinsurl: str, jobs: List[str], maxwait: int = 12000, interval: int = 30, raise_on_timeout: bool = True, username: str = "", password: str = "", ssl_verify: bool = True, ) -> None: """ Wait until all of the jobs in the list are complete. """ assert maxwait > 0 assert maxwait > interval assert interval > 0 report: str = "" obj_jenkins: Jenkins = Jenkins( jenkinsurl, username=username, password=password, ssl_verify=ssl_verify ) obj_jobs: List[Job] = [obj_jenkins[jid] for jid in jobs] for time_left in range(maxwait, 0, -interval): still_running = [j for j in obj_jobs if j.is_queued_or_running()] if not still_running: return report = ", ".join('"%s"' % str(a) for a in still_running) log.warning( "Waiting for jobs %s to complete. Will wait another %is", report, time_left, ) time.sleep(interval) if raise_on_timeout: # noinspection PyUnboundLocalVariable raise TimeOut( "Waited too long for these jobs to complete: %s" % report ) def get_view_from_url( url: str, username: str = "", password: str = "", ssl_verify: bool = True ) -> View: """ Factory method """ matched = constants.RE_SPLIT_VIEW_URL.search(url) if not matched: raise BadURL("Cannot parse URL %s" % url) jenkinsurl, view_name = matched.groups() jenkinsci = Jenkins( jenkinsurl, username=username, password=password, ssl_verify=ssl_verify ) return jenkinsci.views[view_name] def get_nested_view_from_url( url: str, username: str = "", password: str = "", ssl_verify: bool = True ) -> View: """ Returns View based on provided URL. Convenient for nested views. """ matched = constants.RE_SPLIT_VIEW_URL.search(url) if not matched: raise BadURL("Cannot parse URL %s" % url) jenkinsci = Jenkins( matched.group(0), username=username, password=password, ssl_verify=ssl_verify, ) return jenkinsci.get_view_by_url(url) def install_artifacts( artifacts, dirstruct: Dict[str, str], installdir: str, basestaticurl: str, strict_validation: bool = False, ): """ Install the artifacts. """ assert basestaticurl.endswith("/"), "Basestaticurl should end with /" installed = [] for reldir, artifactnames in dirstruct.items(): destdir = os.path.join(installdir, reldir) if not os.path.exists(destdir): log.warning("Making install directory %s", destdir) os.makedirs(destdir) else: assert os.path.isdir(destdir) for artifactname in artifactnames: destpath = os.path.abspath(os.path.join(destdir, artifactname)) if artifactname in artifacts.keys(): # The artifact must be loaded from jenkins theartifact = artifacts[artifactname] else: # It's probably a static file, # we can get it from the static collection staticurl = urlparse.urljoin(basestaticurl, artifactname) theartifact = Artifact(artifactname, staticurl, None) theartifact.save(destpath, strict_validation) installed.append(destpath) return installed def search_artifact_by_regexp( jenkinsurl: str, jobname: str, artifactRegExp: re.Pattern, username: str = "", password: str = "", ssl_verify: bool = True, ) -> Artifact: """ Search the entire history of a Jenkins job for a build which has an artifact whose name matches a supplied regular expression. Return only that artifact. @param jenkinsurl: The base URL of the jenkins server @param jobid: The name of the job we are to search through @param artifactRegExp: A compiled regular expression object (not a re-string) @param username: Jenkins login user name, optional @param password: Jenkins login password, optional """ job = Jenkins( jenkinsurl, username=username, password=password, ssl_verify=ssl_verify ) j = job[jobname] build_ids = j.get_build_ids() for build_id in build_ids: build = j.get_build(build_id) artifacts = build.get_artifact_dict() for name, art in artifacts.items(): md_match = artifactRegExp.search(name) if md_match: return art raise ArtifactsMissing() jenkinsapi-0.3.23/jenkinsapi/artifact.py0000644000000000000000000001216413615410400015160 0ustar00""" Artifacts can be used to represent data created as a side-effect of running a Jenkins build. Artifacts are files which are associated with a single build. A build can have any number of artifacts associated with it. This module provides a class called Artifact which allows you to download objects from the server and also access them as a stream. """ from __future__ import annotations import os import logging import hashlib from typing import Any, Literal from jenkinsapi.fingerprint import Fingerprint from jenkinsapi.custom_exceptions import ArtifactBroken log = logging.getLogger(__name__) class Artifact(object): """ Represents a single Jenkins artifact, usually some kind of file generated as a by-product of executing a Jenkins build. """ def __init__( self, filename: str, url: str, build: "Build", relative_path: str | None = None, ) -> None: self.filename: str = filename self.url: str = url self.build: "Build" = build self.relative_path: str | None = relative_path def save(self, fspath: str, strict_validation: bool = False) -> str: """ Save the artifact to an explicit path. The containing directory must exist. Returns a reference to the file which has just been writen to. :param fspath: full pathname including the filename, str :return: filepath """ log.info(msg="Saving artifact @ %s to %s" % (self.url, fspath)) if not fspath.endswith(self.filename): log.warning( "Attempt to change the filename of artifact %s on save.", self.filename, ) if os.path.exists(fspath): if self.build: try: if self._verify_download(fspath, strict_validation): log.info( "Local copy of %s is already up to date.", self.filename, ) return fspath except ArtifactBroken: log.warning("Jenkins artifact could not be identified.") else: log.info( "This file did not originate from Jenkins, " "so cannot check." ) else: log.info("Local file is missing, downloading new.") filepath = self._do_download(fspath) self._verify_download(filepath, strict_validation) return fspath def get_jenkins_obj(self) -> Jenkins: return self.build.get_jenkins_obj() def get_data(self) -> Any: """ Grab the text of the artifact """ response = self.get_jenkins_obj().requester.get_and_confirm_status( self.url ) return response.content def _do_download(self, fspath: str) -> str: """ Download the the artifact to a path. """ data = self.get_jenkins_obj().requester.get_and_confirm_status( self.url, stream=True ) with open(fspath, "wb") as out: for chunk in data.iter_content(chunk_size=1024): out.write(chunk) return fspath def _verify_download(self, fspath, strict_validation) -> Literal[True]: """ Verify that a downloaded object has a valid fingerprint. Returns True if the fingerprint is valid, raises an exception if the fingerprint is invalid. """ local_md5 = self._md5sum(fspath) baseurl = self.build.job.jenkins.baseurl fp = Fingerprint(baseurl, local_md5, self.build.job.jenkins) valid = fp.validate_for_build( self.filename, self.build.job.get_full_name(), self.build.buildno ) if not valid or (fp.unknown and strict_validation): # strict = 404 as invalid raise ArtifactBroken( "Artifact %s seems to be broken, check %s" % (local_md5, baseurl) ) return True def _md5sum(self, fspath: str, chunksize: int = 2**20) -> str: """ A MD5 hashing function intended to produce the same results as that used by Jenkins. """ md5 = hashlib.md5() with open(fspath, "rb") as f: for chunk in iter(lambda: f.read(chunksize), ""): if chunk: md5.update(chunk) else: break return md5.hexdigest() def save_to_dir( self, dirpath: str, strict_validation: bool = False ) -> str: """ Save the artifact to a folder. The containing directory must exist, but use the artifact's default filename. """ assert os.path.exists(dirpath) assert os.path.isdir(dirpath) outputfilepath: str = os.path.join(dirpath, self.filename) return self.save(outputfilepath, strict_validation) def __repr__(self) -> str: """ Produce a handy repr-string. """ return """<%s.%s %s>""" % ( self.__class__.__module__, self.__class__.__name__, self.url, ) jenkinsapi-0.3.23/jenkinsapi/build.py0000644000000000000000000005214213615410400014462 0ustar00""" A Jenkins build represents a single execution of a Jenkins Job. Builds can be thought of as the second level of the Jenkins hierarchy beneath Jobs. Builds can have state, such as whether they are running or not. They can also have outcomes, such as whether they passed or failed. Build objects can be associated with Results and Artifacts. """ from __future__ import annotations import time import logging import warnings import datetime from time import sleep from typing import Iterator, List, Dict, Any import pytz from jenkinsapi import config from jenkinsapi.artifact import Artifact # from jenkinsapi.job import Job from jenkinsapi.result_set import ResultSet from jenkinsapi.jenkinsbase import JenkinsBase from jenkinsapi.constants import STATUS_SUCCESS from jenkinsapi.custom_exceptions import NoResults from jenkinsapi.custom_exceptions import JenkinsAPIException from urllib.parse import quote from requests import HTTPError log = logging.getLogger(__name__) class Build(JenkinsBase): """ Represents a Jenkins build, executed in context of a job. """ STR_TOTALCOUNT = "totalCount" STR_TPL_NOTESTS_ERR = ( "%s has status %s, and does not have any test results" ) def __init__( self, url: str, buildno: int, job: "Job", depth: int = 1 ) -> None: """ depth=1 is for backward compatibility consideration About depth, the deeper it is, the more build data you get back. If depth=0 is sufficient for you, don't go up to 1. For more information, see https://www.jenkins.io/doc/book/using/remote-access-api/#RemoteaccessAPI-Depthcontrol """ self.buildno: int = buildno self.job: "Job" = job self.depth = depth JenkinsBase.__init__(self, url) def _poll(self, tree=None): # For builds we need more information for downstream and # upstream builds so we override the poll to get at the extra # data for build objects url = self.python_api_url(self.baseurl) return self.get_data(url, params={"depth": self.depth}, tree=tree) def __str__(self) -> str: return self._data["fullDisplayName"] @property def name(self): return str(self) def get_description(self) -> str: return self._data["description"] def get_number(self) -> int: return self._data["number"] def get_status(self) -> str: return self._data["result"] def get_slave(self) -> str: return self._data["builtOn"] def get_revision(self) -> str: return getattr(self, f"_get_{self._get_vcs()}_rev", lambda: "")() def get_revision_branch(self) -> str: return getattr( self, f"_get_{self._get_vcs()}_rev_branch", lambda: "" )() def get_repo_url(self) -> str: return getattr(self, f"_get_{self._get_vcs()}_repo_url", lambda: "")() def get_params(self) -> dict[str, str]: """ Return a dictionary of params names and their values, or an empty dictionary if no parameters are returned. """ # This is what a parameter action looks like: # {'_class': 'hudson.model.ParametersAction', 'parameters': [ # {'_class': 'hudson.model.StringParameterValue', # 'value': '12', # 'name': 'FOO_BAR_BAZ'}]} actions = self._data.get("actions") if actions: parameters = {} for elem in actions: if elem.get("_class") == "hudson.model.ParametersAction": parameters = elem.get("parameters", {}) break return {pair["name"]: pair.get("value") for pair in parameters} return {} def get_changeset_items(self): """ Returns a list of changeSet items. Each item has structure as in following example: { "affectedPaths": [ "content/rcm/v00-rcm-xccdf.xml" ], "author" : { "absoluteUrl": "http://jenkins_url/user/username79", "fullName": "username" }, "commitId": "3097", "timestamp": 1414398423091, "date": "2014-10-27T08:27:03.091288Z", "msg": "commit message", "paths": [{ "editType": "edit", "file": "/some/path/of/changed_file" }], "revision": 3097, "user": "username" } """ if "changeSet" in self._data: if "items" in self._data["changeSet"]: return self._data["changeSet"]["items"] elif "changeSets" in self._data: if "items" in self._data["changeSets"]: return self._data["changeSets"]["items"] return [] def _get_vcs(self) -> str: """ Returns a string VCS. By default, 'git' will be used. """ vcs = "git" if "changeSet" in self._data and "kind" in self._data["changeSet"]: vcs = self._data["changeSet"]["kind"] or "git" elif "changeSets" in self._data and "kind" in self._data["changeSets"]: vcs = self._data["changeSets"]["kind"] or "git" return vcs def _get_git_rev(self) -> str | None: # Sometimes we have None as part of actions. Filter those actions # which have lastBuiltRevision in them _actions = [ x for x in self._data["actions"] if x and "lastBuiltRevision" in x ] if _actions: return _actions[0]["lastBuiltRevision"]["SHA1"] return None def _get_git_rev_branch(self) -> str: # Sometimes we have None as part of actions. Filter those actions # which have lastBuiltRevision in them _actions = [ x for x in self._data["actions"] if x and "lastBuiltRevision" in x ] return _actions[0]["lastBuiltRevision"]["branch"] def _get_git_repo_url(self) -> str: # Sometimes we have None as part of actions. Filter those actions # which have lastBuiltRevision in them _actions = [ x for x in self._data["actions"] if x and "lastBuiltRevision" in x ] # old Jenkins version have key remoteUrl v/s the new version # has a list remoteUrls result = _actions[0].get("remoteUrls", _actions[0].get("remoteUrl")) if isinstance(result, list): result = ",".join(result) return result def get_duration(self) -> datetime.timedelta: return datetime.timedelta(milliseconds=self._data["duration"]) def get_build_url(self) -> str: return self._data["url"] def get_artifacts(self) -> Iterator[Artifact]: data = self.poll(tree="artifacts[relativePath,fileName]") for afinfo in data["artifacts"]: url = "%s/artifact/%s" % ( self.baseurl, quote(afinfo["relativePath"]), ) af = Artifact( afinfo["fileName"], url, self, relative_path=afinfo["relativePath"], ) yield af def get_artifact_dict(self) -> dict[str, Artifact]: return {af.relative_path: af for af in self.get_artifacts()} def get_upstream_job_name(self) -> str | None: """ Get the upstream job name if it exist, None otherwise :return: String or None """ try: return self.get_actions()["causes"][0]["upstreamProject"] except KeyError: return None def get_upstream_job(self) -> Job | None: """ Get the upstream job object if it exist, None otherwise :return: Job or None """ if self.get_upstream_job_name(): return self.get_jenkins_obj().get_job(self.get_upstream_job_name()) return None def get_upstream_build_number(self) -> int | None: """ Get the upstream build number if it exist, None otherwise :return: int or None """ try: return int(self.get_actions()["causes"][0]["upstreamBuild"]) except KeyError: return None def get_upstream_build(self) -> "Build" | None: """ Get the upstream build if it exist, None otherwise :return Build or None """ upstream_job: "Job" = self.get_upstream_job() if upstream_job: return upstream_job.get_build(self.get_upstream_build_number()) return None def get_master_job_name(self) -> str | None: """ Get the master job name if it exist, None otherwise :return: String or None """ try: return self.get_actions()["parameters"][0]["value"] except KeyError: return None def get_master_job(self) -> Job | None: """ Get the master job object if it exist, None otherwise :return: Job or None """ if self.get_master_job_name(): return self.get_jenkins_obj().get_job(self.get_master_job_name()) return None def get_master_build_number(self) -> int | None: """ Get the master build number if it exist, None otherwise :return: int or None """ try: return int(self.get_actions()["parameters"][1]["value"]) except KeyError: return None def get_master_build(self) -> "Build" | None: """ Get the master build if it exist, None otherwise :return Build or None """ master_job: Job | None = self.get_master_job() if master_job: return master_job.get_build(self.get_master_build_number()) return None def get_downstream_jobs(self) -> List[Job]: """ Get the downstream jobs for this build :return List of jobs or None """ downstream_jobs: List[Job] = [] try: for job_name in self.get_downstream_job_names(): downstream_jobs.append( self.get_jenkins_obj().get_job(job_name) ) return downstream_jobs except (IndexError, KeyError): return [] def get_downstream_job_names(self) -> List[str]: """ Get the downstream job names for this build :return List of string or None """ downstream_job_names: List[str] = self.job.get_downstream_job_names() downstream_names: List[str] = [] try: fingerprints = self._data["fingerprint"] for fingerprint in fingerprints: for job_usage in fingerprint["usage"]: if job_usage["name"] in downstream_job_names: downstream_names.append(job_usage["name"]) return downstream_names except (IndexError, KeyError): return [] def get_downstream_builds(self) -> List["Build"]: """ Get the downstream builds for this build :return List of Build or None """ downstream_job_names: List[str] = self.get_downstream_job_names() downstream_builds: List[Build] = [] try: # pylint: disable=R1702 fingerprints = self._data["fingerprint"] for fingerprint in fingerprints: for job_usage in fingerprint["usage"]: if job_usage["name"] in downstream_job_names: job = self.get_jenkins_obj().get_job(job_usage["name"]) for job_range in job_usage["ranges"]["ranges"]: for build_id in range( job_range["start"], job_range["end"] ): downstream_builds.append( job.get_build(build_id) ) return downstream_builds except (IndexError, KeyError): return [] def get_matrix_runs(self) -> Iterator["Build"]: """ For a matrix job, get the individual builds for each matrix configuration :return: Generator of Build """ if "runs" in self._data: for rinfo in self._data["runs"]: number: int = rinfo["number"] if number == self._data["number"]: yield Build(rinfo["url"], number, self.job) def is_running(self) -> bool: """ Return a bool if running. """ data = self.poll(tree="building") return data.get("building", False) def block(self) -> None: while self.is_running(): time.sleep(1) def is_good(self) -> bool: """ Return a bool, true if the build was good. If the build is still running, return False. """ if self.is_running(): return False self._data = self._poll() return self._data["result"] == STATUS_SUCCESS def block_until_complete(self, delay: int = 15) -> None: count: int = 0 while self.is_running(): total_wait: int = delay * count log.info( msg="Waited %is for %s #%s to complete" % (total_wait, self.job.name, self.name) ) sleep(delay) count += 1 def get_jenkins_obj(self) -> "Jenkins": return self.job.get_jenkins_obj() def get_result_url(self) -> str: """ Return the URL for the object which provides the job's result summary. """ url_tpl: str = r"%stestReport/%s" return url_tpl % (self._data["url"], config.JENKINS_API) def get_resultset(self) -> ResultSet: """ Obtain detailed results for this build. Raises NoResults if the build has no results. :return: ResultSet """ result_url: str = self.get_result_url() if self.STR_TOTALCOUNT not in self.get_actions(): raise NoResults( "%s does not have any published results" % str(self) ) buildstatus: str = self.get_status() if not self.get_actions()[self.STR_TOTALCOUNT]: raise NoResults( self.STR_TPL_NOTESTS_ERR % (str(self), buildstatus) ) return ResultSet(result_url, build=self) def has_resultset(self) -> bool: """ Return a boolean, true if a result set is available. false if not. """ return self.STR_TOTALCOUNT in self.get_actions() def get_actions(self) -> Dict[str, Any]: all_actions: Dict[str, Any] = {} for dct_action in self._data["actions"]: if dct_action is None: continue all_actions.update(dct_action) return all_actions def get_causes(self) -> List[str]: """ Returns a list of causes. There can be multiple causes lists and some of the can be empty. For instance, when a build is manually aborted, Jenkins could add an empty causes list to the actions dict. Empty ones are ignored. """ all_causes: List[str] = [] for dct_action in self._data["actions"]: if dct_action is None: continue if "causes" in dct_action and dct_action["causes"]: all_causes.extend(dct_action["causes"]) return all_causes def get_timestamp(self) -> datetime.datetime: """ Returns build timestamp in UTC """ # Java timestamps are given in miliseconds since the epoch start! naive_timestamp = datetime.datetime( *time.gmtime(self._data["timestamp"] / 1000.0)[:6] ) return pytz.utc.localize(naive_timestamp) def get_console(self) -> str: """ Return the current state of the text console. """ url: str = "%s/consoleText" % self.baseurl resp = self.job.jenkins.requester.get_url(url) content: Any = resp.content # This check was made for Python 3.x # In this version content is a bytes string # By contract this function must return string if isinstance(content, str): return content elif isinstance(content, bytes): return content.decode(resp.encoding or "ISO-8859-1") else: raise JenkinsAPIException("Unknown content type for console") def stream_logs(self, interval=0) -> Iterator[str]: """ Return generator which streams parts of text console. """ url: str = "%s/logText/progressiveText" % self.baseurl size: int = 0 more_data: bool = True while more_data: resp = self.job.jenkins.requester.get_url( url, params={"start": size} ) content = resp.content if content: if isinstance(content, str): yield content elif isinstance(content, bytes): yield content.decode(resp.encoding or "ISO-8859-1") else: raise JenkinsAPIException( "Unknown content type for console" ) size = resp.headers["X-Text-Size"] more_data = resp.headers.get("X-More-Data") sleep(interval) def get_estimated_duration(self) -> int | None: """ Return the estimated build duration (in seconds) or none. """ try: eta_ms = self._data["estimatedDuration"] return max(0, eta_ms / 1000.0) except KeyError: return None def stop(self) -> bool: """ Stops the build execution if it's running :return: boolean True if succeeded False otherwis """ if self.is_running(): url: str = "%s/stop" % self.baseurl # Starting from Jenkins 2.7 stop function sometimes breaks # on redirect to job page. Call to stop works fine, and # we don't need to have job page here. self.job.jenkins.requester.post_and_confirm_status( url, data="", valid=[ 302, 200, 500, ], ) return True return False def get_env_vars(self) -> Dict[str, str]: """ Return the environment variables. This method is using the Environment Injector plugin: https://wiki.jenkins-ci.org/display/JENKINS/EnvInject+Plugin """ url: str = self.python_api_url("%s/injectedEnvVars" % self.baseurl) try: data = self.get_data(url, params={"depth": self.depth}) except HTTPError as ex: warnings.warn( "Make sure the Environment Injector plugin is installed." ) raise ex return data["envMap"] def set_description(self, description: str) -> None: """ Set the description for this build. """ url: str = "%s/submitDescription" % self.baseurl data = "description=%s" % description self.job.jenkins.requester.post_and_confirm_status(url, data=data) def get_pending_input_actions(self) -> List[Dict[str, Any]]: """ Get pending input actions for a Pipeline build. Returns a list of pending input action dicts, each containing: - id: The input step ID - message: The prompt message - inputs: List of parameter definitions :return: List of pending input action dicts """ url: str = "%s/wfapi/pendingInputActions" % self.baseurl return self.get_data(url) def proceed_input(self, input_id: str) -> None: """ Proceed (approve) a pending Pipeline input step. :param input_id: The ID of the input step to proceed """ url: str = "%s/input/%s/proceedEmpty" % (self.baseurl, input_id) self.job.jenkins.requester.post_and_confirm_status(url, data="") def abort_input(self, input_id: str) -> None: """ Abort a pending Pipeline input step. :param input_id: The ID of the input step to abort """ url: str = "%s/input/%s/abort" % (self.baseurl, input_id) self.job.jenkins.requester.post_and_confirm_status(url, data="") def toggle_keep(self) -> None: """ Toggle "keep this build forever" on and off """ url: str = "%s/toggleLogKeep" % self.baseurl self.get_jenkins_obj().requester.post_and_confirm_status(url, data={}) self._data = self._poll() def is_kept_forever(self) -> bool: return self._data["keepLog"] def get_stages(self) -> List[Dict[str, Any]]: """ Get pipeline stages from a Pipeline build. Returns an empty list for non-pipeline builds. Returns a list of stage dictionaries, each containing: - id: Unique stage identifier - name: Stage name - status: Stage status (SUCCESS, FAILED, etc.) - startTimeMillis: Stage start time - durationMillis: Stage duration in milliseconds - stageFlowNodes: List of steps within the stage :return: List of stage dictionaries, or empty list if not a pipeline build """ data = self.poll(tree="stages") return data.get("stages", []) jenkinsapi-0.3.23/jenkinsapi/config.py0000644000000000000000000000011613615410400014622 0ustar00""" Jenkins configuration """ JENKINS_API = r"api/python" LOAD_TIMEOUT = 30 jenkinsapi-0.3.23/jenkinsapi/constants.py0000644000000000000000000000064013615410400015373 0ustar00""" Constants for jenkinsapi """ import re STATUS_FAIL = "FAIL" STATUS_ERROR = "ERROR" STATUS_ABORTED = "ABORTED" STATUS_REGRESSION = "REGRESSION" STATUS_SUCCESS = "SUCCESS" STATUS_FIXED = "FIXED" STATUS_PASSED = "PASSED" RESULTSTATUS_FAILURE = "FAILURE" RESULTSTATUS_FAILED = "FAILED" RESULTSTATUS_SKIPPED = "SKIPPED" STR_RE_SPLIT_VIEW = "(.*)/view/([^/]*)/?" RE_SPLIT_VIEW_URL = re.compile(STR_RE_SPLIT_VIEW) jenkinsapi-0.3.23/jenkinsapi/credential.py0000644000000000000000000004307713615410400015504 0ustar00""" Module for jenkinsapi Credential class """ import base64 import logging import xml.etree.cElementTree as ET log = logging.getLogger(__name__) class Credential(object): """ Base abstract class for credentials Credentials returned from Jenkins don't hold any sensitive information, so there is nothing useful can be done with existing credentials besides attaching them to Nodes or other objects. You can create concrete Credential instance: UsernamePasswordCredential or SSHKeyCredential by passing credential's description and credential dict. Each class expects specific credential dict, see below. """ # pylint: disable=unused-argument def __init__(self, cred_dict, jenkins_class=""): """ Create credential :param str description: as Jenkins doesn't allow human friendly names for credentials and makes "displayName" itself, there is no way to find credential later, this field is used to distinguish between credentials :param dict cred_dict: dict containing credential information """ self.credential_id = cred_dict.get("credential_id", "") self.description = cred_dict["description"] self.fullname = cred_dict.get("fullName", "") self.displayname = cred_dict.get("displayName", "") self.jenkins_class = jenkins_class def __str__(self): return self.description def get_attributes(self): pass def get_attributes_xml(self): pass def get_files(self): return None def _get_attributes_xml(self, data): root = ET.Element(self.jenkins_class) for key in data: value = data[key] if isinstance(value, dict): node = ET.SubElement(root, key) if "stapler-class" in value: node.attrib["class"] = value["stapler-class"] for sub_key in value: ET.SubElement(node, sub_key).text = value[sub_key] else: ET.SubElement(root, key).text = data[key] return ET.tostring(root) class UsernamePasswordCredential(Credential): """ Username and password credential Constructor expects following dict: { 'credential_id': str, Automatically set by jenkinsapi 'displayName': str, Automatically set by Jenkins 'fullName': str, Automatically set by Jenkins 'typeName': str, Automatically set by Jenkins 'description': str, 'userName': str, 'password': str } When creating credential via jenkinsapi automatic fields not need to be in dict """ def __init__(self, cred_dict: dict) -> None: jenkins_class: str = ( "com.cloudbees.plugins.credentials.impl." "UsernamePasswordCredentialsImpl" ) super(UsernamePasswordCredential, self).__init__( cred_dict, jenkins_class ) if "typeName" in cred_dict: username: str = cred_dict["displayName"].split("/")[0] else: username: str = cred_dict["userName"] self.username: str = username self.password: str = cred_dict.get("password", "") def get_attributes(self): """ Used by Credentials object to create credential in Jenkins """ c_id = "" if self.credential_id is None else self.credential_id return { "stapler-class": self.jenkins_class, "Submit": "OK", "json": { "": "1", "credentials": { "stapler-class": self.jenkins_class, "id": c_id, "username": self.username, "password": self.password, "description": self.description, }, }, } def get_attributes_xml(self): """ Used by Credentials object to update a credential in Jenkins """ c_id = "" if self.credential_id is None else self.credential_id data = { "id": c_id, "username": self.username, "password": self.password, "description": self.description, } return super(UsernamePasswordCredential, self)._get_attributes_xml( data ) class SecretTextCredential(Credential): """ Secret text credential Constructor expects following dict: { 'credential_id': str, Automatically set by jenkinsapi 'displayName': str, Automatically set by Jenkins 'fullName': str, Automatically set by Jenkins 'typeName': str, Automatically set by Jenkins 'description': str, 'secret': str, } When creating credential via jenkinsapi automatic fields not need to be in dict """ def __init__(self, cred_dict): jenkins_class = ( "org.jenkinsci.plugins.plaincredentials.impl.StringCredentialsImpl" ) super(SecretTextCredential, self).__init__(cred_dict, jenkins_class) self.secret = cred_dict.get("secret", None) def get_attributes(self): """ Used by Credentials object to create credential in Jenkins """ c_id = "" if self.credential_id is None else self.credential_id return { "stapler-class": self.jenkins_class, "Submit": "OK", "json": { "": "1", "credentials": { "stapler-class": self.jenkins_class, "$class": self.jenkins_class, "id": c_id, "secret": self.secret, "description": self.description, }, }, } def get_attributes_xml(self): """ Used by Credentials object to update a credential in Jenkins """ c_id = "" if self.credential_id is None else self.credential_id data = { "id": c_id, "secret": self.secret, "description": self.description, } return super(SecretTextCredential, self)._get_attributes_xml(data) class SSHKeyCredential(Credential): """ SSH key credential Constructr expects following dict: { 'credential_id': str, Automatically set by jenkinsapi 'displayName': str, Automatically set by Jenkins 'fullName': str, Automatically set by Jenkins 'typeName': str, Automatically set by Jenkins 'description': str, 'userName': str, 'passphrase': str, SSH key passphrase, 'private_key': str Private SSH key } private_key value is parsed to find type of credential to create: private_key starts with - the value is private key itself These credential variations are no longer supported by SSH Credentials plugin. jenkinsapi will raise ValueError if they are used: private_key starts with / the value is a path to key private_key starts with ~ the value is a key from ~/.ssh When creating credential via jenkinsapi automatic fields not need to be in dict """ def __init__(self, cred_dict: dict) -> None: jenkins_class: str = ( "com.cloudbees.jenkins.plugins.sshcredentials.impl." "BasicSSHUserPrivateKey" ) super(SSHKeyCredential, self).__init__(cred_dict, jenkins_class) if "typeName" in cred_dict: username: str = cred_dict["displayName"].split(" ")[0] else: username: str = cred_dict["userName"] self.username: str = username self.passphrase: str = cred_dict.get("passphrase", "") if "private_key" not in cred_dict or cred_dict["private_key"] is None: self.key_type: int = -1 self.key_value: str = "" elif cred_dict["private_key"].startswith("-"): self.key_type: int = 0 self.key_value: str = cred_dict["private_key"] else: raise ValueError("Invalid private_key value") @property def attrs(self): if self.key_type == 0: c_class = self.jenkins_class + "$DirectEntryPrivateKeySource" elif self.key_type == 1: c_class = self.jenkins_class + "$FileOnMasterPrivateKeySource" elif self.key_type == 2: c_class = self.jenkins_class + "$UsersPrivateKeySource" else: c_class = None attrs = { "value": self.key_type, "privateKey": self.key_value, "stapler-class": c_class, } # We need one more attr when using the key file on master. if self.key_type == 1: attrs["privateKeyFile"] = self.key_value return attrs def get_attributes(self): """ Used by Credentials object to create credential in Jenkins """ c_id = "" if self.credential_id is None else self.credential_id return { "stapler-class": self.attrs["stapler-class"], "Submit": "OK", "json": { "": "1", "credentials": { "scope": "GLOBAL", "id": c_id, "username": self.username, "description": self.description, "privateKeySource": self.attrs, "passphrase": self.passphrase, "stapler-class": self.jenkins_class, "$class": self.jenkins_class, }, }, } def get_attributes_xml(self): """ Used by Credentials object to update a credential in Jenkins """ c_id = "" if self.credential_id is None else self.credential_id data = { "id": c_id, "username": self.username, "description": self.description, "privateKeySource": self.attrs, "passphrase": self.passphrase, } return super(SSHKeyCredential, self)._get_attributes_xml(data) class FileCredentials(Credential): """ Secret file credential Constructor expects following dict: { 'credential_id': str, Automatically set by jenkinsapi 'displayName': str, Automatically set by Jenkins 'fullName': str, Automatically set by Jenkins 'typeName': str, Automatically set by Jenkins 'description': str, 'filename': str, 'secret_bytes': str, Base64-encoded file content } When creating credential via jenkinsapi automatic fields not need to be in dict """ def __init__(self, cred_dict): jenkins_class = ( "org.jenkinsci.plugins.plaincredentials.impl.FileCredentialsImpl" ) super(FileCredentials, self).__init__(cred_dict, jenkins_class) self.filename = cred_dict.get( "filename", cred_dict.get("fileName", "") ) self.secret_bytes = cred_dict.get( "secret_bytes", cred_dict.get("secretBytes", "") ) def get_attributes(self): """ Used by Credentials object to create credential in Jenkins """ c_id = "" if self.credential_id is None else self.credential_id return { "stapler-class": self.jenkins_class, "Submit": "OK", "json": { "": "1", "credentials": { "stapler-class": self.jenkins_class, "$class": self.jenkins_class, "id": c_id, "file": "file0", "description": self.description, }, }, } def get_files(self): if not self.secret_bytes: return None return { "file0": ( self.filename or "secret.file", base64.b64decode(self.secret_bytes), ) } def get_attributes_xml(self): """ Used by Credentials object to update a credential in Jenkins """ c_id = "" if self.credential_id is None else self.credential_id data = { "id": c_id, "fileName": self.filename, "secretBytes": self.secret_bytes, "description": self.description, } return super(FileCredentials, self)._get_attributes_xml(data) class AmazonWebServicesCredentials(Credential): """ AWS credential using the CloudBees AWS Credentials Plugin See https://wiki.jenkins.io/display/JENKINS/CloudBees+AWS+Credentials+Plugin Constructor expects following dict: { 'credential_id': str, Automatically set by jenkinsapi 'displayName': str, Automatically set by Jenkins 'fullName': str, Automatically set by Jenkins 'description': str, 'accessKey': str, 'secretKey': str, 'iamRoleArn': str, 'iamMfaSerialNumber': str } When creating credential via jenkinsapi automatic fields not need to be in dict """ def __init__(self, cred_dict): jenkins_class = ( "com.cloudbees.jenkins.plugins.awscredentials.AWSCredentialsImpl" ) super(AmazonWebServicesCredentials, self).__init__( cred_dict, jenkins_class ) self.access_key = cred_dict["accessKey"] self.secret_key = cred_dict["secretKey"] self.iam_role_arn = cred_dict.get("iamRoleArn", "") self.iam_mfa_serial_number = cred_dict.get("iamMfaSerialNumber", "") def get_attributes(self): """ Used by Credentials object to create credential in Jenkins """ c_id = "" if self.credential_id is None else self.credential_id return { "stapler-class": self.jenkins_class, "Submit": "OK", "json": { "": "1", "credentials": { "stapler-class": self.jenkins_class, "$class": self.jenkins_class, "id": c_id, "accessKey": self.access_key, "secretKey": self.secret_key, "iamRoleArn": self.iam_role_arn, "iamMfaSerialNumber": self.iam_mfa_serial_number, "description": self.description, }, }, } def get_attributes_xml(self): """ Used by Credentials object to update a credential in Jenkins """ c_id = "" if self.credential_id is None else self.credential_id data = { "id": c_id, "accessKey": self.access_key, "secretKey": self.secret_key, "iamRoleArn": self.iam_role_arn, "iamMfaSerialNumber": self.iam_mfa_serial_number, "description": self.description, } return super(AmazonWebServicesCredentials, self)._get_attributes_xml( data ) class DockerServerCredentials(Credential): """ Docker server credentials using the Docker Commons Plugin Constructor expects following dict: { 'credential_id': str, Automatically set by jenkinsapi 'displayName': str, Automatically set by Jenkins 'fullName': str, Automatically set by Jenkins 'description': str, 'username': str, 'clientKey': str, 'clientCertificate': str, 'serverCaCertificate': str, } When creating credential via jenkinsapi automatic fields not need to be in dict """ def __init__(self, cred_dict): jenkins_class = ( "org.jenkinsci.plugins.docker.commons.credentials." "DockerServerCredentials" ) super(DockerServerCredentials, self).__init__(cred_dict, jenkins_class) self.username = cred_dict.get("username", "") self.client_key = cred_dict.get( "clientKeySecret", cred_dict.get("clientKey", "") ) self.client_certificate = cred_dict.get("clientCertificate", "") self.server_ca_certificate = cred_dict.get("serverCaCertificate", "") def get_attributes(self): """ Used by Credentials object to create credential in Jenkins """ c_id = "" if self.credential_id is None else self.credential_id return { "stapler-class": self.jenkins_class, "Submit": "OK", "json": { "": "1", "credentials": { "stapler-class": self.jenkins_class, "$class": self.jenkins_class, "id": c_id, "username": self.username, "clientKeySecret": self.client_key, "clientCertificate": self.client_certificate, "serverCaCertificate": self.server_ca_certificate, "description": self.description, }, }, } def get_attributes_xml(self): """ Used by Credentials object to update a credential in Jenkins """ c_id = "" if self.credential_id is None else self.credential_id data = { "id": c_id, "username": self.username, "clientKeySecret": self.client_key, "clientCertificate": self.client_certificate, "serverCaCertificate": self.server_ca_certificate, "description": self.description, } return super(DockerServerCredentials, self)._get_attributes_xml(data) jenkinsapi-0.3.23/jenkinsapi/credentials.py0000644000000000000000000002103413615410400015654 0ustar00""" This module implements the Credentials class, which is intended to be a container-like interface for all of the Global credentials defined on a single Jenkins node. """ from __future__ import annotations import json from typing import Iterator import logging from urllib.parse import urlencode from jenkinsapi.credential import Credential from jenkinsapi.credential import UsernamePasswordCredential from jenkinsapi.credential import SecretTextCredential from jenkinsapi.credential import SSHKeyCredential from jenkinsapi.credential import FileCredentials from jenkinsapi.credential import DockerServerCredentials from jenkinsapi.jenkinsbase import JenkinsBase from jenkinsapi.custom_exceptions import JenkinsAPIException log: logging.Logger = logging.getLogger(__name__) class Credentials(JenkinsBase): """ This class provides a container-like API which gives access to all global credentials on a Jenkins node. Returns a list of Credential Objects. """ def __init__( self, baseurl: str, jenkins_obj: "Jenkins", poll: bool = True ): self.baseurl: str = baseurl self.jenkins: "Jenkins" = jenkins_obj JenkinsBase.__init__(self, baseurl, poll=poll) self.credentials = ( self._data.get("credentials", {}) if self._data else {} ) def _poll(self, tree=None): url: str = self.python_api_url(self.baseurl) + "?depth=2" data = self.get_data(url, tree=tree) credentials = data["credentials"] for cred_id, cred_dict in credentials.items(): cred_dict["credential_id"] = cred_id credentials[cred_id] = self._make_credential(cred_dict) return data def __str__(self) -> str: return "Global Credentials @ %s" % self.baseurl def get_jenkins_obj(self) -> "Jenkins": return self.jenkins def __iter__(self) -> Iterator[Credential]: for cred in self.credentials.values(): yield cred.description def __contains__(self, description: str) -> bool: return description in self.keys() def iterkeys(self): return self.__iter__() def keys(self): return list(self.iterkeys()) def iteritems(self) -> Iterator[str, "Credential"]: for cred in self.credentials.values(): yield cred.description, cred def __getitem__(self, description: str) -> "Credential": for cred in self.credentials.values(): if cred.description == description: return cred raise KeyError( 'Credential with description "%s" not found' % description ) def __len__(self) -> int: return len(self.keys()) def __setitem__(self, description: str, credential: "Credential"): """ Creates Credential in Jenkins using username, password and description Description must be unique in Jenkins instance because it is used to find Credential later. If description already exists - this method is going to update existing Credential :param str description: Credential description :param tuple credential_tuple: (username, password, description) tuple. """ if description not in self: params = credential.get_attributes() files = credential.get_files() url = "%s/createCredentials" % self.baseurl try: if files: payload = { key: json.dumps(value) if key == "json" else value for key, value in params.items() } self.jenkins.requester.post_and_confirm_status( url, params={}, data=payload, files=files ) else: self.jenkins.requester.post_and_confirm_status( url, params={}, data=urlencode(params) ) except JenkinsAPIException as jae: raise JenkinsAPIException( "Latest version of Credentials " "plugin is required to be able " "to create credentials. " "Original exception: %s" % str(jae) ) else: cred_id = self[description].credential_id credential.credential_id = cred_id params = credential.get_attributes_xml() url = "%s/credential/%s/config.xml" % (self.baseurl, cred_id) try: self.jenkins.requester.post_xml_and_confirm_status( url, params={}, data=params ) except JenkinsAPIException as jae: raise JenkinsAPIException( "Latest version of Credentials " "plugin is required to be able " "to update credentials. " "Original exception: %s" % str(jae) ) self.poll() self.credentials = self._data["credentials"] if description not in self: raise JenkinsAPIException("Problem creating/updating credential.") def get(self, item, default): return self[item] if item in self else default def __delitem__(self, description: str): if description not in self: raise KeyError( 'Credential with description "%s" not found' % description ) params = {"Submit": "OK", "json": {}} url = "%s/credential/%s/doDelete" % ( self.baseurl, self[description].credential_id, ) try: self.jenkins.requester.post_and_confirm_status( url, params={}, data=urlencode(params) ) except JenkinsAPIException as jae: raise JenkinsAPIException( "Latest version of Credentials " "required to be able to create " "credentials. Original exception: %s" % str(jae) ) self.poll() self.credentials = self._data["credentials"] if description in self: raise JenkinsAPIException("Problem deleting credential.") def _make_credential(self, cred_dict): cred_type = cred_dict.get("typeName") if cred_type == "Username with password": cr = UsernamePasswordCredential(cred_dict) elif cred_type == "SSH Username with private key": cr = SSHKeyCredential(cred_dict) elif cred_type == "Secret text": cr = SecretTextCredential(cred_dict) elif cred_type == "Secret file" or any( key in cred_dict for key in ("fileName", "filename", "secretBytes") ): cr = FileCredentials(cred_dict) elif ( cred_type in ( "Docker Host Certificate Authentication", "X.509 Client Certificate", ) or "clientKey" in cred_dict or "clientKeySecret" in cred_dict or "clientCertificate" in cred_dict or "serverCaCertificate" in cred_dict ): cr = DockerServerCredentials(cred_dict) else: cr = Credential(cred_dict) return cr class Credentials2x(Credentials): """ This class provides a container-like API which gives access to all global credentials on a Jenkins node. Returns a list of Credential Objects. """ def _poll(self, tree=None): url = self.python_api_url(self.baseurl) + "?depth=2" data = self.get_data(url, tree=tree) credentials = data["credentials"] new_creds = {} for cred_dict in credentials: cred_dict["credential_id"] = cred_dict["id"] new_creds[cred_dict["id"]] = self._make_credential(cred_dict) data["credentials"] = new_creds return data class CredentialsById(Credentials2x): """ This class provides a container-like API which gives access to all global credentials on a Jenkins node. Returns a list of Credential Objects. """ def __iter__(self): for cred in self.credentials.values(): yield cred.credential_id def __contains__(self, credential_id): return credential_id in self.keys() def iteritems(self): for cred in self.credentials.values(): yield cred.credential_id, cred def __getitem__(self, credential_id): for cred in self.credentials.values(): if cred.credential_id == credential_id: return cred raise KeyError( 'Credential with credential_id "%s" not found' % credential_id ) jenkinsapi-0.3.23/jenkinsapi/custom_exceptions.py0000644000000000000000000000455013615410400017136 0ustar00"""Module for custom_exceptions. Where possible we try to throw exceptions with non-generic, meaningful names. """ class JenkinsAPIException(Exception): """Base class for all errors""" pass class NotFound(JenkinsAPIException): """Resource cannot be found""" pass class ArtifactsMissing(NotFound): """Cannot find a build with all of the required artifacts.""" pass class UnknownJob(KeyError, NotFound): """Jenkins does not recognize the job requested.""" pass class UnknownView(KeyError, NotFound): """Jenkins does not recognize the view requested.""" pass class UnknownNode(KeyError, NotFound): """Jenkins does not recognize the node requested.""" pass class UnknownQueueItem(KeyError, NotFound): """Jenkins does not recognize the requested queue item""" pass class UnknownPlugin(KeyError, NotFound): """Jenkins does not recognize the plugin requested.""" pass class NoBuildData(NotFound): """A job has no build data.""" pass class NotBuiltYet(NotFound): """A job has no build data.""" pass class ArtifactBroken(JenkinsAPIException): """An artifact is broken, wrong""" pass class TimeOut(JenkinsAPIException): """Some jobs have taken too long to complete.""" pass class NoResults(JenkinsAPIException): """A build did not publish any results.""" pass class FailedNoResults(NoResults): """A build did not publish any results because it failed""" pass class BadURL(ValueError, JenkinsAPIException): """A URL appears to be broken""" pass class NotAuthorized(JenkinsAPIException): """Not Authorized to access resource""" # Usually thrown when we get a 403 returned pass class NotSupportSCM(JenkinsAPIException): """ It's a SCM that does not supported by current version of jenkinsapi """ pass class NotConfiguredSCM(JenkinsAPIException): """It's a job that doesn't have configured SCM""" pass class NotInQueue(JenkinsAPIException): """It's a job that is not in the queue""" pass class PostRequired(JenkinsAPIException): """Method requires POST and not GET""" pass class BadParams(JenkinsAPIException): """Invocation was given bad or inappropriate params""" pass class AlreadyExists(JenkinsAPIException): """ Method requires POST and not GET """ pass jenkinsapi-0.3.23/jenkinsapi/executor.py0000644000000000000000000000354113615410400015220 0ustar00""" Module for jenkinsapi Executer class """ from __future__ import annotations from jenkinsapi.jenkinsbase import JenkinsBase import logging log = logging.getLogger(__name__) class Executor(JenkinsBase): """ Class to hold information on nodes that are attached as slaves to the master jenkins instance """ def __init__( self, baseurl: str, nodename: str, jenkins_obj: "Jenkins", number: int ) -> None: """ Init a node object by providing all relevant pointers to it :param baseurl: basic url for querying information on a node :param nodename: hostname of the node :param jenkins_obj: ref to the jenkins obj :return: Node obj """ self.nodename: str = nodename self.number: int = number self.jenkins: "Jenkins" = jenkins_obj self.baseurl: str = baseurl JenkinsBase.__init__(self, baseurl) def __str__(self) -> str: return f"{self.nodename} {self.number}" def get_jenkins_obj(self) -> "Jenkins": return self.jenkins def get_progress(self) -> str: """Returns percentage""" return self.poll(tree="progress")["progress"] def get_number(self) -> int: """ Get Executor number. """ return self.poll(tree="number")["number"] def is_idle(self) -> bool: """ Returns Boolean: whether Executor is idle or not. """ return self.poll(tree="idle")["idle"] def likely_stuck(self) -> bool: """ Returns Boolean: whether Executor is likely stuck or not. """ return self.poll(tree="likelyStuck")["likelyStuck"] def get_current_executable(self) -> str: """ Returns the current Queue.Task this executor is running. """ return self.poll(tree="currentExecutable")["currentExecutable"] jenkinsapi-0.3.23/jenkinsapi/executors.py0000644000000000000000000000231013615410400015374 0ustar00""" This module implements the Executors class, which is intended to be a container-like interface for all of the executors defined on a single Jenkins node. """ from __future__ import annotations import logging from typing import Iterator from jenkinsapi.executor import Executor from jenkinsapi.jenkinsbase import JenkinsBase log: logging.Logger = logging.getLogger(__name__) class Executors(JenkinsBase): """ This class provides a container-like API which gives access to all executors on a Jenkins node. Returns a list of Executor Objects. """ def __init__( self, baseurl: str, nodename: str, jenkins: "Jenkins" ) -> None: self.nodename: str = nodename self.jenkins: str = jenkins JenkinsBase.__init__(self, baseurl) self.count: int = self._data["numExecutors"] def __str__(self) -> str: return f"Executors @ {self.baseurl}" def get_jenkins_obj(self) -> "Jenkins": return self.jenkins def __iter__(self) -> Iterator[Executor]: for index in range(self.count): executor_url = "%s/executors/%s" % (self.baseurl, index) yield Executor(executor_url, self.nodename, self.jenkins, index) jenkinsapi-0.3.23/jenkinsapi/fingerprint.py0000644000000000000000000001046513615410400015714 0ustar00""" Module for jenkinsapi Fingerprint """ from __future__ import annotations import re import logging from typing import Any import requests from jenkinsapi.jenkinsbase import JenkinsBase from jenkinsapi.custom_exceptions import ArtifactBroken log: logging.Logger = logging.getLogger(__name__) class Fingerprint(JenkinsBase): """ Represents a jenkins fingerprint on a single artifact file ?? """ RE_MD5 = re.compile("^([0-9a-z]{32})$") def __init__(self, baseurl: str, id_: str, jenkins_obj: "Jenkins") -> None: self.jenkins_obj: "Jenkins" = jenkins_obj assert self.RE_MD5.search(id_), ( "%s does not look like a valid id" % id_ ) url: str = f"{baseurl}/fingerprint/{id_}/" JenkinsBase.__init__(self, url, poll=False) self.id_: str = id_ self.unknown: bool = False # Previously uninitialized in ctor def get_jenkins_obj(self) -> "Jenkins": return self.jenkins_obj def __str__(self) -> str: return self.id_ def valid(self) -> bool: """ Return True / False if valid. If returns True, self.unknown is set to either True or False, and can be checked if we have positive validity (fingerprint known at server) or negative validity (fingerprint not known at server, but not really an error). """ try: self.poll() self.unknown = False except requests.exceptions.HTTPError as err: # We can't really say anything about the validity of # fingerprints not found -- but the artifact can still # exist, so it is not possible to definitely say they are # valid or not. # The response object is of type: requests.models.Response # extract the status code from it response_obj: Any = err.response if response_obj.status_code == 404: logging.warning( "MD5 cannot be checked if fingerprints are not enabled" ) self.unknown = True return True return False return True def validate_for_build(self, filename: str, job: str, build: int) -> bool: if not self.valid(): log.info("Fingerprint is not known to jenkins.") return False if self.unknown: # not request error, but unknown to jenkins return True if self._data["original"] is not None: if self._data["original"]["name"] == job: if self._data["original"]["number"] == build: return True if self._data["fileName"] != filename: log.info( msg="Filename from jenkins (%s) did not match provided (%s)" % (self._data["fileName"], filename) ) return False for usage_item in self._data["usage"]: if usage_item["name"] == job: for range_ in usage_item["ranges"]["ranges"]: if range_["start"] <= build <= range_["end"]: msg = ( "This artifact was generated by %s " "between build %i and %i" % ( job, range_["start"], range_["end"], ) ) log.info(msg=msg) return True return False def validate(self) -> bool: try: assert self.valid() except AssertionError as ae: raise ArtifactBroken( "Artifact %s seems to be broken, check %s" % (self.id_, self.baseurl) ) from ae except requests.exceptions.HTTPError: raise ArtifactBroken( "Unable to validate artifact id %s using %s" % (self.id_, self.baseurl) ) return True def get_info(self): """ Returns a tuple of build-name, build# and artifact filename for a good build. """ self.poll() return ( self._data["original"]["name"], self._data["original"]["number"], self._data["fileName"], ) jenkinsapi-0.3.23/jenkinsapi/jenkins.py0000644000000000000000000007200013615410400015017 0ustar00""" Module for jenkinsapi Jenkins object """ from __future__ import annotations import time import logging import warnings from urllib.parse import urlparse from urllib.request import Request, HTTPRedirectHandler, build_opener from urllib.parse import quote as urlquote from urllib.parse import urlencode from requests import HTTPError, ConnectionError from jenkinsapi import config from jenkinsapi.credentials import Credentials from jenkinsapi.credentials import Credentials2x from jenkinsapi.credentials import CredentialsById from jenkinsapi.executors import Executors from jenkinsapi.jobs import Jobs from jenkinsapi.job import Job from jenkinsapi.lockable_resources import LockableResources from jenkinsapi.view import View from jenkinsapi.label import Label from jenkinsapi.node import Node from jenkinsapi.nodes import Nodes from jenkinsapi.plugins import Plugins from jenkinsapi.plugin import Plugin from jenkinsapi.utils.requester import Requester from jenkinsapi.views import Views from jenkinsapi.queue import Queue from jenkinsapi.fingerprint import Fingerprint from jenkinsapi.jenkinsbase import JenkinsBase from jenkinsapi.custom_exceptions import JenkinsAPIException from jenkinsapi.utils.crumb_requester import CrumbRequester log = logging.getLogger(__name__) class Jenkins(JenkinsBase): """ Represents a jenkins environment. """ # pylint: disable=too-many-arguments def __init__( self, baseurl: str, username: str = "", password: str = "", requester=None, lazy: bool = False, ssl_verify: bool = True, cert=None, timeout: int = 10, use_crumb: bool = True, max_retries=None, ) -> None: """ :param baseurl: baseurl for jenkins instance including port, str :param username: username for jenkins auth, str :param password: password for jenkins auth, str :return: a Jenkins obj """ self.username = username self.password = password if requester is None: if use_crumb: requester = CrumbRequester else: requester = Requester self.requester = requester( username, password, baseurl=baseurl, ssl_verify=ssl_verify, cert=cert, timeout=timeout, max_retries=max_retries, ) else: self.requester = requester self.requester.timeout = timeout self.lazy = lazy self.jobs_container = None JenkinsBase.__init__(self, baseurl, poll=not lazy) def _poll(self, tree=None): url = self.python_api_url(self.baseurl) return self.get_data( url, tree="jobs[name,color,url]" if not tree else tree ) def _poll_if_needed(self): if self.lazy and self._data is None: self.poll() def _clone(self): return Jenkins( self.baseurl, username=self.username, password=self.password, requester=self.requester, ) def base_server_url(self): if config.JENKINS_API in self.baseurl: return self.baseurl[: -(len(config.JENKINS_API))] return self.baseurl def validate_fingerprint(self, id_): obj_fingerprint = Fingerprint(self.baseurl, id_, jenkins_obj=self) obj_fingerprint.validate() log.info(msg="Jenkins says %s is valid" % id_) def get_artifact_data(self, id_): obj_fingerprint = Fingerprint(self.baseurl, id_, jenkins_obj=self) obj_fingerprint.validate() return obj_fingerprint.get_info() def validate_fingerprint_for_build(self, digest, filename, job, build): obj_fingerprint = Fingerprint(self.baseurl, digest, jenkins_obj=self) return obj_fingerprint.validate_for_build(filename, job, build) def get_jenkins_obj(self): return self def get_jenkins_obj_from_url(self, url: str): return Jenkins(url, self.username, self.password, self.requester) def get_create_url(self) -> str: # This only ever needs to work on the base object return "%s/createItem" % self.baseurl def get_nodes_url(self) -> str: # This only ever needs to work on the base object return self.nodes.baseurl @property def jobs(self): if self.jobs_container is None: self.jobs_container = Jobs(self) return self.jobs_container def get_jobs(self): """ Fetch all the build-names on this Jenkins server. """ return self.jobs.iteritems() def get_jobs_info(self): """ Get the jobs information :return url, name """ for name, job in self.jobs.iteritems(): yield job.url, name def get_job(self, jobname: str) -> Job: """ Get a job by name :param jobname: name of the job, str :return: Job obj """ return self.jobs[jobname] def get_job_by_url(self, url: str, job_name: str) -> Job: """ Get a job by url :param url: jobs' url :param jobname: name of the job, str :return: Job obj """ return Job(url, job_name, self) def has_job(self, jobname: str) -> bool: """ Does a job by the name specified exist :param jobname: string :return: boolean """ return jobname in self.jobs def create_job(self, jobname: str, xml) -> Job: """ Create a job alternatively you can create job using Jobs object: self.jobs['job_name'] = config :param jobname: name of new job, str :param config: configuration of new job, xml :return: new Job obj """ return self.jobs.create(jobname, xml) def create_multibranch_pipeline_job( self, jobname, xml, block=True, delay=60 ): """ :return: list of new Job objects """ return self.jobs.create_multibranch_pipeline( jobname, xml, block, delay ) def copy_job(self, jobname: str, newjobname: str) -> Job: return self.jobs.copy(jobname, newjobname) def build_job(self, jobname: str, params=None) -> None: """ Invoke a build by job name :param jobname: name of exist job, str :param params: the job params, dict :return: none """ self[jobname].invoke(build_params=params or {}) def delete_job(self, jobname: str) -> None: """ Delete a job by name :param jobname: name of a exist job, str :return: new jenkins_obj """ del self.jobs[jobname] def rename_job(self, jobname: str, newjobname: str) -> Job: """ Rename a job :param jobname: name of a exist job, str :param newjobname: name of new job, str :return: new Job obj """ return self.jobs.rename(jobname, newjobname) def items(self): """ :param return: A list of pairs. Each pair will be (job name, Job object) """ return list(self.iteritems()) def get_jobs_list(self): return self.jobs.keys() def iterkeys(self): return self.jobs.iterkeys() def iteritems(self): return self.jobs.iteritems() def keys(self): return self.jobs.keys() def __str__(self) -> str: return "Jenkins server at %s" % self.baseurl @property def views(self): return Views(self) def get_view_by_url(self, view_url: str): # for nested view view_name = view_url.split("/view/")[-1].replace("/", "") return View(view_url, view_name, jenkins_obj=self) def delete_view_by_url(self, viewurl: str): url = f"{viewurl}/doDelete" self.requester.post_and_confirm_status(url, data="") self.poll() return self def get_label(self, label_name: str) -> Label: label_url = "%s/label/%s" % (self.baseurl, label_name) return Label(label_url, label_name, jenkins_obj=self) def __getitem__(self, jobname: str) -> Job: """ Get a job by name :param jobname: name of job, str :return: Job obj """ return self.jobs[jobname] def __len__(self) -> int: return len(self.jobs) def __contains__(self, jobname: str) -> bool: """ Does a job by the name specified exist :param jobname: string :return: boolean """ return jobname in self.jobs def __delitem__(self, job_name: str) -> None: del self.jobs[job_name] def get_node(self, nodename: str) -> Node: """Get a node object for a specific node""" return self.nodes[nodename] def get_node_url(self, nodename: str = "") -> str: """Return the url for nodes""" url = urlparse.urljoin( self.base_server_url(), "computer/%s" % urlquote(nodename) ) return url def get_queue_url(self): url = f"{self.base_server_url()}/queue" return url def get_queue(self) -> Queue: queue_url = self.get_queue_url() return Queue(queue_url, self) def get_nodes(self) -> Nodes: return Nodes(self.baseurl, self) @property def nodes(self): return self.get_nodes() def has_node(self, nodename: str) -> bool: """ Does a node by the name specified exist :param nodename: string, hostname :return: boolean """ self.poll() return nodename in self.nodes def delete_node(self, nodename: str) -> None: """ Remove a node from the managed slave list Please note that you cannot remove the master node :param nodename: string holding a hostname :return: None """ del self.nodes[nodename] def create_node( self, name: str, num_executors: int = 2, node_description: str = "", remote_fs: str = "/var/lib/jenkins", labels=None, exclusive: bool = False, ) -> Node: """ Create a new JNLP slave node by name. To create SSH node, please see description in Node class :param name: fqdn of slave, str :param num_executors: number of executors, int :param node_description: a freetext field describing the node :param remote_fs: jenkins path, str :param labels: labels to associate with slave, str :param exclusive: tied to specific job, boolean :return: node obj """ node_dict = { "num_executors": num_executors, "node_description": node_description, "remote_fs": remote_fs, "labels": labels, "exclusive": exclusive, } return self.nodes.create_node(name, node_dict) def create_node_with_config(self, name: str, config) -> Node | None: """ Create a new slave node with specific configuration. Config should be resemble the output of node.get_node_attributes() :param str name: name of slave :param dict config: Node attributes for Jenkins API request to create node (See function output Node.get_node_attributes()) :return: node obj """ return self.nodes.create_node_with_config(name=name, config=config) def get_plugins_url(self, depth): # This only ever needs to work on the base object return f"{self.baseurl}/pluginManager/api/python?depth={depth}" def install_plugin( self, plugin: str | Plugin, restart: bool = True, force_restart: bool = False, wait_for_reboot: bool = True, no_reboot_warning: bool = False, ): """ Install a plugin and optionally restart jenkins. @param plugin: Plugin (string or Plugin object) to be installed @param restart: Boolean, restart Jenkins when required by plugin @param force_restart: Boolean, force Jenkins to restart, ignoring plugin preferences @param no_warning: Don't show warning when restart is needed and restart parameters are set to False """ if not isinstance(plugin, Plugin): plugin = Plugin(plugin) self.plugins[plugin.shortName] = plugin if force_restart or (restart and self.plugins.restart_required): self.safe_restart(wait_for_reboot=wait_for_reboot) elif self.plugins.restart_required and not no_reboot_warning: warnings.warn( "System reboot is required, but automatic reboot is disabled. " "Please reboot manually." ) def install_plugins( self, plugin_list, restart: bool = True, force_restart: bool = False, wait_for_reboot: bool = True, no_reboot_warning: bool = False, ) -> None: """ Install a list of plugins and optionally restart jenkins. @param plugin_list: List of plugins (strings, Plugin objects or a mix of the two) to be installed @param restart: Boolean, restart Jenkins when required by plugin @param force_restart: Boolean, force Jenkins to restart, ignoring plugin preferences """ plugins = [ p if isinstance(p, Plugin) else Plugin(p) for p in plugin_list ] for plugin in plugins: self.install_plugin(plugin, restart=False, no_reboot_warning=True) if force_restart or (restart and self.plugins.restart_required): self.safe_restart(wait_for_reboot=wait_for_reboot) elif self.plugins.restart_required and not no_reboot_warning: warnings.warn( "System reboot is required, but automatic reboot is disabled. " "Please reboot manually." ) def delete_plugin( self, plugin: str | Plugin, restart: bool = True, force_restart: bool = False, wait_for_reboot: bool = True, no_reboot_warning: bool = False, ) -> None: """ Delete a plugin and optionally restart jenkins. Will not delete dependencies. @param plugin: Plugin (string or Plugin object) to be deleted @param restart: Boolean, restart Jenkins when required by plugin @param force_restart: Boolean, force Jenkins to restart, ignoring plugin preferences """ if isinstance(plugin, str): if "@" in plugin: raise ValueError( "Plugin name must not include a version. " 'Use the short name only, e.g. "plugin-name" not "%s"' % plugin ) elif isinstance(plugin, Plugin): plugin = plugin.shortName del self.plugins[plugin] if force_restart or (restart and self.plugins.restart_required): self.safe_restart(wait_for_reboot=wait_for_reboot) elif self.plugins.restart_required and not no_reboot_warning: warnings.warn( "System reboot is required, but automatic reboot is disabled. " "Please reboot manually." ) def delete_plugins( self, plugin_list, restart: bool = True, force_restart: bool = False, wait_for_reboot: bool = True, no_reboot_warning: bool = False, ): """ Delete a list of plugins and optionally restart jenkins. Will not delete dependencies. @param plugin_list: List of plugins (strings, Plugin objects or a mix of the two) to be deleted @param restart: Boolean, restart Jenkins when required by plugin @param force_restart: Boolean, force Jenkins to restart, ignoring plugin preferences """ for plugin in plugin_list: self.delete_plugin(plugin, restart=False, no_reboot_warning=True) if force_restart or (restart and self.plugins.restart_required): self.safe_restart(wait_for_reboot=wait_for_reboot) elif self.plugins.restart_required and not no_reboot_warning: warnings.warn( "System reboot is required, but automatic reboot is disabled. " "Please reboot manually." ) def safe_restart(self, wait_for_reboot: bool = True): """restarts jenkins when no jobs are running""" # NB: unlike other methods, the value of resp.status_code # here can be 503 even when everything is normal url = "%s/safeRestart" % (self.baseurl,) valid = self.requester.VALID_STATUS_CODES + [503, 500] resp = self.requester.post_and_confirm_status( url, data="", valid=valid ) if wait_for_reboot: self._wait_for_reboot() return resp def _wait_for_reboot(self) -> None: # We need to make sure all jobs have finished, # and that jenkins is actually restarting. # One way to be sure is to make sure jenkins is really down. wait = 5 count = 0 max_count = 30 self.__jenkins_is_unavailable() # Blocks until jenkins is restarting while count < max_count: time.sleep(wait) try: self.poll() len(self.plugins) # Make sure jenkins is fully started return # By this time jenkins is back online except (HTTPError, ConnectionError): msg = ( "Jenkins has not restarted yet! (This is" " try {0} of {1}, waited {2} seconds so far)" " Sleeping and trying again.." ) msg = msg.format(count, max_count, count * wait) log.debug(msg) count += 1 msg = ( "Jenkins did not come back from safe restart! " "Waited %s seconds altogether. This " "failure may cause other failures." ) log.critical(msg, count * wait) def __jenkins_is_unavailable(self): while True: try: res = self.requester.get_and_confirm_status( self.baseurl, valid=[503, 500, 200] ) # If there is a running job in Jenkins, the system message will # pop up but the Jenkins instance will return 200 if ( res.status_code == 200 and "Jenkins is going to shut down" in str(res.content, encoding="utf-8") ): time.sleep(1) continue return True except ConnectionError: # This is also a possibility while Jenkins is restarting return True except HTTPError: # This is a return code that is not 503, # so Jenkins is likely available time.sleep(1) def safe_exit(self, wait_for_exit: bool = True, max_wait: int = 360): """ Restarts jenkins when no jobs are running, except for pipeline jobs """ # NB: unlike other methods, the value of resp.status_code # here can be 503 even when everything is normal url = f"{self.baseurl}/safeExit" valid = self.requester.VALID_STATUS_CODES + [503, 500] resp = self.requester.post_and_confirm_status( url, data="", valid=valid ) if wait_for_exit: self._wait_for_exit(max_wait=max_wait) return resp def _wait_for_exit(self, max_wait: int = 360) -> None: # We need to make sure all non pipeline jobs have finished, # and that jenkins is unavailable self.__jenkins_is_unresponsive(max_wait=max_wait) def __jenkins_is_unresponsive(self, max_wait: int = 360): # Blocks until jenkins returns ConnectionError or JenkinsAPIException # Default wait is one hour is_alive = True wait = 0 while is_alive and wait < max_wait: try: self.requester.get_and_confirm_status( self.baseurl, valid=[200] ) time.sleep(1) wait += 1 is_alive = True except (ConnectionError, JenkinsAPIException): # Jenkins is finally down is_alive = False return True except HTTPError: # This is a return code that is not 503, # so Jenkins is likely available, and we need to wait time.sleep(1) wait += 1 is_alive = True def quiet_down(self): """ Put Jenkins in a Quiet mode, preparation for restart. No new builds started """ # NB: unlike other methods, the value of resp.status_code # here can be 503 even when everything is normal url = "%s/quietDown" % (self.baseurl,) valid = self.requester.VALID_STATUS_CODES + [503, 500] resp = self.requester.post_and_confirm_status( url, data="", valid=valid ) return resp def cancel_quiet_down(self): """Cancel the effect of the quiet-down command""" # NB: unlike other methods, the value of resp.status_code # here can be 503 even when everything is normal url = "%s/cancelQuietDown" % (self.baseurl,) valid = self.requester.VALID_STATUS_CODES + [503, 500] resp = self.requester.post_and_confirm_status( url, data="", valid=valid ) return resp @property def plugins(self): return self.get_plugins() def get_plugins(self, depth: int = 1) -> Plugins: url = self.get_plugins_url(depth=depth) return Plugins(url, self) def has_plugin(self, plugin_name: str) -> bool: return plugin_name in self.plugins def get_executors(self, nodename: str) -> Executors: url = f"{self.baseurl}/computer/{nodename}" return Executors(url, nodename, self) def get_master_data(self): url = f"{self.baseurl}/computer/api/python" return self.get_data(url) @property def version(self) -> str: """ Return version number of Jenkins """ response = self.requester.get_and_confirm_status(self.baseurl) version_key = "X-Jenkins" return response.headers.get(version_key, "0.0") def get_credentials(self, cred_class=Credentials2x): """ Return credentials """ if "credentials" not in self.plugins: raise JenkinsAPIException("Credentials plugin not installed") if self.plugins["credentials"].version.startswith("1."): url = f"{self.baseurl}/credential-store/domain/_/" return Credentials(url, self) url = f"{self.baseurl}/credentials/store/system/domain/_/" return cred_class(url, self) @property def credentials(self): return self.get_credentials(Credentials2x) @property def credentials_by_id(self): return self.get_credentials(CredentialsById) @property def is_quieting_down(self) -> bool: url = "%s/api/python?tree=quietingDown" % (self.baseurl,) data = self.get_data(url=url) return data.get("quietingDown", False) def shutdown(self) -> None: url = "%s/exit" % self.baseurl self.requester.post_and_confirm_status(url, data="") def generate_new_api_token( self, new_token_name: str = "Token By jenkinsapi python" ): subUrl = ( "/me/descriptorByName/jenkins.security." "ApiTokenProperty/generateNewToken" ) url = "%s%s" % (self.baseurl, subUrl) data = urlencode({"newTokenName": new_token_name}) response = self.requester.post_and_confirm_status(url, data=data) token = response.json()["data"]["tokenValue"] return token def revoke_api_token(self, token_uuid: str) -> None: subUrl = ( "/me/descriptorByName/jenkins.security.ApiTokenProperty/revoke" ) url = "%s%s" % (self.baseurl, subUrl) data = urlencode({"tokenUuid": token_uuid}) self.requester.post_and_confirm_status(url, data=data) def revoke_all_api_tokens(self) -> None: subUrl = ( "/me/descriptorByName/jenkins.security.ApiTokenProperty/revokeAll" ) url = "%s%s" % (self.baseurl, subUrl) self.requester.post_and_confirm_status(url, data="") def create_user( self, username: str, password: str, full_name: str, email: str ) -> None: url = "%s/securityRealm/createAccountByAdmin" % self.baseurl data = urlencode( { "username": username, "password1": password, "password2": password, "fullname": full_name, "email": email, } ) self.requester.post_and_confirm_status(url, data=data) def delete_user(self, username: str) -> None: url = "%s/securityRealm/user/%s/doDelete" % (self.baseurl, username) data = urlencode({"Submit": "Yes"}) self.requester.post_and_confirm_status(url, data=data) def run_groovy_script(self, script: str) -> str: """ Runs the requested groovy script on the Jenkins server returning the result as text. Raises a JenkinsAPIException if the returned HTTP response code from the POST request is not 200 OK. Example: server = Jenkins(...) script = 'println "Hello world!"' result = server.run_groovy_script(script) print(result) # will print "Hello world!" """ url = f"{self.baseurl}/scriptText" data = urlencode({"script": script}) response = self.requester.post_and_confirm_status(url, data=data) if response.status_code != 200: raise JenkinsAPIException( "Unexpected response %d." % response.status_code ) return response.text def use_auth_cookie(self) -> None: assert self.username and self.baseurl, ( "Please provide jenkins url, username " "and password to get the session ID cookie." ) login_url = "j_acegi_security_check" jenkins_url = "{0}/{1}".format(self.baseurl, login_url) data = urlencode( {"j_username": self.username, "j_password": self.password} ).encode("utf-8") class SmartRedirectHandler(HTTPRedirectHandler): def extract_cookie(self, setcookie): # Extracts the last cookie. # Example of set-cookie value for python2 # ('set-cookie', 'JSESSIONID.30blah=blahblahblah;Path=/; # HttpOnly, JSESSIONID.30ablah=blahblah;Path=/;HttpOnly'), return setcookie.split(",")[-1].split(";")[0].strip("\n\r ") def http_error_302(self, req, fp, code, msg, headers): # Jenkins can send several Set-Cookie values sometimes # The valid one is the last one for header, value in headers.items(): if header.lower() == "set-cookie": cookie = self.extract_cookie(value) req.headers["Cookie"] = cookie result = HTTPRedirectHandler.http_error_302( self, req, fp, code, msg, headers ) result.orig_status = code result.orig_headers = headers result.cookie = cookie return result request = Request(jenkins_url, data) opener = build_opener(SmartRedirectHandler()) res = opener.open(request) Requester.AUTH_COOKIE = res.cookie def get_lockable_resources(self) -> LockableResources: return LockableResources(self) def get_jobs_by_status(self, status: str) -> list: """ Return all jobs matching the given build status. :param status: one of "success", "failure", "unstable", "aborted", "disabled", "notbuilt" :return: list of Job objects """ COLOR_TO_STATUS = { "blue": "success", "red": "failure", "yellow": "unstable", "aborted": "aborted", "disabled": "disabled", "notbuilt": "notbuilt", "grey": "notbuilt", } target = status.lower() result = [] for row in self.jobs.poll().get("jobs", []): color = row.get("color", "").replace("_anime", "") if COLOR_TO_STATUS.get(color) == target: result.append(Job(row["url"].rstrip("/"), row["name"], self)) return result def reload(self, wait_for_completion: bool = True) -> None: """ Reload Jenkins configuration from disk without restarting. Useful after manually editing config files on the server. """ url = "%s/reload" % self.baseurl valid = self.requester.VALID_STATUS_CODES + [503, 500] self.requester.post_and_confirm_status(url, data="", valid=valid) if wait_for_completion: self._wait_for_reboot() jenkinsapi-0.3.23/jenkinsapi/jenkinsbase.py0000644000000000000000000001073013615410400015654 0ustar00""" Module for JenkinsBase class """ from __future__ import annotations import json import pprint import logging from urllib.parse import quote from jenkinsapi import config from jenkinsapi.custom_exceptions import JenkinsAPIException logger = logging.getLogger(__name__) class JenkinsBase(object): """ This appears to be the base object that all other jenkins objects are inherited from """ def __repr__(self): return """<%s.%s %s>""" % ( self.__class__.__module__, self.__class__.__name__, str(self), ) def __str__(self): raise NotImplementedError def __init__(self, baseurl: str, poll: bool = True): """ Initialize a jenkins connection """ self._data = None self.baseurl = self.strip_trailing_slash(baseurl) if poll: self.poll() def get_jenkins_obj(self): raise NotImplementedError( "Please implement this method on %s" % self.__class__.__name__ ) def __eq__(self, other) -> bool: """ Return true if the other object represents a connection to the same server """ if not isinstance(other, self.__class__): return False return other.baseurl == self.baseurl @classmethod def strip_trailing_slash(cls, url: str) -> str: while url.endswith("/"): url = url[:-1] return url def poll(self, tree=None): data = self._poll(tree=tree) if "jobs" in data: data["jobs"] = self.resolve_job_folders(data["jobs"]) if not tree: self._data = data return data def _poll(self, tree=None): url = self.python_api_url(self.baseurl) return self.get_data(url, tree=tree) def get_data(self, url, params=None, tree=None): requester = self.get_jenkins_obj().requester if tree: if not params: params = {"tree": tree} else: params.update({"tree": tree}) response = requester.get_url(url, params) if response.status_code != 200: logger.error( "Failed request at %s with params: %s %s", url, params, tree if tree else "", ) response.raise_for_status() try: return json.loads(response.text) except json.JSONDecodeError: # Fallback: Some Jenkins API endpoints return Python literal syntax # (True, False, None) instead of JSON (true, false, null) try: converted = ( response.text.replace("True", "true") .replace("False", "false") .replace("None", "null") ) return json.loads(converted) except Exception: logger.exception("Inappropriate content found at %s", url) raise JenkinsAPIException("Cannot parse %s" % response.content) except Exception: logger.exception("Inappropriate content found at %s", url) raise JenkinsAPIException("Cannot parse %s" % response.content) def pprint(self): """ Print out all the data in this object for debugging. """ pprint.pprint(self._data) def resolve_job_folders(self, jobs): for job in list(jobs): if "color" not in job.keys(): jobs.remove(job) jobs += self.process_job_folder(job, self.baseurl) return jobs def process_job_folder(self, folder, folder_path): logger.debug("Processing folder %s in %s", folder["name"], folder_path) folder_path += "/job/%s" % quote(folder["name"]) data = self.get_data( self.python_api_url(folder_path), tree="jobs[name,color]" ) result = [] for job in data.get("jobs", []): if "color" not in job.keys(): result += self.process_job_folder(job, folder_path) else: job["url"] = "%s/job/%s" % (folder_path, quote(job["name"])) result.append(job) return result @classmethod def python_api_url(cls, url: str) -> str: if url.endswith(config.JENKINS_API): return url else: if url.endswith(r"/"): fmt = "%s%s" else: fmt = "%s/%s" return fmt % (url, config.JENKINS_API) jenkinsapi-0.3.23/jenkinsapi/job.py0000644000000000000000000007724713615410400014152 0ustar00""" Module for jenkinsapi Job """ from __future__ import annotations import json import logging import xml.etree.ElementTree as ET import urllib.parse as urlparse from collections import defaultdict from jenkinsapi.build import Build from jenkinsapi.credentials import Credentials2x from jenkinsapi.custom_exceptions import ( NoBuildData, NotConfiguredSCM, NotFound, NotInQueue, NotSupportSCM, UnknownQueueItem, BadParams, ) from jenkinsapi.jenkinsbase import JenkinsBase from jenkinsapi.mutable_jenkins_thing import MutableJenkinsThing from jenkinsapi.queue import QueueItem SVN_URL = "./scm/locations/hudson.scm.SubversionSCM_-ModuleLocation/remote" GIT_URL = "./scm/userRemoteConfigs/hudson.plugins.git.UserRemoteConfig/url" HG_URL = "./scm/source" GIT_BRANCH = "./scm/branches/hudson.plugins.git.BranchSpec/name" HG_BRANCH = "./scm/branch" DEFAULT_HG_BRANCH_NAME = "default" log = logging.getLogger(__name__) class Job(JenkinsBase, MutableJenkinsThing): """ Represents a jenkins job A job can hold N builds which are the actual execution environments """ def __init__(self, url: str, name: str, jenkins_obj: "Jenkins") -> None: self.name: str = name self.jenkins: "Jenkins" = jenkins_obj self._revmap = None self._config = None self._element_tree = None self._scm_prefix = "" self._scm_map = { "hudson.scm.SubversionSCM": "svn", "hudson.plugins.git.GitSCM": "git", "hudson.plugins.mercurial.MercurialSCM": "hg", "hudson.scm.NullSCM": "NullSCM", } self._scmurlmap = { "svn": lambda element_tree: list(element_tree.findall(SVN_URL)), "git": lambda element_tree: list( element_tree.findall(self._scm_prefix + GIT_URL) ), "hg": lambda element_tree: list(element_tree.findall(HG_URL)), None: lambda element_tree: [], } self._scmbranchmap = { "svn": lambda element_tree: [], "git": lambda element_tree: list( element_tree.findall(self._scm_prefix + GIT_BRANCH) ), "hg": self._get_hg_branch, None: lambda element_tree: [], } self.url: str = url JenkinsBase.__init__(self, self.url) def __str__(self) -> str: return self.name def get_description(self) -> str: return self._data["description"] def get_jenkins_obj(self) -> "Jenkins": return self.jenkins @property def credentials(self) -> "Credentials2x": """ Return folder-scoped credentials for this job/folder. Requires the Credentials Binding plugin and the job to be a folder. """ url = "%s/credentials/store/folder/domain/_/" % self.baseurl return Credentials2x(url, self.jenkins, poll=False) # When the name of the hg branch used in the job is default hg branch (i.e. # default), Mercurial plugin doesn't store default branch name in # config XML file of the job. Create XML node corresponding to # default branch def _get_hg_branch(self, element_tree): branches = element_tree.findall(HG_BRANCH) if not branches: hg_default_branch = ET.Element("branch") hg_default_branch.text = DEFAULT_HG_BRANCH_NAME branches.append(hg_default_branch) return branches def poll(self, tree=None): data = super(Job, self).poll(tree=tree) if not tree and not self.jenkins.lazy: self._data = self._add_missing_builds(self._data) return data # pylint: disable=E1123 # Unexpected keyword arg 'params' def _add_missing_builds(self, data): """ Query Jenkins to get all builds of the job in the data object. Jenkins API loads the first 100 builds and thus may not contain all builds information. This method checks if all builds are loaded in the data object and updates it with the missing builds if needed. """ if not data.get("builds"): return data # do not call _buildid_for_type here: it would poll and do an infinite # loop oldest_loaded_build_number = data["builds"][-1]["number"] if "firstBuild" not in self._data or not self._data["firstBuild"]: first_build_number = oldest_loaded_build_number else: first_build_number = self._data["firstBuild"]["number"] all_builds_loaded = oldest_loaded_build_number == first_build_number if all_builds_loaded: return data response = self.poll(tree="allBuilds[number,url]") data["builds"] = response["allBuilds"] return data def _get_config_element_tree(self): """ The ElementTree objects creation is unnecessary, it can be a singleton per job """ if self._config is None: self.load_config() if self._element_tree is None: self._element_tree = ET.fromstring(self._config) return self._element_tree def get_build_triggerurl(self) -> str: if not self.has_params(): return "%s/build" % self.baseurl return "%s/buildWithParameters" % self.baseurl @staticmethod def _mk_json_from_build_parameters(build_params, file_params=None): """ Build parameters must be submitted in a particular format Key-Value pairs would be far too simple, no no! Watch and read on and behold! """ if not isinstance(build_params, dict): raise ValueError("Build parameters must be a dict") build_p = [ {"name": k, "value": v} for k, v in sorted(build_params.items()) ] out = {"parameter": build_p} if file_params: file_p = [{"name": k, "file": k} for k in file_params.keys()] out["parameter"].extend(file_p) if len(out["parameter"]) == 1: out["parameter"] = out["parameter"][0] return out @staticmethod def mk_json_from_build_parameters(build_params, file_params=None): json_structure = Job._mk_json_from_build_parameters( build_params, file_params ) json_structure["statusCode"] = "303" json_structure["redirectTo"] = "." return json.dumps(json_structure) def invoke( self, securitytoken=None, block: bool = False, build_params=None, cause=None, files=None, delay: int = 5, quiet_period=None, ) -> QueueItem: assert isinstance(block, bool) if build_params and (not self.has_params()): raise BadParams("This job does not support parameters") params = {} # Via Get string if securitytoken: params["token"] = securitytoken # Either copy the params dict or make a new one. build_params = ( dict(build_params.items()) if build_params else {} ) # Via POSTed JSON url = self.get_build_triggerurl() # If quiet period is set, the build will have {quiet_period} seconds # quiet peroid before start. if quiet_period is not None: url += "?delay={0}sec".format(quiet_period) if cause: build_params["cause"] = cause # Build require params as form fields # and as Json. data = { "json": self.mk_json_from_build_parameters(build_params, files) } data.update(build_params) response = self.jenkins.requester.post_and_confirm_status( url, data=data, params=params, files=files, valid=[200, 201, 303], allow_redirects=False, ) redirect_url = response.headers["location"] # # Enterprise Jenkins implementations such as CloudBees locate their # queue REST API base https://server.domain.com/jenkins/queue/api/ # above the team-specific REST API base # https://server.domain.com/jenkins/job/my_team/api/ # queue_baseurl_candidates = [self.jenkins.baseurl] scheme, netloc, path, _, query, frag = urlparse.urlparse( self.jenkins.baseurl ) while path: path = "/".join(path.rstrip("/").split("/")[:-1]) queue_baseurl_candidates.append( urlparse.urlunsplit([scheme, netloc, path, query, frag]) ) redirect_url_valid = False for queue_baseurl_candidate in queue_baseurl_candidates: redirect_url_valid = redirect_url.startswith( "%s/queue/item" % queue_baseurl_candidate ) if redirect_url_valid: break if not redirect_url_valid: raise ValueError("Not a Queue URL: %s" % redirect_url) qi = QueueItem(redirect_url, self.jenkins) if block: qi.block_until_complete(delay=delay) return qi def _buildid_for_type(self, buildtype): """ Gets a buildid for a given type of build """ KNOWNBUILDTYPES = [ "lastStableBuild", "lastSuccessfulBuild", "lastBuild", "lastCompletedBuild", "firstBuild", "lastFailedBuild", ] assert buildtype in KNOWNBUILDTYPES, ( "Unknown build info type: %s" % buildtype ) data = self.poll(tree="%s[number]" % buildtype) if not data.get(buildtype): raise NoBuildData(buildtype) return data[buildtype]["number"] def get_first_buildnumber(self): """ Get the numerical ID of the first build. """ return self._buildid_for_type("firstBuild") def get_last_stable_buildnumber(self): """ Get the numerical ID of the last stable build. """ return self._buildid_for_type("lastStableBuild") def get_last_good_buildnumber(self): """ Get the numerical ID of the last good build. """ return self._buildid_for_type("lastSuccessfulBuild") def get_last_failed_buildnumber(self): """ Get the numerical ID of the last failed build. """ return self._buildid_for_type(buildtype="lastFailedBuild") def get_last_buildnumber(self): """ Get the numerical ID of the last build. """ return self._buildid_for_type("lastBuild") def get_last_completed_buildnumber(self): """ Get the numerical ID of the last complete build. """ return self._buildid_for_type("lastCompletedBuild") def get_build_dict(self): builds = self.poll(tree="builds[number,url]") if not builds: raise NoBuildData(repr(self)) builds = self._add_missing_builds(builds) builds = builds["builds"] last_build = self.poll(tree="lastBuild[number,url]")["lastBuild"] if ( builds and last_build and builds[0]["number"] != last_build["number"] ): builds = [last_build] + builds # FIXME SO how is this supposed to work if build is false-y? # I don't think that builds *can* be false here, so I don't # understand the test above. return dict((build["number"], build["url"]) for build in builds) def get_build_by_params(self, build_params, order=1): first_build_number = self.get_first_buildnumber() last_build_number = self.get_last_buildnumber() if order != 1 and order != -1: raise ValueError( "Direction should be ascending or descending (1/-1)" ) for number in range(first_build_number, last_build_number + 1)[ ::order ]: build = self.get_build(number) if build.get_params() == build_params: return build raise NoBuildData( "No build with such params {params}".format(params=build_params) ) def get_revision_dict(self): """ Get dictionary of all revisions with a list of buildnumbers (int) that used that particular revision """ revs = defaultdict(list) if "builds" not in self._data: raise NoBuildData(repr(self)) for buildnumber in self.get_build_ids(): rev = self.get_build(buildnumber).get_revision() if rev is not None: revs[rev].append(buildnumber) return revs def get_build_ids(self): """ Return a sorted list of all good builds as ints. """ return reversed(sorted(self.get_build_dict().keys())) def get_next_build_number(self): """ Return the next build number that Jenkins will assign. """ return self._data.get("nextBuildNumber", 0) def get_last_stable_build(self): """ Get the last stable build """ bn = self.get_last_stable_buildnumber() return self.get_build(bn) def get_last_good_build(self): """ Get the last good build """ bn = self.get_last_good_buildnumber() return self.get_build(bn) def get_last_build(self): """ Get the last build """ bn = self.get_last_buildnumber() return self.get_build(bn) def get_first_build(self): bn = self.get_first_buildnumber() return self.get_build(bn) def get_last_build_or_none(self): """ Get the last build or None if there is no builds """ try: return self.get_last_build() except NoBuildData: return None def get_last_completed_build(self): """ Get the last build regardless of status """ bn = self.get_last_completed_buildnumber() return self.get_build(bn) def get_buildnumber_for_revision(self, revision, refresh=False): """ Get buildnumbers for a given revision. :param revision: revision to look for. For SVN, this is an int. For Git, this is a SHA1 string. :param refresh: boolean, whether or not to refresh the revision -> buildnumber map :return: list of buildnumbers, [int] """ if self.get_scm_type() == "svn" and not isinstance(revision, int): revision = int(revision) if self._revmap is None or refresh: self._revmap = self.get_revision_dict() try: return self._revmap[revision] except KeyError: raise NotFound("Couldn't find a build with that revision") def get_build(self, buildnumber): assert isinstance(buildnumber, int) try: url = self.get_build_dict()[buildnumber] return Build(url, buildnumber, job=self) except KeyError: raise NotFound("Build #%s not found" % buildnumber) def delete_build(self, build_number): """ Remove build :param int build_number: Build number :raises NotFound: When build is not found """ try: url = self.get_build_dict()[build_number] url = "%s/doDelete" % url self.jenkins.requester.post_and_confirm_status(url, data="") self.jenkins.poll() except KeyError: raise NotFound("Build #%s not found" % build_number) def get_build_metadata(self, buildnumber): """ Get the build metadata for a given build number. For large builds with tons of tests, this method is faster than get_build by returning less data. """ if not isinstance(buildnumber, int): raise ValueError('Parameter "buildNumber" must be int') try: url = self.get_build_dict()[buildnumber] return Build(url, buildnumber, job=self, depth=0) except KeyError: raise NotFound("Build #%s not found" % buildnumber) def __delitem__(self, build_number): self.delete_build(build_number) def __getitem__(self, buildnumber): return self.get_build(buildnumber) def __len__(self): return len(self.get_build_dict()) def is_queued_or_running(self): return self.is_queued() or self.is_running() def is_queued(self): data = self.poll(tree="inQueue") return data.get("inQueue", False) def get_queue_item(self): """ Return a QueueItem if this object is in a queue, otherwise raise an exception """ if not self.is_queued(): raise UnknownQueueItem() q_item = self.poll(tree="queueItem[url]") qi_url = urlparse.urljoin( self.jenkins.baseurl, q_item["queueItem"]["url"] ) return QueueItem(qi_url, self.jenkins) def is_running(self): # self.poll() try: build = self.get_last_build_or_none() if build is not None: return build.is_running() except NoBuildData: log.info( "No build info available for %s, assuming not running.", str(self), ) return False def get_config(self): """ Returns the config.xml from the job """ response = self.jenkins.requester.get_and_confirm_status( "%(baseurl)s/config.xml" % self.__dict__ ) return response.text def load_config(self): self._config = self.get_config() def get_scm_type(self): element_tree = self._get_config_element_tree() scm_element = element_tree.find("scm") # Search alternative locations for SCM if not at root level if scm_element is None: scm_alt_locations = [ { "prefix": ( "properties/org.jenkinsci.plugins." "workflow.multibranch.BranchJobProperty/branch/" ), "path": ( "properties/org.jenkinsci.plugins." "workflow.multibranch.BranchJobProperty/branch/scm" ), }, { "prefix": "definition/", "path": "definition/scm", }, ] for alt_location in scm_alt_locations: scm_element = element_tree.find(alt_location["path"]) if scm_element is not None: self._scm_prefix = alt_location["prefix"] break scm_class = ( scm_element.get("class") if scm_element is not None else None ) scm = self._scm_map.get(scm_class) if not scm: raise NotSupportSCM( 'SCM class "%s" not supported by API for job "%s"' % (scm_class, self.name) ) if scm == "NullSCM": raise NotConfiguredSCM( 'SCM is not configured for job "%s"' % self.name ) return scm def get_scm_url(self): """ Get list of project SCM urls For some SCM's jenkins allow to configure and use number of SCM url's : return: list of SCM urls """ element_tree = self._get_config_element_tree() scm = self.get_scm_type() scm_url_list = [ scm_url.text for scm_url in self._scmurlmap[scm](element_tree) ] return scm_url_list def get_scm_branch(self): """ Get list of SCM branches : return: list of SCM branches """ element_tree = self._get_config_element_tree() scm = self.get_scm_type() return [ scm_branch.text for scm_branch in self._scmbranchmap[scm](element_tree) ] def modify_scm_branch(self, new_branch, old_branch=None): """ Modify SCM ("Source Code Management") branch name for configured job. :param new_branch : new repository branch name to set. If job has multiple branches configured and "old_branch" not provided - method will allways modify first url. :param old_branch (optional): exact value of branch name to be replaced. For some SCM's jenkins allow set multiple branches per job this parameter intended to indicate which branch need to be modified """ element_tree = self._get_config_element_tree() scm = self.get_scm_type() scm_branch_list = self._scmbranchmap[scm](element_tree) if scm_branch_list and not old_branch: scm_branch_list[0].text = new_branch self.update_config(ET.tostring(element_tree)) else: for scm_branch in scm_branch_list: if scm_branch.text == old_branch: scm_branch.text = new_branch self.update_config(ET.tostring(element_tree)) def modify_scm_url(self, new_source_url, old_source_url=None): """ Modify SCM ("Source Code Management") url for configured job. :param new_source_url : new repository url to set. If job has multiple repositories configured and "old_source_url" not provided - method will allways modify first url. :param old_source_url (optional): for some SCM's jenkins allows settting multiple repositories per job this parameter intended to indicate which repository need to be modified """ element_tree = self._get_config_element_tree() scm = self.get_scm_type() scm_url_list = self._scmurlmap[scm](element_tree) if scm_url_list and not old_source_url: scm_url_list[0].text = new_source_url self.update_config(ET.tostring(element_tree)) else: for scm_url in scm_url_list: if scm_url.text == old_source_url: scm_url.text = new_source_url self.update_config(ET.tostring(element_tree)) def get_config_xml_url(self): return "%s/config.xml" % self.baseurl def update_config(self, config, full_response=False, encoding="utf-8"): """ Update the config.xml to the job Also refresh the ElementTree object since the config has changed :param full_response (optional): if True, it will return the full response object instead of just the response text. Useful for debugging and validation workflows. """ url = self.get_config_xml_url() if isinstance(config, bytes): config = config.decode(encoding) else: config = str(config) # cast unicode in case of Python 2 response = self.jenkins.requester.post_url( url, params={}, data=config.encode(encoding) ) self._element_tree = ET.fromstring(config) if full_response: return response return response.text def get_downstream_jobs(self): """ Get all the possible downstream jobs :return List of Job """ downstream_jobs = [] try: for j in self._data["downstreamProjects"]: downstream_jobs.append(self.get_jenkins_obj()[j["name"]]) except KeyError: return [] return downstream_jobs def get_downstream_job_names(self): """ Get all the possible downstream job names :return List of String """ downstream_jobs = [] try: for j in self._data["downstreamProjects"]: downstream_jobs.append(j["name"]) except KeyError: return [] return downstream_jobs def get_upstream_job_names(self): """ Get all the possible upstream job names :return List of String """ upstream_jobs = [] try: for j in self._data["upstreamProjects"]: upstream_jobs.append(j["name"]) except KeyError: return [] return upstream_jobs def get_upstream_jobs(self): """ Get all the possible upstream jobs :return List of Job """ upstream_jobs = [] try: for j in self._data["upstreamProjects"]: upstream_jobs.append(self.get_jenkins_obj().get_job(j["name"])) except KeyError: return [] return upstream_jobs def is_enabled(self): data = self.poll(tree="color") return "disabled" not in data.get("color", "") def disable(self): """ Disable job """ url = "%s/disable" % self.baseurl return self.get_jenkins_obj().requester.post_url(url, data="") def enable(self): """ Enable job """ url = "%s/enable" % self.baseurl return self.get_jenkins_obj().requester.post_url(url, data="") def delete_from_queue(self): """ Delete a job from the queue only if it's enqueued :raise NotInQueue if the job is not in the queue """ if not self.is_queued(): raise NotInQueue() queue_id = self._data["queueItem"]["id"] url = urlparse.urljoin( self.get_jenkins_obj().get_queue().baseurl, "queue/cancelItem?id=%s" % queue_id, ) self.get_jenkins_obj().requester.post_and_confirm_status(url, data="") return True def get_params(self): """ Get the parameters for this job. Format varies by parameter type. Here is an example string parameter: { 'type': 'StringParameterDefinition', 'description': 'Parameter description', 'defaultParameterValue': {'value': 'default value'}, 'name': 'FOO_BAR' } """ places = ["actions", "property"] found_definitions = False for place in places: if found_definitions: return actions = (x for x in self._data[place] if x is not None) for action in actions: try: for param in action["parameterDefinitions"]: found_definitions = True yield param except KeyError: continue def get_params_list(self): """ Gets the list of parameter names for this job. """ return [param["name"] for param in self.get_params()] def has_params(self): """ If job has parameters, returns True, else False """ if any( "parameterDefinitions" in a for a in (self._data["actions"]) if a ): return True if any( "parameterDefinitions" in a for a in (self._data["property"]) if a ): return True return False def has_queued_build(self, build_params): """ Returns True if a build with build_params is currently queued. """ queue = self.jenkins.get_queue() queued_builds = queue.get_queue_items_for_job(self.name) for build in queued_builds: if build.get_parameters() == build_params: return True return False @staticmethod def get_full_name_from_url_and_baseurl(url, baseurl): """ Get the full name for a job (including parent folders) from the job URL. """ path = url.replace(baseurl, "") split = path.split("/") split = [urlparse.unquote(part) for part in split[::2] if part] return "/".join(split) def get_full_name(self): """ Get the full name for a job (including parent folders) from the job URL. """ return Job.get_full_name_from_url_and_baseurl( self.url, self.jenkins.baseurl ) def toggle_keep_build(self, build_number): self.get_build(build_number).toggle_keep() def recreate(self) -> "Job": """ Delete and recreate the job using its current configuration. Useful for resetting build history while keeping job settings. :return: the recreated Job object """ config = self.get_config() name = self.name jenkins = self.get_jenkins_obj() del jenkins.jobs[name] return jenkins.jobs.create(name, config) def wipe_out_workspace(self): """ Wipe out the workspace of this job. """ url = "%s/doWipeOutWorkspace" % self.baseurl return self.get_jenkins_obj().requester.post_and_confirm_status( url, data="" ) def trigger_scm_poll(self): """ Trigger an SCM poll for this job to check for new changes. """ url = "%s/polling" % self.baseurl return self.get_jenkins_obj().requester.post_and_confirm_status( url, data="" ) def change_description(self, description: str) -> None: """ Update the description of this job. :param description: new description text """ element_tree = self._get_config_element_tree() node = element_tree.find("description") if node is None: node = ET.SubElement(element_tree, "description") if node.text != description: node.text = description self.update_config(ET.tostring(element_tree)) def set_concurrent_builds(self, enabled): """ Enable or disable concurrent build execution for this job. :param enabled: bool, True to allow concurrent builds """ element_tree = self._get_config_element_tree() node = element_tree.find("concurrentBuild") if node is None: node = ET.SubElement(element_tree, "concurrentBuild") new_value = "true" if enabled else "false" if node.text != new_value: node.text = new_value self.update_config(ET.tostring(element_tree)) def block_build_when_downstream_building(self): """ Block builds of this job while downstream projects are building. """ self._set_build_block("blockBuildWhenDownstreamBuilding", True) def unblock_build_when_downstream_building(self): """ Allow builds of this job while downstream projects are building. """ self._set_build_block("blockBuildWhenDownstreamBuilding", False) def block_build_when_upstream_building(self): """ Block builds of this job while upstream projects are building. """ self._set_build_block("blockBuildWhenUpstreamBuilding", True) def unblock_build_when_upstream_building(self): """ Allow builds of this job while upstream projects are building. """ self._set_build_block("blockBuildWhenUpstreamBuilding", False) def _set_build_block(self, element_name, block): element_tree = self._get_config_element_tree() node = element_tree.find(element_name) if node is None: node = ET.SubElement(element_tree, element_name) new_value = "true" if block else "false" if node.text != new_value: node.text = new_value self.update_config(ET.tostring(element_tree)) def restrict_to_node(self, node_name): """ Restrict this job to run only on the specified node or label. :param node_name: string, name of the node or label to restrict to """ element_tree = self._get_config_element_tree() assigned_node = element_tree.find("assignedNode") if assigned_node is not None: assigned_node.text = node_name else: assigned_node = ET.SubElement(element_tree, "assignedNode") assigned_node.text = node_name can_roam = element_tree.find("canRoam") if can_roam is not None: can_roam.text = "false" self.update_config(ET.tostring(element_tree)) jenkinsapi-0.3.23/jenkinsapi/jobs.py0000644000000000000000000002777113615410400014332 0ustar00""" This module implements the Jobs class, which is intended to be a container-like interface for all of the jobs defined on a single Jenkins server. """ from __future__ import annotations from typing import Iterator import logging import time from urllib.parse import quote, unquote from jenkinsapi.job import Job from jenkinsapi.custom_exceptions import JenkinsAPIException, UnknownJob log = logging.getLogger(__name__) class Jobs(object): """ This class provides a container-like API which gives access to all jobs defined on the Jenkins server. It behaves like a dict in which keys are Job-names and values are actual jenkinsapi.Job objects. """ def __init__(self, jenkins: "Jenkins") -> None: self.jenkins = jenkins self._data = [] def _del_data(self, job_name: str) -> None: if not self._data: return for num, job_data in enumerate(self._data): if job_data["name"] == job_name: del self._data[num] return def __len__(self) -> int: return len(self.keys()) def poll(self, tree="jobs[name,color,url]"): return self.jenkins.poll(tree=tree) def __delitem__(self, job_name: str) -> None: """ Delete a job by name :param str job_name: name of a existing job :raises JenkinsAPIException: When job is not deleted """ normalized_name = self._normalize_job_name(job_name) if normalized_name in self: try: delete_job_url = self[normalized_name].get_delete_url() self.jenkins.requester.post_and_confirm_status( delete_job_url, data="some random bytes..." ) self._del_data(normalized_name) except JenkinsAPIException: # Sometimes jenkins throws NPE when removing job # It removes job ok, but it is good to be sure # so we re-try if job was not deleted if normalized_name in self: delete_job_url = self[normalized_name].get_delete_url() self.jenkins.requester.post_and_confirm_status( delete_job_url, data="some random bytes..." ) self._del_data(normalized_name) def __setitem__(self, key: str, value: str) -> "Job": """ Create Job :param str key: Job name :param str value: XML configuration of the job .. code-block:: python api = Jenkins('http://localhost:8080/') new_job = api.jobs['my_new_job'] = config_xml """ return self.create(key, value) def __getitem__(self, job_name: str) -> "Job": normalized_name = self._normalize_job_name(job_name) if normalized_name in self: job_data = next( job_row for job_row in self._data if self._job_row_matches_name(job_row, normalized_name) ) name = self._normalize_job_name(job_data["name"]) if name != normalized_name: name = normalized_name return Job( Job.strip_trailing_slash(job_data["url"]), name, self.jenkins, ) else: raise UnknownJob(normalized_name) def iteritems(self) -> Iterator[str, "Job"]: """ Iterate over the names & objects for all jobs """ for job in self.itervalues(): if job.name != job.get_full_name(): yield job.get_full_name(), job else: yield job.name, job def __contains__(self, job_name: str) -> bool: """ True if job_name exists in Jenkins """ normalized_name = self._normalize_job_name(job_name) if not self._data: self._data = self.poll().get("jobs", []) return any( self._job_row_matches_name(job_row, normalized_name) for job_row in self._data ) def iterkeys(self) -> Iterator[str]: """ Iterate over the names of all available jobs """ if not self._data: self._data = self.poll().get("jobs", []) for row in self._data: row_name = self._normalize_job_name(row["name"]) full_name = self._get_full_name_from_row(row) if "/" in full_name: yield full_name elif row_name: yield row_name else: yield full_name def itervalues(self) -> Iterator["Job"]: """ Iterate over all available jobs """ if not self._data: self._data = self.poll().get("jobs", []) for row in self._data: yield Job( Job.strip_trailing_slash(row["url"]), row["name"], self.jenkins, ) def keys(self) -> list[str]: """ Return a list of the names of all jobs """ return list(self.iterkeys()) def create(self, job_name: str, config: str | bytes) -> "Job": """ Create a job :param str jobname: Name of new job :param str config: XML configuration of new job :returns Job: new Job object """ full_name, folder_parts, job_leaf = self._split_job_name(job_name) if full_name in self: return self[full_name] if not config: raise JenkinsAPIException("Job XML config cannot be empty") create_url = self._get_create_url(folder_parts) params = {"name": job_leaf} if isinstance(config, bytes): config = config.decode("utf-8") self.jenkins.requester.post_xml_and_confirm_status( create_url, data=config, params=params ) # Reset to get it refreshed from Jenkins self._data = [] return Job(self._build_job_url(full_name), full_name, self.jenkins) def create_multibranch_pipeline( self, job_name: str, config: str, block: bool = True, delay: int = 60 ) -> list["Job"]: """ Create a multibranch pipeline job :param str jobname: Name of new job :param str config: XML configuration of new job :param block: block until scan is finished? :param delay: max delay to wait for scan to finish (seconds) :returns list of new Jobs after scan """ if not config: raise JenkinsAPIException("Job XML config cannot be empty") full_name, folder_parts, job_leaf = self._split_job_name(job_name) params = {"name": job_leaf} if isinstance(config, bytes): config = config.decode("utf-8") self.jenkins.requester.post_xml_and_confirm_status( self._get_create_url(folder_parts), data=config, params=params ) # Reset to get it refreshed from Jenkins self._data = [] # Launch a first scan / indexing to discover the branches... self.jenkins.requester.post_and_confirm_status( "{}/build".format(self._build_job_url(full_name)), data="", valid=[200, 302], # expect 302 without redirects allow_redirects=False, ) start_time = time.time() # redirect-url does not work with indexing; # so the only workaround found is to parse the console output # until scan has finished. scan_finished = False while not scan_finished and block and time.time() < start_time + delay: indexing_console_text = self.jenkins.requester.get_url( "{}/indexing/consoleText".format( self._build_job_url(full_name) ) ) if ( indexing_console_text.text.strip() .split("\n")[-1] .startswith("Finished:") ): scan_finished = True time.sleep(1) # now search for all jobs created; those who start with job_name + '/' jobs = [] for name in self.jenkins.get_jobs_list(): if name.startswith(full_name + "/"): jobs.append(self[name]) return jobs def copy(self, job_name: str, new_job_name: str) -> "Job": """ Copy a job :param str job_name: Name of an existing job :param new_job_name: Name of new job :returns Job: new Job object """ full_source_name = self._normalize_job_name(job_name) full_target_name, folder_parts, job_leaf = self._split_job_name( new_job_name ) params = {"name": job_leaf, "mode": "copy", "from": full_source_name} self.jenkins.requester.post_and_confirm_status( self._get_create_url(folder_parts), params=params, data="" ) self._data = [] return self[full_target_name] @staticmethod def _normalize_job_name(job_name: str) -> str: name = (job_name or "").strip().strip("/") if not name: return name if name.startswith("job/"): name = name[4:] if "/job/" in name: name = name.replace("/job/", "/").lstrip("/") return name def _split_job_name(self, job_name: str) -> tuple[str, list[str], str]: full_name = self._normalize_job_name(job_name) parts = [part for part in full_name.split("/") if part] if not parts: return full_name, [], "" return full_name, parts[:-1], parts[-1] def _get_full_name_from_row(self, row: dict) -> str: url = row.get("url", "") path = url.replace(self.jenkins.baseurl, "").strip("/") if path: tokens = path.split("/") parts = [] idx = 0 while idx < len(tokens): if tokens[idx] == "job" and idx + 1 < len(tokens): parts.append(unquote(tokens[idx + 1])) idx += 2 else: idx += 1 if parts: return "/".join(parts) return self._normalize_job_name( Job.get_full_name_from_url_and_baseurl(url, self.jenkins.baseurl) ) def _job_row_matches_name(self, row: dict, normalized_name: str) -> bool: if self._normalize_job_name(row.get("name", "")) == normalized_name: return True return self._get_full_name_from_row(row) == normalized_name def _get_create_url(self, folder_parts: list[str]) -> str: if not folder_parts: return self.jenkins.get_create_url() folder_path = "/job/".join(quote(part) for part in folder_parts) return "{}/job/{}/createItem".format(self.jenkins.baseurl, folder_path) def _build_job_url(self, full_name: str) -> str: parts = [part for part in full_name.split("/") if part] if not parts: return self.jenkins.baseurl job_path = "/job/".join(quote(part) for part in parts) return "{}/job/{}".format(self.jenkins.baseurl, job_path) def rename(self, job_name: str, new_job_name: str) -> "Job": """ Rename a job :param str job_name: Name of an existing job :param str new_job_name: Name of new job :returns Job: new Job object """ params = {"newName": new_job_name} rename_job_url = self[job_name].get_rename_url() self.jenkins.requester.post_and_confirm_status( rename_job_url, params=params, data="" ) self._data = [] return self[new_job_name] def build(self, job_name: str, params=None, **kwargs) -> "QueueItem": """ Executes build of a job :param str job_name: Job name :param dict params: Job parameters :param kwargs: Parameters for Job.invoke() function :returns QueueItem: Object to track build progress """ if params: assert isinstance(params, dict) return self[job_name].invoke(build_params=params, **kwargs) return self[job_name].invoke(**kwargs) jenkinsapi-0.3.23/jenkinsapi/label.py0000644000000000000000000000250113615410400014434 0ustar00""" Module for jenkinsapi labels """ from jenkinsapi.jenkinsbase import JenkinsBase import logging log = logging.getLogger(__name__) class Label(JenkinsBase): """ Class to hold information on labels that tied to a collection of jobs """ def __init__(self, baseurl, labelname, jenkins_obj): """ Init a label object by providing all relevant pointers to it :param baseurl: basic url for querying information on a node :param labelname: name of the label :param jenkins_obj: ref to the jenkins obj :return: Label obj """ self.labelname = labelname self.jenkins = jenkins_obj self.baseurl = baseurl JenkinsBase.__init__(self, baseurl) def __str__(self): return "%s" % (self.labelname) def get_jenkins_obj(self): return self.jenkins def is_online(self): return not self.poll(tree="offline")["offline"] def get_tied_jobs(self): """ Get a list of jobs. """ if self.get_tied_job_names(): for job in self.get_tied_job_names(): yield self.get_jenkins_obj().get_job(job["name"]) def get_tied_job_names(self): """ Get a list of the name of tied jobs. """ return self.poll(tree="tiedJobs[name]")["tiedJobs"] jenkinsapi-0.3.23/jenkinsapi/lockable_resources.py0000644000000000000000000003160013615410400017225 0ustar00from abc import ABC, abstractmethod import logging import time from typing import ( TYPE_CHECKING, Dict, Iterator, List, Mapping, Optional, TypedDict, ) from requests import Response from jenkinsapi.custom_exceptions import JenkinsAPIException from jenkinsapi.jenkinsbase import JenkinsBase from jenkinsapi.utils.retry import RetryConfig, SimpleRetryConfig if TYPE_CHECKING: from jenkinsapi.jenkins import Jenkins logger = logging.getLogger(__name__) class LockableResourcePropertyDict(TypedDict): """Property of a lockable resource, as returned by Jenkins API""" name: str value: str class LockableResourceDict(TypedDict, total=False): """ Dictionary representation of a lockable resource This is exactly as returned by Jenkins API """ name: str description: str note: str labels: str labelsAsList: List[str] properties: List[LockableResourcePropertyDict] free: bool stolen: bool lockCause: Optional[str] locked: bool ephemeral: bool reserved: bool reservedBy: Optional[str] reservedByEmail: Optional[str] reservedTimestamp: Optional[int] buildName: Optional[str] class LockableResource: """Object representation of a lockable resource""" def __init__(self, parent: "LockableResources", name: str): self.parent = parent self.name = name @property def data(self) -> LockableResourceDict: return self.parent.data_dict[self.name] def is_free(self) -> bool: """ Check if the resource is free for reservation This is what the java plugin implementation checks internally """ return self.data["free"] def is_reserved(self) -> bool: return self.data["reserved"] def reserve(self) -> None: self.parent.reserve(self.name) def unreserve(self) -> None: self.parent.unreserve(self.name) #: Specific HTTP status code returned by API when resource is locked HTTP_STATUS_CODE_LOCKED = 423 class ResourceLockedError(JenkinsAPIException): """Raised when a resource is locked and cannot be reserved""" class ResourceReservationTimeoutError(JenkinsAPIException, TimeoutError): """Raised when resource reservation times out""" DEFAULT_WAIT_SLEEP_PERIOD = 5 DEFAULT_WAIT_TIMEOUT_PERIOD = 3600 DEFAULT_RETRY_CONFIG = SimpleRetryConfig( sleep_period=DEFAULT_WAIT_SLEEP_PERIOD, timeout=DEFAULT_WAIT_TIMEOUT_PERIOD, ) class LockableResources(JenkinsBase, Mapping[str, LockableResource]): """Object representation of the lockable resource jenkins API""" jenkins: "Jenkins" poll_after_post: bool """ If true then poll again after every successful post request This ensure that resource properties are up-to-date after any changes. Setting this to False would require manual poll() calls but could be more efficient in advanced scenarios with careful usage. """ def __init__( self, jenkins_obj: "Jenkins", poll=True, poll_after_post: bool = True, ): self.jenkins = jenkins_obj baseurl = jenkins_obj.baseurl + "/lockable-resources/api/python" JenkinsBase.__init__(self, baseurl, poll=poll) self.poll_after_post = poll_after_post def __str__(self) -> str: return f"Lockable Resources @ {self.baseurl}" def get_jenkins_obj(self) -> "Jenkins": return self.jenkins def poll(self, tree=None) -> None: super().poll(tree) self._data_dict = None @property def data_list(self) -> List[LockableResourceDict]: """API data as a list of `LockableResourceDict`""" if self._data is None: raise ValueError("need poll") return self._data["resources"] _data_dict: Optional[Dict[str, LockableResourceDict]] = None @property def data_dict(self) -> Dict[str, LockableResourceDict]: """API data as a dict mapping name to `LockableResourceDict`""" if self._data_dict is None: self._data_dict = {item["name"]: item for item in self.data_list} return self._data_dict def __len__(self) -> int: return len(self.data_list) def __iter__(self): return iter(self.data_dict) def __getitem__(self, name: str) -> LockableResource: return LockableResource(self, name) def is_free(self, name: str) -> bool: return self.data_dict[name]["free"] def is_reserved(self, name: str) -> bool: return self.data_dict[name]["reserved"] def _make_resource_request( self, req: str, name: str, ) -> Response: """Make a resource-specific request via HTTP POST NOTE: The lockable-resources plugin endpoints (reserve/unreserve) require the resource name as a URL query parameter (?resource=name), NOT as POST body data. The plugin also requires an authenticated (non-anonymous) user; anonymous requests are silently ignored even when security is "disabled". """ # Resource name must be a URL query parameter; POST body is empty. # Using data= (POST body) causes the action to be silently ignored. response = self.jenkins.requester.post_and_confirm_status( self.jenkins.baseurl + f"/lockable-resources/{req}", params=dict(resource=name), data={}, valid=[ 200, HTTP_STATUS_CODE_LOCKED, ], ) if response.status_code == HTTP_STATUS_CODE_LOCKED: raise ResourceLockedError( f"Resource {name} is busy or reserved by another user." ) if self.poll_after_post: # Jenkins commits state changes asynchronously; poll until the # API reflects the new state rather than using a fixed sleep. self._poll_until_consistent() return response def _poll_until_consistent( self, retries: int = 10, interval: float = 0.2 ) -> None: """Poll repeatedly until two consecutive polls return the same data. This waits for Jenkins to fully commit a state change before returning, without relying on an arbitrary fixed sleep duration. """ self.poll() prev = self._data for _ in range(retries): self.poll() if self._data == prev: return prev = self._data time.sleep(interval) def reserve(self, name: str) -> None: self._make_resource_request("reserve", name) def unreserve(self, name: str) -> None: self._make_resource_request("unreserve", name) def try_reserve( self, selector: "ResourceSelector", ) -> Optional[str]: """ Try to reserve a resource that matches the given condition :return: the name of the reserved resource on success :return: None if all resources are busy """ for resource_name in selector.select(self): resource = self[resource_name] # if server reported that the resource is not free # don't try to reserve it if not resource.is_free(): continue # if server reported that the resource is free # it might have been reserved since the last poll try: resource.reserve() except ResourceLockedError: continue return resource.name return None def wait_reserve( self, selector: "ResourceSelector", retry: RetryConfig = DEFAULT_RETRY_CONFIG, ) -> str: """ Wait for a resource that matches the given condition to become available :return: the name of the reserved resource on success :raise ResourceReservationTimeoutError: if no matching resources are found during the timeout period. """ retry_state = retry.begin() while True: result = self.try_reserve(selector) if result is not None: return result try: retry_state.check_retry() except TimeoutError as err: raise ResourceReservationTimeoutError( f"Timeout waiting for a resource matching {selector} after {retry}" ) from err logger.info("No free resources matching %r, retry", selector) self.poll() def reservation_by_label( self, label: str, retry: RetryConfig = DEFAULT_RETRY_CONFIG, ) -> "LockedResourceReservation": return LockedResourceReservation( self, ResourceLabelSelector(label), retry=retry, ) def reservation_by_name( self, name: str, retry: RetryConfig = DEFAULT_RETRY_CONFIG, ) -> "LockedResourceReservation": return LockedResourceReservation( self, ResourceNameSelector(name), retry=retry, ) def reservation_by_name_list( self, name_list: List[str], retry: RetryConfig = DEFAULT_RETRY_CONFIG, ) -> "LockedResourceReservation": return LockedResourceReservation( self, ResourceNameListSelector(name_list), retry=retry, ) class ResourceSelector(ABC): """Base class for which iterates acceptable resources for a reservation""" @abstractmethod def select(self, lockable_resources: LockableResources) -> Iterator[str]: """Iterate acceptable resource names""" pass class ResourceNameSelector(ResourceSelector): """Implementation of :py:class:`ResourceSelector` that selects a single resource by name""" def __init__(self, name: str): self.name = name def select(self, lockable_resources: LockableResources) -> Iterator[str]: yield self.name def __repr__(self) -> str: return f"{self.__class__.__name__}({self.name!r})" class ResourceNameListSelector(ResourceSelector): """Implementation of :py:class:`ResourceSelector` that selects from a list of resources""" def __init__(self, name_list: List[str]): self.name_list = name_list def select(self, lockable_resources: LockableResources) -> Iterator[str]: return iter(self.name_list) def __repr__(self) -> str: return f"{self.__class__.__name__}({self.name_list!r})" class ResourceLabelSelector(ResourceSelector): """Implementation of :py:class:`ResourceSelector` that selects any resources with a given jenkins label""" def __init__(self, label: str): self.label = label def select(self, lockable_resources: LockableResources) -> Iterator[str]: for resource in lockable_resources.values(): if self.label in resource.data["labelsAsList"]: yield resource.name def __repr__(self) -> str: return f"{self.__class__.__name__}({self.label!r})" class LockedResourceReservation: """ Context manager for locking a Jenkins resource Creating this object does not lock the resource, it is only locked and unlocked on :meth:`__enter__` and :meth:`__exit__` methods. Example:: reservation: LockedResourceReservation = init_reservation() # .. possibly much later ... print("Resource will be locked ...") with reservation as locked_resource: name = locked_resource.locked_resource_name print(f"Resource currently locked: {name}") print("Resource no longer locked") If resources are busy this will retry until it will eventually succeed or time out. :raise ResourceReservationTimeoutError: if reservation process times out """ _locked_resource_name: Optional[str] = None retry: RetryConfig def __init__( self, api: LockableResources, selector: ResourceSelector, retry: RetryConfig = DEFAULT_RETRY_CONFIG, ): self.api = api self.selector = selector self.retry = retry def is_active(self) -> bool: """Check if the resource is currently locked""" return self._locked_resource_name is not None @property def locked_resource_name(self) -> str: """ Return the name of the locked resource This throws an error if the resource is not currently locked. """ if self._locked_resource_name is None: raise RuntimeError("Resource not locked") return self._locked_resource_name def __enter__(self) -> "LockedResourceReservation": """Acquire a lock for the specified label.""" if self._locked_resource_name is not None: raise RuntimeError("Lock already acquired") self._locked_resource_name = self.api.wait_reserve( self.selector, retry=self.retry ) return self def __exit__(self, *a) -> None: if self._locked_resource_name is not None: self.api.unreserve(self._locked_resource_name) self._locked_resource_name = None jenkinsapi-0.3.23/jenkinsapi/mutable_jenkins_thing.py0000644000000000000000000000050613615410400017723 0ustar00""" Module for MutableJenkinsThing """ class MutableJenkinsThing(object): """ A mixin for certain mutable objects which can be renamed and deleted. """ def get_delete_url(self) -> str: return f"{self.baseurl}/doDelete" def get_rename_url(self) -> str: return f"{self.baseurl}/doRename" jenkinsapi-0.3.23/jenkinsapi/node.py0000644000000000000000000005577413615410400014326 0ustar00""" Module for jenkinsapi Node class """ from __future__ import annotations import json import logging import xml.etree.ElementTree as ET import time from jenkinsapi.jenkinsbase import JenkinsBase from jenkinsapi.custom_exceptions import PostRequired, TimeOut from jenkinsapi.custom_exceptions import JenkinsAPIException from urllib.parse import quote as urlquote log = logging.getLogger(__name__) class Node(JenkinsBase): """ Class to hold information on nodes that are attached as slaves to the master jenkins instance """ def __init__( self, jenkins_obj: "Jenkins", baseurl: str, nodename: str, node_dict, poll: bool = True, ) -> None: """ Init a node object by providing all relevant pointers to it :param jenkins_obj: ref to the jenkins obj :param baseurl: basic url for querying information on a node If url is not set - object will construct it itself. This is useful when node is being created and not exists in Jenkins yet :param nodename: hostname of the node :param dict node_dict: Dict with node parameters as described below :param bool poll: set to False if node does not exist or automatic refresh from Jenkins is not required. Default is True. If baseurl parameter is set to None - poll parameter will be set to False JNLP Node: { 'num_executors': int, 'node_description': str, 'remote_fs': str, 'labels': str, 'exclusive': bool } SSH Node: { 'num_executors': int, 'node_description': str, 'remote_fs': str, 'labels': str, 'exclusive': bool, 'host': str, 'port': int 'credential_description': str, 'jvm_options': str, 'java_path': str, 'prefix_start_slave_cmd': str, 'suffix_start_slave_cmd': str 'max_num_retries': int, 'retry_wait_time': int, 'retention': str ('Always' or 'OnDemand') 'ondemand_delay': int (only for OnDemand retention) 'ondemand_idle_delay': int (only for OnDemand retention) 'env': [ { 'key':'TEST', 'value':'VALUE' }, { 'key':'TEST2', 'value':'value2' } ], 'tool_location': [ { "key": "hudson.tasks.Maven$MavenInstallation$DescriptorImpl@Maven 3.0.5", # noqa "home": "/home/apache-maven-3.0.5/" }, { "key": "hudson.plugins.git.GitTool$DescriptorImpl@Default", "home": "/home/git-3.0.5/" }, ] } :return: None :return: Node obj """ self.name: str = nodename self.jenkins: "Jenkins" = jenkins_obj if not baseurl: poll = False baseurl = f"{self.jenkins.baseurl}/computer/{self.name}" JenkinsBase.__init__(self, baseurl, poll=poll) self.node_attributes: dict = node_dict self._element_tree = None self._config = None def get_node_attributes(self) -> dict: """ Gets node attributes as dict Used by Nodes object when node is created :return: Node attributes dict formatted for Jenkins API request to create node """ na: dict = self.node_attributes if not na.get("credential_description", False): # If credentials description is not present - we will create # JNLP node launcher = {"stapler-class": "hudson.slaves.JNLPLauncher"} else: try: credential = self.jenkins.credentials[ na["credential_description"] ] except KeyError: raise JenkinsAPIException( 'Credential with description "%s"' " not found" % na["credential_description"] ) retries: int = ( na["max_num_retries"] if "max_num_retries" in na else 0 ) re_wait: int = ( na["retry_wait_time"] if "retry_wait_time" in na else 0 ) launcher = { "stapler-class": "hudson.plugins.sshslaves.SSHLauncher", "$class": "hudson.plugins.sshslaves.SSHLauncher", "host": na["host"], "port": na["port"], "credentialsId": credential.credential_id, "jvmOptions": na["jvm_options"], "javaPath": na["java_path"], "prefixStartSlaveCmd": na["prefix_start_slave_cmd"], "suffixStartSlaveCmd": na["suffix_start_slave_cmd"], "maxNumRetries": retries, "retryWaitTime": re_wait, } retention = { "stapler-class": "hudson.slaves.RetentionStrategy$Always", "$class": "hudson.slaves.RetentionStrategy$Always", } if "retention" in na and na["retention"].lower() == "ondemand": retention = { "stapler-class": "hudson.slaves.RetentionStrategy$Demand", "$class": "hudson.slaves.RetentionStrategy$Demand", "inDemandDelay": na["ondemand_delay"], "idleDelay": na["ondemand_idle_delay"], } node_props: dict = {"stapler-class-bag": "true"} if "env" in na: node_props.update( { "hudson-slaves-EnvironmentVariablesNodeProperty": { "env": na["env"] } } ) if "tool_location" in na: node_props.update( { "hudson-tools-ToolLocationNodeProperty": { "locations": na["tool_location"] } } ) params = { "name": self.name, "type": "hudson.slaves.DumbSlave$DescriptorImpl", "json": json.dumps( { "name": self.name, "nodeDescription": na.get("node_description", ""), "numExecutors": na["num_executors"], "remoteFS": na["remote_fs"], "labelString": na["labels"], "mode": "EXCLUSIVE" if na["exclusive"] else "NORMAL", "retentionStrategy": retention, "type": "hudson.slaves.DumbSlave", "nodeProperties": node_props, "launcher": launcher, } ), } return params def get_jenkins_obj(self) -> "Jenkins": return self.jenkins def __str__(self) -> str: return self.name def is_online(self) -> bool: return not self.poll(tree="offline")["offline"] def is_temporarily_offline(self) -> bool: return self.poll(tree="temporarilyOffline")["temporarilyOffline"] def is_jnlpagent(self) -> bool: return self._data["jnlpAgent"] def is_idle(self) -> bool: return self.poll(tree="idle")["idle"] def set_online(self) -> None: """ Set node online. Before change state verify client state: if node set 'offline' but 'temporarilyOffline' is not set - client has connection problems and AssertionError raised. If after run node state has not been changed raise AssertionError. """ self.poll() # Before change state check if client is connected if self._data["offline"] and not self._data["temporarilyOffline"]: raise AssertionError( "Node is offline and not marked as " "temporarilyOffline, check client " "connection: offline = %s, " "temporarilyOffline = %s" % (self._data["offline"], self._data["temporarilyOffline"]) ) if self._data["offline"] and self._data["temporarilyOffline"]: self.toggle_temporarily_offline() if self._data["offline"]: raise AssertionError( "The node state is still offline, " "check client connection:" " offline = %s, " "temporarilyOffline = %s" % (self._data["offline"], self._data["temporarilyOffline"]) ) def set_offline(self, message="requested from jenkinsapi") -> None: """ Set node offline. If after run node state has not been changed raise AssertionError. : param message: optional string explain why you are taking this node offline """ if not self._data["offline"]: self.toggle_temporarily_offline(message) data = self.poll(tree="offline,temporarilyOffline") if not data["offline"]: raise AssertionError( "The node state is still online:" + "offline = %s , temporarilyOffline = %s" % (data["offline"], data["temporarilyOffline"]) ) def launch(self) -> None: """ Tries to launch a connection with the slave if it is currently disconnected. Because launching a connection with the slave does not mean it is online (a slave can be launched, but set offline), this function does not check if the launch was successful. """ if not self._data["launchSupported"]: raise AssertionError("The node does not support manually launch.") if not self._data["manualLaunchAllowed"]: raise AssertionError( "It is not allowed to manually launch this node." ) url = self.baseurl + "/launchSlaveAgent" html_result = self.jenkins.requester.post_and_confirm_status( url, data={} ) log.debug(html_result) def toggle_temporarily_offline( self, message="requested from jenkinsapi" ) -> None: """ Switches state of connected node (online/offline) and set 'temporarilyOffline' property (True/False) Calling the same method again will bring node status back. :param message: optional string can be used to explain why you are taking this node offline """ initial_state = self.is_temporarily_offline() url = ( self.baseurl + "/toggleOffline?offlineMessage=" + urlquote(message) ) try: html_result = self.jenkins.requester.get_and_confirm_status(url) except PostRequired: html_result = self.jenkins.requester.post_and_confirm_status( url, data={} ) self.poll() log.debug(html_result) state = self.is_temporarily_offline() if initial_state == state: raise AssertionError( "The node state has not changed: temporarilyOffline = %s" % state ) def update_offline_reason(self, reason: str) -> None: """ Update offline reason on a temporary offline clsuter """ if self.is_temporarily_offline(): url = ( self.baseurl + "/changeOfflineCause?offlineMessage=" + urlquote(reason) ) self.jenkins.requester.post_and_confirm_status(url, data={}) def offline_reason(self) -> str: return self._data["offlineCauseReason"] @property def _et(self): return self._get_config_element_tree() def _get_config_element_tree(self) -> ET.Element: """ Returns an xml element tree for the node's config.xml. The resulting tree is cached for quick lookup. """ if self._config is None: self.load_config() if self._element_tree is None: self._element_tree = ET.fromstring(self._config) return self._element_tree def get_config(self) -> str: """ Returns the config.xml from the node. """ response = self.jenkins.requester.get_and_confirm_status( "%(baseurl)s/config.xml" % self.__dict__ ) return response.text def load_config(self) -> None: """ Loads the config.xml for the node allowing it to be re-queried without generating new requests. """ if self.name == "Built-In Node": raise JenkinsAPIException("Built-In node does not have config.xml") self._config = self.get_config() self._get_config_element_tree() def upload_config(self, config_xml: str) -> None: """ Uploads config_xml to the config.xml for the node. """ if self.name == "Built-In Node": raise JenkinsAPIException("Built-In node does not have config.xml") self.jenkins.requester.post_xml_and_confirm_status( "%(baseurl)s/config.xml" % self.__dict__, data=config_xml ) def get_labels(self) -> str | None: """ Returns the labels for a slave as a string with each label separated by the ' ' character. """ return self.get_config_element("label") def add_labels(self, labels: str | list, dryRun: bool = False) -> None: """Adds new label(s) to a node""" if isinstance(labels, str): labels = labels.split() current_labels = self.get_labels() or "" log.info("Current Node Labels: %s", current_labels) current_labels_set = set(current_labels.split()) updated_labels_set = current_labels_set.union(labels) updated_labels = " ".join(sorted(updated_labels_set)) log.info("Updated Node Labels: %s", updated_labels) if not dryRun: self.set_config_element("label", updated_labels) self.poll() def modify_labels( self, new_labels: str | list[str], dryRun: bool = False ) -> None: """ Replaces the current node labels with new label(s). :param new_labels: A string of space-separated labels or a list of labels to set. """ if isinstance(new_labels, list): new_labels = " ".join(new_labels) log.info("Setting node labels to: %s", new_labels) if not dryRun: self.set_config_element("label", new_labels) self.poll() def delete_labels( self, labels_to_remove: str | list[str], dryRun: bool = False ) -> None: """ Removes label(s) from the node. :param labels_to_remove: A string of space-separated labels or a list of labels to remove. """ if isinstance(labels_to_remove, str): labels_to_remove = labels_to_remove.split() log.info("Removing labels %s from Node", labels_to_remove) current_labels = self.get_labels() or "" current_labels_set = set(current_labels.split()) updated_labels_set = current_labels_set.difference(labels_to_remove) updated_labels = " ".join(sorted(updated_labels_set)) log.info("Updated Node Labels: %s", updated_labels) if not dryRun: self.set_config_element("label", updated_labels) self.poll() def get_num_executors(self) -> str: try: return self.get_config_element("numExecutors") except JenkinsAPIException: return self._data["numExecutors"] def set_num_executors(self, value: int | str) -> None: """ Sets number of executors for node Warning! Setting number of executors on master node will erase all other settings """ set_value = value if isinstance(value, str) else str(value) if self.name == "Built-In Node": # master node doesn't have config.xml, so we're going to submit # form here data = "json=%s" % urlquote( json.dumps( { "numExecutors": set_value, "nodeProperties": {"stapler-class-bag": "true"}, } ) ) url = self.baseurl + "/configSubmit" self.jenkins.requester.post_and_confirm_status(url, data=data) else: self.set_config_element("numExecutors", set_value) self.poll() def change_mode(self, mode: str) -> None: """ Change the node's usage mode. :param mode: "NORMAL" (use this node as much as possible) or "EXCLUSIVE" (only build jobs tied to this node) """ valid_modes = {"NORMAL", "EXCLUSIVE"} if mode not in valid_modes: raise ValueError( "mode must be one of %s, got %r" % (valid_modes, mode) ) self.set_config_element("mode", mode) def get_config_element(self, el_name: str) -> str: """ Returns simple config element. Better not to be used to return "nodeProperties" or "launcher" """ return self._et.find(el_name).text def set_config_element(self, el_name: str, value: str) -> None: """ Sets simple config element """ self._et.find(el_name).text = value xml_str = ET.tostring(self._et) self.upload_config(xml_str) def get_monitor(self, monitor_name: str, poll_monitor=True) -> str: """ Polls the node returning one of the monitors in the monitorData branch of the returned node api tree. """ monitor_data_key = "monitorData" if poll_monitor: # polling as monitors like response time can be updated monitor_data = self.poll(tree=monitor_data_key)[monitor_data_key] else: monitor_data = self._data[monitor_data_key] full_monitor_name = "hudson.node_monitors.{0}".format(monitor_name) if full_monitor_name not in monitor_data: raise AssertionError("Node monitor %s not found" % monitor_name) return monitor_data[full_monitor_name] # Monitor field mappings with metadata _MONITOR_FIELDS = { "available_physical_memory": { "monitor": "SwapSpaceMonitor", "field": "availablePhysicalMemory", "doc": "Returns the node's available physical memory in bytes.", }, "available_swap_space": { "monitor": "SwapSpaceMonitor", "field": "availableSwapSpace", "doc": "Returns the node's available swap space in bytes.", }, "total_physical_memory": { "monitor": "SwapSpaceMonitor", "field": "totalPhysicalMemory", "doc": "Returns the node's total physical memory in bytes.", }, "total_swap_space": { "monitor": "SwapSpaceMonitor", "field": "totalSwapSpace", "doc": "Returns the node's total swap space in bytes.", }, "workspace_path": { "monitor": "DiskSpaceMonitor", "field": "path", "doc": "Returns the local path to the node's Jenkins workspace directory.", }, "workspace_size": { "monitor": "DiskSpaceMonitor", "field": "size", "doc": "Returns the size in bytes of the node's Jenkins workspace directory.", }, "temp_path": { "monitor": "TemporarySpaceMonitor", "field": "path", "doc": "Returns the local path to the node's temp directory.", }, "temp_size": { "monitor": "TemporarySpaceMonitor", "field": "size", "doc": "Returns the size in bytes of the node's temp directory.", }, "response_time": { "monitor": "ResponseTimeMonitor", "field": "average", "doc": "Returns the node's average response time.", }, "clock_difference": { "monitor": "ClockMonitor", "field": "diff", "doc": ( "Returns the difference between the node's clock and " "the master Jenkins clock. Used to detect out of sync clocks." ), }, } def get_monitor_data( self, monitor_name: str, item: str, poll_monitor: bool = True ): """ Returns a specific data item from a node monitor. :param monitor_name: Name of the monitor (e.g., "SwapSpaceMonitor") :param item: The key to retrieve from the monitor data :param poll_monitor: Whether to poll for fresh data (default: True) :return: The requested monitor data item """ monitor_data = self.get_monitor( monitor_name, poll_monitor=poll_monitor ) return monitor_data[item] def get_architecture(self) -> str: """ Returns the system architecture of the node eg. "Linux (amd64)". """ # no need to poll as the architecture will never change return self.get_monitor("ArchitectureMonitor", poll_monitor=False) def get_log_text(self) -> str: """ Return the node's log text. """ url: str = "%s/logText/progressiveHtml/?start=0" % self.baseurl resp = self.jenkins.requester.get_url(url) content = resp.content if isinstance(content, str): return content elif isinstance(content, bytes): return content.decode(resp.encoding or "UTF-8") else: from jenkinsapi.custom_exceptions import JenkinsAPIException raise JenkinsAPIException("Unknown content type for node log") def block_until_idle(self, timeout: int, poll_time: int = 5) -> None: """ Blocks until the node become idle. :param timeout: Time in second when the wait is aborted. :param poll_time: Interval in seconds between each check. :@raise TimeOut """ start_time = time.time() while not self.is_idle() and (time.time() - start_time) < timeout: log.debug( "Waiting for the node to become idle. Elapsed time: %s", (time.time() - start_time), ) time.sleep(poll_time) if not self.is_idle(): raise TimeOut( "The node has not become idle after {} minutes.".format( timeout / 60 ) ) # Dynamically generate monitor data retrieval methods from _MONITOR_FIELDS def _make_monitor_method(method_name, monitor_name, field_name, doc): """Factory function to create monitor data retrieval methods.""" def method(self): return self.get_monitor_data(monitor_name, field_name) method.__name__ = method_name method.__doc__ = doc return method for key, config in Node._MONITOR_FIELDS.items(): method_name = f"get_{key}" method = _make_monitor_method( method_name, config["monitor"], config["field"], config["doc"] ) setattr(Node, method_name, method) jenkinsapi-0.3.23/jenkinsapi/nodes.py0000644000000000000000000001470613615410400014477 0ustar00""" Module for jenkinsapi nodes """ from __future__ import annotations from typing import Iterator import logging from urllib.parse import urlencode from jenkinsapi.node import Node from jenkinsapi.jenkinsbase import JenkinsBase from jenkinsapi.custom_exceptions import JenkinsAPIException from jenkinsapi.custom_exceptions import UnknownNode from jenkinsapi.custom_exceptions import PostRequired log: logging.Logger = logging.getLogger(__name__) class Nodes(JenkinsBase): """ Class to hold information on a collection of nodes """ def __init__(self, baseurl: str, jenkins_obj: "Jenkins") -> None: """ Handy access to all of the nodes on your Jenkins server """ self.jenkins = jenkins_obj JenkinsBase.__init__( self, ( baseurl.rstrip("/") if "/computer" in baseurl else baseurl.rstrip("/") + "/computer" ), ) def get_jenkins_obj(self) -> "Jenkins": return self.jenkins def _poll(self, tree=None): """ Poll Jenkins for the list of nodes. Uses tree=computer[displayName] to optimize performance on large instances by limiting the response size, avoiding timeouts (issue #855). """ if not tree: tree = "computer[displayName]" return super()._poll(tree=tree) def __str__(self) -> str: return "Nodes @ %s" % self.baseurl def __contains__(self, node_name: str) -> bool: return node_name in self.keys() def iterkeys(self) -> Iterator[str]: """ Return an iterator over the container's node names. Using iterkeys() while creating nodes may raise a RuntimeError or fail to iterate over all entries. """ for item in self._data["computer"]: yield item["displayName"] def keys(self) -> list[str]: """ Return a copy of the container's list of node names. """ return list(self.iterkeys()) def _make_node(self, nodename) -> Node: """ Creates an instance of Node for the given nodename. This function assumes the returned node exists. """ if nodename.lower() == "built-in node": nodeurl = "%s/(%s)" % (self.baseurl, "built-in") else: nodeurl = "%s/%s" % (self.baseurl, nodename) return Node(self.jenkins, nodeurl, nodename, node_dict={}) def iteritems(self) -> Iterator[tuple[str, Node]]: """ Return an iterator over the container's (name, node) pairs. Using iteritems() while creating nodes may raise a RuntimeError or fail to iterate over all entries. """ for item in self._data["computer"]: nodename = item["displayName"] try: yield nodename, self._make_node(nodename) except Exception: raise JenkinsAPIException("Unable to iterate nodes") def items(self) -> list[tuple[str, Node]]: """ Return a copy of the container's list of (name, node) pairs. """ return list(self.iteritems()) def itervalues(self) -> Iterator[Node]: """ Return an iterator over the container's nodes. Using itervalues() while creating nodes may raise a RuntimeError or fail to iterate over all entries. """ for item in self._data["computer"]: try: yield self._make_node(item["displayName"]) except Exception: raise JenkinsAPIException("Unable to iterate nodes") def values(self) -> list[Node]: """ Return a copy of the container's list of nodes. """ return list(self.itervalues()) def __getitem__(self, nodename: str) -> Node: if nodename in self: return self._make_node(nodename) raise UnknownNode(nodename) def __len__(self) -> int: return len(self.keys()) def __delitem__(self, item: str) -> None: if item in self and item != "Built-In Node": url = "%s/doDelete" % self[item].baseurl try: self.jenkins.requester.get_and_confirm_status(url) except PostRequired: # Latest Jenkins requires POST here. GET kept for compatibility self.jenkins.requester.post_and_confirm_status(url, data={}) self.poll() else: if item != "Built-In Node": raise UnknownNode("Node %s does not exist" % item) log.info("Requests to remove built-in node ignored") def __setitem__(self, name: str, node_dict: dict): if not isinstance(node_dict, dict): raise ValueError('"node_dict" parameter must be a Node dict') if name not in self: self.create_node(name, node_dict) self.poll() def create_node(self, name: str, node_dict: dict) -> Node: """ Create a new slave node :param str name: name of slave :param dict node_dict: node dict (See Node class) :return: node obj """ if name in self: return self[name] node = Node( jenkins_obj=self.jenkins, baseurl="", nodename=name, node_dict=node_dict, poll=False, ) url = "%s/computer/doCreateItem?%s" % ( self.jenkins.baseurl, urlencode(node.get_node_attributes()), ) data = {"json": urlencode(node.get_node_attributes())} self.jenkins.requester.post_and_confirm_status(url, data=data) self.poll() return self[name] def create_node_with_config(self, name: str, config: dict) -> Node | None: """ Create a new slave node with specific configuration. Config should be resemble the output of node.get_node_attributes() :param str name: name of slave :param dict config: Node attributes for Jenkins API request to create node (See function output Node.get_node_attributes()) :return: node obj """ if name in self: return self[name] if not isinstance(config, dict): return None url = "%s/computer/doCreateItem?%s" % ( self.jenkins.baseurl, urlencode(config), ) data = {"json": urlencode(config)} self.jenkins.requester.post_and_confirm_status(url, data=data) self.poll() return self[name] jenkinsapi-0.3.23/jenkinsapi/plugin.py0000644000000000000000000000467713615410400014673 0ustar00""" Module for jenkinsapi Plugin """ from __future__ import annotations from typing import Union class Plugin(object): """ Plugin class """ def __init__(self, plugin_dict: Union[dict, str]) -> None: if isinstance(plugin_dict, dict): self.__dict__ = plugin_dict else: self.__dict__ = self.to_plugin(plugin_dict) self.shortName: str = self.__dict__["shortName"] self.version: str = self.__dict__.get("version", "Unknown") def to_plugin(self, plugin_string: str) -> dict: plugin_string = str(plugin_string) if "@" not in plugin_string or len(plugin_string.split("@")) != 2: usage_err: str = ( "plugin specification must be a string like " '"plugin-name@version", not "{0}"' ) usage_err = usage_err.format(plugin_string) raise ValueError(usage_err) shortName, version = plugin_string.split("@") return {"shortName": shortName, "version": version} def __eq__(self, other) -> bool: return self.__dict__ == other.__dict__ def __str__(self) -> str: return self.shortName def __repr__(self) -> str: return "<%s.%s %s>" % ( self.__class__.__module__, self.__class__.__name__, str(self), ) def get_attributes(self) -> str: """ Used by Plugins object to install plugins in Jenkins """ return ' ' % ( self.shortName, self.version, ) def is_latest(self, update_center_dict: dict) -> bool: """ Used by Plugins object to determine if plugin can be installed through the update center (when plugin version is latest version), or must be installed by uploading the plugin hpi file. """ if self.version == "latest": return True center_plugin = update_center_dict["plugins"][self.shortName] return center_plugin["version"] == self.version def get_download_link(self, update_center_dict) -> str: latest_version = update_center_dict["plugins"][self.shortName][ "version" ] latest_url = update_center_dict["plugins"][self.shortName]["url"] return latest_url.replace( "/".join((self.shortName, latest_version)), "/".join((self.shortName, self.version)), ) jenkinsapi-0.3.23/jenkinsapi/plugins.py0000644000000000000000000003062213615410400015043 0ustar00""" jenkinsapi plugins """ from __future__ import annotations from typing import Generator import logging import time import re from io import BytesIO from urllib.parse import urlencode import json import requests from jenkinsapi.plugin import Plugin from jenkinsapi.jenkinsbase import JenkinsBase from jenkinsapi.custom_exceptions import UnknownPlugin from jenkinsapi.custom_exceptions import JenkinsAPIException from jenkinsapi.utils.jsonp_to_json import jsonp_to_json from jenkinsapi.utils.manifest import Manifest, read_manifest log: logging.Logger = logging.getLogger(__name__) class Plugins(JenkinsBase): """ Plugins class for jenkinsapi """ def __init__(self, url: str, jenkins_obj: "Jenkins") -> None: self.jenkins_obj: "Jenkins" = jenkins_obj JenkinsBase.__init__(self, url) def get_jenkins_obj(self) -> "Jenkins": return self.jenkins_obj def check_updates_server(self) -> None: url: str = ( f"{self.jenkins_obj.baseurl}/pluginManager/checkUpdatesServer" ) self.jenkins_obj.requester.post_and_confirm_status( url, params={}, data={} ) @property def update_center_dict(self): update_center = "https://updates.jenkins.io/update-center.json" jsonp = requests.get(update_center).content.decode("utf-8") return json.loads(jsonp_to_json(jsonp)) def _poll(self, tree=None): return self.get_data(self.baseurl, tree=tree) def keys(self) -> list[str]: return self.get_plugins_dict().keys() __iter__ = keys def iteritems(self) -> Generator[str, "Plugin"]: return self._get_plugins() def values(self) -> list["Plugin"]: return [a[1] for a in self.iteritems()] def _get_plugins(self) -> Generator[str, "Plugin"]: if "plugins" in self._data: for p_dict in self._data["plugins"]: yield p_dict["shortName"], Plugin(p_dict) def get_plugins_dict(self) -> dict[str, "Plugin"]: return dict(self._get_plugins()) def __len__(self) -> int: return len(self.get_plugins_dict().keys()) def __getitem__(self, plugin_name: str) -> Plugin: try: return self.get_plugins_dict()[plugin_name] except KeyError: raise UnknownPlugin(plugin_name) def __setitem__(self, shortName, plugin: "Plugin") -> None: """ Installs plugin in Jenkins. If plugin already exists - this method is going to uninstall the existing plugin and install the specified version if it is not already installed. :param shortName: Plugin ID :param plugin a Plugin object to be installed. """ if self.plugin_version_already_installed(plugin): return if plugin.is_latest(self.update_center_dict): self._install_plugin_from_updatecenter(plugin) else: self._install_specific_version(plugin) self._wait_until_plugin_installed(plugin) def _install_plugin_from_updatecenter(self, plugin: "Plugin") -> None: """ Latest versions of plugins can be installed from the update center (and don't need a restart.) """ xml_str: str = plugin.get_attributes() url: str = ( "%s/pluginManager/installNecessaryPlugins" % self.jenkins_obj.baseurl ) self.jenkins_obj.requester.post_xml_and_confirm_status( url, data=xml_str ) @property def update_center_install_status(self): """ Jenkins 2.x specific """ url: str = "%s/updateCenter/installStatus" % self.jenkins_obj.baseurl status = self.jenkins_obj.requester.get_url(url) if status.status_code == 404: raise JenkinsAPIException( "update_center_install_status not available for Jenkins 1.X" ) return status.json() @property def restart_required(self): """ Call after plugin installation to check if Jenkins requires a restart """ try: jobs = self.update_center_install_status["data"]["jobs"] except JenkinsAPIException: return True # Jenkins 1.X has no update_center return any([job for job in jobs if job["requiresRestart"] == "true"]) def _install_specific_version(self, plugin: "Plugin") -> None: """ Plugins that are not the latest version have to be uploaded. """ download_link: str = plugin.get_download_link( update_center_dict=self.update_center_dict ) downloaded_plugin: BytesIO = self._download_plugin(download_link) plugin_dependencies = self._get_plugin_dependencies(downloaded_plugin) log.debug("Installing dependencies for plugin '%s'", plugin.shortName) self.jenkins_obj.install_plugins(plugin_dependencies) url = "%s/pluginManager/uploadPlugin" % self.jenkins_obj.baseurl requester = self.jenkins_obj.requester downloaded_plugin.seek(0) requester.post_and_confirm_status( url, files={"file": ("plugin.hpi", downloaded_plugin)}, data={}, params={}, ) def _get_plugin_dependencies( self, downloaded_plugin: BytesIO ) -> list["Plugin"]: """ Returns a list of all dependencies for a downloaded plugin """ plugin_dependencies = [] manifest: Manifest = read_manifest(downloaded_plugin) manifest_dependencies = manifest.main_section.get( "Plugin-Dependencies" ) if manifest_dependencies: dependencies = manifest_dependencies.split(",") for dep in dependencies: # split plugin:version;resolution:optional entries components = dep.split(";") dep_plugin = components[0] name = dep_plugin.split(":")[0] # install latest dependency, avoids multiple # versions of the same dep plugin_dependencies.append( Plugin({"shortName": name, "version": "latest"}) ) return plugin_dependencies def _download_plugin(self, download_link): downloaded_plugin = BytesIO() downloaded_plugin.write(requests.get(download_link).content) return downloaded_plugin def _plugin_has_finished_installation(self, plugin) -> bool: """ Return True if installation is marked as 'Success' or 'SuccessButRequiresRestart' in Jenkins' update_center, else return False. """ try: jobs = self.update_center_install_status["data"]["jobs"] for job in jobs: if job["name"] == plugin.shortName and job[ "installStatus" ] in [ "Success", "SuccessButRequiresRestart", ]: return True return False except JenkinsAPIException: return False # lack of update_center in Jenkins 1.X def plugin_version_is_being_installed(self, plugin) -> bool: """ Return true if plugin is currently being installed. """ try: jobs = self.update_center_install_status["data"]["jobs"] except JenkinsAPIException: return False # lack of update_center in Jenkins 1.X return any( [ job for job in jobs if job["name"] == plugin.shortName and job["version"] == plugin.version ] ) def plugin_version_already_installed(self, plugin) -> bool: """ Check if plugin version is already installed """ if plugin.shortName not in self: if self.plugin_version_is_being_installed(plugin): return True return False installed_plugin = self[plugin.shortName] if plugin.version == installed_plugin.version: return True elif plugin.version == "latest": # we don't have an exact version, we first check if Jenkins # knows about an update if ( hasattr(installed_plugin, "hasUpdates") and installed_plugin.hasUpdates ): return False # Jenkins may not have an up-to-date catalogue, # so check update-center directly latest_version = self.update_center_dict["plugins"][ plugin.shortName ]["version"] return installed_plugin.version == latest_version return False def __delitem__(self, shortName): if re.match(".*@.*", shortName): real_shortName = re.compile("(.*)@(.*)").search(shortName).group(1) raise ValueError( ("Plugin shortName can't contain version. '%s' should be '%s'") % (shortName, real_shortName) ) if shortName not in self: raise KeyError( 'Plugin with ID "%s" not found, cannot uninstall' % shortName ) if self[shortName].deleted: raise JenkinsAPIException( 'Plugin "%s" already marked for uninstall. ' "Restart jenkins for uninstall to complete." ) params = {"Submit": "OK", "json": {}} url = "%s/pluginManager/plugin/%s/doUninstall" % ( self.jenkins_obj.baseurl, shortName, ) self.jenkins_obj.requester.post_and_confirm_status( url, params={}, data=urlencode(params) ) self.poll() if not self[shortName].deleted: raise JenkinsAPIException( "Problem uninstalling plugin '%s'." % shortName ) def _wait_until_plugin_installed(self, plugin, maxwait=120, interval=1): for _ in range(maxwait, 0, -interval): self.poll() if self._plugin_has_finished_installation(plugin): return True if plugin.shortName in self: return True # for Jenkins 1.X time.sleep(interval) if self.jenkins_obj.version.startswith("2"): raise JenkinsAPIException( "Problem installing plugin '%s'." % plugin.shortName ) log.warning( "Plugin '%s' not found in loaded plugins." "You may need to restart Jenkins.", plugin.shortName, ) return False def enable_plugin(self, shortName: str) -> None: """ Enable a disabled plugin. :param shortName: Plugin short name (ID) """ url = "%s/pluginManager/plugin/%s/makeEnabled" % ( self.jenkins_obj.baseurl, shortName, ) self.jenkins_obj.requester.post_and_confirm_status( url, params={}, data="" ) def downgrade_plugin(self, shortName: str) -> None: """ Downgrade a plugin to its previous version. :param shortName: Plugin short name (ID) """ url = "%s/pluginManager/plugin/%s/downgrade" % ( self.jenkins_obj.baseurl, shortName, ) self.jenkins_obj.requester.post_and_confirm_status( url, params={}, data="", valid=[200, 404] ) def disable_plugin(self, shortName: str) -> None: """ Disable an enabled plugin. :param shortName: Plugin short name (ID) """ url = "%s/pluginManager/plugin/%s/makeDisabled" % ( self.jenkins_obj.baseurl, shortName, ) self.jenkins_obj.requester.post_and_confirm_status( url, params={}, data="" ) def unpin_plugin(self, shortName: str) -> None: """ Unpin a pinned plugin, allowing it to be updated or replaced. :param shortName: Plugin short name (ID) """ url = "%s/pluginManager/plugin/%s/unpin" % ( self.jenkins_obj.baseurl, shortName, ) self.jenkins_obj.requester.post_and_confirm_status( url, params={}, data="", valid=[200, 404] ) def __contains__(self, plugin_name): """ True if plugin_name is the name of a defined plugin """ return plugin_name in self.keys() def __str__(self): plugins = [ plugin["shortName"] for plugin in self._data.get("plugins", []) ] return str(sorted(plugins)) jenkinsapi-0.3.23/jenkinsapi/queue.py0000644000000000000000000001676213615410400014517 0ustar00""" Queue module for jenkinsapi """ from __future__ import annotations from typing import Iterator, Tuple import logging import time from requests import HTTPError from jenkinsapi.jenkinsbase import JenkinsBase from jenkinsapi.custom_exceptions import UnknownQueueItem, NotBuiltYet log: logging.Logger = logging.getLogger(__name__) class Queue(JenkinsBase): """ Class that represents the Jenkins queue """ def __init__(self, baseurl: str, jenkins_obj: "Jenkins") -> None: """ Init the Jenkins queue object :param baseurl: basic url for the queue :param jenkins_obj: ref to the jenkins obj """ self.jenkins: "Jenkins" = jenkins_obj JenkinsBase.__init__(self, baseurl) def __str__(self) -> str: return self.baseurl def get_jenkins_obj(self) -> "Jenkins": return self.jenkins def iteritems(self) -> Iterator[Tuple[str, "QueueItem"]]: for item in self._data["items"]: queue_id = item["id"] item_baseurl = "%s/item/%i" % (self.baseurl, queue_id) yield ( item["id"], QueueItem(baseurl=item_baseurl, jenkins_obj=self.jenkins), ) def iterkeys(self) -> Iterator[str]: for item in self._data["items"]: yield item["id"] def itervalues(self) -> Iterator["QueueItem"]: for item in self._data["items"]: yield QueueItem(self.jenkins, **item) def keys(self) -> list[str]: return list(self.iterkeys()) def values(self) -> list["QueueItem"]: return list(self.itervalues()) def __len__(self) -> int: return len(self._data["items"]) def __getitem__(self, item_id: str) -> "QueueItem": self_as_dict = dict(self.iteritems()) if item_id in self_as_dict: return self_as_dict[item_id] else: raise UnknownQueueItem(item_id) def _get_queue_items_for_job(self, job_name: str) -> Iterator["QueueItem"]: def normalize(name: str) -> str: name = (name or "").strip().strip("/") if not name: return name if name.startswith("job/"): name = name[4:] if "/job/" in name: return name.replace("/job/", "/").lstrip("/") return name normalized_name = normalize(job_name) for item in self._data["items"]: if ( "name" in item["task"] and normalize(item["task"]["name"]) == normalized_name ): yield QueueItem( self.get_queue_item_url(item), jenkins_obj=self.jenkins ) def get_queue_items_for_job(self, job_name: str): return list(self._get_queue_items_for_job(job_name)) def get_queue_item_url(self, item: str) -> str: return "%s/item/%i" % (self.baseurl, item["id"]) def delete_item(self, queue_item: "QueueItem"): self.delete_item_by_id(queue_item.queue_id) def delete_item_by_id(self, item_id: str): deleteurl: str = "%s/cancelItem?id=%s" % (self.baseurl, item_id) self.get_jenkins_obj().requester.post_url(deleteurl) class QueueItem(JenkinsBase): """An individual item in the queue""" def __init__(self, baseurl: str, jenkins_obj: "Jenkins") -> None: self.jenkins: "Jenkins" = jenkins_obj JenkinsBase.__init__(self, baseurl) @property def queue_id(self): return self._data["id"] @property def name(self): return self._data["task"]["name"] @property def why(self): return self._data.get("why") def get_jenkins_obj(self) -> "Jenkins": return self.jenkins def get_job(self) -> "Job": """ Return the job associated with this queue item """ return self.jenkins.get_job_by_url( self._data["task"]["url"], self._data["task"]["name"], ) def get_parameters(self): """returns parameters of queue item""" actions = self._data.get("actions", []) for action in actions: if isinstance(action, dict) and "parameters" in action: parameters = action["parameters"] return dict( [(x["name"], x.get("value", None)) for x in parameters] ) return [] def __repr__(self) -> str: return "<%s.%s %s>" % ( self.__class__.__module__, self.__class__.__name__, str(self), ) def __str__(self) -> str: return "%s Queue #%i" % (self.name, self.queue_id) def get_build(self) -> "Build": build_number = self.get_build_number() job = self.get_job() return job[build_number] def block_until_complete(self, delay=5): build = self.block_until_building(delay) return build.block_until_complete(delay=delay) def block_until_building(self, delay=5): while True: try: self.poll() return self.get_build() except NotBuiltYet: time.sleep(delay) continue except HTTPError as http_error: log.debug(str(http_error)) time.sleep(delay) continue def is_running(self) -> bool: """Return True if this queued item is running.""" try: return self.get_build().is_running() except NotBuiltYet: return False def is_queued(self) -> bool: """Return True if this queued item is queued.""" try: self.get_build() except NotBuiltYet: return True else: return False def get_build_number(self) -> int: try: return self._data["executable"]["number"] except (KeyError, TypeError): raise NotBuiltYet() def get_job_name(self) -> str: try: return self._data["task"]["name"] except KeyError: raise NotBuiltYet() @property def is_blocked(self) -> bool: """True if this queue item is blocked from building.""" return self._data.get("blocked", False) @property def is_stuck(self) -> bool: """True if this queue item is stuck (blocked for a long time).""" return self._data.get("stuck", False) @property def is_buildable(self) -> bool: """True if this queue item is ready to be built.""" return self._data.get("buildable", False) def get_age(self) -> float: """ Return how long (in seconds) this item has been in the queue. """ in_queue_since = self._data.get("inQueueSince") if in_queue_since is None: return 0.0 return (time.time() * 1000 - in_queue_since) / 1000.0 def get_eta(self) -> float: """ Return the estimated time (in seconds) until the build starts. Returns 0 if the item is already buildable or no estimate available. """ start_millis = self._data.get("buildableStartMilliseconds") if start_millis is None: return 0.0 remaining = start_millis - time.time() * 1000 return max(0.0, remaining / 1000.0) def get_causes(self) -> list: """ Return the list of causes for this queue item being queued. """ actions = self._data.get("actions", []) for action in actions: if isinstance(action, dict) and "causes" in action: return action["causes"] return [] jenkinsapi-0.3.23/jenkinsapi/result.py0000644000000000000000000000115113615410400014673 0ustar00""" Module for jenkinsapi Result """ class Result(object): """ Result class """ def __init__(self, **kwargs): self.__dict__.update(kwargs) def __str__(self): return f"{self.className} {self.name} {self.status}" def __repr__(self) -> str: module_name = self.__class__.__module__ class_name = self.__class__.__name__ self_str = str(self) return "<%s.%s %s>" % (module_name, class_name, self_str) def identifier(self) -> str: """ Calculate an ID for this object. """ return f"{self.className}.{self.name}" jenkinsapi-0.3.23/jenkinsapi/result_set.py0000644000000000000000000000307613615410400015556 0ustar00""" Module for jenkinsapi ResultSet """ from __future__ import annotations from jenkinsapi.jenkinsbase import JenkinsBase from jenkinsapi.result import Result class ResultSet(JenkinsBase): """ Represents a result from a completed Jenkins run. """ def __init__(self, url: str, build: "Build") -> None: """ Init a resultset :param url: url for a build, str :param build: build obj """ self.build: "Build" = build JenkinsBase.__init__(self, url) def get_jenkins_obj(self) -> "Jenkins": return self.build.job.get_jenkins_obj() def __str__(self) -> str: return "Test Result for %s" % str(self.build) @property def name(self): return str(self) def keys(self) -> list[str]: return [a[0] for a in self.iteritems()] def items(self): return [a for a in self.iteritems()] def iteritems(self): for suite in self._data.get("suites", []): for case in suite["cases"]: result = Result(**case) yield result.identifier(), result for report_set in self._data.get("childReports", []): if report_set["result"]: for suite in report_set["result"]["suites"]: for case in suite["cases"]: result = Result(**case) yield result.identifier(), result def __len__(self): return len(self.items()) def __getitem__(self, key): self_as_dict = dict(self.iteritems()) return self_as_dict[key] jenkinsapi-0.3.23/jenkinsapi/view.py0000644000000000000000000001511513615410400014334 0ustar00""" Module for jenkinsapi views """ from __future__ import annotations from typing import Iterator, Tuple import logging from jenkinsapi.jenkinsbase import JenkinsBase from jenkinsapi.job import Job from jenkinsapi.custom_exceptions import NotFound log: logging.Logger = logging.getLogger(__name__) class View(JenkinsBase): """ View class """ def __init__(self, url: str, name: str, jenkins_obj: "Jenkins") -> None: self.name: str = name self.jenkins_obj: "Jenkins" = jenkins_obj JenkinsBase.__init__(self, url) self.deleted: bool = False def __len__(self) -> int: return len(self.get_job_dict().keys()) def __str__(self) -> str: return self.name def __repr__(self) -> str: return self.name def __getitem__(self, job_name) -> Job: assert isinstance(job_name, str) normalized_name = self._normalize_job_name(job_name) api_url = self.python_api_url(self.get_job_url(normalized_name)) return Job(api_url, normalized_name, self.jenkins_obj) def __contains__(self, job_name: str) -> bool: """ True if view_name is the name of a defined view """ return self._normalize_job_name(job_name) in self.keys() def delete(self) -> None: """ Remove this view object """ url: str = f"{self.baseurl}/doDelete" self.jenkins_obj.requester.post_and_confirm_status(url, data="") self.jenkins_obj.poll() self.deleted = True def keys(self) -> list[str]: return self.get_job_dict().keys() def iteritems(self) -> Iterator[Tuple[str, Job]]: it = self.get_job_dict().items() for name, url in it: yield name, Job(url, name, self.jenkins_obj) def values(self) -> list[Job]: return [a[1] for a in self.iteritems()] def items(self): return [a for a in self.iteritems()] def _get_jobs(self) -> Iterator[Tuple[str, str]]: if "jobs" in self._data: for viewdict in self._data["jobs"]: yield viewdict["name"], viewdict["url"] def get_job_dict(self) -> dict: return dict(self._get_jobs()) def get_job_url(self, str_job_name: str) -> str: normalized_name = self._normalize_job_name(str_job_name) if normalized_name in self: return self.get_job_dict()[normalized_name] else: # noinspection PyUnboundLocalVariable views_jobs = ", ".join(self.get_job_dict().keys()) raise NotFound( "Job %s is not known, available jobs" " in view are: %s" % (normalized_name, views_jobs) ) def get_jenkins_obj(self) -> "Jenkins": return self.jenkins_obj def add_job(self, job_name: str, job=None) -> bool: """ Add job to a view :param job_name: name of the job to be added :param job: Job object to be added :return: True if job has been added, False if job already exists or job not known to Jenkins """ job_name = self._normalize_job_name(job_name) if not job: if job_name in self.get_job_dict(): log.warning( "Job %s is already in the view %s", job_name, self.name ) return False else: # Since this call can be made from nested view, # which doesn't have any jobs, we can miss existing job # Thus let's create top level Jenkins and ask him # http://jenkins:8080/view/CRT/view/CRT-FB/view/CRT-SCRT-1301/ top_jenkins = self.get_jenkins_obj().get_jenkins_obj_from_url( self.baseurl.split("view/")[0] ) if not top_jenkins.has_job(job_name): log.error( msg='Job "%s" is not known to Jenkins' % job_name ) return False else: job = top_jenkins.get_job(job_name) log.info(msg="Creating job %s in view %s" % (job_name, self.name)) url = "%s/addJobToView" % self.baseurl params = {"name": job_name} self.get_jenkins_obj().requester.post_and_confirm_status( url, data={}, params=params ) self.poll() log.debug( msg='Job "%s" has been added to a view "%s"' % (job.name, self.name) ) return True def remove_job(self, job_name: str) -> bool: """ Remove job from a view :param job_name: name of the job to be removed :return: True if job has been removed, False if job not assigned to this view """ job_name = self._normalize_job_name(job_name) if job_name not in self: return False url = "%s/removeJobFromView" % self.baseurl params = {"name": job_name} self.get_jenkins_obj().requester.post_and_confirm_status( url, data={}, params=params ) self.poll() log.debug( msg='Job "%s" has been added to a view "%s"' % (job_name, self.name) ) return True @staticmethod def _normalize_job_name(job_name: str) -> str: name = (job_name or "").strip().strip("/") if not name: return name if name.startswith("job/"): name = name[4:] if "/job/" in name: return name.replace("/job/", "/").lstrip("/") return name def _get_nested_views(self) -> Iterator[Tuple[str, str]]: for viewdict in self._data.get("views", []): yield viewdict["name"], viewdict["url"] def get_nested_view_dict(self) -> dict: return dict(self._get_nested_views()) def get_config_xml_url(self) -> str: return "%s/config.xml" % self.baseurl def get_config(self) -> str: """ Return the config.xml from the view """ url = self.get_config_xml_url() response = self.get_jenkins_obj().requester.get_and_confirm_status(url) return response.text def update_config(self, config: str) -> str: """ Update the config.xml to the view """ url = self.get_config_xml_url() config = str(config) # cast unicode in case of Python 2 response = self.get_jenkins_obj().requester.post_url( url, params={}, data=config ) return response.text @property def views(self): return ( self.get_jenkins_obj().get_jenkins_obj_from_url(self.baseurl).views ) jenkinsapi-0.3.23/jenkinsapi/views.py0000644000000000000000000001005513615410400014515 0ustar00""" Module for jenkinsapi Views """ import logging import json from jenkinsapi.view import View from jenkinsapi.custom_exceptions import JenkinsAPIException log = logging.getLogger(__name__) class Views(object): """ An abstraction on a Jenkins object's views """ LIST_VIEW = "hudson.model.ListView" NESTED_VIEW = "hudson.plugins.nested_view.NestedView" CATEGORIZED_VIEW = ( "org.jenkinsci.plugins.categorizedview.CategorizedJobsView" ) MY_VIEW = "hudson.model.MyView" DASHBOARD_VIEW = "hudson.plugins.view.dashboard.Dashboard" PIPELINE_VIEW = ( "au.com.centrumsystems.hudson.plugin.buildpipeline.BuildPipelineView" ) def __init__(self, jenkins): self.jenkins = jenkins self._data = None def poll(self, tree=None): self._data = self.jenkins.poll( tree="views[name,url]" if tree is None else tree ) def __len__(self): return len(self.keys()) def __delitem__(self, view_name): if view_name == "All": raise ValueError("Cannot delete this view: %s" % view_name) if view_name in self: self[view_name].delete() self.poll() def __setitem__(self, view_name, job_names_list): new_view = self.create(view_name) if isinstance(job_names_list, str): job_names_list = [job_names_list] for job_name in job_names_list: if not new_view.add_job(job_name): # Something wrong - delete view del self[new_view] raise TypeError("Job %s does not exist in Jenkins" % job_name) def __getitem__(self, view_name): self.poll() for row in self._data.get("views", []): if row["name"] == view_name: return View(row["url"], row["name"], self.jenkins) raise KeyError("View %s not found" % view_name) def iteritems(self): """ Get the names & objects for all views """ self.poll() for row in self._data.get("views", []): name = row["name"] url = row["url"] yield name, View(url, name, self.jenkins) def __contains__(self, view_name): """ True if view_name is the name of a defined view """ return view_name in self.keys() def iterkeys(self): """ Get the names of all available views """ self.poll() for row in self._data.get("views", []): yield row["name"] def keys(self): """ Return a list of the names of all views """ return list(self.iterkeys()) def create(self, view_name, view_type=LIST_VIEW, config=None): """ Create a view :param view_name: name of new view, str :param view_type: type of the view, one of the constants in Views, str :param config: XML configuration of the new view :return: new View obj or None if view was not created """ log.info('Creating "%s" view "%s"', view_type, view_name) if view_name in self: log.warning('View "%s" already exists', view_name) return self[view_name] url = "%s/createView" % self.jenkins.baseurl if view_type == self.CATEGORIZED_VIEW: if not config: raise JenkinsAPIException( "Job XML config cannot be empty for CATEGORIZED_VIEW" ) params = {"name": view_name} self.jenkins.requester.post_xml_and_confirm_status( url, data=config, params=params ) else: headers = {"Content-Type": "application/x-www-form-urlencoded"} data = { "name": view_name, "mode": view_type, "Submit": "OK", "json": json.dumps({"name": view_name, "mode": view_type}), } self.jenkins.requester.post_and_confirm_status( url, data=data, headers=headers ) self.poll() return self[view_name] jenkinsapi-0.3.23/jenkinsapi/command_line/__init__.py0000644000000000000000000000005313615410400017541 0ustar00""" __init__,py for commandline module """ jenkinsapi-0.3.23/jenkinsapi/command_line/jenkins_invoke.py0000644000000000000000000001107613615410400021025 0ustar00""" jenkinsapi class for invoking Jenkins """ import os import sys import logging import argparse import getpass from jenkinsapi import jenkins log = logging.getLogger(__name__) class JenkinsInvoke(object): """ JenkinsInvoke object implements class to call from command line """ @classmethod def mkparser(cls): default_baseurl = os.environ.get( "JENKINS_URL", "http://localhost/jenkins" ) default_username = ( os.environ.get("JENKINS_USERNAME") or os.environ.get("JENKINS_USER") or None ) default_password = ( os.environ.get("JENKINS_API_TOKEN") or os.environ.get("JENKINS_PASSWORD") or None ) parser = argparse.ArgumentParser( description=( "Execute one or more Jenkins jobs on the selected server." ) ) parser.add_argument( "-J", "--jenkinsbase", dest="baseurl", help=( "Base URL for the Jenkins server " "(default: %(default)s or $JENKINS_URL)" ), type=str, default=default_baseurl, ) parser.add_argument( "-u", "--username", dest="username", help=( "Username for Jenkins authentication " "(default: $JENKINS_USERNAME or $JENKINS_USER)." ), type=str, default=default_username, ) parser.add_argument( "-p", "--password", dest="password", help=( "Password or API token for Jenkins authentication " "(default: $JENKINS_API_TOKEN or $JENKINS_PASSWORD)." ), type=str, default=default_password, ) parser.add_argument( "-b", "--block", dest="block", action="store_true", default=False, help="Block until each job completes.", ) parser.add_argument( "-t", "--token", dest="token", help="Optional per-job security token.", default=None, ) parser.add_argument( "jobs", metavar="job", nargs="+", help="One or more Jenkins job names to invoke.", ) return parser @classmethod def main(cls): parser = cls.mkparser() options = parser.parse_args() options = cls.resolve_password( options=options, is_interactive=sys.stdin.isatty(), prompt_password=getpass.getpass, ) if options.username and options.password is None: parser.error( "Password/API token missing. Use --password, set " "JENKINS_API_TOKEN/JENKINS_PASSWORD, or run in an " "interactive terminal to be prompted." ) invoker = cls(options, options.jobs) invoker() @staticmethod def resolve_password(options, is_interactive, prompt_password): if options.username and options.password is None and is_interactive: options.password = prompt_password( "Jenkins password or API token: " ) return options def __init__(self, options, jobs): self.options = options self.jobs = jobs self.api = self._get_api( baseurl=options.baseurl, username=options.username, password=options.password, ) def _get_api(self, baseurl, username, password): return jenkins.Jenkins(baseurl, username, password) def __call__(self): for job in self.jobs: self.invokejob( job, block=self.options.block, token=self.options.token ) def invokejob(self, jobname, block, token): if not isinstance(jobname, str): raise TypeError( f"jobname must be str, got {type(jobname).__name__}" ) if not isinstance(block, bool): raise TypeError(f"block must be bool, got {type(block).__name__}") if token is not None and not isinstance(token, str): raise TypeError( f"token must be None or str, got {type(token).__name__}" ) job = self.api.get_job(jobname) job.invoke(securitytoken=token, block=block) def main(): logging.basicConfig() logging.getLogger("").setLevel(logging.INFO) JenkinsInvoke.main() jenkinsapi-0.3.23/jenkinsapi/command_line/jenkinsapi_version.py0000644000000000000000000000026213615410400021704 0ustar00"""jenkinsapi.command_line.jenkinsapi_version""" import jenkinsapi import sys def main(): sys.stdout.write(jenkinsapi.__version__) if __name__ == "__main__": main() jenkinsapi-0.3.23/jenkinsapi/utils/__init__.py0000644000000000000000000000004213615410400016252 0ustar00""" Module __init__ for utils """ jenkinsapi-0.3.23/jenkinsapi/utils/crumb_requester.py0000644000000000000000000000526113615410400017732 0ustar00# Code from https://github.com/ros-infrastructure/ros_buildfarm # (c) Open Source Robotics Foundation import ast import logging from jenkinsapi.utils.requester import Requester logger = logging.getLogger(__name__) class CrumbRequester(Requester): """Adapter for Requester inserting the crumb in every request.""" def __init__(self, *args, **kwargs): super(CrumbRequester, self).__init__(*args, **kwargs) self._baseurl = kwargs["baseurl"] self._last_crumb_data = None def post_url( self, url, params=None, data=None, files=None, headers=None, allow_redirects=True, **kwargs, ): if self._last_crumb_data: # first try request with previous crumb if available response = self._post_url_with_crumb( self._last_crumb_data, url, params, data, files, headers, allow_redirects, **kwargs, ) # code 403 might indicate that the crumb is not valid anymore if response.status_code != 403: return response # fetch new crumb (if server has crumbs enabled) if self._last_crumb_data is not False: self._last_crumb_data = self._get_crumb_data() return self._post_url_with_crumb( self._last_crumb_data, url, params, data, files, headers, allow_redirects, **kwargs, ) def _get_crumb_data(self): response = self.get_url(self._baseurl + "/crumbIssuer/api/python") if response.status_code in [404]: logger.warning("The Jenkins master does not require a crumb") return False if response.status_code not in [200]: raise RuntimeError("Failed to fetch crumb: %s" % response.text) crumb_issuer_response = ast.literal_eval(response.text) crumb_request_field = crumb_issuer_response["crumbRequestField"] crumb = crumb_issuer_response["crumb"] logger.debug("Fetched crumb: %s", crumb) return {crumb_request_field: crumb} def _post_url_with_crumb( self, crumb_data, url, params, data, files, headers, allow_redirects, **kwargs, ): if crumb_data: if headers is None: headers = crumb_data else: headers.update(crumb_data) return super(CrumbRequester, self).post_url( url, params, data, files, headers, allow_redirects, **kwargs ) jenkinsapi-0.3.23/jenkinsapi/utils/docker_jenkins.py0000644000000000000000000001424013615410400017510 0ustar00"""Docker-based Jenkins instance manager for testing.""" import io import logging import os import tarfile import time from typing import Optional import docker import requests from requests.adapters import HTTPAdapter from urllib3.util.retry import Retry log = logging.getLogger(__name__) DEFAULT_IMAGE_NAME = "jenkinsapi-systest:latest" DEFAULT_CONTAINER_NAME = "jenkinsapi-systest" class DockerJenkinsError(Exception): """Raised when Docker-backed Jenkins setup or control fails.""" class DockerJenkins: """Manage a Jenkins instance running in Docker.""" def __init__( self, image_name: str = DEFAULT_IMAGE_NAME, container_name: str = DEFAULT_CONTAINER_NAME, port: int = 8080, jenkins_home: Optional[str] = None, ): self.image_name = image_name self.container_name = container_name self.port = port self.jenkins_home = jenkins_home self.client = docker.from_env() self.container = None self.jenkins_url = f"http://localhost:{port}" def build_image(self, dockerfile_path: str = "docker/Dockerfile") -> None: try: self.client.images.get(self.image_name) log.info( "Image %s already exists, skipping build", self.image_name ) return except docker.errors.ImageNotFound: pass if not os.path.exists(dockerfile_path): raise DockerJenkinsError( f"Dockerfile not found: {dockerfile_path}" ) log.info("Building image %s from %s", self.image_name, dockerfile_path) try: self.client.images.build( path=os.path.dirname(os.path.abspath(dockerfile_path)), dockerfile=os.path.basename(dockerfile_path), tag=self.image_name, rm=True, ) except (docker.errors.BuildError, docker.errors.APIError) as e: raise DockerJenkinsError(f"Failed to build image: {e}") def start(self, timeout: int = 300) -> None: self._remove_existing_container() volumes = {} if self.jenkins_home: volumes = { self.jenkins_home: {"bind": "/var/jenkins_home", "mode": "rw"} } try: self.container = self.client.containers.run( self.image_name, name=self.container_name, ports={"8080/tcp": self.port}, volumes=volumes, detach=True, remove=False, ) except docker.errors.ImageNotFound: raise DockerJenkinsError( f"Image not found: {self.image_name}. Build it first." ) except (docker.errors.ContainerError, docker.errors.APIError) as e: msg = str(e).lower() if "port" in msg and ( "already allocated" in msg or "address already in use" in msg ): raise DockerJenkinsError( f"Port conflict on port {self.port}: {e}" ) raise DockerJenkinsError(f"Failed to start container: {e}") self.wait_for_ready(timeout) def _remove_existing_container(self) -> None: try: existing = self.client.containers.get(self.container_name) except docker.errors.NotFound: return existing.stop() existing.remove() def stop(self) -> None: if self.container: try: self.container.stop(timeout=10) self.container.remove() except docker.errors.APIError as e: log.warning("Error stopping container: %s", e) finally: self.container = None def restart_jenkins_service(self, timeout: int = 60) -> None: if not self.container: raise DockerJenkinsError("Container is not running") result = self.container.exec_run("/usr/local/bin/restart-jenkins") if result.exit_code != 0: raise DockerJenkinsError( f"Failed to restart Jenkins: {result.output.decode()}" ) self.wait_for_ready(timeout) def wait_for_ready(self, timeout: int = 300) -> None: start_time = time.time() session = requests.Session() session.mount( "http://", HTTPAdapter(max_retries=Retry(total=5, backoff_factor=1)), ) while time.time() - start_time < timeout: try: resp = session.get(f"{self.jenkins_url}/api/json", timeout=5) if resp.status_code == 200: return except requests.RequestException: pass time.sleep(2) raise DockerJenkinsError( f"Jenkins did not become ready within {timeout} seconds" ) def dump_plugin_versions( self, output_file: str = "plugin_versions.txt" ) -> str: if not self.container: raise DockerJenkinsError("Container is not running") archive_name = os.path.basename(output_file) result = self.container.exec_run( f"/usr/local/bin/dump-plugin-versions {archive_name}" ) if result.exit_code != 0: raise DockerJenkinsError( f"Failed to dump plugin versions: {result.output.decode()}" ) bits, _ = self.container.get_archive(archive_name) archive_bytes = b"".join(bits) with tarfile.open(fileobj=io.BytesIO(archive_bytes)) as archive: member = archive.getmember(archive_name) extracted = archive.extractfile(member) if extracted is None: raise DockerJenkinsError( f"Failed to extract plugin versions: {archive_name}" ) with open(output_file, "wb") as f: f.write(extracted.read()) return output_file def get_logs(self, lines: int = 100) -> str: if not self.container: return "" return self.container.logs(tail=lines).decode(errors="replace") def __enter__(self): return self def __exit__(self, exc_type, exc_val, exc_tb): self.stop() jenkinsapi-0.3.23/jenkinsapi/utils/jsonp_to_json.py0000644000000000000000000000045013615410400017402 0ustar00""" Module for converting jsonp to json. """ def jsonp_to_json(jsonp): try: l_index = jsonp.index("(") + 1 r_index = jsonp.rindex(")") except ValueError: print("Input is not in jsonp format.") return None res = jsonp[l_index:r_index] return res jenkinsapi-0.3.23/jenkinsapi/utils/krb_requester.py0000644000000000000000000000250313615410400017374 0ustar00""" Kerberos aware Requester """ from jenkinsapi.utils.requester import Requester from requests_kerberos import HTTPKerberosAuth, OPTIONAL # pylint: disable=W0222 class KrbRequester(Requester): """ A class which carries out HTTP requests with Kerberos/GSSAPI authentication. """ def __init__(self, *args, **kwargs): """ :param ssl_verify: flag indicating if server certificate in HTTPS requests should be verified :param baseurl: Jenkins' base URL :param mutual_auth: type of mutual authentication, use one of REQUIRED, OPTIONAL or DISABLED from requests_kerberos package """ super(KrbRequester, self).__init__(*args, **kwargs) self.mutual_auth = ( kwargs["mutual_auth"] if "mutual_auth" in kwargs else OPTIONAL ) def get_request_dict( self, params=None, data=None, files=None, headers=None, **kwargs ): req_dict = super(KrbRequester, self).get_request_dict( params=params, data=data, files=files, headers=headers, **kwargs ) if self.mutual_auth: auth = HTTPKerberosAuth(self.mutual_auth) else: auth = HTTPKerberosAuth() req_dict["auth"] = auth return req_dict jenkinsapi-0.3.23/jenkinsapi/utils/logging.py0000644000000000000000000000245213615410400016150 0ustar00""" Helpers for configuring JenkinsAPI logging without clobbering user settings. """ from __future__ import annotations import logging import os def _resolve_level(level) -> int | None: if level is None: return None if isinstance(level, int): return level if not isinstance(level, str): return None level_value = logging.getLevelName(level.upper()) if isinstance(level_value, str): return None return level_value def configure_logging(level: str | int | None = None) -> bool: """ Configure JenkinsAPI logging if the user hasn't already. If level is None, the environment variable JENKINSAPI_LOG_LEVEL is used. Returns True when logging was configured. """ if level is None: level = os.getenv("JENKINSAPI_LOG_LEVEL") level_value = _resolve_level(level) if level_value is None: return False logger = logging.getLogger("jenkinsapi") logger.setLevel(level_value) root = logging.getLogger() if logger.handlers or root.handlers: return True handler = logging.StreamHandler() formatter = logging.Formatter( "%(asctime)s %(levelname)s %(name)s: %(message)s" ) handler.setFormatter(formatter) logger.addHandler(handler) logger.propagate = False return True jenkinsapi-0.3.23/jenkinsapi/utils/manifest.py0000644000000000000000000000641413615410400016332 0ustar00""" This module enables Manifest file parsing. Copied from https://chromium.googlesource.com/external/googleappengine/python/+/master /google/appengine/tools/jarfile.py """ import zipfile _MANIFEST_NAME = "META-INF/MANIFEST.MF" class InvalidJarError(Exception): """ InvalidJar exception class """ pass class Manifest(object): """ The parsed manifest from a jar file. Attributes: main_section: a dict representing the main (first) section of the manifest. Each key is a string that is an attribute, such as 'Manifest-Version', and the corresponding value is a string that is the value of the attribute, such as '1.0'. sections: a dict representing the other sections of the manifest. Each key is a string that is the value of the 'Name' attribute for the section, and the corresponding value is a dict like the main_section one, for the other attributes. """ def __init__(self, main_section, sections): self.main_section = main_section self.sections = sections def read_manifest(jar_file_name): """ Read and parse the manifest out of the given jar. Args: jar_file_name: the name of the jar from which the manifest is to be read. Returns: A parsed Manifest object, or None if the jar has no manifest. Raises: IOError: if the jar does not exist or cannot be read. """ with zipfile.ZipFile(jar_file_name) as jar: try: manifest_string = jar.read(_MANIFEST_NAME).decode("UTF-8") except KeyError: return None return _parse_manifest(manifest_string) def _parse_manifest(manifest_string): """ Parse a Manifest object out of the given string. Args: manifest_string: a str or unicode that is the manifest contents. Returns: A Manifest object parsed out of the string. Raises: InvalidJarError: if the manifest is not well-formed. """ manifest_string = "\n".join(manifest_string.splitlines()).rstrip("\n") section_strings = manifest_string.split("\n\n") parsed_sections = [_parse_manifest_section(s) for s in section_strings] main_section = parsed_sections[0] sections = dict() try: for entry in parsed_sections[1:]: sections[entry["Name"]] = entry except KeyError: raise InvalidJarError( "Manifest entry has no Name attribute: %s" % entry ) return Manifest(main_section, sections) def _parse_manifest_section(section): """Parse a dict out of the given manifest section string. Args: section: a str or unicode that is the manifest section. It looks something like this (without the >): > Name: section-name > Some-Attribute: some value > Another-Attribute: another value Returns: A dict where the keys are the attributes (here, 'Name', 'Some-Attribute', 'Another-Attribute'), and the values are the corresponding attribute values. Raises: InvalidJarError: if the manifest section is not well-formed. """ section = section.replace("\n ", "") try: return dict(line.split(": ", 1) for line in section.split("\n")) except ValueError: raise InvalidJarError("Invalid manifest %r" % section) jenkinsapi-0.3.23/jenkinsapi/utils/requester.py0000644000000000000000000002500613615410400016541 0ustar00""" Module for jenkinsapi requester (which is a wrapper around python-requests) """ import logging import time import requests import urllib.parse as urlparse from jenkinsapi.custom_exceptions import JenkinsAPIException, PostRequired from jenkinsapi.utils.logging import configure_logging # import logging # these two lines enable debugging at httplib level # (requests->urllib3->httplib) # you will see the REQUEST, including HEADERS and DATA, and RESPONSE # with HEADERS but without DATA. # the only thing missing will be the response.body which is not logged. # import httplib # httplib.HTTPConnection.debuglevel = 1 # you need to initialize logging, otherwise you will not see anything # from requests # logging.basicConfig() # logging.getLogger().setLevel(logging.DEBUG) # requests_log = logging.getLogger("requests.packages.urllib3") # requests_log.setLevel(logging.DEBUG) # requests_log.propagate = True class Requester(object): """ A class which carries out HTTP requests. You can replace this class with one of your own implementation if you require some other way to access Jenkins. This default class can handle simple authentication only. """ VALID_STATUS_CODES = [ 200, ] AUTH_COOKIE = None def __init__(self, *args, **kwargs): username = None password = None ssl_verify = True cert = None baseurl = None timeout = 10 max_retries = 3 if len(args) == 1: (username,) = args elif len(args) == 2: username, password = args elif len(args) == 3: username, password, ssl_verify = args elif len(args) == 4: username, password, ssl_verify, cert = args elif len(args) == 5: username, password, ssl_verify, cert, baseurl = args elif len(args) == 6: username, password, ssl_verify, cert, baseurl, timeout = args elif len(args) > 6: raise ValueError("To much positional arguments given!") baseurl = kwargs.get("baseurl", baseurl) self.base_scheme = ( urlparse.urlsplit(baseurl).scheme if baseurl else None ) self.username = kwargs.get("username", username) self.password = kwargs.get("password", password) if self.username: assert self.password, ( "Please provide both username and password " "or don't provide them at all" ) if self.password: assert self.username, ( "Please provide both username and password " "or don't provide them at all" ) self.ssl_verify = kwargs.get("ssl_verify", ssl_verify) self.cert = kwargs.get("cert", cert) self.timeout = kwargs.get("timeout", timeout) self.session = requests.Session() self.max_retries = kwargs.get("max_retries", max_retries) if self.max_retries is not None: retry_adapter = requests.adapters.HTTPAdapter( max_retries=self.max_retries ) self.session.mount("http://", retry_adapter) self.session.mount("https://", retry_adapter) configure_logging() self._log = logging.getLogger(__name__) self._sensitive_keys = { "password", "token", "secret", "apikey", "api_key", "authorization", "auth", "crumb", } def get_request_dict( self, params=None, data=None, files=None, headers=None, **kwargs ): requestKwargs = kwargs if self.username: requestKwargs["auth"] = (self.username, self.password) if params: assert isinstance(params, dict), ( f"Params must be a dict, got {repr(params)}" ) requestKwargs["params"] = params if headers: assert isinstance(headers, dict), ( f"headers must be a dict, got {repr(headers)}" ) requestKwargs["headers"] = headers if self.AUTH_COOKIE: currentheaders = requestKwargs.get("headers", {}) currentheaders.update({"Cookie": self.AUTH_COOKIE}) requestKwargs["headers"] = currentheaders requestKwargs["verify"] = self.ssl_verify requestKwargs["cert"] = self.cert if data: # It may seem odd, but some Jenkins operations require posting # an empty string. requestKwargs["data"] = data if files: requestKwargs["files"] = files requestKwargs["timeout"] = self.timeout return requestKwargs def _redact_dict(self, value): if not isinstance(value, dict): return value redacted = {} for key, item in value.items(): key_lower = str(key).lower() if any( sensitive in key_lower for sensitive in self._sensitive_keys ): redacted[key] = "***" else: redacted[key] = item return redacted def _redact_url(self, url): parts = urlparse.urlsplit(url) if parts.username or parts.password: username = parts.username or "" hostname = parts.hostname or "" netloc = "{}:***@{}".format(username, hostname) if parts.port: netloc = "{}:{}".format(netloc, parts.port) return urlparse.urlunsplit( [parts.scheme, netloc, parts.path, parts.query, parts.fragment] ) return url def _log_request(self, method, url, params, headers, data, files): if not self._log.isEnabledFor(logging.DEBUG): return safe_params = self._redact_dict(params) safe_headers = self._redact_dict(headers) data_len = None if data is not None: try: data_len = len(data) except TypeError: data_len = None self._log.debug( "HTTP %s %s params=%s headers=%s data_len=%s files=%s", method, self._redact_url(url), safe_params, safe_headers, data_len, bool(files), ) def _log_response(self, method, url, status_code, elapsed): if not self._log.isEnabledFor(logging.DEBUG): return self._log.debug( "HTTP %s %s -> %s (%.3fs)", method, self._redact_url(url), status_code, elapsed, ) def _update_url_scheme(self, url): """ Updates scheme of given url to the one used in Jenkins baseurl. """ if self.base_scheme and not url.startswith("%s://" % self.base_scheme): url_split = urlparse.urlsplit(url) url = urlparse.urlunsplit( [ self.base_scheme, url_split.netloc, url_split.path, url_split.query, url_split.fragment, ] ) return url def get_url( self, url, params=None, headers=None, allow_redirects=True, stream=False, ): requestKwargs = self.get_request_dict( params=params, headers=headers, allow_redirects=allow_redirects, stream=stream, ) final_url = self._update_url_scheme(url) self._log_request("GET", final_url, params, headers, None, None) start = time.time() response = self.session.get(final_url, **requestKwargs) status_code = getattr(response, "status_code", "unknown") self._log_response("GET", final_url, status_code, time.time() - start) return response def post_url( self, url, params=None, data=None, files=None, headers=None, allow_redirects=True, **kwargs, ): requestKwargs = self.get_request_dict( params=params, data=data, files=files, headers=headers, allow_redirects=allow_redirects, **kwargs, ) final_url = self._update_url_scheme(url) self._log_request("POST", final_url, params, headers, data, files) start = time.time() response = self.session.post(final_url, **requestKwargs) status_code = getattr(response, "status_code", "unknown") self._log_response("POST", final_url, status_code, time.time() - start) return response def post_xml_and_confirm_status( self, url, params=None, data=None, valid=None ): headers = {"Content-Type": "text/xml"} return self.post_and_confirm_status( url, params=params, data=data, headers=headers, valid=valid ) def post_and_confirm_status( self, url, params=None, data=None, files=None, headers=None, valid=None, allow_redirects=True, ): valid = valid or self.VALID_STATUS_CODES if not headers and not files: headers = {"Content-Type": "application/x-www-form-urlencoded"} assert data is not None, "Post messages must have data" response = self.post_url( url, params, data, files, headers, allow_redirects ) if response.status_code not in valid: raise JenkinsAPIException( "Operation failed. url={0}, data={1}, headers={2}, " "status={3}, text={4}".format( response.url, data, headers, response.status_code, response.text.encode("UTF-8"), ) ) return response def get_and_confirm_status( self, url, params=None, headers=None, valid=None, stream=False ): valid = valid or self.VALID_STATUS_CODES response = self.get_url(url, params, headers, stream=stream) if response.status_code not in valid: if response.status_code == 405: # POST required raise PostRequired("POST required for url {0}".format(url)) raise JenkinsAPIException( "Operation failed. url={0}, headers={1}, status={2}, " "text={3}".format( response.url, headers, response.status_code, response.text.encode("UTF-8"), ) ) return response jenkinsapi-0.3.23/jenkinsapi/utils/retry.py0000644000000000000000000000301713615410400015665 0ustar00import time from abc import ABC, abstractmethod from dataclasses import dataclass class RetryConfig(ABC): """ Base class for retry configuration Usage:: retry_check = retry_config.begin() while True: result = try_something() if result: return result retry_check.check_retry() All state is stored in the `RetryCheck` instance so `RetryConfig` can be used in multiple contexts simultaneously. """ @abstractmethod def begin(self) -> "RetryState": ... class RetryState(ABC): """ Base class for limited retry checks """ @abstractmethod def check_retry(self) -> None: """Sleep or raise `TimeoutError`""" pass @dataclass class SimpleRetryConfig(RetryConfig): sleep_period: float = 1 timeout: float = 5 def begin(self) -> "SimpleRetryState": return SimpleRetryState(self) @dataclass class SimpleRetryState(RetryState): """Basic implementation of RetryCheck with fixed sleep and timeout.""" config: SimpleRetryConfig start_time: float def get_current_time(self) -> float: return time.monotonic() def __init__(self, config: SimpleRetryConfig): self.config = config self.start_time = self.get_current_time() def check_retry(self) -> None: curr_time = self.get_current_time() if curr_time - self.start_time > self.config.timeout: raise TimeoutError("Retry timed out") time.sleep(self.config.sleep_period) jenkinsapi-0.3.23/jenkinsapi/utils/simple_post_logger.py0000755000000000000000000000173613615410400020426 0ustar00#!/usr/bin/env python try: from SimpleHTTPServer import SimpleHTTPRequestHandler except ImportError: from http.server import SimpleHTTPRequestHandler try: import SocketServer as socketserver except ImportError: import socketserver import logging import cgi PORT = 8080 class ServerHandler(SimpleHTTPRequestHandler): def do_GET(self): logging.error(self.headers) super().do_GET() def do_POST(self): logging.error(self.headers) form = cgi.FieldStorage( fp=self.rfile, headers=self.headers, environ={ "REQUEST_METHOD": "POST", "CONTENT_TYPE": self.headers["Content-Type"], }, ) for item in form.list: logging.error(item) super().do_GET() if __name__ == "__main__": Handler = ServerHandler httpd = socketserver.TCPServer(("", PORT), Handler) print("serving at port", PORT) httpd.serve_forever() jenkinsapi-0.3.23/jenkinsapi_tests/__init__.py0000644000000000000000000000000013615410400016326 0ustar00jenkinsapi-0.3.23/jenkinsapi_tests/conftest.py0000644000000000000000000000074213615410400016431 0ustar00import os import logging logging.basicConfig( format="%(module)s.%(funcName)s %(levelname)s: %(message)s", level=logging.INFO, ) level = ( logging.WARNING if "LOG_LEVEL" not in os.environ else os.environ["LOG_LEVEL"].upper().strip() ) modules = [ "requests.packages.urllib3.connectionpool", "requests", "urllib3", "urllib3.connectionpool", ] for module_name in modules: logger = logging.getLogger(module_name) logger.setLevel(level) jenkinsapi-0.3.23/jenkinsapi_tests/systests/__init__.py0000644000000000000000000000000013615410400020227 0ustar00jenkinsapi-0.3.23/jenkinsapi_tests/systests/config.xml0000644000000000000000000000226713615410400020126 0ustar00 1.0 2 NORMAL true ${JENKINS_HOME}/workspace/${ITEM_FULLNAME} ${ITEM_ROOTDIR}/builds 0 All false false All 0 jenkinsapi-0.3.23/jenkinsapi_tests/systests/conftest.py0000644000000000000000000001537013615410400020335 0ustar00import os import logging import pytest import time import requests from jenkinsapi.jenkins import Jenkins from jenkinsapi.utils.docker_jenkins import DockerJenkins, DEFAULT_IMAGE_NAME from jenkinsapi_tests.unittests.conftest_helpers import ( parse_worker_id, generate_container_name, find_free_port, ) log = logging.getLogger(__name__) state = {} # User/password for authentication testcases ADMIN_USER = "admin" ADMIN_PASSWORD = "admin" def _delete_all_jobs(jenkins): jenkins.poll() for name in jenkins.keys(): del jenkins[name] def _delete_all_views(jenkins): all_view_names = jenkins.views.keys()[1:] for name in all_view_names: del jenkins.views[name] def _delete_all_credentials(jenkins): all_cred_names = jenkins.credentials.keys() for name in all_cred_names: del jenkins.credentials[name] def _create_admin_user(launched_jenkins): # Groovy script that creates a user "admin/admin" in jenkins # and enable security. "admin" user will be the only user and # have admin permissions. Anonymous cannot read anything. create_admin_groovy = """ import jenkins.model.* import hudson.security.* def instance = Jenkins.getInstance() def hudsonRealm = new HudsonPrivateSecurityRealm(false) hudsonRealm.createAccount('{0}','{1}') instance.setSecurityRealm(hudsonRealm) def strategy = new FullControlOnceLoggedInAuthorizationStrategy() strategy.setAllowAnonymousRead(false) instance.setAuthorizationStrategy(strategy) """.format(ADMIN_USER, ADMIN_PASSWORD) url = launched_jenkins.jenkins_url jenkins_instance = Jenkins(url) jenkins_instance.run_groovy_script(create_admin_groovy) def _disable_security(launched_jenkins): # Groovy script that disables security in jenkins, # reverting the changes made in "_create_admin_user" function. disable_security_groovy = """ import jenkins.model.* import hudson.security.* def instance = Jenkins.getInstance() instance.disableSecurity() instance.save() """ url = launched_jenkins.jenkins_url jenkins_instance = Jenkins(url, ADMIN_USER, ADMIN_PASSWORD) jenkins_instance.run_groovy_script(disable_security_groovy) @pytest.fixture(scope="session") def docker_image(worker_id): """ Build shared Docker image once per session. Worker 0 builds the image; all other workers wait for it. All workers share one image but each gets a unique container. """ import docker as docker_lib worker_num, display_name = parse_worker_id(worker_id) repo_root = os.path.dirname( os.path.dirname(os.path.dirname(os.path.abspath(__file__))) ) dockerfile_path = os.path.join(repo_root, "docker", "Dockerfile") client = docker_lib.from_env() image_name = DEFAULT_IMAGE_NAME # If image already exists, all workers can use it immediately try: client.images.get(image_name) log.info( "[%s] Docker image already exists, skipping build", display_name ) return image_name except docker_lib.errors.ImageNotFound: pass if worker_num == 0: log.info("[%s] Building Docker image %s...", display_name, image_name) try: client.images.build( path=os.path.dirname(dockerfile_path), dockerfile="Dockerfile", tag=image_name, rm=True, ) log.info("[%s] Image build complete", display_name) return image_name except Exception as e: log.error("[%s] Image build failed: %s", display_name, e) raise log.info( "[%s] Waiting for shared image to be built by worker-0...", display_name, ) max_wait = 300 start = time.time() while time.time() - start < max_wait: try: client.images.get(image_name) log.info("[%s] Image ready", display_name) return image_name except docker_lib.errors.ImageNotFound: time.sleep(2) raise RuntimeError(f"Timeout waiting for image (waited {max_wait}s)") @pytest.fixture(scope="session") def launched_jenkins(docker_image, worker_id): worker_num, display_name = parse_worker_id(worker_id) base_port = 8080 if worker_num == 0 else 8100 + (worker_num - 1) * 10 port = find_free_port(start_port=base_port) container_name = generate_container_name( worker_id, base_name="jenkinsapi-systest" ) launcher = DockerJenkins( image_name=docker_image, container_name=container_name, port=port, ) log.info( "[%s] Starting container %s on port %d", display_name, container_name, port, ) launcher.start(timeout=300) ensure_jenkins_up(launcher.jenkins_url, timeout=60) yield launcher log.info("[%s] All tests finished", display_name) launcher.stop() def ensure_jenkins_up(url, timeout=60): start = time.time() while time.time() - start < timeout: try: resp = requests.get(url, timeout=5) if resp.status_code == 200: return except Exception as err: print("Exception connecting to jenkins", err) time.sleep(2) pytest.exit("Jenkins didnt become available to call") @pytest.fixture(scope="function") def jenkins(launched_jenkins): url = launched_jenkins.jenkins_url jenkins_instance = Jenkins(url, timeout=60) ensure_jenkins_up(url, timeout=60) # Retry cleanup operations to handle transient connection issues max_retries = 3 for attempt in range(max_retries): try: _delete_all_jobs(jenkins_instance) _delete_all_views(jenkins_instance) _delete_all_credentials(jenkins_instance) break except Exception as e: if attempt < max_retries - 1: log.warning( f"Cleanup attempt {attempt + 1} failed, retrying: {e}" ) time.sleep(2) return jenkins_instance @pytest.fixture(scope="function") def lazy_jenkins(launched_jenkins): url = launched_jenkins.jenkins_url jenkins_instance = Jenkins(url, lazy=True) _delete_all_jobs(jenkins_instance) _delete_all_views(jenkins_instance) _delete_all_credentials(jenkins_instance) return jenkins_instance @pytest.fixture(scope="function") def jenkins_admin_admin(launched_jenkins, jenkins): # pylint: disable=unused-argument # Using "jenkins" fixture makes sure that jobs/views/credentials are # cleaned before security is enabled. url = launched_jenkins.jenkins_url _create_admin_user(launched_jenkins) jenkins_admin_instance = Jenkins(url, ADMIN_USER, ADMIN_PASSWORD) yield jenkins_admin_instance jenkins_admin_instance.requester.__class__.AUTH_COOKIE = None _disable_security(launched_jenkins) jenkinsapi-0.3.23/jenkinsapi_tests/systests/jenkins_home.tar.gz0000644000000000000000000001327013615410400021733 0ustar00‹85Wí<xÕÖy‘®CH€ÉîÌöHX)RI£HqvæîfÈîÌ23›d)‚€RB MŠBP‚€RC£HG%J•&¡# ðß;³»)"ññ0¼ÿ™óAf÷–ÓÏ™sïÜYP<¥`%ÓA ÈÚHA| (ψÎÀ4Æj 2‘JõzJÀq\§Ñ`ðJèµxÉ«´¡Òãz�JCh´®RéU:/ Z‚ÿ8‘ä!+¤¤ Àù§ãÊë—Á<×ÿ'`&­xÞLTÂsÁÿ ˈ i ¡m O BÇÓÏŠ†ÿøâ_C¸â_Mèa.€Ýz•¾2þ+´¤ZeÂ5:©#4­3™Uh€–Ôã´–"MÞÏ›ÁJø[ÁÿfÆ ì¤˜?Àû¿  •j<�fÒaÇšŸžF9÷� ×»ã_¥Váèþ'TÆE€/Ö=<6$¥:{qRõ¤`±Ô½92cFY³ÄN¥25¼@+Â8€m¬+t)9áËÔŠ“èó.ƒþ±àº(à%¨d;LÓ4èÍ %yZ�<ï?ß*ÿO]bÿO남\ÿW ¨¢åJø/÷úÖI°.—*ˆ@ &TŽ¢M€gAƃ^¯}Rüãžõ?N¨aüãj­Ê Ó> âåÁ?<þÝö—KJEÚ$|j(/ÿk<öGù_…á„VW¹ÿS1@Td�Î Ô”ŽÐà³ICiÕ„š6¨5ÒlÐáj³Á ¥T@MeÒ•:ˆ¤5ú Ê@˜UzIë(³Ù¬Ñéõ”™Ôkp�I«6àz¤1«5Aj4zWT&h_½'q­&(JeÐ}� &$bÖ‘x�š4©T„ŠÖãZdVã¸AG˜Ì¸Z�‚0ãAW›L&³A­ÓS7LÀ ÕªôZ¥2ÀvÚ¤¥U&­6(ˆ&L-\ÒÓZ“š5­Õ@Ô´N¥×zÊDšÕ­áyëÿy#môˆNù ¸ðú;h øÇÿüù�Z¥úCü« ¢2þ+’_ôÞåU×_9wmòg}-„�~z8¨ÍÅ:GšÔÅô­é¶5;¬î™/(çŸÞ–˜öÍŽ…ys(ÕY#õd ïL¯³òŽe´ÙÙüÅ•µNumá?¾àý‹>ü×U«ñyNѰú§>¯¹Ýgö¡_ë ÒöÝ~mïƒqêñªúît31¢gì|rfk�Q^ë¬=ÈLŸé5úBÇü¹UØ„ý»¶¼›Õã"�?ÿVwT㎩_íùú@Í®Kµ:ÔŸóp¿úh÷‰½nÕžtý°¯¦Ù¤”MÄý˜/5Ó>;îsôÔï‹3Uöë lè¾ïÞŽúŽì= Ì ¶¶¹é;ù§ë«ï&E[ O.[Óöò†ÉÙKÖ69Ћ¹Ñ1kl0¾öÚªNø®ÄÞ+«Ä©Nk×;ð“íæ5œ5úãZ£Î.öZ¾2f†ºsÕõb:9ÞxèÅ�[=áÈŸkä£ám²ÄŽk,ž½ÀöÂ�ÓÞ¸>t©5ê^ƒØ3�V�PÔtæ°ë §|­ñ‘ßæ�®:¡}ÈäÜïê¿(nsA?jð¤›/~?ãUßï}'Ó�WF~9‡Š�çýóÑÜaÇæ>Ü/nûfÛ½úÞ#Ò ë37,æ�·úNÈk¼ìÐ}¢ÃŠéUR–oüþè/ÖES;¾ÒùÕu~ªÙ¬ÏAjRû‡;ë.Ó4Þzðnçå±µš¾·=¾z~ËÚ{Ã;ĺiC×ïvalÿ‰ÜÔŠßc:å:“;U¯[E5pê¤+—&×3½®ûð˜e4�ešòðÓ“½Ïïo~Þ'‹9÷kí•+»œW-v?bi½ý÷:\FgûÕ?¬±8ØùÛµþCÆ|òh™êA•“cl>"iÓ{¹y¼æÂņoG]Xt-àøü%Y5 súGßeš~éÈ�êŸNÍiqk±¬g½—ó~^Øÿó:Gçe­©7çŠ{¨¿×Ösjî�½{ >7‡4yÁr0±ðݶû>ÈòŸã³©ë/Ió"Î)sNúœÞÖ¸þ×oŸ÷Ý=ýT�ŠÉíóYûZ¡Ù?ÇOð·zξG;æÞÐ~×›ü¼¯ÎFÔ®zϷ﬎‡û5Ø•Õð_þ•ýù£__ÿz×á»ÞŸ²«ö±Mû«¶óJΫŠßžT}wòñ£Ç)¯Ú¯ç&õ+œÖvÓñ†¯™¼gç¯Ùê·üæ`Ñ»YÍ’Vâñêü�fV¿ám,9õoÝ{¿CÑT,ú­^ ãε¸Ñ~ ±ƒþ☽öcT³K#’üQ_Çlˆ¾_X”ùÐÞ¯1sŽbý@~ž~ÜÅ.'F�ÛŒ™}�ŒM™0®Yh?ZóéÈÍÙ»|™•íÞ ¾S³~¿¹{nÕºskãÜŸÏŸëS [vvJæa:EspÖŸtµê'—šm?kã€w}>ñ¢GÞÌ¥äM|çÂÜc­w¿µ|gRΊAê·â»ê£úñjËA'.r³s>ís‚κP£GvWãÒ5#vWa 0☟mÜÑÉ- û×Î:¿Ï{Å£�áYyQ­²š¦W¥¦Þ»B5^§õΩ9¼úÝŠ©çSÔ˜ Ö XQ`\ZðrD­EçF|^Û«Mb×~'ZÎî·›™úÖº¾gTË*ª‰Ö‚K yùëVù~7Ð1"­õßÐï;<³�vFÝI«¬¦ß^é}ÂØô zîØþ5†üƽ¸´JÐÜžÞ âÖNžÛ¼(ŸèUmþÑœfaÍß4x¾#1ãPŸ£Æ Ûçï¹É�-|aòƒ6i«fÕ{©å{e‰7«’×òÚÄ�Ìiôeß.TCêîGùwºé²¦çÌ«µöææî\�¨W3‹nÿ0ª_F‚�rcȪá�ïc³²�&9Óêз5tÛ½âV›û«þõmHó‘dµorëÅmîwYW­öÒ8Ý„¡Ó±D¿:ïŽÝùä²Ã½¶âH¯ïD]­¹_kûå[¼Nݘ�º+ãÛãl䮋ãt¶Üa©Â4Ó"éç›Íï86åõ9÷R÷Ã÷ïÌ;U°uÒƒ£æÝ?ŒoðpßûÇ|bÞî;çÅfg:5:¹ó ýGé]·üXØpÏ´ñáF¢€j[íã¨üïÿ†Z¸-ñ€n˜Á¶¶Yï%u·¬¨FLJ´D3C¶¥ŽÕ�¬ùÞɘ o6}kË‹K¦Ç·ê·çî'ÝYíÎ >§«¿ü£¹Ûï…Œ¸W¦:ºÒxE,ú6å“„S7;¯%?îpiáÔÑ˾ÌÛ’|áÓ5ßoÙ™7ñ‰ío×`+‚Ý={aÊ¥ßâWö|3}ñŒ•Ã/¥l¹”yûr«Ë; z­K¹Ñþ,%äöïQóñÂu9ÛW�:•{A=Ú�·wj_;¿=Á|äóCkØÿí»šÎ¹=ê6Αÿj›‹Q—¬õÙ–mT?ïûá? ÊîÿEÊ×d‘±*¬pMž ¨À Ç>5�¿¾ÿ‡žÿèù¿ŽÐVÖ•ûÿlHuÐÇ*l Ðæ?MŠ ®�žÛ=#(þuOˆ•ºÌùtþ¯rýW!Ü=ŸM—3|çv„o‡Žzp4ÃZ:·KNŠ4´ëbôÐM£7†IŸÐø‘¡�®bÁJøYntðV£ë@ŠCò'¡ìq¹9�’ýl0ô¿`%š…°+eôòU0>oõüσ{ÿ÷ï¤�öÿŸ°ÿóÇýB­Óè+÷ÿ+Ê{þó,N€–Sÿ:=Qöü§N[™ÿ+þžóŸ®#iGrÃÊ@g@­ H—�÷¡3nž›,yó©Žl–b¸"�SÚÈtH@–ÝÛ]X¡6E úÛ ‹ç¹L§_„tÖ/Dp²T$›Î¥^:Edå´ !�v+çD‡elgSxz®³\ ÷(`-è(¥"\ºBô¬ðvª& ��#ENÂ/8L®;}.ʦHôtövŸKVé¸��f[1�‚Öc=öõ‡$ " þ} �¾½7T“û^•ææΓ§)B­LwF ‰�Œ´Ù­~Ý�Ï3é°ˆNAâOH ,qöôñ»W¬nôpR‰9ŠÄÄ!tàHd(ÒÃ;å´d0l �çÐ$饶ÒÊ…–àì‚SP�<õäù®éòlE¨Ô/·EIm~ò%Ø8XGCï—¤‚Zd‡-°´Î=ü»º.w %í¤‰±BO‹‘ÎOñ~Ñ$Œ×—PWH=«Sîü_Út6’‘Öèl—¼5ÀR`�çYQ¤«%ÒÝÞ÷ 4Ê}þ[öù�Wë*ó…@¢ỏ7³ýÖlI>Ù-ÚˆOù ÁAáS_d¹êìÍ/m½6vÜ*ë¹�'Ϲ³~â©Cþí–_lxïƒãî,òn´|ìÒUíæÿ´kRÌÖÖÂÚ言¤Ñk'0×Ãf øyyÑ�>¾áGØÍ}¾U†Íœ9jõÕÐ�oŸïÐooPûóùßõ°±¿ìj—Û­Å—…[Z~xhx‹s—.Ì?ÜÉÐ÷Ô…Ï[%Q†øÃwÇ-²ž2¯PÔ W¶¹Q3ù£SWG´Þ¢ßpô…‚Gú·}ŒcbO:<½íø)g_nóh×OC^¾¼ú–oü›‹ª4ú¢0ñ`�»ZpnÖWdƒí_ÕyÿžÏ£„­¯/=,dx7Ñõðv#`ÖŽ-¯ì'÷Ócd7‰Ú¶øtÛ™í¹E\óv¦Çó¶GEƒÿÒÓ_–.Pny–4Êßÿ+ÿj=†*B¼òü_…ª«Ë3Üìy ”³ÿ£Ò굞ú_Eè ý5*^iÿŠ€¿ºÿ#-Ò Í¨¡¥w…aÁËK&Äp,Ë-Áµ ›!£»X£ÒÂr¬É`…æ*¤#…8³Ù5-Xéš í•O!ØÅ±2¬tA¬Ã†žY8¤‘ª`e©ïhÚê4ÆÆ%Ä„D+¥/¨Õ!7_F‘w€`eÉ4‚„U%Ç3C¥÷OÀâ„%;)�[»ëh×EÈãû%³Ò@·VÊ[i®á €´Úþ UbÉA~± \³]Z‚Å&@ï›Å£ô2¿G}%:ÐpX¼¢Óö±$Ô©¥,ÿn;ÉûÀñ�ê&o<¶ÓÅ’çý‘0†7ú +ù²Â¥§Sé7,2)Ûú×VAÿÂzä¬ìµ¯õ†ã/� »w5mè›õWí̽:�îάo©ýZ—3ù³gûÿöUd‹c�]¹å;/í›{»ö¦‚�ˆi_³óW®ômy©úE|D¯«t^Ò£_j\“3î?r^Ìé~¹QöÃZ×ûy¨[v­I ={øhMû!›�Ý£rÕM3{ç_˜]kxã"§%fõm:¾Ê’/O�zá­Ë½Bã;^¾�_Û£›ß‘«/$7ÈT| ÜZ7aöÍ5cŽ×_ØüAÔ‚¦»k�öynèNî97ÿáö:SëG¬ú±s8^#ymè+±W«�Ð~ûÁ¼æ‹ê?ø3YßþO¯o‹÷ÿÿ>åÅ?ñ‡ýô“`•ñ_ # èÌ@e T0ÐD�ŽnÒƒ‰‚U{a6!³Ð”‰2ã:B‹kÔÚ M©àH½æyó_ •P •P Oÿšÿ\jenkinsapi-0.3.23/jenkinsapi_tests/systests/job_configs.py0000644000000000000000000003100013615410400020756 0ustar00""" A selection of job objects used in testing. """ EMPTY_JOB = """\ false true false false false false """.strip() LONG_RUNNING_JOB = """ false true false false false false sleep 100 """.strip() SHORTISH_JOB = """ false true false false false false python3 -c "print('ok')" """.strip() SCM_GIT_JOB = """ false 2 https://github.com/salimfadhley/jenkinsapi.git ** false false false false false false false false false false Default true true false false false false """.strip() JOB_WITH_ARTIFACTS = """ Ping a load of stuff for about 10s false true false false false false printf 'PING localhost (127.0.0.1): 56 data bytes\n64 bytes from 127.0.0.1: icmp_seq=1 ttl=64 time=0.050 ms' > out.txt cat out.txt | gzip > out.gz *.txt,*.gz false *.* true """.strip() MATRIX_JOB = """ false true false false false false foo one two three python3 -c "print('ok')" """.strip() JOB_WITH_FILE = """ false file.txt true false false false false cat file.txt * false """.strip() JOB_WITH_PARAMETERS = """ A build that explores the wonderous possibilities of parameterized builds. false B B, like buzzing B. true false false false false python3 -c "print('ok')" > out.txt echo $A > a.txt echo $B > b.txt * false true """.strip() # noqa JOB_WITH_FILE_AND_PARAMS = """ false file.txt B B, like buzzing B. true false false false false cat file.txt;echo $B > file1.txt * false """.strip() JOB_WITH_ENV_VARS = """\ false true false false false false return [\'key1\': \'value1\', \'key2\': \'value2\'] false """.strip() PIPELINE_SCM_JOB = """ false Jenkinsfile 2 https://github.com/salimfadhley/jenkinsapi.git ** false false """.strip() PIPELINE_SCM_CONFIG = { "scm_class": "hudson.plugins.git.GitSCM", "git_url": "https://github.com/salimfadhley/jenkinsapi.git", } PIPELINE_INPUT_JOB = """ false true false """.strip() FOLDER = """ """.strip() jenkinsapi-0.3.23/jenkinsapi_tests/systests/test_api_token_revocation.py0000644000000000000000000000346713615410400023755 0ustar00""" System tests for API token revocation. """ import logging import json import pytest from jenkinsapi.utils.crumb_requester import CrumbRequester log = logging.getLogger(__name__) pytestmark = pytest.mark.docker def _get_token_uuids(jenkins): script = """ import groovy.json.JsonOutput import hudson.model.User import jenkins.security.ApiTokenProperty def user = User.current() def property = user.getProperty(ApiTokenProperty) def token_list = property.tokenStore.tokenList.collect { token -> [uuid: token.uuid] } println(JsonOutput.toJson(token_list)) """.strip() return [ token["uuid"] for token in json.loads(jenkins.run_groovy_script(script)) ] def test_revoke_api_token(jenkins_admin_admin): jenkins_admin_admin.requester = CrumbRequester( baseurl=jenkins_admin_admin.baseurl, username=jenkins_admin_admin.username, password=jenkins_admin_admin.password, ) jenkins_admin_admin.poll() jenkins_admin_admin.generate_new_api_token("token-to-revoke") token_uuids = _get_token_uuids(jenkins_admin_admin) assert token_uuids token_uuid = token_uuids[-1] jenkins_admin_admin.revoke_api_token(token_uuid) remaining = _get_token_uuids(jenkins_admin_admin) assert token_uuid not in remaining def test_revoke_all_api_tokens(jenkins_admin_admin): jenkins_admin_admin.requester = CrumbRequester( baseurl=jenkins_admin_admin.baseurl, username=jenkins_admin_admin.username, password=jenkins_admin_admin.password, ) jenkins_admin_admin.poll() jenkins_admin_admin.generate_new_api_token("token-1") jenkins_admin_admin.generate_new_api_token("token-2") assert _get_token_uuids(jenkins_admin_admin) jenkins_admin_admin.revoke_all_api_tokens() assert _get_token_uuids(jenkins_admin_admin) == [] jenkinsapi-0.3.23/jenkinsapi_tests/systests/test_authentication.py0000644000000000000000000000530213615410400022560 0ustar00""" System tests for authentication functionality """ import pytest from jenkinsapi_tests.test_utils.retry import retry from jenkinsapi.utils.requester import Requester from requests import HTTPError as REQHTTPError from jenkinsapi.jenkins import Jenkins pytestmark = pytest.mark.docker @retry() def test_normal_authentication(jenkins_admin_admin): # No problem with the righ user/pass jenkins_user = Jenkins( jenkins_admin_admin.baseurl, jenkins_admin_admin.username, jenkins_admin_admin.password, ) assert jenkins_user is not None # We cannot connect if no user/pass with pytest.raises(REQHTTPError) as http_excep: Jenkins(jenkins_admin_admin.baseurl) assert Requester.AUTH_COOKIE is None assert http_excep.value.response.status_code == 403 # def test_auth_cookie(jenkins_admin_admin): # initial_cookie_value = None # final_cookie_value = "JSESSIONID" # assert initial_cookie_value == Requester.AUTH_COOKIE # # jenkins_admin_admin.use_auth_cookie() # # result = Requester.AUTH_COOKIE # assert result is not None # assert final_cookie_value in result # # # def test_wrongauth_cookie(jenkins_admin_admin): # initial_cookie_value = None # assert initial_cookie_value == Requester.AUTH_COOKIE # # jenkins_admin_admin.username = "fakeuser" # jenkins_admin_admin.password = "fakepass" # # with pytest.raises(HTTPError) as http_excep: # jenkins_admin_admin.use_auth_cookie() # # assert Requester.AUTH_COOKIE is None # assert http_excep.value.code == 401 # # # def test_verify_cookie_isworking(jenkins_admin_admin): # initial_cookie_value = None # final_cookie_value = "JSESSIONID" # assert initial_cookie_value == Requester.AUTH_COOKIE # # # Remove requester user/pass # jenkins_admin_admin.requester.username = None # jenkins_admin_admin.requester.password = None # # # Verify that we cannot connect # with pytest.raises(REQHTTPError) as http_excep: # jenkins_admin_admin.poll() # # assert Requester.AUTH_COOKIE is None # assert http_excep.value.response.status_code == 403 # # # Retrieve the auth cookie, we can because we # # have right values for jenkins_admin_admin.username # # and jenkins_admin_admin.password # jenkins_admin_admin.use_auth_cookie() # # result = Requester.AUTH_COOKIE # assert result is not None # assert final_cookie_value in result # # # Verify that we can connect even with no requester user/pass # # If we have the cookie the requester user/pass is not needed # jenkins_admin_admin.poll() jenkinsapi-0.3.23/jenkinsapi_tests/systests/test_build_set_description.py0000644000000000000000000000121413615410400024114 0ustar00""" System tests for Build.set_description(). """ import logging import pytest from jenkinsapi_tests.test_utils.random_strings import random_string from jenkinsapi_tests.systests.job_configs import SHORTISH_JOB log = logging.getLogger(__name__) pytestmark = pytest.mark.docker def test_set_description(jenkins): job_name = "desc_%s" % random_string() job = jenkins.create_job(job_name, SHORTISH_JOB) qq = job.invoke() qq.block_until_building() build = qq.get_build() build.block_until_complete() build.set_description("my test description") build.poll() assert build.get_description() == "my test description" jenkinsapi-0.3.23/jenkinsapi_tests/systests/test_credentials.py0000644000000000000000000002156213615410400022044 0ustar00""" System tests for `jenkinsapi.jenkins` module. """ import time import logging import pytest from jenkinsapi_tests.test_utils.random_strings import random_string from jenkinsapi.credentials import Credentials from jenkinsapi.credentials import UsernamePasswordCredential from jenkinsapi.credentials import SecretTextCredential from jenkinsapi.credential import SSHKeyCredential from jenkinsapi.credential import FileCredentials from jenkinsapi.credential import DockerServerCredentials log = logging.getLogger(__name__) pytestmark = pytest.mark.docker def test_get_credentials(jenkins): # Retry with exponential backoff for transient connection failures max_retries = 5 retry_delay = 1 last_error = None for attempt in range(max_retries): try: creds = jenkins.credentials assert isinstance(creds, Credentials) is True return except Exception as e: last_error = e if attempt < max_retries - 1: time.sleep(retry_delay) retry_delay = min( retry_delay * 1.5, 5 ) # exponential backoff, capped at 5s raise last_error def test_delete_inexistant_credential(jenkins): with pytest.raises(KeyError): creds = jenkins.credentials del creds[random_string()] def test_create_user_pass_credential(jenkins): # Retry with exponential backoff for transient connection failures max_retries = 5 retry_delay = 1 last_error = None for attempt in range(max_retries): try: creds = jenkins.credentials cred_descr = random_string() cred_dict = { "description": cred_descr, "userName": "userName", "password": "password", } creds[cred_descr] = UsernamePasswordCredential(cred_dict) assert cred_descr in creds cred = creds[cred_descr] assert isinstance(cred, UsernamePasswordCredential) is True assert cred.password == "" assert cred.description == cred_descr del creds[cred_descr] return except Exception as e: last_error = e if attempt < max_retries - 1: time.sleep(retry_delay) retry_delay = min( retry_delay * 1.5, 5 ) # exponential backoff, capped at 5s raise last_error def test_update_user_pass_credential(jenkins): # Retry with exponential backoff for transient connection failures max_retries = 5 retry_delay = 1 last_error = None for attempt in range(max_retries): try: creds = jenkins.credentials cred_descr = random_string() cred_dict = { "description": cred_descr, "userName": "userName", "password": "password", } creds[cred_descr] = UsernamePasswordCredential(cred_dict) cred = creds[cred_descr] cred.userName = "anotheruser" cred.password = "password2" cred = creds[cred_descr] assert isinstance(cred, UsernamePasswordCredential) is True assert cred.userName == "anotheruser" assert cred.password == "password2" return except Exception as e: last_error = e if attempt < max_retries - 1: time.sleep(retry_delay) retry_delay = min( retry_delay * 1.5, 5 ) # exponential backoff, capped at 5s raise last_error def test_create_ssh_credential(jenkins): # Retry with exponential backoff for transient connection failures max_retries = 5 retry_delay = 1 last_error = None for attempt in range(max_retries): try: creds = jenkins.credentials cred_descr = random_string() cred_dict = { "description": cred_descr, "userName": "userName", "passphrase": "", "private_key": "-----BEGIN RSA PRIVATE KEY-----", } creds[cred_descr] = SSHKeyCredential(cred_dict) assert cred_descr in creds cred = creds[cred_descr] assert isinstance(cred, SSHKeyCredential) is True assert cred.description == cred_descr del creds[cred_descr] cred_dict = { "description": cred_descr, "userName": "userName", "passphrase": "", "private_key": "/tmp/key", } with pytest.raises(ValueError): creds[cred_descr] = SSHKeyCredential(cred_dict) cred_dict = { "description": cred_descr, "userName": "userName", "passphrase": "", "private_key": "~/.ssh/key", } with pytest.raises(ValueError): creds[cred_descr] = SSHKeyCredential(cred_dict) cred_dict = { "description": cred_descr, "userName": "userName", "passphrase": "", "private_key": "invalid", } with pytest.raises(ValueError): creds[cred_descr] = SSHKeyCredential(cred_dict) return except Exception as e: last_error = e if attempt < max_retries - 1: time.sleep(retry_delay) retry_delay = min( retry_delay * 1.5, 5 ) # exponential backoff, capped at 5s raise last_error def test_delete_credential(jenkins): # Retry with exponential backoff for transient connection failures max_retries = 5 retry_delay = 1 last_error = None for attempt in range(max_retries): try: creds = jenkins.credentials cred_descr = random_string() cred_dict = { "description": cred_descr, "userName": "userName", "password": "password", } creds[cred_descr] = UsernamePasswordCredential(cred_dict) assert cred_descr in creds del creds[cred_descr] assert cred_descr not in creds return except Exception as e: last_error = e if attempt < max_retries - 1: time.sleep(retry_delay) retry_delay = min( retry_delay * 1.5, 5 ) # exponential backoff, capped at 5s raise last_error def test_create_secret_text_credential(jenkins): """ Tests the creation of a secret text. """ # Retry with exponential backoff for transient connection failures max_retries = 5 retry_delay = 1 last_error = None for attempt in range(max_retries): try: creds = jenkins.credentials cred_descr = random_string() cred_dict = {"description": cred_descr, "secret": "newsecret"} creds[cred_descr] = SecretTextCredential(cred_dict) assert cred_descr in creds cred = creds[cred_descr] assert isinstance(cred, SecretTextCredential) is True assert cred.secret is None assert cred.description == cred_descr del creds[cred_descr] return except Exception as e: last_error = e if attempt < max_retries - 1: time.sleep(retry_delay) retry_delay = min( retry_delay * 1.5, 5 ) # exponential backoff, capped at 5s raise last_error def test_create_file_credential(jenkins): import base64 creds = jenkins.credentials cred_descr = random_string() secret_content = b"my secret file content" cred_dict = { "description": cred_descr, "filename": "secret.txt", "secret_bytes": base64.b64encode(secret_content).decode("utf-8"), } creds[cred_descr] = FileCredentials(cred_dict) assert cred_descr in creds cred = creds[cred_descr] assert isinstance(cred, FileCredentials) def test_create_docker_server_credential(jenkins): creds = jenkins.credentials cred_descr = random_string() cred_dict = { "description": cred_descr, "username": "dockeruser", "clientKey": "-----BEGIN RSA PRIVATE KEY-----\nclient-key\n-----END RSA PRIVATE KEY-----", "clientCertificate": "-----BEGIN CERTIFICATE-----\nclient-cert\n-----END CERTIFICATE-----", "serverCaCertificate": "-----BEGIN CERTIFICATE-----\nca-cert\n-----END CERTIFICATE-----", } creds[cred_descr] = DockerServerCredentials(cred_dict) assert cred_descr in creds cred = creds[cred_descr] assert isinstance(cred, DockerServerCredentials) assert cred.description == cred_descr del creds[cred_descr] assert cred_descr not in creds jenkinsapi-0.3.23/jenkinsapi_tests/systests/test_crumbs_requester.py0000644000000000000000000000672413615410400023144 0ustar00import io import json import logging import pytest from urllib.parse import urljoin from jenkinsapi.jenkins import Jenkins from jenkinsapi.utils.crumb_requester import CrumbRequester from jenkinsapi_tests.test_utils.retry import retry from jenkinsapi_tests.test_utils.random_strings import random_string from jenkinsapi_tests.systests.job_configs import JOB_WITH_FILE log = logging.getLogger(__name__) pytestmark = pytest.mark.docker DEFAULT_JENKINS_PORT = 8080 ENABLE_CRUMBS_CONFIG = { "hudson-security-csrf-GlobalCrumbIssuerConfiguration": { "csrf": { "issuer": { "value": "0", "stapler-class": "hudson.security.csrf.DefaultCrumbIssuer", "$class": "hudson.security.csrf.DefaultCrumbIssuer", "excludeClientIPFromCrumb": False, } } } } DISABLE_CRUMBS_CONFIG = { "hudson-security-csrf-GlobalCrumbIssuerConfiguration": {}, } SECURITY_SETTINGS = { "": "0", "markupFormatter": { "stapler-class": "hudson.markup.EscapedMarkupFormatter", "$class": "hudson.markup.EscapedMarkupFormatter", }, "org-jenkinsci-main-modules-sshd-SSHD": { "port": {"value": "", "type": "disabled"} }, "jenkins-CLI": {"enabled": False}, # This is not required if envinject plugin is not installed # but since it is installed for test suite - we must have this config # If this is not present - Jenkins will return error "org-jenkinsci-plugins-envinject-EnvInjectPluginConfiguration": { "enablePermissions": False, "hideInjectedVars": False, "enableLoadingFromMaster": False, }, "jenkins-model-DownloadSettings": {"useBrowser": False}, "slaveAgentPort": {"value": "", "type": "disable"}, "agentProtocol": [ "CLI-connect", "CLI2-connect", "JNLP-connect", "JNLP2-connect", "JNLP4-connect", ], "core:apply": "", } @pytest.fixture(scope="function") def crumbed_jenkins(jenkins): ENABLE_CRUMBS_CONFIG.update(SECURITY_SETTINGS) DISABLE_CRUMBS_CONFIG.update(SECURITY_SETTINGS) jenkins.requester.post_and_confirm_status( urljoin(jenkins.baseurl, "/configureSecurity/configure"), data={"Submit": "save", "json": json.dumps(ENABLE_CRUMBS_CONFIG)}, headers={"Content-Type": "application/x-www-form-urlencoded"}, ) log.info("Enabled Jenkins security") crumbed = Jenkins( jenkins.baseurl, requester=CrumbRequester(baseurl=jenkins.baseurl) ) yield crumbed crumbed.requester.post_and_confirm_status( jenkins.baseurl + "/configureSecurity/configure", data={"Submit": "save", "json": json.dumps(DISABLE_CRUMBS_CONFIG)}, headers={"Content-Type": "application/x-www-form-urlencoded"}, ) log.info("Disabled Jenkins security") @retry() def test_invoke_job_with_file(crumbed_jenkins): import time file_data = random_string() param_file = io.BytesIO(file_data.encode("utf-8")) job_name = "create1_%s" % random_string() job = crumbed_jenkins.create_job(job_name, JOB_WITH_FILE) assert job.has_params() assert len(job.get_params_list()) job.invoke(block=True, files={"file.txt": param_file}) build = job.get_last_build() while build.is_running(): time.sleep(0.25) artifacts = build.get_artifact_dict() assert isinstance(artifacts, dict) is True art_file = artifacts["file.txt"] assert art_file.get_data().decode("utf-8").strip() == file_data jenkinsapi-0.3.23/jenkinsapi_tests/systests/test_downstream_upstream.py0000644000000000000000000000642513615410400023653 0ustar00""" System tests for `jenkinsapi.jenkins` module. """ import logging import time import pytest from jenkinsapi_tests.test_utils.retry import retry from jenkinsapi.custom_exceptions import NoBuildData log = logging.getLogger(__name__) pytestmark = pytest.mark.docker JOB_CONFIGS = { "A": """ false true false false false false B SUCCESS 0 BLUE """, "B": """ false true false false false false C SUCCESS 0 BLUE """, "C": """ false true false false false false """, } DELAY = 10 @retry() def test_stream_relationship(jenkins): """ Can we keep track of the relationships between upstream & downstream jobs? """ for job_name, job_config in JOB_CONFIGS.items(): jenkins.create_job(job_name, job_config) time.sleep(1) jenkins["A"].invoke() for _ in range(10): try: jenkins["C"].get_last_completed_buildnumber() > 0 except NoBuildData: log.info( "Waiting %i seconds for until the final job has run", DELAY, ) time.sleep(DELAY) else: break else: pytest.fail("Jenkins took too long to run these jobs") assert jenkins["C"].get_upstream_jobs() == [jenkins["B"]] assert jenkins["B"].get_upstream_jobs() == [jenkins["A"]] assert jenkins["A"].get_downstream_jobs() == [jenkins["B"]] assert jenkins["B"].get_downstream_jobs() == [jenkins["C"]] jenkinsapi-0.3.23/jenkinsapi_tests/systests/test_env_vars.py0000644000000000000000000000152013615410400021362 0ustar00""" System tests for `jenkinsapi.jenkins` module. """ import time import pytest from jenkinsapi_tests.test_utils.retry import retry from jenkinsapi_tests.systests.job_configs import JOB_WITH_ENV_VARS from jenkinsapi_tests.test_utils.random_strings import random_string pytestmark = pytest.mark.docker @retry(max_attempts=20, initial_delay=0.5) def test_get_env_vars(jenkins): job_name = "get_env_vars_create1_%s" % random_string() job = jenkins.create_job(job_name, JOB_WITH_ENV_VARS) job.invoke(block=True) build = job.get_last_build() while build.is_running(): time.sleep(0.25) # Poll for environment variables with exponential backoff # Jenkins takes variable time to write injected env vars to API data = build.get_env_vars() assert data["key1"] == "value1" assert data["key2"] == "value2" jenkinsapi-0.3.23/jenkinsapi_tests/systests/test_executors.py0000644000000000000000000000453013615410400021564 0ustar00""" System tests for `jenkinsapi.jenkins` module. """ import logging import pytest from jenkinsapi_tests.test_utils.retry import retry from jenkinsapi_tests.systests.job_configs import LONG_RUNNING_JOB from jenkinsapi_tests.test_utils.random_strings import random_string log = logging.getLogger(__name__) pytestmark = pytest.mark.docker @retry() def test_get_executors(jenkins): node_name = random_string() node_dict = { "num_executors": 2, "node_description": "Test JNLP Node", "remote_fs": "/tmp", "labels": "systest_jnlp", "exclusive": True, } jenkins.nodes.create_node(node_name, node_dict) executors = jenkins.get_executors(node_name) assert executors.count == 2 for count, execs in enumerate(executors): assert count == execs.get_number() assert execs.is_idle() is True @retry() def test_running_executor(jenkins): import time node_name = random_string() node_dict = { "num_executors": 1, "node_description": "Test JNLP Node", "remote_fs": "/tmp", "labels": "systest_jnlp", "exclusive": True, } jenkins.nodes.create_node(node_name, node_dict) job_name = "create_%s" % random_string() job = jenkins.create_job(job_name, LONG_RUNNING_JOB) qq = job.invoke() qq.block_until_building() if job.is_running() is False: time.sleep(1) executors = jenkins.get_executors(node_name) all_idle = True for execs in executors: if execs.is_idle() is False: all_idle = False assert execs.get_progress() != -1 assert execs.get_current_executable() == qq.get_build_number() assert execs.likely_stuck() is False assert all_idle is True, "Executor should have been triggered." @retry() def test_idle_executors(jenkins): node_name = random_string() node_dict = { "num_executors": 1, "node_description": "Test JNLP Node", "remote_fs": "/tmp", "labels": "systest_jnlp", "exclusive": True, } jenkins.nodes.create_node(node_name, node_dict) executors = jenkins.get_executors(node_name) for execs in executors: assert execs.get_progress() == -1 assert execs.get_current_executable() is None assert execs.likely_stuck() is False assert execs.is_idle() is True jenkinsapi-0.3.23/jenkinsapi_tests/systests/test_folder_credentials.py0000644000000000000000000000534113615410400023374 0ustar00""" System tests for job/folder credentials property (issue #802). Tests verifying that Job objects have a credentials property that constructs the folder credentials URL and returns a Credentials2x instance. This provides access to folder-scoped credentials without requiring the workaround mentioned in issue #802. """ import logging import pytest from jenkinsapi_tests.test_utils.random_strings import random_string from jenkinsapi_tests.test_utils.retry import retry from jenkinsapi_tests.systests.job_configs import EMPTY_JOB from jenkinsapi.credentials import Credentials2x from jenkinsapi.job import Job log = logging.getLogger(__name__) pytestmark = pytest.mark.docker @retry() def test_job_credentials_property_accessible(jenkins): """ Test that Job.credentials property is accessible. This test verifies the credentials property can be accessed without errors. Whether credentials actually exist at that location depends on the Jenkins configuration and available plugins. """ job_name = "job_%s" % random_string() jenkins.create_job(job_name, EMPTY_JOB) jenkins.poll() job = jenkins[job_name] # The credentials property should be accessible assert hasattr(job, "credentials") # And it should return a Credentials2x instance credentials = job.credentials assert isinstance(credentials, Credentials2x) @retry() def test_job_credentials_url_construction(jenkins): """ Test that Job.credentials constructs the correct folder credentials URL. The URL pattern for folder credentials should follow: {job_url}/credentials/store/folder/domain/_/ """ job_name = "job_%s" % random_string() jenkins.create_job(job_name, EMPTY_JOB) jenkins.poll() job = jenkins[job_name] credentials = job.credentials # Construct the expected URL expected_url = ( "http://localhost:8080/job/%s/credentials/store/folder/domain/_" % job_name ) # Verify the baseurl matches (note: JenkinsBase may strip trailing slashes) assert credentials.baseurl == expected_url @retry() def test_credentials_property_is_credentials2x_instance(jenkins): """ Test that Job.credentials returns a Credentials2x instance. This ensures the correct credentials class is used for accessing folder-scoped credentials. """ job_name = "job_%s" % random_string() jenkins.create_job(job_name, EMPTY_JOB) jenkins.poll() job = jenkins[job_name] credentials = job.credentials # Verify it's a Credentials2x instance assert isinstance(credentials, Credentials2x) # Verify it has the expected methods assert hasattr(credentials, "keys") assert hasattr(credentials, "__getitem__") assert hasattr(credentials, "__setitem__") jenkinsapi-0.3.23/jenkinsapi_tests/systests/test_generate_new_api_token.py0000644000000000000000000000133613615410400024240 0ustar00""" System tests for generation new api token for logged in user """ import pytest import logging from jenkinsapi.utils.crumb_requester import CrumbRequester log = logging.getLogger(__name__) pytestmark = pytest.mark.docker @pytest.mark.generate_new_api_token def test_generate_new_api_token(jenkins_admin_admin): jenkins_admin_admin.requester = CrumbRequester( baseurl=jenkins_admin_admin.baseurl, username=jenkins_admin_admin.username, password=jenkins_admin_admin.password, ) jenkins_admin_admin.poll() new_token = ( jenkins_admin_admin.generate_new_api_token() ) # generate new token log.info("newly generated token: %s", new_token) assert new_token is not None jenkinsapi-0.3.23/jenkinsapi_tests/systests/test_invocation.py0000644000000000000000000000775513615410400021730 0ustar00""" System tests for `jenkinsapi.jenkins` module. """ import logging import pytest from jenkinsapi.build import Build from jenkinsapi.queue import QueueItem from jenkinsapi_tests.test_utils.random_strings import random_string from jenkinsapi_tests.test_utils.retry import retry from jenkinsapi_tests.systests.job_configs import LONG_RUNNING_JOB from jenkinsapi_tests.systests.job_configs import SHORTISH_JOB, EMPTY_JOB from jenkinsapi.custom_exceptions import BadParams, NotFound log = logging.getLogger(__name__) pytestmark = pytest.mark.docker @retry() def test_invocation_object(jenkins): job_name = "Acreate_%s" % random_string() job = jenkins.create_job(job_name, SHORTISH_JOB) qq = job.invoke() assert isinstance(qq, QueueItem) # Let Jenkins catchup qq.block_until_building() assert qq.get_build_number() == 1 @retry() def test_get_block_until_build_running(jenkins): import time job_name = "Bcreate_%s" % random_string() job = jenkins.create_job(job_name, LONG_RUNNING_JOB) qq = job.invoke() time.sleep(3) bn = qq.block_until_building(delay=3).get_number() assert isinstance(bn, int) build = qq.get_build() assert isinstance(build, Build) assert build.is_running() build.stop() # if we call next line right away - Jenkins have no time to stop job # so we wait a bit time.sleep(1) assert not build.is_running() console = build.get_console() assert isinstance(console, str) assert "Started by user" in console @retry() def test_get_block_until_build_complete(jenkins): job_name = "Ccreate_%s" % random_string() job = jenkins.create_job(job_name, SHORTISH_JOB) qq = job.invoke() qq.block_until_complete() assert not qq.get_build().is_running() @retry() def test_mi_and_get_last_build(jenkins): job_name = "Dcreate_%s" % random_string() job = jenkins.create_job(job_name, SHORTISH_JOB) for _ in range(3): ii = job.invoke() ii.block_until_complete(delay=2) build_number = job.get_last_good_buildnumber() assert build_number == 3 build = job.get_build(build_number) assert isinstance(build, Build) build = job.get_build_metadata(build_number) assert isinstance(build, Build) @retry() def test_mi_and_get_build_number(jenkins): job_name = "Ecreate_%s" % random_string() job = jenkins.create_job(job_name, EMPTY_JOB) for invocation in range(3): qq = job.invoke() qq.block_until_complete(delay=1) build_number = qq.get_build_number() assert build_number == invocation + 1 @retry() def test_mi_and_delete_build(jenkins): job_name = "Ecreate_%s" % random_string() job = jenkins.create_job(job_name, EMPTY_JOB) for invocation in range(3): qq = job.invoke() qq.block_until_complete(delay=1) build_number = qq.get_build_number() assert build_number == invocation + 1 # Delete build using Job.delete_build job.get_build(1) job.delete_build(1) with pytest.raises(NotFound): job.get_build(1) # Delete build using Job as dictionary of builds assert isinstance(job[2], Build) del job[2] with pytest.raises(NotFound): job.get_build(2) with pytest.raises(NotFound): job.delete_build(99) @retry() def test_give_params_on_non_parameterized_job(jenkins): job_name = "Ecreate_%s" % random_string() job = jenkins.create_job(job_name, EMPTY_JOB) with pytest.raises(BadParams): job.invoke(build_params={"foo": "bar", "baz": 99}) @retry() def test_keep_build_toggle(jenkins): job_name = "Ecreate_%s" % random_string() job = jenkins.create_job(job_name, EMPTY_JOB) qq = job.invoke() qq.block_until_complete(delay=1) build = job.get_last_build() assert not build.is_kept_forever() build.toggle_keep() assert build.is_kept_forever() build_number = job.get_last_buildnumber() job.toggle_keep_build(build_number) build = job.get_last_build() assert not build.is_kept_forever() jenkinsapi-0.3.23/jenkinsapi_tests/systests/test_jenkins.py0000644000000000000000000001320413615410400021202 0ustar00""" System tests for `jenkinsapi.jenkins` module. """ import pytest from jenkinsapi.job import Job from jenkinsapi.jobs import Jobs from jenkinsapi.build import Build from jenkinsapi.queue import QueueItem from jenkinsapi_tests.systests.job_configs import EMPTY_JOB from jenkinsapi_tests.test_utils.random_strings import random_string from jenkinsapi.custom_exceptions import UnknownJob pytestmark = pytest.mark.docker def job_present(jenkins, name): jenkins.poll() assert name in jenkins, "Job %r is absent in jenkins." % name assert isinstance(jenkins.get_job(name), Job) is True assert isinstance(jenkins[name], Job) is True def job_absent(jenkins, name): jenkins.poll() assert name not in jenkins, "Job %r is present in jenkins." % name def test_create_job(jenkins): job_name = "create_%s" % random_string() jenkins.create_job(job_name, EMPTY_JOB) job_present(jenkins, job_name) def test_create_job_with_plus(jenkins): job_name = "create+%s" % random_string() jenkins.create_job(job_name, EMPTY_JOB) job_present(jenkins, job_name) job = jenkins[job_name] assert job_name in job.url def test_create_dup_job(jenkins): job_name = "create_%s" % random_string() old_job = jenkins.create_job(job_name, EMPTY_JOB) job_present(jenkins, job_name) new_job = jenkins.create_job(job_name, EMPTY_JOB) assert new_job == old_job def test_get_jobs_info(jenkins): job_name = "create_%s" % random_string() job = jenkins.create_job(job_name, EMPTY_JOB) jobs_info = list(jenkins.get_jobs_info()) assert len(jobs_info) == 1 for url, name in jobs_info: assert url == job.url assert name == job.name def test_create_job_through_jobs_dict(jenkins): job_name = "create_%s" % random_string() jenkins.jobs[job_name] = EMPTY_JOB job_present(jenkins, job_name) def test_enable_disable_job(jenkins): job_name = "create_%s" % random_string() jenkins.create_job(job_name, EMPTY_JOB) job_present(jenkins, job_name) j = jenkins[job_name] j.invoke(block=True) # run this at least once # Ensure job begins as enabled assert j.is_enabled() is True, "An enabled job is reporting incorrectly" j.disable() assert j.is_enabled() is False, "A disabled job is reporting incorrectly" j.enable() assert j.is_enabled() is True, "An enabled job is reporting incorrectly" def test_get_job_and_update_config(jenkins): job_name = "config_%s" % random_string() jenkins.create_job(job_name, EMPTY_JOB) job_present(jenkins, job_name) config = jenkins[job_name].get_config() assert config.strip() == EMPTY_JOB.strip() jenkins[job_name].update_config(EMPTY_JOB) def test_invoke_job(jenkins): job_name = "create_%s" % random_string() job = jenkins.create_job(job_name, EMPTY_JOB) job.invoke(block=True) assert isinstance(job.get_build(1), Build) def test_invocation_object(jenkins): job_name = "create_%s" % random_string() job = jenkins.create_job(job_name, EMPTY_JOB) ii = job.invoke() assert isinstance(ii, QueueItem) is True def test_get_jobs_list(jenkins): job1_name = "first_%s" % random_string() job2_name = "second_%s" % random_string() jenkins.create_job(job1_name, EMPTY_JOB) jenkins.create_job(job2_name, EMPTY_JOB) assert len(jenkins.jobs) >= 2 job_list = jenkins.get_jobs_list() assert [job1_name, job2_name] == job_list def test_get_job(jenkins): job1_name = "first_%s" % random_string() jenkins.create_job(job1_name, EMPTY_JOB) job = jenkins[job1_name] assert isinstance(job, Job) is True assert job.name == job1_name def test_get_jobs(jenkins): job1_name = "first_%s" % random_string() job2_name = "second_%s" % random_string() jenkins.create_job(job1_name, EMPTY_JOB) jenkins.create_job(job2_name, EMPTY_JOB) jobs = jenkins.jobs assert isinstance(jobs, Jobs) is True assert len(jobs) >= 2 for job_name, job in jobs.iteritems(): assert isinstance(job_name, str) is True assert isinstance(job, Job) is True def test_get_job_that_does_not_exist(jenkins): with pytest.raises(UnknownJob): jenkins["doesnot_exist"] def test_has_job(jenkins): job1_name = "first_%s" % random_string() jenkins.create_job(job1_name, EMPTY_JOB) assert jenkins.has_job(job1_name) is True assert job1_name in jenkins def test_has_no_job(jenkins): assert jenkins.has_job("doesnt_exist") is False assert "doesnt_exist" not in jenkins def test_delete_job(jenkins): job1_name = "delete_me_%s" % random_string() jenkins.create_job(job1_name, EMPTY_JOB) jenkins.delete_job(job1_name) job_absent(jenkins, job1_name) def test_rename_job(jenkins): job1_name = "A__%s" % random_string() job2_name = "B__%s" % random_string() jenkins.create_job(job1_name, EMPTY_JOB) jenkins.rename_job(job1_name, job2_name) job_absent(jenkins, job1_name) job_present(jenkins, job2_name) def test_copy_job(jenkins): template_job_name = "TPL%s" % random_string() copied_job_name = "CPY%s" % random_string() jenkins.create_job(template_job_name, EMPTY_JOB) j = jenkins.copy_job(template_job_name, copied_job_name) job_present(jenkins, template_job_name) job_present(jenkins, copied_job_name) assert isinstance(j, Job) is True assert j.name == copied_job_name def test_get_master_data(jenkins): master_data = jenkins.get_master_data() assert master_data["totalExecutors"] == 2 def test_run_groovy_script(jenkins): expected_result = "Hello world!" result = jenkins.run_groovy_script('print "%s"' % expected_result) assert result.strip() == "Hello world!" jenkinsapi-0.3.23/jenkinsapi_tests/systests/test_jenkins_artifacts.py0000644000000000000000000000407013615410400023243 0ustar00""" System tests for `jenkinsapi.jenkins` module. """ import os from posixpath import join import re import time import gzip import shutil import tempfile import logging import pytest from jenkinsapi_tests.systests.job_configs import JOB_WITH_ARTIFACTS from jenkinsapi_tests.test_utils.random_strings import random_string log = logging.getLogger(__name__) pytestmark = pytest.mark.docker def test_artifacts(jenkins): job_name = "create_%s" % random_string() job = jenkins.create_job(job_name, JOB_WITH_ARTIFACTS) job.invoke(block=True) build = job.get_last_build() while build.is_running(): time.sleep(1) artifacts = build.get_artifact_dict() assert isinstance(artifacts, dict) is True text_artifact = artifacts["out.txt"] binary_artifact = artifacts["out.gz"] tempDir = tempfile.mkdtemp() try: # Verify that we can handle text artifacts text_artifact.save_to_dir(tempDir, strict_validation=True) text_file_path = join(tempDir, text_artifact.filename) assert os.path.exists(text_file_path) with open(text_file_path, "rb") as f: read_back_text = f.read().strip() read_back_text = read_back_text.decode("ascii") log.info("Text artifact: %s", read_back_text) assert ( re.match(r"^PING \S+ \(127.0.0.1\)", read_back_text) is not None ) assert read_back_text.endswith("ms") is True # Verify that we can hande binary artifacts binary_artifact.save_to_dir(tempDir, strict_validation=True) bin_file_path = join(tempDir, binary_artifact.filename) assert os.path.exists(bin_file_path) with gzip.open(bin_file_path, "rb") as f: read_back_text = f.read().strip() read_back_text = read_back_text.decode("ascii") assert ( re.match(r"^PING \S+ \(127.0.0.1\)", read_back_text) is not None ) assert read_back_text.endswith("ms") is True finally: shutil.rmtree(tempDir) jenkinsapi-0.3.23/jenkinsapi_tests/systests/test_jenkins_jobs_by_status.py0000644000000000000000000000240413615410400024314 0ustar00""" System tests for Jenkins.get_jobs_by_status. """ import logging import pytest from jenkinsapi.job import Job from jenkinsapi_tests.test_utils.random_strings import random_string from jenkinsapi_tests.systests.job_configs import SHORTISH_JOB, EMPTY_JOB log = logging.getLogger(__name__) pytestmark = pytest.mark.docker def test_get_jobs_by_status_success(jenkins): """A completed successful job should appear under 'success' status.""" job_name = random_string() job = jenkins.create_job(job_name, SHORTISH_JOB) job.invoke(block=True) success_jobs = jenkins.get_jobs_by_status("success") assert any(isinstance(j, Job) for j in success_jobs) names = [j.name for j in success_jobs] assert job_name in names def test_get_jobs_by_status_returns_list(jenkins): """get_jobs_by_status always returns a list.""" result = jenkins.get_jobs_by_status("success") assert isinstance(result, list) def test_get_jobs_by_status_disabled(jenkins): """A disabled job should appear under 'disabled' status.""" job_name = random_string() job = jenkins.create_job(job_name, EMPTY_JOB) job.disable() disabled_jobs = jenkins.get_jobs_by_status("disabled") names = [j.name for j in disabled_jobs] assert job_name in names jenkinsapi-0.3.23/jenkinsapi_tests/systests/test_jenkins_matrix.py0000644000000000000000000000167613615410400022600 0ustar00""" System tests for `jenkinsapi.jenkins` module. """ import re import time import pytest from jenkinsapi_tests.systests.job_configs import MATRIX_JOB from jenkinsapi_tests.test_utils.random_strings import random_string pytestmark = pytest.mark.docker def test_invoke_matrix_job(jenkins): job_name = "create_%s" % random_string() job = jenkins.create_job(job_name, MATRIX_JOB) queueItem = job.invoke() queueItem.block_until_complete() build = job.get_last_build() while build.is_running(): time.sleep(1) set_of_groups = set() for run in build.get_matrix_runs(): assert run.get_number() == build.get_number() assert run.get_upstream_build() == build match_result = re.search("\xbb (.*) #\\d+$", run.name) assert match_result is not None set_of_groups.add(match_result.group(1)) build.get_master_job_name() assert set_of_groups == set(["one", "two", "three"]) jenkinsapi-0.3.23/jenkinsapi_tests/systests/test_jenkins_reload.py0000644000000000000000000000060213615410400022526 0ustar00""" System tests for Jenkins.reload - reloads configuration from disk. """ import logging import pytest log = logging.getLogger(__name__) pytestmark = pytest.mark.docker def test_reload(jenkins): """Reload Jenkins configuration and verify it remains accessible.""" jenkins.poll() jenkins.reload() # Jenkins should still be reachable after reload jenkins.poll() jenkinsapi-0.3.23/jenkinsapi_tests/systests/test_job_build_options.py0000644000000000000000000000723113615410400023250 0ustar00""" System tests for job build configuration options ported from jenkins_api_client: - Job.set_concurrent_builds - Job.block_build_when_downstream_building / unblock_build_when_downstream_building - Job.block_build_when_upstream_building / unblock_build_when_upstream_building - Job.restrict_to_node """ import logging import pytest from jenkinsapi_tests.test_utils.random_strings import random_string from jenkinsapi_tests.systests.job_configs import EMPTY_JOB log = logging.getLogger(__name__) pytestmark = pytest.mark.docker def test_set_concurrent_builds_enable(jenkins): """Enable concurrent builds and verify the config is updated.""" job_name = random_string() job = jenkins.create_job(job_name, EMPTY_JOB) job.set_concurrent_builds(True) config = job.get_config() assert "true" in config def test_set_concurrent_builds_disable(jenkins): """Disable concurrent builds after enabling and verify the config.""" job_name = random_string() job = jenkins.create_job(job_name, EMPTY_JOB) job.set_concurrent_builds(True) job.set_concurrent_builds(False) config = job.get_config() assert "false" in config def test_block_build_when_downstream_building(jenkins): """Block builds when downstream is building and verify config.""" job_name = random_string() job = jenkins.create_job(job_name, EMPTY_JOB) job.block_build_when_downstream_building() config = job.get_config() assert ( "true" in config ) def test_unblock_build_when_downstream_building(jenkins): """Unblock downstream building block and verify config.""" job_name = random_string() job = jenkins.create_job(job_name, EMPTY_JOB) job.block_build_when_downstream_building() job.unblock_build_when_downstream_building() config = job.get_config() assert ( "false" in config ) def test_block_build_when_upstream_building(jenkins): """Block builds when upstream is building and verify config.""" job_name = random_string() job = jenkins.create_job(job_name, EMPTY_JOB) job.block_build_when_upstream_building() config = job.get_config() assert ( "true" in config ) def test_unblock_build_when_upstream_building(jenkins): """Unblock upstream building block and verify config.""" job_name = random_string() job = jenkins.create_job(job_name, EMPTY_JOB) job.block_build_when_upstream_building() job.unblock_build_when_upstream_building() config = job.get_config() assert ( "false" in config ) def test_restrict_to_node(jenkins): """Restrict job to a specific node label and verify config.""" job_name = random_string() job = jenkins.create_job(job_name, EMPTY_JOB) job.restrict_to_node("my-agent") config = job.get_config() assert "my-agent" in config assert "false" in config def test_restrict_to_node_update(jenkins): """Re-restrict a job to a different node and verify config is updated.""" job_name = random_string() job = jenkins.create_job(job_name, EMPTY_JOB) job.restrict_to_node("agent-1") job.restrict_to_node("agent-2") config = job.get_config() assert "agent-2" in config assert "agent-1" not in config jenkinsapi-0.3.23/jenkinsapi_tests/systests/test_job_change_description.py0000644000000000000000000000175513615410400024233 0ustar00""" System tests for Job.change_description. """ import logging import pytest from jenkinsapi_tests.test_utils.random_strings import random_string from jenkinsapi_tests.systests.job_configs import EMPTY_JOB log = logging.getLogger(__name__) pytestmark = pytest.mark.docker def test_change_description(jenkins): """Change job description and verify via get_config.""" job_name = random_string() job = jenkins.create_job(job_name, EMPTY_JOB) job.change_description("my new description") config = job.get_config() assert "my new description" in config def test_change_description_overwrite(jenkins): """Overwrite an existing description.""" job_name = random_string() job = jenkins.create_job(job_name, EMPTY_JOB) job.change_description("first") job.change_description("second") config = job.get_config() assert "second" in config assert "first" not in config jenkinsapi-0.3.23/jenkinsapi_tests/systests/test_job_pipeline_scm.py0000644000000000000000000000552613615410400023052 0ustar00""" Tests for pipeline job SCM handling (issue #891). These tests validate that SCM elements are correctly detected in pipeline jobs, where SCM configuration is nested under 'definition/scm' rather than at the root level like in traditional freestyle jobs. """ import pytest import requests from jenkinsapi.custom_exceptions import NotSupportSCM, NotConfiguredSCM from jenkinsapi_tests.systests.job_configs import ( PIPELINE_SCM_CONFIG, PIPELINE_SCM_JOB, ) from jenkinsapi_tests.test_utils.random_strings import random_string pytestmark = pytest.mark.docker def test_pipeline_job_with_git_scm(jenkins): """Pipeline job SCM type and URL can be extracted (issue #891).""" job_name = random_string() job = jenkins.create_job(job_name, PIPELINE_SCM_JOB) # Verify SCM type is correctly identified assert job.get_scm_type() == job._scm_map[PIPELINE_SCM_CONFIG["scm_class"]] # Verify SCM URL is correctly extracted assert job.get_scm_url()[0] == PIPELINE_SCM_CONFIG["git_url"] def test_pipeline_job_scm_prefix_set(jenkins): """Pipeline job SCM detection sets correct prefix for nested location.""" job_name = random_string() job = jenkins.create_job(job_name, PIPELINE_SCM_JOB) # Verify the SCM prefix is set to the pipeline definition location job.get_scm_type() # This triggers the SCM element discovery assert job._scm_prefix == "definition/" def test_pipeline_job_with_null_scm_raises_error(jenkins): """Pipeline job with NullSCM should raise NotConfiguredSCM.""" pipeline_no_scm = """ false Jenkinsfile false """ job_name = random_string() job = jenkins.create_job(job_name, pipeline_no_scm) with pytest.raises(NotConfiguredSCM): job.get_scm_type() def test_pipeline_job_with_unsupported_scm_raises_error(jenkins): """Pipeline job with unsupported SCM class should raise NotSupportSCM.""" pipeline_unsupported_scm = """ false Jenkinsfile false """ job_name = random_string() job = jenkins.create_job(job_name, pipeline_unsupported_scm) with pytest.raises(NotSupportSCM): job.get_scm_type() jenkinsapi-0.3.23/jenkinsapi_tests/systests/test_job_recreate.py0000644000000000000000000000237013615410400022167 0ustar00""" System tests for Job.recreate - deletes and recreates a job preserving config. """ import logging import pytest from jenkinsapi.job import Job from jenkinsapi_tests.test_utils.random_strings import random_string from jenkinsapi_tests.systests.job_configs import SHORTISH_JOB log = logging.getLogger(__name__) pytestmark = pytest.mark.docker def test_recreate_job_preserves_config(jenkins): """Recreate a job and verify it still exists with the same config.""" job_name = random_string() original_job = jenkins.create_job(job_name, SHORTISH_JOB) original_config = original_job.get_config() new_job = original_job.recreate() assert isinstance(new_job, Job) assert new_job.name == job_name assert jenkins.has_job(job_name) new_config = new_job.get_config() # Core config should be preserved (strip whitespace differences) assert new_config.strip() == original_config.strip() def test_recreate_job_resets_build_history(jenkins): """Recreate should reset build number to 1.""" job_name = random_string() job = jenkins.create_job(job_name, SHORTISH_JOB) qi = job.invoke(block=True) assert job.get_last_buildnumber() >= 1 new_job = job.recreate() assert new_job.get_next_build_number() == 1 jenkinsapi-0.3.23/jenkinsapi_tests/systests/test_job_workspace.py0000644000000000000000000000150113615410400022366 0ustar00""" System tests for Job.wipe_out_workspace and Job.trigger_scm_poll. """ import logging import pytest from jenkinsapi_tests.test_utils.random_strings import random_string from jenkinsapi_tests.systests.job_configs import SHORTISH_JOB, EMPTY_JOB log = logging.getLogger(__name__) pytestmark = pytest.mark.docker def test_wipe_out_workspace(jenkins): """Wipe workspace after a completed build - should not raise.""" job_name = random_string() job = jenkins.create_job(job_name, SHORTISH_JOB) job.invoke(block=True) # Workspace should now exist; wipe it job.wipe_out_workspace() def test_trigger_scm_poll(jenkins): """Trigger an SCM poll on a job - should not raise.""" job_name = random_string() jenkins.create_job(job_name, EMPTY_JOB) job = jenkins[job_name] job.trigger_scm_poll() jenkinsapi-0.3.23/jenkinsapi_tests/systests/test_lockable_resources.py0000644000000000000000000001532513615410400023415 0ustar00from contextlib import ExitStack from unittest import mock import pytest from jenkinsapi.jenkins import Jenkins from jenkinsapi.lockable_resources import ( LockableResource, LockableResources, ResourceLockedError, ResourceReservationTimeoutError, ) from jenkinsapi.utils.retry import SimpleRetryConfig pytestmark = pytest.mark.docker GROOVY_SCRIPT_INIT_TEST_RESOURCES = """ import org.jenkins.plugins.lockableresources.* def manager = LockableResourcesManager.get() // Reset to a clean slate before adding test resources manager.resources.clear() def resource = new LockableResource("locktest") resource.setLabels("locktest") manager.resources.add(resource) def resource2 = new LockableResource("locktest2") resource2.setLabels("locktest") manager.resources.add(resource2) manager.save() """ @pytest.fixture(scope="function") def jenkins_test_lock_init(jenkins_admin_admin) -> None: """Fixture to create two lockable resources for testing.""" jenkins_admin_admin.run_groovy_script(GROOVY_SCRIPT_INIT_TEST_RESOURCES) @pytest.fixture def test_lock_name() -> str: return "locktest" @pytest.fixture def test_lock_name2() -> str: return "locktest2" @pytest.fixture def test_lock_label() -> str: return "locktest" # IMPORTANT: These fixtures use jenkins_admin_admin (not plain jenkins) because the # lockable-resources plugin requires an authenticated non-anonymous user to perform # reserve/unreserve actions. Requests from an anonymous user are silently accepted # (HTTP 200) but the state change never takes effect. Security is enabled/disabled # around each test by the jenkins_admin_admin fixture. @pytest.fixture(scope="function") def lockable_resources( jenkins_admin_admin: Jenkins, jenkins_test_lock_init: None, # pylint: disable=unused-argument ) -> LockableResources: return jenkins_admin_admin.get_lockable_resources() def test_list_lockables(lockable_resources: LockableResources): assert isinstance(str(lockable_resources), str) assert isinstance(repr(lockable_resources), str) # iter names for name in lockable_resources: res = lockable_resources[name] assert isinstance(res, LockableResource) assert isinstance(res.is_free(), bool) assert isinstance(res.is_reserved(), bool) assert isinstance(res.data["description"], str) # iter values directly for res in lockable_resources.values(): assert isinstance(res, LockableResource) assert len(lockable_resources) == len(lockable_resources.values()) def test_reserve_unreserve( lockable_resources: LockableResources, test_lock_name: str, ): rn = test_lock_name assert rn in lockable_resources assert lockable_resources.is_reserved(rn) is False lockable_resources.reserve(rn) assert lockable_resources.is_reserved(rn) is True with pytest.raises(ResourceLockedError): lockable_resources.reserve(rn) lockable_resources.unreserve(rn) assert lockable_resources.is_reserved(rn) is False def test_reserve_unreserve_nopoll( lockable_resources: LockableResources, test_lock_name: str, ): lockable_resources.poll_after_post = False rn = test_lock_name assert rn in lockable_resources assert lockable_resources.is_reserved(rn) is False lockable_resources.reserve(rn) assert lockable_resources.is_reserved(rn) is False lockable_resources.poll() assert lockable_resources.is_reserved(rn) is True with pytest.raises(ResourceLockedError): lockable_resources.reserve(rn) lockable_resources.unreserve(rn) assert lockable_resources.is_reserved(rn) is True lockable_resources.poll() assert lockable_resources.is_reserved(rn) is False def test_reservation_by_name( lockable_resources: LockableResources, test_lock_name: str, ): reservation = lockable_resources.reservation_by_name(test_lock_name) assert lockable_resources.is_free(test_lock_name) with reservation: assert reservation.locked_resource_name == test_lock_name assert lockable_resources.is_free(test_lock_name) is False assert reservation.is_active() assert lockable_resources.is_free(test_lock_name) assert reservation.is_active() is False name = None with pytest.raises(RuntimeError): name = reservation.locked_resource_name assert name is None def test_reservation_by_name_list( lockable_resources: LockableResources, test_lock_name: str, test_lock_name2: str, ): name_list = [test_lock_name, test_lock_name2] r1 = lockable_resources.reservation_by_name_list(name_list) assert lockable_resources.is_free(name_list[0]) assert lockable_resources.is_free(name_list[1]) with lockable_resources.reservation_by_name_list(name_list) as r1: assert r1.locked_resource_name == name_list[0] with lockable_resources.reservation_by_name_list(name_list) as r2: assert r2.locked_resource_name == name_list[1] assert lockable_resources.is_free(name_list[1]) is False assert lockable_resources.is_free(name_list[1]) assert lockable_resources.is_free(name_list[0]) assert lockable_resources.is_free(name_list[1]) def test_reservation_by_label( lockable_resources: LockableResources, test_lock_label: str, ): res = lockable_resources.reservation_by_label(test_lock_label) with res: locked_resource = lockable_resources[res.locked_resource_name] assert locked_resource.is_free() is False assert test_lock_label in locked_resource.data["labelsAsList"] assert locked_resource.is_free() is True def test_custom_retry( lockable_resources: LockableResources, test_lock_name: str, ): with ExitStack() as exit_stack: exit_stack.enter_context( mock.patch( "time.monotonic", side_effect=range(1000, 10000), ) ) mock_time_sleep = exit_stack.enter_context( mock.patch("time.sleep"), ) mock_try_reserve = exit_stack.enter_context( mock.patch.object( lockable_resources, "try_reserve", return_value=None, ) ) mock_poll = exit_stack.enter_context( mock.patch.object( lockable_resources, "poll", ) ) exit_stack.enter_context( pytest.raises(ResourceReservationTimeoutError) ) with lockable_resources.reservation_by_name( test_lock_name, retry=SimpleRetryConfig( sleep_period=1, timeout=5.5, ), ): pass assert mock_time_sleep.call_count == 5 assert mock_try_reserve.call_count == 6 assert mock_poll.call_count == 5 jenkinsapi-0.3.23/jenkinsapi_tests/systests/test_node_change_mode.py0000644000000000000000000000250413615410400023000 0ustar00""" System tests for Node.change_mode - switch between NORMAL and EXCLUSIVE. """ import logging import pytest from jenkinsapi_tests.test_utils.random_strings import random_string log = logging.getLogger(__name__) pytestmark = pytest.mark.docker @pytest.fixture def slave_node(jenkins, request): """Create a JNLP slave node and remove it after the test.""" node_name = "test-node-%s" % random_string() node = jenkins.create_node(node_name, labels="test") def cleanup(): if jenkins.has_node(node_name): jenkins.delete_node(node_name) request.addfinalizer(cleanup) return node def test_change_mode_to_exclusive(slave_node): """Change node mode from NORMAL to EXCLUSIVE.""" slave_node.change_mode("EXCLUSIVE") config = slave_node.get_config() assert "EXCLUSIVE" in config def test_change_mode_to_normal(slave_node): """Change node mode to EXCLUSIVE then back to NORMAL.""" slave_node.change_mode("EXCLUSIVE") slave_node.change_mode("NORMAL") config = slave_node.get_config() assert "NORMAL" in config def test_change_mode_invalid(slave_node): """Invalid mode raises ValueError.""" with pytest.raises( ValueError, match=r"mode must be one of .* got 'INVALID'", ): slave_node.change_mode("INVALID") jenkinsapi-0.3.23/jenkinsapi_tests/systests/test_nodes.py0000644000000000000000000002162713615410400020661 0ustar00""" System tests for `jenkinsapi.jenkins` module. """ import logging import pytest from jenkinsapi.node import Node from jenkinsapi.credential import SSHKeyCredential from jenkinsapi_tests.test_utils.random_strings import random_string log = logging.getLogger(__name__) pytestmark = pytest.mark.docker TOOL_KEY = "hudson.tasks.Maven$MavenInstallation$DescriptorImpl@Maven 3.0.5" def test_online_offline(jenkins): """ Can we flip the online / offline state of the master node. """ # Master node name should be case insensitive # mn0 = jenkins.get_node('MaStEr') mn = jenkins.get_node("Built-In Node") # self.assertEqual(mn, mn0) mn.set_online() # It should already be online, hence no-op assert mn.is_online() is True mn.set_offline() # We switch that suckah off mn.set_offline() # This should be a no-op assert mn.is_online() is False mn.set_online() # Switch it back on assert mn.is_online() is True def test_create_jnlp_node(jenkins): node_name = random_string() node_dict = { "num_executors": 1, "node_description": "Test JNLP Node", "remote_fs": "/tmp", "labels": "systest_jnlp", "exclusive": True, "tool_location": [ { "key": TOOL_KEY, "home": "/home/apache-maven-3.0.5/", }, ], } node = jenkins.nodes.create_node(node_name, node_dict) assert isinstance(node, Node) is True del jenkins.nodes[node_name] def test_create_ssh_node(jenkins): node_name = random_string() creds = jenkins.get_credentials() cred_descr = random_string() cred_dict = { "description": cred_descr, "userName": "username", "passphrase": "", "private_key": "-----BEGIN RSA PRIVATE KEY-----", } creds[cred_descr] = SSHKeyCredential(cred_dict) node_dict = { "num_executors": 1, "node_description": "Description %s" % node_name, "remote_fs": "/tmp", "labels": node_name, "exclusive": False, "host": "127.0.0.1", "port": 22, "credential_description": cred_descr, "jvm_options": "", "java_path": "", "prefix_start_slave_cmd": "", "suffix_start_slave_cmd": "", "retention": "ondemand", "ondemand_delay": 0, "ondemand_idle_delay": 5, "tool_location": [ { "key": TOOL_KEY, "home": "/home/apache-maven-3.0.5/", }, ], } node = jenkins.nodes.create_node(node_name, node_dict) assert isinstance(node, Node) is True del jenkins.nodes[node_name] jenkins.nodes[node_name] = node_dict assert isinstance(jenkins.nodes[node_name], Node) is True del jenkins.nodes[node_name] def test_delete_node(jenkins): node_name = random_string() node_dict = { "num_executors": 1, "node_description": "Test JNLP Node", "remote_fs": "/tmp", "labels": "systest_jnlp", "exclusive": True, } jenkins.nodes.create_node(node_name, node_dict) del jenkins.nodes[node_name] with pytest.raises(KeyError): jenkins.nodes[node_name] with pytest.raises(KeyError): del jenkins.nodes["not_exist"] def test_delete_all_nodes(jenkins): nodes = jenkins.nodes for name in nodes.keys(): del nodes[name] assert len(jenkins.nodes) == 1 def test_get_node_labels(jenkins): node_name = random_string() node_labels = "LABEL1 LABEL2" node_dict = { "num_executors": 1, "node_description": "Test Node with Labels", "remote_fs": "/tmp", "labels": node_labels, "exclusive": True, } node = jenkins.nodes.create_node(node_name, node_dict) assert node.get_labels() == node_labels del jenkins.nodes[node_name] def test_add_node_labels(jenkins): node_name = random_string() extra_label = random_string() node_labels = "LABEL1 LABEL2" node_dict = { "num_executors": 1, "node_description": "Test Node with Labels", "remote_fs": "/tmp", "labels": node_labels, "exclusive": True, } node = jenkins.nodes.create_node(node_name, node_dict) node.add_labels(extra_label) assert node.get_labels() == " ".join([node_labels, extra_label]) del jenkins.nodes[node_name] def test_add_list_node_labels(jenkins): node_name = random_string() extra_labels = [random_string(), random_string()] node_labels = "LABEL1 LABEL2" node_dict = { "num_executors": 1, "node_description": "Test Node with Labels", "remote_fs": "/tmp", "labels": node_labels, "exclusive": True, } node = jenkins.nodes.create_node(node_name, node_dict) node.add_labels(extra_labels) labels_on_node = node.get_labels().split(" ") assert set(extra_labels + node_labels.split(" ")) == set(labels_on_node) del jenkins.nodes[node_name] def test_modify_node_labels(jenkins): node_name = random_string() extra_label = random_string() node_labels = "LABEL1 LABEL2" node_dict = { "num_executors": 1, "node_description": "Test Node with Labels", "remote_fs": "/tmp", "labels": node_labels, "exclusive": True, } node = jenkins.nodes.create_node(node_name, node_dict) node.modify_labels(extra_label) assert node.get_labels() == extra_label del jenkins.nodes[node_name] def test_delete_list_node_labels(jenkins): node_name = random_string() extra_labels = [random_string(), random_string()] node_labels = "LABEL1 LABEL2" node_dict = { "num_executors": 1, "node_description": "Test Node with Labels", "remote_fs": "/tmp", "labels": node_labels, "exclusive": True, } node = jenkins.nodes.create_node(node_name, node_dict) node.add_labels(extra_labels) labels_on_node = node.get_labels().split(" ") assert set(extra_labels + node_labels.split(" ")) == set(labels_on_node) node.delete_labels(extra_labels) labels_on_node = node.get_labels().split(" ") assert set(node_labels.split(" ")) == set(labels_on_node) del jenkins.nodes[node_name] def test_dryrun_delete_list_node_labels(jenkins): node_name = random_string() extra_labels = [random_string(), random_string()] node_labels = "LABEL1 LABEL2" node_dict = { "num_executors": 1, "node_description": "Test Node with Labels", "remote_fs": "/tmp", "labels": node_labels, "exclusive": True, } node = jenkins.nodes.create_node(node_name, node_dict) node.add_labels(extra_labels) labels_on_node = node.get_labels().split(" ") assert set(extra_labels + node_labels.split(" ")) == set(labels_on_node) node.delete_labels(extra_labels, dryRun=True) labels_on_node = node.get_labels().split(" ") assert set(extra_labels + node_labels.split(" ")) == set(labels_on_node) del jenkins.nodes[node_name] def test_get_executors(jenkins): node_name = random_string() node_labels = "LABEL1 LABEL2" node_dict = { "num_executors": 1, "node_description": "Test Node with Labels", "remote_fs": "/tmp", "labels": node_labels, "exclusive": True, } node = jenkins.nodes.create_node(node_name, node_dict) with pytest.raises(AttributeError): assert node.get_config_element("executors") == "1" assert node.get_config_element("numExecutors") == "1" del jenkins.nodes[node_name] def test_set_executors(jenkins): node_name = random_string() node_labels = "LABEL1 LABEL2" node_dict = { "num_executors": 1, "node_description": "Test Node with Labels", "remote_fs": "/tmp", "labels": node_labels, "exclusive": True, } node = jenkins.nodes.create_node(node_name, node_dict) assert node.set_config_element("numExecutors", "5") is None assert node.get_config_element("numExecutors") == "5" del jenkins.nodes[node_name] def test_set_master_executors(jenkins): node = jenkins.nodes["Built-In Node"] assert node.get_num_executors() == 2 node.set_num_executors(5) assert node.get_num_executors() == 5 node.set_num_executors(2) def test_offline_reason(jenkins): node_name = random_string() node_labels = "LABEL1 LABEL2" node_dict = { "num_executors": 1, "node_description": "Test Node with Labels", "remote_fs": "/tmp", "labels": node_labels, "exclusive": True, } node = jenkins.nodes.create_node(node_name, node_dict) node.toggle_temporarily_offline("test1") node.poll() assert node.offline_reason() == "test1" node.update_offline_reason("test2") node.poll() assert node.offline_reason() == "test2" del jenkins.nodes[node_name] def test_get_log_text(jenkins): mn = jenkins.get_node("Built-In Node") log_text = mn.get_log_text() assert isinstance(log_text, str) jenkinsapi-0.3.23/jenkinsapi_tests/systests/test_parameterized_builds.py0000644000000000000000000001014613615410400023741 0ustar00""" System tests for `jenkinsapi.jenkins` module. """ import io import time import pytest from jenkinsapi_tests.test_utils.random_strings import random_string from jenkinsapi_tests.systests.job_configs import JOB_WITH_FILE from jenkinsapi_tests.systests.job_configs import JOB_WITH_FILE_AND_PARAMS from jenkinsapi_tests.systests.job_configs import JOB_WITH_PARAMETERS pytestmark = pytest.mark.docker def test_invoke_job_with_file(jenkins): file_data = random_string() param_file = io.BytesIO(file_data.encode("utf-8")) job_name = "create1_%s" % random_string() job = jenkins.create_job(job_name, JOB_WITH_FILE) assert job.has_params() is True assert len(job.get_params_list()) != 0 job.invoke(block=True, files={"file.txt": param_file}) build = job.get_last_build() while build.is_running(): time.sleep(0.25) artifacts = build.get_artifact_dict() assert isinstance(artifacts, dict) is True art_file = artifacts["file.txt"] assert art_file.get_data().decode("utf-8").strip() == file_data def test_invoke_job_parameterized(jenkins): param_B = random_string() job_name = "create2_%s" % random_string() job = jenkins.create_job(job_name, JOB_WITH_PARAMETERS) job.invoke(block=True, build_params={"B": param_B}) build = job.get_last_build() artifacts = build.get_artifact_dict() artB = artifacts["b.txt"] assert artB.get_data().decode("UTF-8", "replace").strip() == param_B assert param_B in build.get_console() def test_parameterized_job_build_queuing(jenkins): """ Accept multiple builds of parameterized jobs with unique parameters. """ job_name = "create_%s" % random_string() job = jenkins.create_job(job_name, JOB_WITH_PARAMETERS) # Latest Jenkins schedules builds to run right away, so remove all # executors from master node to investigate queue master = jenkins.nodes["Built-In Node"] num_executors = master.get_num_executors() master.set_num_executors(0) for i in range(3): param_B = random_string() params = {"B": param_B} job.invoke(build_params=params) assert job.has_queued_build(params) is True master.set_num_executors(num_executors) while job.has_queued_build(params): time.sleep(0.25) build = job.get_last_build() while build.is_running(): time.sleep(0.25) artifacts = build.get_artifact_dict() assert isinstance(artifacts, dict) is True artB = artifacts["b.txt"] assert artB.get_data().decode("utf-8").strip() == param_B assert param_B in build.get_console() def test_parameterized_multiple_builds_get_the_same_queue_item(jenkins): """ Multiple attempts to run the same parameterized build will get the same queue item. """ job_name = "create_%s" % random_string() job = jenkins.create_job(job_name, JOB_WITH_PARAMETERS) # Latest Jenkins schedules builds to run right away, so remove all # executors from master node to investigate queue master = jenkins.nodes["Built-In Node"] num_executors = master.get_num_executors() master.set_num_executors(0) for i in range(3): params = {"B": random_string()} qq0 = job.invoke(build_params=params) qq1 = job.invoke(build_params=params) assert qq0 == qq1 master.set_num_executors(num_executors) def test_invoke_job_with_file_and_params(jenkins): file_data = random_string() param_data = random_string() param_file = io.BytesIO(file_data.encode("utf-8")) job_name = "create_%s" % random_string() job = jenkins.create_job(job_name, JOB_WITH_FILE_AND_PARAMS) assert job.has_params() is True assert len(job.get_params_list()) != 0 qi = job.invoke( block=True, files={"file.txt": param_file}, build_params={"B": param_data}, ) build = qi.get_build() artifacts = build.get_artifact_dict() assert isinstance(artifacts, dict) is True art_file = artifacts["file.txt"] assert art_file.get_data().decode("utf-8").strip() == file_data art_param = artifacts["file1.txt"] assert art_param.get_data().decode("utf-8").strip() == param_data jenkinsapi-0.3.23/jenkinsapi_tests/systests/test_pipeline_input_actions.py0000644000000000000000000000411013615410400024301 0ustar00""" System tests for Pipeline input action methods on Build. """ import time import logging import pytest from jenkinsapi_tests.test_utils.random_strings import random_string from jenkinsapi_tests.systests.job_configs import PIPELINE_INPUT_JOB log = logging.getLogger(__name__) pytestmark = pytest.mark.docker def _wait_for_input(build, timeout=30): deadline = time.time() + timeout while time.time() < deadline: try: actions = build.get_pending_input_actions() if actions: return actions except Exception: pass time.sleep(2) return [] def test_get_pending_input_actions(jenkins): job_name = "input_%s" % random_string() job = jenkins.create_job(job_name, PIPELINE_INPUT_JOB) qq = job.invoke() qq.block_until_building() build = qq.get_build() actions = _wait_for_input(build) assert len(actions) >= 1 input_id = actions[0]["id"] assert input_id.lower() == "deploy-approval" assert "Deploy to production?" in actions[0]["message"] # Clean up — abort so build doesn't hang build.abort_input(input_id) def test_proceed_input(jenkins): job_name = "proceed_%s" % random_string() job = jenkins.create_job(job_name, PIPELINE_INPUT_JOB) qq = job.invoke() qq.block_until_building() build = qq.get_build() actions = _wait_for_input(build) assert actions input_id = actions[0]["id"] build.proceed_input(input_id) deadline = time.time() + 30 while time.time() < deadline and build.is_running(): time.sleep(2) assert not build.is_running() def test_abort_input(jenkins): job_name = "abort_%s" % random_string() job = jenkins.create_job(job_name, PIPELINE_INPUT_JOB) qq = job.invoke() qq.block_until_building() build = qq.get_build() actions = _wait_for_input(build) assert actions input_id = actions[0]["id"] build.abort_input(input_id) deadline = time.time() + 30 while time.time() < deadline and build.is_running(): time.sleep(2) assert not build.is_running() jenkinsapi-0.3.23/jenkinsapi_tests/systests/test_plugin_compatibility.py0000644000000000000000000000322713615410400023774 0ustar00""" System test to verify all required plugins load and work correctly. This ensures Jenkins and all plugins are compatible and functional. """ import pytest pytestmark = pytest.mark.docker def test_jenkins_loads_with_all_plugins(jenkins): """Test that Jenkins starts successfully with all required plugins.""" # If we got here, Jenkins started successfully with all plugins assert jenkins is not None jenkins.poll() def test_matrix_project_plugin_available(jenkins): """Verify matrix-project plugin is installed and functional.""" from jenkinsapi_tests.systests.job_configs import MATRIX_JOB from jenkinsapi_tests.test_utils.random_strings import random_string job_name = "matrix_plugin_test_%s" % random_string() job = jenkins.create_job(job_name, MATRIX_JOB) assert job is not None assert "matrix-project" in job.get_config() def test_envinject_plugin_available(jenkins): """Verify envinject plugin is installed and functional.""" from jenkinsapi_tests.systests.job_configs import JOB_WITH_ENV_VARS from jenkinsapi_tests.test_utils.random_strings import random_string job_name = "envinject_plugin_test_%s" % random_string() job = jenkins.create_job(job_name, JOB_WITH_ENV_VARS) assert job is not None # The job should be created successfully with envinject wrapper assert "EnvInjectBuildWrapper" in job.get_config() def test_git_plugin_available(jenkins): """Verify git plugin is installed and available.""" # This is a passive test - if the git plugin is missing, other tests will fail # We just verify Jenkins is functional jenkins.poll() assert jenkins.version is not None jenkinsapi-0.3.23/jenkinsapi_tests/systests/test_plugin_downgrade_unpin.py0000644000000000000000000000230513615410400024302 0ustar00""" System tests for Plugins.downgrade_plugin and Plugins.unpin_plugin. Note: downgrade and unpin are only meaningful when there is a previous version to downgrade to, or the plugin is pinned. These tests verify the API calls succeed without raising exceptions, since the test Jenkins instance may not have a downgradable or pinned plugin available. """ import logging import pytest log = logging.getLogger(__name__) pytestmark = pytest.mark.docker # Use "mailer" as a safe leaf plugin SAFE_PLUGIN = "mailer" def test_unpin_plugin_does_not_raise(jenkins): """unpin_plugin should not raise even if the plugin is not pinned.""" plugins = jenkins.get_plugins() assert SAFE_PLUGIN in plugins, "%s plugin not installed" % SAFE_PLUGIN # Unpin is a no-op if the plugin isn't pinned; should not raise plugins.unpin_plugin(SAFE_PLUGIN) def test_downgrade_plugin_does_not_raise(jenkins): """downgrade_plugin should not raise if called on a non-downgradable plugin.""" plugins = jenkins.get_plugins() assert SAFE_PLUGIN in plugins, "%s plugin not installed" % SAFE_PLUGIN # Downgrade is a no-op if there is no previous version; should not raise plugins.downgrade_plugin(SAFE_PLUGIN) jenkinsapi-0.3.23/jenkinsapi_tests/systests/test_plugin_enable_disable.py0000644000000000000000000000206613615410400024034 0ustar00""" System tests for Plugins.enable_plugin and Plugins.disable_plugin. """ import logging import pytest log = logging.getLogger(__name__) pytestmark = pytest.mark.docker # Use "mailer" - a leaf plugin with no test dependencies SAFE_PLUGIN = "mailer" def test_disable_plugin(jenkins): """Disable a plugin and verify its enabled state changes.""" plugins = jenkins.get_plugins() assert SAFE_PLUGIN in plugins, "%s plugin not installed" % SAFE_PLUGIN plugins.disable_plugin(SAFE_PLUGIN) plugins.poll() plugin = plugins[SAFE_PLUGIN] assert not plugin.enabled, "Expected plugin to be disabled" def test_enable_plugin(jenkins): """Disable then re-enable a plugin and verify enabled state.""" plugins = jenkins.get_plugins() assert SAFE_PLUGIN in plugins, "%s plugin not installed" % SAFE_PLUGIN plugins.disable_plugin(SAFE_PLUGIN) plugins.poll() assert not plugins[SAFE_PLUGIN].enabled plugins.enable_plugin(SAFE_PLUGIN) plugins.poll() assert plugins[SAFE_PLUGIN].enabled, "Expected plugin to be re-enabled" jenkinsapi-0.3.23/jenkinsapi_tests/systests/test_plugins.py0000644000000000000000000000635613615410400021234 0ustar00""" System tests for `jenkinsapi.plugins` module. """ import logging import pytest from jenkinsapi_tests.test_utils.random_strings import random_string from jenkinsapi.plugin import Plugin log = logging.getLogger(__name__) pytestmark = pytest.mark.docker def test_plugin_data(jenkins): # It takes time to get plugins json from remote timeout = jenkins.requester.timeout jenkins.requester.timeout = 60 jenkins.plugins.check_updates_server() jenkins.requester.timeout = timeout assert "workflow-api" in jenkins.plugins def test_get_missing_plugin(jenkins): plugins = jenkins.get_plugins() with pytest.raises(KeyError): plugins["lsdajdaslkjdlkasj"] # this plugin surely does not exist! def test_get_single_plugin(jenkins): plugins = jenkins.get_plugins() plugin_name, plugin = next(plugins.iteritems()) assert isinstance(plugin_name, str) assert isinstance(plugin, Plugin) def test_get_single_plugin_depth_2(jenkins): plugins = jenkins.get_plugins(depth=2) _, plugin = next(plugins.iteritems()) assert isinstance(plugin, Plugin) def test_delete_inexistant_plugin(jenkins): with pytest.raises(KeyError): del jenkins.plugins[random_string()] # def test_install_uninstall_plugin(jenkins): # plugin_name = "suppress-stack-trace" # # plugin_dict = { # "shortName": plugin_name, # "version": "latest", # } # jenkins.plugins[plugin_name] = Plugin(plugin_dict) # # assert plugin_name in jenkins.plugins # # plugin = jenkins.get_plugins()[plugin_name] # assert isinstance(plugin, Plugin) # assert plugin.shortName == plugin_name # # del jenkins.plugins[plugin_name] # assert jenkins.plugins[plugin_name].deleted # # # def test_install_multiple_plugins(jenkins): # plugin_one_name = "keyboard-shortcuts-plugin" # plugin_one_version = "latest" # plugin_one = "@".join((plugin_one_name, plugin_one_version)) # plugin_two = Plugin( # {"shortName": "emotional-jenkins-plugin", "version": "latest"} # ) # # assert isinstance(plugin_two, Plugin) # # plugin_list = [plugin_one, plugin_two] # # jenkins.install_plugins(plugin_list) # # assert plugin_one_name in jenkins.plugins # assert plugin_two.shortName in jenkins.plugins # # del jenkins.plugins["keyboard-shortcuts-plugin"] # del jenkins.plugins["emotional-jenkins-plugin"] # # # def test_downgrade_plugin(jenkins): # plugin_name = "console-badge" # plugin_version = "latest" # plugin = Plugin({"shortName": plugin_name, "version": plugin_version}) # # assert isinstance(plugin, Plugin) # # # Need to restart when not installing the latest version # jenkins.install_plugins([plugin]) # # installed_plugin = jenkins.plugins[plugin_name] # # assert installed_plugin.version == "1.1" # # older_plugin = Plugin({"shortName": plugin_name, "version": "1.0"}) # jenkins.install_plugins( # [older_plugin], restart=True, wait_for_reboot=True) # installed_older_plugin = jenkins.plugins[plugin_name] # # assert installed_older_plugin.version == "1.0" # # del jenkins.plugins[plugin_name] jenkinsapi-0.3.23/jenkinsapi_tests/systests/test_queue.py0000644000000000000000000000602013615410400020663 0ustar00""" All kinds of testing on Jenkins Queues """ import time import logging import pytest from jenkinsapi.queue import Queue from jenkinsapi.queue import QueueItem from jenkinsapi.job import Job from jenkinsapi_tests.test_utils.random_strings import random_string from jenkinsapi_tests.systests.job_configs import LONG_RUNNING_JOB log = logging.getLogger(__name__) pytestmark = pytest.mark.docker @pytest.fixture(scope="function") def no_executors(jenkins, request): master = jenkins.nodes["Built-In Node"] num_executors = master.get_num_executors() master.set_num_executors(0) def restore(): master.set_num_executors(num_executors) request.addfinalizer(restore) return num_executors def test_get_queue(jenkins): qq = jenkins.get_queue() assert isinstance(qq, Queue) is True def test_invoke_many_jobs(jenkins, no_executors): job_names = [random_string() for _ in range(5)] jobs = [] while len(jenkins.get_queue()) != 0: log.info("Sleeping to get queue empty...") time.sleep(1) for job_name in job_names: j = jenkins.create_job(job_name, LONG_RUNNING_JOB) jobs.append(j) j.invoke() assert j.is_queued_or_running() is True queue = jenkins.get_queue() reprString = repr(queue) assert queue.baseurl in reprString assert len(queue) == 5, queue.keys() assert isinstance(queue[queue.keys()[0]].get_job(), Job) is True items = queue.get_queue_items_for_job(job_names[2]) assert isinstance(items, list) is True assert len(items) == 1 assert isinstance(items[0], QueueItem) is True assert items[0].get_parameters() == [] for _, item in queue.iteritems(): queue.delete_item(item) queue.poll() assert len(queue) == 0 def test_start_and_stop_long_running_job(jenkins): job_name = random_string() j = jenkins.create_job(job_name, LONG_RUNNING_JOB) j.invoke() time.sleep(1) assert j.is_queued_or_running() is True # Get queue item and wait for it to transition to a build queue_item = j.get_queue_item() build = queue_item.block_until_building() build.stop() time.sleep(1) assert j.is_queued_or_running() is False def test_queueitem_for_why_field(jenkins, no_executors): job_names = [random_string() for _ in range(2)] jobs = [] for job_name in job_names: j = jenkins.create_job(job_name, LONG_RUNNING_JOB) jobs.append(j) j.invoke() queue = jenkins.get_queue() for _, item in queue.iteritems(): assert isinstance(item.why, str) is True # Clean up after ourselves for _, item in queue.iteritems(): queue.delete_item(item) def test_queueitem_from_job(jenkins, no_executors): job_name = random_string() j = jenkins.create_job(job_name, LONG_RUNNING_JOB) j.invoke() qi = j.get_queue_item() assert isinstance(qi, QueueItem) assert qi.get_job() == j assert qi.get_job_name() == job_name assert qi.name == job_name assert qi.is_queued() assert not qi.is_running() jenkinsapi-0.3.23/jenkinsapi_tests/systests/test_queue_item_details.py0000644000000000000000000000503113615410400023407 0ustar00""" System tests for QueueItem detail properties: - is_blocked, is_stuck, is_buildable - get_age, get_eta, get_causes """ import time import logging import pytest from jenkinsapi.queue import QueueItem from jenkinsapi_tests.test_utils.random_strings import random_string from jenkinsapi_tests.systests.job_configs import LONG_RUNNING_JOB log = logging.getLogger(__name__) pytestmark = pytest.mark.docker @pytest.fixture def no_executors(jenkins, request): master = jenkins.nodes["Built-In Node"] num_executors = master.get_num_executors() master.set_num_executors(0) def restore(): master.set_num_executors(num_executors) request.addfinalizer(restore) return num_executors def test_queue_item_is_blocked_and_buildable(jenkins, no_executors): """Queue a job and check is_blocked, is_buildable, is_stuck properties.""" job_name = random_string() job = jenkins.create_job(job_name, LONG_RUNNING_JOB) job.invoke() time.sleep(1) assert job.is_queued() qi = job.get_queue_item() assert isinstance(qi, QueueItem) # With no executors the item is blocked, not buildable assert isinstance(qi.is_blocked, bool) assert isinstance(qi.is_buildable, bool) assert isinstance(qi.is_stuck, bool) # Clean up jenkins.get_queue().delete_item(qi) def test_queue_item_get_age(jenkins, no_executors): """A queued item's age should be a positive number of seconds.""" job_name = random_string() job = jenkins.create_job(job_name, LONG_RUNNING_JOB) job.invoke() time.sleep(2) assert job.is_queued() qi = job.get_queue_item() age = qi.get_age() assert age > 0, "Expected age > 0 seconds, got %s" % age jenkins.get_queue().delete_item(qi) def test_queue_item_get_eta(jenkins, no_executors): """get_eta should return a non-negative float.""" job_name = random_string() job = jenkins.create_job(job_name, LONG_RUNNING_JOB) job.invoke() time.sleep(1) assert job.is_queued() qi = job.get_queue_item() eta = qi.get_eta() assert eta >= 0.0, "Expected eta >= 0, got %s" % eta jenkins.get_queue().delete_item(qi) def test_queue_item_get_causes(jenkins, no_executors): """get_causes should return a list for a manually triggered job.""" job_name = random_string() job = jenkins.create_job(job_name, LONG_RUNNING_JOB) job.invoke() time.sleep(1) assert job.is_queued() qi = job.get_queue_item() causes = qi.get_causes() assert isinstance(causes, list) jenkins.get_queue().delete_item(qi) jenkinsapi-0.3.23/jenkinsapi_tests/systests/test_quiet_down.py0000644000000000000000000000132213615410400021715 0ustar00""" System tests for setting jenkins in quietDown mode """ import logging import pytest from jenkinsapi_tests.test_utils.retry import retry log = logging.getLogger(__name__) pytestmark = pytest.mark.docker @retry() def test_quiet_down_and_cancel_quiet_down(jenkins): jenkins.poll() # jenkins should be alive jenkins.quiet_down() # put Jenkins in quietDown mode # is_quieting_down = jenkins.is_quieting_down assert jenkins.is_quieting_down is True jenkins.poll() # jenkins should be alive jenkins.cancel_quiet_down() # leave quietDown mode # is_quieting_down = jenkins_api['quietingDown'] assert jenkins.is_quieting_down is False jenkins.poll() # jenkins should be alive jenkinsapi-0.3.23/jenkinsapi_tests/systests/test_restart.py0000644000000000000000000000375413615410400021236 0ustar00""" System tests for restarting jenkins NB: this test will be very time consuming because after restart it will wait for jenkins to boot """ import time import logging import pytest from requests import HTTPError, ConnectionError log = logging.getLogger(__name__) pytestmark = pytest.mark.docker def wait_for_restart(jenkins): wait = 15 count = 0 max_count = 30 success = False msg = ( "Jenkins has not restarted yet! (This is try %s of %s, " "waited %s seconds so far) " "Sleeping %s seconds and trying again..." ) while count < max_count or not success: time.sleep(wait) try: jenkins.poll() log.info("Jenkins restarted successfully.") success = True break except HTTPError as ex: log.info(ex) except ConnectionError as ex: log.info(ex) log.info(msg, count + 1, max_count, count * wait, wait) count += 1 if not success: msg = ( "Jenkins did not come back from safe restart! " "Waited {0} seconds altogether. This " "failure may cause other failures." ) log.critical(msg.format(count * wait)) pytest.fail(msg) def test_safe_restart_wait(jenkins): jenkins.poll() # jenkins should be alive jenkins.safe_restart() # restart and wait for reboot (default) jenkins.poll() # jenkins should be alive again def test_safe_restart_dont_wait(jenkins): jenkins.poll() # jenkins should be alive jenkins.safe_restart(wait_for_reboot=False) # Jenkins sleeps for 10 seconds before actually restarting time.sleep(11) with pytest.raises((HTTPError, ConnectionError)): # this is a 503: jenkins is still restarting jenkins.poll() # the test is now complete, but other tests cannot run until # jenkins has finished restarted. to avoid cascading failure # we have to wait for reboot to finish. wait_for_restart(jenkins) jenkinsapi-0.3.23/jenkinsapi_tests/systests/test_safe_exit.py0000644000000000000000000000235113615410400021511 0ustar00""" System tests for `jenkinsapi.jenkins` module. """ import time import logging import pytest from jenkinsapi.build import Build from jenkinsapi_tests.test_utils.random_strings import random_string from jenkinsapi_tests.systests.job_configs import LONG_RUNNING_JOB log = logging.getLogger(__name__) pytestmark = pytest.mark.docker def test_safe_exit(jenkins): job_name = "Bcreate_%s" % random_string() job = jenkins.create_job(job_name, LONG_RUNNING_JOB) qq = job.invoke() time.sleep(3) bn = qq.block_until_building(delay=3).get_number() assert isinstance(bn, int) build = qq.get_build() assert isinstance(build, Build) assert build.is_running() # A job is now running and safe_exit should await running jobs # Call, but wait only for 5 seconds then cancel exit jenkins.safe_exit(wait_for_exit=False) time.sleep(5) jenkins.cancel_quiet_down() # leave quietDown mode assert jenkins.is_quieting_down is False build.stop() # if we call next line right away - Jenkins have no time to stop job # so we wait a bit while build.is_running(): time.sleep(0.5) console = build.get_console() assert isinstance(console, str) assert "Started by user" in console jenkinsapi-0.3.23/jenkinsapi_tests/systests/test_scm.py0000644000000000000000000000464413615410400020333 0ustar00""" System tests for Git SCM support in jenkinsapi. """ import pytest from jenkinsapi_tests.test_utils.retry import retry from jenkinsapi_tests.test_utils.random_strings import random_string from jenkinsapi_tests.systests.job_configs import SCM_GIT_JOB pytestmark = pytest.mark.docker @retry() def test_git_revision_lookup(jenkins): """Test that get_buildnumber_for_revision works with Git SHA1 hashes.""" job_name = "git_revision_test_%s" % random_string() job = jenkins.create_job(job_name, SCM_GIT_JOB) # Trigger multiple builds to test revision lookup revisions = {} for build_num in range(1, 3): qq = job.invoke() qq.block_until_complete(delay=2) build = job.get_build(build_num) revision = build.get_revision() # Revision should be a string (SHA1 hash) assert isinstance(revision, str), f"Expected str, got {type(revision)}" assert len(revision) == 40, ( f"Git SHA1 should be 40 chars, got {len(revision)}" ) revisions[build_num] = revision # Test get_buildnumber_for_revision with Git SHA1 # First revision should map to build 1 found_builds = job.get_buildnumber_for_revision(revisions[1]) assert 1 in found_builds, f"Build 1 should have revision {revisions[1]}" # Second revision should map to build 2 found_builds = job.get_buildnumber_for_revision(revisions[2]) assert 2 in found_builds, f"Build 2 should have revision {revisions[2]}" @retry() def test_get_revision_dict_skips_none(jenkins): """Test that get_revision_dict() doesn't include None revisions.""" job_name = "git_revision_dict_test_%s" % random_string() job = jenkins.create_job(job_name, SCM_GIT_JOB) # Trigger a build qq = job.invoke() qq.block_until_complete(delay=2) # Get the revision dictionary rev_dict = job.get_revision_dict() # None should not be a key in the dictionary assert None not in rev_dict, ( "Revision dictionary should not contain None key" ) # Should have at least one valid revision assert len(rev_dict) > 0, ( "Revision dictionary should have at least one entry" ) # All keys should be strings (Git SHA1 hashes) for revision in rev_dict.keys(): assert isinstance(revision, str), f"Expected str, got {type(revision)}" assert len(revision) == 40, ( f"Git SHA1 should be 40 chars, got {len(revision)}" ) jenkinsapi-0.3.23/jenkinsapi_tests/systests/test_special_chars_auth.py0000644000000000000000000000716613615410400023374 0ustar00""" Tests for issue #862: Auth when password has special characters fails. https://github.com/pycontribs/jenkinsapi/issues/862 These tests use real Jenkins auth requests against a launcher instance. """ import base64 import pytest import requests from jenkinsapi.jenkins import Jenkins SPECIAL_CHAR_PASSWORD = "zzzzzz6@Y74(K.xxxxxxx" SPECIAL_CHAR_USER = "specialuser" pytestmark = pytest.mark.docker def _create_user_with_special_chars(jenkins_admin_admin): """Create a user with special character password using Groovy.""" create_user_groovy = f""" import jenkins.model.* import hudson.security.* def instance = Jenkins.getInstance() def realm = instance.getSecurityRealm() if (realm instanceof HudsonPrivateSecurityRealm) {{ realm.createAccount('{SPECIAL_CHAR_USER}', '{SPECIAL_CHAR_PASSWORD}') }} """ jenkins_admin_admin.run_groovy_script(create_user_groovy) def _delete_user_with_special_chars(jenkins_admin_admin): """Delete the special chars test user.""" delete_user_groovy = f""" import jenkins.model.* def instance = Jenkins.getInstance() def realm = instance.getSecurityRealm() if (realm instanceof HudsonPrivateSecurityRealm) {{ realm.deleteUser('{SPECIAL_CHAR_USER}') }} """ jenkins_admin_admin.run_groovy_script(delete_user_groovy) @pytest.fixture def jenkins_with_special_char_user(launched_jenkins, jenkins_admin_admin): """Set up Jenkins with security enabled and a user with special char password.""" _create_user_with_special_chars(jenkins_admin_admin) yield launched_jenkins _delete_user_with_special_chars(jenkins_admin_admin) def test_special_char_password_auth_succeeds(jenkins_with_special_char_user): """Authentication with special character password must succeed.""" url = jenkins_with_special_char_user.jenkins_url # Should not raise an exception jenkins = Jenkins( url, username=SPECIAL_CHAR_USER, password=SPECIAL_CHAR_PASSWORD, timeout=10, ) assert jenkins.username == SPECIAL_CHAR_USER assert jenkins.password == SPECIAL_CHAR_PASSWORD # Verify we can make requests jenkins.poll() def test_special_char_password_wrong_password_raises_401( jenkins_with_special_char_user, ): """Wrong password with special chars in correct password must raise HTTPError.""" url = jenkins_with_special_char_user.jenkins_url with pytest.raises(requests.exceptions.HTTPError) as exc_info: Jenkins( url, username=SPECIAL_CHAR_USER, password="wrongpassword", timeout=10, ) assert "401" in str(exc_info.value) def test_special_char_password_encoding_preserved( jenkins_with_special_char_user, ): """Special characters must be encoded correctly in auth headers.""" url = jenkins_with_special_char_user.jenkins_url # Create a custom requester to capture the auth header from jenkinsapi.utils.requester import Requester requester = Requester( SPECIAL_CHAR_USER, SPECIAL_CHAR_PASSWORD, baseurl=url, timeout=10 ) # Make a request and verify the auth header encoding request_dict = requester.get_request_dict( params=None, data=None, headers=None ) auth_tuple = request_dict["auth"] # Verify auth tuple contains the exact password assert auth_tuple == (SPECIAL_CHAR_USER, SPECIAL_CHAR_PASSWORD) # Verify encoding for wire protocol raw = f"{SPECIAL_CHAR_USER}:{SPECIAL_CHAR_PASSWORD}".encode("latin1") encoded = base64.b64encode(raw).decode("utf-8") decoded = base64.b64decode(encoded.encode("utf-8")).decode("utf-8") assert decoded == f"{SPECIAL_CHAR_USER}:{SPECIAL_CHAR_PASSWORD}" jenkinsapi-0.3.23/jenkinsapi_tests/systests/test_user_management.py0000644000000000000000000000146113615410400022715 0ustar00""" System tests for Jenkins user management (create/delete). """ import logging import pytest from jenkinsapi_tests.test_utils.random_strings import random_string log = logging.getLogger(__name__) pytestmark = pytest.mark.docker def test_create_and_delete_user(jenkins_admin_admin): username = "testuser_%s" % random_string(6) jenkins_admin_admin.create_user( username, "password123", "Test User", "%s@example.com" % username ) url = "%s/user/%s/api/json" % (jenkins_admin_admin.baseurl, username) resp = jenkins_admin_admin.requester.get_and_confirm_status(url) assert resp.status_code == 200 jenkins_admin_admin.delete_user(username) resp = jenkins_admin_admin.requester.get_and_confirm_status( url, valid=[404, 200] ) assert resp.status_code == 404 jenkinsapi-0.3.23/jenkinsapi_tests/systests/test_views.py0000644000000000000000000001335213615410400020702 0ustar00""" System tests for `jenkinsapi.jenkins` module. """ import logging import pytest from jenkinsapi_tests.test_utils.retry import retry from jenkinsapi.view import View from jenkinsapi.views import Views from jenkinsapi.job import Job from jenkinsapi.api import get_view_from_url from jenkinsapi_tests.systests.job_configs import EMPTY_JOB from jenkinsapi_tests.systests.view_configs import VIEW_WITH_FILTER_AND_REGEX from jenkinsapi_tests.test_utils.random_strings import random_string log = logging.getLogger(__name__) pytestmark = pytest.mark.docker def create_job(jenkins, job_name="whatever"): job = jenkins.create_job(job_name, EMPTY_JOB) return job def test_make_views(jenkins): view_name = random_string() assert view_name not in jenkins.views new_view = jenkins.views.create(view_name) assert view_name in jenkins.views assert isinstance(new_view, View) is True assert view_name == str(new_view) # Can we create a view that already exists? existing = jenkins.views.create(view_name) assert existing == new_view # Can we use the API convenience methods new_view_1 = get_view_from_url(new_view.baseurl) assert new_view == new_view_1 del jenkins.views[view_name] def test_add_job_to_view(jenkins): job_name = random_string() create_job(jenkins, job_name) view_name = random_string() assert view_name not in jenkins.views new_view = jenkins.views.create(view_name) assert view_name in jenkins.views assert isinstance(new_view, View) is True assert job_name not in new_view assert new_view.add_job(job_name) is True assert job_name in new_view assert isinstance(new_view[job_name], Job) is True assert len(new_view) == 1 for j_name, j in new_view.iteritems(): assert j_name == job_name assert isinstance(j, Job) is True for j in new_view.values(): assert isinstance(j, Job) is True jobs = new_view.items() assert isinstance(jobs, list) is True assert isinstance(jobs[0], tuple) is True assert new_view.add_job(job_name) is False assert new_view.add_job("unknown") is False del jenkins.views[view_name] @retry() def test_create_and_delete_views(jenkins): view1_name = random_string() new_view = jenkins.views.create(view1_name) assert isinstance(new_view, View) is True assert view1_name in jenkins.views del jenkins.views[view1_name] assert view1_name not in jenkins.views def test_create_and_delete_views_by_url(jenkins): view1_name = random_string() new_view = jenkins.views.create(view1_name) assert isinstance(new_view, View) is True assert view1_name in jenkins.views view_url = new_view.baseurl view_by_url = jenkins.get_view_by_url(view_url) assert isinstance(view_by_url, View) is True jenkins.delete_view_by_url(view_url) assert view1_name not in jenkins.views def test_delete_view_which_does_not_exist(jenkins): view1_name = random_string() assert view1_name not in jenkins.views del jenkins.views[view1_name] def test_update_view_config(jenkins): view_name = random_string() new_view = jenkins.views.create(view_name) assert isinstance(new_view, View) is True assert view_name in jenkins.views config = jenkins.views[view_name].get_config().strip() new_view_config = VIEW_WITH_FILTER_AND_REGEX % view_name assert config != new_view_config jenkins.views[view_name].update_config(new_view_config) config = jenkins.views[view_name].get_config().strip() assert config == new_view_config def test_make_nested_views(jenkins): job = create_job(jenkins) top_view_name = random_string() sub1_view_name = random_string() sub2_view_name = random_string() assert top_view_name not in jenkins.views tv = jenkins.views.create(top_view_name, Views.NESTED_VIEW) assert top_view_name in jenkins.views assert isinstance(tv, View) is True # Empty sub view sv1 = tv.views.create(sub1_view_name) assert sub1_view_name in tv.views assert isinstance(sv1, View) is True # Sub view with job in it tv.views[sub2_view_name] = job.name assert sub2_view_name in tv.views sv2 = tv.views[sub2_view_name] assert isinstance(sv2, View) is True assert job.name in sv2 # Can we use the API convenience methods new_view = get_view_from_url(sv2.baseurl) assert new_view == sv2 def test_add_to_view_after_copy(jenkins): # This test is for issue #291 job = create_job(jenkins) new_job_name = random_string() view_name = random_string() new_view = jenkins.views.create(view_name) new_view = jenkins.views[view_name] new_job = jenkins.copy_job(job.name, new_job_name) assert new_view.add_job(new_job.name) is True assert new_job.name in new_view def test_get_job_config(jenkins): # This test is for issue #301 job = create_job(jenkins) view_name = random_string() new_view = jenkins.views.create(view_name) assert new_view.add_job(job.name) is True assert " %s true true regex false """.strip() jenkinsapi-0.3.23/jenkinsapi_tests/test_utils/__init__.py0000644000000000000000000000000013615410400020525 0ustar00jenkinsapi-0.3.23/jenkinsapi_tests/test_utils/random_strings.py0000644000000000000000000000032313615410400022027 0ustar00import random import string def random_string(length=10): return "".join( random.choice(string.ascii_lowercase) for i in range(length) ) if __name__ == "__main__": print(random_string()) jenkinsapi-0.3.23/jenkinsapi_tests/test_utils/retry.py0000644000000000000000000000317513615410400020153 0ustar00"""Retry decorator with exponential backoff for resilient tests.""" import functools import time from typing import Any, Callable, TypeVar T = TypeVar("T") def retry( max_attempts: int = 5, initial_delay: float = 1.0, max_delay: float = 5.0, backoff_multiplier: float = 1.5, ) -> Callable: """ Decorator that retries a function with exponential backoff. Useful for system tests that experience transient failures (e.g., connection errors). Args: max_attempts: Maximum number of attempts (default: 5) initial_delay: Initial delay in seconds (default: 1.0) max_delay: Maximum delay between retries, capped (default: 5.0) backoff_multiplier: Multiplier for exponential backoff (default: 1.5) Example: @retry() def test_something(jenkins): # test logic pass @retry(max_attempts=3, initial_delay=0.5) def test_something_else(jenkins): # test logic pass """ def decorator(func: Callable[..., T]) -> Callable[..., T]: @functools.wraps(func) def wrapper(*args: Any, **kwargs: Any) -> T: delay = initial_delay last_error = None for attempt in range(max_attempts): try: return func(*args, **kwargs) except Exception as e: last_error = e if attempt < max_attempts - 1: time.sleep(delay) delay = min(delay * backoff_multiplier, max_delay) raise last_error return wrapper return decorator jenkinsapi-0.3.23/jenkinsapi_tests/unittests/__init__.py0000644000000000000000000000000013615410400020370 0ustar00jenkinsapi-0.3.23/jenkinsapi_tests/unittests/configs.py0000644000000000000000000002725413615410400020305 0ustar00# flake8: noqa from jenkinsapi import config JOB_DATA = { "actions": [ { "parameterDefinitions": [ { "defaultParameterValue": { "name": "param1", "value": "test1", }, "description": "", "name": "param1", "type": "StringParameterDefinition", }, { "defaultParameterValue": {"name": "param2", "value": ""}, "description": "", "name": "param2", "type": "StringParameterDefinition", }, ], } ], "description": "test job", "displayName": "foo", "displayNameOrNull": None, "name": "foo", "url": "http://halob:8080/job/foo/", "buildable": True, "builds": [ {"number": 3, "url": "http://halob:8080/job/foo/3/"}, {"number": 2, "url": "http://halob:8080/job/foo/2/"}, {"number": 1, "url": "http://halob:8080/job/foo/1/"}, ], # allBuilds is not present in job dict returned by Jenkins # it is inserted here to test _add_missing_builds() "allBuilds": [ {"number": 3, "url": "http://halob:8080/job/foo/3/"}, {"number": 2, "url": "http://halob:8080/job/foo/2/"}, {"number": 1, "url": "http://halob:8080/job/foo/1/"}, ], "color": "blue", "firstBuild": {"number": 1, "url": "http://halob:8080/job/foo/1/"}, "healthReport": [ { "description": "Build stability: No recent builds failed.", "iconUrl": "health-80plus.png", "score": 100, } ], "inQueue": False, "keepDependencies": False, # build running "lastBuild": {"number": 4, "url": "http://halob:8080/job/foo/4/"}, "lastCompletedBuild": {"number": 3, "url": "http://halob:8080/job/foo/3/"}, "lastFailedBuild": None, "lastStableBuild": {"number": 3, "url": "http://halob:8080/job/foo/3/"}, "lastSuccessfulBuild": { "number": 3, "url": "http://halob:8080/job/foo/3/", }, "lastUnstableBuild": None, "lastUnsuccessfulBuild": None, "nextBuildNumber": 4, "property": [], "queueItem": None, "concurrentBuild": False, # test1 job exists, test2 does not "downstreamProjects": [{"name": "test1"}, {"name": "test2"}], "scm": {}, "upstreamProjects": [], } URL_DATA = {"http://halob:8080/job/foo/%s" % config.JENKINS_API: JOB_DATA} BUILD_DATA = { "actions": [ { "causes": [ { "shortDescription": "Started by user anonymous", "userId": None, "userName": "anonymous", "upstreamProject": "parentBuild", "upstreamBuild": 1, } ], "parameters": [ {"name": "masterBuild", "value": "masterBuild"}, {"name": "lastBuild", "value": 1}, ], } ], "artifacts": [{"fileName": "foo.txt", "relativePath": "foo.txt"}], "building": False, "builtOn": "localhost", "changeSet": { "items": [ { "affectedPaths": ["content/rcm/v00-rcm-xccdf.xml"], "author": { "absoluteUrl": "http://jenkins_url/user/username79", "fullName": "username", }, "commitId": "3097", "timestamp": 1414398423091, "date": "2014-10-27T08:27:03.091288Z", "msg": "commit message", "paths": [ {"editType": "edit", "file": "/some/path/of/changed_file"} ], "revision": 3097, "user": "username", } ], "kind": None, }, "culprits": [], "description": "Best build ever!", "duration": 5782, "estimatedDuration": 106, "executor": None, "fingerprint": [ { "fileName": "BuildId.json", "hash": "e3850a45ab64aa34c1aa66e30c1a8977", "original": {"name": "ArtifactGenerateJob", "number": 469}, "timestamp": 1380270162488, "usage": [ { "name": "test1", "ranges": {"ranges": [{"end": 567, "start": 566}]}, }, { "name": "test2", "ranges": {"ranges": [{"end": 150, "start": 139}]}, }, ], } ], "fullDisplayName": "foo #1", "id": "2013-05-31_23-15-40", "keepLog": False, "number": 1, "result": "SUCCESS", "timestamp": 1370042140000, "url": "http://localhost:8080/job/foo/1/", "runs": [ {"number": 1, "url": "http//localhost:8080/job/foo/SHARD_NUM=1/1/"}, {"number": 2, "url": "http//localhost:8080/job/foo/SHARD_NUM=1/2/"}, ], } BUILD_DATA_PIPELINE = { "actions": [ { "causes": [ { "shortDescription": "Started by user anonymous", "userId": None, "userName": "anonymous", } ] } ], "artifacts": [], "building": False, "builtOn": "localhost", "changeSets": { "items": [ { "affectedPaths": ["content/rcm/v00-rcm-xccdf.xml"], "author": { "absoluteUrl": "http://jenkins_url/user/username79", "fullName": "username", }, "commitId": "3097", "timestamp": 1414398423091, "date": "2014-10-27T08:27:03.091288Z", "msg": "commit message", "paths": [ {"editType": "edit", "file": "/some/path/of/changed_file"} ], "revision": 3097, "user": "username", } ], "kind": None, }, "culprits": [], "description": "Best build ever!", "duration": 5782, "estimatedDuration": 106, "executor": None, "fingerprint": [ { "fileName": "BuildId.json", "hash": "e3850a45ab64aa34c1aa66e30c1a8977", "original": {"name": "ArtifactGenerateJob", "number": 469}, "timestamp": 1380270162488, "usage": [ { "name": "test1", "ranges": {"ranges": [{"end": 567, "start": 566}]}, }, { "name": "test2", "ranges": {"ranges": [{"end": 150, "start": 139}]}, }, ], } ], "fullDisplayName": "foo #1", "id": "2013-05-31_23-15-40", "keepLog": False, "number": 1, "result": "SUCCESS", "timestamp": 1370042140000, "url": "http://localhost:8080/job/foo/1/", "runs": [ {"number": 1, "url": "http//localhost:8080/job/foo/SHARD_NUM=1/1/"}, {"number": 2, "url": "http//localhost:8080/job/foo/SHARD_NUM=1/2/"}, ], "stages": [ { "id": "stage-id-1", "name": "Build", "status": "SUCCESS", "startTimeMillis": 1370042140000, "durationMillis": 5000, "stageFlowNodes": [], }, { "id": "stage-id-2", "name": "Test", "status": "SUCCESS", "startTimeMillis": 1370042145000, "durationMillis": 3000, "stageFlowNodes": [], }, ], } BUILD_SCM_DATA = { "actions": [ {"causes": [{"shortDescription": "Started by an SCM change"}]}, {}, { "buildsByBranchName": { "origin/HEAD": { "buildNumber": 2, "buildResult": None, "revision": { "SHA1": "d2a5d435fa2df3bff572bd06e43c86544749c5d2", "branch": [ { "SHA1": "d2a5d435fa2df3bff572bd06e43c86544749c5d2", "name": "origin/HEAD", }, { "SHA1": "d2a5d435fa2df3bff572bd06e43c86544749c5d2", "name": "origin/master", }, ], }, }, "origin/master": { "buildNumber": 2, "buildResult": None, "revision": { "SHA1": "d2a5d435fa2df3bff572bd06e43c86544749c5d2", "branch": [ { "SHA1": "d2a5d435fa2df3bff572bd06e43c86544749c5d2", "name": "origin/HEAD", }, { "SHA1": "d2a5d435fa2df3bff572bd06e43c86544749c5d2", "name": "origin/master", }, ], }, }, "origin/python_3_compatibility": { "buildNumber": 1, "buildResult": None, "revision": { "SHA1": "c9d1c96bc926ff63a5209c51b3ed537e62ea50e6", "branch": [ { "SHA1": "c9d1c96bc926ff63a5209c51b3ed537e62ea50e6", "name": "origin/python_3_compatibility", } ], }, }, "origin/unstable": { "buildNumber": 3, "buildResult": None, "revision": { "SHA1": "7def9ed6e92580f37d00e4980c36c4d36e68f702", "branch": [ { "SHA1": "7def9ed6e92580f37d00e4980c36c4d36e68f702", "name": "origin/unstable", } ], }, }, }, "lastBuiltRevision": { "SHA1": "7def9ed6e92580f37d00e4980c36c4d36e68f702", "branch": [ { "SHA1": "7def9ed6e92580f37d00e4980c36c4d36e68f702", "name": "origin/unstable", } ], }, "remoteUrls": ["https://github.com/salimfadhley/jenkinsapi.git"], "scmName": "", }, {}, {}, ], "artifacts": [], "building": False, "builtOn": "", "changeSet": {"items": [], "kind": "git"}, "culprits": [], "description": None, "duration": 1051, "estimatedDuration": 2260, "executor": None, "fullDisplayName": "git_yssrtigfds #3", "id": "2013-06-30_01-54-35", "keepLog": False, "number": 3, "result": "SUCCESS", "timestamp": 1372553675652, "url": "http://localhost:8080/job/git_yssrtigfds/3/", } BUILD_ENV_VARS = { "_class": "org.jenkinsci.plugins.envinject.EnvInjectVarList", "envMap": {"KEY": "VALUE"}, } PIPELINE_INPUT_ACTIONS = [ { "id": "Input-step-1", "message": "Deploy to production?", "inputs": [ { "name": "DEPLOY_ENV", "description": "Target environment", "type": "StringParameterDefinition", } ], "proceedText": "Proceed", "abortUrl": "/input/input-step-1/abort", "proceedUrl": "/input/input-step-1/proceed", } ] jenkinsapi-0.3.23/jenkinsapi_tests/unittests/conftest_helpers.py0000644000000000000000000000225513615410400022216 0ustar00"""Helper utilities for Docker test fixtures.""" import socket import logging from typing import Tuple log = logging.getLogger(__name__) def find_free_port(start_port: int = 8100) -> int: port = start_port for _ in range(100): try: sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) sock.bind(("", port)) sock.close() return port except OSError: port += 1 raise RuntimeError( f"Could not find a free port starting from {start_port}" ) def parse_worker_id(worker_id: str) -> Tuple[int, str]: if worker_id == "master": return 0, "master" try: if "gw" in worker_id: worker_num = int(worker_id.split("gw")[1]) return worker_num, f"worker-{worker_num}" except (IndexError, ValueError): pass return 0, worker_id def generate_container_name( worker_id: str, base_name: str = "jenkinsapi-test" ) -> str: _, display_name = parse_worker_id(worker_id) if display_name == "master": return base_name return f"{base_name}-{display_name}" jenkinsapi-0.3.23/jenkinsapi_tests/unittests/test_artifact.py0000644000000000000000000001644213615410400021506 0ustar00import pytest from mock import Mock, patch, call from requests.exceptions import HTTPError from jenkinsapi.artifact import Artifact from jenkinsapi.jenkinsbase import JenkinsBase from jenkinsapi.fingerprint import Fingerprint from jenkinsapi.custom_exceptions import ArtifactBroken try: import unittest2 as unittest except ImportError: import unittest @pytest.fixture() def artifact(mocker): return Artifact( "artifact.zip", "http://foo/job/TestJob/1/artifact/artifact.zip", mocker.MagicMock(), ) def test_verify_download_valid_positive(artifact, monkeypatch): def fake_md5(cls, fspath): # pylint: disable=unused-argument return "097c42989a9e5d9dcced7b35ec4b0486" monkeypatch.setattr(Artifact, "_md5sum", fake_md5) def fake_poll(cls, tree=None): # pylint: disable=unused-argument return {} monkeypatch.setattr(JenkinsBase, "_poll", fake_poll) def fake_validate(cls, filename, job, build): # pylint: disable=unused-argument # pylint: disable=unused-argument return True monkeypatch.setattr(Fingerprint, "validate_for_build", fake_validate) assert artifact._verify_download( "/tmp/artifact.zip", strict_validation=False ) def test_verify_download_valid_positive_with_rename(artifact, monkeypatch): def fake_md5(cls, fspath): # pylint: disable=unused-argument return "097c42989a9e5d9dcced7b35ec4b0486" monkeypatch.setattr(Artifact, "_md5sum", fake_md5) def fake_poll(cls, tree=None): # pylint: disable=unused-argument return {} monkeypatch.setattr(JenkinsBase, "_poll", fake_poll) def fake_validate(cls, filename, job, build): # pylint: disable=unused-argument # pylint: disable=unused-argument return filename == "artifact.zip" monkeypatch.setattr(Fingerprint, "validate_for_build", fake_validate) assert artifact._verify_download( "/tmp/temporary_filename", strict_validation=False ) def test_verify_download_valid_negative(artifact, monkeypatch): def fake_md5(cls, fspath): # pylint: disable=unused-argument return "097c42989a9e5d9dcced7b35ec4b0486" monkeypatch.setattr(Artifact, "_md5sum", fake_md5) class FakeResponse(object): status_code = 404 text = "{}" class FakeHTTPError(HTTPError): def __init__(self): self.response = FakeResponse() def fake_poll(cls, tree=None): # pylint: disable=unused-argument raise FakeHTTPError() monkeypatch.setattr(JenkinsBase, "_poll", fake_poll) def fake_validate(cls, filename, job, build): # pylint: disable=unused-argument # pylint: disable=unused-argument return True monkeypatch.setattr(Fingerprint, "validate_for_build", fake_validate) assert artifact._verify_download( "/tmp/artifact.zip", strict_validation=False ) def test_verify_dl_valid_negative_strict(artifact, monkeypatch): def fake_md5(cls, fspath): # pylint: disable=unused-argument return "097c42989a9e5d9dcced7b35ec4b0486" monkeypatch.setattr(Artifact, "_md5sum", fake_md5) class FakeResponse(object): status_code = 404 text = "{}" class FakeHTTPError(HTTPError): def __init__(self): self.response = FakeResponse() def fake_poll(cls, tree=None): # pylint: disable=unused-argument raise FakeHTTPError() monkeypatch.setattr(JenkinsBase, "_poll", fake_poll) with pytest.raises(ArtifactBroken) as ab: artifact._verify_download("/tmp/artifact.zip", strict_validation=True) assert ( "Artifact 097c42989a9e5d9dcced7b35ec4b0486 seems to be broken" in str(ab.value) ) def test_verify_download_invalid(artifact, monkeypatch): def fake_md5(cls, fspath): # pylint: disable=unused-argument return "097c42989a9e5d9dcced7b35ec4b0486" monkeypatch.setattr(Artifact, "_md5sum", fake_md5) def fake_poll(cls, tree=None): # pylint: disable=unused-argument return {} monkeypatch.setattr(JenkinsBase, "_poll", fake_poll) def fake_validate(cls, filename, job, build): # pylint: disable=unused-argument # pylint: disable=unused-argument return False monkeypatch.setattr(Fingerprint, "validate_for_build", fake_validate) with pytest.raises(ArtifactBroken) as ab: artifact._verify_download("/tmp/artifact.zip", strict_validation=True) assert ( "Artifact 097c42989a9e5d9dcced7b35ec4b0486 seems to be broken" in str(ab.value) ) class ArtifactTest(unittest.TestCase): def setUp(self): self._build = build = Mock() build.buildno = 9999 job = self._build.job job.jenkins.baseurl = "http://localhost" job.name = "TestJob" self._artifact = Artifact( "artifact.zip", "http://localhost/job/TestJob/9999/artifact/artifact.zip", build, ) @patch("jenkinsapi.artifact.os.path.exists", spec=True, return_value=True) def test_save_has_valid_local_copy(self, mock_exists): artifact = self._artifact artifact._verify_download = Mock(return_value=True) assert artifact.save("/tmp/artifact.zip") == "/tmp/artifact.zip" mock_exists.assert_called_once_with("/tmp/artifact.zip") artifact._verify_download.assert_called_once_with( "/tmp/artifact.zip", False ) @patch("jenkinsapi.artifact.os.path.exists", spec=True, return_value=True) def test_save_has_invalid_local_copy_dl_again(self, mock_exists): artifact = self._artifact artifact._verify_download = Mock(side_effect=[ArtifactBroken, True]) artifact._do_download = Mock(return_value="/tmp/artifact.zip") assert artifact.save("/tmp/artifact.zip", True) == "/tmp/artifact.zip" mock_exists.assert_called_once_with("/tmp/artifact.zip") artifact._do_download.assert_called_once_with("/tmp/artifact.zip") assert ( artifact._verify_download.mock_calls == [call("/tmp/artifact.zip", True)] * 2 ) @patch("jenkinsapi.artifact.os.path.exists", spec=True, return_value=True) def test_has_invalid_lcl_copy_dl_but_invalid(self, mock_exists): artifact = self._artifact artifact._verify_download = Mock( side_effect=[ArtifactBroken, ArtifactBroken] ) artifact._do_download = Mock(return_value="/tmp/artifact.zip") with pytest.raises(ArtifactBroken): artifact.save("/tmp/artifact.zip", True) mock_exists.assert_called_once_with("/tmp/artifact.zip") artifact._do_download.assert_called_once_with("/tmp/artifact.zip") assert ( artifact._verify_download.mock_calls == [call("/tmp/artifact.zip", True)] * 2 ) @patch("jenkinsapi.artifact.os.path.exists", spec=True, return_value=False) def test_save_has_no_local_copy(self, mock_exists): artifact = self._artifact artifact._do_download = Mock(return_value="/tmp/artifact.zip") artifact._verify_download = Mock(return_value=True) assert artifact.save("/tmp/artifact.zip") == "/tmp/artifact.zip" mock_exists.assert_called_once_with("/tmp/artifact.zip") artifact._do_download.assert_called_once_with("/tmp/artifact.zip") artifact._verify_download.assert_called_once_with( "/tmp/artifact.zip", False ) jenkinsapi-0.3.23/jenkinsapi_tests/unittests/test_build.py0000644000000000000000000002452513615410400021011 0ustar00import requests import pytest import pytz from . import configs import datetime import warnings from typing import List from jenkinsapi.build import Build from jenkinsapi.job import Job from jenkinsapi.artifact import Artifact @pytest.fixture(scope="function") def jenkins(mocker): return mocker.MagicMock() @pytest.fixture(scope="function") def job(monkeypatch, jenkins) -> Job: def fake_poll(cls, tree=None): # pylint: disable=unused-argument return configs.JOB_DATA monkeypatch.setattr(Job, "_poll", fake_poll) fake_job: Job = Job("http://", "Fake_Job", jenkins) return fake_job @pytest.fixture(scope="function") def build(job, monkeypatch) -> Build: def fake_poll(cls, tree=None): # pylint: disable=unused-argument return configs.BUILD_DATA monkeypatch.setattr(Build, "_poll", fake_poll) return Build("http://", 97, job) @pytest.fixture(scope="function") def build_pipeline(job, monkeypatch) -> Build: def fake_poll(cls, tree=None): # pylint: disable=unused-argument return configs.BUILD_DATA_PIPELINE monkeypatch.setattr(Build, "_poll", fake_poll) return Build("http://", 97, job) def test_timestamp(build) -> None: assert isinstance(build.get_timestamp(), datetime.datetime) expected: datetime.datetime = pytz.utc.localize( datetime.datetime(2013, 5, 31, 23, 15, 40) ) assert build.get_timestamp() == expected def test_name(build) -> None: with pytest.raises(AttributeError): build.id() assert build.name == "foo #1" def test_duration(build) -> None: expected = datetime.timedelta(milliseconds=5782) assert build.get_duration() == expected assert build.get_duration().seconds == 5 assert build.get_duration().microseconds == 782000 assert str(build.get_duration()) == "0:00:05.782000" def test_get_causes(build) -> None: assert build.get_causes() == [ { "shortDescription": "Started by user anonymous", "userId": None, "userName": "anonymous", "upstreamProject": "parentBuild", "upstreamBuild": 1, } ] def test_get_changeset(build): assert build.get_changeset_items() == [ { "affectedPaths": ["content/rcm/v00-rcm-xccdf.xml"], "author": { "absoluteUrl": "http://jenkins_url/user/username79", "fullName": "username", }, "commitId": "3097", "timestamp": 1414398423091, "date": "2014-10-27T08:27:03.091288Z", "msg": "commit message", "paths": [ {"editType": "edit", "file": "/some/path/of/changed_file"} ], "revision": 3097, "user": "username", } ] def test_get_changeset_pipeline(build_pipeline): assert build_pipeline.get_changeset_items() == [ { "affectedPaths": ["content/rcm/v00-rcm-xccdf.xml"], "author": { "absoluteUrl": "http://jenkins_url/user/username79", "fullName": "username", }, "commitId": "3097", "timestamp": 1414398423091, "date": "2014-10-27T08:27:03.091288Z", "msg": "commit message", "paths": [ {"editType": "edit", "file": "/some/path/of/changed_file"} ], "revision": 3097, "user": "username", } ] def test_get_description(build): assert build.get_description() == "Best build ever!" def test_get_slave(build): assert build.get_slave() == "localhost" def test_get_revision_no_scm(build): """with no scm, get_revision should return None""" assert build.get_revision() is None def test_downstream(build): expected = ["test1", "test2"] assert build.get_downstream_job_names() == expected def test_get_params(build): expected = { "first_param": "first_value", "second_param": "second_value", } build._data = { "actions": [ { "_class": "hudson.model.ParametersAction", "parameters": [ {"name": "first_param", "value": "first_value"}, {"name": "second_param", "value": "second_value"}, ], } ] } params = build.get_params() assert params == expected def test_get_build_url(build): expected = "http://foo/1" build._data = {"url": "http://foo/1"} url = build.get_build_url() assert url == expected def test_get_params_different_order(build): """ Dictionary with `parameters` key is not always the first element in `actions` list, so we need to search through whole array. This test covers such a case """ expected = { "first_param": "first_value", "second_param": "second_value", } build._data = { "actions": [ { "not_parameters": "some_data", }, { "another_action": "some_value", }, { "_class": "hudson.model.ParametersAction", "parameters": [ {"name": "first_param", "value": "first_value"}, {"name": "second_param", "value": "second_value"}, ], }, ] } params = build.get_params() assert params == expected def test_only_ParametersAction_parameters_considered(build): """Actions other than ParametersAction can have dicts called parameters.""" expected = { "param": "value", } build._data = { "actions": [ { "_class": "hudson.model.SomeOtherAction", "parameters": [ {"name": "Not", "value": "OurValue"}, ], }, { "_class": "hudson.model.ParametersAction", "parameters": [ {"name": "param", "value": "value"}, ], }, ] } params = build.get_params() assert params == expected def test_ParametersWithNoValueSetValueNone_issue_583(build): """SecretParameters don't share their value in the API.""" expected = { "some-secret": None, } build._data = { "actions": [ { "_class": "hudson.model.ParametersAction", "parameters": [ {"name": "some-secret"}, ], } ] } params = build.get_params() assert params == expected def test_build_env_vars(monkeypatch, build): def fake_get_data(cls, tree=None, params=None): return configs.BUILD_ENV_VARS monkeypatch.setattr(Build, "get_data", fake_get_data) assert build.get_env_vars() == configs.BUILD_ENV_VARS["envMap"] def test_build_env_vars_wo_injected_env_vars_plugin(monkeypatch, build): def fake_get_data(cls, tree=None, params=None): raise requests.HTTPError("404") monkeypatch.setattr(Build, "get_data", fake_get_data) with pytest.raises(requests.HTTPError) as excinfo: with pytest.warns(UserWarning) as record: build.get_env_vars() assert "404" == str(excinfo.value) assert len(record) == 1 expected = UserWarning( "Make sure the Environment Injector plugin is installed." ) assert str(record[0].message) == str(expected) def test_build_env_vars_other_exception(monkeypatch, build): def fake_get_data(cls, tree=None, params=None): raise ValueError() monkeypatch.setattr(Build, "get_data", fake_get_data) with pytest.raises(Exception) as excinfo: with warnings.catch_warnings(): warnings.simplefilter("error") build.get_env_vars() assert "" == str(excinfo.value) def test_build_get_status(build) -> None: assert build.get_status() == "SUCCESS" def test_build_get_params_return_empty_dict(build) -> None: build._data = {"actions": []} assert build.get_params() == {} def test_build_get_changeset_empty(build) -> None: build._data = {"changeSet": {}} assert build.get_changeset_items() == [] def test_build_get_changesets_vcs(build) -> None: # This test shall test lines 162-164 in build.py build._data = {"changeSets": {"kind": "git"}} assert build._get_vcs() == "git" def test_build_get_number(build) -> None: assert build.get_number() == 1 def test_is_good_returns_true_for_successful_completed_build(build) -> None: assert build.is_good() is True def test_is_good_returns_false_when_running(build, monkeypatch) -> None: monkeypatch.setattr(Build, "is_running", lambda self: True) assert build.is_good() is False def test_is_good_polls_fresh_data(build, monkeypatch) -> None: """is_good() must call _poll() to avoid returning stale cached data.""" build._data = dict(configs.BUILD_DATA, result="FAILURE") assert build.is_good() is True # _poll() refreshes result to SUCCESS def test_build_get_artifacts(build) -> None: afs: List[Artifact] = list(build.get_artifacts()) assert len(afs) == 1 assert isinstance(afs[0], Artifact) assert list(afs)[0].filename == "foo.txt" def test_build_get_upstream_job_name(build) -> None: assert build.get_upstream_job_name() == "parentBuild" def test_build_get_ustream_job_name_none(build) -> None: build._data = {"actions": []} assert build.get_upstream_job_name() is None def test_build_get_master_job_name(build) -> None: assert build.get_master_job_name() == "masterBuild" def test_build_get_master_build_number(build) -> None: assert build.get_master_build_number() == 1 def test_get_stages_pipeline(build_pipeline) -> None: stages = build_pipeline.get_stages() assert isinstance(stages, list) assert len(stages) == 2 assert stages[0]["name"] == "Build" assert stages[0]["status"] == "SUCCESS" assert stages[0]["id"] == "stage-id-1" assert stages[0]["durationMillis"] == 5000 assert stages[1]["name"] == "Test" assert stages[1]["status"] == "SUCCESS" assert stages[1]["id"] == "stage-id-2" assert stages[1]["durationMillis"] == 3000 def test_get_stages_non_pipeline(build) -> None: stages = build.get_stages() assert isinstance(stages, list) assert stages == [] def test_get_stages_missing_data(build) -> None: build._data = {} stages = build.get_stages() assert isinstance(stages, list) assert stages == [] jenkinsapi-0.3.23/jenkinsapi_tests/unittests/test_build_scm_git.py0000644000000000000000000000324513615410400022512 0ustar00import pytest from . import configs from jenkinsapi.build import Build from jenkinsapi.job import Job @pytest.fixture(scope="function") def jenkins(mocker): return mocker.MagicMock() @pytest.fixture(scope="function") def job(monkeypatch, jenkins): def fake_poll(cls, tree=None): # pylint: disable=unused-argument return configs.JOB_DATA monkeypatch.setattr(Job, "_poll", fake_poll) fake_job = Job("http://", "Fake_Job", jenkins) return fake_job @pytest.fixture(scope="function") def build(job, monkeypatch): def fake_poll(cls, tree=None): # pylint: disable=unused-argument return configs.BUILD_SCM_DATA monkeypatch.setattr(Build, "_poll", fake_poll) return Build("http://", 97, job) def test_git_scm(build): """ Can we extract git build revision data from a build object? """ assert isinstance(build.get_revision(), str) assert build.get_revision() == "7def9ed6e92580f37d00e4980c36c4d36e68f702" def test_git_revision_branch(build): """ Can we extract git build branch from a build object? """ assert isinstance(build.get_revision_branch(), list) assert len(build.get_revision_branch()) == 1 assert isinstance(build.get_revision_branch()[0], dict) assert ( build.get_revision_branch()[0]["SHA1"] == "7def9ed6e92580f37d00e4980c36c4d36e68f702" ) assert build.get_revision_branch()[0]["name"] == "origin/unstable" def test_git_repo_url(build): """ Can we Extract git repo url for a given build """ assert isinstance(build.get_repo_url(), str) assert ( build.get_repo_url() == "https://github.com/salimfadhley/jenkinsapi.git" ) jenkinsapi-0.3.23/jenkinsapi_tests/unittests/test_command_line.py0000644000000000000000000000553313615410400022335 0ustar00import sys from types import SimpleNamespace from unittest.mock import Mock, call import pytest from jenkinsapi.command_line.jenkins_invoke import JenkinsInvoke def test_main_requires_at_least_one_job(monkeypatch, capsys): monkeypatch.setattr(sys, "argv", ["jenkins_invoke"]) with pytest.raises(SystemExit) as exc: JenkinsInvoke.main() assert exc.value.code == 2 captured = capsys.readouterr() assert "the following arguments are required: job" in captured.err def test_help_text_is_actionable(capsys): parser = JenkinsInvoke.mkparser() with pytest.raises(SystemExit) as exc: parser.parse_args(["--help"]) assert exc.value.code == 0 output = capsys.readouterr().out assert "Execute one or more Jenkins jobs" in output assert "One or more Jenkins job names to invoke." in output def test_parser_uses_environment_defaults(monkeypatch): monkeypatch.setenv("JENKINS_URL", "https://jenkins.example") monkeypatch.setenv("JENKINS_USERNAME", "env-user") monkeypatch.setenv("JENKINS_API_TOKEN", "env-token") parser = JenkinsInvoke.mkparser() options = parser.parse_args(["job-one"]) assert options.baseurl == "https://jenkins.example" assert options.username == "env-user" assert options.password == "env-token" def test_resolve_password_prompts_in_interactive_terminal(): options = SimpleNamespace(username="user", password=None) prompt = Mock(return_value="prompted-token") resolved = JenkinsInvoke.resolve_password(options, True, prompt) assert resolved.password == "prompted-token" prompt.assert_called_once_with("Jenkins password or API token: ") def test_main_errors_for_non_interactive_missing_password(monkeypatch, capsys): monkeypatch.setattr( sys, "argv", ["jenkins_invoke", "--username", "user", "job-one"] ) class NonInteractiveStdin(object): @staticmethod def isatty(): return False monkeypatch.setattr(sys, "stdin", NonInteractiveStdin()) with pytest.raises(SystemExit) as exc: JenkinsInvoke.main() assert exc.value.code == 2 captured = capsys.readouterr() assert "Password/API token missing." in captured.err def test_invokes_each_requested_job(monkeypatch): options = SimpleNamespace( baseurl="http://localhost:8080", username="user", password="token", block=True, token="job-token", ) job = Mock() api = Mock() api.get_job.return_value = job monkeypatch.setattr(JenkinsInvoke, "_get_api", Mock(return_value=api)) invoker = JenkinsInvoke(options, ["job-one", "job-two"]) invoker() assert api.get_job.call_args_list == [call("job-one"), call("job-two")] assert job.invoke.call_args_list == [ call(securitytoken="job-token", block=True), call(securitytoken="job-token", block=True), ] jenkinsapi-0.3.23/jenkinsapi_tests/unittests/test_credentials.py0000644000000000000000000000161713615410400022204 0ustar00import base64 from jenkinsapi.credential import DockerServerCredentials from jenkinsapi.credential import FileCredentials from jenkinsapi.credentials import Credentials def test_make_credential_recognizes_docker_x509_type(): creds = object.__new__(Credentials) credential = creds._make_credential( { "description": "docker cert", "typeName": "X.509 Client Certificate", } ) assert isinstance(credential, DockerServerCredentials) def test_file_credentials_create_files_payload_decodes_secret_bytes(): secret_bytes = base64.b64encode(b"secret payload").decode("ascii") credential = FileCredentials( { "description": "file cred", "filename": "secret.txt", "secret_bytes": secret_bytes, } ) assert credential.get_files() == { "file0": ("secret.txt", b"secret payload") } jenkinsapi-0.3.23/jenkinsapi_tests/unittests/test_docker_jenkins.py0000644000000000000000000001531413615410400022676 0ustar00"""Tests for Docker-based Jenkins (requires Docker running).""" import io import os import logging import tarfile import pytest from jenkinsapi.jenkins import Jenkins from jenkinsapi.utils.docker_jenkins import ( DEFAULT_IMAGE_NAME, DockerJenkins, DockerJenkinsError, ) from jenkinsapi_tests.unittests.conftest_helpers import ( parse_worker_id, generate_container_name, find_free_port, ) log = logging.getLogger(__name__) pytestmark = pytest.mark.docker def _docker_client_or_skip(): import docker as docker_lib try: client = docker_lib.from_env() client.ping() return client except docker_lib.errors.DockerException as exc: pytest.skip(f"Docker daemon unavailable: {exc}") @pytest.fixture(scope="session") def docker_image(worker_id): """Build the shared Docker image once per test session.""" import docker as docker_lib worker_num, display_name = parse_worker_id(worker_id) repo_root = os.path.dirname( os.path.dirname(os.path.dirname(os.path.abspath(__file__))) ) dockerfile_path = os.path.join(repo_root, "docker", "Dockerfile") client = _docker_client_or_skip() try: client.images.get(DEFAULT_IMAGE_NAME) log.info("[%s] Docker image already exists", display_name) return DEFAULT_IMAGE_NAME except docker_lib.errors.ImageNotFound: pass docker_jenkins = DockerJenkins( image_name=DEFAULT_IMAGE_NAME, container_name="jenkinsapi-test-build", port=9999, ) if worker_num == 0: log.info("[%s] Building Docker image...", display_name) docker_jenkins.build_image(dockerfile_path) return docker_jenkins.image_name log.info("[%s] Waiting for image from worker-0...", display_name) import time start = time.time() while time.time() - start < 300: try: client.images.get(docker_jenkins.image_name) return docker_jenkins.image_name except docker_lib.errors.ImageNotFound: time.sleep(1) raise DockerJenkinsError("Timeout waiting for image to be built") @pytest.fixture(scope="session") def docker_jenkins(docker_image, worker_id): """Start one Jenkins container per xdist worker.""" worker_num, display_name = parse_worker_id(worker_id) base_port = 8080 if worker_num == 0 else 8100 + (worker_num - 1) * 10 port = find_free_port(start_port=base_port) container_name = generate_container_name( worker_id, base_name="jenkinsapi-systest" ) log.info( "[%s] Starting container %s on port %d...", display_name, container_name, port, ) dj = DockerJenkins( image_name=docker_image, container_name=container_name, port=port, ) try: dj.start(timeout=300) yield dj finally: try: dj.stop() except Exception as e: log.warning("[%s] Error during cleanup: %s", display_name, e) @pytest.fixture def jenkins_client(docker_jenkins): return Jenkins(docker_jenkins.jenkins_url, timeout=60) class TestDockerJenkins: def test_dump_plugin_versions_extracts_text_file(self, tmp_path): plugin_text = "Jenkins Plugins\nworkflow-job:latest\n" archive_buffer = io.BytesIO() with tarfile.open(fileobj=archive_buffer, mode="w") as archive: member = tarfile.TarInfo(name="plugin_versions.txt") member.size = len(plugin_text.encode()) archive.addfile(member, io.BytesIO(plugin_text.encode())) class FakeResult: exit_code = 0 output = b"" class FakeContainer: def exec_run(self, command): assert command == ( "/usr/local/bin/dump-plugin-versions plugin_versions.txt" ) return FakeResult() def get_archive(self, path): assert path == "plugin_versions.txt" return iter([archive_buffer.getvalue()]), {} docker_jenkins = DockerJenkins.__new__(DockerJenkins) docker_jenkins.container = FakeContainer() output_file = tmp_path / "plugin_versions.txt" result = docker_jenkins.dump_plugin_versions(str(output_file)) assert result == str(output_file) assert output_file.read_text() == plugin_text def test_container_starts_successfully(self, docker_jenkins): assert docker_jenkins.container is not None assert docker_jenkins.jenkins_url.startswith("http://localhost:") def test_jenkins_api_accessible(self, jenkins_client): assert jenkins_client.version is not None def test_get_jenkins_info(self, jenkins_client): info = jenkins_client._poll() assert "_class" in info assert "jobs" in info def test_dump_plugin_versions(self, docker_jenkins, tmp_path): output_file = str(tmp_path / "plugin_versions.txt") result = docker_jenkins.dump_plugin_versions(output_file) assert os.path.exists(result) with open(result) as f: content = f.read() assert "Jenkins Plugins" in content def test_restart_jenkins_service(self, docker_jenkins, jenkins_client): initial_version = jenkins_client.version docker_jenkins.restart_jenkins_service(timeout=120) assert ( Jenkins(docker_jenkins.jenkins_url, timeout=60).version == initial_version ) def test_jenkins_available_after_restart(self, docker_jenkins): docker_jenkins.restart_jenkins_service(timeout=120) info = Jenkins(docker_jenkins.jenkins_url, timeout=60)._poll() assert info.get("_class") == "hudson.model.Hudson" def test_container_logs(self, docker_jenkins): logs = docker_jenkins.get_logs(lines=50) assert len(logs) > 0 assert "Jenkins" in logs or "supervisord" in logs class TestDockerJenkinsJobCreation: def test_create_job(self, jenkins_client): job_config = """ Test job false true false false false false """ jenkins_client.create_job("test-job", job_config) assert "test-job" in jenkins_client.keys() del jenkins_client["test-job"] def test_list_jobs(self, jenkins_client): jobs = jenkins_client.keys() assert len(jobs) == 0 or isinstance(jobs, list) jenkinsapi-0.3.23/jenkinsapi_tests/unittests/test_docs_examples.py0000644000000000000000000000065413615410400022535 0ustar00from pathlib import Path def _assert_python_files_compile(directory: str) -> None: for path in Path(directory).glob("*.py"): source = path.read_text(encoding="utf-8") compile(source, str(path), "exec") def test_how_to_examples_are_valid_python(): _assert_python_files_compile("examples/how_to") def test_low_level_examples_are_valid_python(): _assert_python_files_compile("examples/low_level") jenkinsapi-0.3.23/jenkinsapi_tests/unittests/test_executors.py0000644000000000000000000002040713615410400021726 0ustar00import pytest import mock from jenkinsapi.jenkins import Jenkins from jenkinsapi.executors import Executors from jenkinsapi.executor import Executor DATAM = { "assignedLabels": [{}], "description": None, "jobs": [], "mode": "NORMAL", "nodeDescription": "the master Jenkins node", "nodeName": "", "numExecutors": 2, "overallLoad": {}, "primaryView": {"name": "All", "url": "http://localhost:8080/"}, "quietingDown": False, "slaveAgentPort": 0, "unlabeledLoad": {}, "useCrumbs": False, "useSecurity": False, "views": [ {"name": "All", "url": "http://localhost:8080/"}, {"name": "BigMoney", "url": "http://localhost:8080/view/BigMoney/"}, ], } DATA0 = { "actions": [], "displayName": "host0.host.com", "executors": [{}, {}, {}, {}, {}, {}, {}, {}], "icon": "computer.png", "idle": False, "jnlpAgent": True, "launchSupported": False, "loadStatistics": {}, "manualLaunchAllowed": True, "monitorData": { "hudson.node_monitors.SwapSpaceMonitor": { "availablePhysicalMemory": 8462417920, "availableSwapSpace": 0, "totalPhysicalMemory": 75858042880, "totalSwapSpace": 0, }, "hudson.node_monitors.ArchitectureMonitor": "Linux (amd64)", "hudson.node_monitors.ResponseTimeMonitor": {"average": 2}, "hudson.node_monitors.TemporarySpaceMonitor": { "path": "/tmp", "size": 430744551424, }, "hudson.node_monitors.DiskSpaceMonitor": { "path": "/data/jenkins", "size": 1214028627968, }, "hudson.node_monitors.ClockMonitor": {"diff": 1}, }, "numExecutors": 8, "offline": False, "offlineCause": None, "offlineCauseReason": "", "oneOffExecutors": [{}, {}], "temporarilyOffline": False, } DATA1 = { "actions": [], "displayName": "host1.host.com", "executors": [{}, {}], "icon": "computer.png", "idle": False, "jnlpAgent": True, "launchSupported": False, "loadStatistics": {}, "manualLaunchAllowed": True, "monitorData": { "hudson.node_monitors.SwapSpaceMonitor": { "availablePhysicalMemory": 8462417920, "availableSwapSpace": 0, "totalPhysicalMemory": 75858042880, "totalSwapSpace": 0, }, "hudson.node_monitors.ArchitectureMonitor": "Linux (amd64)", "hudson.node_monitors.ResponseTimeMonitor": {"average": 2}, "hudson.node_monitors.TemporarySpaceMonitor": { "path": "/tmp", "size": 430744551424, }, "hudson.node_monitors.DiskSpaceMonitor": { "path": "/data/jenkins", "size": 1214028627968, }, "hudson.node_monitors.ClockMonitor": {"diff": 1}, }, "numExecutors": 2, "offline": False, "offlineCause": None, "offlineCauseReason": "", "oneOffExecutors": [{}, {}], "temporarilyOffline": False, } DATA2 = { "actions": [], "displayName": "host2.host.com", "executors": [{}, {}, {}, {}], "icon": "computer.png", "idle": False, "jnlpAgent": True, "launchSupported": False, "loadStatistics": {}, "manualLaunchAllowed": True, "monitorData": { "hudson.node_monitors.SwapSpaceMonitor": { "availablePhysicalMemory": 8462417920, "availableSwapSpace": 0, "totalPhysicalMemory": 75858042880, "totalSwapSpace": 0, }, "hudson.node_monitors.ArchitectureMonitor": "Linux (amd64)", "hudson.node_monitors.ResponseTimeMonitor": {"average": 2}, "hudson.node_monitors.TemporarySpaceMonitor": { "path": "/tmp", "size": 430744551424, }, "hudson.node_monitors.DiskSpaceMonitor": { "path": "/data/jenkins", "size": 1214028627968, }, "hudson.node_monitors.ClockMonitor": {"diff": 1}, }, "numExecutors": 4, "offline": False, "offlineCause": None, "offlineCauseReason": "", "oneOffExecutors": [{}, {}], "temporarilyOffline": False, } DATA3 = { "actions": [], "displayName": "host3.host.com", "executors": [{}], "icon": "computer.png", "idle": False, "jnlpAgent": True, "launchSupported": False, "loadStatistics": {}, "manualLaunchAllowed": True, "monitorData": { "hudson.node_monitors.SwapSpaceMonitor": { "availablePhysicalMemory": 8462417920, "availableSwapSpace": 0, "totalPhysicalMemory": 75858042880, "totalSwapSpace": 0, }, "hudson.node_monitors.ArchitectureMonitor": "Linux (amd64)", "hudson.node_monitors.ResponseTimeMonitor": {"average": 2}, "hudson.node_monitors.TemporarySpaceMonitor": { "path": "/tmp", "size": 430744551424, }, "hudson.node_monitors.DiskSpaceMonitor": { "path": "/data/jenkins", "size": 1214028627968, }, "hudson.node_monitors.ClockMonitor": {"diff": 1}, }, "numExecutors": 1, "offline": False, "offlineCause": None, "offlineCauseReason": "", "oneOffExecutors": [{}, {}], "temporarilyOffline": False, } EXEC0 = { "currentExecutable": { "number": 4168, "url": "http://localhost:8080/job/testjob/4168/", }, "currentWorkUnit": {}, "idle": False, "likelyStuck": False, "number": 0, "progress": 48, } EXEC1 = { "currentExecutable": None, "currentWorkUnit": None, "idle": True, "likelyStuck": False, "number": 0, "progress": -1, } @pytest.fixture(scope="function") def jenkins(monkeypatch): def fake_poll(cls, tree=None): # pylint: disable=unused-argument return DATAM monkeypatch.setattr(Jenkins, "_poll", fake_poll) return Jenkins("http://localhost:8080") def test_repr(jenkins): # Can we produce a repr string for this object assert repr(jenkins) def test_check_url(jenkins): assert jenkins.baseurl == "http://localhost:8080" def test_get_executors(jenkins, monkeypatch): def fake_poll_extr(cls, tree=None): # pylint: disable=unused-argument return EXEC0 def fake_poll_extrs(cls, tree=None): # pylint: disable=unused-argument return DATA3 monkeypatch.setattr(Executor, "_poll", fake_poll_extr) monkeypatch.setattr(Executors, "_poll", fake_poll_extrs) exec_info = jenkins.get_executors(DATA3["displayName"]) assert isinstance(exec_info, object) assert isinstance(repr(exec_info), str) for ex in exec_info: assert ex.get_progress() == 48, "Should return 48 %" def testis_idle(jenkins, monkeypatch): def fake_poll_extr(cls, tree=None): # pylint: disable=unused-argument return EXEC1 def fake_poll_extrs(cls, tree=None): # pylint: disable=unused-argument return DATA3 monkeypatch.setattr(Executor, "_poll", fake_poll_extr) monkeypatch.setattr(Executors, "_poll", fake_poll_extrs) exec_info = jenkins.get_executors("host3.host.com") assert isinstance(exec_info, object) for ex in exec_info: assert ex.get_progress() == -1, "Should return 48 %" assert ex.is_idle() is True, "Should return True" assert repr(ex) == "" @mock.patch.object(Executor, "_poll") def test_likely_stuck(jenkins, monkeypatch): def fake_poll_extr(cls, tree=None): # pylint: disable=unused-argument return EXEC0 monkeypatch.setattr(Executor, "_poll", fake_poll_extr) baseurl = "http://localhost:8080/computer/host0.host.com/executors/0" nodename = "host0.host.com" single_executer = Executor(baseurl, nodename, jenkins, "0") assert single_executer.likely_stuck() is False def test_get_current_executable(jenkins, monkeypatch): def fake_poll_extr(cls, tree=None): # pylint: disable=unused-argument return EXEC0 monkeypatch.setattr(Executor, "_poll", fake_poll_extr) baseurl = "http://localhost:8080/computer/host0.host.com/executors/0" nodename = "host0.host.com" single_executer = Executor(baseurl, nodename, jenkins, "0") assert single_executer.get_current_executable()["number"] == 4168 assert ( single_executer.get_current_executable()["url"] == "http://localhost:8080/job/testjob/4168/" ) jenkinsapi-0.3.23/jenkinsapi_tests/unittests/test_fingerprint.py0000644000000000000000000000560413615410400022236 0ustar00import pytest import hashlib from jenkinsapi.jenkins import Jenkins from jenkinsapi.jenkinsbase import JenkinsBase from jenkinsapi.fingerprint import Fingerprint from jenkinsapi.utils.requester import Requester from requests.exceptions import HTTPError @pytest.fixture(scope="function") def jenkins(monkeypatch): def fake_poll(cls, tree=None): # pylint: disable=unused-argument return {} monkeypatch.setattr(Jenkins, "_poll", fake_poll) return Jenkins( "http://localhost:8080", username="foouser", password="foopassword" ) @pytest.fixture(scope="module") def dummy_md5(): md = hashlib.md5() md.update("some dummy string".encode("ascii")) return md.hexdigest() def test_object_creation(jenkins, dummy_md5, monkeypatch): def fake_poll(cls, tree=None): # pylint: disable=unused-argument return {} monkeypatch.setattr(JenkinsBase, "_poll", fake_poll) fp_instance = Fingerprint("http://foo:8080", dummy_md5, jenkins) assert isinstance(fp_instance, Fingerprint) assert str(fp_instance) == dummy_md5 assert fp_instance.valid() def test_valid_for_404(jenkins, dummy_md5, monkeypatch): class FakeResponse(object): status_code = 404 text = "{}" class FakeHTTPError(HTTPError): def __init__(self): self.response = FakeResponse() def fake_poll(cls, tree=None): # pylint: disable=unused-argument raise FakeHTTPError() monkeypatch.setattr(JenkinsBase, "_poll", fake_poll) def fake_get_url( url, # pylint: disable=unused-argument params=None, # pylint: disable=unused-argument headers=None, # pylint: disable=unused-argument allow_redirects=True, # pylint: disable=unused-argument stream=False, ): # pylint: disable=unused-argument return FakeResponse() monkeypatch.setattr(Requester, "get_url", fake_get_url) fingerprint = Fingerprint("http://foo:8080", dummy_md5, jenkins) assert fingerprint.valid() is True def test_invalid_for_401(jenkins, dummy_md5, monkeypatch): class FakeResponse(object): status_code = 401 text = "{}" class FakeHTTPError(HTTPError): def __init__(self): self.response = FakeResponse() def fake_poll(cls, tree=None): # pylint: disable=unused-argument raise FakeHTTPError() monkeypatch.setattr(JenkinsBase, "_poll", fake_poll) def fake_get_url( url, # pylint: disable=unused-argument params=None, # pylint: disable=unused-argument headers=None, # pylint: disable=unused-argument allow_redirects=True, # pylint: disable=unused-argument stream=False, ): # pylint: disable=unused-argument return FakeResponse() monkeypatch.setattr(Requester, "get_url", fake_get_url) fingerprint = Fingerprint("http://foo:8080", dummy_md5, jenkins) assert fingerprint.valid() is not True jenkinsapi-0.3.23/jenkinsapi_tests/unittests/test_jenkins.py0000644000000000000000000003044713615410400021353 0ustar00import pytest from collections import namedtuple import jenkinsapi from jenkinsapi.plugins import Plugins from jenkinsapi.utils.requester import Requester from jenkinsapi.jenkins import Jenkins from jenkinsapi.jenkinsbase import JenkinsBase from jenkinsapi.job import Job from jenkinsapi.custom_exceptions import JenkinsAPIException DATA = {} TWO_JOBS_DATA = { "jobs": [ { "name": "job_one", "url": "http://localhost:8080/job/job_one", "color": "blue", }, { "name": "job_two", "url": "http://localhost:8080/job/job_two", "color": "blue", }, ] } MULTIBRANCH_JOBS_DATA = { "jobs": [ { "name": "multibranch-repo/master", "url": "http://localhost:8080/job/multibranch-repo/job/master", "color": "blue", }, { "name": "multibranch-repo/develop", "url": "http://localhost:8080/job/multibranch-repo/job/develop", "color": "blue", }, ] } SCAN_MULTIBRANCH_PIPELINE_LOG = """ Started by timer [Fri Jul 05 06:46:00 CEST 2019] Starting branch indexing... Connecting to https://stash.macq.eu using Jenkins/****** (jenkins-ldap) Repository type: Git Looking up internal/base for branches Checking branch master from internal/base 'Jenkinsfile' found Met criteria No changes detected: master (still at 26d4d8a673f57a957fd5a23f5adfe0be02089294) 1 branches were processed Looking up internal/base for pull requests 0 pull requests were processed [Fri Jul 05 06:46:01 CEST 2019] Finished branch indexing. Indexing took 1.1 sec Finished: SUCCESS """ @pytest.fixture(scope="function") def jenkins(monkeypatch): def fake_poll(cls, tree=None): # pylint: disable=unused-argument return {} monkeypatch.setattr(Jenkins, "_poll", fake_poll) return Jenkins( "http://localhost:8080", username="foouser", password="foopassword" ) def test__clone(jenkins): cloned = jenkins._clone() assert id(cloned) != id(jenkins) assert cloned == jenkins def test_stored_passwords(jenkins): assert jenkins.requester.password == "foopassword" assert jenkins.requester.username == "foouser" def test_reload(monkeypatch): class FakeResponse(object): status_code = 200 text = "{}" def fake_get_url( url, # pylint: disable=unused-argument params=None, # pylint: disable=unused-argument headers=None, # pylint: disable=unused-argument allow_redirects=True, # pylint: disable=unused-argument stream=False, ): # pylint: disable=unused-argument return FakeResponse() monkeypatch.setattr(Requester, "get_url", fake_get_url) mock_requester = Requester(username="foouser", password="foopassword") jenkins = Jenkins( "http://localhost:8080/", username="foouser", password="foopassword", requester=mock_requester, ) jenkins.poll() def test_get_jobs_list(monkeypatch): def fake_jenkins_poll(cls, tree=None): # pylint: disable=unused-argument return TWO_JOBS_DATA def fake_job_poll(cls, tree=None): # pylint: disable=unused-argument return {} monkeypatch.setattr(JenkinsBase, "_poll", fake_jenkins_poll) monkeypatch.setattr(Jenkins, "_poll", fake_jenkins_poll) monkeypatch.setattr(Job, "_poll", fake_job_poll) jenkins = Jenkins( "http://localhost:8080/", username="foouser", password="foopassword" ) for idx, job_name in enumerate(jenkins.get_jobs_list()): assert job_name == TWO_JOBS_DATA["jobs"][idx]["name"] for idx, job_name in enumerate(jenkins.jobs.keys()): assert job_name == TWO_JOBS_DATA["jobs"][idx]["name"] def test_create_new_job_fail(mocker, monkeypatch): def fake_jenkins_poll(cls, tree=None): # pylint: disable=unused-argument return TWO_JOBS_DATA def fake_job_poll(cls, tree=None): # pylint: disable=unused-argument return {} monkeypatch.setattr(JenkinsBase, "_poll", fake_jenkins_poll) monkeypatch.setattr(Jenkins, "_poll", fake_jenkins_poll) monkeypatch.setattr(Job, "_poll", fake_job_poll) mock_requester = Requester(username="foouser", password="foopassword") mock_requester.post_xml_and_confirm_status = mocker.MagicMock( return_value="" ) jenkins = Jenkins( "http://localhost:8080/", username="foouser", password="foopassword", requester=mock_requester, ) with pytest.raises(JenkinsAPIException) as ar: jenkins.create_job("job_new", None) assert "Job XML config cannot be empty" in str(ar.value) def test_create_multibranch_pipeline_job(mocker, monkeypatch): def fake_jenkins_poll(cls, tree=None): # pylint: disable=unused-argument # return multibranch jobs and other jobs. # create_multibranch_pipeline_job is supposed to filter out # the MULTIBRANCH jobs return {"jobs": TWO_JOBS_DATA["jobs"] + MULTIBRANCH_JOBS_DATA["jobs"]} def fake_job_poll(cls, tree=None): # pylint: disable=unused-argument return {} monkeypatch.setattr(JenkinsBase, "_poll", fake_jenkins_poll) monkeypatch.setattr(Jenkins, "_poll", fake_jenkins_poll) monkeypatch.setattr(Job, "_poll", fake_job_poll) mock_requester = Requester(username="foouser", password="foopassword") mock_requester.post_xml_and_confirm_status = mocker.MagicMock( return_value="" ) mock_requester.post_and_confirm_status = mocker.MagicMock(return_value="") get_response = namedtuple("get_response", "text") mock_requester.get_url = mocker.MagicMock( return_value=get_response(text=SCAN_MULTIBRANCH_PIPELINE_LOG) ) jenkins = Jenkins( "http://localhost:8080/", username="foouser", password="foopassword", requester=mock_requester, ) jobs = jenkins.create_multibranch_pipeline_job( "multibranch-repo", "multibranch-xml-content" ) for idx, job_instance in enumerate(jobs): assert job_instance.name == MULTIBRANCH_JOBS_DATA["jobs"][idx]["name"] # make sure we didn't get more jobs. assert len(MULTIBRANCH_JOBS_DATA["jobs"]) == len(jobs) def test_get_jenkins_obj_from_url(mocker, monkeypatch): def fake_jenkins_poll(cls, tree=None): # pylint: disable=unused-argument return TWO_JOBS_DATA def fake_job_poll(cls, tree=None): # pylint: disable=unused-argument return {} monkeypatch.setattr(JenkinsBase, "_poll", fake_jenkins_poll) monkeypatch.setattr(Jenkins, "_poll", fake_jenkins_poll) monkeypatch.setattr(Job, "_poll", fake_job_poll) mock_requester = Requester(username="foouser", password="foopassword") mock_requester.post_xml_and_confirm_status = mocker.MagicMock( return_value="" ) jenkins = Jenkins( "http://localhost:8080/", username="foouser", password="foopassword", requester=mock_requester, ) new_jenkins = jenkins.get_jenkins_obj_from_url("http://localhost:8080/") assert new_jenkins == jenkins new_jenkins = jenkins.get_jenkins_obj_from_url("http://localhost:8080/foo") assert new_jenkins != jenkins def test_get_jenkins_obj(mocker, monkeypatch): def fake_jenkins_poll(cls, tree=None): # pylint: disable=unused-argument return TWO_JOBS_DATA def fake_job_poll(cls, tree=None): # pylint: disable=unused-argument return {} monkeypatch.setattr(JenkinsBase, "_poll", fake_jenkins_poll) monkeypatch.setattr(Jenkins, "_poll", fake_jenkins_poll) monkeypatch.setattr(Job, "_poll", fake_job_poll) mock_requester = Requester(username="foouser", password="foopassword") mock_requester.post_xml_and_confirm_status = mocker.MagicMock( return_value="" ) jenkins = Jenkins( "http://localhost:8080/", username="foouser", password="foopassword", requester=mock_requester, ) new_jenkins = jenkins.get_jenkins_obj() assert new_jenkins == jenkins def test_get_version(monkeypatch): class MockResponse(object): def __init__(self): self.headers = {} self.headers["X-Jenkins"] = "1.542" def fake_poll(cls, tree=None): # pylint: disable=unused-argument return {} monkeypatch.setattr(Jenkins, "_poll", fake_poll) def fake_get(cls, *arga, **kwargs): # pylint: disable=unused-argument return MockResponse() monkeypatch.setattr(Requester, "get_and_confirm_status", fake_get) jenkins = Jenkins( "http://foobar:8080/", username="foouser", password="foopassword" ) assert jenkins.version == "1.542" def test_get_version_nonexistent(mocker): class MockResponse(object): status_code = 200 headers = {} text = "{}" mock_requester = Requester(username="foouser", password="foopassword") mock_requester.get_url = mocker.MagicMock(return_value=MockResponse()) jenkins = Jenkins( "http://localhost:8080", username="foouser", password="foopassword", requester=mock_requester, ) assert jenkins.version == "0.0" def test_get_master_data(mocker): class MockResponse(object): status_code = 200 headers = {} text = "{}" mock_requester = Requester(username="foouser", password="foopassword") mock_requester.get_url = mocker.MagicMock(return_value=MockResponse()) jenkins = Jenkins( "http://localhost:808", username="foouser", password="foopassword", requester=mock_requester, ) jenkins.get_data = mocker.MagicMock( return_value={"busyExecutors": 59, "totalExecutors": 75} ) data = jenkins.get_master_data() assert data["busyExecutors"] == 59 assert data["totalExecutors"] == 75 def test_get_create_url(monkeypatch): def fake_poll(cls, tree=None): # pylint: disable=unused-argument return {} monkeypatch.setattr(Jenkins, "_poll", fake_poll) # Jenkins URL w/o slash jenkins = Jenkins( "http://localhost:8080", username="foouser", password="foopassword" ) assert jenkins.get_create_url() == "http://localhost:8080/createItem" # Jenkins URL w/ slash jenkins = Jenkins( "http://localhost:8080/", username="foouser", password="foopassword" ) assert jenkins.get_create_url() == "http://localhost:8080/createItem" def test_has_plugin(monkeypatch): def fake_poll(cls, tree=None): # pylint: disable=unused-argument return {} monkeypatch.setattr(Jenkins, "_poll", fake_poll) def fake_plugin_poll(cls, tree=None): # pylint: disable=unused-argument return { "plugins": [ { "deleted": False, "hasUpdate": True, "downgradable": False, "dependencies": [{}, {}, {}, {}], "longName": "Jenkins Subversion Plug-in", "active": True, "shortName": "subversion", "backupVersion": None, "url": "http://wiki.jenkins-ci.org/" "display/JENKINS/Subversion+Plugin", "enabled": True, "pinned": False, "version": "1.45", "supportsDynamicLoad": "MAYBE", "bundled": True, } ] } monkeypatch.setattr(Plugins, "_poll", fake_plugin_poll) jenkins = Jenkins( "http://localhost:8080/", username="foouser", password="foopassword" ) assert jenkins.has_plugin("subversion") is True def test_get_use_auth_cookie(mocker, monkeypatch): COOKIE_VALUE = "FAKE_COOKIE" def fake_opener(redirect_handler): # pylint: disable=unused-argument mock_response = mocker.MagicMock() mock_response.cookie = COOKIE_VALUE mock_opener = mocker.MagicMock() mock_opener.open.return_value = mock_response return mock_opener def fake_poll(cls, tree=None): # pylint: disable=unused-argument return {} monkeypatch.setattr(Jenkins, "_poll", fake_poll) monkeypatch.setattr(Requester, "AUTH_COOKIE", None) monkeypatch.setattr(jenkinsapi.jenkins, "build_opener", fake_opener) jenkins = Jenkins( "http://localhost:8080", username="foouser", password="foopassword" ) jenkins.use_auth_cookie() assert Requester.AUTH_COOKIE == COOKIE_VALUE jenkinsapi-0.3.23/jenkinsapi_tests/unittests/test_job.py0000644000000000000000000003171313615410400020461 0ustar00# -*- coding: utf-8 -*- import pytest import mock import json from . import configs from jenkinsapi.job import Job from jenkinsapi.build import Build from jenkinsapi.jenkins import Jenkins from jenkinsapi.jenkinsbase import JenkinsBase from jenkinsapi.custom_exceptions import NoBuildData from jenkinsapi.credentials import Credentials2x @pytest.fixture(scope="function") def jenkins(monkeypatch): def fake_poll(cls, tree=None): # pylint: disable=unused-argument return {} monkeypatch.setattr(Jenkins, "_poll", fake_poll) new_jenkins = Jenkins("http://halob:8080/") return new_jenkins @pytest.fixture(scope="function") def job(jenkins, monkeypatch): def fake_get_data(cls, url, tree=None): # pylint: disable=unused-argument return configs.JOB_DATA monkeypatch.setattr(JenkinsBase, "get_data", fake_get_data) new_job = Job("http://halob:8080/job/foo/", "foo", jenkins) return new_job @pytest.fixture(scope="function") def job_tree(jenkins, monkeypatch): def fake_get_data(cls, url, tree=None): # pylint: disable=unused-argument if tree is not None and "builds" in tree: return {"builds": configs.JOB_DATA["builds"]} else: return {"lastBuild": configs.JOB_DATA["lastBuild"]} monkeypatch.setattr(Job, "get_data", fake_get_data) new_job = Job("http://halob:8080/job/foo/", "foo", jenkins) return new_job @pytest.fixture(scope="function") def job_tree_empty(jenkins, monkeypatch): def fake_get_data(cls, url, tree=None): # pylint: disable=unused-argument return {} monkeypatch.setattr(Job, "get_data", fake_get_data) new_job = Job("http://halob:8080/job/foo/", "foo", jenkins) return new_job def test_repr(job): # Can we produce a repr string for this object assert repr(job) == "" def test_name(job): with pytest.raises(AttributeError): job.id() assert job.name == "foo" def test_next_build_number(job): assert job.get_next_build_number() == 4 def test_lastcompleted_build_number(job): assert job.get_last_completed_buildnumber() == 3 def test_lastgood_build_number(job): assert job.get_last_good_buildnumber() == 3 def test_special_urls(job): assert job.baseurl == "http://halob:8080/job/foo" assert job.get_delete_url() == "http://halob:8080/job/foo/doDelete" assert job.get_rename_url() == "http://halob:8080/job/foo/doRename" def test_get_description(job): assert job.get_description() == "test job" def test_get_build_triggerurl(job): assert ( job.get_build_triggerurl() == "http://halob:8080/job/foo/buildWithParameters" ) def test_wrong__mk_json_from_build_parameters(job): with pytest.raises(ValueError) as ar: job._mk_json_from_build_parameters(build_params="bad parameter") assert str(ar.value) == "Build parameters must be a dict" def test_unicode_mk_json(job): json = job._mk_json_from_build_parameters( {"age": 20, "name": "å“�å“�", "country": "USA", "height": 1.88} ) assert isinstance(json, dict) def test_wrong_field__build_id_for_type(job): with pytest.raises(AssertionError): job._buildid_for_type("wrong") def test_get_last_good_buildnumber(job): ret = job.get_last_good_buildnumber() assert ret == 3 def test_get_last_stable_buildnumber(job): ret = job.get_last_stable_buildnumber() assert ret == 3 def test_get_last_failed_buildnumber(job): with pytest.raises(NoBuildData): job.get_last_failed_buildnumber() def test_get_last_buildnumber(job): ret = job.get_last_buildnumber() assert ret == 4 def test_get_last_completed_buildnumber(job): ret = job.get_last_completed_buildnumber() assert ret == 3 def test_get_build_dict(job_tree): ret = job_tree.get_build_dict() assert isinstance(ret, dict) assert len(ret) == 4 def test_get_build_metadata(job_tree): with pytest.raises(ValueError) as ve: job_tree.get_build_metadata("abc") assert 'Parameter "buildNumber" must be int' in str(ve.value) def test_nobuilds_get_build_dict(job_tree_empty): with pytest.raises(NoBuildData): job_tree_empty.get_build_dict() def test_get_build_ids(job): # We don't want to deal with listreverseiterator here # So we convert result to a list ret = list(job.get_build_ids()) assert isinstance(ret, list) assert len(ret) == 4 def test_nobuilds_get_revision_dict(jenkins, monkeypatch): def fake_poll(cls, tree=None): # pylint: disable=unused-argument return {"name": "foo"} monkeypatch.setattr(Job, "_poll", fake_poll) job = Job("http://halob:8080/job/foo/", "foo", jenkins) with pytest.raises(NoBuildData): job.get_revision_dict() def test_nobuilds_get_last_build(jenkins, monkeypatch): def fake_poll(cls, tree=None): # pylint: disable=unused-argument return {"name": "foo"} monkeypatch.setattr(Job, "_poll", fake_poll) job = Job("http://halob:8080/job/foo/", "foo", jenkins) with pytest.raises(NoBuildData): job.get_last_build() def test__add_missing_builds_not_all_loaded(jenkins, monkeypatch): def fake_get_data(cls, url, tree): # pylint: disable=unused-argument return configs.JOB_DATA.copy() monkeypatch.setattr(JenkinsBase, "get_data", fake_get_data) job = Job("http://halob:8080/job/foo/", "foo", jenkins) # to test this function we change data to not have one build # and set it to mark that firstBuild was not loaded # in that condition function will call j.get_data # and will use syntetic field 'allBuilds' to # repopulate 'builds' field with all builds mock_data = configs.JOB_DATA.copy() mock_data["firstBuild"] = {"number": 1} del mock_data["builds"][-1] job._data = mock_data assert len(mock_data["builds"]) == 2 new_data = job._add_missing_builds(mock_data) assert len(new_data["builds"]) == 3 def test__add_missing_builds_no_first_build(job, mocker): mocker.spy(JenkinsBase, "get_data") initial_call_count = job.get_data.call_count mock_data = configs.JOB_DATA.copy() mock_data["firstBuild"] = None job._data = mock_data job._add_missing_builds(mock_data) assert initial_call_count == job.get_data.call_count @mock.patch.object(JenkinsBase, "get_data") def test__add_missing_builds_no_builds(job, mocker): mocker.spy(JenkinsBase, "get_data") initial_call_count = job.get_data.call_count mock_data = configs.JOB_DATA.copy() mock_data["builds"] = None job._data = mock_data job._add_missing_builds(mock_data) assert initial_call_count == job.get_data.call_count def test_get_params(job): params = list(job.get_params()) assert len(params) == 2 def test_get_params_list(job): assert job.has_params() is True params = job.get_params_list() assert isinstance(params, list) assert len(params) == 2 assert params == ["param1", "param2"] def json_equal(json_a, json_b): dict_a = json.loads(json_a) dict_b = json.loads(json_b) assert dict_a == dict_b def test_get_json_for_single_param(): params = {"B": "one two three"} expected = ( '{"parameter": {"name": "B", "value": "one two three"}, ' '"statusCode": "303", "redirectTo": "."}' ) json_equal(Job.mk_json_from_build_parameters(params), expected) def test_get_json_for_many_params(): params = {"B": "Honey", "A": "Boo", "C": 2} expected = ( '{"parameter": [{"name": "A", "value": "Boo"}, ' '{"name": "B", "value": "Honey"}, ' '{"name": "C", "value": 2}], ' '"statusCode": "303", "redirectTo": "."}' ) json_equal(Job.mk_json_from_build_parameters(params), expected) def test__mk_json_from_build_parameters(job): params = {"param1": "value1", "param2": "value2"} expected = { "parameter": [ {"name": "param1", "value": "value1"}, {"name": "param2", "value": "value2"}, ] } result = job._mk_json_from_build_parameters(build_params=params) assert isinstance(result, dict) assert result == expected def test_wrong_mk_json_from_build_parameters(job): with pytest.raises(ValueError) as ar: job.mk_json_from_build_parameters(build_params="bad parameter") assert "Build parameters must be a dict" in str(ar.value) def test_update_config_accepts_bytes(job, mocker): response = mocker.Mock(text="ok") job.jenkins.requester.post_url = mocker.Mock(return_value=response) config = ( b'' b"bytes" ) result = job.update_config(config) assert result == "ok" job.jenkins.requester.post_url.assert_called_once_with( job.get_config_xml_url(), params={}, data=config ) assert job._element_tree.find("description").text == "bytes" def test_change_description_same_value_is_noop(job, mocker): job._config = "test job" update_config = mocker.patch.object(job, "update_config") job.change_description("test job") update_config.assert_not_called() def test_set_concurrent_builds_same_value_is_noop(job, mocker): job._config = "false" update_config = mocker.patch.object(job, "update_config") job.set_concurrent_builds(False) update_config.assert_not_called() def test_wipe_out_workspace_posts_expected_url(job, mocker): post_and_confirm_status = mocker.patch.object( job.jenkins.requester, "post_and_confirm_status", return_value="ok", ) result = job.wipe_out_workspace() assert result == "ok" post_and_confirm_status.assert_called_once_with( "http://halob:8080/job/foo/doWipeOutWorkspace", data="", ) def test_trigger_scm_poll_posts_expected_url(job, mocker): post_and_confirm_status = mocker.patch.object( job.jenkins.requester, "post_and_confirm_status", return_value="ok", ) result = job.trigger_scm_poll() assert result == "ok" post_and_confirm_status.assert_called_once_with( "http://halob:8080/job/foo/polling", data="", ) def test_restrict_to_node_sets_assigned_node_and_disables_roaming(job, mocker): job._config = "true" update_config = mocker.patch.object(job, "update_config") job.restrict_to_node("agent-1") update_config.assert_called_once() updated_config = update_config.call_args.args[0].decode("utf-8") assert "agent-1" in updated_config assert "false" in updated_config def test_get_build_by_params(jenkins, monkeypatch, mocker): build_params = {"param1": "value1"} fake_builds = ( mocker.Mock(get_params=lambda: {}), mocker.Mock(get_params=lambda: {}), mocker.Mock(get_params=lambda: build_params), ) build_call_count = [0] def fake_get_build(cls, number): # pylint: disable=unused-argument build_call_count[0] += 1 return fake_builds[number - 1] monkeypatch.setattr(Job, "_poll", lambda self, tree=None: {}) monkeypatch.setattr(Job, "get_first_buildnumber", lambda x: 1) monkeypatch.setattr(Job, "get_last_buildnumber", lambda x: 3) monkeypatch.setattr(Job, "get_build", fake_get_build) mocker.spy(Build, "get_params") mocker.spy(Job, "get_build") job = Job("http://localhost/jobs/foo", "foo", jenkins) result = job.get_build_by_params(build_params) assert job.get_build.call_count == 3 assert build_call_count[0] == 3 assert result == fake_builds[2] def test_get_build_by_params_not_found(jenkins, monkeypatch, mocker): build_params = {"param1": "value1"} fake_builds = ( mocker.Mock(get_params=lambda: {}), mocker.Mock(get_params=lambda: {}), mocker.Mock(get_params=lambda: {}), ) build_call_count = [0] def fake_get_build(cls, number): # pylint: disable=unused-argument build_call_count[0] += 1 return fake_builds[number - 1] monkeypatch.setattr(Job, "_poll", lambda self, tree=None: {}) monkeypatch.setattr(Job, "get_first_buildnumber", lambda x: 1) monkeypatch.setattr(Job, "get_last_buildnumber", lambda x: 3) monkeypatch.setattr(Job, "get_build", fake_get_build) mocker.spy(Build, "get_params") mocker.spy(Job, "get_build") job = Job("http://localhost/jobs/foo", "foo", jenkins) with pytest.raises(NoBuildData): job.get_build_by_params(build_params) assert job.get_build.call_count == 3 assert build_call_count[0] == 3 def test_credentials_property(job, monkeypatch): def fake_get_data(cls, url, tree=None): # pylint: disable=unused-argument return {"credentials": []} monkeypatch.setattr(JenkinsBase, "get_data", fake_get_data) credentials = job.credentials assert isinstance(credentials, Credentials2x) assert ( credentials.baseurl == "http://halob:8080/job/foo/credentials/store/folder/domain/_" ) jenkinsapi-0.3.23/jenkinsapi_tests/unittests/test_job_folders.py0000644000000000000000000001666013615410400022203 0ustar00import pytest import mock from jenkinsapi.jenkins import JenkinsBase @pytest.fixture(scope="function") def jenkinsbase(): return JenkinsBase("http://localhost:8080/", poll=False) def test_called_in__poll(jenkinsbase, monkeypatch, mocker): def fake_poll(cls, tree=None): # pylint: disable=unused-argument return { "description": "My jobs", "jobs": [ { "name": "Foo", "url": "http://localhost:8080/job/Foo", "color": "blue", } ], "name": "All", "property": [], "url": "http://localhost:8080/view/All/", } monkeypatch.setattr(JenkinsBase, "_poll", fake_poll) stub = mocker.stub() monkeypatch.setattr(JenkinsBase, "resolve_job_folders", stub) jenkinsbase.poll() stub.assert_called_once_with( [ { "name": "Foo", "url": "http://localhost:8080/job/Foo", "color": "blue", }, ], ) def test_no_folders(jenkinsbase): jobs = [ { "name": "Foo", "url": "http://localhost:8080/job/Foo", "color": "blue", }, { "name": "Bar", "url": "http://localhost:8080/job/Bar", "color": "disabled", }, ] assert jenkinsbase.resolve_job_folders(jobs) == [ { "name": "Foo", "url": "http://localhost:8080/job/Foo", "color": "blue", }, { "name": "Bar", "url": "http://localhost:8080/job/Bar", "color": "disabled", }, ] def test_empty_folder(jenkinsbase, monkeypatch, mocker): def fake_get_data(cls, url, tree=None): # pylint: disable=unused-argument return {"jobs": []} monkeypatch.setattr(JenkinsBase, "get_data", fake_get_data) spy = mocker.spy(jenkinsbase, "get_data") jobs = [ { "name": "Folder1", "url": "http://localhost:8080/job/Folder1", }, ] assert jenkinsbase.resolve_job_folders(jobs) == [] spy.assert_called_once_with( "http://localhost:8080/job/Folder1/api/python", tree="jobs[name,color]" ) def test_folder_job_mix(jenkinsbase, monkeypatch, mocker): def fake_get_data(cls, url, tree=None): # pylint: disable=unused-argument return { "jobs": [ { "name": "Bar", "url": "http://localhost:8080/job/Folder1/job/Bar", "color": "disabled", } ] } monkeypatch.setattr(JenkinsBase, "get_data", fake_get_data) spy = mocker.spy(jenkinsbase, "get_data") jobs = [ { "name": "Foo", "url": "http://localhost:8080/job/Foo", "color": "blue", }, { "name": "Folder1", "url": "http://localhost:8080/job/Folder1", }, ] assert jenkinsbase.resolve_job_folders(jobs) == [ { "name": "Foo", "url": "http://localhost:8080/job/Foo", "color": "blue", }, { "name": "Bar", "url": "http://localhost:8080/job/Folder1/job/Bar", "color": "disabled", }, ] spy.assert_called_once_with( "http://localhost:8080/job/Folder1/api/python", tree="jobs[name,color]" ) def test_multiple_folders(jenkinsbase, monkeypatch, mocker): def fake_get_data(cls, url, tree=None): # pylint: disable=unused-argument # first call if "Folder1" in url: return { "jobs": [ { "name": "Foo", "url": "http://localhost:8080/job/Folder1/job/Foo", "color": "disabled", }, ] } if "Folder2" in url: # second call return { "jobs": [ { "name": "Bar", "url": "http://localhost:8080/job/Folder2/job/Bar", "color": "blue", }, ] } monkeypatch.setattr(JenkinsBase, "get_data", fake_get_data) spy = mocker.spy(jenkinsbase, "get_data") jobs = [ { "name": "Folder1", "url": "http://localhost:8080/job/Folder1", }, { "name": "Folder2", "url": "http://localhost:8080/job/Folder2", }, ] assert jenkinsbase.resolve_job_folders(jobs) == [ { "name": "Foo", "url": "http://localhost:8080/job/Folder1/job/Foo", "color": "disabled", }, { "name": "Bar", "url": "http://localhost:8080/job/Folder2/job/Bar", "color": "blue", }, ] assert spy.call_args_list == [ mock.call( "http://localhost:8080/job/Folder1/api/python", tree="jobs[name,color]", ), mock.call( "http://localhost:8080/job/Folder2/api/python", tree="jobs[name,color]", ), ] def test_multiple_folder_levels(jenkinsbase, monkeypatch, mocker): def fake_get_data(cls, url, tree=None): # pylint: disable=unused-argument if "Folder1" in url and "Folder2" not in url: # first call return { "jobs": [ { "name": "Bar", "url": "http://localhost:8080/job/Folder1/job/Bar", "color": "disabled", }, { "name": "Folder2", "url": "http://localhost:8080/job/Folder1/job/Folder2", }, ] } if "Folder2" in url: # second call return { "jobs": [ { "name": "Baz", "url": ( "http://localhost:8080/job/Folder1/" "job/Folder2/job/Baz" ), "color": "disabled", }, ] } monkeypatch.setattr(JenkinsBase, "get_data", fake_get_data) spy = mocker.spy(jenkinsbase, "get_data") jobs = [ { "name": "Foo", "url": "http://localhost:8080/job/Foo", "color": "blue", }, { "name": "Folder1", "url": "http://localhost:8080/job/Folder1", }, ] assert jenkinsbase.resolve_job_folders(jobs) == [ { "name": "Foo", "url": "http://localhost:8080/job/Foo", "color": "blue", }, { "name": "Bar", "url": "http://localhost:8080/job/Folder1/job/Bar", "color": "disabled", }, { "name": "Baz", "url": ("http://localhost:8080/job/Folder1/job/Folder2/job/Baz"), "color": "disabled", }, ] assert spy.call_args_list == [ mock.call( "http://localhost:8080/job/Folder1/api/python", tree="jobs[name,color]", ), mock.call( "http://localhost:8080/job/Folder1/job/Folder2/api/python", tree="jobs[name,color]", ), ] jenkinsapi-0.3.23/jenkinsapi_tests/unittests/test_job_get_all_builds.py0000644000000000000000000002330613615410400023511 0ustar00import mock # To run unittests on python 2.6 please use unittest2 library try: import unittest2 as unittest except ImportError: import unittest from jenkinsapi import config from jenkinsapi.job import Job from jenkinsapi.jenkinsbase import JenkinsBase class TestJobGetAllBuilds(unittest.TestCase): # this job has builds JOB1_DATA = { "actions": [], "description": "test job", "displayName": "foo", "displayNameOrNull": None, "name": "foo", "url": "http://halob:8080/job/foo/", "buildable": True, # do as if build 1 & 2 are not returned by jenkins "builds": [{"number": 3, "url": "http://halob:8080/job/foo/3/"}], "color": "blue", "firstBuild": {"number": 1, "url": "http://halob:8080/job/foo/1/"}, "healthReport": [ { "description": "Build stability: No recent builds failed.", "iconUrl": "health-80plus.png", "score": 100, } ], "inQueue": False, "keepDependencies": False, # build running "lastBuild": {"number": 4, "url": "http://halob:8080/job/foo/4/"}, "lastCompletedBuild": { "number": 3, "url": "http://halob:8080/job/foo/3/", }, "lastFailedBuild": None, "lastStableBuild": { "number": 3, "url": "http://halob:8080/job/foo/3/", }, "lastSuccessfulBuild": { "number": 3, "url": "http://halob:8080/job/foo/3/", }, "lastUnstableBuild": None, "lastUnsuccessfulBuild": None, "nextBuildNumber": 4, "property": [], "queueItem": None, "concurrentBuild": False, "downstreamProjects": [], "scm": {}, "upstreamProjects": [], } JOB1_ALL_BUILDS_DATA = { "allBuilds": [ {"number": 3, "url": "http://halob:8080/job/foo/3/"}, {"number": 2, "url": "http://halob:8080/job/foo/2/"}, {"number": 1, "url": "http://halob:8080/job/foo/1/"}, ], } JOB1_API_URL = "http://halob:8080/job/foo/%s" % config.JENKINS_API JOB2_DATA = { "actions": [], "buildable": True, "builds": [], "color": "notbuilt", "concurrentBuild": False, "description": "", "displayName": "look_ma_no_builds", "displayNameOrNull": None, "downstreamProjects": [], "firstBuild": None, "healthReport": [], "inQueue": False, "keepDependencies": False, "lastBuild": None, "lastCompletedBuild": None, "lastFailedBuild": None, "lastStableBuild": None, "lastSuccessfulBuild": None, "lastUnstableBuild": None, "lastUnsuccessfulBuild": None, "name": "look_ma_no_builds", "nextBuildNumber": 1, "property": [{}], "queueItem": None, "scm": {}, "upstreamProjects": [], "url": "http://halob:8080/job/look_ma_no_builds/", } JOB2_API_URL = ( "http://halob:8080/job/look_ma_no_builds/%s" % config.JENKINS_API ) # Full list available immediatly JOB3_DATA = { "actions": [], "description": "test job", "displayName": "fullfoo", "displayNameOrNull": None, "name": "fullfoo", "url": "http://halob:8080/job/fullfoo/", "buildable": True, # all builds have been returned by Jenkins "builds": [ {"number": 3, "url": "http://halob:8080/job/fullfoo/3/"}, {"number": 2, "url": "http://halob:8080/job/fullfoo/2/"}, {"number": 1, "url": "http://halob:8080/job/fullfoo/1/"}, ], "color": "blue", "firstBuild": {"number": 1, "url": "http://halob:8080/job/fullfoo/1/"}, "healthReport": [ { "description": "Build stability: No recent builds failed.", "iconUrl": "health-80plus.png", "score": 100, } ], "inQueue": False, "keepDependencies": False, # build running "lastBuild": {"number": 4, "url": "http://halob:8080/job/fullfoo/4/"}, "lastCompletedBuild": { "number": 3, "url": "http://halob:8080/job/fullfoo/3/", }, "lastFailedBuild": None, "lastStableBuild": { "number": 3, "url": "http://halob:8080/job/fullfoo/3/", }, "lastSuccessfulBuild": { "number": 3, "url": "http://halob:8080/job/fullfoo/3/", }, "lastUnstableBuild": None, "lastUnsuccessfulBuild": None, "nextBuildNumber": 4, "property": [], "queueItem": None, "concurrentBuild": False, "downstreamProjects": [], "scm": {}, "upstreamProjects": [], } JOB3_ALL_BUILDS_DATA = { "allBuilds": [ {"number": 3, "url": "http://halob:8080/job/fullfoo/3/"}, {"number": 2, "url": "http://halob:8080/job/fullfoo/2/"}, {"number": 1, "url": "http://halob:8080/job/fullfoo/1/"}, ], } JOB3_API_URL = "http://halob:8080/job/fullfoo/%s" % config.JENKINS_API URL_DATA = { JOB1_API_URL: JOB1_DATA, (JOB1_API_URL, "allBuilds[number,url]"): JOB1_ALL_BUILDS_DATA, JOB2_API_URL: JOB2_DATA, JOB3_API_URL: JOB3_DATA, # this one below should never be used (JOB3_API_URL, "allBuilds[number,url]"): JOB3_ALL_BUILDS_DATA, } def fakeGetData(self, url, params=None, tree=None): TestJobGetAllBuilds.__get_data_call_count += 1 if params is None: try: return dict(TestJobGetAllBuilds.URL_DATA[url]) except KeyError: raise Exception("Missing data for url: %s" % url) else: try: return dict(TestJobGetAllBuilds.URL_DATA[(url, str(params))]) except KeyError: raise Exception( "Missing data for url: %s with parameters %s" % (url, repr(params)) ) def fakeGetDataTree(self, url, **args): TestJobGetAllBuilds.__get_data_call_count += 1 try: if args["tree"]: if "builds" in args["tree"]: return { "builds": TestJobGetAllBuilds.URL_DATA[url]["builds"] } elif "allBuilds" in args["tree"]: return TestJobGetAllBuilds.URL_DATA[(url, args["tree"])] elif "lastBuild" in args["tree"]: return { "lastBuild": TestJobGetAllBuilds.URL_DATA[url][ "lastBuild" ] } else: return dict(TestJobGetAllBuilds.URL_DATA[url]) except KeyError: raise Exception("Missing data for %s" % url) @mock.patch.object(JenkinsBase, "get_data", fakeGetDataTree) def setUp(self): TestJobGetAllBuilds.__get_data_call_count = 0 self.J = mock.MagicMock() # Jenkins object self.j = Job("http://halob:8080/job/foo/", "foo", self.J) @mock.patch.object(JenkinsBase, "get_data", fakeGetDataTree) def test_get_build_dict(self): # The job data contains only one build, so we expect that the # remaining jobs will be fetched automatically ret = self.j.get_build_dict() self.assertTrue(isinstance(ret, dict)) self.assertEqual(len(ret), 4) @mock.patch.object(JenkinsBase, "get_data", fakeGetDataTree) def test_incomplete_builds_list_will_call_jenkins_twice(self): # The job data contains only one build, so we expect that the # remaining jobs will be fetched automatically, and to have two calls # to the Jenkins API TestJobGetAllBuilds.__get_data_call_count = 0 self.J.lazy = False self.j = Job("http://halob:8080/job/foo/", "foo", self.J) self.assertEqual(TestJobGetAllBuilds.__get_data_call_count, 2) @mock.patch.object(JenkinsBase, "get_data", fakeGetDataTree) def test_lazy_builds_list_will_not_call_jenkins_twice(self): # The job data contains only one build, so we expect that the # remaining jobs will be fetched automatically, and to have two calls # to the Jenkins API TestJobGetAllBuilds.__get_data_call_count = 0 self.J.lazy = True self.j = Job("http://halob:8080/job/foo/", "foo", self.J) self.assertEqual(TestJobGetAllBuilds.__get_data_call_count, 1) self.J.lazy = False @mock.patch.object(JenkinsBase, "get_data", fakeGetDataTree) def test_complete_builds_list_will_call_jenkins_once(self): # The job data contains all builds, so we will not gather remaining # builds TestJobGetAllBuilds.__get_data_call_count = 0 self.j = Job("http://halob:8080/job/fullfoo/", "fullfoo", self.J) self.assertEqual(TestJobGetAllBuilds.__get_data_call_count, 1) @mock.patch.object(JenkinsBase, "get_data", fakeGetDataTree) def test_nobuilds_get_build_dict(self): j = Job( "http://halob:8080/job/look_ma_no_builds/", "look_ma_no_builds", self.J, ) ret = j.get_build_dict() self.assertTrue(isinstance(ret, dict)) self.assertEqual(len(ret), 0) @mock.patch.object(JenkinsBase, "get_data", fakeGetDataTree) def test_get_build_ids(self): # The job data contains only one build, so we expect that the # remaining jobs will be fetched automatically ret = list(self.j.get_build_ids()) self.assertTrue(isinstance(ret, list)) self.assertEqual(len(ret), 4) if __name__ == "__main__": unittest.main() jenkinsapi-0.3.23/jenkinsapi_tests/unittests/test_job_scm_hg.py0000644000000000000000000001755713615410400022013 0ustar00from unittest import mock import unittest from jenkinsapi import config from jenkinsapi.job import Job from jenkinsapi.jenkinsbase import JenkinsBase CFG_NODE = """ http://cm5/hg/sandbox/v01.0/int false http://cm5/hg/sandbox/v01.0/int/ """ # TODO: Make JOB_DATA to be one coming from Hg job class TestHgJob(unittest.TestCase): JOB_DATA = { "actions": [], "description": "test job", "displayName": "foo", "displayNameOrNull": None, "name": "foo", "url": "http://halob:8080/job/foo/", "buildable": True, "builds": [ {"number": 3, "url": "http://halob:8080/job/foo/3/"}, {"number": 2, "url": "http://halob:8080/job/foo/2/"}, {"number": 1, "url": "http://halob:8080/job/foo/1/"}, ], "color": "blue", "firstBuild": {"number": 1, "url": "http://halob:8080/job/foo/1/"}, "healthReport": [ { "description": "Build stability: No recent builds failed.", "iconUrl": "health-80plus.png", "score": 100, } ], "inQueue": False, "keepDependencies": False, # build running "lastBuild": {"number": 4, "url": "http://halob:8080/job/foo/4/"}, "lastCompletedBuild": { "number": 3, "url": "http://halob:8080/job/foo/3/", }, "lastFailedBuild": None, "lastStableBuild": { "number": 3, "url": "http://halob:8080/job/foo/3/", }, "lastSuccessfulBuild": { "number": 3, "url": "http://halob:8080/job/foo/3/", }, "lastUnstableBuild": None, "lastUnsuccessfulBuild": None, "nextBuildNumber": 4, "property": [], "queueItem": None, "concurrentBuild": False, "downstreamProjects": [], "scm": {}, "upstreamProjects": [], } URL_DATA = {"http://halob:8080/job/foo/%s" % config.JENKINS_API: JOB_DATA} def fakeGetData(self, url, *args, **kwargs): try: return TestHgJob.URL_DATA[url] except KeyError: raise Exception("Missing data for %s" % url) @mock.patch.object(JenkinsBase, "get_data", fakeGetData) def setUp(self): self.J = mock.MagicMock() # Jenkins object self.j = Job("http://halob:8080/job/foo/", "foo", self.J) def configtree_with_branch(self): config_node = """ http://cm5/hg/sandbox/v01.0/int testme false http://cm5/hg/sandbox/v01.0/int/ """ return config_node def configtree_with_default_branch(self): config_node = CFG_NODE return config_node def configtree_multibranch_git(self): config_node = """ false H H * * H(6-7) SUCCESS 0 BLUE true -1 5 -1 5 a2d4bcda-6141-4af2-8088-39139a147902 master GIT 2 origin +refs/heads/master:refs/remotes/origin/master ssh://git@bitbucket.site/project-name/reponame.git jenkins-stash master false https://bitbucket.site/projects/project-name/repos/reponame false Jenkinsfile false """ return config_node @mock.patch.object(Job, "get_config", configtree_with_branch) def test_hg_attributes(self): expected_url = ["http://cm5/hg/sandbox/v01.0/int"] self.j.load_config() self.assertEqual(self.j.get_scm_type(), "hg") self.assertEqual(self.j.get_scm_url(), expected_url) self.assertEqual(self.j.get_scm_branch(), ["testme"]) @mock.patch.object(Job, "get_config", configtree_with_default_branch) def test_hg_attributes_default_branch(self): self.j.load_config() self.assertEqual(self.j.get_scm_branch(), ["default"]) @mock.patch.object(Job, "get_config", configtree_multibranch_git) def test_git_attributes_multibranch(self): expected_url = ["ssh://git@bitbucket.site/project-name/reponame.git"] self.j.load_config() self.assertEqual(self.j.get_scm_type(), "git") self.assertEqual(self.j.get_scm_url(), expected_url) self.assertEqual(self.j.get_scm_branch(), ["master"]) if __name__ == "__main__": unittest.main() jenkinsapi-0.3.23/jenkinsapi_tests/unittests/test_jobs.py0000644000000000000000000000416113615410400020641 0ustar00import pytest from jenkinsapi.jenkins import Jenkins from jenkinsapi.jenkinsbase import JenkinsBase from jenkinsapi.jobs import Jobs from jenkinsapi.utils.requester import Requester @pytest.fixture(scope="function") def jenkins(mocker, monkeypatch): def fake_poll(cls, tree=None): # pylint: disable=unused-argument return {} monkeypatch.setattr(JenkinsBase, "_poll", fake_poll) monkeypatch.setattr(Jenkins, "_poll", fake_poll) mock_requester = Requester(username="foouser", password="foopassword") mock_requester.post_xml_and_confirm_status = mocker.MagicMock( return_value="" ) return Jenkins( "http://localhost:8080/", username="foouser", password="foopassword", requester=mock_requester, ) def test_create_job_in_folder_path(jenkins): jenkins.jobs.create("folder1/folder2/job-name", "") jenkins.requester.post_xml_and_confirm_status.assert_called_once_with( "http://localhost:8080/job/folder1/job/folder2/createItem", data="", params={"name": "job-name"}, ) def test_create_job_at_root(jenkins): jenkins.jobs.create("job-name", "") jenkins.requester.post_xml_and_confirm_status.assert_called_once_with( "http://localhost:8080/createItem", data="", params={"name": "job-name"}, ) def test_normalizes_job_name_for_contains_and_getitem(): class DummyResponse: status_code = 200 text = "{}" class DummyRequester: @staticmethod def get_url(*args, **kwargs): # pylint: disable=unused-argument return DummyResponse() class DummyJenkins: baseurl = "http://localhost:8080/" requester = DummyRequester() lazy = True jobs = Jobs(DummyJenkins()) jobs._data = [ { "name": "folder1/folder2/job-name", "url": "http://localhost:8080/job/folder1/job/folder2/job/job-name", "color": "blue", } ] assert "folder1/folder2/job-name" in jobs assert jobs["folder1/folder2/job-name"].name == ( "folder1/folder2/job-name" ) jenkinsapi-0.3.23/jenkinsapi_tests/unittests/test_json_null_parsing.py0000644000000000000000000000402613615410400023432 0ustar00""" Tests for issue #780: Cannot parse response.content when contains null as value. Jenkins responses can contain JSON null values which json.loads() handles correctly. """ import json def test_json_with_null_values_parses_correctly(): """json.loads() correctly parses JSON responses with null values.""" json_with_null = """{ "id": "953", "name": "#953", "status": "ABORTED", "message": null, "stages": [ { "id": "361", "name": "Stage 1", "status": "FAILED", "error": { "message": null, "type": "FlowInterruptedException" } } ] }""" # Should not raise an exception data = json.loads(json_with_null) assert data["id"] == "953" assert data["message"] is None assert data["stages"][0]["error"]["message"] is None def test_json_with_deeply_nested_null(): """Deeply nested null values are parsed correctly.""" json_data = """{ "level1": { "level2": { "level3": null, "level3b": "value" }, "level2b": null }, "list": [null, "item", null] }""" data = json.loads(json_data) assert data["level1"]["level2"]["level3"] is None assert data["level1"]["level2"]["level3b"] == "value" assert data["level1"]["level2b"] is None assert data["list"][0] is None assert data["list"][1] == "item" assert data["list"][2] is None def test_json_loads_handles_normal_responses(): """Ensure json.loads() doesn't break normal JSON parsing.""" json_data = """{ "jobs": [ {"name": "job1", "color": "blue"}, {"name": "job2", "color": "red"} ], "views": [ {"name": "All", "url": "http://dummy/"} ] }""" data = json.loads(json_data) assert len(data["jobs"]) == 2 assert data["jobs"][0]["name"] == "job1" assert len(data["views"]) == 1 jenkinsapi-0.3.23/jenkinsapi_tests/unittests/test_label.py0000644000000000000000000000426713615410400020772 0ustar00import pytest from jenkinsapi.label import Label DATA = { "actions": [], "busyExecutors": 0, "clouds": [], "description": None, "idleExecutors": 0, "loadStatistics": {}, "name": "jenkins-slave", "nodes": [], "offline": True, "tiedJobs": [ { "name": "test_job1", "url": "http://jtest:8080/job/test_job1/", "color": "blue", }, { "name": "test_job2", "url": "http://jtest:8080/job/test_job2/", "color": "blue", }, { "name": "test_job3", "url": "http://jtest:8080/job/test_job3/", "color": "blue", }, { "name": "test_job4", "url": "http://jtest:8080/job/test_job4/", "color": "blue", }, ], "totalExecutors": 0, "propertiesList": [], } DATA_JOB_NAMES = { "tiedJobs": [ {"name": "test_job1"}, {"name": "test_job2"}, {"name": "test_job3"}, {"name": "test_job4"}, ] } DATA_JOBS = [ { "url": "http://jtest:8080/job/test_job1/", "color": "blue", "name": "test_job1", }, { "url": "http://jtest:8080/job/test_job2/", "color": "blue", "name": "test_job2", }, { "url": "http://jtest:8080/job/test_job3/", "color": "blue", "name": "test_job3", }, { "url": "http://jtest:8080/job/test_job4/", "color": "blue", "name": "test_job4", }, ] @pytest.fixture(scope="function") def label(monkeypatch, mocker): def fake_poll(cls, tree=None): # pylint: disable=unused-argument return DATA monkeypatch.setattr(Label, "_poll", fake_poll) jenkins = mocker.MagicMock() return Label("http://foo:8080", "jenkins-slave", jenkins) def test_repr(label): # Can we produce a repr string for this object repr(label) def test_name(label): with pytest.raises(AttributeError): label.id() assert label.labelname == "jenkins-slave" def test_get_tied_job_names(label): assert label.get_tied_job_names() == DATA_JOBS def test_online(label): assert label.is_online() is False jenkinsapi-0.3.23/jenkinsapi_tests/unittests/test_misc.py0000644000000000000000000000065013615410400020636 0ustar00import jenkinsapi def test_jenkinsapi_version(): """Verify that we can get the jenkinsapi version number from the package's __version__ property. """ version = jenkinsapi.__version__ # only first two parts must be interger, 1.0.dev5 being a valid version. parts = [int(x) for x in version.split(".")[0:2]] for part in parts: assert part >= 0, "Implausible version number: %r" % version jenkinsapi-0.3.23/jenkinsapi_tests/unittests/test_node.py0000644000000000000000000001017513615410400020633 0ustar00import pytest from jenkinsapi.node import Node # Import monitor field mappings for use in tests MONITOR_FIELDS = Node._MONITOR_FIELDS DATA = { "actions": [], "displayName": "bobnit", "executors": [{}], "icon": "computer.png", "idle": True, "jnlpAgent": False, "launchSupported": True, "loadStatistics": {}, "manualLaunchAllowed": True, "monitorData": { "hudson.node_monitors.SwapSpaceMonitor": { "availablePhysicalMemory": 7681417216, "availableSwapSpace": 12195983360, "totalPhysicalMemory": 8374497280, "totalSwapSpace": 12195983360, }, "hudson.node_monitors.ArchitectureMonitor": "Linux (amd64)", "hudson.node_monitors.ResponseTimeMonitor": {"average": 64}, "hudson.node_monitors.TemporarySpaceMonitor": { "path": "/tmp", "size": 250172776448, }, "hudson.node_monitors.DiskSpaceMonitor": { "path": "/home/sal/jenkins", "size": 170472026112, }, "hudson.node_monitors.ClockMonitor": {"diff": 6736}, }, "numExecutors": 1, "offline": False, "offlineCause": None, "oneOffExecutors": [], "temporarilyOffline": False, } def get_monitor_expected_value(field_key: str) -> any: """Helper to get expected value from DATA using MONITOR_FIELDS config.""" config = MONITOR_FIELDS[field_key] monitor_key = f"hudson.node_monitors.{config['monitor']}" return DATA["monitorData"][monitor_key][config["field"]] @pytest.fixture(scope="function") def node(monkeypatch, mocker): def fake_poll(cls, tree=None): # pylint: disable=unused-argument return DATA monkeypatch.setattr(Node, "_poll", fake_poll) jenkins = mocker.MagicMock() return Node(jenkins, "http://foo:8080", "bobnit", {}) def test_repr(node): # Can we produce a repr string for this object repr(node) def test_name(node): with pytest.raises(AttributeError): node.id() assert node.name == "bobnit" def test_online(node): assert node.is_online() is True @pytest.mark.parametrize( "field_key,method_name", [ ("available_physical_memory", "get_available_physical_memory"), ("available_swap_space", "get_available_swap_space"), ("total_physical_memory", "get_total_physical_memory"), ("total_swap_space", "get_total_swap_space"), ("workspace_path", "get_workspace_path"), ("workspace_size", "get_workspace_size"), ("temp_path", "get_temp_path"), ("temp_size", "get_temp_size"), ("response_time", "get_response_time"), ("clock_difference", "get_clock_difference"), ], ) def test_monitor_methods(node, field_key, method_name): """Test monitor getter methods retrieve correct values from monitorData.""" expected = get_monitor_expected_value(field_key) result = getattr(node, method_name)() assert result == expected def test_architecture(node): expected_value = DATA["monitorData"][ "hudson.node_monitors.ArchitectureMonitor" ] assert node.get_architecture() == expected_value @pytest.mark.parametrize( "field_key", [ "available_physical_memory", "workspace_path", "response_time", "clock_difference", "temp_path", ], ) def test_get_monitor_data(node, field_key): """Test get_monitor_data helper retrieves correct monitor field values.""" config = MONITOR_FIELDS[field_key] result = node.get_monitor_data(config["monitor"], config["field"]) assert result == get_monitor_expected_value(field_key) def test_get_monitor_data_with_poll_false(node): """Test get_monitor_data with poll_monitor=False uses cached data.""" config = MONITOR_FIELDS["temp_path"] result = node.get_monitor_data( config["monitor"], config["field"], poll_monitor=False ) assert result == get_monitor_expected_value("temp_path") def test_get_monitor_data_nonexistent_monitor(node): """Test get_monitor_data raises AssertionError for non-existent monitor""" with pytest.raises(AssertionError): node.get_monitor_data("NonExistentMonitor", "some_key") jenkinsapi-0.3.23/jenkinsapi_tests/unittests/test_nodes.py0000644000000000000000000002147713615410400021025 0ustar00import pytest from jenkinsapi.jenkins import Jenkins from jenkinsapi.nodes import Nodes from jenkinsapi.node import Node DATA0 = { "assignedLabels": [{}], "description": None, "jobs": [], "mode": "NORMAL", "nodeDescription": "the master Jenkins node", "nodeName": "", "numExecutors": 2, "overallLoad": {}, "primaryView": {"name": "All", "url": "http://halob:8080/"}, "quietingDown": False, "slaveAgentPort": 0, "unlabeledLoad": {}, "useCrumbs": False, "useSecurity": False, "views": [ {"name": "All", "url": "http://halob:8080/"}, {"name": "FodFanFo", "url": "http://halob:8080/view/FodFanFo/"}, ], } DATA1 = { "busyExecutors": 0, "computer": [ { "actions": [], "displayName": "master", "executors": [{}, {}], "icon": "computer.png", "idle": True, "jnlpAgent": False, "launchSupported": True, "loadStatistics": {}, "manualLaunchAllowed": True, "monitorData": { "hudson.node_monitors.ArchitectureMonitor": "Linux (amd64)", "hudson.node_monitors.ClockMonitor": {"diff": 0}, "hudson.node_monitors.DiskSpaceMonitor": { "path": "/var/lib/jenkins", "size": 671924924416, }, "hudson.node_monitors.ResponseTimeMonitor": {"average": 0}, "hudson.node_monitors.SwapSpaceMonitor": { "availablePhysicalMemory": 3174686720, "availableSwapSpace": 17163087872, "totalPhysicalMemory": 16810180608, "totalSwapSpace": 17163087872, }, "hudson.node_monitors.TemporarySpaceMonitor": { "path": "/tmp", "size": 671924924416, }, }, "numExecutors": 2, "offline": False, "offlineCause": None, "oneOffExecutors": [], "temporarilyOffline": False, }, { "actions": [], "displayName": "bobnit", "executors": [{}], "icon": "computer-x.png", "idle": True, "jnlpAgent": False, "launchSupported": True, "loadStatistics": {}, "manualLaunchAllowed": True, "monitorData": { "hudson.node_monitors.ArchitectureMonitor": "Linux (amd64)", "hudson.node_monitors.ClockMonitor": {"diff": 4261}, "hudson.node_monitors.DiskSpaceMonitor": { "path": "/home/sal/jenkins", "size": 169784860672, }, "hudson.node_monitors.ResponseTimeMonitor": {"average": 29}, "hudson.node_monitors.SwapSpaceMonitor": { "availablePhysicalMemory": 4570710016, "availableSwapSpace": 12195983360, "totalPhysicalMemory": 8374497280, "totalSwapSpace": 12195983360, }, "hudson.node_monitors.TemporarySpaceMonitor": { "path": "/tmp", "size": 249737277440, }, }, "numExecutors": 1, "offline": True, "offlineCause": {}, "oneOffExecutors": [], "temporarilyOffline": False, }, { "actions": [], "displayName": "halob", "executors": [{}], "icon": "computer-x.png", "idle": True, "jnlpAgent": True, "launchSupported": False, "loadStatistics": {}, "manualLaunchAllowed": True, "monitorData": { "hudson.node_monitors.ArchitectureMonitor": None, "hudson.node_monitors.ClockMonitor": None, "hudson.node_monitors.DiskSpaceMonitor": None, "hudson.node_monitors.ResponseTimeMonitor": None, "hudson.node_monitors.SwapSpaceMonitor": None, "hudson.node_monitors.TemporarySpaceMonitor": None, }, "numExecutors": 1, "offline": True, "offlineCause": None, "oneOffExecutors": [], "temporarilyOffline": False, }, ], "displayName": "nodes", "totalExecutors": 2, } DATA2 = { "actions": [], "displayName": "master", "executors": [{}, {}], "icon": "computer.png", "idle": True, "jnlpAgent": False, "launchSupported": True, "loadStatistics": {}, "manualLaunchAllowed": True, "monitorData": { "hudson.node_monitors.ArchitectureMonitor": "Linux (amd64)", "hudson.node_monitors.ClockMonitor": {"diff": 0}, "hudson.node_monitors.DiskSpaceMonitor": { "path": "/var/lib/jenkins", "size": 671942561792, }, "hudson.node_monitors.ResponseTimeMonitor": {"average": 0}, "hudson.node_monitors.SwapSpaceMonitor": { "availablePhysicalMemory": 2989916160, "availableSwapSpace": 17163087872, "totalPhysicalMemory": 16810180608, "totalSwapSpace": 17163087872, }, "hudson.node_monitors.TemporarySpaceMonitor": { "path": "/tmp", "size": 671942561792, }, }, "numExecutors": 2, "offline": False, "offlineCause": None, "oneOffExecutors": [], "temporarilyOffline": False, } DATA3 = { "actions": [], "displayName": "halob", "executors": [{}], "icon": "computer-x.png", "idle": True, "jnlpAgent": True, "launchSupported": False, "loadStatistics": {}, "manualLaunchAllowed": True, "monitorData": { "hudson.node_monitors.ArchitectureMonitor": None, "hudson.node_monitors.ClockMonitor": None, "hudson.node_monitors.DiskSpaceMonitor": None, "hudson.node_monitors.ResponseTimeMonitor": None, "hudson.node_monitors.SwapSpaceMonitor": None, "hudson.node_monitors.TemporarySpaceMonitor": None, }, "numExecutors": 1, "offline": True, "offlineCause": None, "oneOffExecutors": [], "temporarilyOffline": False, } @pytest.fixture(scope="function") def nodes(monkeypatch): def fake_jenkins_poll(cls, tree=None): # pylint: disable=unused-argument return DATA0 monkeypatch.setattr(Jenkins, "_poll", fake_jenkins_poll) def fake_nodes_poll(cls, tree=None): # pylint: disable=unused-argument return DATA1 monkeypatch.setattr(Nodes, "_poll", fake_nodes_poll) jenkins = Jenkins("http://foo:8080") return jenkins.get_nodes() def fake_node_poll(self, tree=None): # pylint: disable=unused-argument """ Fakes a poll of data by returning the correct section of the DATA1 test block. """ for node_poll in DATA1["computer"]: if node_poll["displayName"] == self.name: return node_poll return DATA2 def test_repr(nodes): # Can we produce a repr string for this object repr(nodes) def test_baseurl(nodes): assert nodes.baseurl == "http://foo:8080/computer" def test_get_master_node(nodes, monkeypatch): monkeypatch.setattr(Node, "_poll", fake_node_poll) node = nodes["master"] assert isinstance(node, Node) def test_get_nonmaster_node(nodes, monkeypatch): monkeypatch.setattr(Node, "_poll", fake_node_poll) node = nodes["halob"] assert isinstance(node, Node) def test_iterkeys(nodes): expected_names = set(["master", "bobnit", "halob"]) actual_names = set([n for n in nodes.iterkeys()]) assert actual_names == expected_names def test_keys(nodes): expected_names = set(["master", "bobnit", "halob"]) actual_names = set(nodes.keys()) assert actual_names == expected_names def items_test_case(nodes_method, monkeypatch): monkeypatch.setattr(Node, "_poll", fake_node_poll) expected_names = set(["master", "bobnit", "halob"]) actual_names = set() for name, node in nodes_method(): assert name == node.name assert isinstance(node, Node) actual_names.add(name) assert actual_names == expected_names def test_iteritems(nodes, monkeypatch): items_test_case(nodes.iteritems, monkeypatch) def test_items(nodes, monkeypatch): items_test_case(nodes.items, monkeypatch) def values_test_case(nodes_method, monkeypatch): monkeypatch.setattr(Node, "_poll", fake_node_poll) expected_names = set(["master", "bobnit", "halob"]) actual_names = set() for node in nodes_method(): assert isinstance(node, Node) actual_names.add(node.name) assert actual_names == expected_names def test_itervalues(nodes, monkeypatch): values_test_case(nodes.itervalues, monkeypatch) def test_values(nodes, monkeypatch): values_test_case(nodes.values, monkeypatch) jenkinsapi-0.3.23/jenkinsapi_tests/unittests/test_nodes_tree_optimization.py0000644000000000000000000000403513615410400024641 0ustar00""" Tests for issue #855: Nodes class should limit API requests with tree parameter. On large Jenkins instances with many nodes, fetching all node data times out. Using tree=computer[displayName] restricts the response and avoids timeouts. """ import pytest from unittest.mock import MagicMock import requests from jenkinsapi.nodes import Nodes class MockResponse(requests.Response): def __init__(self, content): super().__init__() self.status_code = 200 self._content = content self.encoding = "utf-8" def test_nodes_poll_uses_tree_parameter(monkeypatch): """Nodes._poll() must use tree=computer[displayName] to reduce payload.""" captured_calls = [] def fake_get_data(self, url, params=None, tree=None): captured_calls.append({"url": url, "params": params, "tree": tree}) return {"computer": [{"displayName": "node1"}]} monkeypatch.setattr( "jenkinsapi.jenkinsbase.JenkinsBase.get_data", fake_get_data ) jenkins_mock = MagicMock() nodes = Nodes("http://dummy/computer", jenkins_mock) nodes.poll() assert len(captured_calls) > 0, "get_data was not called" call = captured_calls[0] assert call["tree"] == "computer[displayName]", ( f"Expected tree='computer[displayName]', got tree={call['tree']}" ) def test_nodes_default_tree_when_none_specified(monkeypatch): """Nodes must use the default tree=computer[displayName] when poll() is called without tree.""" captured_calls = [] def fake_get_data(self, url, params=None, tree=None): captured_calls.append({"tree": tree}) return {"computer": [{"displayName": "node1"}]} monkeypatch.setattr( "jenkinsapi.jenkinsbase.JenkinsBase.get_data", fake_get_data ) jenkins_mock = MagicMock() nodes = Nodes("http://dummy/computer", jenkins_mock) nodes.poll() # No tree specified call = captured_calls[0] assert call["tree"] == "computer[displayName]", ( f"Expected default tree to be 'computer[displayName]', got {call['tree']}" ) jenkinsapi-0.3.23/jenkinsapi_tests/unittests/test_plugins.py0000644000000000000000000003246213615410400021372 0ustar00""" jenkinsapi_tests.test_plugins """ import mock # To run unittests on python 2.6 please use unittest2 library try: import unittest2 as unittest except ImportError: import unittest try: from StringIO import StringIO # python2 except ImportError: from io import BytesIO as StringIO # python3 import zipfile from jenkinsapi.jenkins import Requester from jenkinsapi.jenkins import Jenkins from jenkinsapi.plugins import Plugins from jenkinsapi.plugin import Plugin class TestPlugins(unittest.TestCase): DATA = { "plugins": [ { "deleted": False, "hasUpdate": True, "downgradable": False, "dependencies": [{}, {}, {}, {}], "longName": "Jenkins Subversion Plug-in", "active": True, "shortName": "subversion", "backupVersion": None, "url": "http://wiki.jenkins-ci.org/display/" "JENKINS/Subversion+Plugin", "enabled": True, "pinned": False, "version": "1.45", "supportsDynamicLoad": "MAYBE", "bundled": True, }, { "deleted": False, "hasUpdate": True, "downgradable": False, "dependencies": [{}, {}], "longName": "Maven Integration plugin", "active": True, "shortName": "maven-plugin", "backupVersion": None, "url": "http://wiki.jenkins-ci.org/display/JENKINS/" "Maven+Project+Plugin", "enabled": True, "pinned": False, "version": "1.521", "supportsDynamicLoad": "MAYBE", "bundled": True, }, ] } @mock.patch.object(Jenkins, "_poll") def setUp(self, _poll_jenkins): _poll_jenkins.return_value = {} self.J = Jenkins("http://localhost:8080") @mock.patch.object(Plugins, "_poll") def test_get_plugins(self, _poll_plugins): _poll_plugins.return_value = self.DATA # Can we produce a repr string for this object self.assertIsInstance(self.J.get_plugins(), Plugins) @mock.patch.object(Plugins, "_poll") def test_no_plugins_str(self, _poll_plugins): _poll_plugins.return_value = {} plugins = self.J.get_plugins() self.assertEqual(str(plugins), "[]") @mock.patch.object(Plugins, "_poll") def test_plugins_str(self, _poll_plugins): _poll_plugins.return_value = self.DATA plugins = self.J.get_plugins() self.assertEqual(str(plugins), "['maven-plugin', 'subversion']") @mock.patch.object(Plugins, "_poll") def test_plugins_len(self, _poll_plugins): _poll_plugins.return_value = self.DATA plugins = self.J.get_plugins() self.assertEqual(len(plugins), 2) @mock.patch.object(Plugins, "_poll") def test_plugins_contains(self, _poll_plugins): _poll_plugins.return_value = self.DATA plugins = self.J.get_plugins() self.assertIn("subversion", plugins) self.assertIn("maven-plugin", plugins) @mock.patch.object(Plugins, "_poll") def test_plugins_values(self, _poll_plugins): _poll_plugins.return_value = self.DATA p = Plugin( { "deleted": False, "hasUpdate": True, "downgradable": False, "dependencies": [{}, {}, {}, {}], "longName": "Jenkins Subversion Plug-in", "active": True, "shortName": "subversion", "backupVersion": None, "url": "http://wiki.jenkins-ci.org/display/JENKINS/" "Subversion+Plugin", "enabled": True, "pinned": False, "version": "1.45", "supportsDynamicLoad": "MAYBE", "bundled": True, } ) plugins = self.J.get_plugins().values() self.assertIn(p, plugins) @mock.patch.object(Plugins, "_poll") def test_plugins_keys(self, _poll_plugins): _poll_plugins.return_value = self.DATA plugins = self.J.get_plugins().keys() self.assertIn("subversion", plugins) self.assertIn("maven-plugin", plugins) @mock.patch.object(Plugins, "_poll") def test_plugins_empty(self, _poll_plugins): _poll_plugins.return_value = {} # list() is required here for python 3.x compatibility plugins = list(self.J.get_plugins().keys()) self.assertEqual([], plugins) @mock.patch.object(Plugins, "_poll") def test_plugin_get_by_name(self, _poll_plugins): _poll_plugins.return_value = self.DATA p = Plugin( { "deleted": False, "hasUpdate": True, "downgradable": False, "dependencies": [{}, {}, {}, {}], "longName": "Jenkins Subversion Plug-in", "active": True, "shortName": "subversion", "backupVersion": None, "url": "http://wiki.jenkins-ci.org/display/JENKINS/" "Subversion+Plugin", "enabled": True, "pinned": False, "version": "1.45", "supportsDynamicLoad": "MAYBE", "bundled": True, } ) plugin = self.J.get_plugins()["subversion"] self.assertEqual(p, plugin) @mock.patch.object(Plugins, "_poll") def test_get_plugin_details(self, _poll_plugins): _poll_plugins.return_value = self.DATA plugin = self.J.get_plugins()["subversion"] self.assertEqual("1.45", plugin.version) self.assertEqual("subversion", plugin.shortName) self.assertEqual("Jenkins Subversion Plug-in", plugin.longName) self.assertEqual( "http://wiki.jenkins-ci.org/display/JENKINS/Subversion+Plugin", plugin.url, ) @mock.patch.object(Requester, "post_xml_and_confirm_status") def test_install_plugin_bad_input(self, _post): with self.assertRaises(ValueError): self.J.install_plugin("test") @mock.patch.object(Requester, "post_xml_and_confirm_status") def test_delete_plugin_bad_input(self, _post): with self.assertRaises(ValueError): self.J.delete_plugin("test@latest") @mock.patch.object(Plugins, "update_center_dict") @mock.patch.object(Plugins, "_poll") @mock.patch.object(Plugins, "plugin_version_already_installed") @mock.patch.object(Plugins, "restart_required") @mock.patch.object(Plugins, "_wait_until_plugin_installed") @mock.patch.object(Requester, "post_xml_and_confirm_status") @mock.patch.object(Jenkins, "safe_restart") def test_install_plugin_good_input( self, _reboot, _post, _wait, _restart_required, already_installed, _poll_plugins, _center_dict, ): _poll_plugins.return_value = self.DATA already_installed.return_value = False self.J.install_plugin("test@latest") expected_data = ' ' _post.assert_called_with( "/".join( [self.J.baseurl, "pluginManager", "installNecessaryPlugins"] ), data=expected_data, ) @mock.patch.object(Plugins, "update_center_dict") @mock.patch.object(Plugins, "_poll") @mock.patch.object(Plugins, "plugin_version_already_installed") @mock.patch.object( Plugins, "restart_required", new_callable=mock.mock.PropertyMock ) @mock.patch.object(Plugins, "_wait_until_plugin_installed") @mock.patch.object(Requester, "post_xml_and_confirm_status") @mock.patch.object(Jenkins, "safe_restart") def test_install_plugins_good_input_no_restart_required( self, _restart, _post, _wait, restart_required, already_installed, _poll_plugins, _center_dict, ): _poll_plugins.return_value = self.DATA restart_required.return_value = False already_installed.return_value = False self.J.install_plugins(["test@latest", "test@latest"]) self.assertEqual(_post.call_count, 2) self.assertEqual(_restart.call_count, 0) @mock.patch.object(Plugins, "update_center_dict") @mock.patch.object(Plugins, "_poll") @mock.patch.object(Plugins, "plugin_version_already_installed") @mock.patch.object( Plugins, "restart_required", new_callable=mock.mock.PropertyMock ) @mock.patch.object(Plugins, "_wait_until_plugin_installed") @mock.patch.object(Requester, "post_xml_and_confirm_status") @mock.patch.object(Jenkins, "safe_restart") def test_install_plugins_good_input_with_restart_required( self, _restart, _post, _wait, restart_required, already_installed, _poll_plugins, _center_dict, ): _poll_plugins.return_value = self.DATA restart_required.return_value = True already_installed.return_value = False self.J.install_plugins(["test@latest", "test@latest"]) self.assertEqual(_post.call_count, 2) self.assertEqual(_restart.call_count, 1) @mock.patch.object(Plugins, "_poll") def test_get_plugin_dependencies(self, _poll_plugins): manifest = ( "Manifest-Version: 1.0\n" "bla: somestuff\n" "Plugin-Dependencies: aws-java-sdk:1.10.45,aws-credentials:1.15" ) downloaded_plugin = StringIO() zipfile.ZipFile(downloaded_plugin, mode="w").writestr( "META-INF/MANIFEST.MF", manifest ) _poll_plugins.return_value = self.DATA dependencies = self.J.plugins._get_plugin_dependencies( downloaded_plugin ) self.assertEqual(len(dependencies), 2) for dep in dependencies: self.assertIsInstance(dep, Plugin) @mock.patch.object(Plugins, "update_center_dict") @mock.patch.object(Plugins, "_poll") def test_plugin_version_already_installed(self, _poll_plugins, _update): _poll_plugins.return_value = self.DATA already_installed = Plugin( {"shortName": "subversion", "version": "1.45"} ) self.assertTrue( self.J.plugins.plugin_version_already_installed(already_installed) ) not_installed = Plugin({"shortName": "subversion", "version": "1.46"}) self.assertFalse( self.J.plugins.plugin_version_already_installed(not_installed) ) latest = Plugin({"shortName": "subversion", "version": "latest"}) self.assertFalse( self.J.plugins.plugin_version_already_installed(latest) ) @mock.patch.object(Plugins, "_poll") @mock.patch.object( Plugins, "update_center_install_status", new_callable=mock.mock.PropertyMock, ) def test_restart_required_after_plugin_installation( self, status, _poll_plugins ): _poll_plugins.return_value = self.DATA status.return_value = { "data": { "jobs": [ { "installStatus": "SuccessButRequiresRestart", "name": "credentials", "requiresRestart": "true", "title": None, "version": "0", } ], "state": "RUNNING", }, "status": "ok", } self.assertTrue(self.J.plugins.restart_required) @mock.patch.object(Plugins, "_poll") @mock.patch.object( Plugins, "update_center_install_status", new_callable=mock.mock.PropertyMock, ) def test_restart_not_required_after_plugin_installation( self, status, _poll_plugins ): _poll_plugins.return_value = self.DATA status.return_value = { "data": {"jobs": [], "state": "RUNNING"}, "status": "ok", } self.assertFalse(self.J.plugins.restart_required) def test_plugin_repr(self): p = Plugin( { "shortName": "subversion", } ) self.assertEqual(repr(p), "") @mock.patch.object(Plugins, "_poll") @mock.patch.object(Requester, "post_and_confirm_status") def test_downgrade_plugin_accepts_not_found_as_noop( self, post, _poll_plugins ): plugins = Plugins("http://localhost:8080/pluginManager", self.J) plugins.downgrade_plugin("mailer") post.assert_called_with( "http://localhost:8080/pluginManager/plugin/mailer/downgrade", params={}, data="", valid=[200, 404], ) @mock.patch.object(Plugins, "_poll") @mock.patch.object(Requester, "post_and_confirm_status") def test_unpin_plugin_accepts_not_found_as_noop(self, post, _poll_plugins): plugins = Plugins("http://localhost:8080/pluginManager", self.J) plugins.unpin_plugin("mailer") post.assert_called_with( "http://localhost:8080/pluginManager/plugin/mailer/unpin", params={}, data="", valid=[200, 404], ) if __name__ == "__main__": unittest.main() jenkinsapi-0.3.23/jenkinsapi_tests/unittests/test_queue.py0000644000000000000000000000170013615410400021024 0ustar00import mock import pytest from jenkinsapi.queue import Queue, QueueItem @pytest.fixture def queue(monkeypatch): def fake_poll(self, tree=None): # pylint: disable=unused-argument return { "items": [ { "id": 1, "task": { "name": "folder1/folder2/job-name", "url": "http://localhost:8080/job/folder1/job/folder2/job/job-name/", }, } ] } def fake_queue_item_poll(self, tree=None): # pylint: disable=unused-argument return {} monkeypatch.setattr(Queue, "_poll", fake_poll) monkeypatch.setattr(QueueItem, "_poll", fake_queue_item_poll) return Queue("http://localhost:8080/queue", mock.MagicMock()) def test_queue_items_for_job_normalizes_path(queue): items = queue.get_queue_items_for_job("folder1/folder2/job-name") assert len(items) == 1 jenkinsapi-0.3.23/jenkinsapi_tests/unittests/test_requester.py0000644000000000000000000002703513615410400021730 0ustar00import pytest import requests from jenkinsapi.jenkins import Requester from jenkinsapi.custom_exceptions import JenkinsAPIException from mock import patch def test_no_parameters_uses_default_values(): req = Requester() assert isinstance(req, Requester) assert req.username is None assert req.password is None assert req.ssl_verify assert req.cert is None assert req.base_scheme is None assert req.timeout == 10 def test_all_named_parameters(): req = Requester( username="foo", password="bar", ssl_verify=False, cert="foobar", baseurl="http://dummy", timeout=5, ) assert isinstance(req, Requester) assert req.username == "foo" assert req.password == "bar" assert not req.ssl_verify assert req.cert == "foobar" assert req.base_scheme == "http", "dummy" assert req.timeout == 5 def test_mix_one_unnamed_named_parameters(): req = Requester( "foo", password="bar", ssl_verify=False, cert="foobar", baseurl="http://dummy", timeout=5, ) assert isinstance(req, Requester) assert req.username == "foo" assert req.password == "bar" assert not req.ssl_verify assert req.cert == "foobar" assert req.base_scheme == "http", "dummy" assert req.timeout == 5 def test_mix_two_unnamed_named_parameters(): req = Requester( "foo", "bar", ssl_verify=False, cert="foobar", baseurl="http://dummy", timeout=5, ) assert isinstance(req, Requester) assert req.username == "foo" assert req.password == "bar" assert not req.ssl_verify assert req.cert == "foobar" assert req.base_scheme == "http", "dummy" assert req.timeout == 5 def test_mix_three_unnamed_named_parameters(): req = Requester( "foo", "bar", False, cert="foobar", baseurl="http://dummy", timeout=5 ) assert isinstance(req, Requester) assert req.username == "foo" assert req.password == "bar" assert not req.ssl_verify assert req.cert == "foobar" assert req.base_scheme == "http", "dummy" assert req.timeout == 5 def test_mix_four_unnamed_named_parameters(): req = Requester( "foo", "bar", False, "foobar", baseurl="http://dummy", timeout=5 ) assert isinstance(req, Requester) assert req.username == "foo" assert req.password == "bar" assert not req.ssl_verify assert req.cert == "foobar" assert req.base_scheme == "http", "dummy" assert req.timeout == 5 def test_mix_five_unnamed_named_parameters(): req = Requester("foo", "bar", False, "foobar", "http://dummy", timeout=5) assert isinstance(req, Requester) assert req.username == "foo" assert req.password == "bar" assert not req.ssl_verify assert req.cert == "foobar" assert req.base_scheme == "http", "dummy" assert req.timeout == 5 def test_all_unnamed_parameters(): req = Requester("foo", "bar", False, "foobar", "http://dummy", 5) assert isinstance(req, Requester) assert req.username == "foo" assert req.password == "bar" assert not req.ssl_verify assert req.cert == "foobar" assert req.base_scheme == "http", "dummy" assert req.timeout == 5 def test_to_much_unnamed_parameters_raises_error(): with pytest.raises(Exception): Requester("foo", "bar", False, "foobar", "http://dummy", 5, "test") def test_username_without_password_raises_error(): with pytest.raises(Exception): Requester(username="foo") Requester("foo") def test_password_without_username_raises_error(): with pytest.raises(AssertionError): Requester(password="bar") def test_get_request_dict_auth(): req = Requester("foo", "bar") req_return = req.get_request_dict(params={}, data=None, headers=None) assert isinstance(req_return, dict) assert req_return.get("auth") assert req_return["auth"] == ("foo", "bar") @patch("jenkinsapi.jenkins.Requester.AUTH_COOKIE", "FAKE") def test_get_request_dict_cookie(): req = Requester("foo", "bar") req_return = req.get_request_dict(params={}, data=None, headers=None) assert isinstance(req_return, dict) assert req_return.get("headers") assert req_return.get("headers").get("Cookie") assert req_return.get("headers").get("Cookie") == "FAKE" @patch("jenkinsapi.jenkins.Requester.AUTH_COOKIE", "FAKE") def test_get_request_dict_updatecookie(): req = Requester("foo", "bar") req_return = req.get_request_dict( params={}, data=None, headers={"key": "value"} ) assert isinstance(req_return, dict) assert req_return.get("headers") assert req_return.get("headers").get("key") assert req_return.get("headers").get("key") == "value" assert req_return.get("headers").get("Cookie") assert req_return.get("headers").get("Cookie") == "FAKE" def test_get_request_dict_nocookie(): req = Requester("foo", "bar") req_return = req.get_request_dict(params={}, data=None, headers=None) assert isinstance(req_return, dict) assert not req_return.get("headers") def test_get_request_dict_wrong_params(): req = Requester("foo", "bar") with pytest.raises(AssertionError) as na: req.get_request_dict(params="wrong", data=None, headers=None) assert "Params must be a dict, got 'wrong'" in str(na.value) def test_get_request_dict_correct_params(): req = Requester("foo", "bar") req_return = req.get_request_dict( params={"param": "value"}, data=None, headers=None ) assert isinstance(req_return, dict) assert req_return.get("params") assert req_return["params"] == {"param": "value"} def test_get_request_dict_wrong_headers(): req = Requester("foo", "bar") with pytest.raises(AssertionError) as na: req.get_request_dict(params={}, data=None, headers="wrong") assert "headers must be a dict, got 'wrong'" in str(na.value) def test_get_request_dict_correct_headers(): req = Requester("foo", "bar") req_return = req.get_request_dict( params={"param": "value"}, data=None, headers={"header": "value"} ) assert isinstance(req_return, dict) assert req_return.get("headers") assert req_return["headers"] == {"header": "value"} def test_get_request_dict_data_passed(): req = Requester("foo", "bar") req_return = req.get_request_dict( params={"param": "value"}, data="some data", headers={"header": "value"}, ) assert isinstance(req_return, dict) assert req_return.get("data") assert req_return["data"] == "some data" def test_get_request_dict_data_not_passed(): req = Requester("foo", "bar") req_return = req.get_request_dict( params={"param": "value"}, data=None, headers={"header": "value"} ) assert isinstance(req_return, dict) assert req_return.get("data") is None def test_get_url_get(monkeypatch): def fake_get(*args, **kwargs): # pylint: disable=unused-argument return "SUCCESS" monkeypatch.setattr(requests.Session, "get", fake_get) req = Requester("foo", "bar") response = req.get_url( "http://dummy", params={"param": "value"}, headers=None ) assert response == "SUCCESS" def test_get_url_post(monkeypatch): def fake_post(*args, **kwargs): # pylint: disable=unused-argument return "SUCCESS" monkeypatch.setattr(requests.Session, "post", fake_post) req = Requester("foo", "bar") response = req.post_url( "http://dummy", params={"param": "value"}, headers=None ) assert response == "SUCCESS" def test_post_xml_empty_xml(monkeypatch): def fake_post(*args, **kwargs): # pylint: disable=unused-argument return "SUCCESS" monkeypatch.setattr(requests.Session, "post", fake_post) req = Requester("foo", "bar") with pytest.raises(AssertionError): req.post_xml_and_confirm_status( url="http://dummy", params={"param": "value"}, data=None ) def test_post_xml_and_confirm_status_some_xml(monkeypatch): class FakeResponse(requests.Response): def __init__(self, *args, **kwargs): # pylint: disable=unused-argument self.status_code = 200 def fake_post(*args, **kwargs): # pylint: disable=unused-argument return FakeResponse() monkeypatch.setattr(requests.Session, "post", fake_post) req = Requester("foo", "bar") ret = req.post_xml_and_confirm_status( url="http://dummy", params={"param": "value"}, data="" ) assert isinstance(ret, requests.Response) def test_post_and_confirm_status_empty_data(monkeypatch): def fake_post(*args, **kwargs): # pylint: disable=unused-argument return "SUCCESS" monkeypatch.setattr(requests.Session, "post", fake_post) req = Requester("foo", "bar") with pytest.raises(AssertionError): req.post_and_confirm_status( url="http://dummy", params={"param": "value"}, data=None ) def test_post_and_confirm_status_some_data(monkeypatch): class FakeResponse(requests.Response): def __init__(self, *args, **kwargs): # pylint: disable=unused-argument self.status_code = 200 def fake_post(*args, **kwargs): # pylint: disable=unused-argument return FakeResponse() monkeypatch.setattr(requests.Session, "post", fake_post) req = Requester("foo", "bar") ret = req.post_and_confirm_status( url="http://dummy", params={"param": "value"}, data="some data" ) assert isinstance(ret, requests.Response) def test_post_and_confirm_status_bad_result(monkeypatch): class FakeResponse(object): def __init__(self, *args, **kwargs): # pylint: disable=unused-argument self.status_code = 500 self.url = "http://dummy" self.text = "something" def fake_post(*args, **kwargs): # pylint: disable=unused-argument return FakeResponse() monkeypatch.setattr(requests.Session, "post", fake_post) req = Requester("foo", "bar") with pytest.raises(JenkinsAPIException) as error: req.post_and_confirm_status( url="http://dummy", params={"param": "value"}, data="some data" ) assert "status=500" in str(error) def test_get_and_confirm_status(monkeypatch): class FakeResponse(requests.Response): def __init__(self, *args, **kwargs): # pylint: disable=unused-argument self.status_code = 200 def fake_get(*args, **kwargs): # pylint: disable=unused-argument return FakeResponse() monkeypatch.setattr(requests.Session, "get", fake_get) req = Requester("foo", "bar") ret = req.get_and_confirm_status( url="http://dummy", params={"param": "value"} ) assert isinstance(ret, requests.Response) def test_get_and_confirm_status_bad_result(monkeypatch): class FakeResponse(object): def __init__(self, *args, **kwargs): # pylint: disable=unused-argument self.status_code = 500 self.url = "http://dummy" self.text = "something" def fake_get(*args, **kwargs): # pylint: disable=unused-argument return FakeResponse() monkeypatch.setattr(requests.Session, "get", fake_get) req = Requester("foo", "bar", baseurl="http://dummy") with pytest.raises(JenkinsAPIException) as error: req.get_and_confirm_status( url="http://dummy", params={"param": "value"} ) assert "status=500" in str(error) def test_configure_max_retries(): req = Requester( "username", "password", baseurl="http://dummy", max_retries=3 ) for adapter in req.session.adapters.values(): assert adapter.max_retries.total == 3 jenkinsapi-0.3.23/jenkinsapi_tests/unittests/test_result_set.py0000644000000000000000000001105513615410400022075 0ustar00import mock # To run unittests on python 2.6 please use unittest2 library try: import unittest2 as unittest except ImportError: import unittest from jenkinsapi.result_set import ResultSet from jenkinsapi.result import Result class TestResultSet(unittest.TestCase): DATA = { "duration": 0.0, "failCount": 2, "passCount": 0, "skipCount": 0, "suites": [ { "cases": [ { "age": 1, "className": ":setup", "skipped": False, "status": "FAILED", "stderr": None, "stdout": None, }, { "age": 1, "className": "nose.failure.Failure", "duration": 0.0, "errorDetails": "No module named mock", "errorStackTrace": 'Traceback (most recent call last):\n File "/usr/lib/python2.7/unittest/case.py", line 332, in run\n testMethod()\n File "/usr/lib/python2.7/dist-packages/nose/loader.py", line 390, in loadTestsFromName\n addr.filename, addr.module)\n File "/usr/lib/python2.7/dist-packages/nose/importer.py", line 39, in importFromPath\n return self.importFromDir(dir_path, fqname)\n File "/usr/lib/python2.7/dist-packages/nose/importer.py", line 86, in importFromDir\n mod = load_module(part_fqname, fh, filename, desc)\n File "/var/lib/jenkins/jobs/test_jenkinsapi/workspace/jenkinsapi/src/jenkinsapi_tests/unittests/test_build.py", line 1, in \n import mock\nImportError: No module named mock\n', # noqa "failedSince": 88, "name": "runTest", "skipped": False, "status": "FAILED", "stderr": None, "stdout": None, }, ], "duration": 0.0, "id": None, "name": "nosetests", "stderr": None, "stdout": None, "timestamp": None, } ], "childReports": [ {"child": {"number": 1915, "url": "url1"}, "result": None}, ], } @mock.patch.object(ResultSet, "_poll") def setUp(self, _poll): _poll.return_value = self.DATA # def __init__(self, url, build ): self.b = mock.MagicMock() # Build object self.b.__str__.return_value = "FooBuild" self.rs = ResultSet("http://", self.b) def testRepr(self): # Can we produce a repr string for this object repr(self.rs) def testName(self): with self.assertRaises(AttributeError): self.rs.id() self.assertEqual(self.rs.name, "Test Result for FooBuild") def testBuildComponents(self): self.assertTrue(self.rs.items()) for k, v in self.rs.items(): self.assertIsInstance(k, str) self.assertIsInstance(v, Result) self.assertIsInstance(v.identifier(), str) if __name__ == "__main__": unittest.main() jenkinsapi-0.3.23/jenkinsapi_tests/unittests/test_retry.py0000644000000000000000000000336613615410400021057 0ustar00from contextlib import ExitStack from unittest import mock import pytest from jenkinsapi.utils.retry import RetryConfig, RetryState, SimpleRetryConfig def validate_retry_check( retry_config: RetryConfig, pass_index: int, expected_sleep_count: int, expected_pass: bool = True, ) -> None: """Check if the retry check works as expected.""" attempt_index = 0 success = False with ExitStack() as exit_stack: exit_stack.enter_context( mock.patch("time.monotonic", side_effect=range(100, 1000)) ) mock_sleep = exit_stack.enter_context(mock.patch("time.sleep")) if not expected_pass: exit_stack.enter_context(pytest.raises(TimeoutError)) retry_state = retry_config.begin() assert isinstance(retry_state, RetryState) while True: attempt_index += 1 if attempt_index >= pass_index: success = True break retry_state.check_retry() if expected_pass: assert success else: assert success is False assert mock_sleep.call_count == expected_sleep_count def test_simple_retry_check(): retry_config = SimpleRetryConfig(sleep_period=1, timeout=5) validate_retry_check( retry_config, pass_index=3, expected_sleep_count=2, expected_pass=True, ) def test_simple_retry_check_fail(): retry_config = SimpleRetryConfig(sleep_period=1, timeout=5) validate_retry_check( retry_config, pass_index=10, expected_sleep_count=5, expected_pass=False, ) def test_repr(): retry_config = SimpleRetryConfig(sleep_period=1, timeout=5) assert repr(retry_config) == "SimpleRetryConfig(sleep_period=1, timeout=5)" jenkinsapi-0.3.23/jenkinsapi_tests/unittests/test_view.py0000644000000000000000000001573513615410400020667 0ustar00import unittest.mock as mock import pytest from jenkinsapi.jenkinsbase import JenkinsBase from jenkinsapi.view import View from jenkinsapi.job import Job from jenkinsapi.custom_exceptions import NotFound DATA = { "description": "Important Shizz", "jobs": [ {"color": "blue", "name": "foo", "url": "http://halob:8080/job/foo/"}, { "color": "red", "name": "test_jenkinsapi", "url": "http://halob:8080/job/test_jenkinsapi/", }, ], "name": "FodFanFo", "property": [], "url": "http://halob:8080/view/FodFanFo/", } JOB_DATA = { "actions": [], "description": "test job", "displayName": "foo", "displayNameOrNull": None, "name": "foo", "url": "http://halob:8080/job/foo/", "buildable": True, "builds": [ {"number": 3, "url": "http://halob:8080/job/foo/3/"}, {"number": 2, "url": "http://halob:8080/job/foo/2/"}, {"number": 1, "url": "http://halob:8080/job/foo/1/"}, ], "color": "blue", "firstBuild": {"number": 1, "url": "http://halob:8080/job/foo/1/"}, "healthReport": [ { "description": "Build stability: No recent builds failed.", "iconUrl": "health-80plus.png", "score": 100, } ], "inQueue": False, "keepDependencies": False, "lastBuild": {"number": 3, "url": "http://halob:8080/job/foo/3/"}, "lastCompletedBuild": {"number": 3, "url": "http://halob:8080/job/foo/3/"}, "lastFailedBuild": None, "lastStableBuild": {"number": 3, "url": "http://halob:8080/job/foo/3/"}, "lastSuccessfulBuild": { "number": 3, "url": "http://halob:8080/job/foo/3/", }, "lastUnstableBuild": None, "lastUnsuccessfulBuild": None, "nextBuildNumber": 4, "property": [], "queueItem": None, "concurrentBuild": False, "downstreamProjects": [], "scm": {}, "upstreamProjects": [], } @pytest.fixture def jenkins(): jenkins = mock.MagicMock(autospec=True) jenkins.has_job.return_value = False return jenkins @pytest.fixture @mock.patch.object(Job, "_poll", autospec=True) @mock.patch.object(View, "_poll", autospec=True) def view(_view_poll, _job_poll, jenkins): _view_poll.return_value = DATA _job_poll.return_value = JOB_DATA return View("http://localhost:800/view/FodFanFo", "FodFanFo", jenkins) @pytest.fixture def jenkins_patch(): class Jenkins: def has_job(self, job_name): return False def get_jenkins_obj_from_url(self, url): return self return Jenkins @pytest.fixture def busy_patch(): class Jenkins: def has_job(self, job_name): return True def get_jenkins_obj_from_url(self, url): return self return Jenkins class TestView: def test_returns_name_when_repr_is_called(self, view): assert repr(view) == "FodFanFo" def test_returns_name_when_str_method_called(self, view): assert str(view) == "FodFanFo" def test_raises_error_when_is_called(self, view): with pytest.raises(AttributeError): view.id() def test_returns_name_when_name_property_is_called(self, view): assert view.name == "FodFanFo" @mock.patch.object(JenkinsBase, "_poll") def test_iteritems(self, _poll, view): _poll.return_value = JOB_DATA for job_name, job_obj in view.iteritems(): assert isinstance(job_obj, Job) assert job_name in ["foo", "test_jenkinsapi"] def test_returns_dict_of_job_info_when_job_dict_method_called(self, view): jobs = view.get_job_dict() assert jobs == { "foo": "http://halob:8080/job/foo/", "test_jenkinsapi": "http://halob:8080/job/test_jenkinsapi/", } def test_returns_len_when_len_is_called(self, view): assert len(view) == 2 @mock.patch.object(JenkinsBase, "_poll") def test_getitem(self, _poll, view): _poll.return_value = JOB_DATA assert isinstance(view["foo"], Job) def test_sets_delete_to_true_when_deleted(self, view): view.delete() assert view.deleted def test_returns_url_when_get_job_url_is_called(self, view): url = view.get_job_url("foo") assert url == "http://halob:8080/job/foo/" def test_get_job_url_uses_job_name(self, view): url = view.get_job_url("foo") assert url == "http://halob:8080/job/foo/" def test_raises_not_found_when_get_job_url_is_invalid(self, view): with pytest.raises(NotFound): view.get_job_url("bar") @mock.patch.object(View, "get_jenkins_obj") def test_returns_false_when_adding_wrong_job( self, _get_jenkins, view, jenkins_patch ): _get_jenkins.return_value = jenkins_patch() result = view.add_job("bar") assert result is False def test_returns_false_when_add_existing_job(self, view): result = view.add_job("foo") assert result is False def test_get_nested_view_dict(self, view): result = view.get_nested_view_dict() assert isinstance(result, dict) def test_returns_jenkins_obj_when_get_jenkins_obj_is_called(self, view): obj = view.get_jenkins_obj() assert obj == view.jenkins_obj class TestKeys: def test_returns_key_when_called(self, view): keys = view.keys() assert "foo" in list(keys) assert "test_jenkinsapi" in list(keys) class TestAddJob: @mock.patch.object(JenkinsBase, "_poll") def test_returns_true_when_no_job_provided(self, _poll, view): _poll.return_value = DATA result = view.add_job("bar") assert result is True @mock.patch.object(JenkinsBase, "_poll") def test_returns_false_when_already_registered(self, _poll, view): _poll.return_value = DATA result = view.add_job("foo") assert result is False @mock.patch.object(View, "get_jenkins_obj") def test_returns_false_when_jenkins_has_job( self, _get_jenkins, view, jenkins_patch ): _get_jenkins.return_value = jenkins_patch() result = view.add_job("Foo") _get_jenkins.assert_called() assert result is False @mock.patch.object(View, "get_jenkins_obj") @mock.patch.object(JenkinsBase, "_poll") def test_returns_true_when_jenkins_has_job( self, _poll, _get_jenkins, view, jenkins ): _get_jenkins.return_value = jenkins() _poll.return_value = DATA result = view.add_job("Foo") _get_jenkins.assert_called() assert result is True class TestRemove: @mock.patch.object(JenkinsBase, "_poll") def test_returns_true_when_job_has_been_removed(self, _poll, view): _poll.return_value = DATA result = view.remove_job("foo") assert result is True @mock.patch.object(JenkinsBase, "_poll") def test_returns_false_when_job_does_not_exist(self, _poll, view): _poll.return_value = DATA result = view.remove_job("Non-existant Foo") assert result is False jenkinsapi-0.3.23/.gitignore0000644000000000000000000000054313615410400012644 0ustar00.svn .project .pydevproject *.pyc *.egg-info /build /dist .settings *.DS_Store localinstance_files/ .coverage/ .coverage nosetests.xml coverage*/ .idea/ include/ lib/ *.egg .tox/* *.sw? /jenkinsapi_tests/systests/coverage.xml /.cache /AUTHORS /ChangeLog .pytest_cache .eggs coverage.xml .venv/ .vscode/ *.war venv/ tags .pytype/ .ruff_cache/ __pycache__/ jenkinsapi-0.3.23/LICENSE0000644000000000000000000000205313615410400011657 0ustar00MIT License Copyright (c) 2023 PyContribs 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 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. jenkinsapi-0.3.23/README.rst0000644000000000000000000001227513615410400012350 0ustar00Jenkinsapi ========== .. image:: https://badge.fury.io/py/jenkinsapi.png :target: http://badge.fury.io/py/jenkinsapi Installation ------------ .. code-block:: bash pip install jenkinsapi Logging ------- JenkinsAPI emits request/response debug logs when enabled. You can opt in with the environment variable or by calling the helper: .. code-block:: bash export JENKINSAPI_LOG_LEVEL=DEBUG .. code-block:: python from jenkinsapi.utils.logging import configure_logging configure_logging("INFO") Important Links --------------- * `Documentation `__ * `Source Code `_ * `Support and bug-reports `_ * `Releases `_ About this library ------------------- Jenkins is the market leading continuous integration system. Jenkins (and its predecessor Hudson) are useful projects for automating common development tasks (e.g. unit-testing, production batches) - but they are somewhat Java-centric. Jenkinsapi makes scripting Jenkins tasks a breeze by wrapping the REST api into familiar python objects. Here is a list of some of the most commonly used functionality * Add, remove, and query Jenkins jobs * Control pipeline execution * Query the results of a completed build * Block until jobs are complete or run jobs asyncronously * Get objects representing the latest builds of a job * Artifact management * Search for artifacts by simple criteria * Install artifacts to custom-specified directory structures * Search for builds by source code revision * Create, destroy, and monitor * Build nodes (Webstart and SSH slaves) * Views (including nested views using NestedViews Jenkins plugin) * Credentials (username/password and ssh key) * Authentication support for username and password * Manage jenkins and plugin installation Full library capabilities are outlined in the `Documentation `__ Get details of jobs running on Jenkins server --------------------------------------------- .. code-block:: python """Get job details of each job that is running on the Jenkins instance""" def get_job_details(): # Refer Example #1 for definition of function 'get_server_instance' server = get_server_instance() for job_name, job_instance in server.get_jobs(): print('Job Name:%s' % (job_instance.name)) print('Job Description:%s' % (job_instance.get_description())) print('Is Job running:%s' % (job_instance.is_running())) print('Is Job enabled:%s' % (job_instance.is_enabled())) Disable/Enable a Jenkins Job ---------------------------- .. code-block:: python def disable_job(): """Disable a Jenkins job""" # Refer Example #1 for definition of function 'get_server_instance' server = get_server_instance() job_name = 'nightly-build-job' if (server.has_job(job_name)): job_instance = server.get_job(job_name) job_instance.disable() print('Name:%s,Is Job Enabled ?:%s' % (job_name, job_instance.is_enabled())) Use the call ``job_instance.enable()`` to enable a Jenkins Job. Creating jobs in folders ------------------------ When using the Folders plugin, you can pass folder paths directly: .. code-block:: python jenkins.create_job("folder1/folder2/job-name", config_xml) jenkins.create_job("/job/folder1/job/folder2/job-name", config_xml) Known issues ------------ * Job deletion operations fail unless Cross-Site scripting protection is disabled. For other issues, please refer to the `support URL `_ Development ----------- * Make sure that you have Java_ installed. Jenkins will be automatically downloaded and started during tests. * Create virtual environment for development * Install package in development mode .. code-block:: bash uv sync * Make your changes, write tests and check your code .. code-block:: bash uv run pytest -sv Python versions --------------- The project has been tested against Python versions: <<<<<<< HEAD * 3.10 - 3.14 ======= * 3.10 - 3.14 (tested via CI) >>>>>>> e554089 (docs: comprehensive documentation and examples updates) Jenkins versions ---------------- Project tested on both stable (LTS) and latest Jenkins versions. Project Contributors -------------------- * Aleksey Maksimov (ctpeko3a@gmail.com) * Salim Fadhley (sal@stodge.org) * Ramon van Alteren (ramon@vanalteren.nl) * Ruslan Lutsenko (ruslan.lutcenko@gmail.com) * Cleber J Santos (cleber@simplesconsultoria.com.br) * William Zhang (jollychang@douban.com) * Victor Garcia (bravejolie@gmail.com) * Bradley Harris (bradley@ninelb.com) * Kyle Rockman (kyle.rockman@mac.com) * Sascha Peilicke (saschpe@gmx.de) * David Johansen (david@makewhat.is) * Misha Behersky (bmwant@gmail.com) * Clinton Steiner (clintonsteiner@gmail.com) Please do not contact these contributors directly for support questions! Use the GitHub tracker instead. .. _Java: https://www.oracle.com/java/technologies/downloads/#java21 jenkinsapi-0.3.23/pyproject.toml0000644000000000000000000000505713615410400013575 0ustar00[build-system] requires = ["hatchling"] build-backend = "hatchling.build" [project] name = "jenkinsapi" version = "0.3.23" authors = [ {name = "Salim Fadhley", email = "salimfadhley@gmail.com"}, {name = "Aleksey Maksimov", email = "ctpeko3a@gmail.com"}, {name = "Clinton Steiner", email = "clintonsteiner@gmail.com"}, ] maintainers = [ {name = "Aleksey Maksimov", email = "ctpeko3a@gmail.com"}, {name = "Clinton Steiner", email = "clintonsteiner@gmail.com"}, ] description = "A Python API for accessing resources on a Jenkins continuous-integration server." readme = "README.rst" license = {text = "MIT license"} classifiers = [ "Development Status :: 5 - Production/Stable", "Environment :: Console", "Intended Audience :: Developers", "Intended Audience :: Information Technology", "Intended Audience :: System Administrators", "License :: OSI Approved :: MIT License", "Natural Language :: English", "Operating System :: OS Independent", "Programming Language :: Python", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.14", "Topic :: Software Development :: Testing", "Topic :: Utilities", ] requires-python = ">=3.10" dependencies = [ "pytz>=2014.4", "requests>=2.33.0", ] [tool.hatch.build.targets.wheel] packages = ["jenkinsapi", "jenkinsapi_tests"] [tool.hatch.build.targets.sdist] exclude = [ "/.github", "/docker", "/doc", "/.pre-commit-config.yaml", ] [project.scripts] jenkins_invoke = "jenkinsapi.command_line.jenkins_invoke:main" jenkinsapi_version = "jenkinsapi.command_line.jenkinsapi_version:main" [dependency-groups] dev = [ "pytest-mock>=3.14.0", "pytest>=8.3.4", "pytest-cov>=4.0.0", "pytest-timeout>=2.3.0", "pytest-xdist>=3.5.0", "mock>=5.1.0", "requests-kerberos>=0.15.0", "ruff>=0.9.6", "docker>=6.0.0", ] docs = [ "docutils>=0.20.1", "furo>=2024.8.6", "myst-parser>=3.0.1", "pygments>=2.20.0", "sphinx>=7.1.2", ] [tool.ruff] line-length = 79 [tool.ruff.lint] select = ["E9", "F63", "F7", "F82"] # Equivalent to flake8’s default rules ignore = ["F821"] #, "W503", "W504" [tool.pytest.ini_options] timeout = 120 timeout_method = "thread" markers = [ "docker: marks tests as requiring Docker (deselect with -m \"not docker\")", "generate_new_api_token: marks tests for Jenkins API token generation", ] jenkinsapi-0.3.23/PKG-INFO0000644000000000000000000001501513615410400011751 0ustar00Metadata-Version: 2.4 Name: jenkinsapi Version: 0.3.23 Summary: A Python API for accessing resources on a Jenkins continuous-integration server. Author-email: Salim Fadhley , Aleksey Maksimov , Clinton Steiner Maintainer-email: Aleksey Maksimov , Clinton Steiner License: MIT license License-File: LICENSE Classifier: Development Status :: 5 - Production/Stable Classifier: Environment :: Console Classifier: Intended Audience :: Developers Classifier: Intended Audience :: Information Technology Classifier: Intended Audience :: System Administrators Classifier: License :: OSI Approved :: MIT License Classifier: Natural Language :: English Classifier: Operating System :: OS Independent Classifier: Programming Language :: Python Classifier: Programming Language :: Python :: 3 Classifier: Programming Language :: Python :: 3.10 Classifier: Programming Language :: Python :: 3.11 Classifier: Programming Language :: Python :: 3.12 Classifier: Programming Language :: Python :: 3.13 Classifier: Programming Language :: Python :: 3.14 Classifier: Topic :: Software Development :: Testing Classifier: Topic :: Utilities Requires-Python: >=3.10 Requires-Dist: pytz>=2014.4 Requires-Dist: requests>=2.33.0 Description-Content-Type: text/x-rst Jenkinsapi ========== .. image:: https://badge.fury.io/py/jenkinsapi.png :target: http://badge.fury.io/py/jenkinsapi Installation ------------ .. code-block:: bash pip install jenkinsapi Logging ------- JenkinsAPI emits request/response debug logs when enabled. You can opt in with the environment variable or by calling the helper: .. code-block:: bash export JENKINSAPI_LOG_LEVEL=DEBUG .. code-block:: python from jenkinsapi.utils.logging import configure_logging configure_logging("INFO") Important Links --------------- * `Documentation `__ * `Source Code `_ * `Support and bug-reports `_ * `Releases `_ About this library ------------------- Jenkins is the market leading continuous integration system. Jenkins (and its predecessor Hudson) are useful projects for automating common development tasks (e.g. unit-testing, production batches) - but they are somewhat Java-centric. Jenkinsapi makes scripting Jenkins tasks a breeze by wrapping the REST api into familiar python objects. Here is a list of some of the most commonly used functionality * Add, remove, and query Jenkins jobs * Control pipeline execution * Query the results of a completed build * Block until jobs are complete or run jobs asyncronously * Get objects representing the latest builds of a job * Artifact management * Search for artifacts by simple criteria * Install artifacts to custom-specified directory structures * Search for builds by source code revision * Create, destroy, and monitor * Build nodes (Webstart and SSH slaves) * Views (including nested views using NestedViews Jenkins plugin) * Credentials (username/password and ssh key) * Authentication support for username and password * Manage jenkins and plugin installation Full library capabilities are outlined in the `Documentation `__ Get details of jobs running on Jenkins server --------------------------------------------- .. code-block:: python """Get job details of each job that is running on the Jenkins instance""" def get_job_details(): # Refer Example #1 for definition of function 'get_server_instance' server = get_server_instance() for job_name, job_instance in server.get_jobs(): print('Job Name:%s' % (job_instance.name)) print('Job Description:%s' % (job_instance.get_description())) print('Is Job running:%s' % (job_instance.is_running())) print('Is Job enabled:%s' % (job_instance.is_enabled())) Disable/Enable a Jenkins Job ---------------------------- .. code-block:: python def disable_job(): """Disable a Jenkins job""" # Refer Example #1 for definition of function 'get_server_instance' server = get_server_instance() job_name = 'nightly-build-job' if (server.has_job(job_name)): job_instance = server.get_job(job_name) job_instance.disable() print('Name:%s,Is Job Enabled ?:%s' % (job_name, job_instance.is_enabled())) Use the call ``job_instance.enable()`` to enable a Jenkins Job. Creating jobs in folders ------------------------ When using the Folders plugin, you can pass folder paths directly: .. code-block:: python jenkins.create_job("folder1/folder2/job-name", config_xml) jenkins.create_job("/job/folder1/job/folder2/job-name", config_xml) Known issues ------------ * Job deletion operations fail unless Cross-Site scripting protection is disabled. For other issues, please refer to the `support URL `_ Development ----------- * Make sure that you have Java_ installed. Jenkins will be automatically downloaded and started during tests. * Create virtual environment for development * Install package in development mode .. code-block:: bash uv sync * Make your changes, write tests and check your code .. code-block:: bash uv run pytest -sv Python versions --------------- The project has been tested against Python versions: <<<<<<< HEAD * 3.10 - 3.14 ======= * 3.10 - 3.14 (tested via CI) >>>>>>> e554089 (docs: comprehensive documentation and examples updates) Jenkins versions ---------------- Project tested on both stable (LTS) and latest Jenkins versions. Project Contributors -------------------- * Aleksey Maksimov (ctpeko3a@gmail.com) * Salim Fadhley (sal@stodge.org) * Ramon van Alteren (ramon@vanalteren.nl) * Ruslan Lutsenko (ruslan.lutcenko@gmail.com) * Cleber J Santos (cleber@simplesconsultoria.com.br) * William Zhang (jollychang@douban.com) * Victor Garcia (bravejolie@gmail.com) * Bradley Harris (bradley@ninelb.com) * Kyle Rockman (kyle.rockman@mac.com) * Sascha Peilicke (saschpe@gmx.de) * David Johansen (david@makewhat.is) * Misha Behersky (bmwant@gmail.com) * Clinton Steiner (clintonsteiner@gmail.com) Please do not contact these contributors directly for support questions! Use the GitHub tracker instead. .. _Java: https://www.oracle.com/java/technologies/downloads/#java21