jenkinsapi-0.3.23/.codacy.yaml 0000644 0000000 0000000 00000000034 13615410400 013053 0 ustar 00 ---
exclude_paths:
- "**"
jenkinsapi-0.3.23/.python-version 0000644 0000000 0000000 00000000005 13615410400 013652 0 ustar 00 3.13
jenkinsapi-0.3.23/CHANGELOG.md 0000644 0000000 0000000 00000005447 13615410400 012475 0 ustar 00 # 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/Makefile 0000644 0000000 0000000 00000003465 13615410400 012322 0 ustar 00 .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/TODO 0000644 0000000 0000000 00000000250 13615410400 011337 0 ustar 00 TODO:
* 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.lock 0000644 0000000 0000000 00000705521 13615410400 012170 0 ustar 00 version = 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__.py 0000644 0000000 0000000 00000000000 13615410400 014567 0 ustar 00 jenkinsapi-0.3.23/examples/addjob.xml 0000644 0000000 0000000 00000001016 13615410400 014433 0 ustar 00
false
true
false
false
false
false
jenkinsapi-0.3.23/examples/how_to/add_command.py 0000644 0000000 0000000 00000002134 13615410400 016567 0 ustar 00 """
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.py 0000644 0000000 0000000 00000001017 13615410400 016735 0 ustar 00 """
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.py 0000644 0000000 0000000 00000003766 13615410400 020175 0 ustar 00 """
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.py 0000644 0000000 0000000 00000004131 13615410400 020362 0 ustar 00 """
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.py 0000644 0000000 0000000 00000004530 13615410400 017000 0 ustar 00 """
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.py 0000644 0000000 0000000 00000004544 13615410400 017030 0 ustar 00 """
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.py 0000644 0000000 0000000 00000000600 13615410400 023402 0 ustar 00 """
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.py 0000644 0000000 0000000 00000000412 13615410400 016442 0 ustar 00 """
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.py 0000644 0000000 0000000 00000000353 13615410400 021104 0 ustar 00 """
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.py 0000644 0000000 0000000 00000000363 13615410400 024137 0 ustar 00 """
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.py 0000644 0000000 0000000 00000000465 13615410400 017172 0 ustar 00 """
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.rst 0000644 0000000 0000000 00000003131 13615410400 015754 0 ustar 00 "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.py 0000644 0000000 0000000 00000000502 13615410400 021524 0 ustar 00 """
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.py 0000644 0000000 0000000 00000000451 13615410400 017646 0 ustar 00 """
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.py 0000644 0000000 0000000 00000001067 13615410400 021575 0 ustar 00 """
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.py 0000644 0000000 0000000 00000000446 13615410400 016514 0 ustar 00 """
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.py 0000644 0000000 0000000 00000001217 13615410400 017137 0 ustar 00 """
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.py 0000644 0000000 0000000 00000001100 13615410400 021667 0 ustar 00 """
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.py 0000644 0000000 0000000 00000001010 13615410400 021014 0 ustar 00 import 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.py 0000644 0000000 0000000 00000000317 13615410400 020217 0 ustar 00 """
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.py 0000644 0000000 0000000 00000002615 13615410400 017540 0 ustar 00 """
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.rst 0000644 0000000 0000000 00000000574 13615410400 016455 0 ustar 00 Low-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/.gitignore 0000644 0000000 0000000 00000000015 13615410400 014771 0 ustar 00 /__pycache__
jenkinsapi-0.3.23/jenkinsapi/__init__.py 0000644 0000000 0000000 00000003774 13615410400 015131 0 ustar 00 """
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.py 0000644 0000000 0000000 00000022647 13615410400 014143 0 ustar 00 """
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.py 0000644 0000000 0000000 00000012164 13615410400 015160 0 ustar 00 """
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.py 0000644 0000000 0000000 00000052142 13615410400 014462 0 ustar 00 """
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.py 0000644 0000000 0000000 00000000116 13615410400 014622 0 ustar 00 """
Jenkins configuration
"""
JENKINS_API = r"api/python"
LOAD_TIMEOUT = 30
jenkinsapi-0.3.23/jenkinsapi/constants.py 0000644 0000000 0000000 00000000640 13615410400 015373 0 ustar 00 """
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.py 0000644 0000000 0000000 00000043077 13615410400 015504 0 ustar 00 """
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.py 0000644 0000000 0000000 00000021034 13615410400 015654 0 ustar 00 """
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.py 0000644 0000000 0000000 00000004550 13615410400 017136 0 ustar 00 """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.py 0000644 0000000 0000000 00000003541 13615410400 015220 0 ustar 00 """
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.py 0000644 0000000 0000000 00000002310 13615410400 015374 0 ustar 00 """
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.py 0000644 0000000 0000000 00000010465 13615410400 015714 0 ustar 00 """
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.py 0000644 0000000 0000000 00000072000 13615410400 015017 0 ustar 00 """
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.py 0000644 0000000 0000000 00000010730 13615410400 015654 0 ustar 00 """
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.py 0000644 0000000 0000000 00000077247 13615410400 014152 0 ustar 00 """
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.py 0000644 0000000 0000000 00000027771 13615410400 014332 0 ustar 00 """
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.py 0000644 0000000 0000000 00000002501 13615410400 014434 0 ustar 00 """
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.py 0000644 0000000 0000000 00000031600 13615410400 017225 0 ustar 00 from 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.py 0000644 0000000 0000000 00000000506 13615410400 017723 0 ustar 00 """
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.py 0000644 0000000 0000000 00000055774 13615410400 014326 0 ustar 00 """
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.py 0000644 0000000 0000000 00000014706 13615410400 014477 0 ustar 00 """
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.py 0000644 0000000 0000000 00000004677 13615410400 014673 0 ustar 00 """
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.py 0000644 0000000 0000000 00000030622 13615410400 015043 0 ustar 00 """
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.py 0000644 0000000 0000000 00000016762 13615410400 014517 0 ustar 00 """
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.py 0000644 0000000 0000000 00000001151 13615410400 014673 0 ustar 00 """
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.py 0000644 0000000 0000000 00000003076 13615410400 015556 0 ustar 00 """
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.py 0000644 0000000 0000000 00000015115 13615410400 014334 0 ustar 00 """
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.py 0000644 0000000 0000000 00000010055 13615410400 014515 0 ustar 00 """
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__.py 0000644 0000000 0000000 00000000053 13615410400 017541 0 ustar 00 """
__init__,py for commandline module
"""
jenkinsapi-0.3.23/jenkinsapi/command_line/jenkins_invoke.py 0000644 0000000 0000000 00000011076 13615410400 021025 0 ustar 00 """
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.py 0000644 0000000 0000000 00000000262 13615410400 021704 0 ustar 00 """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__.py 0000644 0000000 0000000 00000000042 13615410400 016252 0 ustar 00 """
Module __init__ for utils
"""
jenkinsapi-0.3.23/jenkinsapi/utils/crumb_requester.py 0000644 0000000 0000000 00000005261 13615410400 017732 0 ustar 00 # 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.py 0000644 0000000 0000000 00000014240 13615410400 017510 0 ustar 00 """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.py 0000644 0000000 0000000 00000000450 13615410400 017402 0 ustar 00 """
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.py 0000644 0000000 0000000 00000002503 13615410400 017374 0 ustar 00 """
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.py 0000644 0000000 0000000 00000002452 13615410400 016150 0 ustar 00 """
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.py 0000644 0000000 0000000 00000006414 13615410400 016332 0 ustar 00 """
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.py 0000644 0000000 0000000 00000025006 13615410400 016541 0 ustar 00 """
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.py 0000644 0000000 0000000 00000003017 13615410400 015665 0 ustar 00 import 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.py 0000755 0000000 0000000 00000001736 13615410400 020426 0 ustar 00 #!/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__.py 0000644 0000000 0000000 00000000000 13615410400 016326 0 ustar 00 jenkinsapi-0.3.23/jenkinsapi_tests/conftest.py 0000644 0000000 0000000 00000000742 13615410400 016431 0 ustar 00 import 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__.py 0000644 0000000 0000000 00000000000 13615410400 020227 0 ustar 00 jenkinsapi-0.3.23/jenkinsapi_tests/systests/config.xml 0000644 0000000 0000000 00000002267 13615410400 020126 0 ustar 00
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.py 0000644 0000000 0000000 00000015370 13615410400 020335 0 ustar 00 import 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.gz 0000644 0000000 0000000 00000013270 13615410400 021733 0 ustar 00 ‹ 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
¡mO
BÇÓÏŠ†ÿøâ_C¸â_Mèa.€Ýz•¾2þ+´¤ZeÂ5:©#43™U h€–Ôã´–"MÞÏ›ÁJø[ÁÿfÆ
줘?Àû¿ •j<�fÒaÇšŸžF9÷�
×»ã_¥Váèþ'TÆE€/Ö=<6