├── .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 | 
4 | [](https://codecov.io/gh/nomios-opensource/pytest-netconf)
5 | 
6 | 
7 | 
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 |
--------------------------------------------------------------------------------