├── .github └── workflows │ ├── publish.yml │ ├── release-please.yml │ └── tests.yml ├── .gitignore ├── .release-please-manifest.json ├── CHANGELOG.md ├── LICENSE ├── README.md ├── poetry.lock ├── pyproject.toml ├── pytest_netconf ├── __init__.py ├── constants.py ├── exceptions.py ├── netconfserver.py ├── pytest_plugin.py ├── settings.py ├── sshserver.py └── version.py ├── release-please-config.json └── tests ├── conftest.py ├── integration ├── test_ncclient.py ├── test_netconf_client.py └── test_scrapli_netconf.py └── unit └── test_netconfserver.py /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: publish 2 | 3 | on: 4 | release: 5 | types: 6 | - released 7 | 8 | jobs: 9 | build-wheel: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v4 13 | - uses: actions/setup-python@v5 14 | with: 15 | python-version: "3.x" 16 | - name: Install poetry 17 | run: pip install poetry 18 | - name: Configure poetry 19 | run: python -m poetry config virtualenvs.in-project true 20 | - name: Show poetry version 21 | run: poetry --version 22 | - name: Build wheel 23 | run: poetry build --format wheel 24 | - uses: actions/upload-artifact@v4 25 | with: 26 | name: ${{ github.event.repository.name }}.wheel 27 | path: dist/ 28 | 29 | upload-github: 30 | needs: [build-wheel] 31 | runs-on: ubuntu-latest 32 | steps: 33 | - uses: actions/checkout@v4 34 | - uses: actions/download-artifact@v4 35 | with: 36 | name: ${{ github.event.repository.name }}.wheel 37 | path: dist/ 38 | - name: Generate app token 39 | uses: actions/create-github-app-token@v1 40 | id: app-token 41 | with: 42 | app-id: ${{ secrets.BOT_APP_ID }} 43 | private-key: ${{ secrets.BOT_PRIVATE_KEY }} 44 | - name: Process module name and version number 45 | id: process_names 46 | # Replace hyphens with underscores for module name 47 | # Remove v from version number as poetry builds wheel without 'v' 48 | run: | 49 | module_name=$(echo "${GITHUB_REPOSITORY##*/}" | sed 's/-/_/g') 50 | version_number=$(echo "${GITHUB_REF##*/}" | sed 's/^v//') 51 | echo "module_name=$module_name" >> $GITHUB_ENV 52 | echo "version_number=$version_number" >> $GITHUB_ENV 53 | - name: Upload package to Github 54 | uses: actions/upload-release-asset@v1.0.2 55 | env: 56 | GITHUB_TOKEN: ${{ steps.app-token.outputs.token }} 57 | with: 58 | upload_url: ${{ github.event.release.upload_url }} 59 | asset_path: dist/${{ env.module_name }}-${{ env.version_number }}-py3-none-any.whl 60 | asset_name: ${{ env.module_name }}-${{ env.version_number }}-py3-none-any.whl 61 | asset_content_type: application/zip 62 | 63 | upload-pypi: 64 | needs: [build-wheel] 65 | runs-on: ubuntu-latest 66 | steps: 67 | - uses: actions/checkout@v4 68 | - uses: actions/download-artifact@v4 69 | with: 70 | name: ${{ github.event.repository.name }}.wheel 71 | path: dist/ 72 | - name: Publish to PyPI 73 | if: startsWith(github.ref, 'refs/tags') 74 | uses: pypa/gh-action-pypi-publish@release/v1 75 | with: 76 | password: ${{ secrets.PYPI_API_TOKEN }} 77 | -------------------------------------------------------------------------------- /.github/workflows/release-please.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | branches: 4 | - develop 5 | 6 | name: release-please 7 | 8 | jobs: 9 | release-please: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Generate app token 13 | uses: actions/create-github-app-token@v1 14 | id: app-token 15 | with: 16 | app-id: ${{ secrets.BOT_APP_ID }} 17 | private-key: ${{ secrets.BOT_PRIVATE_KEY }} 18 | - uses: googleapis/release-please-action@v4 19 | id: release 20 | with: 21 | token: ${{ steps.app-token.outputs.token }} 22 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: tests 2 | concurrency: 3 | group: ${{ github.head_ref || github.run_id }} 4 | cancel-in-progress: true 5 | 6 | on: [push, pull_request] 7 | 8 | jobs: 9 | test: 10 | strategy: 11 | fail-fast: false 12 | matrix: 13 | os: [ubuntu-latest] 14 | # os: [ubuntu-latest, macos-latest, windows-latest] 15 | python-version: [3.8, 3.9, "3.10", 3.11, 3.12] 16 | runs-on: ${{ matrix.os }} 17 | steps: 18 | - uses: actions/checkout@v3 19 | - uses: actions/setup-python@v3 20 | with: 21 | python-version: ${{ matrix.python-version }} 22 | - name: Install Python Poetry 23 | shell: bash 24 | run: pip install poetry 25 | - name: Configure poetry 26 | shell: bash 27 | run: python -m poetry config virtualenvs.in-project true 28 | - name: Show poetry version 29 | run: poetry --version 30 | - name: Install dependencies 31 | run: poetry install 32 | - name: Test with pytest 33 | run: poetry run pytest -vv 34 | 35 | coverage: 36 | runs-on: ubuntu-latest 37 | needs: test 38 | steps: 39 | - uses: actions/checkout@v3 40 | - uses: actions/setup-python@v3 41 | with: 42 | python-version: "3.10" 43 | - name: Install Python Poetry 44 | shell: bash 45 | run: pip install poetry 46 | - name: Configure poetry 47 | shell: bash 48 | run: python -m poetry config virtualenvs.in-project true 49 | - name: Show poetry version 50 | run: poetry --version 51 | - name: Install dependencies 52 | run: poetry install 53 | - name: Test with pytest 54 | run: | 55 | MODNAME=$(echo "${PWD##*/}" | sed 's/-/_/g') 56 | poetry run coverage run --source $MODNAME --parallel-mode -m pytest 57 | poetry run coverage combine 58 | poetry run coverage report 59 | poetry run coverage xml 60 | - name: Upload coverage 61 | uses: codecov/codecov-action@v3 62 | with: 63 | token: ${{ secrets.CODECOV_TOKEN }} 64 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | testing 54 | 55 | # Translations 56 | *.mo 57 | *.pot 58 | 59 | # Django stuff: 60 | *.log 61 | local_settings.py 62 | db.sqlite3 63 | db.sqlite3-journal 64 | 65 | # Flask stuff: 66 | instance/ 67 | .webassets-cache 68 | 69 | # Scrapy stuff: 70 | .scrapy 71 | 72 | # Sphinx documentation 73 | docs/_build/ 74 | 75 | # PyBuilder 76 | .pybuilder/ 77 | target/ 78 | 79 | # Jupyter Notebook 80 | .ipynb_checkpoints 81 | 82 | # IPython 83 | profile_default/ 84 | ipython_config.py 85 | 86 | # pyenv 87 | # For a library or package, you might want to ignore these files since the code is 88 | # intended to run in multiple environments; otherwise, check them in: 89 | # .python-version 90 | 91 | # pipenv 92 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 93 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 94 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 95 | # install all needed dependencies. 96 | #Pipfile.lock 97 | 98 | # poetry 99 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 100 | # This is especially recommended for binary packages to ensure reproducibility, and is more 101 | # commonly ignored for libraries. 102 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 103 | #poetry.lock 104 | 105 | # pdm 106 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 107 | #pdm.lock 108 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 109 | # in version control. 110 | # https://pdm.fming.dev/latest/usage/project/#working-with-version-control 111 | .pdm.toml 112 | .pdm-python 113 | .pdm-build/ 114 | 115 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 116 | __pypackages__/ 117 | 118 | # Celery stuff 119 | celerybeat-schedule 120 | celerybeat.pid 121 | 122 | # SageMath parsed files 123 | *.sage.py 124 | 125 | # Environments 126 | .env 127 | .venv 128 | env/ 129 | venv/ 130 | ENV/ 131 | env.bak/ 132 | venv.bak/ 133 | 134 | # Spyder project settings 135 | .spyderproject 136 | .spyproject 137 | 138 | # Rope project settings 139 | .ropeproject 140 | 141 | # mkdocs documentation 142 | /site 143 | 144 | # mypy 145 | .mypy_cache/ 146 | .dmypy.json 147 | dmypy.json 148 | 149 | # Pyre type checker 150 | .pyre/ 151 | 152 | # pytype static type analyzer 153 | .pytype/ 154 | 155 | # Cython debug symbols 156 | cython_debug/ 157 | 158 | # PyCharm 159 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 160 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 161 | # and can be added to the global gitignore or merged into this file. For a more nuclear 162 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 163 | .idea/ 164 | 165 | # Visual Studio Code 166 | .vscode 167 | -------------------------------------------------------------------------------- /.release-please-manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | ".": "0.1.1" 3 | } 4 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## [0.1.1](https://github.com/nomios-opensource/pytest-netconf/compare/v0.1.0...v0.1.1) (2025-01-06) 4 | 5 | 6 | ### Bug Fixes 7 | 8 | * server restart hanging ([#5](https://github.com/nomios-opensource/pytest-netconf/issues/5)) ([397858a](https://github.com/nomios-opensource/pytest-netconf/commit/397858a9f7c0fb3632a4ba256f3b7fa47727c417)) 9 | * update dependencies ([140d31f](https://github.com/nomios-opensource/pytest-netconf/commit/140d31fc536bbba2a1db8ca10ea6379356fa3f3e)) 10 | * update dependencies ([03857e0](https://github.com/nomios-opensource/pytest-netconf/commit/03857e0542e329464698fdf6c8caa88175f2f48e)) 11 | 12 | 13 | ### Documentation 14 | 15 | * add installation instructions ([12ae0c9](https://github.com/nomios-opensource/pytest-netconf/commit/12ae0c92ed8fd56af8d6802754fb1efa51b6f9be)) 16 | * update codecov badge ([addbcfd](https://github.com/nomios-opensource/pytest-netconf/commit/addbcfdbb7489af033d822896c14fa27fc89d5c7)) 17 | 18 | ## 0.1.0 (2024-08-08) 19 | 20 | 21 | ### Features 22 | 23 | * add NETCONF SSH server ([7c4594c](https://github.com/nomios-opensource/pytest-netconf/commit/7c4594c124c91aa0560ea4d7d6e1add492dc6202)) 24 | * add SSH password authentication ([9f01ce0](https://github.com/nomios-opensource/pytest-netconf/commit/9f01ce0b3a8366e8d7bbeafb0143b0187f9445ff)) 25 | * add SSH public key authentication ([1fe8cc3](https://github.com/nomios-opensource/pytest-netconf/commit/1fe8cc3c8c1f2518da0611aa37ef43760a190b6c)) 26 | * add support for NETCONF base version 1.0 and 1.1 ([322e68a](https://github.com/nomios-opensource/pytest-netconf/commit/322e68a1cb31648065de713230c657c38630bc3a)) 27 | * add support for regex request matching ([bc29e4d](https://github.com/nomios-opensource/pytest-netconf/commit/bc29e4d090305c8d38f561401a499e64bc213411)) 28 | * add tests for popular NETCONF clients ([c4c963a](https://github.com/nomios-opensource/pytest-netconf/commit/c4c963a3ce8472d53e90acf25511fbd03ce93439)) 29 | 30 | 31 | ### Bug Fixes 32 | 33 | * add test for publickey auth using wrong key ([1bb8067](https://github.com/nomios-opensource/pytest-netconf/commit/1bb8067a6b242d1172a2f557eb1f727a665064a1)) 34 | * channel close error ([06264c4](https://github.com/nomios-opensource/pytest-netconf/commit/06264c4b4d3badd5de187f581a6bed6e11d0e3eb)) 35 | * coverage bug when testing pytest plugins ([ac27e25](https://github.com/nomios-opensource/pytest-netconf/commit/ac27e2542630fc81b8978400506604eddce29c49)) 36 | * ncclient disconnect test ([2124520](https://github.com/nomios-opensource/pytest-netconf/commit/21245206419d2806007e474437385d45ae0067ca)) 37 | * pytest-cov threading bug ([6de51c5](https://github.com/nomios-opensource/pytest-netconf/commit/6de51c55e1839adaadc433172e6e0d4e4ae7a926)) 38 | -------------------------------------------------------------------------------- /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-netconf 2 | 3 | ![GitHub Actions Workflow Status](https://img.shields.io/github/actions/workflow/status/nomios-opensource/pytest-netconf/publish.yml) 4 | [![codecov](https://codecov.io/gh/nomios-opensource/pytest-netconf/branch/develop/graph/badge.svg?token=iKZNzUr2LI)](https://codecov.io/gh/nomios-opensource/pytest-netconf) 5 | ![PyPI - Python Version](https://img.shields.io/pypi/pyversions/pytest-netconf) 6 | ![PyPI - Downloads](https://img.shields.io/pypi/dm/pytest-netconf) 7 | ![GitHub License](https://img.shields.io/github/license/nomios-opensource/pytest-netconf) 8 | 9 | A pytest plugin that provides a mock NETCONF (RFC6241/RFC6242) server for local testing. 10 | 11 | `pytest-netconf` is authored by [Adam Kirchberger](https://github.com/adamkirchberger), governed as a [benevolent dictatorship](CODE_OF_CONDUCT.md), and distributed under [license](LICENSE). 12 | 13 | ## Introduction 14 | 15 | Testing NETCONF devices has traditionally required maintaining labs with multiple vendor devices which can be complex and resource-intensive. Additionally, spinning up virtual devices for testing purposes is often time-consuming and too slow for CICD pipelines. This plugin provides a convenient way to mock the behavior and responses of these NETCONF devices. 16 | 17 | ## Features 18 | 19 | - **NETCONF server**, a real SSH server is run locally which enables testing using actual network connections instead of patching. 20 | - **Predefined requests and responses**, define specific NETCONF requests and responses to meet your testing needs. 21 | - **Capability testing**, define specific capabilities you want the server to support and test their responses. 22 | - **Authentication testing**, test error handling for authentication issues (supports password or key auth). 23 | - **Connection testing**, test error handling when tearing down connections unexpectedly. 24 | 25 | ## NETCONF Clients 26 | 27 | The clients below have been tested 28 | 29 | - `ncclient` :white_check_mark: 30 | - `netconf-client` :white_check_mark: 31 | - `scrapli-netconf` :white_check_mark: 32 | 33 | ## Installation 34 | 35 | Install using `pip install pytest-netconf` or `poetry add --group dev pytest-netconf` 36 | 37 | ## Quickstart 38 | 39 | The plugin will install a pytest fixture named `netconf_server`, which will start an SSH server with settings you provide, and **only** reply to requests which you define with corresponding responses. 40 | 41 | For more use cases see [examples](#examples) 42 | 43 | 44 | ```python 45 | # Configure server settings 46 | netconf_server.username = None # allow any username 47 | netconf_server.password = None # allow any password 48 | netconf_server.port = 8830 # default value 49 | 50 | # Configure a request and response 51 | netconf_server.expect_request( 52 | '' 53 | '' 54 | "" 55 | "" 56 | ).respond_with( 57 | """ 58 | 59 | 61 | 62 | 63 | 64 | eth0 65 | 66 | 67 | 68 | 69 | """ 70 | ) 71 | ``` 72 | 73 | ## Examples 74 | 75 |
76 | Get Config 77 |
78 | 79 | ```python 80 | from pytest_netconf import NetconfServer 81 | from ncclient import manager 82 | 83 | 84 | def test_netconf_get_config( 85 | netconf_server: NetconfServer, 86 | ): 87 | # GIVEN server request and response 88 | netconf_server.expect_request( 89 | '' 90 | '' 91 | "" 92 | "" 93 | ).respond_with( 94 | """ 95 | 96 | 98 | 99 | 100 | 101 | eth0 102 | 103 | 104 | 105 | """ 106 | ) 107 | 108 | # WHEN fetching rpc response from server 109 | with manager.connect( 110 | host="localhost", 111 | port=8830, 112 | username="admin", 113 | password="admin", 114 | hostkey_verify=False, 115 | ) as m: 116 | response = m.get_config(source="running").data_xml 117 | 118 | # THEN expect response 119 | assert ( 120 | """ 121 | 122 | 123 | eth0 124 | 125 | 126 | """ 127 | in response 128 | ) 129 | ``` 130 |
131 | 132 |
133 | Authentication Fail 134 |
135 | 136 | ```python 137 | from pytest_netconf import NetconfServer 138 | from ncclient import manager 139 | from ncclient.transport.errors import AuthenticationError 140 | 141 | 142 | def test_netconf_auth_fail( 143 | netconf_server: NetconfServer, 144 | ): 145 | # GIVEN username and password have been defined 146 | netconf_server.username = "admin" 147 | netconf_server.password = "password" 148 | 149 | # WHEN connecting using wrong credentials 150 | with pytest.raises(AuthenticationError) as error: 151 | with manager.connect( 152 | host="localhost", 153 | port=8830, 154 | username="foo", 155 | password="bar", 156 | hostkey_verify=False, 157 | ): 158 | ... 159 | 160 | # THEN expect error 161 | assert error 162 | ``` 163 |
164 | 165 |
166 | Custom Capabilities 167 |
168 | 169 | ```python 170 | from pytest_netconf import NetconfServer 171 | from ncclient import manager 172 | 173 | 174 | def test_netconf_capabilities( 175 | netconf_server: NetconfServer, 176 | ): 177 | # GIVEN extra capabilities 178 | netconf_server.capabilities.append("urn:ietf:params:netconf:capability:foo:1.1") 179 | netconf_server.capabilities.append("urn:ietf:params:netconf:capability:bar:1.1") 180 | 181 | # WHEN receiving server capabilities 182 | with manager.connect( 183 | host="localhost", 184 | port=8830, 185 | username="admin", 186 | password="admin", 187 | hostkey_verify=False, 188 | ) as m: 189 | server_capabilities = m.server_capabilities 190 | 191 | # THEN expect to see capabilities 192 | assert "urn:ietf:params:netconf:capability:foo:1.1" in server_capabilities 193 | assert "urn:ietf:params:netconf:capability:bar:1.1" in server_capabilities 194 | ``` 195 |
196 | 197 |
198 | Server Disconnect 199 |
200 | 201 | ```python 202 | from pytest_netconf import NetconfServer 203 | from ncclient import manager 204 | from ncclient.transport.errors import TransportError 205 | 206 | 207 | def test_netconf_server_disconnect( 208 | netconf_server: NetconfServer, 209 | ): 210 | # GIVEN netconf connection 211 | with pytest.raises(TransportError) as error: 212 | with manager.connect( 213 | host="localhost", 214 | port=8830, 215 | username="admin", 216 | password="admin", 217 | hostkey_verify=False, 218 | ) as m: 219 | pass 220 | # WHEN server stops 221 | netconf_server.stop() 222 | 223 | # THEN expect error 224 | assert str(error.value) == "Not connected to NETCONF server" 225 | ``` 226 |
227 | 228 |
229 | Key Auth 230 |
231 | 232 | ```python 233 | from pytest_netconf import NetconfServer 234 | from ncclient import manager 235 | 236 | 237 | def test_netconf_key_auth( 238 | netconf_server: NetconfServer, 239 | ): 240 | # GIVEN SSH username and authorized key 241 | netconf_server.username = "admin" 242 | netconf_server.authorized_key = "ssh-rsa AAAAB3NzaC1yc..." 243 | 244 | # WHEN connecting using key credentials 245 | with manager.connect( 246 | host="localhost", 247 | port=8830, 248 | username="admin", 249 | key_filename=key_filepath, 250 | hostkey_verify=False, 251 | ) as m: 252 | # THEN expect to be connected 253 | assert m.connected 254 | ``` 255 |
256 | 257 | 258 | ## Versioning 259 | 260 | Releases will follow semantic versioning (major.minor.patch). Before 1.0.0 breaking changes can be included in a minor release, therefore we highly recommend pinning this package. 261 | 262 | ## Contributing 263 | 264 | Suggest a [feature]() or report a [bug](). Read our developer [guide](CONTRIBUTING.md). 265 | 266 | ## License 267 | 268 | pytest-netconf is distributed under the Apache 2.0 [license](LICENSE). 269 | -------------------------------------------------------------------------------- /poetry.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Poetry 1.6.1 and should not be changed by hand. 2 | 3 | [[package]] 4 | name = "bcrypt" 5 | version = "4.2.1" 6 | description = "Modern password hashing for your software and your servers" 7 | optional = false 8 | python-versions = ">=3.7" 9 | files = [ 10 | {file = "bcrypt-4.2.1-cp37-abi3-macosx_10_12_universal2.whl", hash = "sha256:1340411a0894b7d3ef562fb233e4b6ed58add185228650942bdc885362f32c17"}, 11 | {file = "bcrypt-4.2.1-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b1ee315739bc8387aa36ff127afc99120ee452924e0df517a8f3e4c0187a0f5f"}, 12 | {file = "bcrypt-4.2.1-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8dbd0747208912b1e4ce730c6725cb56c07ac734b3629b60d4398f082ea718ad"}, 13 | {file = "bcrypt-4.2.1-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:aaa2e285be097050dba798d537b6efd9b698aa88eef52ec98d23dcd6d7cf6fea"}, 14 | {file = "bcrypt-4.2.1-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:76d3e352b32f4eeb34703370e370997065d28a561e4a18afe4fef07249cb4396"}, 15 | {file = "bcrypt-4.2.1-cp37-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:b7703ede632dc945ed1172d6f24e9f30f27b1b1a067f32f68bf169c5f08d0425"}, 16 | {file = "bcrypt-4.2.1-cp37-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:89df2aea2c43be1e1fa066df5f86c8ce822ab70a30e4c210968669565c0f4685"}, 17 | {file = "bcrypt-4.2.1-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:04e56e3fe8308a88b77e0afd20bec516f74aecf391cdd6e374f15cbed32783d6"}, 18 | {file = "bcrypt-4.2.1-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:cfdf3d7530c790432046c40cda41dfee8c83e29482e6a604f8930b9930e94139"}, 19 | {file = "bcrypt-4.2.1-cp37-abi3-win32.whl", hash = "sha256:adadd36274510a01f33e6dc08f5824b97c9580583bd4487c564fc4617b328005"}, 20 | {file = "bcrypt-4.2.1-cp37-abi3-win_amd64.whl", hash = "sha256:8c458cd103e6c5d1d85cf600e546a639f234964d0228909d8f8dbeebff82d526"}, 21 | {file = "bcrypt-4.2.1-cp39-abi3-macosx_10_12_universal2.whl", hash = "sha256:8ad2f4528cbf0febe80e5a3a57d7a74e6635e41af1ea5675282a33d769fba413"}, 22 | {file = "bcrypt-4.2.1-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:909faa1027900f2252a9ca5dfebd25fc0ef1417943824783d1c8418dd7d6df4a"}, 23 | {file = "bcrypt-4.2.1-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cde78d385d5e93ece5479a0a87f73cd6fa26b171c786a884f955e165032b262c"}, 24 | {file = "bcrypt-4.2.1-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:533e7f3bcf2f07caee7ad98124fab7499cb3333ba2274f7a36cf1daee7409d99"}, 25 | {file = "bcrypt-4.2.1-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:687cf30e6681eeda39548a93ce9bfbb300e48b4d445a43db4298d2474d2a1e54"}, 26 | {file = "bcrypt-4.2.1-cp39-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:041fa0155c9004eb98a232d54da05c0b41d4b8e66b6fc3cb71b4b3f6144ba837"}, 27 | {file = "bcrypt-4.2.1-cp39-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:f85b1ffa09240c89aa2e1ae9f3b1c687104f7b2b9d2098da4e923f1b7082d331"}, 28 | {file = "bcrypt-4.2.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c6f5fa3775966cca251848d4d5393ab016b3afed251163c1436fefdec3b02c84"}, 29 | {file = "bcrypt-4.2.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:807261df60a8b1ccd13e6599c779014a362ae4e795f5c59747f60208daddd96d"}, 30 | {file = "bcrypt-4.2.1-cp39-abi3-win32.whl", hash = "sha256:b588af02b89d9fad33e5f98f7838bf590d6d692df7153647724a7f20c186f6bf"}, 31 | {file = "bcrypt-4.2.1-cp39-abi3-win_amd64.whl", hash = "sha256:e84e0e6f8e40a242b11bce56c313edc2be121cec3e0ec2d76fce01f6af33c07c"}, 32 | {file = "bcrypt-4.2.1-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:76132c176a6d9953cdc83c296aeaed65e1a708485fd55abf163e0d9f8f16ce0e"}, 33 | {file = "bcrypt-4.2.1-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:e158009a54c4c8bc91d5e0da80920d048f918c61a581f0a63e4e93bb556d362f"}, 34 | {file = "bcrypt-4.2.1.tar.gz", hash = "sha256:6765386e3ab87f569b276988742039baab087b2cdb01e809d74e74503c2faafe"}, 35 | ] 36 | 37 | [package.extras] 38 | tests = ["pytest (>=3.2.1,!=3.3.0)"] 39 | typecheck = ["mypy"] 40 | 41 | [[package]] 42 | name = "cffi" 43 | version = "1.17.1" 44 | description = "Foreign Function Interface for Python calling C code." 45 | optional = false 46 | python-versions = ">=3.8" 47 | files = [ 48 | {file = "cffi-1.17.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:df8b1c11f177bc2313ec4b2d46baec87a5f3e71fc8b45dab2ee7cae86d9aba14"}, 49 | {file = "cffi-1.17.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8f2cdc858323644ab277e9bb925ad72ae0e67f69e804f4898c070998d50b1a67"}, 50 | {file = "cffi-1.17.1-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:edae79245293e15384b51f88b00613ba9f7198016a5948b5dddf4917d4d26382"}, 51 | {file = "cffi-1.17.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:45398b671ac6d70e67da8e4224a065cec6a93541bb7aebe1b198a61b58c7b702"}, 52 | {file = "cffi-1.17.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ad9413ccdeda48c5afdae7e4fa2192157e991ff761e7ab8fdd8926f40b160cc3"}, 53 | {file = "cffi-1.17.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5da5719280082ac6bd9aa7becb3938dc9f9cbd57fac7d2871717b1feb0902ab6"}, 54 | {file = "cffi-1.17.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bb1a08b8008b281856e5971307cc386a8e9c5b625ac297e853d36da6efe9c17"}, 55 | {file = "cffi-1.17.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:045d61c734659cc045141be4bae381a41d89b741f795af1dd018bfb532fd0df8"}, 56 | {file = "cffi-1.17.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:6883e737d7d9e4899a8a695e00ec36bd4e5e4f18fabe0aca0efe0a4b44cdb13e"}, 57 | {file = "cffi-1.17.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:6b8b4a92e1c65048ff98cfe1f735ef8f1ceb72e3d5f0c25fdb12087a23da22be"}, 58 | {file = "cffi-1.17.1-cp310-cp310-win32.whl", hash = "sha256:c9c3d058ebabb74db66e431095118094d06abf53284d9c81f27300d0e0d8bc7c"}, 59 | {file = "cffi-1.17.1-cp310-cp310-win_amd64.whl", hash = "sha256:0f048dcf80db46f0098ccac01132761580d28e28bc0f78ae0d58048063317e15"}, 60 | {file = "cffi-1.17.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a45e3c6913c5b87b3ff120dcdc03f6131fa0065027d0ed7ee6190736a74cd401"}, 61 | {file = "cffi-1.17.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:30c5e0cb5ae493c04c8b42916e52ca38079f1b235c2f8ae5f4527b963c401caf"}, 62 | {file = "cffi-1.17.1-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f75c7ab1f9e4aca5414ed4d8e5c0e303a34f4421f8a0d47a4d019ceff0ab6af4"}, 63 | {file = "cffi-1.17.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a1ed2dd2972641495a3ec98445e09766f077aee98a1c896dcb4ad0d303628e41"}, 64 | {file = "cffi-1.17.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:46bf43160c1a35f7ec506d254e5c890f3c03648a4dbac12d624e4490a7046cd1"}, 65 | {file = "cffi-1.17.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a24ed04c8ffd54b0729c07cee15a81d964e6fee0e3d4d342a27b020d22959dc6"}, 66 | {file = "cffi-1.17.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:610faea79c43e44c71e1ec53a554553fa22321b65fae24889706c0a84d4ad86d"}, 67 | {file = "cffi-1.17.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:a9b15d491f3ad5d692e11f6b71f7857e7835eb677955c00cc0aefcd0669adaf6"}, 68 | {file = "cffi-1.17.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:de2ea4b5833625383e464549fec1bc395c1bdeeb5f25c4a3a82b5a8c756ec22f"}, 69 | {file = "cffi-1.17.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:fc48c783f9c87e60831201f2cce7f3b2e4846bf4d8728eabe54d60700b318a0b"}, 70 | {file = "cffi-1.17.1-cp311-cp311-win32.whl", hash = "sha256:85a950a4ac9c359340d5963966e3e0a94a676bd6245a4b55bc43949eee26a655"}, 71 | {file = "cffi-1.17.1-cp311-cp311-win_amd64.whl", hash = "sha256:caaf0640ef5f5517f49bc275eca1406b0ffa6aa184892812030f04c2abf589a0"}, 72 | {file = "cffi-1.17.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:805b4371bf7197c329fcb3ead37e710d1bca9da5d583f5073b799d5c5bd1eee4"}, 73 | {file = "cffi-1.17.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:733e99bc2df47476e3848417c5a4540522f234dfd4ef3ab7fafdf555b082ec0c"}, 74 | {file = "cffi-1.17.1-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1257bdabf294dceb59f5e70c64a3e2f462c30c7ad68092d01bbbfb1c16b1ba36"}, 75 | {file = "cffi-1.17.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:da95af8214998d77a98cc14e3a3bd00aa191526343078b530ceb0bd710fb48a5"}, 76 | {file = "cffi-1.17.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d63afe322132c194cf832bfec0dc69a99fb9bb6bbd550f161a49e9e855cc78ff"}, 77 | {file = "cffi-1.17.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f79fc4fc25f1c8698ff97788206bb3c2598949bfe0fef03d299eb1b5356ada99"}, 78 | {file = "cffi-1.17.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b62ce867176a75d03a665bad002af8e6d54644fad99a3c70905c543130e39d93"}, 79 | {file = "cffi-1.17.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:386c8bf53c502fff58903061338ce4f4950cbdcb23e2902d86c0f722b786bbe3"}, 80 | {file = "cffi-1.17.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:4ceb10419a9adf4460ea14cfd6bc43d08701f0835e979bf821052f1805850fe8"}, 81 | {file = "cffi-1.17.1-cp312-cp312-win32.whl", hash = "sha256:a08d7e755f8ed21095a310a693525137cfe756ce62d066e53f502a83dc550f65"}, 82 | {file = "cffi-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:51392eae71afec0d0c8fb1a53b204dbb3bcabcb3c9b807eedf3e1e6ccf2de903"}, 83 | {file = "cffi-1.17.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f3a2b4222ce6b60e2e8b337bb9596923045681d71e5a082783484d845390938e"}, 84 | {file = "cffi-1.17.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0984a4925a435b1da406122d4d7968dd861c1385afe3b45ba82b750f229811e2"}, 85 | {file = "cffi-1.17.1-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d01b12eeeb4427d3110de311e1774046ad344f5b1a7403101878976ecd7a10f3"}, 86 | {file = "cffi-1.17.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:706510fe141c86a69c8ddc029c7910003a17353970cff3b904ff0686a5927683"}, 87 | {file = "cffi-1.17.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:de55b766c7aa2e2a3092c51e0483d700341182f08e67c63630d5b6f200bb28e5"}, 88 | {file = "cffi-1.17.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c59d6e989d07460165cc5ad3c61f9fd8f1b4796eacbd81cee78957842b834af4"}, 89 | {file = "cffi-1.17.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd398dbc6773384a17fe0d3e7eeb8d1a21c2200473ee6806bb5e6a8e62bb73dd"}, 90 | {file = "cffi-1.17.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:3edc8d958eb099c634dace3c7e16560ae474aa3803a5df240542b305d14e14ed"}, 91 | {file = "cffi-1.17.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:72e72408cad3d5419375fc87d289076ee319835bdfa2caad331e377589aebba9"}, 92 | {file = "cffi-1.17.1-cp313-cp313-win32.whl", hash = "sha256:e03eab0a8677fa80d646b5ddece1cbeaf556c313dcfac435ba11f107ba117b5d"}, 93 | {file = "cffi-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:f6a16c31041f09ead72d69f583767292f750d24913dadacf5756b966aacb3f1a"}, 94 | {file = "cffi-1.17.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:636062ea65bd0195bc012fea9321aca499c0504409f413dc88af450b57ffd03b"}, 95 | {file = "cffi-1.17.1-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c7eac2ef9b63c79431bc4b25f1cd649d7f061a28808cbc6c47b534bd789ef964"}, 96 | {file = "cffi-1.17.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e221cf152cff04059d011ee126477f0d9588303eb57e88923578ace7baad17f9"}, 97 | {file = "cffi-1.17.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:31000ec67d4221a71bd3f67df918b1f88f676f1c3b535a7eb473255fdc0b83fc"}, 98 | {file = "cffi-1.17.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6f17be4345073b0a7b8ea599688f692ac3ef23ce28e5df79c04de519dbc4912c"}, 99 | {file = "cffi-1.17.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0e2b1fac190ae3ebfe37b979cc1ce69c81f4e4fe5746bb401dca63a9062cdaf1"}, 100 | {file = "cffi-1.17.1-cp38-cp38-win32.whl", hash = "sha256:7596d6620d3fa590f677e9ee430df2958d2d6d6de2feeae5b20e82c00b76fbf8"}, 101 | {file = "cffi-1.17.1-cp38-cp38-win_amd64.whl", hash = "sha256:78122be759c3f8a014ce010908ae03364d00a1f81ab5c7f4a7a5120607ea56e1"}, 102 | {file = "cffi-1.17.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:b2ab587605f4ba0bf81dc0cb08a41bd1c0a5906bd59243d56bad7668a6fc6c16"}, 103 | {file = "cffi-1.17.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:28b16024becceed8c6dfbc75629e27788d8a3f9030691a1dbf9821a128b22c36"}, 104 | {file = "cffi-1.17.1-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1d599671f396c4723d016dbddb72fe8e0397082b0a77a4fab8028923bec050e8"}, 105 | {file = "cffi-1.17.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca74b8dbe6e8e8263c0ffd60277de77dcee6c837a3d0881d8c1ead7268c9e576"}, 106 | {file = "cffi-1.17.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f7f5baafcc48261359e14bcd6d9bff6d4b28d9103847c9e136694cb0501aef87"}, 107 | {file = "cffi-1.17.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:98e3969bcff97cae1b2def8ba499ea3d6f31ddfdb7635374834cf89a1a08ecf0"}, 108 | {file = "cffi-1.17.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cdf5ce3acdfd1661132f2a9c19cac174758dc2352bfe37d98aa7512c6b7178b3"}, 109 | {file = "cffi-1.17.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:9755e4345d1ec879e3849e62222a18c7174d65a6a92d5b346b1863912168b595"}, 110 | {file = "cffi-1.17.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:f1e22e8c4419538cb197e4dd60acc919d7696e5ef98ee4da4e01d3f8cfa4cc5a"}, 111 | {file = "cffi-1.17.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:c03e868a0b3bc35839ba98e74211ed2b05d2119be4e8a0f224fba9384f1fe02e"}, 112 | {file = "cffi-1.17.1-cp39-cp39-win32.whl", hash = "sha256:e31ae45bc2e29f6b2abd0de1cc3b9d5205aa847cafaecb8af1476a609a2f6eb7"}, 113 | {file = "cffi-1.17.1-cp39-cp39-win_amd64.whl", hash = "sha256:d016c76bdd850f3c626af19b0542c9677ba156e4ee4fccfdd7848803533ef662"}, 114 | {file = "cffi-1.17.1.tar.gz", hash = "sha256:1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824"}, 115 | ] 116 | 117 | [package.dependencies] 118 | pycparser = "*" 119 | 120 | [[package]] 121 | name = "colorama" 122 | version = "0.4.6" 123 | description = "Cross-platform colored terminal text." 124 | optional = false 125 | python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" 126 | files = [ 127 | {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, 128 | {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, 129 | ] 130 | 131 | [[package]] 132 | name = "coverage" 133 | version = "7.6.1" 134 | description = "Code coverage measurement for Python" 135 | optional = false 136 | python-versions = ">=3.8" 137 | files = [ 138 | {file = "coverage-7.6.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b06079abebbc0e89e6163b8e8f0e16270124c154dc6e4a47b413dd538859af16"}, 139 | {file = "coverage-7.6.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:cf4b19715bccd7ee27b6b120e7e9dd56037b9c0681dcc1adc9ba9db3d417fa36"}, 140 | {file = "coverage-7.6.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e61c0abb4c85b095a784ef23fdd4aede7a2628478e7baba7c5e3deba61070a02"}, 141 | {file = "coverage-7.6.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fd21f6ae3f08b41004dfb433fa895d858f3f5979e7762d052b12aef444e29afc"}, 142 | {file = "coverage-7.6.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f59d57baca39b32db42b83b2a7ba6f47ad9c394ec2076b084c3f029b7afca23"}, 143 | {file = "coverage-7.6.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a1ac0ae2b8bd743b88ed0502544847c3053d7171a3cff9228af618a068ed9c34"}, 144 | {file = "coverage-7.6.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e6a08c0be454c3b3beb105c0596ebdc2371fab6bb90c0c0297f4e58fd7e1012c"}, 145 | {file = "coverage-7.6.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f5796e664fe802da4f57a168c85359a8fbf3eab5e55cd4e4569fbacecc903959"}, 146 | {file = "coverage-7.6.1-cp310-cp310-win32.whl", hash = "sha256:7bb65125fcbef8d989fa1dd0e8a060999497629ca5b0efbca209588a73356232"}, 147 | {file = "coverage-7.6.1-cp310-cp310-win_amd64.whl", hash = "sha256:3115a95daa9bdba70aea750db7b96b37259a81a709223c8448fa97727d546fe0"}, 148 | {file = "coverage-7.6.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:7dea0889685db8550f839fa202744652e87c60015029ce3f60e006f8c4462c93"}, 149 | {file = "coverage-7.6.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ed37bd3c3b063412f7620464a9ac1314d33100329f39799255fb8d3027da50d3"}, 150 | {file = "coverage-7.6.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d85f5e9a5f8b73e2350097c3756ef7e785f55bd71205defa0bfdaf96c31616ff"}, 151 | {file = "coverage-7.6.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9bc572be474cafb617672c43fe989d6e48d3c83af02ce8de73fff1c6bb3c198d"}, 152 | {file = "coverage-7.6.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c0420b573964c760df9e9e86d1a9a622d0d27f417e1a949a8a66dd7bcee7bc6"}, 153 | {file = "coverage-7.6.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1f4aa8219db826ce6be7099d559f8ec311549bfc4046f7f9fe9b5cea5c581c56"}, 154 | {file = "coverage-7.6.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:fc5a77d0c516700ebad189b587de289a20a78324bc54baee03dd486f0855d234"}, 155 | {file = "coverage-7.6.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b48f312cca9621272ae49008c7f613337c53fadca647d6384cc129d2996d1133"}, 156 | {file = "coverage-7.6.1-cp311-cp311-win32.whl", hash = "sha256:1125ca0e5fd475cbbba3bb67ae20bd2c23a98fac4e32412883f9bcbaa81c314c"}, 157 | {file = "coverage-7.6.1-cp311-cp311-win_amd64.whl", hash = "sha256:8ae539519c4c040c5ffd0632784e21b2f03fc1340752af711f33e5be83a9d6c6"}, 158 | {file = "coverage-7.6.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:95cae0efeb032af8458fc27d191f85d1717b1d4e49f7cb226cf526ff28179778"}, 159 | {file = "coverage-7.6.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5621a9175cf9d0b0c84c2ef2b12e9f5f5071357c4d2ea6ca1cf01814f45d2391"}, 160 | {file = "coverage-7.6.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:260933720fdcd75340e7dbe9060655aff3af1f0c5d20f46b57f262ab6c86a5e8"}, 161 | {file = "coverage-7.6.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:07e2ca0ad381b91350c0ed49d52699b625aab2b44b65e1b4e02fa9df0e92ad2d"}, 162 | {file = "coverage-7.6.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c44fee9975f04b33331cb8eb272827111efc8930cfd582e0320613263ca849ca"}, 163 | {file = "coverage-7.6.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:877abb17e6339d96bf08e7a622d05095e72b71f8afd8a9fefc82cf30ed944163"}, 164 | {file = "coverage-7.6.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:3e0cadcf6733c09154b461f1ca72d5416635e5e4ec4e536192180d34ec160f8a"}, 165 | {file = "coverage-7.6.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c3c02d12f837d9683e5ab2f3d9844dc57655b92c74e286c262e0fc54213c216d"}, 166 | {file = "coverage-7.6.1-cp312-cp312-win32.whl", hash = "sha256:e05882b70b87a18d937ca6768ff33cc3f72847cbc4de4491c8e73880766718e5"}, 167 | {file = "coverage-7.6.1-cp312-cp312-win_amd64.whl", hash = "sha256:b5d7b556859dd85f3a541db6a4e0167b86e7273e1cdc973e5b175166bb634fdb"}, 168 | {file = "coverage-7.6.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a4acd025ecc06185ba2b801f2de85546e0b8ac787cf9d3b06e7e2a69f925b106"}, 169 | {file = "coverage-7.6.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a6d3adcf24b624a7b778533480e32434a39ad8fa30c315208f6d3e5542aeb6e9"}, 170 | {file = "coverage-7.6.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d0c212c49b6c10e6951362f7c6df3329f04c2b1c28499563d4035d964ab8e08c"}, 171 | {file = "coverage-7.6.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6e81d7a3e58882450ec4186ca59a3f20a5d4440f25b1cff6f0902ad890e6748a"}, 172 | {file = "coverage-7.6.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:78b260de9790fd81e69401c2dc8b17da47c8038176a79092a89cb2b7d945d060"}, 173 | {file = "coverage-7.6.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a78d169acd38300060b28d600344a803628c3fd585c912cacc9ea8790fe96862"}, 174 | {file = "coverage-7.6.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2c09f4ce52cb99dd7505cd0fc8e0e37c77b87f46bc9c1eb03fe3bc9991085388"}, 175 | {file = "coverage-7.6.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6878ef48d4227aace338d88c48738a4258213cd7b74fd9a3d4d7582bb1d8a155"}, 176 | {file = "coverage-7.6.1-cp313-cp313-win32.whl", hash = "sha256:44df346d5215a8c0e360307d46ffaabe0f5d3502c8a1cefd700b34baf31d411a"}, 177 | {file = "coverage-7.6.1-cp313-cp313-win_amd64.whl", hash = "sha256:8284cf8c0dd272a247bc154eb6c95548722dce90d098c17a883ed36e67cdb129"}, 178 | {file = "coverage-7.6.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:d3296782ca4eab572a1a4eca686d8bfb00226300dcefdf43faa25b5242ab8a3e"}, 179 | {file = "coverage-7.6.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:502753043567491d3ff6d08629270127e0c31d4184c4c8d98f92c26f65019962"}, 180 | {file = "coverage-7.6.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6a89ecca80709d4076b95f89f308544ec8f7b4727e8a547913a35f16717856cb"}, 181 | {file = "coverage-7.6.1-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a318d68e92e80af8b00fa99609796fdbcdfef3629c77c6283566c6f02c6d6704"}, 182 | {file = "coverage-7.6.1-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:13b0a73a0896988f053e4fbb7de6d93388e6dd292b0d87ee51d106f2c11b465b"}, 183 | {file = "coverage-7.6.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:4421712dbfc5562150f7554f13dde997a2e932a6b5f352edcce948a815efee6f"}, 184 | {file = "coverage-7.6.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:166811d20dfea725e2e4baa71fffd6c968a958577848d2131f39b60043400223"}, 185 | {file = "coverage-7.6.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:225667980479a17db1048cb2bf8bfb39b8e5be8f164b8f6628b64f78a72cf9d3"}, 186 | {file = "coverage-7.6.1-cp313-cp313t-win32.whl", hash = "sha256:170d444ab405852903b7d04ea9ae9b98f98ab6d7e63e1115e82620807519797f"}, 187 | {file = "coverage-7.6.1-cp313-cp313t-win_amd64.whl", hash = "sha256:b9f222de8cded79c49bf184bdbc06630d4c58eec9459b939b4a690c82ed05657"}, 188 | {file = "coverage-7.6.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:6db04803b6c7291985a761004e9060b2bca08da6d04f26a7f2294b8623a0c1a0"}, 189 | {file = "coverage-7.6.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:f1adfc8ac319e1a348af294106bc6a8458a0f1633cc62a1446aebc30c5fa186a"}, 190 | {file = "coverage-7.6.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a95324a9de9650a729239daea117df21f4b9868ce32e63f8b650ebe6cef5595b"}, 191 | {file = "coverage-7.6.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b43c03669dc4618ec25270b06ecd3ee4fa94c7f9b3c14bae6571ca00ef98b0d3"}, 192 | {file = "coverage-7.6.1-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8929543a7192c13d177b770008bc4e8119f2e1f881d563fc6b6305d2d0ebe9de"}, 193 | {file = "coverage-7.6.1-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:a09ece4a69cf399510c8ab25e0950d9cf2b42f7b3cb0374f95d2e2ff594478a6"}, 194 | {file = "coverage-7.6.1-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:9054a0754de38d9dbd01a46621636689124d666bad1936d76c0341f7d71bf569"}, 195 | {file = "coverage-7.6.1-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:0dbde0f4aa9a16fa4d754356a8f2e36296ff4d83994b2c9d8398aa32f222f989"}, 196 | {file = "coverage-7.6.1-cp38-cp38-win32.whl", hash = "sha256:da511e6ad4f7323ee5702e6633085fb76c2f893aaf8ce4c51a0ba4fc07580ea7"}, 197 | {file = "coverage-7.6.1-cp38-cp38-win_amd64.whl", hash = "sha256:3f1156e3e8f2872197af3840d8ad307a9dd18e615dc64d9ee41696f287c57ad8"}, 198 | {file = "coverage-7.6.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:abd5fd0db5f4dc9289408aaf34908072f805ff7792632250dcb36dc591d24255"}, 199 | {file = "coverage-7.6.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:547f45fa1a93154bd82050a7f3cddbc1a7a4dd2a9bf5cb7d06f4ae29fe94eaf8"}, 200 | {file = "coverage-7.6.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:645786266c8f18a931b65bfcefdbf6952dd0dea98feee39bd188607a9d307ed2"}, 201 | {file = "coverage-7.6.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9e0b2df163b8ed01d515807af24f63de04bebcecbd6c3bfeff88385789fdf75a"}, 202 | {file = "coverage-7.6.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:609b06f178fe8e9f89ef676532760ec0b4deea15e9969bf754b37f7c40326dbc"}, 203 | {file = "coverage-7.6.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:702855feff378050ae4f741045e19a32d57d19f3e0676d589df0575008ea5004"}, 204 | {file = "coverage-7.6.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:2bdb062ea438f22d99cba0d7829c2ef0af1d768d1e4a4f528087224c90b132cb"}, 205 | {file = "coverage-7.6.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:9c56863d44bd1c4fe2abb8a4d6f5371d197f1ac0ebdee542f07f35895fc07f36"}, 206 | {file = "coverage-7.6.1-cp39-cp39-win32.whl", hash = "sha256:6e2cd258d7d927d09493c8df1ce9174ad01b381d4729a9d8d4e38670ca24774c"}, 207 | {file = "coverage-7.6.1-cp39-cp39-win_amd64.whl", hash = "sha256:06a737c882bd26d0d6ee7269b20b12f14a8704807a01056c80bb881a4b2ce6ca"}, 208 | {file = "coverage-7.6.1-pp38.pp39.pp310-none-any.whl", hash = "sha256:e9a6e0eb86070e8ccaedfbd9d38fec54864f3125ab95419970575b42af7541df"}, 209 | {file = "coverage-7.6.1.tar.gz", hash = "sha256:953510dfb7b12ab69d20135a0662397f077c59b1e6379a768e97c59d852ee51d"}, 210 | ] 211 | 212 | [package.extras] 213 | toml = ["tomli"] 214 | 215 | [[package]] 216 | name = "cryptography" 217 | version = "43.0.3" 218 | description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." 219 | optional = false 220 | python-versions = ">=3.7" 221 | files = [ 222 | {file = "cryptography-43.0.3-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:bf7a1932ac4176486eab36a19ed4c0492da5d97123f1406cf15e41b05e787d2e"}, 223 | {file = "cryptography-43.0.3-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:63efa177ff54aec6e1c0aefaa1a241232dcd37413835a9b674b6e3f0ae2bfd3e"}, 224 | {file = "cryptography-43.0.3-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7e1ce50266f4f70bf41a2c6dc4358afadae90e2a1e5342d3c08883df1675374f"}, 225 | {file = "cryptography-43.0.3-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:443c4a81bb10daed9a8f334365fe52542771f25aedaf889fd323a853ce7377d6"}, 226 | {file = "cryptography-43.0.3-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:74f57f24754fe349223792466a709f8e0c093205ff0dca557af51072ff47ab18"}, 227 | {file = "cryptography-43.0.3-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9762ea51a8fc2a88b70cf2995e5675b38d93bf36bd67d91721c309df184f49bd"}, 228 | {file = "cryptography-43.0.3-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:81ef806b1fef6b06dcebad789f988d3b37ccaee225695cf3e07648eee0fc6b73"}, 229 | {file = "cryptography-43.0.3-cp37-abi3-win32.whl", hash = "sha256:cbeb489927bd7af4aa98d4b261af9a5bc025bd87f0e3547e11584be9e9427be2"}, 230 | {file = "cryptography-43.0.3-cp37-abi3-win_amd64.whl", hash = "sha256:f46304d6f0c6ab8e52770addfa2fc41e6629495548862279641972b6215451cd"}, 231 | {file = "cryptography-43.0.3-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:8ac43ae87929a5982f5948ceda07001ee5e83227fd69cf55b109144938d96984"}, 232 | {file = "cryptography-43.0.3-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:846da004a5804145a5f441b8530b4bf35afbf7da70f82409f151695b127213d5"}, 233 | {file = "cryptography-43.0.3-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f996e7268af62598f2fc1204afa98a3b5712313a55c4c9d434aef49cadc91d4"}, 234 | {file = "cryptography-43.0.3-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:f7b178f11ed3664fd0e995a47ed2b5ff0a12d893e41dd0494f406d1cf555cab7"}, 235 | {file = "cryptography-43.0.3-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:c2e6fc39c4ab499049df3bdf567f768a723a5e8464816e8f009f121a5a9f4405"}, 236 | {file = "cryptography-43.0.3-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:e1be4655c7ef6e1bbe6b5d0403526601323420bcf414598955968c9ef3eb7d16"}, 237 | {file = "cryptography-43.0.3-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:df6b6c6d742395dd77a23ea3728ab62f98379eff8fb61be2744d4679ab678f73"}, 238 | {file = "cryptography-43.0.3-cp39-abi3-win32.whl", hash = "sha256:d56e96520b1020449bbace2b78b603442e7e378a9b3bd68de65c782db1507995"}, 239 | {file = "cryptography-43.0.3-cp39-abi3-win_amd64.whl", hash = "sha256:0c580952eef9bf68c4747774cde7ec1d85a6e61de97281f2dba83c7d2c806362"}, 240 | {file = "cryptography-43.0.3-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:d03b5621a135bffecad2c73e9f4deb1a0f977b9a8ffe6f8e002bf6c9d07b918c"}, 241 | {file = "cryptography-43.0.3-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:a2a431ee15799d6db9fe80c82b055bae5a752bef645bba795e8e52687c69efe3"}, 242 | {file = "cryptography-43.0.3-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:281c945d0e28c92ca5e5930664c1cefd85efe80e5c0d2bc58dd63383fda29f83"}, 243 | {file = "cryptography-43.0.3-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:f18c716be16bc1fea8e95def49edf46b82fccaa88587a45f8dc0ff6ab5d8e0a7"}, 244 | {file = "cryptography-43.0.3-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:4a02ded6cd4f0a5562a8887df8b3bd14e822a90f97ac5e544c162899bc467664"}, 245 | {file = "cryptography-43.0.3-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:53a583b6637ab4c4e3591a15bc9db855b8d9dee9a669b550f311480acab6eb08"}, 246 | {file = "cryptography-43.0.3-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:1ec0bcf7e17c0c5669d881b1cd38c4972fade441b27bda1051665faaa89bdcaa"}, 247 | {file = "cryptography-43.0.3-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:2ce6fae5bdad59577b44e4dfed356944fbf1d925269114c28be377692643b4ff"}, 248 | {file = "cryptography-43.0.3.tar.gz", hash = "sha256:315b9001266a492a6ff443b61238f956b214dbec9910a081ba5b6646a055a805"}, 249 | ] 250 | 251 | [package.dependencies] 252 | cffi = {version = ">=1.12", markers = "platform_python_implementation != \"PyPy\""} 253 | 254 | [package.extras] 255 | docs = ["sphinx (>=5.3.0)", "sphinx-rtd-theme (>=1.1.1)"] 256 | docstest = ["pyenchant (>=1.6.11)", "readme-renderer", "sphinxcontrib-spelling (>=4.0.1)"] 257 | nox = ["nox"] 258 | pep8test = ["check-sdist", "click", "mypy", "ruff"] 259 | sdist = ["build"] 260 | ssh = ["bcrypt (>=3.1.5)"] 261 | test = ["certifi", "cryptography-vectors (==43.0.3)", "pretend", "pytest (>=6.2.0)", "pytest-benchmark", "pytest-cov", "pytest-xdist"] 262 | test-randomorder = ["pytest-randomly"] 263 | 264 | [[package]] 265 | name = "exceptiongroup" 266 | version = "1.2.2" 267 | description = "Backport of PEP 654 (exception groups)" 268 | optional = false 269 | python-versions = ">=3.7" 270 | files = [ 271 | {file = "exceptiongroup-1.2.2-py3-none-any.whl", hash = "sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b"}, 272 | {file = "exceptiongroup-1.2.2.tar.gz", hash = "sha256:47c2edf7c6738fafb49fd34290706d1a1a2f4d1c6df275526b62cbb4aa5393cc"}, 273 | ] 274 | 275 | [package.extras] 276 | test = ["pytest (>=6)"] 277 | 278 | [[package]] 279 | name = "iniconfig" 280 | version = "2.0.0" 281 | description = "brain-dead simple config-ini parsing" 282 | optional = false 283 | python-versions = ">=3.7" 284 | files = [ 285 | {file = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"}, 286 | {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"}, 287 | ] 288 | 289 | [[package]] 290 | name = "lxml" 291 | version = "4.9.4" 292 | description = "Powerful and Pythonic XML processing library combining libxml2/libxslt with the ElementTree API." 293 | optional = false 294 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, != 3.4.*" 295 | files = [ 296 | {file = "lxml-4.9.4-cp27-cp27m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e214025e23db238805a600f1f37bf9f9a15413c7bf5f9d6ae194f84980c78722"}, 297 | {file = "lxml-4.9.4-cp27-cp27m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:ec53a09aee61d45e7dbe7e91252ff0491b6b5fee3d85b2d45b173d8ab453efc1"}, 298 | {file = "lxml-4.9.4-cp27-cp27m-win32.whl", hash = "sha256:7d1d6c9e74c70ddf524e3c09d9dc0522aba9370708c2cb58680ea40174800013"}, 299 | {file = "lxml-4.9.4-cp27-cp27m-win_amd64.whl", hash = "sha256:cb53669442895763e61df5c995f0e8361b61662f26c1b04ee82899c2789c8f69"}, 300 | {file = "lxml-4.9.4-cp27-cp27mu-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:647bfe88b1997d7ae8d45dabc7c868d8cb0c8412a6e730a7651050b8c7289cf2"}, 301 | {file = "lxml-4.9.4-cp27-cp27mu-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:4d973729ce04784906a19108054e1fd476bc85279a403ea1a72fdb051c76fa48"}, 302 | {file = "lxml-4.9.4-cp310-cp310-macosx_11_0_x86_64.whl", hash = "sha256:056a17eaaf3da87a05523472ae84246f87ac2f29a53306466c22e60282e54ff8"}, 303 | {file = "lxml-4.9.4-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:aaa5c173a26960fe67daa69aa93d6d6a1cd714a6eb13802d4e4bd1d24a530644"}, 304 | {file = "lxml-4.9.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:647459b23594f370c1c01768edaa0ba0959afc39caeeb793b43158bb9bb6a663"}, 305 | {file = "lxml-4.9.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:bdd9abccd0927673cffe601d2c6cdad1c9321bf3437a2f507d6b037ef91ea307"}, 306 | {file = "lxml-4.9.4-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:00e91573183ad273e242db5585b52670eddf92bacad095ce25c1e682da14ed91"}, 307 | {file = "lxml-4.9.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:a602ed9bd2c7d85bd58592c28e101bd9ff9c718fbde06545a70945ffd5d11868"}, 308 | {file = "lxml-4.9.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:de362ac8bc962408ad8fae28f3967ce1a262b5d63ab8cefb42662566737f1dc7"}, 309 | {file = "lxml-4.9.4-cp310-cp310-win32.whl", hash = "sha256:33714fcf5af4ff7e70a49731a7cc8fd9ce910b9ac194f66eaa18c3cc0a4c02be"}, 310 | {file = "lxml-4.9.4-cp310-cp310-win_amd64.whl", hash = "sha256:d3caa09e613ece43ac292fbed513a4bce170681a447d25ffcbc1b647d45a39c5"}, 311 | {file = "lxml-4.9.4-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:359a8b09d712df27849e0bcb62c6a3404e780b274b0b7e4c39a88826d1926c28"}, 312 | {file = "lxml-4.9.4-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:43498ea734ccdfb92e1886dfedaebeb81178a241d39a79d5351ba2b671bff2b2"}, 313 | {file = "lxml-4.9.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:4855161013dfb2b762e02b3f4d4a21cc7c6aec13c69e3bffbf5022b3e708dd97"}, 314 | {file = "lxml-4.9.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:c71b5b860c5215fdbaa56f715bc218e45a98477f816b46cfde4a84d25b13274e"}, 315 | {file = "lxml-4.9.4-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:9a2b5915c333e4364367140443b59f09feae42184459b913f0f41b9fed55794a"}, 316 | {file = "lxml-4.9.4-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:d82411dbf4d3127b6cde7da0f9373e37ad3a43e89ef374965465928f01c2b979"}, 317 | {file = "lxml-4.9.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:273473d34462ae6e97c0f4e517bd1bf9588aa67a1d47d93f760a1282640e24ac"}, 318 | {file = "lxml-4.9.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:389d2b2e543b27962990ab529ac6720c3dded588cc6d0f6557eec153305a3622"}, 319 | {file = "lxml-4.9.4-cp311-cp311-win32.whl", hash = "sha256:8aecb5a7f6f7f8fe9cac0bcadd39efaca8bbf8d1bf242e9f175cbe4c925116c3"}, 320 | {file = "lxml-4.9.4-cp311-cp311-win_amd64.whl", hash = "sha256:c7721a3ef41591341388bb2265395ce522aba52f969d33dacd822da8f018aff8"}, 321 | {file = "lxml-4.9.4-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:dbcb2dc07308453db428a95a4d03259bd8caea97d7f0776842299f2d00c72fc8"}, 322 | {file = "lxml-4.9.4-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:01bf1df1db327e748dcb152d17389cf6d0a8c5d533ef9bab781e9d5037619229"}, 323 | {file = "lxml-4.9.4-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:e8f9f93a23634cfafbad6e46ad7d09e0f4a25a2400e4a64b1b7b7c0fbaa06d9d"}, 324 | {file = "lxml-4.9.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:3f3f00a9061605725df1816f5713d10cd94636347ed651abdbc75828df302b20"}, 325 | {file = "lxml-4.9.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:953dd5481bd6252bd480d6ec431f61d7d87fdcbbb71b0d2bdcfc6ae00bb6fb10"}, 326 | {file = "lxml-4.9.4-cp312-cp312-win32.whl", hash = "sha256:266f655d1baff9c47b52f529b5f6bec33f66042f65f7c56adde3fcf2ed62ae8b"}, 327 | {file = "lxml-4.9.4-cp312-cp312-win_amd64.whl", hash = "sha256:f1faee2a831fe249e1bae9cbc68d3cd8a30f7e37851deee4d7962b17c410dd56"}, 328 | {file = "lxml-4.9.4-cp35-cp35m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:23d891e5bdc12e2e506e7d225d6aa929e0a0368c9916c1fddefab88166e98b20"}, 329 | {file = "lxml-4.9.4-cp35-cp35m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:e96a1788f24d03e8d61679f9881a883ecdf9c445a38f9ae3f3f193ab6c591c66"}, 330 | {file = "lxml-4.9.4-cp36-cp36m-macosx_11_0_x86_64.whl", hash = "sha256:5557461f83bb7cc718bc9ee1f7156d50e31747e5b38d79cf40f79ab1447afd2d"}, 331 | {file = "lxml-4.9.4-cp36-cp36m-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:fdb325b7fba1e2c40b9b1db407f85642e32404131c08480dd652110fc908561b"}, 332 | {file = "lxml-4.9.4-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3d74d4a3c4b8f7a1f676cedf8e84bcc57705a6d7925e6daef7a1e54ae543a197"}, 333 | {file = "lxml-4.9.4-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:ac7674d1638df129d9cb4503d20ffc3922bd463c865ef3cb412f2c926108e9a4"}, 334 | {file = "lxml-4.9.4-cp36-cp36m-manylinux_2_28_x86_64.whl", hash = "sha256:ddd92e18b783aeb86ad2132d84a4b795fc5ec612e3545c1b687e7747e66e2b53"}, 335 | {file = "lxml-4.9.4-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2bd9ac6e44f2db368ef8986f3989a4cad3de4cd55dbdda536e253000c801bcc7"}, 336 | {file = "lxml-4.9.4-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:bc354b1393dce46026ab13075f77b30e40b61b1a53e852e99d3cc5dd1af4bc85"}, 337 | {file = "lxml-4.9.4-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:f836f39678cb47c9541f04d8ed4545719dc31ad850bf1832d6b4171e30d65d23"}, 338 | {file = "lxml-4.9.4-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:9c131447768ed7bc05a02553d939e7f0e807e533441901dd504e217b76307745"}, 339 | {file = "lxml-4.9.4-cp36-cp36m-win32.whl", hash = "sha256:bafa65e3acae612a7799ada439bd202403414ebe23f52e5b17f6ffc2eb98c2be"}, 340 | {file = "lxml-4.9.4-cp36-cp36m-win_amd64.whl", hash = "sha256:6197c3f3c0b960ad033b9b7d611db11285bb461fc6b802c1dd50d04ad715c225"}, 341 | {file = "lxml-4.9.4-cp37-cp37m-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:7b378847a09d6bd46047f5f3599cdc64fcb4cc5a5a2dd0a2af610361fbe77b16"}, 342 | {file = "lxml-4.9.4-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:1343df4e2e6e51182aad12162b23b0a4b3fd77f17527a78c53f0f23573663545"}, 343 | {file = "lxml-4.9.4-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:6dbdacf5752fbd78ccdb434698230c4f0f95df7dd956d5f205b5ed6911a1367c"}, 344 | {file = "lxml-4.9.4-cp37-cp37m-manylinux_2_28_x86_64.whl", hash = "sha256:506becdf2ecaebaf7f7995f776394fcc8bd8a78022772de66677c84fb02dd33d"}, 345 | {file = "lxml-4.9.4-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ca8e44b5ba3edb682ea4e6185b49661fc22b230cf811b9c13963c9f982d1d964"}, 346 | {file = "lxml-4.9.4-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:9d9d5726474cbbef279fd709008f91a49c4f758bec9c062dfbba88eab00e3ff9"}, 347 | {file = "lxml-4.9.4-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:bbdd69e20fe2943b51e2841fc1e6a3c1de460d630f65bde12452d8c97209464d"}, 348 | {file = "lxml-4.9.4-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:8671622256a0859f5089cbe0ce4693c2af407bc053dcc99aadff7f5310b4aa02"}, 349 | {file = "lxml-4.9.4-cp37-cp37m-win32.whl", hash = "sha256:dd4fda67f5faaef4f9ee5383435048ee3e11ad996901225ad7615bc92245bc8e"}, 350 | {file = "lxml-4.9.4-cp37-cp37m-win_amd64.whl", hash = "sha256:6bee9c2e501d835f91460b2c904bc359f8433e96799f5c2ff20feebd9bb1e590"}, 351 | {file = "lxml-4.9.4-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:1f10f250430a4caf84115b1e0f23f3615566ca2369d1962f82bef40dd99cd81a"}, 352 | {file = "lxml-4.9.4-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:3b505f2bbff50d261176e67be24e8909e54b5d9d08b12d4946344066d66b3e43"}, 353 | {file = "lxml-4.9.4-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:1449f9451cd53e0fd0a7ec2ff5ede4686add13ac7a7bfa6988ff6d75cff3ebe2"}, 354 | {file = "lxml-4.9.4-cp38-cp38-manylinux_2_28_x86_64.whl", hash = "sha256:4ece9cca4cd1c8ba889bfa67eae7f21d0d1a2e715b4d5045395113361e8c533d"}, 355 | {file = "lxml-4.9.4-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:59bb5979f9941c61e907ee571732219fa4774d5a18f3fa5ff2df963f5dfaa6bc"}, 356 | {file = "lxml-4.9.4-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:b1980dbcaad634fe78e710c8587383e6e3f61dbe146bcbfd13a9c8ab2d7b1192"}, 357 | {file = "lxml-4.9.4-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:9ae6c3363261021144121427b1552b29e7b59de9d6a75bf51e03bc072efb3c37"}, 358 | {file = "lxml-4.9.4-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:bcee502c649fa6351b44bb014b98c09cb00982a475a1912a9881ca28ab4f9cd9"}, 359 | {file = "lxml-4.9.4-cp38-cp38-win32.whl", hash = "sha256:a8edae5253efa75c2fc79a90068fe540b197d1c7ab5803b800fccfe240eed33c"}, 360 | {file = "lxml-4.9.4-cp38-cp38-win_amd64.whl", hash = "sha256:701847a7aaefef121c5c0d855b2affa5f9bd45196ef00266724a80e439220e46"}, 361 | {file = "lxml-4.9.4-cp39-cp39-macosx_11_0_x86_64.whl", hash = "sha256:f610d980e3fccf4394ab3806de6065682982f3d27c12d4ce3ee46a8183d64a6a"}, 362 | {file = "lxml-4.9.4-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:aa9b5abd07f71b081a33115d9758ef6077924082055005808f68feccb27616bd"}, 363 | {file = "lxml-4.9.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:365005e8b0718ea6d64b374423e870648ab47c3a905356ab6e5a5ff03962b9a9"}, 364 | {file = "lxml-4.9.4-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:16b9ec51cc2feab009e800f2c6327338d6ee4e752c76e95a35c4465e80390ccd"}, 365 | {file = "lxml-4.9.4-cp39-cp39-manylinux_2_28_x86_64.whl", hash = "sha256:a905affe76f1802edcac554e3ccf68188bea16546071d7583fb1b693f9cf756b"}, 366 | {file = "lxml-4.9.4-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fd814847901df6e8de13ce69b84c31fc9b3fb591224d6762d0b256d510cbf382"}, 367 | {file = "lxml-4.9.4-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:91bbf398ac8bb7d65a5a52127407c05f75a18d7015a270fdd94bbcb04e65d573"}, 368 | {file = "lxml-4.9.4-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:f99768232f036b4776ce419d3244a04fe83784bce871b16d2c2e984c7fcea847"}, 369 | {file = "lxml-4.9.4-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:bb5bd6212eb0edfd1e8f254585290ea1dadc3687dd8fd5e2fd9a87c31915cdab"}, 370 | {file = "lxml-4.9.4-cp39-cp39-win32.whl", hash = "sha256:88f7c383071981c74ec1998ba9b437659e4fd02a3c4a4d3efc16774eb108d0ec"}, 371 | {file = "lxml-4.9.4-cp39-cp39-win_amd64.whl", hash = "sha256:936e8880cc00f839aa4173f94466a8406a96ddce814651075f95837316369899"}, 372 | {file = "lxml-4.9.4-pp310-pypy310_pp73-macosx_11_0_x86_64.whl", hash = "sha256:f6c35b2f87c004270fa2e703b872fcc984d714d430b305145c39d53074e1ffe0"}, 373 | {file = "lxml-4.9.4-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:606d445feeb0856c2b424405236a01c71af7c97e5fe42fbc778634faef2b47e4"}, 374 | {file = "lxml-4.9.4-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:a1bdcbebd4e13446a14de4dd1825f1e778e099f17f79718b4aeaf2403624b0f7"}, 375 | {file = "lxml-4.9.4-pp37-pypy37_pp73-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:0a08c89b23117049ba171bf51d2f9c5f3abf507d65d016d6e0fa2f37e18c0fc5"}, 376 | {file = "lxml-4.9.4-pp37-pypy37_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:232fd30903d3123be4c435fb5159938c6225ee8607b635a4d3fca847003134ba"}, 377 | {file = "lxml-4.9.4-pp37-pypy37_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:231142459d32779b209aa4b4d460b175cadd604fed856f25c1571a9d78114771"}, 378 | {file = "lxml-4.9.4-pp38-pypy38_pp73-macosx_11_0_x86_64.whl", hash = "sha256:520486f27f1d4ce9654154b4494cf9307b495527f3a2908ad4cb48e4f7ed7ef7"}, 379 | {file = "lxml-4.9.4-pp38-pypy38_pp73-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:562778586949be7e0d7435fcb24aca4810913771f845d99145a6cee64d5b67ca"}, 380 | {file = "lxml-4.9.4-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:a9e7c6d89c77bb2770c9491d988f26a4b161d05c8ca58f63fb1f1b6b9a74be45"}, 381 | {file = "lxml-4.9.4-pp38-pypy38_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:786d6b57026e7e04d184313c1359ac3d68002c33e4b1042ca58c362f1d09ff58"}, 382 | {file = "lxml-4.9.4-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:95ae6c5a196e2f239150aa4a479967351df7f44800c93e5a975ec726fef005e2"}, 383 | {file = "lxml-4.9.4-pp39-pypy39_pp73-macosx_11_0_x86_64.whl", hash = "sha256:9b556596c49fa1232b0fff4b0e69b9d4083a502e60e404b44341e2f8fb7187f5"}, 384 | {file = "lxml-4.9.4-pp39-pypy39_pp73-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:cc02c06e9e320869d7d1bd323df6dd4281e78ac2e7f8526835d3d48c69060683"}, 385 | {file = "lxml-4.9.4-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:857d6565f9aa3464764c2cb6a2e3c2e75e1970e877c188f4aeae45954a314e0c"}, 386 | {file = "lxml-4.9.4-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:c42ae7e010d7d6bc51875d768110c10e8a59494855c3d4c348b068f5fb81fdcd"}, 387 | {file = "lxml-4.9.4-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:f10250bb190fb0742e3e1958dd5c100524c2cc5096c67c8da51233f7448dc137"}, 388 | {file = "lxml-4.9.4.tar.gz", hash = "sha256:b1541e50b78e15fa06a2670157a1962ef06591d4c998b998047fff5e3236880e"}, 389 | ] 390 | 391 | [package.extras] 392 | cssselect = ["cssselect (>=0.7)"] 393 | html5 = ["html5lib"] 394 | htmlsoup = ["BeautifulSoup4"] 395 | source = ["Cython (==0.29.37)"] 396 | 397 | [[package]] 398 | name = "ncclient" 399 | version = "0.6.16" 400 | description = "Python library for NETCONF clients" 401 | optional = false 402 | python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7" 403 | files = [ 404 | {file = "ncclient-0.6.16.tar.gz", hash = "sha256:a16a351d8c234e3bbf3495577b63c96ae4adfcdf67f2d84194313473ea65b805"}, 405 | ] 406 | 407 | [package.dependencies] 408 | lxml = ">=3.3.0" 409 | paramiko = ">=1.15.0" 410 | setuptools = ">0.6" 411 | six = "*" 412 | 413 | [[package]] 414 | name = "netconf-client" 415 | version = "3.1.3" 416 | description = "A Python NETCONF client" 417 | optional = false 418 | python-versions = "<4.0,>=3.8" 419 | files = [ 420 | {file = "netconf_client-3.1.3-py3-none-any.whl", hash = "sha256:a25733c4dc415485e4cda693a5581ce4a22b30f63c38f4bf5cd0a13b69c51c1f"}, 421 | {file = "netconf_client-3.1.3.tar.gz", hash = "sha256:8dfa409ca506597c67a4d422dd3f94db8cf00e19c8a76e2805ed90c08a01a316"}, 422 | ] 423 | 424 | [package.dependencies] 425 | lxml = ">=4.6.3,<6" 426 | paramiko = ">=2.7.2,<4" 427 | 428 | [[package]] 429 | name = "packaging" 430 | version = "24.2" 431 | description = "Core utilities for Python packages" 432 | optional = false 433 | python-versions = ">=3.8" 434 | files = [ 435 | {file = "packaging-24.2-py3-none-any.whl", hash = "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759"}, 436 | {file = "packaging-24.2.tar.gz", hash = "sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f"}, 437 | ] 438 | 439 | [[package]] 440 | name = "paramiko" 441 | version = "3.5.0" 442 | description = "SSH2 protocol library" 443 | optional = false 444 | python-versions = ">=3.6" 445 | files = [ 446 | {file = "paramiko-3.5.0-py3-none-any.whl", hash = "sha256:1fedf06b085359051cd7d0d270cebe19e755a8a921cc2ddbfa647fb0cd7d68f9"}, 447 | {file = "paramiko-3.5.0.tar.gz", hash = "sha256:ad11e540da4f55cedda52931f1a3f812a8238a7af7f62a60de538cd80bb28124"}, 448 | ] 449 | 450 | [package.dependencies] 451 | bcrypt = ">=3.2" 452 | cryptography = ">=3.3" 453 | pynacl = ">=1.5" 454 | 455 | [package.extras] 456 | all = ["gssapi (>=1.4.1)", "invoke (>=2.0)", "pyasn1 (>=0.1.7)", "pywin32 (>=2.1.8)"] 457 | gssapi = ["gssapi (>=1.4.1)", "pyasn1 (>=0.1.7)", "pywin32 (>=2.1.8)"] 458 | invoke = ["invoke (>=2.0)"] 459 | 460 | [[package]] 461 | name = "pluggy" 462 | version = "1.5.0" 463 | description = "plugin and hook calling mechanisms for python" 464 | optional = false 465 | python-versions = ">=3.8" 466 | files = [ 467 | {file = "pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669"}, 468 | {file = "pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1"}, 469 | ] 470 | 471 | [package.extras] 472 | dev = ["pre-commit", "tox"] 473 | testing = ["pytest", "pytest-benchmark"] 474 | 475 | [[package]] 476 | name = "pycparser" 477 | version = "2.22" 478 | description = "C parser in Python" 479 | optional = false 480 | python-versions = ">=3.8" 481 | files = [ 482 | {file = "pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc"}, 483 | {file = "pycparser-2.22.tar.gz", hash = "sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6"}, 484 | ] 485 | 486 | [[package]] 487 | name = "pynacl" 488 | version = "1.5.0" 489 | description = "Python binding to the Networking and Cryptography (NaCl) library" 490 | optional = false 491 | python-versions = ">=3.6" 492 | files = [ 493 | {file = "PyNaCl-1.5.0-cp36-abi3-macosx_10_10_universal2.whl", hash = "sha256:401002a4aaa07c9414132aaed7f6836ff98f59277a234704ff66878c2ee4a0d1"}, 494 | {file = "PyNaCl-1.5.0-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:52cb72a79269189d4e0dc537556f4740f7f0a9ec41c1322598799b0bdad4ef92"}, 495 | {file = "PyNaCl-1.5.0-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a36d4a9dda1f19ce6e03c9a784a2921a4b726b02e1c736600ca9c22029474394"}, 496 | {file = "PyNaCl-1.5.0-cp36-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:0c84947a22519e013607c9be43706dd42513f9e6ae5d39d3613ca1e142fba44d"}, 497 | {file = "PyNaCl-1.5.0-cp36-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:06b8f6fa7f5de8d5d2f7573fe8c863c051225a27b61e6860fd047b1775807858"}, 498 | {file = "PyNaCl-1.5.0-cp36-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:a422368fc821589c228f4c49438a368831cb5bbc0eab5ebe1d7fac9dded6567b"}, 499 | {file = "PyNaCl-1.5.0-cp36-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:61f642bf2378713e2c2e1de73444a3778e5f0a38be6fee0fe532fe30060282ff"}, 500 | {file = "PyNaCl-1.5.0-cp36-abi3-win32.whl", hash = "sha256:e46dae94e34b085175f8abb3b0aaa7da40767865ac82c928eeb9e57e1ea8a543"}, 501 | {file = "PyNaCl-1.5.0-cp36-abi3-win_amd64.whl", hash = "sha256:20f42270d27e1b6a29f54032090b972d97f0a1b0948cc52392041ef7831fee93"}, 502 | {file = "PyNaCl-1.5.0.tar.gz", hash = "sha256:8ac7448f09ab85811607bdd21ec2464495ac8b7c66d146bf545b0f08fb9220ba"}, 503 | ] 504 | 505 | [package.dependencies] 506 | cffi = ">=1.4.1" 507 | 508 | [package.extras] 509 | docs = ["sphinx (>=1.6.5)", "sphinx-rtd-theme"] 510 | tests = ["hypothesis (>=3.27.0)", "pytest (>=3.2.1,!=3.3.0)"] 511 | 512 | [[package]] 513 | name = "pytest" 514 | version = "8.3.4" 515 | description = "pytest: simple powerful testing with Python" 516 | optional = false 517 | python-versions = ">=3.8" 518 | files = [ 519 | {file = "pytest-8.3.4-py3-none-any.whl", hash = "sha256:50e16d954148559c9a74109af1eaf0c945ba2d8f30f0a3d3335edde19788b6f6"}, 520 | {file = "pytest-8.3.4.tar.gz", hash = "sha256:965370d062bce11e73868e0335abac31b4d3de0e82f4007408d242b4f8610761"}, 521 | ] 522 | 523 | [package.dependencies] 524 | colorama = {version = "*", markers = "sys_platform == \"win32\""} 525 | exceptiongroup = {version = ">=1.0.0rc8", markers = "python_version < \"3.11\""} 526 | iniconfig = "*" 527 | packaging = "*" 528 | pluggy = ">=1.5,<2" 529 | tomli = {version = ">=1", markers = "python_version < \"3.11\""} 530 | 531 | [package.extras] 532 | dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] 533 | 534 | [[package]] 535 | name = "pytest-rerunfailures" 536 | version = "14.0" 537 | description = "pytest plugin to re-run tests to eliminate flaky failures" 538 | optional = false 539 | python-versions = ">=3.8" 540 | files = [ 541 | {file = "pytest-rerunfailures-14.0.tar.gz", hash = "sha256:4a400bcbcd3c7a4ad151ab8afac123d90eca3abe27f98725dc4d9702887d2e92"}, 542 | {file = "pytest_rerunfailures-14.0-py3-none-any.whl", hash = "sha256:4197bdd2eaeffdbf50b5ea6e7236f47ff0e44d1def8dae08e409f536d84e7b32"}, 543 | ] 544 | 545 | [package.dependencies] 546 | packaging = ">=17.1" 547 | pytest = ">=7.2" 548 | 549 | [[package]] 550 | name = "scrapli" 551 | version = "2024.7.30.post1" 552 | description = "Fast, flexible, sync/async, Python 3.7+ screen scraping client specifically for network devices" 553 | optional = false 554 | python-versions = ">=3.8" 555 | files = [ 556 | {file = "scrapli-2024.7.30.post1-py3-none-any.whl", hash = "sha256:59b96836f38d27498b141f6153ae0e169a5c806480f5e1f24cbb37ea74021e6f"}, 557 | {file = "scrapli-2024.7.30.post1.tar.gz", hash = "sha256:4a7b862ff66c1fabba5f0c5673cc5c46a46e24e0ebf19c37a56c398cbc3ccfde"}, 558 | ] 559 | 560 | [package.extras] 561 | asyncssh = ["asyncssh (>=2.2.1,<3.0.0)"] 562 | community = ["scrapli_community (>=2021.01.30)"] 563 | dev = ["asyncssh (>=2.2.1,<3.0.0)", "black (>=23.3.0,<25.0.0)", "darglint (>=1.8.1,<2.0.0)", "genie (>=20.2,<24.4)", "isort (>=5.10.1,<6.0.0)", "mypy (>=1.4.1,<2.0.0)", "nox (==2024.4.15)", "ntc-templates (>=1.1.0,<7.0.0)", "paramiko (>=2.6.0,<4.0.0)", "pyats (>=20.2)", "pydocstyle (>=6.1.1,<7.0.0)", "pyfakefs (>=5.4.1,<6.0.0)", "pylint (>=3.0.0,<4.0.0)", "pytest (>=7.0.0,<8.0.0)", "pytest-asyncio (>=0.17.0,<1.0.0)", "pytest-cov (>=3.0.0,<5.0.0)", "scrapli-cfg (==2023.7.30)", "scrapli-replay (==2023.7.30)", "scrapli_community (>=2021.01.30)", "ssh2-python (>=0.23.0,<2.0.0)", "textfsm (>=1.1.0,<2.0.0)", "toml (>=0.10.2,<1.0.0)", "ttp (>=0.5.0,<1.0.0)", "types-paramiko (>=2.8.6,<4.0.0)"] 564 | dev-darwin = ["asyncssh (>=2.2.1,<3.0.0)", "black (>=23.3.0,<25.0.0)", "darglint (>=1.8.1,<2.0.0)", "genie (>=20.2,<24.4)", "isort (>=5.10.1,<6.0.0)", "mypy (>=1.4.1,<2.0.0)", "nox (==2024.4.15)", "ntc-templates (>=1.1.0,<7.0.0)", "paramiko (>=2.6.0,<4.0.0)", "pyats (>=20.2)", "pydocstyle (>=6.1.1,<7.0.0)", "pyfakefs (>=5.4.1,<6.0.0)", "pylint (>=3.0.0,<4.0.0)", "pytest (>=7.0.0,<8.0.0)", "pytest-asyncio (>=0.17.0,<1.0.0)", "pytest-cov (>=3.0.0,<5.0.0)", "scrapli-cfg (==2023.7.30)", "scrapli-replay (==2023.7.30)", "scrapli_community (>=2021.01.30)", "textfsm (>=1.1.0,<2.0.0)", "toml (>=0.10.2,<1.0.0)", "ttp (>=0.5.0,<1.0.0)", "types-paramiko (>=2.8.6,<4.0.0)"] 565 | docs = ["mdx-gh-links (>=0.2,<1.0)", "mkdocs (>=1.2.3,<2.0.0)", "mkdocs-gen-files (>=0.4.0,<1.0.0)", "mkdocs-literate-nav (>=0.5.0,<1.0.0)", "mkdocs-material (>=8.1.6,<10.0.0)", "mkdocs-material-extensions (>=1.0.3,<2.0.0)", "mkdocs-section-index (>=0.3.4,<1.0.0)", "mkdocstrings[python] (>=0.19.0,<1.0.0)"] 566 | genie = ["genie (>=20.2,<24.4)", "pyats (>=20.2)"] 567 | paramiko = ["paramiko (>=2.6.0,<4.0.0)"] 568 | ssh2 = ["ssh2-python (>=0.23.0,<2.0.0)"] 569 | textfsm = ["ntc-templates (>=1.1.0,<7.0.0)", "textfsm (>=1.1.0,<2.0.0)"] 570 | ttp = ["ttp (>=0.5.0,<1.0.0)"] 571 | 572 | [[package]] 573 | name = "scrapli-netconf" 574 | version = "2024.7.30" 575 | description = "Fast, flexible, sync/async, Python 3.7+ NETCONF client built on scrapli" 576 | optional = false 577 | python-versions = ">=3.8" 578 | files = [ 579 | {file = "scrapli_netconf-2024.7.30-py3-none-any.whl", hash = "sha256:e6de8ef905ca4c9365f8c16463db04280235ba68f0baa8ae852a109333e70029"}, 580 | {file = "scrapli_netconf-2024.7.30.tar.gz", hash = "sha256:9ae9a440ed1cefff56f1d7550d8964c5e225f430b3db70ac3197c9b5633bf100"}, 581 | ] 582 | 583 | [package.dependencies] 584 | lxml = ">=4.5.1,<5.0.0" 585 | scrapli = ">=2022.07.30" 586 | 587 | [package.extras] 588 | asyncssh = ["asyncssh (>=2.2.1,<3.0.0)"] 589 | dev = ["asyncssh (>=2.2.1,<3.0.0)", "black (>=23.3.0,<25.0.0)", "darglint (>=1.8.1,<2.0.0)", "isort (>=5.10.1,<6.0.0)", "mypy (>=1.4.1,<2.0.0)", "nox (==2024.4.15)", "paramiko (>=2.6.0,<3.0.0)", "pycodestyle (>=2.8.0,<3.0.0)", "pydocstyle (>=6.1.1,<7.0.0)", "pylama (>=8.4.0,<9.0.0)", "pylint (>=3.0.0,<4.0.0)", "pytest (>=7.0.0,<8.0.0)", "pytest-asyncio (>=0.17.0,<1.0.0)", "pytest-cov (>=3.0.0,<5.0.0)", "ssh2-python (>=0.23.0,<2.0.0)", "toml (>=0.10.2,<1.0.0)"] 590 | dev-darwin = ["asyncssh (>=2.2.1,<3.0.0)", "black (>=23.3.0,<25.0.0)", "darglint (>=1.8.1,<2.0.0)", "isort (>=5.10.1,<6.0.0)", "mypy (>=1.4.1,<2.0.0)", "nox (==2024.4.15)", "paramiko (>=2.6.0,<3.0.0)", "pycodestyle (>=2.8.0,<3.0.0)", "pydocstyle (>=6.1.1,<7.0.0)", "pylama (>=8.4.0,<9.0.0)", "pylint (>=3.0.0,<4.0.0)", "pytest (>=7.0.0,<8.0.0)", "pytest-asyncio (>=0.17.0,<1.0.0)", "pytest-cov (>=3.0.0,<5.0.0)", "toml (>=0.10.2,<1.0.0)"] 591 | docs = ["mdx-gh-links (>=0.2,<1.0)", "mkdocs (>=1.2.3,<2.0.0)", "mkdocs-gen-files (>=0.4.0,<1.0.0)", "mkdocs-literate-nav (>=0.5.0,<1.0.0)", "mkdocs-material (>=8.1.6,<10.0.0)", "mkdocs-material-extensions (>=1.0.3,<2.0.0)", "mkdocs-section-index (>=0.3.4,<1.0.0)", "mkdocstrings[python] (>=0.19.0,<1.0.0)"] 592 | paramiko = ["paramiko (>=2.6.0,<3.0.0)"] 593 | ssh2 = ["ssh2-python (>=0.23.0,<2.0.0)"] 594 | 595 | [[package]] 596 | name = "setuptools" 597 | version = "75.3.0" 598 | description = "Easily download, build, install, upgrade, and uninstall Python packages" 599 | optional = false 600 | python-versions = ">=3.8" 601 | files = [ 602 | {file = "setuptools-75.3.0-py3-none-any.whl", hash = "sha256:f2504966861356aa38616760c0f66568e535562374995367b4e69c7143cf6bcd"}, 603 | {file = "setuptools-75.3.0.tar.gz", hash = "sha256:fba5dd4d766e97be1b1681d98712680ae8f2f26d7881245f2ce9e40714f1a686"}, 604 | ] 605 | 606 | [package.extras] 607 | check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1)", "ruff (>=0.5.2)"] 608 | core = ["importlib-metadata (>=6)", "importlib-resources (>=5.10.2)", "jaraco.collections", "jaraco.functools", "jaraco.text (>=3.7)", "more-itertools", "more-itertools (>=8.8)", "packaging", "packaging (>=24)", "platformdirs (>=4.2.2)", "tomli (>=2.0.1)", "wheel (>=0.43.0)"] 609 | cover = ["pytest-cov"] 610 | doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "pyproject-hooks (!=1.1)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (>=1,<2)", "sphinx-reredirects", "sphinxcontrib-towncrier", "towncrier (<24.7)"] 611 | enabler = ["pytest-enabler (>=2.2)"] 612 | test = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "ini2toml[lite] (>=0.14)", "jaraco.develop (>=7.21)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "jaraco.test (>=5.5)", "packaging (>=23.2)", "pip (>=19.1)", "pyproject-hooks (!=1.1)", "pytest (>=6,!=8.1.*)", "pytest-home (>=0.5)", "pytest-perf", "pytest-subprocess", "pytest-timeout", "pytest-xdist (>=3)", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel (>=0.44.0)"] 613 | type = ["importlib-metadata (>=7.0.2)", "jaraco.develop (>=7.21)", "mypy (==1.12.*)", "pytest-mypy"] 614 | 615 | [[package]] 616 | name = "six" 617 | version = "1.17.0" 618 | description = "Python 2 and 3 compatibility utilities" 619 | optional = false 620 | python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" 621 | files = [ 622 | {file = "six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274"}, 623 | {file = "six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81"}, 624 | ] 625 | 626 | [[package]] 627 | name = "tomli" 628 | version = "2.2.1" 629 | description = "A lil' TOML parser" 630 | optional = false 631 | python-versions = ">=3.8" 632 | files = [ 633 | {file = "tomli-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678e4fa69e4575eb77d103de3df8a895e1591b48e740211bd1067378c69e8249"}, 634 | {file = "tomli-2.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:023aa114dd824ade0100497eb2318602af309e5a55595f76b626d6d9f3b7b0a6"}, 635 | {file = "tomli-2.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ece47d672db52ac607a3d9599a9d48dcb2f2f735c6c2d1f34130085bb12b112a"}, 636 | {file = "tomli-2.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6972ca9c9cc9f0acaa56a8ca1ff51e7af152a9f87fb64623e31d5c83700080ee"}, 637 | {file = "tomli-2.2.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c954d2250168d28797dd4e3ac5cf812a406cd5a92674ee4c8f123c889786aa8e"}, 638 | {file = "tomli-2.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8dd28b3e155b80f4d54beb40a441d366adcfe740969820caf156c019fb5c7ec4"}, 639 | {file = "tomli-2.2.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e59e304978767a54663af13c07b3d1af22ddee3bb2fb0618ca1593e4f593a106"}, 640 | {file = "tomli-2.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:33580bccab0338d00994d7f16f4c4ec25b776af3ffaac1ed74e0b3fc95e885a8"}, 641 | {file = "tomli-2.2.1-cp311-cp311-win32.whl", hash = "sha256:465af0e0875402f1d226519c9904f37254b3045fc5084697cefb9bdde1ff99ff"}, 642 | {file = "tomli-2.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:2d0f2fdd22b02c6d81637a3c95f8cd77f995846af7414c5c4b8d0545afa1bc4b"}, 643 | {file = "tomli-2.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4a8f6e44de52d5e6c657c9fe83b562f5f4256d8ebbfe4ff922c495620a7f6cea"}, 644 | {file = "tomli-2.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8d57ca8095a641b8237d5b079147646153d22552f1c637fd3ba7f4b0b29167a8"}, 645 | {file = "tomli-2.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e340144ad7ae1533cb897d406382b4b6fede8890a03738ff1683af800d54192"}, 646 | {file = "tomli-2.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db2b95f9de79181805df90bedc5a5ab4c165e6ec3fe99f970d0e302f384ad222"}, 647 | {file = "tomli-2.2.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40741994320b232529c802f8bc86da4e1aa9f413db394617b9a256ae0f9a7f77"}, 648 | {file = "tomli-2.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:400e720fe168c0f8521520190686ef8ef033fb19fc493da09779e592861b78c6"}, 649 | {file = "tomli-2.2.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:02abe224de6ae62c19f090f68da4e27b10af2b93213d36cf44e6e1c5abd19fdd"}, 650 | {file = "tomli-2.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b82ebccc8c8a36f2094e969560a1b836758481f3dc360ce9a3277c65f374285e"}, 651 | {file = "tomli-2.2.1-cp312-cp312-win32.whl", hash = "sha256:889f80ef92701b9dbb224e49ec87c645ce5df3fa2cc548664eb8a25e03127a98"}, 652 | {file = "tomli-2.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:7fc04e92e1d624a4a63c76474610238576942d6b8950a2d7f908a340494e67e4"}, 653 | {file = "tomli-2.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f4039b9cbc3048b2416cc57ab3bda989a6fcf9b36cf8937f01a6e731b64f80d7"}, 654 | {file = "tomli-2.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:286f0ca2ffeeb5b9bd4fcc8d6c330534323ec51b2f52da063b11c502da16f30c"}, 655 | {file = "tomli-2.2.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a92ef1a44547e894e2a17d24e7557a5e85a9e1d0048b0b5e7541f76c5032cb13"}, 656 | {file = "tomli-2.2.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9316dc65bed1684c9a98ee68759ceaed29d229e985297003e494aa825ebb0281"}, 657 | {file = "tomli-2.2.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e85e99945e688e32d5a35c1ff38ed0b3f41f43fad8df0bdf79f72b2ba7bc5272"}, 658 | {file = "tomli-2.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ac065718db92ca818f8d6141b5f66369833d4a80a9d74435a268c52bdfa73140"}, 659 | {file = "tomli-2.2.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:d920f33822747519673ee656a4b6ac33e382eca9d331c87770faa3eef562aeb2"}, 660 | {file = "tomli-2.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a198f10c4d1b1375d7687bc25294306e551bf1abfa4eace6650070a5c1ae2744"}, 661 | {file = "tomli-2.2.1-cp313-cp313-win32.whl", hash = "sha256:d3f5614314d758649ab2ab3a62d4f2004c825922f9e370b29416484086b264ec"}, 662 | {file = "tomli-2.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:a38aa0308e754b0e3c67e344754dff64999ff9b513e691d0e786265c93583c69"}, 663 | {file = "tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc"}, 664 | {file = "tomli-2.2.1.tar.gz", hash = "sha256:cd45e1dc79c835ce60f7404ec8119f2eb06d38b1deba146f07ced3bbc44505ff"}, 665 | ] 666 | 667 | [metadata] 668 | lock-version = "2.0" 669 | python-versions = "^3.8" 670 | content-hash = "5fc685058c7d99fdc99290d0d18e1b0a511f8851dd428209a646cd791aeddff8" 671 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "pytest-netconf" 3 | version = "0.1.1" 4 | description = "A pytest plugin that provides a mock NETCONF (RFC6241/RFC6242) server for local testing." 5 | authors = ["Adam Kirchberger "] 6 | license = "Apache License 2.0" 7 | readme = "README.md" 8 | keywords = [ 9 | "Netconf", 10 | "Network automation", 11 | "Network engineering", 12 | "Network testing" 13 | ] 14 | classifiers = [ 15 | "Framework :: Pytest", 16 | "Intended Audience :: Developers", 17 | "Intended Audience :: Telecommunications Industry", 18 | "Topic :: System :: Networking", 19 | "Topic :: Software Development :: Testing", 20 | "Topic :: Software Development :: Testing :: Mocking", 21 | "License :: OSI Approved :: Apache Software License", 22 | ] 23 | 24 | [tool.poetry.group.dev.dependencies] 25 | pytest = "^8.3.2" 26 | ncclient = "^0.6.15" 27 | pytest-rerunfailures = "^14.0" 28 | scrapli-netconf = "^2024.7.30" 29 | netconf-client = "^3.1.1" 30 | coverage = "^7.6.1" 31 | 32 | [tool.pytest.ini_options] 33 | addopts = "--reruns 1 -vv" 34 | 35 | [tool.poetry.dependencies] 36 | python = "^3.8" 37 | paramiko = "^3.4.0" 38 | 39 | [build-system] 40 | requires = ["poetry-core"] 41 | build-backend = "poetry.core.masonry.api" 42 | 43 | [tool.poetry.plugins.pytest11] 44 | pytest_netconf = "pytest_netconf.pytest_plugin" 45 | -------------------------------------------------------------------------------- /pytest_netconf/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Copyright 2024 Nomios UK&I 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | """ 16 | 17 | from .netconfserver import NetconfServer 18 | from .version import __version__ 19 | 20 | __all__ = ["NetconfServer"] 21 | -------------------------------------------------------------------------------- /pytest_netconf/constants.py: -------------------------------------------------------------------------------- 1 | """ 2 | Copyright 2024 Nomios UK&I 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | """ 16 | 17 | RPC_REPLY_OK = """ 18 | 19 | 20 | """ 21 | 22 | RPC_REPLY_ERROR = """ 23 | 24 | 25 | {type} 26 | {tag} 27 | error 28 | {message} 29 | 30 | """ 31 | -------------------------------------------------------------------------------- /pytest_netconf/exceptions.py: -------------------------------------------------------------------------------- 1 | """ 2 | Copyright 2024 Nomios UK&I 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | """ 16 | 17 | 18 | class UnexpectedRequestError(Exception): 19 | """Indicates that a request has been received that has no predefined response.""" 20 | 21 | 22 | class RequestError(Exception): 23 | """Indicates that an error occurred when processing the request.""" 24 | -------------------------------------------------------------------------------- /pytest_netconf/netconfserver.py: -------------------------------------------------------------------------------- 1 | """ 2 | Copyright 2024 Nomios UK&I 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | """ 16 | 17 | import re 18 | import time 19 | import socket 20 | import logging 21 | import threading 22 | import typing as t 23 | from enum import Enum 24 | import xml.etree.ElementTree as ET 25 | import paramiko 26 | 27 | from .settings import Settings 28 | from .exceptions import UnexpectedRequestError, RequestError 29 | from .sshserver import SSHServer 30 | from .constants import RPC_REPLY_OK, RPC_REPLY_ERROR 31 | 32 | 33 | logging.basicConfig(level=logging.INFO) 34 | logger = logging.getLogger(__name__) 35 | 36 | 37 | class NetconfBaseVersion(Enum): 38 | """ 39 | NETCONF protocol versions. 40 | """ 41 | 42 | BASE_10 = "1.0" 43 | BASE_11 = "1.1" 44 | 45 | 46 | class NetconfServer: 47 | """A NETCONF server implementation.""" 48 | 49 | BASE_10_DELIMETER = b"]]>]]>" 50 | BASE_10_TEMPLATE = "{content}]]>]]>" 51 | BASE_11_DELIMETER = b"\n##\n" 52 | BASE_11_PATTERN = r"#(\d+)\n(.*)\n##$" 53 | BASE_11_TEMPLATE = "\n#{length}\n{content}\n##\n" 54 | SESSION_ID = 1 55 | 56 | def __init__(self): 57 | """ 58 | Initialise the NETCONF server. 59 | """ 60 | self.settings: Settings = Settings() 61 | self._base_version: NetconfBaseVersion = NetconfBaseVersion( 62 | self.settings.base_version 63 | ) 64 | self._server_socket: t.Optional[socket.socket] = None 65 | self._client_socket: t.Optional[socket.socket] = None 66 | self.running: bool = False 67 | self._thread: t.Optional[threading.Thread] = None 68 | self._hello_sent: bool = False 69 | self.capabilities: t.List[str] = [] 70 | 71 | self.responses: t.List[t.Tuple[str, str]] = [] 72 | 73 | @property 74 | def host(self) -> str: 75 | """ 76 | Get the host address for the server. 77 | 78 | Returns: 79 | str: The host address. 80 | """ 81 | return self.settings.host 82 | 83 | @host.setter 84 | def host(self, value: str): 85 | """ 86 | Set the host address for the server. 87 | 88 | Args: 89 | value (str): The new host address. 90 | """ 91 | self.settings.host = value 92 | 93 | @property 94 | def port(self) -> int: 95 | """ 96 | Get the port number for the server. 97 | 98 | Returns: 99 | int: The port number. 100 | """ 101 | return self.settings.port 102 | 103 | @port.setter 104 | def port(self, value: int): 105 | """ 106 | Set the port number for the server. 107 | 108 | Args: 109 | value (int): The new port number. 110 | """ 111 | assert isinstance(value, int), "port value must be int" 112 | self.settings.port = value 113 | 114 | @property 115 | def username(self) -> str: 116 | """ 117 | Get the username for authentication. 118 | 119 | Returns: 120 | str: The username. 121 | """ 122 | return self.settings.username 123 | 124 | @username.setter 125 | def username(self, value: str): 126 | """ 127 | Set the username for authentication. 128 | 129 | Args: 130 | value (str): The new username. 131 | """ 132 | self.settings.username = value 133 | 134 | @property 135 | def password(self) -> t.Optional[str]: 136 | """ 137 | Get the password for authentication. 138 | 139 | Returns: 140 | t.Optional[str]: The password, if set. 141 | """ 142 | return self.settings.password 143 | 144 | @password.setter 145 | def password(self, value: t.Optional[str]): 146 | """ 147 | Set the password for authentication. 148 | 149 | Args: 150 | value (t.Optional[str]): The new password. 151 | """ 152 | self.settings.password = value 153 | 154 | @property 155 | def base_version(self) -> str: 156 | """ 157 | Get the base NETCONF protocol version. 158 | 159 | Returns: 160 | str: The base version. 161 | """ 162 | return self._base_version.value 163 | 164 | @base_version.setter 165 | def base_version(self, value: str): 166 | """ 167 | Set the base NETCONF protocol version. 168 | 169 | Args: 170 | value (str): The new base version. 171 | 172 | Raises: 173 | ValueError: when version is invalid. 174 | """ 175 | try: 176 | self._base_version = NetconfBaseVersion(value) 177 | except ValueError as e: 178 | raise ValueError( 179 | f"Invalid NETCONF base version {value}: must be '1.0' or '1.1'" 180 | ) from e 181 | 182 | @property 183 | def authorized_key(self) -> t.Optional[paramiko.PKey]: 184 | """ 185 | Get the SSH authorized key authentication. 186 | 187 | Returns: 188 | t.Optional[paramiko.PKey]: The SSH authorized key, if set. 189 | """ 190 | return self.settings.authorized_key 191 | 192 | @authorized_key.setter 193 | def authorized_key(self, value: t.Optional[paramiko.PKey]): 194 | """ 195 | Set the SSH authorized key for authentication. 196 | 197 | Args: 198 | value (t.Optional[paramiko.PKey]): The new SSH authorized key string. 199 | """ 200 | self.settings.authorized_key = value 201 | 202 | def _hello_response(self) -> str: 203 | """ 204 | Return a hello response based on NETCONF version. 205 | 206 | Returns: 207 | str: The XML hello response. 208 | """ 209 | response = f""" 210 | 211 | 212 | urn:ietf:params:netconf:base:{self._base_version.value}""" 213 | 214 | # Add additional capabilities 215 | for capability in self.capabilities: 216 | response += f""" 217 | {capability}""" 218 | 219 | response += f""" 220 | 221 | {NetconfServer.SESSION_ID} 222 | """ 223 | 224 | return response.strip("\n") 225 | 226 | def start(self) -> None: 227 | """ 228 | Start the mock NETCONF server. 229 | 230 | Raises: 231 | OSError: If the server fails to bind to the specified port. 232 | """ 233 | self.running = True 234 | self._hello_sent = False # reset in case of restart 235 | self._bind_socket() 236 | self._thread = threading.Thread(target=self._run) 237 | self._thread.start() 238 | time.sleep(1) # Give the server a moment to start 239 | 240 | def stop(self) -> None: 241 | """Stop the NETCONF server.""" 242 | self.running = False 243 | if self._client_socket: 244 | self._client_socket.close() 245 | if self._server_socket: 246 | self._server_socket.close() 247 | if self._thread: 248 | self._thread.join() 249 | 250 | def _bind_socket(self) -> None: 251 | """ 252 | Bind the server socket to the specified host and port. 253 | 254 | Raises: 255 | OSError: If the server fails to bind to the specified port. 256 | """ 257 | for _ in range(5): # Retry up to 5 times 258 | try: 259 | self._server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 260 | self._server_socket.setsockopt( 261 | socket.SOL_SOCKET, socket.SO_REUSEADDR, 1 262 | ) 263 | self._server_socket.bind((self.settings.host, self.settings.port)) 264 | self._server_socket.listen(1) 265 | return 266 | except OSError as e: 267 | if e.errno == 48: # Address already in use 268 | logger.warning("port %d in use, retrying...", self.port) 269 | time.sleep(1) 270 | else: 271 | raise e 272 | raise OSError( 273 | f"could not bind to port {self.port}", 274 | ) 275 | 276 | def _run(self) -> None: 277 | """Run the server to accept connections and process requests.""" 278 | try: 279 | self._client_socket, _ = self._server_socket.accept() 280 | transport = paramiko.Transport(self._client_socket) 281 | transport.add_server_key(paramiko.RSAKey.generate(2048)) 282 | server = SSHServer(self.settings) 283 | transport.start_server(server=server) 284 | channel = transport.accept(20) 285 | 286 | if channel is None: 287 | logger.error("channel was not created") 288 | return 289 | 290 | # Wait for the subsystem request 291 | server.event.wait(10) 292 | 293 | if server.event.is_set(): 294 | self._handle_requests(channel) 295 | 296 | finally: 297 | if channel: 298 | try: 299 | channel.close() 300 | except EOFError: # pragma: no cover 301 | pass 302 | transport.close() 303 | 304 | def _handle_requests(self, channel: paramiko.Channel) -> None: 305 | """ 306 | Handle incoming requests on the channel. 307 | 308 | Args: 309 | channel (paramiko.Channel): The communication channel with the client. 310 | """ 311 | buffer = bytearray() 312 | while self.running: 313 | try: 314 | # Send hello 315 | if not self._hello_sent: 316 | channel.sendall( 317 | NetconfServer.BASE_10_TEMPLATE.format( 318 | content=self._hello_response() 319 | ).encode() 320 | ) 321 | self._hello_sent = True 322 | 323 | data = channel.recv(4096) 324 | if not data: 325 | break 326 | buffer.extend(data) 327 | logger.debug("received data: %s", data.decode()) 328 | while True: 329 | processed = self._process_buffer(buffer, channel) 330 | if not processed: 331 | break 332 | except UnexpectedRequestError as e: 333 | logger.error("unexpected request error: %s", e) 334 | except Exception as e: 335 | msg = "failed to handle request: %s" 336 | logger.error(msg, e) 337 | logger.exception(e) 338 | raise RequestError(msg % e) 339 | 340 | def _process_buffer(self, buffer: bytearray, channel: paramiko.Channel) -> bool: 341 | """ 342 | Process the buffered data to extract requests and send responses. 343 | 344 | Args: 345 | buffer (bytearray): The current buffer containing request data. 346 | channel (paramiko.Channel): The communication channel with the client. 347 | 348 | Returns: 349 | bool: True if a complete request was processed, else False. 350 | 351 | Raises: 352 | UnexpectedRequestError: when the request has no defined response. 353 | """ 354 | # Handle client hello 355 | if b"hello" in buffer and b"capabilities" in buffer: 356 | logger.info("received client hello") 357 | del buffer[ 358 | : buffer.index(NetconfServer.BASE_10_DELIMETER) 359 | + len(NetconfServer.BASE_10_DELIMETER) 360 | ] 361 | return True 362 | 363 | # Handle NETCONF v1.0 364 | elif ( 365 | self._base_version is NetconfBaseVersion.BASE_10 366 | and NetconfServer.BASE_10_DELIMETER in buffer 367 | ): 368 | request_end_index = buffer.index(NetconfServer.BASE_10_DELIMETER) 369 | request = buffer[:request_end_index].decode() 370 | del buffer[: request_end_index + len(NetconfServer.BASE_10_DELIMETER)] 371 | logger.debug("processed request: %s", request) 372 | 373 | # Handle NETCONF v1.1 374 | elif ( 375 | self._base_version is NetconfBaseVersion.BASE_11 376 | and NetconfServer.BASE_11_DELIMETER in buffer 377 | ): 378 | try: 379 | buffer_str = buffer.decode() 380 | length, request_content = self._extract_base11_content_and_length( 381 | buffer_str 382 | ) 383 | logger.debug( 384 | "extracted content length=%d content: %s", length, request_content 385 | ) 386 | except ValueError as e: 387 | logger.error("parse error: %s", e) 388 | return False # Wait for more data if parsing fails 389 | 390 | request = request_content 391 | request_len = len( 392 | NetconfServer.BASE_11_TEMPLATE.format(length=length, content=request) 393 | ) 394 | del buffer[:request_len] 395 | else: 396 | logger.debug("waiting for more data...") 397 | return False # Wait for more data 398 | 399 | self._send_response(request, channel) 400 | logger.debug("buffer after processing: %s", buffer) 401 | return True 402 | 403 | def _extract_base11_content_and_length(self, buffer_str: str) -> t.Tuple[int, str]: 404 | """ 405 | Extract the base 1.1 length value and content from string.. 406 | 407 | Args: 408 | buffer_str (str): The input buffer string. 409 | 410 | Returns: 411 | t.Tuple[int, str]: The length value and the extracted content. 412 | 413 | Raises: 414 | ValueError: When length value cannot be parsed or is invalid. 415 | """ 416 | 417 | if m := re.search(NetconfServer.BASE_11_PATTERN, buffer_str, flags=re.DOTALL): 418 | length = int(m.group(1)) 419 | content = m.group(2) 420 | 421 | if len(content) != length: 422 | raise ValueError( 423 | f"received invalid chunk size expected={len(content)} received={length}", 424 | ) 425 | 426 | return length, content 427 | 428 | raise ValueError(f"Invalid content or chunk size format") 429 | 430 | def _extract_message_id(self, request: str) -> str: 431 | """ 432 | Extract the message-id from an XML request. 433 | 434 | Args: 435 | request (str): The XML request string. 436 | 437 | Returns: 438 | str: The extracted message-id, or 'unknown' if parsing fails. 439 | """ 440 | try: 441 | root = ET.fromstring(request) 442 | return root.get("message-id", "unknown") 443 | except ET.ParseError as e: 444 | logger.error("failed to parse XML request: %s", e) 445 | return "unknown" 446 | 447 | def _get_response(self, request: str) -> t.Optional[str]: 448 | """ 449 | Get the appropriate response for a given request. 450 | 451 | Args: 452 | request (str): The request string to match against defined responses. 453 | 454 | Returns: 455 | t.Optional[str]: The matched response string, or None if no match is found. 456 | """ 457 | 458 | for pattern, response in self.responses: 459 | formatted_pattern = pattern.format( 460 | message_id=self._extract_message_id(request), 461 | session_id=NetconfServer.SESSION_ID, 462 | ) 463 | 464 | # Check for exact match or regex match 465 | if (formatted_pattern == request) or re.search( 466 | formatted_pattern, request, flags=re.DOTALL 467 | ): 468 | return re.sub(r"^\s+|\s+$", "", response) 469 | 470 | return None 471 | 472 | def _send_response(self, request: str, channel: paramiko.Channel) -> None: 473 | """ 474 | Send a response to the client based on the request and protocol version. 475 | 476 | Args: 477 | request (str): The client's request. 478 | channel (paramiko.Channel): The communication channel with the client. 479 | 480 | Raises: 481 | UnexpectedRequestError: when the request has no defined response. 482 | """ 483 | message_id = self._extract_message_id(request) 484 | response = self._get_response(request) 485 | 486 | def _fmt_response(_res: str) -> str: 487 | """Helper to format response depending on base version.""" 488 | return ( 489 | NetconfServer.BASE_10_TEMPLATE.format(content=_res.strip("\n")) 490 | if self._base_version is NetconfBaseVersion.BASE_10 491 | else NetconfServer.BASE_11_TEMPLATE.format( 492 | length=len(_res.strip("\n")), content=_res.strip("\n") 493 | ) 494 | ) 495 | 496 | if "close-session" in request: 497 | channel.sendall( 498 | _fmt_response(RPC_REPLY_OK.format(message_id=message_id)).encode() 499 | ), 500 | 501 | elif response: 502 | response = response.format(message_id=message_id) 503 | channel.sendall(_fmt_response(response).encode()) 504 | else: 505 | error_response = RPC_REPLY_ERROR.format( 506 | type="rpc", 507 | message_id=message_id, 508 | tag="operation-failed", 509 | message="pytest-netconf: requested rpc is unknown and has no response defined", 510 | ) 511 | channel.sendall(_fmt_response(error_response).encode()) 512 | raise UnexpectedRequestError( 513 | f"Received request which has no response defined: {request}" 514 | ) 515 | 516 | def expect_request( 517 | self, request_pattern: t.Union[str, t.Pattern[str]] 518 | ) -> "NetconfServer.ResponseSetter": 519 | """ 520 | Define expected requests and associated responses. 521 | 522 | Args: 523 | request_pattern (t.Union[str, Pattern[str]]): The expected request pattern. 524 | 525 | Returns: 526 | NetconfServer.ResponseSetter: A ResponseSetter to set the response for the request. 527 | """ 528 | return self.ResponseSetter(self, request_pattern) 529 | 530 | class ResponseSetter: 531 | """Helper class to set responses for expected requests.""" 532 | 533 | def __init__( 534 | self, server: "NetconfServer", request_pattern: t.Union[str, t.Pattern[str]] 535 | ): 536 | """ 537 | Initialize the ResponseSetter. 538 | 539 | Args: 540 | server (NetconfServer): The server instance to set the response on. 541 | request_pattern (t.Union[str, Pattern[str]]): The expected request pattern. 542 | """ 543 | self.server = server 544 | self._request_pattern = request_pattern 545 | 546 | def respond_with(self, response: str) -> "NetconfServer.ResponseSetter": 547 | """ 548 | Set the response for the specified request pattern. 549 | 550 | Args: 551 | response (str): The response to associate with the request pattern. 552 | 553 | Returns: 554 | NetconfServer.ResponseSetter: The current instance for chaining. 555 | """ 556 | self.server.responses.append((self._request_pattern, response)) 557 | return self 558 | -------------------------------------------------------------------------------- /pytest_netconf/pytest_plugin.py: -------------------------------------------------------------------------------- 1 | """ 2 | Copyright 2024 Nomios UK&I 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | """ 16 | 17 | import pytest 18 | 19 | from pytest_netconf import NetconfServer 20 | 21 | 22 | @pytest.fixture(scope="function") 23 | def netconf_server(): 24 | """ 25 | Pytest fixture to create and start a mock NETCONF server. 26 | """ 27 | server = NetconfServer() 28 | server.start() 29 | yield server 30 | server.stop() # pragma: no cover 31 | -------------------------------------------------------------------------------- /pytest_netconf/settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | Copyright 2024 Nomios UK&I 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | """ 16 | 17 | import os 18 | import typing as t 19 | from dataclasses import dataclass 20 | 21 | 22 | @dataclass 23 | class Settings: 24 | """ 25 | pytest-netconf settings. 26 | """ 27 | 28 | base_version: t.Literal["1.0", "1.1"] = os.getenv("PYTEST_NETCONF_VERSION", "1.1") 29 | host: str = os.getenv("PYTEST_NETCONF_HOST", "localhost") 30 | port: int = int(os.getenv("PYTEST_NETCONF_PORT", "8830")) 31 | username: t.Optional[str] = os.getenv("PYTEST_NETCONF_USERNAME") 32 | password: t.Optional[str] = os.getenv("PYTEST_NETCONF_PASSWORD") 33 | authorized_key: t.Optional[str] = os.getenv("PYTEST_NETCONF_AUTHORIZED_KEY") 34 | allocate_pty: bool = bool( 35 | (os.getenv("PYTEST_NETCONF_AUTHORIZED_KEY", "true")).lower() == "true" 36 | ) 37 | -------------------------------------------------------------------------------- /pytest_netconf/sshserver.py: -------------------------------------------------------------------------------- 1 | """ 2 | Copyright 2024 Nomios UK&I 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | """ 16 | 17 | import logging 18 | import threading 19 | import paramiko 20 | 21 | from .settings import Settings 22 | 23 | 24 | logger = logging.getLogger(__name__) 25 | 26 | 27 | class SSHServer(paramiko.ServerInterface): 28 | """An SSH server.""" 29 | 30 | def __init__(self, settings: Settings): 31 | """ 32 | Initialise the SSH server. 33 | 34 | Args: 35 | settings (Settings): The SSH server settings. 36 | """ 37 | self.event = threading.Event() 38 | self._settings = settings 39 | 40 | def check_channel_request(self, kind: str, _: int) -> int: 41 | """ 42 | Check if a channel request is of type 'session'. 43 | 44 | Args: 45 | kind (str): The type of channel requested. 46 | 47 | Returns: 48 | int: The status of the channel request. 49 | """ 50 | return ( 51 | paramiko.OPEN_SUCCEEDED 52 | if kind == "session" 53 | else paramiko.OPEN_FAILED_ADMINISTRATIVELY_PROHIBITED 54 | ) 55 | 56 | def check_channel_pty_request(self, *_) -> bool: 57 | """ 58 | Determine if a PTY can be provided. 59 | 60 | Returns: 61 | bool: True if the PTY has been allocated, else False. 62 | """ 63 | return self._settings.allocate_pty 64 | 65 | def get_allowed_auths(self, username: str) -> str: 66 | return "password,publickey" 67 | 68 | def check_auth_password(self, username: str, password: str) -> int: 69 | """ 70 | Validate the username and password for authentication. 71 | 72 | Args: 73 | username (str): The username provided for authentication. 74 | password (str): The password provided for authentication. 75 | 76 | Returns: 77 | int: The status of the authentication request. 78 | """ 79 | logger.debug("trying password auth for user: %s", self._settings.username) 80 | if not self._settings.username and not self._settings.password: 81 | logger.info("password auth successful using any username and password") 82 | return paramiko.AUTH_SUCCESSFUL 83 | if username == self._settings.username and password == self._settings.password: 84 | logger.info("password auth successful username and password match") 85 | return paramiko.AUTH_SUCCESSFUL 86 | return paramiko.AUTH_FAILED 87 | 88 | def check_auth_publickey(self, username: str, key: paramiko.PKey) -> int: 89 | """ 90 | Validate the username and SSH key for authentication. 91 | 92 | Args: 93 | username (str): The username provided for authentication. 94 | key (paramiko.PKey): The public key provided for authentication. 95 | 96 | Returns: 97 | int: The status of the authentication request. 98 | """ 99 | logger.debug("trying publickey auth for user: %s", self._settings.username) 100 | if ( 101 | username == self._settings.username 102 | and self._settings.authorized_key 103 | and f"{key.get_name()} {key.get_base64()}" == self._settings.authorized_key 104 | ): 105 | logger.info("publickey auth successful username and key match") 106 | return paramiko.AUTH_SUCCESSFUL 107 | return paramiko.AUTH_FAILED 108 | 109 | def check_channel_subsystem_request(self, _: paramiko.Channel, name: str) -> bool: 110 | """ 111 | Check if the requested subsystem is 'netconf'. 112 | 113 | Args: 114 | name (str): The name of the subsystem requested. 115 | 116 | Returns: 117 | bool: True if the subsystem is 'netconf', False otherwise. 118 | """ 119 | if name == "netconf": 120 | self.event.set() 121 | return True 122 | return False # pragma: no cover 123 | -------------------------------------------------------------------------------- /pytest_netconf/version.py: -------------------------------------------------------------------------------- 1 | """ 2 | Copyright 2024 Nomios UK&I 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | """ 16 | 17 | __version__ = "0.1.1" 18 | -------------------------------------------------------------------------------- /release-please-config.json: -------------------------------------------------------------------------------- 1 | { 2 | "packages": { 3 | ".": { 4 | "changelog-path": "CHANGELOG.md", 5 | "release-type": "python", 6 | "bump-minor-pre-major": true, 7 | "bump-patch-for-minor-pre-major": false, 8 | "draft": false, 9 | "prerelease": false 10 | } 11 | }, 12 | "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json" 13 | } 14 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | # pytest_plugins = ["pytest_netconf.pytest_plugin"] 2 | -------------------------------------------------------------------------------- /tests/integration/test_ncclient.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import paramiko 3 | from pytest_netconf import NetconfServer 4 | 5 | from ncclient import manager 6 | from ncclient.transport.errors import ( 7 | AuthenticationError, 8 | TransportError, 9 | ) 10 | from ncclient.operations.rpc import RPCError 11 | 12 | 13 | @pytest.mark.parametrize("base_version", ["1.0", "1.1"]) 14 | def test_when_full_request_and_response_is_defined_then_response_is_returned( 15 | base_version, 16 | netconf_server: NetconfServer, 17 | ): 18 | # GIVEN server base version 19 | netconf_server.base_version = base_version 20 | 21 | # GIVEN server request and response are defined 22 | netconf_server.expect_request( 23 | '' 24 | '' 25 | "" 26 | "" 27 | ).respond_with( 28 | """ 29 | 30 | 32 | 33 | 34 | 35 | eth0 36 | 37 | 38 | 39 | """ 40 | ) 41 | 42 | # WHEN fetching rpc response from server 43 | with manager.connect( 44 | host="localhost", 45 | port=8830, 46 | username="admin", 47 | password="admin", 48 | hostkey_verify=False, 49 | ) as m: 50 | response = m.get_config(source="running").data_xml 51 | 52 | # THEN expect response 53 | assert ( 54 | """ 55 | 56 | 57 | eth0 58 | 59 | 60 | """ 61 | in response 62 | ) 63 | 64 | 65 | @pytest.mark.parametrize("base_version", ["1.0", "1.1"]) 66 | def test_when_regex_request_and_response_is_defined_then_response_is_returned( 67 | base_version, 68 | netconf_server: NetconfServer, 69 | ): 70 | # GIVEN server base version 71 | netconf_server.base_version = base_version 72 | 73 | # GIVEN server request and response are defined 74 | netconf_server.expect_request( 75 | ".*.*" 76 | ).respond_with( 77 | """ 78 | 79 | 81 | 82 | 83 | 84 | eth0 85 | 86 | 87 | 88 | """ 89 | ) 90 | 91 | # WHEN fetching rpc response from server 92 | with manager.connect( 93 | host="localhost", 94 | port=8830, 95 | username="admin", 96 | password="admin", 97 | hostkey_verify=False, 98 | ) as m: 99 | response = m.get_config(source="running").data_xml 100 | 101 | # THEN expect response 102 | assert ( 103 | """ 104 | 105 | 106 | eth0 107 | 108 | 109 | """ 110 | in response 111 | ) 112 | 113 | 114 | def test_when_unexpected_request_received_then_error_response_is_returned( 115 | netconf_server: NetconfServer, 116 | ): 117 | # GIVEN no server request and response are defined 118 | netconf_server 119 | 120 | # WHEN fetching rpc response from server 121 | with pytest.raises(RPCError) as error: 122 | with manager.connect( 123 | host="localhost", 124 | port=8830, 125 | username="admin", 126 | password="admin", 127 | hostkey_verify=False, 128 | manager_params={"timeout": 10}, 129 | ) as m: 130 | m.foo(source="running") 131 | 132 | # THEN 133 | assert ( 134 | str(error.value) 135 | == "pytest-netconf: requested rpc is unknown and has no response defined" 136 | ) 137 | 138 | 139 | def test_when_server_stops_then_client_error_is_raised( 140 | netconf_server: NetconfServer, 141 | ): 142 | # GIVEN netconf connection 143 | with pytest.raises(TransportError) as error: 144 | with manager.connect( 145 | host="localhost", 146 | port=8830, 147 | username="admin", 148 | password="admin", 149 | hostkey_verify=False, 150 | ) as m: 151 | pass 152 | # WHEN server stops 153 | netconf_server.stop() 154 | 155 | # THEN expect error 156 | assert error 157 | 158 | 159 | @pytest.mark.parametrize("base_version", ["1.0", "1.1"]) 160 | def test_when_defining_custom_capabilities_then_server_returns_them( 161 | base_version, 162 | netconf_server: NetconfServer, 163 | ): 164 | # GIVEN server version 165 | netconf_server.base_version = base_version 166 | 167 | # GIVEN extra capabilities 168 | netconf_server.capabilities.append("urn:ietf:params:netconf:capability:foo:1.1") 169 | netconf_server.capabilities.append("urn:ietf:params:netconf:capability:bar:1.1") 170 | 171 | # WHEN receiving server capabilities connection to server 172 | with manager.connect( 173 | host="localhost", 174 | port=8830, 175 | username="admin", 176 | password="admin", 177 | hostkey_verify=False, 178 | ) as m: 179 | server_capabilities = m.server_capabilities 180 | 181 | # THEN expect to see capabilities 182 | assert f"urn:ietf:params:netconf:base:{base_version}" in server_capabilities 183 | assert "urn:ietf:params:netconf:capability:foo:1.1" in server_capabilities 184 | assert "urn:ietf:params:netconf:capability:bar:1.1" in server_capabilities 185 | 186 | 187 | def test_when_connecting_using_no_username_or_password_then_authentication_passes( 188 | netconf_server: NetconfServer, 189 | ): 190 | # GIVEN no username and password have been defined 191 | netconf_server.username = None 192 | netconf_server.password = None 193 | 194 | # WHEN connecting using random credentials 195 | with manager.connect( 196 | host="localhost", 197 | port=8830, 198 | username="foo", 199 | password="bar", 200 | hostkey_verify=False, 201 | ) as m: 202 | # THEN expect to be connected 203 | assert m.connected 204 | 205 | 206 | def test_when_connecting_using_username_and_password_then_authentication_passes( 207 | netconf_server: NetconfServer, 208 | ): 209 | # GIVEN username and password have been defined 210 | netconf_server.username = "admin" 211 | netconf_server.password = "password" 212 | 213 | # WHEN connecting using correct credentials 214 | with manager.connect( 215 | host="localhost", 216 | port=8830, 217 | username="admin", 218 | password="password", 219 | hostkey_verify=False, 220 | ) as m: 221 | # THEN expect to be connected 222 | assert m.connected 223 | 224 | 225 | def test_when_connecting_using_username_and_password_then_authentication_fails( 226 | netconf_server: NetconfServer, 227 | ): 228 | # GIVEN username and password have been defined 229 | netconf_server.username = "admin" 230 | netconf_server.password = "password" 231 | 232 | # WHEN connecting using wrong credentials 233 | with pytest.raises(AuthenticationError) as error: 234 | with manager.connect( 235 | host="localhost", 236 | port=8830, 237 | username="foo", 238 | password="bar", 239 | hostkey_verify=False, 240 | ): 241 | ... 242 | 243 | # THEN expect error 244 | assert error 245 | 246 | 247 | def test_when_connecting_using_username_and_rsa_key_then_authentication_passes( 248 | netconf_server, tmp_path 249 | ): 250 | # GIVEN generated key 251 | key_filepath = (tmp_path / "key").as_posix() 252 | key = paramiko.RSAKey.generate(bits=2048) 253 | key.write_private_key_file(key_filepath) 254 | 255 | # GIVEN SSH username and key have been defined 256 | netconf_server.username = "admin" 257 | netconf_server.authorized_key = f"{key.get_name()} {key.get_base64()}" 258 | 259 | # WHEN connecting using key credentials 260 | with manager.connect( 261 | host="localhost", 262 | port=8830, 263 | username="admin", 264 | key_filename=key_filepath, 265 | hostkey_verify=False, 266 | ) as m: 267 | # THEN expect to be connected 268 | assert m.connected 269 | 270 | 271 | def test_when_connecting_using_username_and_wrong_key_then_authentication_fails( 272 | netconf_server, tmp_path 273 | ): 274 | # GIVEN generated key 275 | key_filepath = (tmp_path / "key").as_posix() 276 | key = paramiko.RSAKey.generate(bits=2048) 277 | key.write_private_key_file(key_filepath) 278 | 279 | # GIVEN SSH username and a different key have been defined 280 | netconf_server.username = "admin" 281 | netconf_server.authorized_key = f"foobar" 282 | 283 | # WHEN connecting using wrong key 284 | with pytest.raises(AuthenticationError) as error: 285 | with manager.connect( 286 | host="localhost", 287 | port=8830, 288 | username="foo", 289 | key_filename=key_filepath, 290 | hostkey_verify=False, 291 | ): 292 | ... 293 | 294 | # THEN expect error 295 | assert error 296 | 297 | 298 | def test_when_server_restarted_then_connection_passes(netconf_server: NetconfServer): 299 | # GIVEN initial connection to server 300 | with manager.connect( 301 | host="localhost", 302 | port=8830, 303 | username="admin", 304 | password="admin", 305 | hostkey_verify=False, 306 | ) as m: 307 | assert m.connected 308 | 309 | # WHEN server is stopped and then started again 310 | netconf_server.stop() 311 | netconf_server.start() 312 | 313 | # THEN expect reconnection to succeed 314 | with manager.connect( 315 | host="localhost", 316 | port=8830, 317 | username="admin", 318 | password="admin", 319 | hostkey_verify=False, 320 | ) as m: 321 | assert m.connected 322 | -------------------------------------------------------------------------------- /tests/integration/test_netconf_client.py: -------------------------------------------------------------------------------- 1 | import paramiko.ssh_exception 2 | import pytest 3 | import paramiko 4 | from pytest_netconf import NetconfServer 5 | 6 | from netconf_client.connect import connect_ssh 7 | from netconf_client.ncclient import Manager 8 | from netconf_client.error import RpcError 9 | 10 | 11 | @pytest.mark.parametrize("base_version", ["1.0", "1.1"]) 12 | def test_when_full_request_and_response_is_defined_then_response_is_returned( 13 | base_version, 14 | netconf_server: NetconfServer, 15 | ): 16 | # GIVEN server base version 17 | netconf_server.base_version = base_version 18 | 19 | # GIVEN server request and response are defined 20 | netconf_server.expect_request( 21 | '' 22 | '' 23 | "" 24 | ).respond_with( 25 | """ 26 | 27 | 29 | 30 | 31 | 32 | eth0 33 | 34 | 35 | 36 | """ 37 | ) 38 | 39 | # WHEN fetching rpc response from server 40 | with connect_ssh( 41 | host="localhost", 42 | port=8830, 43 | username="admin", 44 | password="admin", 45 | ) as session: 46 | manager = Manager(session=session) 47 | response = manager.get_config(source="running").data_xml 48 | 49 | # THEN expect response 50 | assert ( 51 | """ 52 | 53 | 54 | eth0 55 | 56 | 57 | """.strip( 58 | "\n" 59 | ) 60 | in response.decode() 61 | ) 62 | 63 | 64 | @pytest.mark.parametrize("base_version", ["1.0", "1.1"]) 65 | def test_when_regex_request_and_response_is_defined_then_response_is_returned( 66 | base_version, 67 | netconf_server: NetconfServer, 68 | ): 69 | # GIVEN server base version 70 | netconf_server.base_version = base_version 71 | 72 | # GIVEN server request and response are defined 73 | netconf_server.expect_request("get-config").respond_with( 74 | """ 75 | 76 | 78 | 79 | 80 | 81 | eth0 82 | 83 | 84 | 85 | """ 86 | ) 87 | 88 | # WHEN fetching rpc response from server 89 | with connect_ssh( 90 | host="localhost", 91 | port=8830, 92 | username="admin", 93 | password="admin", 94 | ) as session: 95 | manager = Manager(session=session) 96 | response = manager.get_config(source="running").data_xml 97 | 98 | # THEN expect response 99 | # THEN expect response 100 | assert ( 101 | """ 102 | 103 | 104 | eth0 105 | 106 | 107 | """.strip( 108 | "\n" 109 | ) 110 | in response.decode() 111 | ) 112 | 113 | 114 | def test_when_unexpected_request_received_then_error_response_is_returned( 115 | netconf_server: NetconfServer, 116 | ): 117 | # GIVEN no server request and response are defined 118 | netconf_server 119 | 120 | # WHEN fetching rpc response from server 121 | with pytest.raises(RpcError) as error: 122 | with connect_ssh( 123 | host="localhost", 124 | port=8830, 125 | username="admin", 126 | password="admin", 127 | ) as session: 128 | Manager(session=session).get_config(source="running") 129 | 130 | # THEN expect error response 131 | assert ( 132 | str(error.value) 133 | == "pytest-netconf: requested rpc is unknown and has no response defined" 134 | ) 135 | 136 | 137 | def test_when_server_stops_then_client_error_is_raised( 138 | netconf_server: NetconfServer, 139 | ): 140 | # GIVEN netconf connection 141 | with pytest.raises(OSError) as error: 142 | with connect_ssh( 143 | host="localhost", 144 | port=8830, 145 | username="admin", 146 | password="admin", 147 | general_timeout=5, 148 | ) as session: 149 | manager = Manager(session=session) 150 | 151 | # WHEN server stops 152 | netconf_server.stop() 153 | manager.get_config() # and a request is attempted 154 | session.session_id # needed to probe session 155 | 156 | # THEN expect error 157 | assert str(error.value) == "Socket is closed" 158 | 159 | 160 | @pytest.mark.parametrize("base_version", ["1.0", "1.1"]) 161 | def test_when_defining_custom_capabilities_then_server_returns_them( 162 | base_version, 163 | netconf_server: NetconfServer, 164 | ): 165 | # GIVEN server version 166 | netconf_server.base_version = base_version 167 | 168 | # GIVEN extra capabilities 169 | netconf_server.capabilities.append("urn:ietf:params:netconf:capability:foo:1.1") 170 | netconf_server.capabilities.append("urn:ietf:params:netconf:capability:bar:1.1") 171 | 172 | # WHEN receiving server capabilities connection to server 173 | with connect_ssh( 174 | host="localhost", 175 | port=8830, 176 | username="admin", 177 | password="admin", 178 | ) as session: 179 | server_capabilities = session.server_capabilities 180 | 181 | # THEN expect to see capabilities 182 | assert f"urn:ietf:params:netconf:base:{base_version}" in server_capabilities 183 | assert "urn:ietf:params:netconf:capability:foo:1.1" in server_capabilities 184 | assert "urn:ietf:params:netconf:capability:bar:1.1" in server_capabilities 185 | 186 | 187 | def test_when_connecting_using_no_username_or_password_then_authentication_passes( 188 | netconf_server: NetconfServer, 189 | ): 190 | # GIVEN no username and password have been defined 191 | netconf_server 192 | netconf_server.username = None 193 | netconf_server.password = None 194 | 195 | # WHEN connecting using random credentials 196 | with connect_ssh( 197 | host="localhost", 198 | port=8830, 199 | username="foo", 200 | password="bar", 201 | ) as session: 202 | # THEN expect to be connected 203 | assert session.session_id 204 | 205 | 206 | def test_when_connecting_using_username_and_password_then_authentication_passes( 207 | netconf_server: NetconfServer, 208 | ): 209 | # GIVEN username and password have been defined 210 | netconf_server.username = "admin" 211 | netconf_server.password = "password" 212 | 213 | # WHEN connecting using correct credentials 214 | with connect_ssh( 215 | host="localhost", 216 | port=8830, 217 | username="admin", 218 | password="password", 219 | ) as session: 220 | # THEN expect to be connected 221 | assert session.session_id 222 | 223 | 224 | def test_when_connecting_using_username_and_password_then_authentication_fails( 225 | netconf_server: NetconfServer, 226 | ): 227 | # GIVEN username and password have been defined 228 | netconf_server.username = "admin" 229 | netconf_server.password = "password" 230 | 231 | # WHEN connecting using wrong credentials 232 | with pytest.raises(paramiko.ssh_exception.AuthenticationException) as error: 233 | with connect_ssh( 234 | host="localhost", 235 | port=8830, 236 | username="foo", 237 | password="bar", 238 | ) as session: 239 | Manager(session=session) 240 | 241 | # THEN expect error 242 | assert "Authentication failed." in str(error) 243 | 244 | 245 | def test_when_connecting_using_username_and_rsa_key_then_authentication_passes( 246 | netconf_server, tmp_path 247 | ): 248 | # GIVEN generated key 249 | key_filepath = (tmp_path / "key").as_posix() 250 | key = paramiko.RSAKey.generate(bits=2048) 251 | key.write_private_key_file(key_filepath) 252 | 253 | # GIVEN SSH username and key have been defined 254 | netconf_server.username = "admin" 255 | netconf_server.authorized_key = f"{key.get_name()} {key.get_base64()}" 256 | 257 | # WHEN connecting using key credentials 258 | with connect_ssh( 259 | host="localhost", 260 | port=8830, 261 | username="admin", 262 | password=None, 263 | key_filename=key_filepath, 264 | ) as session: 265 | # THEN expect to be connected 266 | assert session.session_id 267 | 268 | def test_when_connecting_using_username_and_wrong_key_then_authentication_fails( 269 | netconf_server, tmp_path 270 | ): 271 | # GIVEN generated key 272 | key_filepath = (tmp_path / "key").as_posix() 273 | key = paramiko.RSAKey.generate(bits=2048) 274 | key.write_private_key_file(key_filepath) 275 | 276 | # GIVEN SSH username and a different key have been defined 277 | netconf_server.username = "admin" 278 | netconf_server.authorized_key = f"foobar" 279 | 280 | # WHEN connecting using wrong key 281 | with pytest.raises(paramiko.ssh_exception.AuthenticationException) as error: 282 | with connect_ssh( 283 | host="localhost", 284 | port=8830, 285 | username="foo", 286 | password=None, 287 | key_filename=key_filepath, 288 | ) as session: 289 | Manager(session=session) 290 | 291 | # THEN expect error 292 | assert "Authentication failed." in str(error) 293 | -------------------------------------------------------------------------------- /tests/integration/test_scrapli_netconf.py: -------------------------------------------------------------------------------- 1 | from lxml import etree 2 | import pytest 3 | import paramiko 4 | from pytest_netconf import NetconfServer 5 | 6 | from scrapli_netconf.driver import NetconfDriver 7 | from scrapli.exceptions import ScrapliConnectionError 8 | 9 | 10 | @pytest.mark.parametrize("base_version", ["1.0", "1.1"]) 11 | def test_when_full_request_and_response_is_defined_then_response_is_returned( 12 | base_version, 13 | netconf_server: NetconfServer, 14 | ): 15 | # GIVEN server base version 16 | netconf_server.base_version = base_version 17 | 18 | # GIVEN server request and response are defined 19 | netconf_server.expect_request( 20 | "\n" # scrapli seems to send new line for 1.0 21 | if base_version == "1.0" 22 | else "" 23 | "\n" 24 | '' 25 | "" 26 | "" 27 | ).respond_with( 28 | """ 29 | 30 | 32 | 33 | 34 | 35 | eth0 36 | 37 | 38 | 39 | """ 40 | ) 41 | 42 | # WHEN fetching rpc response from server 43 | with NetconfDriver( 44 | host="localhost", 45 | port=8830, 46 | auth_username="admin", 47 | auth_password="admin", 48 | auth_strict_key=False, 49 | strip_namespaces=True, 50 | ) as conn: 51 | response = conn.get_config(source="running").xml_result 52 | 53 | # THEN expect response 54 | assert ( 55 | """ 56 | 57 | eth0 58 | 59 | 60 | """ 61 | == etree.tostring( 62 | response.find(".//data/"), 63 | pretty_print=True, 64 | ).decode() 65 | ) 66 | 67 | 68 | @pytest.mark.parametrize("base_version", ["1.0", "1.1"]) 69 | def test_when_regex_request_and_response_is_defined_then_response_is_returned( 70 | base_version, 71 | netconf_server: NetconfServer, 72 | ): 73 | # GIVEN server base version 74 | netconf_server.base_version = base_version 75 | 76 | # GIVEN server request and response are defined 77 | netconf_server.expect_request("get-config").respond_with( 78 | """ 79 | 80 | 82 | 83 | 84 | 85 | eth0 86 | 87 | 88 | 89 | """ 90 | ) 91 | 92 | # WHEN fetching rpc response from server 93 | with NetconfDriver( 94 | host="localhost", 95 | port=8830, 96 | auth_username="admin", 97 | auth_password="admin", 98 | auth_strict_key=False, 99 | strip_namespaces=True, 100 | ) as conn: 101 | response = conn.get_config(source="running").xml_result 102 | 103 | # THEN expect response 104 | assert ( 105 | """ 106 | 107 | eth0 108 | 109 | 110 | """ 111 | == etree.tostring( 112 | response.find(".//data/"), 113 | pretty_print=True, 114 | ).decode() 115 | ) 116 | 117 | 118 | def test_when_unexpected_request_received_then_error_response_is_returned( 119 | netconf_server: NetconfServer, 120 | ): 121 | # GIVEN no server request and response are defined 122 | netconf_server 123 | 124 | # WHEN fetching rpc response from server 125 | with NetconfDriver( 126 | host="localhost", 127 | port=8830, 128 | auth_username="admin", 129 | auth_password="admin", 130 | auth_strict_key=False, 131 | timeout_ops=5, 132 | ) as conn: 133 | response = conn.get_config(source="running").result 134 | 135 | # THEN expect error response 136 | assert ( 137 | response 138 | == """ 139 | 140 | rpc 141 | operation-failed 142 | error 143 | pytest-netconf: requested rpc is unknown and has no response defined 144 | 145 | 146 | """ 147 | ) 148 | 149 | 150 | def test_when_server_stops_then_client_error_is_raised( 151 | netconf_server: NetconfServer, 152 | ): 153 | # GIVEN netconf connection 154 | with pytest.raises(ScrapliConnectionError) as error: 155 | with NetconfDriver( 156 | host="localhost", 157 | port=8830, 158 | auth_username="admin", 159 | auth_password="admin", 160 | auth_strict_key=False, 161 | ) as conn: 162 | # WHEN server stops 163 | netconf_server.stop() 164 | conn.get_config() # and a request is attempted 165 | 166 | # THEN expect error 167 | assert ( 168 | str(error.value) 169 | == "encountered EOF reading from transport; typically means the device closed the connection" 170 | ) 171 | 172 | 173 | @pytest.mark.parametrize("base_version", ["1.0", "1.1"]) 174 | def test_when_defining_custom_capabilities_then_server_returns_them( 175 | base_version, 176 | netconf_server: NetconfServer, 177 | ): 178 | # GIVEN server version 179 | netconf_server.base_version = base_version 180 | 181 | # GIVEN extra capabilities 182 | netconf_server.capabilities.append("urn:ietf:params:netconf:capability:foo:1.1") 183 | netconf_server.capabilities.append("urn:ietf:params:netconf:capability:bar:1.1") 184 | 185 | # WHEN receiving server capabilities connection to server 186 | with NetconfDriver( 187 | host="localhost", 188 | port=8830, 189 | auth_username="admin", 190 | auth_password="admin", 191 | auth_strict_key=False, 192 | ) as conn: 193 | server_capabilities = conn.server_capabilities 194 | 195 | # THEN expect to see capabilities 196 | assert f"urn:ietf:params:netconf:base:{base_version}" in server_capabilities 197 | assert "urn:ietf:params:netconf:capability:foo:1.1" in server_capabilities 198 | assert "urn:ietf:params:netconf:capability:bar:1.1" in server_capabilities 199 | 200 | 201 | def test_when_connecting_using_no_username_or_password_then_authentication_passes( 202 | netconf_server: NetconfServer, 203 | ): 204 | # GIVEN no username and password have been defined 205 | netconf_server.username = None 206 | netconf_server.password = None 207 | 208 | # WHEN connecting using random credentials 209 | with NetconfDriver( 210 | host="localhost", 211 | port=8830, 212 | auth_username="foo", 213 | auth_password="bar", 214 | auth_strict_key=False, 215 | ) as conn: 216 | # THEN expect to be connected 217 | assert conn.isalive() 218 | 219 | 220 | def test_when_connecting_using_username_and_password_then_authentication_passes( 221 | netconf_server: NetconfServer, 222 | ): 223 | # GIVEN username and password have been defined 224 | netconf_server.username = "admin" 225 | netconf_server.password = "password" 226 | 227 | # WHEN connecting using correct credentials 228 | with NetconfDriver( 229 | host="localhost", 230 | port=8830, 231 | auth_username="admin", 232 | auth_password="password", 233 | auth_strict_key=False, 234 | ) as conn: 235 | # THEN expect to be connected 236 | assert conn.isalive() 237 | 238 | 239 | def test_when_connecting_using_username_and_password_then_authentication_fails( 240 | netconf_server: NetconfServer, 241 | ): 242 | # GIVEN username and password have been defined 243 | netconf_server.username = "admin" 244 | netconf_server.password = "password" 245 | 246 | # WHEN connecting using wrong credentials 247 | with pytest.raises(ScrapliConnectionError) as error: 248 | with NetconfDriver( 249 | host="localhost", 250 | port=8830, 251 | auth_username="foo", 252 | auth_password="bar", 253 | auth_strict_key=False, 254 | ): 255 | pass 256 | 257 | # THEN expect error 258 | assert "permission denied, please try again." in str(error) 259 | 260 | 261 | def test_when_connecting_using_username_and_rsa_key_then_authentication_passes( 262 | netconf_server, tmp_path 263 | ): 264 | # GIVEN generated key 265 | key_filepath = (tmp_path / "key").as_posix() 266 | key = paramiko.RSAKey.generate(bits=2048) 267 | key.write_private_key_file(key_filepath) 268 | 269 | # GIVEN SSH username and key have been defined 270 | netconf_server.username = "admin" 271 | netconf_server.authorized_key = f"{key.get_name()} {key.get_base64()}" 272 | 273 | # WHEN connecting using key credentials 274 | with NetconfDriver( 275 | host="localhost", 276 | port=8830, 277 | auth_username="admin", 278 | auth_private_key=key_filepath, 279 | auth_strict_key=False, 280 | ) as conn: 281 | # THEN expect to be connected 282 | assert conn.isalive() 283 | 284 | def test_when_connecting_using_username_and_wrong_key_then_authentication_fails( 285 | netconf_server, tmp_path 286 | ): 287 | # GIVEN generated key 288 | key_filepath = (tmp_path / "key").as_posix() 289 | key = paramiko.RSAKey.generate(bits=2048) 290 | key.write_private_key_file(key_filepath) 291 | 292 | # GIVEN SSH username and a different key have been defined 293 | netconf_server.username = "admin" 294 | netconf_server.authorized_key = f"foobar" 295 | 296 | # WHEN connecting using wrong key 297 | with pytest.raises(ScrapliConnectionError) as error: 298 | with NetconfDriver( 299 | host="localhost", 300 | port=8830, 301 | auth_username="foo", 302 | auth_private_key=key_filepath, 303 | auth_strict_key=False, 304 | ): 305 | pass 306 | 307 | # THEN expect error 308 | assert "permission denied, please try again." in str(error) 309 | -------------------------------------------------------------------------------- /tests/unit/test_netconfserver.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from unittest.mock import patch, MagicMock 3 | 4 | from pytest_netconf.netconfserver import NetconfServer 5 | from pytest_netconf.exceptions import RequestError 6 | 7 | 8 | @pytest.mark.parametrize( 9 | "prop_name,prop_value", 10 | [ 11 | ("base_version", "1.1"), 12 | ("host", "localhost"), 13 | ("port", 1234), 14 | ("username", "foo"), 15 | ("password", "bar"), 16 | ("authorized_key", "specialkey"), 17 | ], 18 | ) 19 | def test_when_setting_server_settings_then_value_is_returned(prop_name, prop_value): 20 | # GIVEN netconf server instance 21 | nc = NetconfServer() 22 | 23 | # GIVEN settings property has been set 24 | setattr(nc, prop_name, prop_value) 25 | 26 | # WHEN accessing property 27 | val = getattr(nc, prop_name) 28 | 29 | # THEN expect value 30 | assert val == prop_value 31 | 32 | # THEN expect internal settings instance to also match 33 | assert getattr(nc.settings, prop_name) == prop_value 34 | 35 | 36 | def test_when_setting_invalid_server_base_version_then_error_is_raised(): 37 | # GIVEN netconf server instance 38 | nc = NetconfServer() 39 | 40 | # WHEN setting invalid base version 41 | with pytest.raises(ValueError) as error: 42 | nc.base_version = "99" 43 | 44 | # THEN expect error 45 | assert str(error.value) == "Invalid NETCONF base version 99: must be '1.0' or '1.1'" 46 | 47 | 48 | @patch("socket.socket", autospec=True) 49 | def test_when_server_bind_port_in_use_error_is_raised(mock_socket): 50 | # GIVEN socket raises error 51 | mock_socket.side_effect = OSError(48, "Address already in use") 52 | 53 | # GIVEN netconf server instance 54 | nc = NetconfServer() 55 | nc.port = 8830 56 | 57 | # WHEN calling bind socket 58 | with pytest.raises(OSError) as error: 59 | nc._bind_socket() 60 | 61 | # THEN expect error 62 | assert str(error.value) == "could not bind to port 8830" 63 | 64 | 65 | @patch("socket.socket", autospec=True) 66 | def test_when_server_bind_generic_error_then_error_is_raised(mock_socket): 67 | # GIVEN socket raises error 68 | mock_socket.side_effect = OSError(13, "Permission denied") 69 | 70 | # GIVEN netconf server instance 71 | nc = NetconfServer() 72 | nc.port = 8830 73 | 74 | # WHEN calling bind socket 75 | with pytest.raises(OSError) as error: 76 | nc._bind_socket() 77 | 78 | # THEN expect error 79 | assert str(error.value) == "[Errno 13] Permission denied" 80 | 81 | 82 | def test_when_handle_request_has_unknown_error_then_error_is_raised(): 83 | # GIVEN netconf server instance which is running 84 | nc = NetconfServer() 85 | nc.running = True 86 | 87 | # GIVEN patched function that raises error 88 | nc._process_buffer = MagicMock(side_effect=RuntimeError("foo")) 89 | 90 | # WHEN calling handle requests 91 | with pytest.raises(RequestError) as error: 92 | nc._handle_requests(MagicMock()) 93 | 94 | # THEN expect our error to pass through 95 | assert str(error.value) == "failed to handle request: foo" 96 | 97 | 98 | def test_when_process_buffer_receives_base11_missing_size_then_false_is_returned( 99 | caplog, 100 | ): 101 | # GIVEN netconf server instance which is running 102 | nc = NetconfServer() 103 | nc.running = True 104 | nc._hello_sent = True 105 | nc.base_version = "1.1" 106 | 107 | # WHEN calling process buffer 108 | result = nc._process_buffer(buffer=b"999\nfoo\n##\n", channel=MagicMock()) 109 | 110 | # THEN expect result to be false 111 | assert result is False 112 | 113 | # THEN expect log message 114 | assert "parse error: Invalid content or chunk size format" in caplog.text 115 | 116 | 117 | def test_when_extract_base11_invalid_length_then_error_is_raised( 118 | caplog, 119 | ): 120 | # GIVEN netconf server instance which is running 121 | nc = NetconfServer() 122 | 123 | # WHEN calling extract method 124 | with pytest.raises(ValueError) as error: 125 | nc._extract_base11_content_and_length("#999\nfoobar\n##\n") 126 | 127 | # THEN expect error 128 | assert str(error.value) == "received invalid chunk size expected=6 received=999" 129 | 130 | 131 | @pytest.mark.parametrize( 132 | "test_input,expected", 133 | [ 134 | ( 135 | """ 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | """, 144 | "101", 145 | ), 146 | ( 147 | """ 148 | 149 | 150 | 151 | 152 | 153 | 154 | 155 | """, 156 | "unknown", 157 | ), 158 | ( 159 | """ 160 | <> 161 | """, 162 | "unknown", 163 | ), 164 | ], 165 | ids=["valid-101", "unknown-missing", "unknown-invalid"], 166 | ) 167 | def test_when_extract_message_id_then_string_is_returned(test_input, expected): 168 | # GIVEN input rpc 169 | request = test_input 170 | 171 | # GIVEN netconf server instance which is running 172 | nc = NetconfServer() 173 | 174 | # WHEN extracting message id 175 | message_id = nc._extract_message_id(request) 176 | 177 | # THEN expect result 178 | assert message_id == expected 179 | --------------------------------------------------------------------------------