├── .github └── workflows │ ├── cd.yaml │ └── pr.yaml ├── .gitignore ├── .pre-commit-config.yaml ├── .vscode └── settings.json ├── FUNDING.yml ├── LICENSE ├── README.md ├── poetry.lock ├── pyproject.toml ├── pytest_kubernetes ├── __init__.py ├── kubectl.py ├── options.py ├── plugin.py ├── portforwarding.py └── providers │ ├── __init__.py │ ├── base.py │ ├── k3d.py │ ├── kind.py │ └── minikube.py ├── renovate.json ├── setup.cfg └── tests ├── __init__.py ├── conftest.py ├── fixtures ├── Dockerfile └── hello.yaml ├── test_plugin.py ├── test_providers.py └── vendor.py /.github/workflows/cd.yaml: -------------------------------------------------------------------------------- 1 | # This workflow will upload a Python Package to PyPI when a release is created 2 | name: Upload Python Package 3 | 4 | on: 5 | release: 6 | types: [created] 7 | 8 | jobs: 9 | build: 10 | name: Build pytest-kubernetes distribution 📦 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - uses: actions/checkout@v4 15 | - name: Set up Python 16 | uses: actions/setup-python@v5 17 | with: 18 | python-version: 3.11 19 | - name: Install Poetry 20 | uses: snok/install-poetry@v1 21 | - name: Install project 22 | run: poetry install --no-interaction 23 | - name: Build Python Package 24 | run: | 25 | poetry build 26 | - name: Store the distribution packages 27 | uses: actions/upload-artifact@v4 28 | with: 29 | name: python-package-distributions 30 | path: dist/ 31 | 32 | publish-to-pypi: 33 | name: Upload release 📦 to PyPI 34 | runs-on: ubuntu-latest 35 | environment: 36 | name: pypi 37 | url: https://pypi.org/p/pytest-kubernetes 38 | permissions: 39 | id-token: write 40 | needs: build 41 | 42 | steps: 43 | - name: Download all the dists 44 | uses: actions/download-artifact@v4 45 | with: 46 | name: python-package-distributions 47 | path: dist/ 48 | - name: Publish distribution 📦 to PyPI 49 | uses: pypa/gh-action-pypi-publish@release/v1 50 | 51 | github-release: 52 | name: >- 53 | Sign pytest-kubernetes 📦 with Sigstore 54 | and upload them to GitHub Release 55 | needs: publish-to-pypi 56 | runs-on: ubuntu-latest 57 | 58 | permissions: 59 | contents: write # IMPORTANT: mandatory for making GitHub Releases 60 | id-token: write # IMPORTANT: mandatory for sigstore 61 | 62 | steps: 63 | - name: Download all the dists 64 | uses: actions/download-artifact@v4 65 | with: 66 | name: python-package-distributions 67 | path: dist/ 68 | - name: Sign the dists with Sigstore 69 | uses: sigstore/gh-action-sigstore-python@v3.0.0 70 | with: 71 | inputs: >- 72 | ./dist/*.tar.gz 73 | ./dist/*.whl 74 | - name: Upload artifact signatures to GitHub Release 75 | env: 76 | GITHUB_TOKEN: ${{ github.token }} 77 | # Upload to GitHub Release using the `gh` CLI. 78 | # `dist/` contains the built packages, and the 79 | # sigstore-produced signatures and certificates. 80 | run: >- 81 | gh release upload 82 | '${{ github.ref_name }}' dist/** 83 | --repo '${{ github.repository }}' -------------------------------------------------------------------------------- /.github/workflows/pr.yaml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | - pull_request 4 | jobs: 5 | # minikube: 6 | # runs-on: ubuntu-latest 7 | # name: build example and deploy to minikube 8 | # steps: 9 | # - uses: actions/checkout@v4 10 | # - name: Start minikube 11 | # uses: medyagh/setup-minikube@latest 12 | # - name: Run Tests 13 | # run: pytest -k Testkind 14 | kind: 15 | runs-on: ubuntu-latest 16 | name: Test Kind plugin 17 | steps: 18 | - uses: actions/checkout@v4 19 | - name: Install Poetry Action 20 | uses: snok/install-poetry@v1.4.1 21 | - name: Set up python 22 | id: setup-python 23 | uses: actions/setup-python@v5 24 | with: 25 | python-version: "3.12" 26 | - name: Load cached venv 27 | id: cached-poetry-dependencies 28 | uses: actions/cache@v4 29 | with: 30 | path: .venv 31 | key: venv-${{ runner.os }}-${{ steps.setup-python.outputs.python-version }}-${{ hashFiles('**/poetry.lock') }} 32 | - name: Install dependencies 33 | if: steps.cached-poetry-dependencies.outputs.cache-hit != 'true' 34 | run: poetry install --no-interaction --no-root 35 | - name: Create k8s Kind Cluster 36 | uses: helm/kind-action@v1 37 | - name: Run Tests 38 | run: poetry run pytest -k Testkind -v 39 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ### Python template 2 | # Byte-compiled / optimized / DLL files 3 | __pycache__/ 4 | *.py[cod] 5 | *$py.class 6 | 7 | # C extensions 8 | *.so 9 | 10 | # Distribution / packaging 11 | .Python 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/uoat 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | 49 | # Translations 50 | *.mo 51 | *.pot 52 | 53 | # Django stuff: 54 | staticfiles/ 55 | media/ 56 | 57 | # Sphinx documentation 58 | docs/_build/ 59 | 60 | # PyBuilder 61 | target/ 62 | 63 | # pyenv 64 | .python-version 65 | 66 | 67 | 68 | # Environments 69 | .venv 70 | venv/ 71 | .env 72 | ENV/ 73 | 74 | # Rope project settings 75 | .ropeproject 76 | 77 | # mkdocs documentation 78 | /site 79 | 80 | # mypy 81 | .mypy_cache/ 82 | 83 | 84 | ### Node template 85 | # Logs 86 | logs 87 | *.log 88 | npm-debug.log* 89 | yarn-debug.log* 90 | yarn-error.log* 91 | 92 | # Runtime data 93 | pids 94 | *.pid 95 | *.seed 96 | *.pid.lock 97 | 98 | # Directory for instrumented libs generated by jscoverage/JSCover 99 | lib-cov 100 | 101 | # Coverage directory used by tools like istanbul 102 | coverage 103 | 104 | # nyc test coverage 105 | .nyc_output 106 | 107 | # Bower dependency directory (https://bower.io/) 108 | bower_components 109 | 110 | # node-waf configuration 111 | .lock-wscript 112 | 113 | # Compiled binary addons (http://nodejs.org/api/addons.html) 114 | build/Release 115 | 116 | # Dependency directories 117 | node_modules/ 118 | jspm_packages/ 119 | 120 | # Typescript v1 declaration files 121 | typings/ 122 | 123 | # Optional npm cache directory 124 | .npm 125 | 126 | # Optional eslint cache 127 | .eslintcache 128 | 129 | # Optional REPL history 130 | .node_repl_history 131 | 132 | # Output of 'npm pack' 133 | *.tgz 134 | 135 | # Yarn Integrity file 136 | .yarn-integrity 137 | 138 | 139 | ### Linux template 140 | *~ 141 | 142 | # temporary files which can be created if a process still has a handle open of a deleted file 143 | .fuse_hidden* 144 | 145 | # KDE directory preferences 146 | .directory 147 | 148 | # Linux trash folder which might appear on any partition or disk 149 | .Trash-* 150 | 151 | # .nfs files are created when an open file is removed but is still being accessed 152 | .nfs* 153 | 154 | 155 | ### VisualStudioCode template 156 | .vscode/* 157 | !.vscode/settings.json 158 | !.vscode/tasks.json 159 | !.vscode/launch.json 160 | !.vscode/extensions.json 161 | 162 | 163 | # Provided default Pycharm Run/Debug Configurations should be tracked by git 164 | # In case of local modifications made by Pycharm, use update-index command 165 | # for each changed file, like this: 166 | # git update-index --assume-unchanged .idea/loyalty_engine.iml 167 | ### JetBrains template 168 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and Webstorm 169 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 170 | 171 | # User-specific stuff: 172 | .idea/**/workspace.xml 173 | .idea/**/tasks.xml 174 | .idea/dictionaries 175 | 176 | # Sensitive or high-churn files: 177 | .idea/** 178 | .idea/**/dataSources/ 179 | .idea/**/dataSources.ids 180 | .idea/**/dataSources.xml 181 | .idea/**/dataSources.local.xml 182 | .idea/**/sqlDataSources.xml 183 | .idea/**/dynamic.xml 184 | .idea/**/uiDesigner.xml 185 | 186 | # Gradle: 187 | .idea/**/gradle.xml 188 | .idea/**/libraries 189 | 190 | # CMake 191 | cmake-build-debug/ 192 | 193 | # Mongo Explorer plugin: 194 | .idea/**/mongoSettings.xml 195 | 196 | ## File-based project format: 197 | *.iws 198 | 199 | ## Plugin-specific files: 200 | 201 | # IntelliJ 202 | out/ 203 | 204 | # mpeltonen/sbt-idea plugin 205 | .idea_modules/ 206 | 207 | # JIRA plugin 208 | atlassian-ide-plugin.xml 209 | 210 | # Cursive Clojure plugin 211 | .idea/replstate.xml 212 | 213 | # Crashlytics plugin (for Android Studio and IntelliJ) 214 | com_crashlytics_export_strings.xml 215 | crashlytics.properties 216 | crashlytics-build.properties 217 | fabric.properties 218 | 219 | 220 | 221 | ### Windows template 222 | # Windows thumbnail cache files 223 | Thumbs.db 224 | ehthumbs.db 225 | ehthumbs_vista.db 226 | 227 | # Dump file 228 | *.stackdump 229 | 230 | # Folder config file 231 | Desktop.ini 232 | 233 | # Recycle Bin used on file shares 234 | $RECYCLE.BIN/ 235 | 236 | # Windows Installer files 237 | *.cab 238 | *.msi 239 | *.msm 240 | *.msp 241 | 242 | # Windows shortcuts 243 | *.lnk 244 | 245 | 246 | ### macOS template 247 | # General 248 | *.DS_Store 249 | .AppleDouble 250 | .LSOverride 251 | 252 | # Icon must end with two \r 253 | Icon 254 | 255 | # Thumbnails 256 | ._* 257 | 258 | # Files that might appear in the root of a volume 259 | .DocumentRevisions-V100 260 | .fseventsd 261 | .Spotlight-V100 262 | .TemporaryItems 263 | .Trashes 264 | .VolumeIcon.icns 265 | .com.apple.timemachine.donotpresent 266 | 267 | # Directories potentially created on remote AFP share 268 | .AppleDB 269 | .AppleDesktop 270 | Network Trash Folder 271 | Temporary Items 272 | .apdisk 273 | 274 | 275 | ### SublimeText template 276 | # Cache files for Sublime Text 277 | *.tmlanguage.cache 278 | *.tmPreferences.cache 279 | *.stTheme.cache 280 | 281 | # Workspace files are user-specific 282 | *.sublime-workspace 283 | 284 | # Project files should be checked into the repository, unless a significant 285 | # proportion of contributors will probably not be using Sublime Text 286 | # *.sublime-project 287 | 288 | # SFTP configuration file 289 | sftp-config.json 290 | 291 | # Package control specific files 292 | Package Control.last-run 293 | Package Control.ca-list 294 | Package Control.ca-bundle 295 | Package Control.system-ca-bundle 296 | Package Control.cache/ 297 | Package Control.ca-certs/ 298 | Package Control.merged-ca-bundle 299 | Package Control.user-ca-bundle 300 | oscrypto-ca-bundle.crt 301 | bh_unicode_properties.cache 302 | 303 | # Sublime-github package stores a github token in this file 304 | # https://packagecontrol.io/packages/sublime-github 305 | GitHub.sublime-settings 306 | .kube 307 | 308 | ### Vim template 309 | # Swap 310 | [._]*.s[a-v][a-z] 311 | [._]*.sw[a-p] 312 | [._]s[a-v][a-z] 313 | [._]sw[a-p] 314 | 315 | # Session 316 | Session.vim 317 | 318 | # Temporary 319 | .netrwhist 320 | 321 | # Auto-generated tag files 322 | tags 323 | 324 | .pytest_cache/ 325 | 326 | .local-dev/ 327 | 328 | .docker/data/ 329 | 330 | # website 331 | # project 332 | _site 333 | .sass-cache 334 | .vagrant 335 | 336 | # general 337 | .DS_Store 338 | Thumbs.db 339 | ehthumbs.db 340 | 341 | Gemfile.lock 342 | 343 | beautiful-jekyll-theme-*.gem -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | # ruff - ruffing it up! 3 | - repo: https://github.com/astral-sh/ruff-pre-commit 4 | # Ruff version. 5 | rev: v0.6.5 6 | hooks: 7 | # Run the linter. 8 | - id: ruff 9 | args: [--fix] 10 | # Run the formatter. 11 | - id: ruff-format 12 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "python.formatting.provider": "black", 3 | "python.testing.pytestArgs": [], 4 | "python.testing.unittestEnabled": false, 5 | "python.testing.pytestEnabled": true 6 | } -------------------------------------------------------------------------------- /FUNDING.yml: -------------------------------------------------------------------------------- 1 | custom: 2 | - https://blueshoe.io -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # pytest-kubernetes 2 | pytest-kubernetes is a lightweight pytest plugin that makes managing (local) Kubernetes clusters a breeze. You can easily spin up a Kubernetes cluster with one [pytest fixure](https://docs.pytest.org/en/latest/explanation/fixtures.html) and remove them again. 3 | The fixture comes with some simple functions to interact with the cluster, for example `kubectl(...)` that allows you to run typical *kubectl* commands against this cluster without worring 4 | about the *kubeconfig* on the test machine. 5 | 6 | **Features:** 7 | - Set up and tear down (local) Kubernetes clusters with *minikube*, *k3d* and *kind* 8 | - Configure the cluster to recreate for each test case (default), or keep it across multiple test cases 9 | - Automatic management of the *kubeconfig* 10 | - Simple functions to run kubectl commands (with *dict* output), reading logs and load custom container images 11 | - Wait for certain conditions in the cluster 12 | - Port forward Kubernetes-based services (using kubectl port-forward) easily during a test case 13 | - Management utils for custom pytest-fixtures (for example pre-provisioned clusters) 14 | 15 | ## Installation 16 | This plugin can be installed from PyPI: 17 | - `pip install pytest-kubernetes` 18 | - `poetry add -D pytest-kubernetes` 19 | 20 | Note that this package provides entrypoint hooks to be automatically loaded with pytest. 21 | 22 | ## Requirements 23 | pytest-kubernetes expects the following components to be available on the test machine: 24 | - [`kubectl`](https://kubernetes.io/docs/reference/kubectl/) 25 | - [`minikube`](https://minikube.sigs.k8s.io/docs/start/) (optional for minikube-based clusters) 26 | - [`k3d`](https://k3d.io/) (optional for k3d-based clusters) 27 | - [`kind`](https://kind.sigs.k8s.io/) (optional for kind-based clusters) 28 | - [Docker](https://docs.docker.com/get-docker/) (optional for Docker-based Kubernetes clusters) 29 | 30 | Please make sure they are installed to run pytest-kubernetes properly. 31 | 32 | ## Reference 33 | 34 | ### Fixture 35 | 36 | #### k8s 37 | The _k8s_ fixture provides access to an automatically selected Kubernetes provider (depending on the availability on the host). The priority is: k3d, kind, minikube-docker and minikube-kvm2. 38 | 39 | The fixture passes a manager object of type *AClusterManager*. 40 | 41 | It provides the following interface: 42 | - `kubectl(...)`: Execute kubectl command against this cluster (defaults to `dict` as returning format) 43 | - `apply(...)`: Apply resources to this cluster, either from YAML file, or Python dict 44 | - `load_image(...)`: Load a container image into this cluster 45 | - `wait(...)`: Wait for a target and a condition 46 | - `port_forwarding(...)`: Port forward a target 47 | - `logs(...)`: Get the logs of a pod 48 | - `version()`: Get the Kubernetes version of this cluster 49 | - `create(...)`: Create this cluster (pass special cluster arguments with `options: List[str]` to the CLI command) 50 | - `delete()`: Delete this cluster 51 | - `reset()`: Delete this cluster (if it exists) and create it again 52 | 53 | The interface provides proper typing and should be easy to work with. 54 | 55 | **Example** 56 | 57 | ```python 58 | def test_a_feature_with_k3d(k8s: AClusterManager): 59 | k8s.create() 60 | k8s.apply( 61 | { 62 | "apiVersion": "v1", 63 | "kind": "ConfigMap", 64 | "data": {"key": "value"}, 65 | "metadata": {"name": "myconfigmap"}, 66 | }, 67 | ) 68 | k8s.apply("./dependencies.yaml") 69 | k8s.load_image("my-container-image:latest") 70 | k8s.kubectl( 71 | [ 72 | "run", 73 | "test", 74 | "--image", 75 | "my-container-image:latest", 76 | "--restart=Never", 77 | "--image-pull-policy=Never", 78 | ] 79 | ) 80 | ``` 81 | This cluster will be deleted once the test case is over. 82 | 83 | > Please note that you need to set *"--image-pull-policy=Never"* for images that you loaded into the cluster via the `k8s.load(name: str)` function (see example above). 84 | 85 | ### Marks 86 | pytest-kubernetes uses [*pytest marks*](https://docs.pytest.org/en/7.1.x/how-to/mark.html) for specifying the cluster configuration for a test case 87 | 88 | Currently the following settings are supported: 89 | 90 | - *provider* (str): request a specific Kubernetes provider for the test case 91 | - *cluster_name* (str): request a specific cluster name 92 | - *keep* (bool): keep the cluster across multiple test cases 93 | 94 | 95 | **Example** 96 | ```python 97 | @pytest.mark.k8s(provider="minikube", cluster_name="test1", keep=True) 98 | def test_a_feature_in_minikube(k8s: AClusterManager): 99 | ... 100 | ``` 101 | 102 | ### Utils 103 | To write custom Kubernetes-based fixtures in your project you can make use of the following util functions. 104 | 105 | 106 | #### `select_provider_manager` 107 | This function returns a deriving class of *AClusterManager* that is not created and wrapped in a fixture yet. 108 | 109 | `select_provider_manager(name: Optional[str] = None) -> Type[AClusterManager]` 110 | 111 | The returning object gets called with the init parameters of *AClusterManager*, the `cluster_name: str`. 112 | 113 | **Example** 114 | ```python 115 | @pytest.fixture(scope="session") 116 | def k8s_with_workload(request): 117 | cluster = select_provider_manager("k3d")("my-cluster") 118 | # if minikube should be used 119 | # cluster = select_provider_manager("minikube")("my-cluster") 120 | cluster.create() 121 | # init the cluster with a workload 122 | cluster.apply("./fixtures/hello.yaml") 123 | cluster.wait("deployments/hello-nginxdemo", "condition=Available=True") 124 | yield cluster 125 | cluster.delete() 126 | ``` 127 | In this example, the cluster remains active for the entire session and is only deleted once pytest is done. 128 | 129 | > Note that `yield` notation that is prefered by pytest to express clean up tasks for this fixture. 130 | 131 | #### Special cluster options 132 | You can pass more options using `kwargs['options']: List[str]` to the `create(options=...)` function when creating the cluster like so: 133 | ```python 134 | cluster = select_provider_manager("k3d")("my-cluster") 135 | # bind ports of this k3d cluster 136 | cluster.create(options=["--agents", "1", "-p", "8080:80@agent:0", "-p", "31820:31820/UDP@agent:0"]) 137 | ``` 138 | 139 | ## Examples 140 | Please find more examples in *tests/vendor.py* in this repository. These test cases are written as users of pytest-kubernetes would write test cases in their projects. -------------------------------------------------------------------------------- /poetry.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Poetry 2.0.0 and should not be changed by hand. 2 | 3 | [[package]] 4 | name = "colorama" 5 | version = "0.4.6" 6 | description = "Cross-platform colored terminal text." 7 | optional = false 8 | python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" 9 | groups = ["main"] 10 | markers = "sys_platform == \"win32\"" 11 | files = [ 12 | {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, 13 | {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, 14 | ] 15 | 16 | [[package]] 17 | name = "coverage" 18 | version = "7.8.2" 19 | description = "Code coverage measurement for Python" 20 | optional = false 21 | python-versions = ">=3.9" 22 | groups = ["dev"] 23 | files = [ 24 | {file = "coverage-7.8.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:bd8ec21e1443fd7a447881332f7ce9d35b8fbd2849e761bb290b584535636b0a"}, 25 | {file = "coverage-7.8.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4c26c2396674816deaeae7ded0e2b42c26537280f8fe313335858ffff35019be"}, 26 | {file = "coverage-7.8.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1aec326ed237e5880bfe69ad41616d333712c7937bcefc1343145e972938f9b3"}, 27 | {file = "coverage-7.8.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5e818796f71702d7a13e50c70de2a1924f729228580bcba1607cccf32eea46e6"}, 28 | {file = "coverage-7.8.2-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:546e537d9e24efc765c9c891328f30f826e3e4808e31f5d0f87c4ba12bbd1622"}, 29 | {file = "coverage-7.8.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:ab9b09a2349f58e73f8ebc06fac546dd623e23b063e5398343c5270072e3201c"}, 30 | {file = "coverage-7.8.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:fd51355ab8a372d89fb0e6a31719e825cf8df8b6724bee942fb5b92c3f016ba3"}, 31 | {file = "coverage-7.8.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:0774df1e093acb6c9e4d58bce7f86656aeed6c132a16e2337692c12786b32404"}, 32 | {file = "coverage-7.8.2-cp310-cp310-win32.whl", hash = "sha256:00f2e2f2e37f47e5f54423aeefd6c32a7dbcedc033fcd3928a4f4948e8b96af7"}, 33 | {file = "coverage-7.8.2-cp310-cp310-win_amd64.whl", hash = "sha256:145b07bea229821d51811bf15eeab346c236d523838eda395ea969d120d13347"}, 34 | {file = "coverage-7.8.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b99058eef42e6a8dcd135afb068b3d53aff3921ce699e127602efff9956457a9"}, 35 | {file = "coverage-7.8.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5feb7f2c3e6ea94d3b877def0270dff0947b8d8c04cfa34a17be0a4dc1836879"}, 36 | {file = "coverage-7.8.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:670a13249b957bb9050fab12d86acef7bf8f6a879b9d1a883799276e0d4c674a"}, 37 | {file = "coverage-7.8.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0bdc8bf760459a4a4187b452213e04d039990211f98644c7292adf1e471162b5"}, 38 | {file = "coverage-7.8.2-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:07a989c867986c2a75f158f03fdb413128aad29aca9d4dbce5fc755672d96f11"}, 39 | {file = "coverage-7.8.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2db10dedeb619a771ef0e2949ccba7b75e33905de959c2643a4607bef2f3fb3a"}, 40 | {file = "coverage-7.8.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e6ea7dba4e92926b7b5f0990634b78ea02f208d04af520c73a7c876d5a8d36cb"}, 41 | {file = "coverage-7.8.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ef2f22795a7aca99fc3c84393a55a53dd18ab8c93fb431004e4d8f0774150f54"}, 42 | {file = "coverage-7.8.2-cp311-cp311-win32.whl", hash = "sha256:641988828bc18a6368fe72355df5f1703e44411adbe49bba5644b941ce6f2e3a"}, 43 | {file = "coverage-7.8.2-cp311-cp311-win_amd64.whl", hash = "sha256:8ab4a51cb39dc1933ba627e0875046d150e88478dbe22ce145a68393e9652975"}, 44 | {file = "coverage-7.8.2-cp311-cp311-win_arm64.whl", hash = "sha256:8966a821e2083c74d88cca5b7dcccc0a3a888a596a04c0b9668a891de3a0cc53"}, 45 | {file = "coverage-7.8.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e2f6fe3654468d061942591aef56686131335b7a8325684eda85dacdf311356c"}, 46 | {file = "coverage-7.8.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:76090fab50610798cc05241bf83b603477c40ee87acd358b66196ab0ca44ffa1"}, 47 | {file = "coverage-7.8.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2bd0a0a5054be160777a7920b731a0570284db5142abaaf81bcbb282b8d99279"}, 48 | {file = "coverage-7.8.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:da23ce9a3d356d0affe9c7036030b5c8f14556bd970c9b224f9c8205505e3b99"}, 49 | {file = "coverage-7.8.2-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c9392773cffeb8d7e042a7b15b82a414011e9d2b5fdbbd3f7e6a6b17d5e21b20"}, 50 | {file = "coverage-7.8.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:876cbfd0b09ce09d81585d266c07a32657beb3eaec896f39484b631555be0fe2"}, 51 | {file = "coverage-7.8.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:3da9b771c98977a13fbc3830f6caa85cae6c9c83911d24cb2d218e9394259c57"}, 52 | {file = "coverage-7.8.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:9a990f6510b3292686713bfef26d0049cd63b9c7bb17e0864f133cbfd2e6167f"}, 53 | {file = "coverage-7.8.2-cp312-cp312-win32.whl", hash = "sha256:bf8111cddd0f2b54d34e96613e7fbdd59a673f0cf5574b61134ae75b6f5a33b8"}, 54 | {file = "coverage-7.8.2-cp312-cp312-win_amd64.whl", hash = "sha256:86a323a275e9e44cdf228af9b71c5030861d4d2610886ab920d9945672a81223"}, 55 | {file = "coverage-7.8.2-cp312-cp312-win_arm64.whl", hash = "sha256:820157de3a589e992689ffcda8639fbabb313b323d26388d02e154164c57b07f"}, 56 | {file = "coverage-7.8.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ea561010914ec1c26ab4188aef8b1567272ef6de096312716f90e5baa79ef8ca"}, 57 | {file = "coverage-7.8.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cb86337a4fcdd0e598ff2caeb513ac604d2f3da6d53df2c8e368e07ee38e277d"}, 58 | {file = "coverage-7.8.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:26a4636ddb666971345541b59899e969f3b301143dd86b0ddbb570bd591f1e85"}, 59 | {file = "coverage-7.8.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5040536cf9b13fb033f76bcb5e1e5cb3b57c4807fef37db9e0ed129c6a094257"}, 60 | {file = "coverage-7.8.2-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dc67994df9bcd7e0150a47ef41278b9e0a0ea187caba72414b71dc590b99a108"}, 61 | {file = "coverage-7.8.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6e6c86888fd076d9e0fe848af0a2142bf606044dc5ceee0aa9eddb56e26895a0"}, 62 | {file = "coverage-7.8.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:684ca9f58119b8e26bef860db33524ae0365601492e86ba0b71d513f525e7050"}, 63 | {file = "coverage-7.8.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8165584ddedb49204c4e18da083913bdf6a982bfb558632a79bdaadcdafd0d48"}, 64 | {file = "coverage-7.8.2-cp313-cp313-win32.whl", hash = "sha256:34759ee2c65362163699cc917bdb2a54114dd06d19bab860725f94ef45a3d9b7"}, 65 | {file = "coverage-7.8.2-cp313-cp313-win_amd64.whl", hash = "sha256:2f9bc608fbafaee40eb60a9a53dbfb90f53cc66d3d32c2849dc27cf5638a21e3"}, 66 | {file = "coverage-7.8.2-cp313-cp313-win_arm64.whl", hash = "sha256:9fe449ee461a3b0c7105690419d0b0aba1232f4ff6d120a9e241e58a556733f7"}, 67 | {file = "coverage-7.8.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:8369a7c8ef66bded2b6484053749ff220dbf83cba84f3398c84c51a6f748a008"}, 68 | {file = "coverage-7.8.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:159b81df53a5fcbc7d45dae3adad554fdbde9829a994e15227b3f9d816d00b36"}, 69 | {file = "coverage-7.8.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e6fcbbd35a96192d042c691c9e0c49ef54bd7ed865846a3c9d624c30bb67ce46"}, 70 | {file = "coverage-7.8.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:05364b9cc82f138cc86128dc4e2e1251c2981a2218bfcd556fe6b0fbaa3501be"}, 71 | {file = "coverage-7.8.2-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:46d532db4e5ff3979ce47d18e2fe8ecad283eeb7367726da0e5ef88e4fe64740"}, 72 | {file = "coverage-7.8.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:4000a31c34932e7e4fa0381a3d6deb43dc0c8f458e3e7ea6502e6238e10be625"}, 73 | {file = "coverage-7.8.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:43ff5033d657cd51f83015c3b7a443287250dc14e69910577c3e03bd2e06f27b"}, 74 | {file = "coverage-7.8.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:94316e13f0981cbbba132c1f9f365cac1d26716aaac130866ca812006f662199"}, 75 | {file = "coverage-7.8.2-cp313-cp313t-win32.whl", hash = "sha256:3f5673888d3676d0a745c3d0e16da338c5eea300cb1f4ada9c872981265e76d8"}, 76 | {file = "coverage-7.8.2-cp313-cp313t-win_amd64.whl", hash = "sha256:2c08b05ee8d7861e45dc5a2cc4195c8c66dca5ac613144eb6ebeaff2d502e73d"}, 77 | {file = "coverage-7.8.2-cp313-cp313t-win_arm64.whl", hash = "sha256:1e1448bb72b387755e1ff3ef1268a06617afd94188164960dba8d0245a46004b"}, 78 | {file = "coverage-7.8.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:496948261eaac5ac9cf43f5d0a9f6eb7a6d4cb3bedb2c5d294138142f5c18f2a"}, 79 | {file = "coverage-7.8.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:eacd2de0d30871eff893bab0b67840a96445edcb3c8fd915e6b11ac4b2f3fa6d"}, 80 | {file = "coverage-7.8.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b039ffddc99ad65d5078ef300e0c7eed08c270dc26570440e3ef18beb816c1ca"}, 81 | {file = "coverage-7.8.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0e49824808d4375ede9dd84e9961a59c47f9113039f1a525e6be170aa4f5c34d"}, 82 | {file = "coverage-7.8.2-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b069938961dfad881dc2f8d02b47645cd2f455d3809ba92a8a687bf513839787"}, 83 | {file = "coverage-7.8.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:de77c3ba8bb686d1c411e78ee1b97e6e0b963fb98b1637658dd9ad2c875cf9d7"}, 84 | {file = "coverage-7.8.2-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:1676628065a498943bd3f64f099bb573e08cf1bc6088bbe33cf4424e0876f4b3"}, 85 | {file = "coverage-7.8.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:8e1a26e7e50076e35f7afafde570ca2b4d7900a491174ca357d29dece5aacee7"}, 86 | {file = "coverage-7.8.2-cp39-cp39-win32.whl", hash = "sha256:6782a12bf76fa61ad9350d5a6ef5f3f020b57f5e6305cbc663803f2ebd0f270a"}, 87 | {file = "coverage-7.8.2-cp39-cp39-win_amd64.whl", hash = "sha256:1efa4166ba75ccefd647f2d78b64f53f14fb82622bc94c5a5cb0a622f50f1c9e"}, 88 | {file = "coverage-7.8.2-pp39.pp310.pp311-none-any.whl", hash = "sha256:ec455eedf3ba0bbdf8f5a570012617eb305c63cb9f03428d39bf544cb2b94837"}, 89 | {file = "coverage-7.8.2-py3-none-any.whl", hash = "sha256:726f32ee3713f7359696331a18daf0c3b3a70bb0ae71141b9d3c52be7c595e32"}, 90 | {file = "coverage-7.8.2.tar.gz", hash = "sha256:a886d531373a1f6ff9fad2a2ba4a045b68467b779ae729ee0b3b10ac20033b27"}, 91 | ] 92 | 93 | [package.extras] 94 | toml = ["tomli"] 95 | 96 | [[package]] 97 | name = "iniconfig" 98 | version = "2.0.0" 99 | description = "brain-dead simple config-ini parsing" 100 | optional = false 101 | python-versions = ">=3.7" 102 | groups = ["main"] 103 | files = [ 104 | {file = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"}, 105 | {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"}, 106 | ] 107 | 108 | [[package]] 109 | name = "mypy" 110 | version = "1.15.0" 111 | description = "Optional static typing for Python" 112 | optional = false 113 | python-versions = ">=3.9" 114 | groups = ["dev"] 115 | files = [ 116 | {file = "mypy-1.15.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:979e4e1a006511dacf628e36fadfecbcc0160a8af6ca7dad2f5025529e082c13"}, 117 | {file = "mypy-1.15.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c4bb0e1bd29f7d34efcccd71cf733580191e9a264a2202b0239da95984c5b559"}, 118 | {file = "mypy-1.15.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be68172e9fd9ad8fb876c6389f16d1c1b5f100ffa779f77b1fb2176fcc9ab95b"}, 119 | {file = "mypy-1.15.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c7be1e46525adfa0d97681432ee9fcd61a3964c2446795714699a998d193f1a3"}, 120 | {file = "mypy-1.15.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:2e2c2e6d3593f6451b18588848e66260ff62ccca522dd231cd4dd59b0160668b"}, 121 | {file = "mypy-1.15.0-cp310-cp310-win_amd64.whl", hash = "sha256:6983aae8b2f653e098edb77f893f7b6aca69f6cffb19b2cc7443f23cce5f4828"}, 122 | {file = "mypy-1.15.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:2922d42e16d6de288022e5ca321cd0618b238cfc5570e0263e5ba0a77dbef56f"}, 123 | {file = "mypy-1.15.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2ee2d57e01a7c35de00f4634ba1bbf015185b219e4dc5909e281016df43f5ee5"}, 124 | {file = "mypy-1.15.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:973500e0774b85d9689715feeffcc980193086551110fd678ebe1f4342fb7c5e"}, 125 | {file = "mypy-1.15.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a95fb17c13e29d2d5195869262f8125dfdb5c134dc8d9a9d0aecf7525b10c2c"}, 126 | {file = "mypy-1.15.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1905f494bfd7d85a23a88c5d97840888a7bd516545fc5aaedff0267e0bb54e2f"}, 127 | {file = "mypy-1.15.0-cp311-cp311-win_amd64.whl", hash = "sha256:c9817fa23833ff189db061e6d2eff49b2f3b6ed9856b4a0a73046e41932d744f"}, 128 | {file = "mypy-1.15.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:aea39e0583d05124836ea645f412e88a5c7d0fd77a6d694b60d9b6b2d9f184fd"}, 129 | {file = "mypy-1.15.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2f2147ab812b75e5b5499b01ade1f4a81489a147c01585cda36019102538615f"}, 130 | {file = "mypy-1.15.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ce436f4c6d218a070048ed6a44c0bbb10cd2cc5e272b29e7845f6a2f57ee4464"}, 131 | {file = "mypy-1.15.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8023ff13985661b50a5928fc7a5ca15f3d1affb41e5f0a9952cb68ef090b31ee"}, 132 | {file = "mypy-1.15.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1124a18bc11a6a62887e3e137f37f53fbae476dc36c185d549d4f837a2a6a14e"}, 133 | {file = "mypy-1.15.0-cp312-cp312-win_amd64.whl", hash = "sha256:171a9ca9a40cd1843abeca0e405bc1940cd9b305eaeea2dda769ba096932bb22"}, 134 | {file = "mypy-1.15.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:93faf3fdb04768d44bf28693293f3904bbb555d076b781ad2530214ee53e3445"}, 135 | {file = "mypy-1.15.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:811aeccadfb730024c5d3e326b2fbe9249bb7413553f15499a4050f7c30e801d"}, 136 | {file = "mypy-1.15.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:98b7b9b9aedb65fe628c62a6dc57f6d5088ef2dfca37903a7d9ee374d03acca5"}, 137 | {file = "mypy-1.15.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c43a7682e24b4f576d93072216bf56eeff70d9140241f9edec0c104d0c515036"}, 138 | {file = "mypy-1.15.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:baefc32840a9f00babd83251560e0ae1573e2f9d1b067719479bfb0e987c6357"}, 139 | {file = "mypy-1.15.0-cp313-cp313-win_amd64.whl", hash = "sha256:b9378e2c00146c44793c98b8d5a61039a048e31f429fb0eb546d93f4b000bedf"}, 140 | {file = "mypy-1.15.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:e601a7fa172c2131bff456bb3ee08a88360760d0d2f8cbd7a75a65497e2df078"}, 141 | {file = "mypy-1.15.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:712e962a6357634fef20412699a3655c610110e01cdaa6180acec7fc9f8513ba"}, 142 | {file = "mypy-1.15.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f95579473af29ab73a10bada2f9722856792a36ec5af5399b653aa28360290a5"}, 143 | {file = "mypy-1.15.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8f8722560a14cde92fdb1e31597760dc35f9f5524cce17836c0d22841830fd5b"}, 144 | {file = "mypy-1.15.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:1fbb8da62dc352133d7d7ca90ed2fb0e9d42bb1a32724c287d3c76c58cbaa9c2"}, 145 | {file = "mypy-1.15.0-cp39-cp39-win_amd64.whl", hash = "sha256:d10d994b41fb3497719bbf866f227b3489048ea4bbbb5015357db306249f7980"}, 146 | {file = "mypy-1.15.0-py3-none-any.whl", hash = "sha256:5469affef548bd1895d86d3bf10ce2b44e33d86923c29e4d675b3e323437ea3e"}, 147 | {file = "mypy-1.15.0.tar.gz", hash = "sha256:404534629d51d3efea5c800ee7c42b72a6554d6c400e6a79eafe15d11341fd43"}, 148 | ] 149 | 150 | [package.dependencies] 151 | mypy_extensions = ">=1.0.0" 152 | typing_extensions = ">=4.6.0" 153 | 154 | [package.extras] 155 | dmypy = ["psutil (>=4.0)"] 156 | faster-cache = ["orjson"] 157 | install-types = ["pip"] 158 | mypyc = ["setuptools (>=50)"] 159 | reports = ["lxml"] 160 | 161 | [[package]] 162 | name = "mypy-extensions" 163 | version = "1.0.0" 164 | description = "Type system extensions for programs checked with the mypy type checker." 165 | optional = false 166 | python-versions = ">=3.5" 167 | groups = ["dev"] 168 | files = [ 169 | {file = "mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d"}, 170 | {file = "mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782"}, 171 | ] 172 | 173 | [[package]] 174 | name = "packaging" 175 | version = "24.1" 176 | description = "Core utilities for Python packages" 177 | optional = false 178 | python-versions = ">=3.8" 179 | groups = ["main"] 180 | files = [ 181 | {file = "packaging-24.1-py3-none-any.whl", hash = "sha256:5b8f2217dbdbd2f7f384c41c628544e6d52f2d0f53c6d0c3ea61aa5d1d7ff124"}, 182 | {file = "packaging-24.1.tar.gz", hash = "sha256:026ed72c8ed3fcce5bf8950572258698927fd1dbda10a5e981cdf0ac37f4f002"}, 183 | ] 184 | 185 | [[package]] 186 | name = "pluggy" 187 | version = "1.5.0" 188 | description = "plugin and hook calling mechanisms for python" 189 | optional = false 190 | python-versions = ">=3.8" 191 | groups = ["main"] 192 | files = [ 193 | {file = "pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669"}, 194 | {file = "pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1"}, 195 | ] 196 | 197 | [package.extras] 198 | dev = ["pre-commit", "tox"] 199 | testing = ["pytest", "pytest-benchmark"] 200 | 201 | [[package]] 202 | name = "pytest" 203 | version = "8.3.5" 204 | description = "pytest: simple powerful testing with Python" 205 | optional = false 206 | python-versions = ">=3.8" 207 | groups = ["main"] 208 | files = [ 209 | {file = "pytest-8.3.5-py3-none-any.whl", hash = "sha256:c69214aa47deac29fad6c2a4f590b9c4a9fdb16a403176fe154b79c0b4d4d820"}, 210 | {file = "pytest-8.3.5.tar.gz", hash = "sha256:f4efe70cc14e511565ac476b57c279e12a855b11f48f212af1080ef2263d3845"}, 211 | ] 212 | 213 | [package.dependencies] 214 | colorama = {version = "*", markers = "sys_platform == \"win32\""} 215 | iniconfig = "*" 216 | packaging = "*" 217 | pluggy = ">=1.5,<2" 218 | 219 | [package.extras] 220 | dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] 221 | 222 | [[package]] 223 | name = "pyyaml" 224 | version = "6.0.2" 225 | description = "YAML parser and emitter for Python" 226 | optional = false 227 | python-versions = ">=3.8" 228 | groups = ["main"] 229 | files = [ 230 | {file = "PyYAML-6.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0a9a2848a5b7feac301353437eb7d5957887edbf81d56e903999a75a3d743086"}, 231 | {file = "PyYAML-6.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:29717114e51c84ddfba879543fb232a6ed60086602313ca38cce623c1d62cfbf"}, 232 | {file = "PyYAML-6.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8824b5a04a04a047e72eea5cec3bc266db09e35de6bdfe34c9436ac5ee27d237"}, 233 | {file = "PyYAML-6.0.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7c36280e6fb8385e520936c3cb3b8042851904eba0e58d277dca80a5cfed590b"}, 234 | {file = "PyYAML-6.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ec031d5d2feb36d1d1a24380e4db6d43695f3748343d99434e6f5f9156aaa2ed"}, 235 | {file = "PyYAML-6.0.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:936d68689298c36b53b29f23c6dbb74de12b4ac12ca6cfe0e047bedceea56180"}, 236 | {file = "PyYAML-6.0.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:23502f431948090f597378482b4812b0caae32c22213aecf3b55325e049a6c68"}, 237 | {file = "PyYAML-6.0.2-cp310-cp310-win32.whl", hash = "sha256:2e99c6826ffa974fe6e27cdb5ed0021786b03fc98e5ee3c5bfe1fd5015f42b99"}, 238 | {file = "PyYAML-6.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:a4d3091415f010369ae4ed1fc6b79def9416358877534caf6a0fdd2146c87a3e"}, 239 | {file = "PyYAML-6.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:cc1c1159b3d456576af7a3e4d1ba7e6924cb39de8f67111c735f6fc832082774"}, 240 | {file = "PyYAML-6.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1e2120ef853f59c7419231f3bf4e7021f1b936f6ebd222406c3b60212205d2ee"}, 241 | {file = "PyYAML-6.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5d225db5a45f21e78dd9358e58a98702a0302f2659a3c6cd320564b75b86f47c"}, 242 | {file = "PyYAML-6.0.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5ac9328ec4831237bec75defaf839f7d4564be1e6b25ac710bd1a96321cc8317"}, 243 | {file = "PyYAML-6.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ad2a3decf9aaba3d29c8f537ac4b243e36bef957511b4766cb0057d32b0be85"}, 244 | {file = "PyYAML-6.0.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:ff3824dc5261f50c9b0dfb3be22b4567a6f938ccce4587b38952d85fd9e9afe4"}, 245 | {file = "PyYAML-6.0.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:797b4f722ffa07cc8d62053e4cff1486fa6dc094105d13fea7b1de7d8bf71c9e"}, 246 | {file = "PyYAML-6.0.2-cp311-cp311-win32.whl", hash = "sha256:11d8f3dd2b9c1207dcaf2ee0bbbfd5991f571186ec9cc78427ba5bd32afae4b5"}, 247 | {file = "PyYAML-6.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:e10ce637b18caea04431ce14fabcf5c64a1c61ec9c56b071a4b7ca131ca52d44"}, 248 | {file = "PyYAML-6.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:c70c95198c015b85feafc136515252a261a84561b7b1d51e3384e0655ddf25ab"}, 249 | {file = "PyYAML-6.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce826d6ef20b1bc864f0a68340c8b3287705cae2f8b4b1d932177dcc76721725"}, 250 | {file = "PyYAML-6.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f71ea527786de97d1a0cc0eacd1defc0985dcf6b3f17bb77dcfc8c34bec4dc5"}, 251 | {file = "PyYAML-6.0.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b22676e8097e9e22e36d6b7bda33190d0d400f345f23d4065d48f4ca7ae0425"}, 252 | {file = "PyYAML-6.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80bab7bfc629882493af4aa31a4cfa43a4c57c83813253626916b8c7ada83476"}, 253 | {file = "PyYAML-6.0.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:0833f8694549e586547b576dcfaba4a6b55b9e96098b36cdc7ebefe667dfed48"}, 254 | {file = "PyYAML-6.0.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8b9c7197f7cb2738065c481a0461e50ad02f18c78cd75775628afb4d7137fb3b"}, 255 | {file = "PyYAML-6.0.2-cp312-cp312-win32.whl", hash = "sha256:ef6107725bd54b262d6dedcc2af448a266975032bc85ef0172c5f059da6325b4"}, 256 | {file = "PyYAML-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:7e7401d0de89a9a855c839bc697c079a4af81cf878373abd7dc625847d25cbd8"}, 257 | {file = "PyYAML-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba"}, 258 | {file = "PyYAML-6.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1"}, 259 | {file = "PyYAML-6.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133"}, 260 | {file = "PyYAML-6.0.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484"}, 261 | {file = "PyYAML-6.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5"}, 262 | {file = "PyYAML-6.0.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc"}, 263 | {file = "PyYAML-6.0.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652"}, 264 | {file = "PyYAML-6.0.2-cp313-cp313-win32.whl", hash = "sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183"}, 265 | {file = "PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563"}, 266 | {file = "PyYAML-6.0.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:24471b829b3bf607e04e88d79542a9d48bb037c2267d7927a874e6c205ca7e9a"}, 267 | {file = "PyYAML-6.0.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d7fded462629cfa4b685c5416b949ebad6cec74af5e2d42905d41e257e0869f5"}, 268 | {file = "PyYAML-6.0.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d84a1718ee396f54f3a086ea0a66d8e552b2ab2017ef8b420e92edbc841c352d"}, 269 | {file = "PyYAML-6.0.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9056c1ecd25795207ad294bcf39f2db3d845767be0ea6e6a34d856f006006083"}, 270 | {file = "PyYAML-6.0.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:82d09873e40955485746739bcb8b4586983670466c23382c19cffecbf1fd8706"}, 271 | {file = "PyYAML-6.0.2-cp38-cp38-win32.whl", hash = "sha256:43fa96a3ca0d6b1812e01ced1044a003533c47f6ee8aca31724f78e93ccc089a"}, 272 | {file = "PyYAML-6.0.2-cp38-cp38-win_amd64.whl", hash = "sha256:01179a4a8559ab5de078078f37e5c1a30d76bb88519906844fd7bdea1b7729ff"}, 273 | {file = "PyYAML-6.0.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:688ba32a1cffef67fd2e9398a2efebaea461578b0923624778664cc1c914db5d"}, 274 | {file = "PyYAML-6.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a8786accb172bd8afb8be14490a16625cbc387036876ab6ba70912730faf8e1f"}, 275 | {file = "PyYAML-6.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d8e03406cac8513435335dbab54c0d385e4a49e4945d2909a581c83647ca0290"}, 276 | {file = "PyYAML-6.0.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f753120cb8181e736c57ef7636e83f31b9c0d1722c516f7e86cf15b7aa57ff12"}, 277 | {file = "PyYAML-6.0.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3b1fdb9dc17f5a7677423d508ab4f243a726dea51fa5e70992e59a7411c89d19"}, 278 | {file = "PyYAML-6.0.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:0b69e4ce7a131fe56b7e4d770c67429700908fc0752af059838b1cfb41960e4e"}, 279 | {file = "PyYAML-6.0.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:a9f8c2e67970f13b16084e04f134610fd1d374bf477b17ec1599185cf611d725"}, 280 | {file = "PyYAML-6.0.2-cp39-cp39-win32.whl", hash = "sha256:6395c297d42274772abc367baaa79683958044e5d3835486c16da75d2a694631"}, 281 | {file = "PyYAML-6.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:39693e1f8320ae4f43943590b49779ffb98acb81f788220ea932a6b6c51004d8"}, 282 | {file = "pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e"}, 283 | ] 284 | 285 | [[package]] 286 | name = "ruff" 287 | version = "0.11.0" 288 | description = "An extremely fast Python linter and code formatter, written in Rust." 289 | optional = false 290 | python-versions = ">=3.7" 291 | groups = ["dev"] 292 | files = [ 293 | {file = "ruff-0.11.0-py3-none-linux_armv6l.whl", hash = "sha256:dc67e32bc3b29557513eb7eeabb23efdb25753684b913bebb8a0c62495095acb"}, 294 | {file = "ruff-0.11.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:38c23fd9bdec4eb437b4c1e3595905a0a8edfccd63a790f818b28c78fe345639"}, 295 | {file = "ruff-0.11.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:7c8661b0be91a38bd56db593e9331beaf9064a79028adee2d5f392674bbc5e88"}, 296 | {file = "ruff-0.11.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b6c0e8d3d2db7e9f6efd884f44b8dc542d5b6b590fc4bb334fdbc624d93a29a2"}, 297 | {file = "ruff-0.11.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3c3156d3f4b42e57247275a0a7e15a851c165a4fc89c5e8fa30ea6da4f7407b8"}, 298 | {file = "ruff-0.11.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:490b1e147c1260545f6d041c4092483e3f6d8eba81dc2875eaebcf9140b53905"}, 299 | {file = "ruff-0.11.0-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:1bc09a7419e09662983b1312f6fa5dab829d6ab5d11f18c3760be7ca521c9329"}, 300 | {file = "ruff-0.11.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bcfa478daf61ac8002214eb2ca5f3e9365048506a9d52b11bea3ecea822bb844"}, 301 | {file = "ruff-0.11.0-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6fbb2aed66fe742a6a3a0075ed467a459b7cedc5ae01008340075909d819df1e"}, 302 | {file = "ruff-0.11.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:92c0c1ff014351c0b0cdfdb1e35fa83b780f1e065667167bb9502d47ca41e6db"}, 303 | {file = "ruff-0.11.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:e4fd5ff5de5f83e0458a138e8a869c7c5e907541aec32b707f57cf9a5e124445"}, 304 | {file = "ruff-0.11.0-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:96bc89a5c5fd21a04939773f9e0e276308be0935de06845110f43fd5c2e4ead7"}, 305 | {file = "ruff-0.11.0-py3-none-musllinux_1_2_i686.whl", hash = "sha256:a9352b9d767889ec5df1483f94870564e8102d4d7e99da52ebf564b882cdc2c7"}, 306 | {file = "ruff-0.11.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:049a191969a10897fe052ef9cc7491b3ef6de79acd7790af7d7897b7a9bfbcb6"}, 307 | {file = "ruff-0.11.0-py3-none-win32.whl", hash = "sha256:3191e9116b6b5bbe187447656f0c8526f0d36b6fd89ad78ccaad6bdc2fad7df2"}, 308 | {file = "ruff-0.11.0-py3-none-win_amd64.whl", hash = "sha256:c58bfa00e740ca0a6c43d41fb004cd22d165302f360aaa56f7126d544db31a21"}, 309 | {file = "ruff-0.11.0-py3-none-win_arm64.whl", hash = "sha256:868364fc23f5aa122b00c6f794211e85f7e78f5dffdf7c590ab90b8c4e69b657"}, 310 | {file = "ruff-0.11.0.tar.gz", hash = "sha256:e55c620690a4a7ee6f1cccb256ec2157dc597d109400ae75bbf944fc9d6462e2"}, 311 | ] 312 | 313 | [[package]] 314 | name = "types-pyyaml" 315 | version = "6.0.12.20240917" 316 | description = "Typing stubs for PyYAML" 317 | optional = false 318 | python-versions = ">=3.8" 319 | groups = ["dev"] 320 | files = [ 321 | {file = "types-PyYAML-6.0.12.20240917.tar.gz", hash = "sha256:d1405a86f9576682234ef83bcb4e6fff7c9305c8b1fbad5e0bcd4f7dbdc9c587"}, 322 | {file = "types_PyYAML-6.0.12.20240917-py3-none-any.whl", hash = "sha256:392b267f1c0fe6022952462bf5d6523f31e37f6cea49b14cee7ad634b6301570"}, 323 | ] 324 | 325 | [[package]] 326 | name = "typing-extensions" 327 | version = "4.12.2" 328 | description = "Backported and Experimental Type Hints for Python 3.8+" 329 | optional = false 330 | python-versions = ">=3.8" 331 | groups = ["dev"] 332 | files = [ 333 | {file = "typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d"}, 334 | {file = "typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8"}, 335 | ] 336 | 337 | [metadata] 338 | lock-version = "2.1" 339 | python-versions = "^3.11.7" 340 | content-hash = "d97ee36872d1e49e34449460a9864a7b0f7c0e52fccd7f664c1f2c3cdbdf1b65" 341 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "pytest-kubernetes" 3 | version = "0.5.0" 4 | description = "" 5 | authors = ["Michael Schilonka "] 6 | readme = "README.md" 7 | packages = [{include = "pytest_kubernetes"}] 8 | repository = "https://github.com/Blueshoe/pytest-kubernetes" 9 | classifiers = [ 10 | "Framework :: Pytest", 11 | "Topic :: Software Development :: Testing", 12 | "Programming Language :: Python :: 3.8", 13 | "Programming Language :: Python :: 3.9", 14 | "Programming Language :: Python :: 3.10" 15 | ] 16 | 17 | [tool.poetry.dependencies] 18 | python = "^3.11.7" 19 | pytest = "^8.3.0" 20 | pyyaml = "^6.0" 21 | 22 | [tool.poetry.group.dev.dependencies] 23 | coverage = "^7.1.0" 24 | mypy = "^1.0.0" 25 | types-pyyaml = "^6.0.12.6" 26 | ruff = "^0.11.0" 27 | 28 | [tool.poetry.plugins.pytest11] 29 | pytest-kubernetes = "pytest_kubernetes.plugin" 30 | 31 | [tool.pytest.ini_options] 32 | markers = [ 33 | "k8s: Kubernetes-based tests", 34 | ] 35 | 36 | [tool.mypy] 37 | warn_return_any = "True" 38 | warn_unused_configs = "True" 39 | exclude = """ 40 | tests 41 | """ 42 | 43 | [build-system] 44 | requires = ["poetry-core"] 45 | build-backend = "poetry.core.masonry.api" 46 | -------------------------------------------------------------------------------- /pytest_kubernetes/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Blueshoe/pytest-kubernetes/9e3b3c473e2c9a9bac360624962665fa6da3d393/pytest_kubernetes/__init__.py -------------------------------------------------------------------------------- /pytest_kubernetes/kubectl.py: -------------------------------------------------------------------------------- 1 | import json 2 | from typing import Dict, Union 3 | import os 4 | from pathlib import Path 5 | import shutil 6 | import subprocess 7 | from typing import List 8 | 9 | 10 | class Kubectl: 11 | """A wrapper for the kubectl command.""" 12 | 13 | _kubeconfig = None 14 | _context = None 15 | 16 | def __init__( 17 | self, 18 | kubeconfig: Path | None = None, 19 | context: str | None = None, 20 | command_prefix: List[str] | None = None, 21 | ) -> None: 22 | if kubeconfig is None: 23 | raise RuntimeError("The kubeconfig is not set. Did you create the cluster?") 24 | self._kubeconfig = kubeconfig 25 | self._context = context 26 | self._prefix = command_prefix 27 | 28 | @property 29 | def _exec_path(self) -> Path: 30 | return Path(str(shutil.which("kubectl")).strip()) 31 | 32 | def _ensure_executable(self) -> None: 33 | if not self._exec_path: 34 | raise RuntimeError("Executable is not set") 35 | 36 | if not self._exec_path.exists(): 37 | raise RuntimeError("Executable not found") 38 | 39 | def _get_exec_env(self) -> Dict: 40 | return os.environ # type: ignore 41 | 42 | def _get_command_prefix(self) -> List[str]: 43 | return self._prefix or [] 44 | 45 | def _get_kubeconfig_args(self) -> List[str]: 46 | args = ["--kubeconfig", str(self._kubeconfig)] 47 | if self._context: 48 | args += ["--context", str(self._context)] 49 | return args 50 | 51 | def _exec( 52 | self, arguments: List[str], timeout: int = 60 53 | ) -> subprocess.CompletedProcess: 54 | try: 55 | proc = subprocess.run( 56 | " ".join( 57 | self._get_command_prefix() 58 | + [str(self._exec_path)] 59 | + self._get_kubeconfig_args() 60 | + arguments 61 | ), 62 | shell=True, 63 | env=self._get_exec_env(), 64 | capture_output=True, 65 | check=True, 66 | timeout=timeout, 67 | ) 68 | return proc 69 | except subprocess.CalledProcessError as e: 70 | raise RuntimeError(e.stderr.decode("utf-8")) from None 71 | 72 | def __call__( 73 | self, args: List[str], as_dict: bool = True, timeout: int = 60 74 | ) -> Union[Dict, str]: 75 | if as_dict: 76 | args += ["-o", "json"] 77 | try: 78 | proc = self._exec(args, timeout=timeout) 79 | except RuntimeError as e: 80 | if as_dict and "unknown shorthand flag" in str(e): 81 | raise RuntimeError( 82 | "Cannot parse kubectl command into Dict. Please use kubectl([..], as_dict=False) to return a string" 83 | ) from None 84 | else: 85 | raise e 86 | output: str = proc.stdout.decode("utf-8") 87 | if as_dict: 88 | return json.loads(output) # type: ignore 89 | return output 90 | -------------------------------------------------------------------------------- /pytest_kubernetes/options.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass, field 2 | from pathlib import Path 3 | 4 | 5 | @dataclass 6 | class ClusterOptions: 7 | api_version: str = field(default="1.25.3") 8 | # nodes: int = None 9 | kubeconfig_path: Path | None = None 10 | cluster_timeout: int = field(default=240) 11 | -------------------------------------------------------------------------------- /pytest_kubernetes/plugin.py: -------------------------------------------------------------------------------- 1 | from typing import Dict, Type 2 | import pytest 3 | 4 | from pytest_kubernetes.providers import select_provider_manager 5 | from pytest_kubernetes.providers.base import AClusterManager 6 | 7 | cluster_cache: Dict[str, Type[AClusterManager]] = {} 8 | 9 | 10 | @pytest.fixture 11 | def k8s(request): 12 | """Provide a Kubernetes cluster as test fixture.""" 13 | 14 | provider = None 15 | cluster_name = None 16 | keep = False 17 | if "k8s" in request.keywords: 18 | req = dict(request.keywords["k8s"].kwargs) 19 | provider = req.get("provider") 20 | cluster_name = req.get("cluster_name") or cluster_name 21 | keep = req.get("keep") 22 | if not provider: 23 | provider = provider = request.config.getoption("k8s_provider") 24 | if not cluster_name: 25 | cluster_name = request.config.getoption("k8s_cluster_name") 26 | 27 | manager_klass = select_provider_manager(provider) 28 | cache_key = f"{manager_klass.__name__}-{cluster_name}" 29 | # check if this provider is kept from another test function 30 | if cache_key in cluster_cache: 31 | manager = cluster_cache[cache_key] 32 | del cluster_cache[cache_key] 33 | else: 34 | manager: AClusterManager = manager_klass(cluster_name) 35 | 36 | def delete_cluster(): 37 | manager.delete() 38 | 39 | if not keep: 40 | request.addfinalizer(delete_cluster) 41 | else: 42 | # if this cluster is to be kept put it to cache 43 | cluster_cache[cache_key] = manager 44 | return manager 45 | 46 | 47 | @pytest.fixture(scope="session", autouse=True) 48 | def remaining_clusters_teardown(): 49 | yield 50 | for _, cluster in cluster_cache.items(): 51 | cluster.delete() 52 | 53 | 54 | def pytest_addoption(parser): 55 | group = parser.getgroup("k8s") 56 | group.addoption( 57 | "--k8s-cluster-name", 58 | default="pytest", 59 | help="Name of the Kubernetes cluster (default 'pytest').", 60 | ) 61 | group.addoption( 62 | "--k8s-provider", 63 | help="The default cluster provider; selects k3d, kind, minikube depending on what is available", 64 | ) 65 | group.addoption( 66 | "--k8s-version", 67 | help="The default cluster provider; selects k3d, kind, minikube depending on what is available", 68 | ) 69 | -------------------------------------------------------------------------------- /pytest_kubernetes/portforwarding.py: -------------------------------------------------------------------------------- 1 | import io 2 | from pathlib import Path 3 | import subprocess 4 | import tempfile 5 | from time import sleep 6 | from typing import List, Tuple 7 | 8 | from pytest_kubernetes.kubectl import Kubectl 9 | 10 | 11 | class PortForwarding(Kubectl): 12 | """Port forwarding for a target. A target can be a pod, deployment, statefulset or a service. 13 | 14 | This class is responsible for setting up port forwarding for the target. It 15 | will start a process that will forward the specified ports to the pod. 16 | The process will be stopped when the object is destroyed. 17 | """ 18 | 19 | def __init__( 20 | self, 21 | target: str, 22 | ports: Tuple[int, int], 23 | namespace: str = "default", 24 | kubeconfig: Path | None = None, 25 | context: str | None = None, 26 | timeout: int = 90, 27 | ): 28 | self._target = target 29 | self._ports = ports 30 | self._process = None 31 | self._kubeconfig = kubeconfig 32 | self._context = context 33 | self._namespace = namespace 34 | self._timeout = timeout 35 | 36 | def __enter__(self): 37 | self.start() 38 | return self 39 | 40 | def __exit__(self, type, value, traceback): 41 | self.stop() 42 | 43 | def start(self): 44 | """Start the port forwarding process.""" 45 | if self._process: 46 | raise RuntimeError("Port forwarding already started") 47 | self._log = tempfile.TemporaryFile() 48 | self._forward() 49 | _t = self._timeout 50 | while _t > 0: 51 | self._log.seek(0) 52 | if "Forwarding from" in self._log.read().decode("utf-8"): 53 | break 54 | self._log.seek(0, io.SEEK_END) 55 | sleep(1) 56 | _t -= 1 57 | else: 58 | self._log.seek(0) 59 | logs = self._log.read().decode("utf-8") 60 | self.stop() 61 | raise RuntimeError(f"Port forwarding failed with error: {logs}") 62 | 63 | def stop(self): 64 | """Stop the port forwarding process.""" 65 | import socket 66 | 67 | if self._process: 68 | self._process.terminate() 69 | self._process.wait() 70 | self._process = None 71 | self._log.close() 72 | _t = self._timeout 73 | # make sure this port forwarding is stopped 74 | while _t > 0: 75 | s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 76 | socket.setdefaulttimeout(2.0) 77 | result = s.connect_ex(("127.0.0.1", int(self._ports[0]))) 78 | s.close() 79 | if result == 0: 80 | _t -= 1 81 | sleep(1) 82 | continue 83 | else: 84 | break 85 | else: 86 | raise RuntimeError("Port forwarding failed to stop") 87 | 88 | def _exec(self, arguments: List[str]) -> subprocess.Popen: # type: ignore 89 | proc = subprocess.Popen( 90 | [str(self._exec_path)] + self._get_kubeconfig_args() + arguments, 91 | stdout=self._log, 92 | stderr=self._log, 93 | ) 94 | return proc 95 | 96 | def _forward(self): 97 | """Forward the ports to the target.""" 98 | # Create the port forwarding command. 99 | self._process = self._exec( 100 | [ 101 | "port-forward", 102 | self._target, 103 | "--namespace", 104 | self._namespace, 105 | f"{self._ports[0]}:{self._ports[1]}", 106 | f"--pod-running-timeout={self._timeout}s", 107 | ] 108 | ) 109 | -------------------------------------------------------------------------------- /pytest_kubernetes/providers/__init__.py: -------------------------------------------------------------------------------- 1 | import shutil 2 | from typing import Type 3 | from pytest_kubernetes.providers.base import AClusterManager 4 | from .k3d import K3dManager 5 | from .kind import KindManager 6 | from .minikube import MinikubeDockerManager, MinikubeKVM2Manager 7 | 8 | 9 | def select_provider_manager(name: str | None = None) -> Type[AClusterManager]: 10 | if name: 11 | providers = { 12 | "k3d": K3dManager, 13 | "kind": KindManager, 14 | "minikube": MinikubeDockerManager, 15 | "minikube-docker": MinikubeDockerManager, 16 | "minikube-kvm2": MinikubeKVM2Manager, 17 | } 18 | provider = providers.get(name.lower(), None) 19 | if not provider: 20 | raise RuntimeError( 21 | f"Provider {name} not available. Options are {list(providers.keys())}" 22 | ) 23 | return provider 24 | else: 25 | # select a default provider 26 | for provider in [K3dManager, KindManager, MinikubeDockerManager]: 27 | if not shutil.which(provider.get_binary_name()): 28 | continue 29 | return provider 30 | else: 31 | raise RuntimeError( 32 | "There is none of the supported Kubernetes provider installed to this system" 33 | ) 34 | -------------------------------------------------------------------------------- /pytest_kubernetes/providers/base.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | import os 3 | import shutil 4 | import subprocess 5 | from pathlib import Path 6 | import tempfile 7 | from time import sleep 8 | from typing import Dict, List, Tuple, Union 9 | 10 | import yaml 11 | 12 | from pytest_kubernetes.kubectl import Kubectl 13 | from pytest_kubernetes.options import ClusterOptions 14 | from pytest_kubernetes.portforwarding import PortForwarding 15 | 16 | 17 | class AClusterManager(ABC): 18 | """ 19 | A manager to handle Kubernetes cluster providers. 20 | 21 | Attributes 22 | ---------- 23 | cluster_name : str 24 | the name of this cluster 25 | kubeconfig : Path 26 | a Path instance to the kubeconfig file for this cluster (after it was created) 27 | context : str 28 | the name of the context (usually None) 29 | 30 | Methods 31 | ------- 32 | kubectl(): 33 | Execute kubectl command against this cluster 34 | apply(): 35 | Apply resources to this cluster, either from YAML file, or Python dict 36 | load_image(): 37 | Load a container image into this cluster 38 | logs(): 39 | Get the logs of a pod 40 | port_forwarding(): 41 | Port forward a target 42 | wait(): 43 | Wait for a target to be ready 44 | version(): 45 | Get the Kubernetes version of this cluster 46 | create(): 47 | Create this cluster 48 | delete(): 49 | Delete this cluster 50 | reset(): 51 | Delete this cluster (if it exists) and create it again 52 | """ 53 | 54 | _binary_name = "" 55 | _cluster_options: ClusterOptions = ClusterOptions() 56 | cluster_name = "" 57 | context = None 58 | 59 | def __init__(self, cluster_name: str) -> None: 60 | self.cluster_name = f"pytest-{cluster_name}" 61 | self._ensure_executable() 62 | 63 | @classmethod 64 | @abstractmethod 65 | def get_binary_name(cls) -> str: 66 | raise NotImplementedError 67 | 68 | @property 69 | def _exec_path(self) -> Path: 70 | return Path(str(shutil.which(self.get_binary_name()))) 71 | 72 | def _ensure_executable(self) -> None: 73 | if not self._exec_path: 74 | raise RuntimeError("Executable is not set") 75 | 76 | if not self._exec_path.exists(): 77 | raise RuntimeError("Executable not found") 78 | 79 | def _get_exec_env(self) -> Dict: 80 | return os.environ # type: ignore 81 | 82 | def _exec( 83 | self, 84 | arguments: List[str], 85 | additional_env: Dict[str, str] = {}, 86 | timeout: int | None = None, 87 | ) -> subprocess.CompletedProcess: 88 | _timout = timeout or self._cluster_options.cluster_timeout 89 | proc = subprocess.run( 90 | f"{self._exec_path} {' '.join(arguments)}", 91 | env=self._get_exec_env().update(additional_env), 92 | shell=True, 93 | capture_output=True, 94 | check=True, 95 | timeout=_timout, 96 | ) 97 | return proc 98 | 99 | @abstractmethod 100 | def _on_create(self, cluster_options: ClusterOptions, **kwargs) -> None: 101 | raise NotImplementedError 102 | 103 | @abstractmethod 104 | def _on_delete(self) -> None: 105 | raise NotImplementedError 106 | 107 | @property 108 | def kubeconfig(self) -> Path | None: 109 | return ( 110 | Path(self._cluster_options.kubeconfig_path) 111 | if self._cluster_options.kubeconfig_path 112 | else None 113 | ) 114 | 115 | # 116 | # Interface 117 | # 118 | 119 | def kubectl( 120 | self, args: List[str], as_dict: bool = True, timeout: int = 60 121 | ) -> dict | str: 122 | """Execute kubectl command against this cluster""" 123 | return Kubectl(self.kubeconfig, self.context)(args, as_dict, timeout) 124 | 125 | def apply(self, input: Union[Path, Dict]) -> None: 126 | """Apply resources to this cluster, either from YAML file, or Python dict""" 127 | if type(input) in [Path, str] or isinstance(input, Path): 128 | self.kubectl(["apply", "-f", str(input)], as_dict=False) 129 | elif type(input) is dict: 130 | _yaml = yaml.dump(input) 131 | Kubectl( 132 | self.kubeconfig, 133 | self.context, 134 | command_prefix=[ 135 | "echo", 136 | f"'{_yaml}'", 137 | "|", 138 | ], 139 | )(["apply", "-f", "-"], as_dict=False) 140 | else: 141 | raise RuntimeError(f"Input must be of type Path or dict, was {type(input)}") 142 | 143 | def wait( 144 | self, name: str, waitfor: str, timeout: int = 90, namespace: str = "default" 145 | ) -> None: 146 | """Wait for a target and a contition""" 147 | self.kubectl( 148 | [ 149 | "wait", 150 | name, 151 | f"--for={waitfor}", 152 | f"--timeout={timeout}s", 153 | f"--namespace={namespace}", 154 | ], 155 | as_dict=False, 156 | timeout=timeout, 157 | ) 158 | 159 | def port_forwarding( 160 | self, 161 | target: str, 162 | source_port: int, 163 | target_port: int, 164 | namespace: str = "default", 165 | timeout: int = 90, 166 | ) -> PortForwarding: 167 | """Forward a local port to a pod""" 168 | return PortForwarding( 169 | target, 170 | (source_port, target_port), 171 | namespace, 172 | self.kubeconfig, 173 | self.context, 174 | timeout, 175 | ) 176 | 177 | @abstractmethod 178 | def load_image(self, image: str) -> None: 179 | """Load a container image into this cluster""" 180 | raise NotImplementedError 181 | 182 | def logs( 183 | self, pod: str, container: str | None = None, namespace: str | None = None 184 | ) -> str: 185 | """Get the logs of a pod""" 186 | args = ["logs", pod] 187 | 188 | if namespace: 189 | args.extend(["-n", namespace]) 190 | if container: 191 | args.extend(["-c", container]) 192 | 193 | return self.kubectl(args, as_dict=False) # type: ignore 194 | 195 | def version(self) -> Tuple[int, int]: 196 | """Get the Kubernetes version of this cluster""" 197 | data = self.kubectl(["version"]) 198 | return int(data["serverVersion"]["major"]), int(data["serverVersion"]["minor"]) # type: ignore 199 | 200 | def create( 201 | self, 202 | cluster_options: ClusterOptions | None = None, 203 | timeout: int = 20, 204 | **kwargs, 205 | ) -> None: 206 | """Create this cluster""" 207 | self._cluster_options = cluster_options or self._cluster_options 208 | if not self._cluster_options.kubeconfig_path: 209 | tmp_kubeconfig = tempfile.NamedTemporaryFile(delete=False) 210 | tmp_kubeconfig.close() 211 | self._cluster_options.kubeconfig_path = Path(tmp_kubeconfig.name) 212 | self._on_create(self._cluster_options, **kwargs) 213 | _i = 0 214 | # check if this cluster is ready: readyz check passed and default service account is available 215 | while _i < timeout: 216 | sleep(1) 217 | try: 218 | ready = self.kubectl(["get", "--raw='/readyz?verbose'"], as_dict=False) 219 | sa_available = self.kubectl( 220 | ["get", "sa", "default", "-n", "default"], as_dict=False 221 | ) 222 | except RuntimeError: 223 | _i += 1 224 | continue 225 | if "readyz check passed" in ready and "not found" not in sa_available: 226 | break 227 | else: 228 | _i += 1 229 | else: 230 | raise RuntimeError( 231 | f"Cluster '{self.cluster_name}' is not ready. Readyz: {ready}, SA: {sa_available}" 232 | ) 233 | 234 | def delete(self) -> None: 235 | """Delete this cluster""" 236 | self._on_delete() 237 | if self.kubeconfig: 238 | self.kubeconfig.unlink(missing_ok=True) 239 | self._cluster_options.kubeconfig_path = None 240 | sleep(1) 241 | 242 | def reset(self) -> None: 243 | """Reset this cluster (delete if exists and recreates)""" 244 | self.delete() 245 | self.create() 246 | -------------------------------------------------------------------------------- /pytest_kubernetes/providers/k3d.py: -------------------------------------------------------------------------------- 1 | from pytest_kubernetes.providers.base import AClusterManager 2 | from pytest_kubernetes.options import ClusterOptions 3 | 4 | 5 | class K3dManager(AClusterManager): 6 | @classmethod 7 | def get_binary_name(self) -> str: 8 | return "k3d" 9 | 10 | def _translate_version(self, version: str) -> str: 11 | return f"rancher/k3s:v{version}-k3s1" 12 | 13 | def _on_create(self, cluster_options: ClusterOptions, **kwargs) -> None: 14 | opts = kwargs.get("options", []) 15 | self._exec( 16 | [ 17 | "cluster", 18 | "create", 19 | self.cluster_name, 20 | "--kubeconfig-update-default=0", 21 | "--image", 22 | self._translate_version(cluster_options.api_version), 23 | "--wait", 24 | f"--timeout={cluster_options.cluster_timeout}s", 25 | ] 26 | + opts 27 | ) 28 | self._exec( 29 | [ 30 | "kubeconfig", 31 | "get", 32 | self.cluster_name, 33 | ">", 34 | str(cluster_options.kubeconfig_path), 35 | ] 36 | ) 37 | 38 | def _on_delete(self) -> None: 39 | self._exec(["cluster", "delete", self.cluster_name]) 40 | 41 | def load_image(self, image: str) -> None: 42 | self._exec(["image", "import", image, "--cluster", self.cluster_name]) 43 | -------------------------------------------------------------------------------- /pytest_kubernetes/providers/kind.py: -------------------------------------------------------------------------------- 1 | from pytest_kubernetes.providers.base import AClusterManager 2 | from pytest_kubernetes.options import ClusterOptions 3 | 4 | 5 | class KindManager(AClusterManager): 6 | @classmethod 7 | def get_binary_name(self) -> str: 8 | return "kind" 9 | 10 | def _on_create(self, cluster_options: ClusterOptions, **kwargs) -> None: 11 | opts = kwargs.get("options", []) 12 | _ = self._exec( 13 | [ 14 | "create", 15 | "cluster", 16 | "--name", 17 | self.cluster_name, 18 | "--kubeconfig", 19 | str(cluster_options.kubeconfig_path), 20 | "--image", 21 | f"kindest/node:v{cluster_options.api_version}", 22 | ] 23 | + opts 24 | ) 25 | 26 | def _on_delete(self) -> None: 27 | _ = self._exec(["delete", "cluster", "--name", self.cluster_name]) 28 | 29 | def load_image(self, image: str) -> None: 30 | self._exec(["load", "docker-image", image, "--name", self.cluster_name]) 31 | -------------------------------------------------------------------------------- /pytest_kubernetes/providers/minikube.py: -------------------------------------------------------------------------------- 1 | from pytest_kubernetes.providers.base import AClusterManager 2 | from pytest_kubernetes.options import ClusterOptions 3 | 4 | 5 | class MinikubeManager(AClusterManager): 6 | @classmethod 7 | def get_binary_name(cls) -> str: 8 | return "minikube" 9 | 10 | def _on_delete(self) -> None: 11 | self._exec(["delete", "-p", self.cluster_name]) 12 | 13 | def load_image(self, image: str) -> None: 14 | self._exec(["image", "load", image, "-p", self.cluster_name]) 15 | 16 | 17 | class MinikubeKVM2Manager(MinikubeManager): 18 | def _on_create(self, cluster_options: ClusterOptions, **kwargs) -> None: 19 | opts = kwargs.get("options", []) 20 | self._exec( 21 | [ 22 | "start", 23 | "-p", 24 | self.cluster_name, 25 | "--driver", 26 | "kvm2", 27 | "--embed-certs", 28 | "--kubernetes-version", 29 | f"v{cluster_options.api_version}", 30 | ] 31 | + opts, 32 | additional_env={"KUBECONFIG": str(cluster_options.kubeconfig_path)}, 33 | ) 34 | 35 | 36 | class MinikubeDockerManager(MinikubeManager): 37 | def _on_create(self, cluster_options: ClusterOptions, **kwargs) -> None: 38 | opts = kwargs.get("options", []) 39 | self._exec( 40 | [ 41 | "start", 42 | "-p", 43 | self.cluster_name, 44 | "--driver", 45 | "docker", 46 | "--embed-certs", 47 | "--kubernetes-version", 48 | f"v{cluster_options.api_version}", 49 | ] 50 | + opts, 51 | additional_env={"KUBECONFIG": str(cluster_options.kubeconfig_path)}, 52 | ) 53 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "config:base" 5 | ], 6 | "packageRules": [ 7 | { 8 | "matchUpdateTypes": ["patch"], 9 | "matchCurrentVersion": "!/^0/", 10 | "automerge": true 11 | } 12 | ] 13 | } 14 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [flake8] 2 | exclude = 3 | .git, 4 | __pycache__, 5 | docs/source/conf.py, 6 | tests 7 | old, 8 | build, 9 | dist 10 | .venv 11 | max-complexity = 10 12 | max-line-length = 100 13 | extend-ignore = 14 | W503, 15 | E203, 16 | E701, 17 | E501 18 | per-file-ignores = 19 | __init__.py: F401 20 | ./src/*: E402 21 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Blueshoe/pytest-kubernetes/9e3b3c473e2c9a9bac360624962665fa6da3d393/tests/__init__.py -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | import subprocess 3 | import pytest 4 | 5 | 6 | pytest_plugins = ["pytester"] 7 | 8 | 9 | @pytest.fixture(scope="session") 10 | def a_unique_image(request): 11 | name = "a-unique-image:latest" 12 | subprocess.run( 13 | f"docker build -t {name} -f {(Path(__file__).parent / Path('./fixtures/Dockerfile')).resolve()}" 14 | f" {(Path(__file__).parent / Path('./fixtures/')).resolve()}", 15 | shell=True, 16 | ) 17 | request.addfinalizer(lambda: subprocess.run(f"docker rmi {name}", shell=True)) 18 | return name 19 | -------------------------------------------------------------------------------- /tests/fixtures/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM busybox 2 | CMD [ "echo", "hello world" ] -------------------------------------------------------------------------------- /tests/fixtures/hello.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Service 3 | metadata: 4 | name: hello-nginx 5 | labels: 6 | run: hello-nginx 7 | spec: 8 | ports: 9 | - port: 80 10 | protocol: TCP 11 | targetPort: 80 12 | selector: 13 | app: hello-nginx 14 | 15 | --- 16 | apiVersion: apps/v1 17 | kind: Deployment 18 | metadata: 19 | name: hello-nginxdemo 20 | labels: 21 | app: hello-nginxdemo 22 | spec: 23 | selector: 24 | matchLabels: 25 | app: hello-nginx 26 | replicas: 1 27 | template: 28 | metadata: 29 | labels: 30 | app: hello-nginx 31 | spec: 32 | containers: 33 | - name: hello-nginx 34 | image: nginxdemos/hello 35 | ports: 36 | - containerPort: 80 37 | --- 38 | apiVersion: v1 39 | kind: Namespace 40 | metadata: 41 | name: commands 42 | labels: 43 | name: commands 44 | --- 45 | apiVersion: apps/v1 46 | kind: Deployment 47 | metadata: 48 | name: hello-nginxdemo-command 49 | namespace: commands 50 | spec: 51 | selector: 52 | matchLabels: 53 | app: hello-nginx-command 54 | replicas: 1 55 | template: 56 | metadata: 57 | labels: 58 | app: hello-nginx-command 59 | spec: 60 | containers: 61 | - name: hello-nginx-command 62 | image: nginxdemos/hello 63 | command: ["nginx", "-g", "daemon off;"] 64 | ports: 65 | - containerPort: 80 66 | --- 67 | apiVersion: networking.k8s.io/v1 68 | kind: Ingress 69 | metadata: 70 | name: hello-ingress 71 | spec: 72 | rules: 73 | - host: hello.127.0.0.1.nip.io 74 | http: 75 | paths: 76 | - path: / 77 | pathType: ImplementationSpecific 78 | backend: 79 | service: 80 | name: hello-nginx 81 | port: 82 | number: 80 -------------------------------------------------------------------------------- /tests/test_plugin.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | import subprocess 3 | 4 | 5 | def test_vendor_fixture_cases(testdir): 6 | # testdir is a pytest fixture 7 | vendor_test = (Path(__file__).parent / Path("./vendor.py")).resolve() 8 | result = testdir.runpytest(vendor_test, "--k8s-cluster-name", "kubernetes-plugin") 9 | result.assert_outcomes(passed=9) 10 | # assert no cluster is running 11 | process = subprocess.run( 12 | ["docker", "ps", "--format", '\'{"Names":"{{ .Names }}"}\''], 13 | stdout=subprocess.PIPE, 14 | universal_newlines=True, 15 | ) 16 | assert "pytest-kubernetes-plugin" not in process.stdout 17 | -------------------------------------------------------------------------------- /tests/test_providers.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from time import sleep 3 | from typing import Type 4 | 5 | import pytest 6 | 7 | from pytest_kubernetes.providers import ( 8 | AClusterManager, 9 | K3dManager, 10 | KindManager, 11 | MinikubeDockerManager, 12 | MinikubeKVM2Manager, 13 | select_provider_manager, 14 | ) 15 | 16 | 17 | class KubernetesManagerTest: 18 | manager: Type[AClusterManager] | None = None 19 | cluster: AClusterManager | None = None 20 | cluster_name = "pytest" 21 | 22 | def test_a_create_simple_cluster(self): 23 | self.cluster.create() 24 | output = self.cluster.kubectl(["get", "nodes"], as_dict=False) 25 | assert self.cluster.cluster_name in output 26 | assert "control-plane" in output 27 | data = self.cluster.kubectl(["get", "nodes"]) 28 | assert len(data["items"]) == 1 29 | assert self.cluster.kubeconfig is not None 30 | # assert server version 31 | assert (1, 25) == self.cluster.version() 32 | 33 | def test_b_reset_cluster(self): 34 | self.cluster = self.manager(self.cluster_name) 35 | self.cluster.create() 36 | self.cluster.kubectl(["get", "nodes"]) 37 | kubeconfig1 = self.cluster.kubeconfig 38 | self.cluster.reset() 39 | kubeconfig2 = self.cluster.kubeconfig 40 | self.cluster.kubectl(["get", "nodes"]) 41 | assert kubeconfig1 != kubeconfig2 42 | 43 | def test_c_apply_yaml_file(self): 44 | self.cluster.create() 45 | self.cluster.apply( 46 | (Path(__file__).parent / Path("./fixtures/hello.yaml")).resolve() 47 | ) 48 | data = self.cluster.kubectl(["get", "deployments"]) 49 | assert len(data["items"]) == 1 50 | assert data["items"][0]["metadata"]["name"] == "hello-nginxdemo" 51 | 52 | def test_c_apply_data(self): 53 | self.cluster.create() 54 | # apply a configmap from dict 55 | self.cluster.apply( 56 | { 57 | "apiVersion": "v1", 58 | "kind": "ConfigMap", 59 | "data": {"key": "value"}, 60 | "metadata": {"name": "myconfigmap"}, 61 | }, 62 | ) 63 | configmap = self.cluster.kubectl(["get", "configmap", "myconfigmap"]) 64 | assert len(configmap["data"].keys()) == 1 65 | assert configmap["data"]["key"] == "value" 66 | assert configmap["metadata"]["uid"] is not None 67 | 68 | def test_e_load_image_read_logs(self, a_unique_image): 69 | self.cluster.create() 70 | self.cluster.load_image(a_unique_image) 71 | self.cluster.kubectl( 72 | [ 73 | "run", 74 | "test", 75 | "--image", 76 | a_unique_image, 77 | "--restart=Never", 78 | "--image-pull-policy=Never", 79 | ] 80 | ) 81 | _i = 0 82 | exception = None 83 | while _i < 30: 84 | sleep(1) 85 | try: 86 | pod = self.cluster.kubectl(["get", "pod", "test"]) 87 | assert pod["spec"]["containers"][0]["image"] == a_unique_image 88 | assert pod["status"]["phase"] == "Succeeded" 89 | assert "hello world" in self.cluster.logs(pod="test", container="test") 90 | break 91 | except (AssertionError, KeyError) as e: 92 | _i += 1 93 | exception = e 94 | continue 95 | else: 96 | raise exception 97 | 98 | def test_f_portforwarding(self): 99 | import urllib.request 100 | 101 | self.cluster.create() 102 | self.cluster.apply( 103 | (Path(__file__).parent / Path("./fixtures/hello.yaml")).resolve() 104 | ) 105 | self.cluster.wait("deployments/hello-nginxdemo", "condition=Available=True") 106 | forwarding_nginx = self.cluster.port_forwarding("svc/hello-nginx", 9090, 80) 107 | with forwarding_nginx: 108 | response = urllib.request.urlopen("http://127.0.0.1:9090", timeout=20) 109 | assert response.status == 200 110 | 111 | forwarding_nginx = self.cluster.port_forwarding("svc/hello-nginx", 9090, 80) 112 | forwarding_nginx.start() 113 | response = urllib.request.urlopen("http://127.0.0.1:9090", timeout=20) 114 | assert response.status == 200 115 | forwarding_nginx.stop() 116 | 117 | def test_d_logs_namespace(self): 118 | self.cluster.create() 119 | self.cluster.apply( 120 | (Path(__file__).parent / Path("./fixtures/hello.yaml")).resolve() 121 | ) 122 | self.cluster.wait("deployments/hello-nginxdemo", "condition=Available=True") 123 | 124 | data = self.cluster.kubectl(["get", "deployments", "-n", "commands"]) 125 | assert len(data["items"]) == 1 126 | assert data["items"][0]["metadata"]["name"] == "hello-nginxdemo-command" 127 | 128 | pod_name = self.cluster.kubectl( 129 | [ 130 | "get", 131 | "pod", 132 | "-n", 133 | "commands", 134 | "-o", 135 | "jsonpath={.items[0].metadata.name}", 136 | ], 137 | as_dict=False, 138 | ) 139 | assert pod_name 140 | 141 | _i = 0 142 | exception = None 143 | while _i < 30: 144 | sleep(1) 145 | try: 146 | assert 'using the "epoll" event method' in self.cluster.logs( 147 | pod=pod_name, namespace="commands" 148 | ) 149 | break 150 | except (AssertionError, KeyError) as e: 151 | _i += 1 152 | exception = e 153 | continue 154 | else: 155 | raise exception 156 | 157 | def teardown_method(self, method): 158 | self.cluster.delete() 159 | 160 | def setup_method(self, method): 161 | self.cluster = self.manager(self.cluster_name) 162 | 163 | 164 | class Testk3d(KubernetesManagerTest): 165 | manager = K3dManager 166 | 167 | 168 | class Testkind(KubernetesManagerTest): 169 | manager = KindManager 170 | 171 | 172 | class TestDockerminikube(KubernetesManagerTest): 173 | manager = MinikubeDockerManager 174 | 175 | 176 | class TestKVM2minikube(KubernetesManagerTest): 177 | manager = MinikubeKVM2Manager 178 | 179 | 180 | def test_select_provider(monkeypatch): 181 | provider_klass = select_provider_manager() 182 | assert issubclass(provider_klass, AClusterManager) 183 | 184 | k3d_klass = select_provider_manager("k3d") 185 | assert k3d_klass == K3dManager 186 | minikube_klass = select_provider_manager("minikube") 187 | assert minikube_klass == MinikubeDockerManager 188 | minikube_klass = select_provider_manager("minikube-docker") 189 | assert minikube_klass == MinikubeDockerManager 190 | minikube_klass = select_provider_manager("minikube-kvm2") 191 | assert minikube_klass == MinikubeKVM2Manager 192 | # if k3d is not available 193 | monkeypatch.setattr(K3dManager, "get_binary_name", lambda: "k3dlol") 194 | provider_klass = select_provider_manager() 195 | assert provider_klass != K3dManager 196 | 197 | with pytest.raises(RuntimeError): 198 | _ = select_provider_manager("rofl") 199 | -------------------------------------------------------------------------------- /tests/vendor.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | import subprocess 3 | 4 | import pytest 5 | 6 | from pytest_kubernetes.providers import select_provider_manager 7 | from pytest_kubernetes.providers.base import AClusterManager 8 | 9 | 10 | @pytest.fixture(scope="session") 11 | def k8s_with_workload(request): 12 | cluster = select_provider_manager()("my-cluster") 13 | # if minikube should be used 14 | # cluster = select_provider_manager("minikube")("my-cluster") 15 | cluster.create() 16 | # init the cluster with a workload 17 | cluster.apply((Path(__file__).parent / Path("./fixtures/hello.yaml")).resolve()) 18 | yield cluster 19 | cluster.delete() 20 | 21 | 22 | def test_a_feature_with_k3d(k8s: AClusterManager): 23 | assert k8s.get_binary_name() == "k3d" 24 | k8s.create() 25 | 26 | 27 | def test_b_cluster_deleted(): 28 | process = subprocess.run( 29 | ["docker", "ps", "--format", '\'{"Names":"{{ .Names }}"}\''], 30 | stdout=subprocess.PIPE, 31 | universal_newlines=True, 32 | ) 33 | assert "k3d-pytest-kubernetes-plugin" not in process.stdout 34 | 35 | 36 | @pytest.mark.k8s(keep=True) 37 | def test_c_keep_k3d(k8s: AClusterManager): 38 | assert k8s.get_binary_name() == "k3d" 39 | k8s.create() 40 | k8s.apply( 41 | { 42 | "apiVersion": "v1", 43 | "kind": "ConfigMap", 44 | "data": {"key": "value"}, 45 | "metadata": {"name": "myconfigmap"}, 46 | }, 47 | ) 48 | k8s.apply((Path(__file__).parent / Path("./fixtures/hello.yaml")).resolve()) 49 | 50 | 51 | @pytest.mark.k8s(keep=True) 52 | def test_d_kept_cluster_delete(k8s: AClusterManager): 53 | process = subprocess.run( 54 | ["docker", "ps", "--format", '\'{"Names":"{{ .Names }}"}\''], 55 | stdout=subprocess.PIPE, 56 | universal_newlines=True, 57 | ) 58 | assert "k3d-pytest-kubernetes-plugin" in process.stdout 59 | 60 | configmap = k8s.kubectl(["get", "configmap", "myconfigmap"]) 61 | assert len(configmap["data"].keys()) == 1 62 | assert configmap["data"]["key"] == "value" 63 | assert configmap["metadata"]["uid"] is not None 64 | 65 | 66 | def test_e_kept_cluster_delete(k8s: AClusterManager): 67 | process = subprocess.run( 68 | ["docker", "ps", "--format", '\'{"Names":"{{ .Names }}"}\''], 69 | stdout=subprocess.PIPE, 70 | universal_newlines=True, 71 | ) 72 | assert "k3d-pytest-kubernetes-plugin" in process.stdout 73 | 74 | configmap = k8s.kubectl(["get", "configmap", "myconfigmap"]) 75 | assert len(configmap["data"].keys()) == 1 76 | assert configmap["data"]["key"] == "value" 77 | assert configmap["metadata"]["uid"] is not None 78 | 79 | k8s.reset() 80 | with pytest.raises(RuntimeError): 81 | configmap = k8s.kubectl(["get", "configmap", "myconfigmap"]) 82 | 83 | k8s.delete() 84 | process = subprocess.run( 85 | ["docker", "ps", "--format", '\'{"Names":"{{ .Names }}"}\''], 86 | stdout=subprocess.PIPE, 87 | universal_newlines=True, 88 | ) 89 | assert "k3d-pytest-kubernetes-plugin" not in process.stdout 90 | 91 | 92 | def test_f_prepopulated_cluster(k8s_with_workload: AClusterManager): 93 | k8s = k8s_with_workload 94 | 95 | deployment = k8s.kubectl(["get", "deployment", "hello-nginxdemo"]) 96 | assert deployment["metadata"]["uid"] is not None 97 | 98 | process = subprocess.run( 99 | ["docker", "ps", "--format", '\'{"Names":"{{ .Names }}"}\''], 100 | stdout=subprocess.PIPE, 101 | universal_newlines=True, 102 | ) 103 | assert "k3d-pytest-my-cluster" in process.stdout 104 | 105 | 106 | def test_g_prepopulated_cluster_kept(k8s_with_workload: AClusterManager): 107 | k8s = k8s_with_workload 108 | 109 | deployment = k8s.kubectl(["get", "deployment", "hello-nginxdemo"]) 110 | assert deployment["metadata"]["uid"] is not None 111 | 112 | process = subprocess.run( 113 | ["docker", "ps", "--format", '\'{"Names":"{{ .Names }}"}\''], 114 | stdout=subprocess.PIPE, 115 | universal_newlines=True, 116 | ) 117 | assert "k3d-pytest-my-cluster" in process.stdout 118 | k8s.reset() 119 | with pytest.raises(RuntimeError): 120 | k8s.kubectl(["get", "deployment", "hello-nginxdemo"]) 121 | 122 | 123 | @pytest.mark.k8s(provider="minikube", keep=True) 124 | def test_z_feature_with_minikube(k8s: AClusterManager): 125 | assert k8s.get_binary_name() == "minikube" 126 | k8s.create() 127 | # after this test case, we want no minikube cluster remaing; assertion in test_plugin.py 128 | 129 | 130 | @pytest.mark.k8s(keep=True) 131 | def test_z_keep_k3d_over_testrun(k8s: AClusterManager): 132 | k8s.create() 133 | # after this test case, we want no k3d cluster remaing; assertion in test_plugin.py 134 | --------------------------------------------------------------------------------