From d63bbe75917d7ace5673e58103f3340309210a01 Mon Sep 17 00:00:00 2001 From: Alex Dubois Date: Thu, 30 Jul 2026 13:00:13 -0500 Subject: [PATCH 1/5] Add gRPC Channel Creation to pypi-nitlsconfig --- .github/ISSUE_TEMPLATE/bug_report.md | 2 +- .github/workflows/check_module.yml | 4 +- .github/workflows/publish.yml | 2 +- .github/workflows/run_unit_tests.yml | 39 +- CONTRIBUTING.md | 2 +- README.md | 49 ++- docs/index.rst | 4 +- poetry.lock | 508 ++++++++++++++++++++++++++- pyproject.toml | 16 +- src/nitlsconfig/__init__.py | 55 ++- src/nitlsconfig/cli.py | 19 + src/nitlsconfig/grpc_channel.py | 338 ++++++++++++++++++ tests/unit/certificates.py | 94 +++++ tests/unit/fake_config.py | 34 ++ tests/unit/test_grpc_channel.py | 370 +++++++++++++++++++ tests/unit/test_grpc_channel_real.py | 112 ++++++ tests/unit/test_grpc_channel_tls.py | 159 +++++++++ tests/unit/test_nitlsconfig.py | 42 ++- tests/unit/test_package_exports.py | 49 +++ 19 files changed, 1878 insertions(+), 20 deletions(-) create mode 100644 src/nitlsconfig/grpc_channel.py create mode 100644 tests/unit/certificates.py create mode 100644 tests/unit/fake_config.py create mode 100644 tests/unit/test_grpc_channel.py create mode 100644 tests/unit/test_grpc_channel_real.py create mode 100644 tests/unit/test_grpc_channel_tls.py create mode 100644 tests/unit/test_package_exports.py diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index a78e6cd..c5fef37 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -43,5 +43,5 @@ Search open/closed issues before submitting. Someone may have reported the same -* `pypi-nitlsconfig` version +* `nitlsconfig` version * Python version \ No newline at end of file diff --git a/.github/workflows/check_module.yml b/.github/workflows/check_module.yml index f278d8e..7850ecb 100644 --- a/.github/workflows/check_module.yml +++ b/.github/workflows/check_module.yml @@ -10,7 +10,7 @@ jobs: strategy: matrix: os: [windows-latest, ubuntu-latest, macos-latest] - python-version: ["3.10", 3.13, 3.14] + python-version: ["3.9", "3.10", 3.13, 3.14] runs-on: ${{ matrix.os }} steps: - name: Check out repo @@ -25,6 +25,6 @@ jobs: - name: Analyze Python Project uses: ni/python-actions/analyze-project@aa64e60612cb078b0c2ada666becbd70d4817d55 # 0.7.1 with: - install-args: "--with test --with lint" + install-args: "--with test --with lint --all-extras" - name: Bandit security checks run: poetry run bandit -c pyproject.toml -r src/nitlsconfig diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 15e4813..19179f7 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -66,7 +66,7 @@ jobs: environment: # This logic is duplicated because `name` doesn't support the `env` context. name: ${{ github.event_name == 'release' && 'pypi' || inputs.environment }} - url: ${{ fromJson(env.environment-info)[env.environment].base-url }}/p/pypi-nitlsconfig + url: ${{ fromJson(env.environment-info)[env.environment].base-url }}/p/nitlsconfig permissions: id-token: write steps: diff --git a/.github/workflows/run_unit_tests.yml b/.github/workflows/run_unit_tests.yml index 08f7179..d1c492a 100644 --- a/.github/workflows/run_unit_tests.yml +++ b/.github/workflows/run_unit_tests.yml @@ -11,7 +11,7 @@ jobs: strategy: matrix: os: [windows-latest, ubuntu-latest] - python-version: ["3.10", 3.11, 3.12, 3.13, 3.14] + python-version: ["3.9", "3.10", 3.11, 3.12, 3.13, 3.14] # Fail-fast skews the pass/fail ratio and seems to make pytest produce # incomplete JUnit XML results. fail-fast: false @@ -31,7 +31,7 @@ jobs: path: .venv key: nitlsconfig-${{ runner.os }}-py${{ steps.setup-python.outputs.python-version }}-${{ hashFiles('poetry.lock') }} - name: Install nitlsconfig - run: poetry install -v + run: poetry install -v --all-extras - name: Run unit tests and code coverage run: poetry run pytest ./tests/unit -v --cov=nitlsconfig --junitxml=test_results/nitlsconfig-${{ matrix.os }}-py${{ matrix.python-version }}.xml - name: Upload test results @@ -40,3 +40,38 @@ jobs: name: test_results_unit_${{ matrix.os }}_py${{ matrix.python-version }} path: ./test_results/*.xml if: always() + + check_install_without_extras: + name: Check install without extras + # grpcio is an optional extra. Without this job, nothing stops a top-level + # "import grpc" from creeping into the config-reading code and silently + # making the extra mandatory again. + runs-on: ubuntu-latest + steps: + - name: Check out repo + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Set up Python + uses: ni/python-actions/setup-python@aa64e60612cb078b0c2ada666becbd70d4817d55 # 0.7.1 + with: + python-version: "3.13" + - name: Install nitlsconfig without extras + run: pip install . + - name: Verify grpcio is absent + run: | + if pip show grpcio > /dev/null 2>&1; then + echo "grpcio was installed without the grpc extra" + exit 1 + fi + - name: Verify the package imports and fails helpfully + run: | + python - <<'PY' + import nitlsconfig + + assert nitlsconfig.ClientConfig is not None + try: + nitlsconfig.create_client_channel + except ImportError as exc: + assert "pip install nitlsconfig[grpc]" in str(exc), exc + else: + raise AssertionError("expected ImportError without the grpc extra") + PY diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index df44aa7..93c5b66 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -67,7 +67,7 @@ in the pypi-nitlsconfig repo. Here are the steps to follow to publish the packag 1. From the main GitHub repo page, select "Create a new release". 2. On the "New Release" page, create a new tag using the "Select Tag" drop down. The tag must be the package version, matching the value found in pyproject.toml. Example: `1.0.0`. -3. Enter a title in the "Release title" field. The title should contain the package name and version in the format `pypi-nitlsconfig `. For example: `pypi-nitlsconfig 1.0.0`. +3. Enter a title in the "Release title" field. The title should contain the package name and version in the format `nitlsconfig `. For example: `nitlsconfig 1.0.0`. 4. Click "Generate release notes" and edit the release notes. - Delete entries for PRs that do not affect users, such as "chore(deps):" and "fix(deps):" PRs. - Consider grouping related entries. diff --git a/README.md b/README.md index 6b05272..bd98222 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,10 @@ -# pypi-nitlsconfig +# nitlsconfig -Python API that reads nitlsconfig configurations through `nitlsconfig` command line. +Python API that reads nitlsconfig configurations through the `nitlsconfig` command line, +and builds gRPC client channels from them. + +Installed and imported as `nitlsconfig`; developed at +[ni/pypi-nitlsconfig](https://github.com/ni/pypi-nitlsconfig). ## Runtime dependencies @@ -8,9 +12,46 @@ Python API that reads nitlsconfig configurations through `nitlsconfig` command l ## Install -- pip install nitlsconfig +Reading NI-TLS configuration is pure Python and has no third-party dependencies: + +- `pip install nitlsconfig` + +The gRPC channel factory additionally needs grpcio, which is an optional extra: + +- `pip install nitlsconfig[grpc]` + +## Creating a gRPC channel + +`create_client_channel` reads the local NI-TLS client configuration and returns a +`grpc.Channel` secured accordingly. Pass it straight to any NI gRPC Python API: + +```python +import nidcpower +import nitlsconfig + +with nitlsconfig.create_client_channel("localhost", 31763) as channel: + options = nidcpower.GrpcSessionOptions(channel, "") + with nidcpower.Session("Dev1", grpc_options=options) as session: + ... +``` + +The channel is mutually authenticated, one-way TLS, or insecure depending on how +the machine is configured; no code change is needed to move between them. The +channel is owned by the caller - NI driver APIs never close it. + +Retries are opt-in: + +```python +channel = nitlsconfig.create_client_channel( + "localhost", 31763, retry_policy=nitlsconfig.RetryPolicy() +) +``` + +`TlsConfigurationError` is raised when TLS is enabled but the configuration is +unusable. Accessing any of these names without the `grpc` extra installed raises +`ImportError` telling you which extra to install. -## Usage +## Reading configurations ```python import nitlsconfig diff --git a/docs/index.rst b/docs/index.rst index 71ef5ff..4f99be7 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -1,5 +1,5 @@ -NI Panel Python API -=================== +nitlsconfig Python API +====================== .. toctree:: :maxdepth: 3 diff --git a/poetry.lock b/poetry.lock index ff89ccc..5f64c3d 100644 --- a/poetry.lock +++ b/poetry.lock @@ -303,6 +303,218 @@ files = [ {file = "certifi-2026.6.17.tar.gz", hash = "sha256:024c88eeec92ca068db80f02b8b07c9cef7b9fe261d1d535abfd5abd6f6af432"}, ] +[[package]] +name = "cffi" +version = "2.0.0" +description = "Foreign Function Interface for Python calling C code." +optional = false +python-versions = ">=3.9" +groups = ["test"] +markers = "python_version < \"3.11\" and python_version != \"3.10\" and platform_python_implementation != \"PyPy\"" +files = [ + {file = "cffi-2.0.0-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44"}, + {file = "cffi-2.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f73b96c41e3b2adedc34a7356e64c8eb96e03a3782b535e043a986276ce12a49"}, + {file = "cffi-2.0.0-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:53f77cbe57044e88bbd5ed26ac1d0514d2acf0591dd6bb02a3ae37f76811b80c"}, + {file = "cffi-2.0.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3e837e369566884707ddaf85fc1744b47575005c0a229de3327f8f9a20f4efeb"}, + {file = "cffi-2.0.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:5eda85d6d1879e692d546a078b44251cdd08dd1cfb98dfb77b670c97cee49ea0"}, + {file = "cffi-2.0.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9332088d75dc3241c702d852d4671613136d90fa6881da7d770a483fd05248b4"}, + {file = "cffi-2.0.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc7de24befaeae77ba923797c7c87834c73648a05a4bde34b3b7e5588973a453"}, + {file = "cffi-2.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cf364028c016c03078a23b503f02058f1814320a56ad535686f90565636a9495"}, + {file = "cffi-2.0.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e11e82b744887154b182fd3e7e8512418446501191994dbf9c9fc1f32cc8efd5"}, + {file = "cffi-2.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8ea985900c5c95ce9db1745f7933eeef5d314f0565b27625d9a10ec9881e1bfb"}, + {file = "cffi-2.0.0-cp310-cp310-win32.whl", hash = "sha256:1f72fb8906754ac8a2cc3f9f5aaa298070652a0ffae577e0ea9bd480dc3c931a"}, + {file = "cffi-2.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:b18a3ed7d5b3bd8d9ef7a8cb226502c6bf8308df1525e1cc676c3680e7176739"}, + {file = "cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe"}, + {file = "cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c"}, + {file = "cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92"}, + {file = "cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93"}, + {file = "cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5"}, + {file = "cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664"}, + {file = "cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26"}, + {file = "cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9"}, + {file = "cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414"}, + {file = "cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743"}, + {file = "cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5"}, + {file = "cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5"}, + {file = "cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d"}, + {file = "cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d"}, + {file = "cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c"}, + {file = "cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe"}, + {file = "cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062"}, + {file = "cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e"}, + {file = "cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037"}, + {file = "cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba"}, + {file = "cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94"}, + {file = "cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187"}, + {file = "cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18"}, + {file = "cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5"}, + {file = "cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6"}, + {file = "cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb"}, + {file = "cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca"}, + {file = "cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b"}, + {file = "cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b"}, + {file = "cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2"}, + {file = "cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3"}, + {file = "cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26"}, + {file = "cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c"}, + {file = "cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b"}, + {file = "cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27"}, + {file = "cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75"}, + {file = "cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91"}, + {file = "cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5"}, + {file = "cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13"}, + {file = "cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b"}, + {file = "cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c"}, + {file = "cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef"}, + {file = "cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775"}, + {file = "cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205"}, + {file = "cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1"}, + {file = "cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f"}, + {file = "cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25"}, + {file = "cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad"}, + {file = "cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9"}, + {file = "cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d"}, + {file = "cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c"}, + {file = "cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8"}, + {file = "cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc"}, + {file = "cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592"}, + {file = "cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512"}, + {file = "cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4"}, + {file = "cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e"}, + {file = "cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6"}, + {file = "cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9"}, + {file = "cffi-2.0.0-cp39-cp39-macosx_10_13_x86_64.whl", hash = "sha256:fe562eb1a64e67dd297ccc4f5addea2501664954f2692b69a76449ec7913ecbf"}, + {file = "cffi-2.0.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:de8dad4425a6ca6e4e5e297b27b5c824ecc7581910bf9aee86cb6835e6812aa7"}, + {file = "cffi-2.0.0-cp39-cp39-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:4647afc2f90d1ddd33441e5b0e85b16b12ddec4fca55f0d9671fef036ecca27c"}, + {file = "cffi-2.0.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3f4d46d8b35698056ec29bca21546e1551a205058ae1a181d871e278b0b28165"}, + {file = "cffi-2.0.0-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e6e73b9e02893c764e7e8d5bb5ce277f1a009cd5243f8228f75f842bf937c534"}, + {file = "cffi-2.0.0-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:cb527a79772e5ef98fb1d700678fe031e353e765d1ca2d409c92263c6d43e09f"}, + {file = "cffi-2.0.0-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:61d028e90346df14fedc3d1e5441df818d095f3b87d286825dfcbd6459b7ef63"}, + {file = "cffi-2.0.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:0f6084a0ea23d05d20c3edcda20c3d006f9b6f3fefeac38f59262e10cef47ee2"}, + {file = "cffi-2.0.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:1cd13c99ce269b3ed80b417dcd591415d3372bcac067009b6e0f59c7d4015e65"}, + {file = "cffi-2.0.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:89472c9762729b5ae1ad974b777416bfda4ac5642423fa93bd57a09204712322"}, + {file = "cffi-2.0.0-cp39-cp39-win32.whl", hash = "sha256:2081580ebb843f759b9f617314a24ed5738c51d2aee65d31e02f6f7a2b97707a"}, + {file = "cffi-2.0.0-cp39-cp39-win_amd64.whl", hash = "sha256:b882b3df248017dba09d6b16defe9b5c407fe32fc7c65a9c69798e6175601be9"}, + {file = "cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529"}, +] + +[package.dependencies] +pycparser = {version = "*", markers = "implementation_name != \"PyPy\""} + +[[package]] +name = "cffi" +version = "2.1.0" +description = "Foreign Function Interface for Python calling C code." +optional = false +python-versions = ">=3.10" +groups = ["test"] +markers = "python_version >= \"3.10\" and platform_python_implementation != \"PyPy\"" +files = [ + {file = "cffi-2.1.0-cp310-cp310-macosx_10_15_x86_64.whl", hash = "sha256:b65f590ef2a44640f9a05dbb548a429b4ade77913ce683ac8b1480777658a6c0"}, + {file = "cffi-2.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:164bff1657b2a74f0b6d54e11c9b375bc97b931f2ca9c43fcf875838da1570dd"}, + {file = "cffi-2.1.0-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:c941bb58d5a6e1c3892d86e42927ed6c180302f07e6d395d08c416e594b98b46"}, + {file = "cffi-2.1.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a016194dbe13d14ee9556e734b772d8d67b947092b268d757fd4290e3ba2dfc2"}, + {file = "cffi-2.1.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:03e9810d18c646077e501f661b682fbf5dee4676048527ca3cffe66faa9960dd"}, + {file = "cffi-2.1.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:19c54ac121cad98450b4896fa9a43ee0180d57bc4bc911a33db6cab1efab6cd3"}, + {file = "cffi-2.1.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4d433a51f1870e43a13b6732f92aaf540ff77c2015097c78556f75a2d6c030e0"}, + {file = "cffi-2.1.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:3d7f118b5adbfdfead90c25822690b02bc8074fba949bb7858bec4ebd55adb43"}, + {file = "cffi-2.1.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:c5f5df567f6eb216de69be06ce55c8b714090fae02b18a3b40da8163b8c5fa9c"}, + {file = "cffi-2.1.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:11b3fb55f4f8ad92274ed26705f65d8f91457de71f5380061eb6d125a768fecd"}, + {file = "cffi-2.1.0-cp310-cp310-win32.whl", hash = "sha256:9d72af0cf10a76a600a9690078fe31c63b9588c8e86bf9fd353f713c84b5db0f"}, + {file = "cffi-2.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:fb62edb5bb52cca65fab91a63afa7561607120d26090a7e8fda6fb9f064726da"}, + {file = "cffi-2.1.0-cp311-cp311-macosx_10_15_x86_64.whl", hash = "sha256:02cb7ff33ded4f1532476731f89ede53e2e488a8e6205515a82144246ffa7dcc"}, + {file = "cffi-2.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f5bce581e6b8c235e566a14768a943b172ada3ed73537bb0c0be1edee312d4e7"}, + {file = "cffi-2.1.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:30b65779d598c370374fefabf138d456fd6f3216bfa7bedfab1ba82025b0cd93"}, + {file = "cffi-2.1.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:88023dfe18799507b73f1dbb0d14326a17465de1bc9c9c7655c22845e9ddc3a2"}, + {file = "cffi-2.1.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:0a96b74cda968eebbad56d973efe5098974f0a9fb323865bf99ea1fd24e3e64c"}, + {file = "cffi-2.1.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a5781494d4d400a3f47f8f1da94b324f6e6b440a53387774002890a2a2f4b50f"}, + {file = "cffi-2.1.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aa7a1b53a2a4452ada2d1b5dade9960b2522f1e61293a811a077439e39029565"}, + {file = "cffi-2.1.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:9d8272c0e483b024e1b9ad029821470ed8ec65631dbd90217469da0e7cd89f1c"}, + {file = "cffi-2.1.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:7762faa47e8ff7eb80bd261d9a7d8eea2d8baa69de5e95b70c1f338bbe712f02"}, + {file = "cffi-2.1.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:89095c1968b4ba8285840e131bf2891b09ae137fe2146905acae0354fbce1b5e"}, + {file = "cffi-2.1.0-cp311-cp311-win32.whl", hash = "sha256:64c753a0f87a256020004f37a1c8c02c480e725f910f0b2a0f3f07debd1b2479"}, + {file = "cffi-2.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:4f26194e3d95e06501b942642855aed4f953d55e95d7d01b7c4483db3ecff458"}, + {file = "cffi-2.1.0-cp311-cp311-win_arm64.whl", hash = "sha256:35aaea0c7ee0e58a5cd8c2fd1a48fdf7ece0d2699b7ecdda08194e9ce5dd9b3d"}, + {file = "cffi-2.1.0-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:df2b82571a1b30f58a87bf4e5a9e78d2b1eff6c6ce8fd3aa3757221f93f0863f"}, + {file = "cffi-2.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:78474632761faa0fb96f30b1c928c84ebcf68713cbb80d15bab09dfe61640fde"}, + {file = "cffi-2.1.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:5972433ad71a9e46516584ef60a0fda12d9dc459938d1539c3ddecf9bdc1368d"}, + {file = "cffi-2.1.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b6422532152adf4e59b110cb2808cee7a033800952f5c036b4af047ee43199e7"}, + {file = "cffi-2.1.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:46b1c8db8f6122420f32d02fffb924c2fe9bc772d228c7c711748fff56aabb2b"}, + {file = "cffi-2.1.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9fafc5aa2e2a39aaf7f8cc0c1f044a9b07fca12e558dca53a3cc5c654ad67a7"}, + {file = "cffi-2.1.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1e9f50d192a3e525b15a75ab5114e442d83d657b7ec29182a991bc9a88fd3a66"}, + {file = "cffi-2.1.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:98fff996e983a36d3aa2eca83af40c5821202e7e6f32d13ae94e3d2286f10cfe"}, + {file = "cffi-2.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:379de10ce1ba048b1448599d1b37b24caee16309d1ac98d3982fc997f768700b"}, + {file = "cffi-2.1.0-cp312-cp312-win32.whl", hash = "sha256:9b8f0f26ca4e7513c534d351eca551947d053fac438f2a04ac96d882909b0d3a"}, + {file = "cffi-2.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:c97f080ea627e2863524c5af3836e2270b5f5dfff1f104392b959f8df0c5d384"}, + {file = "cffi-2.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:6d194185eabd279f1c05ebe3504265ddfc5ad2b58d0714f7db9f01da592e9eb6"}, + {file = "cffi-2.1.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:10537b1df4967ca26d21e5072d7d54188354483b91dc75058968d3f0cf13fbda"}, + {file = "cffi-2.1.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:a95b05f9baf29b91171b3a8bd2020b028835243e7b0ff6bb23e2a3c228518b1b"}, + {file = "cffi-2.1.0-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:15faec4adfff450819f3aee0e2e02c812de6edb88203aa58807955db2003472a"}, + {file = "cffi-2.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:716ff8ec22f20b4d988b12884086bcef0fc99737043e503f7a3935a6be99b1ea"}, + {file = "cffi-2.1.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:63960549e4f8dc41e31accb97b975abaecfc44c03e396c093a6436763c2ea7db"}, + {file = "cffi-2.1.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ff067a8d8d880e7809e4ac88eb009bb848870115317b306666502ccad30b147f"}, + {file = "cffi-2.1.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:3b926723c13eba9f81d2ef3820d63aeceec3b2d4639906047bf675cb8a7a500d"}, + {file = "cffi-2.1.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:47ff3a8bfd8cb9da1af7524b965127095055654c177fcfc7578debcb015eecd0"}, + {file = "cffi-2.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:799416bae98336e400981ff6e532d67d5c709cfb30afb79865a1315f94b0e224"}, + {file = "cffi-2.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:961be50688f7fba2fa65f63712d3b9b341a22311f5253460ce933f52f0de1c8c"}, + {file = "cffi-2.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:bf5c6cf48238b0eb4c086978c492ad1cbc22373fc5b2d7353b3a598ce6db887a"}, + {file = "cffi-2.1.0-cp313-cp313-win32.whl", hash = "sha256:db3eb7d46527159a878ec3460e9d40615bc25ba337d477db681aea6e4f05c5d2"}, + {file = "cffi-2.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:8e74a6135550c4748af665b1b1118b6aab33b1fc6a16f9aff630af107c3b4512"}, + {file = "cffi-2.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:2282cd5e38aa8accd03e99d1256af8411c84cdbee6a89d841b563fdbd1f3e50f"}, + {file = "cffi-2.1.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:d2117334c3af3bdcb9a88522b844a2bdb5efdc4f71c6c822df55486ae1c3347a"}, + {file = "cffi-2.1.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:702c436735fbe99d59ada02a1f65cfc0d31c0ee8b7290912f8fbc5cd1e4b16c3"}, + {file = "cffi-2.1.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:1ff3456eab0d889592d1936d6125bbfbc7ae4d3354a700f8bd80450a66445d4d"}, + {file = "cffi-2.1.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c4165821e131d6d4ca444347c2b694e2311bcfa3fe5a861cc72968f28867beac"}, + {file = "cffi-2.1.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:276f20fffd7b396e12516ba8edf9509210ac248cbbc5acbc39cd512f9f59ebe6"}, + {file = "cffi-2.1.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:7d5980a3433d4b71a5e120f9dd551403d7824e31e2e67124fe2769c404c06913"}, + {file = "cffi-2.1.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:6ca4919c6e4f89aa99c42510b42cf54596892c00b3f9077f6bdd1505e24b9c8d"}, + {file = "cffi-2.1.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d53d10f7da99ae46f7373b9150393e9c5eab9b224909982b43832668de4779f5"}, + {file = "cffi-2.1.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c351efb95e832a853a29361675f33a7ce53de1a109cd73fd47af0712213aa4ce"}, + {file = "cffi-2.1.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:dbf7c7a88e2bac086f06d14577332760bdeecc42bdec8ac4077f6260557d9326"}, + {file = "cffi-2.1.0-cp314-cp314-win32.whl", hash = "sha256:1854b724d00f6654c742097d5387569021be12d3a0f770eae1df8f8acfcc6acd"}, + {file = "cffi-2.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:1b96bfe2c4bd825681b7d311ad6d9b7280a091f43e8f63da5729638083cd3bfb"}, + {file = "cffi-2.1.0-cp314-cp314-win_arm64.whl", hash = "sha256:7d28dff1db6764108bc30788d85d61c876beff416d9a49cb9dd7c5a9f34f5804"}, + {file = "cffi-2.1.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7ea6b3e2c4250ff1de21c630fe72d0f63eb95c2c32ffbf64a358cf4a8836d714"}, + {file = "cffi-2.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6af371f3767faeffc6ac1ef57cdfd25844403e9d3f476c5537caee499de96376"}, + {file = "cffi-2.1.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:eb4e8997a49aa2c08a3e43c9045d224448b8941d88e7ac163c7d383e560cbf98"}, + {file = "cffi-2.1.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:bf01d8c84cbea96b944c73b22182e6c7c432b3475632b8111dbfdc95ddad6e13"}, + {file = "cffi-2.1.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:33eb1ad83ebe8f313e0df035c406227d55a79456704a863fad9842136af5ad7d"}, + {file = "cffi-2.1.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ac0f1a2d0cfa7eea3f2aaf006ab6e70e8feeb16b75d65b7e5939982ca2f11056"}, + {file = "cffi-2.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c16914df9fb7f500e440e6875fa23ff5e0b31db01fa9c06af98d59a91f0dc2e4"}, + {file = "cffi-2.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5ecbd0499275d57506d397eebe1981cee87b47fcd9ef5c22cab7ed7644a39a94"}, + {file = "cffi-2.1.0-cp314-cp314t-win32.whl", hash = "sha256:7d034dcffa09e9a46c93fa3a3be402096cb5354ac6e41ab8e5cc9cd8b642ad76"}, + {file = "cffi-2.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:0582a58f3051372229ca8e7f5f589f9e5632678208d8636fea3676711fdf7fe5"}, + {file = "cffi-2.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:510aeeeac94811b138077451da1fb18b308a5feab47dd2b603af55804155e1c8"}, + {file = "cffi-2.1.0-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:2e9dabb9abcb7ad15938c7196ad5c1718a4e6d33cc79b4c0209bdb64c4a54a5c"}, + {file = "cffi-2.1.0-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:37f525a7e7e50c017fdebe58b787be310ad59357ae43a053943a6e1a6c526001"}, + {file = "cffi-2.1.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:95f2954c2c9473d892eca6e0409f3568b37ab62a8eedb122461f73cc273476e3"}, + {file = "cffi-2.1.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:cdf2448aab5f661c9315308ec8b93f4e8a1a67a3c733f8631067a2b67d5913dc"}, + {file = "cffi-2.1.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:90bec57cf82089383bd06a605b3eb8daebf7e5a668520beaf6e327a83a947699"}, + {file = "cffi-2.1.0-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6274dcb2d15cef48daa73ed1be5a40d501d74dccd0cd6db364776d12cb6ba022"}, + {file = "cffi-2.1.0-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:2b71d409cccee78310ab5dec549aed052aaea483346e282c7b02362596e01bb0"}, + {file = "cffi-2.1.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7d3538f9c0e50670f4deb93dbb696576e60590369cae2faf7de681e597a8a1f1"}, + {file = "cffi-2.1.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:8f9ec95b8a043d3dfbc74d9abc6f7baf524dd27a8dc160b0a32ff9cdab650c28"}, + {file = "cffi-2.1.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:af5e2915d41fe6c961694d7bfdc8562942638200f3ce2765dfb8b745cf997629"}, + {file = "cffi-2.1.0-cp315-cp315-win32.whl", hash = "sha256:0a42c688d19fca6e095a53c6a6e2295a5b050a8b289f109adab02a9e61a25de6"}, + {file = "cffi-2.1.0-cp315-cp315-win_amd64.whl", hash = "sha256:bccbbb5ee76a61f9d99b5bf3846a51d7fca4b6a732fe46f89295610edaf41853"}, + {file = "cffi-2.1.0-cp315-cp315-win_arm64.whl", hash = "sha256:8d35c139744adb3e727cd51b1a18324bbe44b8bd41bf8322bca4d41289f48eda"}, + {file = "cffi-2.1.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:f9912624a0c0b834b7520d7769b3644453aabc0a7e1c839da7359f050750e9bc"}, + {file = "cffi-2.1.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:df92f2aba50eb4d96718b68ef76f2e57a57b54f2fa62333496d16c6d585a85ca"}, + {file = "cffi-2.1.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0520e1f4c35f44e209cbbb421b67eec42e6a157f59444dfb6058874ff3610e5d"}, + {file = "cffi-2.1.0-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:3681e031db29958a7502f5c0c9d6bbc4c36cb20f7b104086fa642d1799631ff8"}, + {file = "cffi-2.1.0-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:762f99479dcb369f60ab9017ad4ab97a36a1dd7c1ee5a3b15db0f4b8659120cd"}, + {file = "cffi-2.1.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0611e7ebf90573a535ebdc33ae9da222d037853983e13359f580fab781ca017f"}, + {file = "cffi-2.1.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:86cf8755a791f72c85dc287128cc62d4f24d392e3f1e15837245623f4a33cccc"}, + {file = "cffi-2.1.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:ba00f661f8ba35d075c937174e27c2c421cec3942fd2e0ea3e66996757c0fdd9"}, + {file = "cffi-2.1.0-cp315-cp315t-win32.whl", hash = "sha256:cb96698e3c7413d906ce83f8ffd245ec1bd94707541f299d0ce4d6b0193e982b"}, + {file = "cffi-2.1.0-cp315-cp315t-win_amd64.whl", hash = "sha256:f146d154428a2523f9cc7936c02353c2459b8f6cf07d3cd1ee1c0a611109c5d5"}, + {file = "cffi-2.1.0-cp315-cp315t-win_arm64.whl", hash = "sha256:cbb7640ce37159548d2147b5b8c241f962143d4c71231431820783f4dc78f210"}, + {file = "cffi-2.1.0.tar.gz", hash = "sha256:efc1cdd798b1aaf39b4610bba7aad28c9bea9b910f25c784ccf9ec1fa719d1f9"}, +] + +[package.dependencies] +pycparser = {version = "*", markers = "implementation_name != \"PyPy\""} + [[package]] name = "charset-normalizer" version = "3.4.9" @@ -680,6 +892,121 @@ tomli = {version = "*", optional = true, markers = "python_full_version <= \"3.1 [package.extras] toml = ["tomli ; python_full_version <= \"3.11.0a6\""] +[[package]] +name = "cryptography" +version = "43.0.3" +description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." +optional = false +python-versions = ">=3.7" +groups = ["test"] +markers = "python_version < \"3.11\" and python_version != \"3.10\"" +files = [ + {file = "cryptography-43.0.3-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:bf7a1932ac4176486eab36a19ed4c0492da5d97123f1406cf15e41b05e787d2e"}, + {file = "cryptography-43.0.3-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:63efa177ff54aec6e1c0aefaa1a241232dcd37413835a9b674b6e3f0ae2bfd3e"}, + {file = "cryptography-43.0.3-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7e1ce50266f4f70bf41a2c6dc4358afadae90e2a1e5342d3c08883df1675374f"}, + {file = "cryptography-43.0.3-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:443c4a81bb10daed9a8f334365fe52542771f25aedaf889fd323a853ce7377d6"}, + {file = "cryptography-43.0.3-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:74f57f24754fe349223792466a709f8e0c093205ff0dca557af51072ff47ab18"}, + {file = "cryptography-43.0.3-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9762ea51a8fc2a88b70cf2995e5675b38d93bf36bd67d91721c309df184f49bd"}, + {file = "cryptography-43.0.3-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:81ef806b1fef6b06dcebad789f988d3b37ccaee225695cf3e07648eee0fc6b73"}, + {file = "cryptography-43.0.3-cp37-abi3-win32.whl", hash = "sha256:cbeb489927bd7af4aa98d4b261af9a5bc025bd87f0e3547e11584be9e9427be2"}, + {file = "cryptography-43.0.3-cp37-abi3-win_amd64.whl", hash = "sha256:f46304d6f0c6ab8e52770addfa2fc41e6629495548862279641972b6215451cd"}, + {file = "cryptography-43.0.3-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:8ac43ae87929a5982f5948ceda07001ee5e83227fd69cf55b109144938d96984"}, + {file = "cryptography-43.0.3-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:846da004a5804145a5f441b8530b4bf35afbf7da70f82409f151695b127213d5"}, + {file = "cryptography-43.0.3-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f996e7268af62598f2fc1204afa98a3b5712313a55c4c9d434aef49cadc91d4"}, + {file = "cryptography-43.0.3-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:f7b178f11ed3664fd0e995a47ed2b5ff0a12d893e41dd0494f406d1cf555cab7"}, + {file = "cryptography-43.0.3-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:c2e6fc39c4ab499049df3bdf567f768a723a5e8464816e8f009f121a5a9f4405"}, + {file = "cryptography-43.0.3-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:e1be4655c7ef6e1bbe6b5d0403526601323420bcf414598955968c9ef3eb7d16"}, + {file = "cryptography-43.0.3-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:df6b6c6d742395dd77a23ea3728ab62f98379eff8fb61be2744d4679ab678f73"}, + {file = "cryptography-43.0.3-cp39-abi3-win32.whl", hash = "sha256:d56e96520b1020449bbace2b78b603442e7e378a9b3bd68de65c782db1507995"}, + {file = "cryptography-43.0.3-cp39-abi3-win_amd64.whl", hash = "sha256:0c580952eef9bf68c4747774cde7ec1d85a6e61de97281f2dba83c7d2c806362"}, + {file = "cryptography-43.0.3-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:d03b5621a135bffecad2c73e9f4deb1a0f977b9a8ffe6f8e002bf6c9d07b918c"}, + {file = "cryptography-43.0.3-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:a2a431ee15799d6db9fe80c82b055bae5a752bef645bba795e8e52687c69efe3"}, + {file = "cryptography-43.0.3-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:281c945d0e28c92ca5e5930664c1cefd85efe80e5c0d2bc58dd63383fda29f83"}, + {file = "cryptography-43.0.3-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:f18c716be16bc1fea8e95def49edf46b82fccaa88587a45f8dc0ff6ab5d8e0a7"}, + {file = "cryptography-43.0.3-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:4a02ded6cd4f0a5562a8887df8b3bd14e822a90f97ac5e544c162899bc467664"}, + {file = "cryptography-43.0.3-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:53a583b6637ab4c4e3591a15bc9db855b8d9dee9a669b550f311480acab6eb08"}, + {file = "cryptography-43.0.3-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:1ec0bcf7e17c0c5669d881b1cd38c4972fade441b27bda1051665faaa89bdcaa"}, + {file = "cryptography-43.0.3-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:2ce6fae5bdad59577b44e4dfed356944fbf1d925269114c28be377692643b4ff"}, + {file = "cryptography-43.0.3.tar.gz", hash = "sha256:315b9001266a492a6ff443b61238f956b214dbec9910a081ba5b6646a055a805"}, +] + +[package.dependencies] +cffi = {version = ">=1.12", markers = "platform_python_implementation != \"PyPy\""} + +[package.extras] +docs = ["sphinx (>=5.3.0)", "sphinx-rtd-theme (>=1.1.1)"] +docstest = ["pyenchant (>=1.6.11)", "readme-renderer", "sphinxcontrib-spelling (>=4.0.1)"] +nox = ["nox"] +pep8test = ["check-sdist", "click", "mypy", "ruff"] +sdist = ["build"] +ssh = ["bcrypt (>=3.1.5)"] +test = ["certifi", "cryptography-vectors (==43.0.3)", "pretend", "pytest (>=6.2.0)", "pytest-benchmark", "pytest-cov", "pytest-xdist"] +test-randomorder = ["pytest-randomly"] + +[[package]] +name = "cryptography" +version = "49.0.0" +description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." +optional = false +python-versions = "!=3.9.0,!=3.9.1,>=3.9" +groups = ["test"] +markers = "python_version >= \"3.10\"" +files = [ + {file = "cryptography-49.0.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:966fe0e9c67490071f14c0d2b1cb2dfb3023c5ce39457343931415f08382f2db"}, + {file = "cryptography-49.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:36d1709f992593689b45bda411498d62c6e365f2ca00b84657d4dadd24de16db"}, + {file = "cryptography-49.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0e959b578856a3924bc0cbb710fc12c387b9412a951389f3ca61704a9e25f325"}, + {file = "cryptography-49.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:53ecee2e23f7169b6117e99fc8a944e5e50f79e69758a83b52a00cb98ab2b2d2"}, + {file = "cryptography-49.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:2eda353d8a27bcbcaa4cbed18994a74ab4d19a2ca897db188ea269ab9b71419b"}, + {file = "cryptography-49.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2afe9051da7ae7bd5905da5a949280c7d2bb75682e188f650a9d0f2756b834c6"}, + {file = "cryptography-49.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:0b82e28ee398a386f0807bba7884d30f25218855690f45115831bcce5d90822c"}, + {file = "cryptography-49.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:ccac2bfebc306b862133e3bb71f3f6ee8bb525240089b2d952e4144b3a6d5da7"}, + {file = "cryptography-49.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:d0527ce944105f257f605a827d6ebead966c752038b6e8656abb9c5edee6fc68"}, + {file = "cryptography-49.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:cbc77da8c523d5abd028635ba850a6966fcee2c82e2bf65a41d1d8afe0f98be9"}, + {file = "cryptography-49.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:b87e65d263b3e5d3bb92a57e2a6638e2f31110fa7aa890c7b2dbba42248d0a3f"}, + {file = "cryptography-49.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:66ec79c3904820572d7e987abdf304281f141d37ad9a489b8e97066e7b9b6459"}, + {file = "cryptography-49.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:e5dfc1e64de5677cec922ffa8da89c546d0415bf6efdf081842e5d44c84e1f0e"}, + {file = "cryptography-49.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:73a205dce83953d131a4aa1e0fd917a2fd1c5b1eef251e9d7152efefcbf5caf7"}, + {file = "cryptography-49.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:196ecd6a36e4e9aa10270393bb98d8df88fccee0bf1e5128b91ae4eb4375896d"}, + {file = "cryptography-49.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7abcee80084cda3f7691f3eb1ce480d8df49cec637b429aa35986c1de71738aa"}, + {file = "cryptography-49.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:4ae387c9cb68ea569ca17e490d66d8142b81c3cc814bf179974b7d146e490bbb"}, + {file = "cryptography-49.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:f37d847238971164fdbc68ade6f6574aecc9c0af714190e2083429ff68f4ce9d"}, + {file = "cryptography-49.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:c2bc30226390d60ea19d9f82b19db005fe0452154a23c1c410c12ea801e43561"}, + {file = "cryptography-49.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:07cab27cc7b7e0fd28e5e26bb9eeedde5c135c868b46de4a27845abe94af6122"}, + {file = "cryptography-49.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:b20133d204d2bb56ba047642199603876c872026ca53e79c35b83772ab2cc505"}, + {file = "cryptography-49.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:b970c6da94d5bb18629db453d14f2a1300f6bf59b61e9b82377931ef95504866"}, + {file = "cryptography-49.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:d8ecde755e2e91bf773fc94e8c9d730cd7f2007004cb492263a794ec3899a1c8"}, + {file = "cryptography-49.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e3fb64c420688e5319ae25113a354015abbd8dffbfbc41781a1ea66fc7622ac3"}, + {file = "cryptography-49.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32703d93296f5c1f4b53349ad3a250c2cae0fdecd3a3dd5d47e616d8d616af27"}, + {file = "cryptography-49.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:33cd0565932807baddb67b96dbee92f2c374b5c89dee09fd74079aeb8c8dba61"}, + {file = "cryptography-49.0.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ec5e529fb80935c94fe7b729f9972b50e351a0e6b50aa294fd5cabb109fcc29a"}, + {file = "cryptography-49.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f78ff2c9ed8dc2d036b0f4d640e22522213d047c1b14e61205a7e55c80a494d4"}, + {file = "cryptography-49.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:35b151772baff2c74cba7fa290ceaff4c3b11c0c881eb93eb5dbc05a7cfbba18"}, + {file = "cryptography-49.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:0f21641cf4b30fca7aee061ced0ec7ad7b073518088b7c9969a297c0ae796c69"}, + {file = "cryptography-49.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9e82dcc8e56052715fb18b2429e3bca4823b1629136a2084fc45a9a5cecb9b64"}, + {file = "cryptography-49.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:6f2debedf9ca60cf1d5bd466475638af5130f89965605cd818484d19987d3a21"}, + {file = "cryptography-49.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:8c25ceb16df5b9435f3f6a9829204985b0e0cbee3b48aacd432c7d2c850b44d9"}, + {file = "cryptography-49.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:28d8b15e6275f12c8a207dc309dfa957903c927d08d0cc937ee3f63f200693cc"}, + {file = "cryptography-49.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:6fc361c34fb6aac015ce19435876635e5c6d21db31998b0920f675f131e043b8"}, + {file = "cryptography-49.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:2400ef9c9e2299a25614eb1dea3db54a69b1349efd043bfac9c67630d136df36"}, + {file = "cryptography-49.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:67e1d20ad9ef3a563c59ef22e7a8a0b8210bd26604369ea4a30a7c66aefe504e"}, + {file = "cryptography-49.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:42b0684e0e40cf26122427802486f6d93aea593612603a94fbf260c7eb1e9c1b"}, + {file = "cryptography-49.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:026ac7423e6fa66872d3bf889be5974507da3944f866f704fa200eadacd00001"}, + {file = "cryptography-49.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:fc1e275c2f1d97b1a6450b8b0ea3ebfa6e087a611c2b26cb2404d48588abab7b"}, + {file = "cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:c83782480a4a9da4d0feb51950131ba32e12e70813848b3343f6e18c28a66838"}, + {file = "cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:b39efa323140595abd3ecca8529d321ae50f55f3aa3ba9cc81ea56a6011953d5"}, + {file = "cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:b47db11c2c3525083296069b98ac5221907455e989ae0c2e3008bde851921615"}, + {file = "cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:084ef1af862eb07ec46d25f68689f2102a9fc0e05ce7b80f14f5fe51e4eef0f6"}, + {file = "cryptography-49.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:be9fcb48a55f023493482827d4f459bd263cc20efde64f204b97c123201850c6"}, + {file = "cryptography-49.0.0.tar.gz", hash = "sha256:f89660a348f4f78a92366240a61404e337586ef7f5909a2fef59ca88ef505493"}, +] + +[package.dependencies] +cffi = {version = ">=2.0.0", markers = "platform_python_implementation != \"PyPy\""} +typing-extensions = {version = ">=4.13.2", markers = "python_full_version < \"3.11.0\""} + +[package.extras] +ssh = ["bcrypt (>=3.1.5)"] + [[package]] name = "docutils" version = "0.21.2" @@ -828,6 +1155,152 @@ files = [ [package.dependencies] flake8 = ">=3.8" +[[package]] +name = "grpcio" +version = "1.80.0" +description = "HTTP/2-based RPC framework" +optional = true +python-versions = ">=3.9" +groups = ["main"] +markers = "python_version < \"3.11\" and python_version != \"3.10\" and extra == \"grpc\"" +files = [ + {file = "grpcio-1.80.0-cp310-cp310-linux_armv7l.whl", hash = "sha256:886457a7768e408cdce226ad1ca67d2958917d306523a0e21e1a2fdaa75c9c9c"}, + {file = "grpcio-1.80.0-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:7b641fc3f1dc647bfd80bd713addc68f6d145956f64677e56d9ebafc0bd72388"}, + {file = "grpcio-1.80.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:33eb763f18f006dc7fee1e69831d38d23f5eccd15b2e0f92a13ee1d9242e5e02"}, + {file = "grpcio-1.80.0-cp310-cp310-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:52d143637e3872633fc7dd7c3c6a1c84e396b359f3a72e215f8bf69fd82084fc"}, + {file = "grpcio-1.80.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c51bf8ac4575af2e0678bccfb07e47321fc7acb5049b4482832c5c195e04e13a"}, + {file = "grpcio-1.80.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:50a9871536d71c4fba24ee856abc03a87764570f0c457dd8db0b4018f379fed9"}, + {file = "grpcio-1.80.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:a72d84ad0514db063e21887fbacd1fd7acb4d494a564cae22227cd45c7fbf199"}, + {file = "grpcio-1.80.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f7691a6788ad9196872f95716df5bc643ebba13c97140b7a5ee5c8e75d1dea81"}, + {file = "grpcio-1.80.0-cp310-cp310-win32.whl", hash = "sha256:46c2390b59d67f84e882694d489f5b45707c657832d7934859ceb8c33f467069"}, + {file = "grpcio-1.80.0-cp310-cp310-win_amd64.whl", hash = "sha256:dc053420fc75749c961e2a4c906398d7c15725d36ccc04ae6d16093167223b58"}, + {file = "grpcio-1.80.0-cp311-cp311-linux_armv7l.whl", hash = "sha256:dfab85db094068ff42e2a3563f60ab3dddcc9d6488a35abf0132daec13209c8a"}, + {file = "grpcio-1.80.0-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:5c07e82e822e1161354e32da2662f741a4944ea955f9f580ec8fb409dd6f6060"}, + {file = "grpcio-1.80.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ba0915d51fd4ced2db5ff719f84e270afe0e2d4c45a7bdb1e8d036e4502928c2"}, + {file = "grpcio-1.80.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:3cb8130ba457d2aa09fa6b7c3ed6b6e4e6a2685fce63cb803d479576c4d80e21"}, + {file = "grpcio-1.80.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:09e5e478b3d14afd23f12e49e8b44c8684ac3c5f08561c43a5b9691c54d136ab"}, + {file = "grpcio-1.80.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:00168469238b022500e486c1c33916acf2f2a9b2c022202cf8a1885d2e3073c1"}, + {file = "grpcio-1.80.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8502122a3cc1714038e39a0b071acb1207ca7844208d5ea0d091317555ee7106"}, + {file = "grpcio-1.80.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ce1794f4ea6cc3ca29463f42d665c32ba1b964b48958a66497917fe9069f26e6"}, + {file = "grpcio-1.80.0-cp311-cp311-win32.whl", hash = "sha256:51b4a7189b0bef2aa30adce3c78f09c83526cf3dddb24c6a96555e3b97340440"}, + {file = "grpcio-1.80.0-cp311-cp311-win_amd64.whl", hash = "sha256:02e64bb0bb2da14d947a49e6f120a75e947250aebe65f9629b62bb1f5c14e6e9"}, + {file = "grpcio-1.80.0-cp312-cp312-linux_armv7l.whl", hash = "sha256:c624cc9f1008361014378c9d776de7182b11fe8b2e5a81bc69f23a295f2a1ad0"}, + {file = "grpcio-1.80.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:f49eddcac43c3bf350c0385366a58f36bed8cc2c0ec35ef7b74b49e56552c0c2"}, + {file = "grpcio-1.80.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d334591df610ab94714048e0d5b4f3dd5ad1bee74dfec11eee344220077a79de"}, + {file = "grpcio-1.80.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:0cb517eb1d0d0aaf1d87af7cc5b801d686557c1d88b2619f5e31fab3c2315921"}, + {file = "grpcio-1.80.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4e78c4ac0d97dc2e569b2f4bcbbb447491167cb358d1a389fc4af71ab6f70411"}, + {file = "grpcio-1.80.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2ed770b4c06984f3b47eb0517b1c69ad0b84ef3f40128f51448433be904634cd"}, + {file = "grpcio-1.80.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:256507e2f524092f1473071a05e65a5b10d84b82e3ff24c5b571513cfaa61e2f"}, + {file = "grpcio-1.80.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:9a6284a5d907c37db53350645567c522be314bac859a64a7a5ca63b77bb7958f"}, + {file = "grpcio-1.80.0-cp312-cp312-win32.whl", hash = "sha256:c71309cfce2f22be26aa4a847357c502db6c621f1a49825ae98aa0907595b193"}, + {file = "grpcio-1.80.0-cp312-cp312-win_amd64.whl", hash = "sha256:9fe648599c0e37594c4809d81a9e77bd138cc82eb8baa71b6a86af65426723ff"}, + {file = "grpcio-1.80.0-cp313-cp313-linux_armv7l.whl", hash = "sha256:e9e408fc016dffd20661f0126c53d8a31c2821b5c13c5d67a0f5ed5de93319ad"}, + {file = "grpcio-1.80.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:92d787312e613754d4d8b9ca6d3297e69994a7912a32fa38c4c4e01c272974b0"}, + {file = "grpcio-1.80.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8ac393b58aa16991a2f1144ec578084d544038c12242da3a215966b512904d0f"}, + {file = "grpcio-1.80.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:68e5851ac4b9afe07e7f84483803ad167852570d65326b34d54ca560bfa53fb6"}, + {file = "grpcio-1.80.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:873ff5d17d68992ef6605330127425d2fc4e77e612fa3c3e0ed4e668685e3140"}, + {file = "grpcio-1.80.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2bea16af2750fd0a899bf1abd9022244418b55d1f37da2202249ba4ba673838d"}, + {file = "grpcio-1.80.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ba0db34f7e1d803a878284cd70e4c63cb6ae2510ba51937bf8f45ba997cefcf7"}, + {file = "grpcio-1.80.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8eb613f02d34721f1acf3626dfdb3545bd3c8505b0e52bf8b5710a28d02e8aa7"}, + {file = "grpcio-1.80.0-cp313-cp313-win32.whl", hash = "sha256:93b6f823810720912fd131f561f91f5fed0fda372b6b7028a2681b8194d5d294"}, + {file = "grpcio-1.80.0-cp313-cp313-win_amd64.whl", hash = "sha256:e172cf795a3ba5246d3529e4d34c53db70e888fa582a8ffebd2e6e48bc0cba50"}, + {file = "grpcio-1.80.0-cp314-cp314-linux_armv7l.whl", hash = "sha256:3d4147a97c8344d065d01bbf8b6acec2cf86fb0400d40696c8bdad34a64ffc0e"}, + {file = "grpcio-1.80.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:d8e11f167935b3eb089ac9038e1a063e6d7dbe995c0bb4a661e614583352e76f"}, + {file = "grpcio-1.80.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f14b618fc30de822681ee986cfdcc2d9327229dc4c98aed16896761cacd468b9"}, + {file = "grpcio-1.80.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:4ed39fbdcf9b87370f6e8df4e39ca7b38b3e5e9d1b0013c7b6be9639d6578d14"}, + {file = "grpcio-1.80.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2dcc70e9f0ba987526e8e8603a610fb4f460e42899e74e7a518bf3c68fe1bf05"}, + {file = "grpcio-1.80.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:448c884b668b868562b1bda833c5fce6272d26e1926ec46747cda05741d302c1"}, + {file = "grpcio-1.80.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:a1dc80fe55685b4a543555e6eef975303b36c8db1023b1599b094b92aa77965f"}, + {file = "grpcio-1.80.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:31b9ac4ad1aa28ffee5503821fafd09e4da0a261ce1c1281c6c8da0423c83b6e"}, + {file = "grpcio-1.80.0-cp314-cp314-win32.whl", hash = "sha256:367ce30ba67d05e0592470428f0ec1c31714cab9ef19b8f2e37be1f4c7d32fae"}, + {file = "grpcio-1.80.0-cp314-cp314-win_amd64.whl", hash = "sha256:3b01e1f5464c583d2f567b2e46ff0d516ef979978f72091fd81f5ab7fa6e2e7f"}, + {file = "grpcio-1.80.0-cp39-cp39-linux_armv7l.whl", hash = "sha256:aacdfb4ed3eb919ca997504d27e03d5dba403c85130b8ed450308590a738f7a4"}, + {file = "grpcio-1.80.0-cp39-cp39-macosx_11_0_universal2.whl", hash = "sha256:a361c20ec1ccd3c3953d20fb6d7b4125093bdd10dff44c5e2bbb39e58917cedc"}, + {file = "grpcio-1.80.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:43168871f170d1e4ed16ae03d10cd21efa29f190e710a624cee7e5ae07da6f4f"}, + {file = "grpcio-1.80.0-cp39-cp39-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:1b97cd29a8eda100b559b455331c487a80915b6ea6bd91cf3e89836c4ee8d957"}, + {file = "grpcio-1.80.0-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bac1d573dfa84ce59a5547073e28fa7326d53352adda6912e362da0b917fcef4"}, + {file = "grpcio-1.80.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:4560cf0e86514595dbbd330cd65b7afad4b5c4b8c4905c041cfffa138d45e6fd"}, + {file = "grpcio-1.80.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:ec0a592e926071b4abad50c1495cd0d0d513324b3ff5e7267067c33ba27506e4"}, + {file = "grpcio-1.80.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:deb10a1528473c11f72a0939eed36d83e847d7cbb63e8cc5611fb7a912d38614"}, + {file = "grpcio-1.80.0-cp39-cp39-win32.whl", hash = "sha256:627fb7312171cdc52828bd6fac8d7028ff2a64b89f1957b6f3416caa2218d141"}, + {file = "grpcio-1.80.0-cp39-cp39-win_amd64.whl", hash = "sha256:05d55e1798756282cddd52d56c896b3e7d673e3a8798c2f1cd05ba249a3bb4de"}, + {file = "grpcio-1.80.0.tar.gz", hash = "sha256:29aca15edd0688c22ba01d7cc01cb000d72b2033f4a3c72a81a19b56fd143257"}, +] + +[package.dependencies] +typing-extensions = ">=4.12,<5.0" + +[package.extras] +protobuf = ["grpcio-tools (>=1.80.0)"] + +[[package]] +name = "grpcio" +version = "1.83.0" +description = "HTTP/2-based RPC framework" +optional = true +python-versions = ">=3.10" +groups = ["main"] +markers = "python_version >= \"3.10\" and extra == \"grpc\"" +files = [ + {file = "grpcio-1.83.0-cp310-cp310-linux_armv7l.whl", hash = "sha256:fba099b716e73512d61b97f71ea3c31a72abb36904036e316bf4dd148ca8dcc8"}, + {file = "grpcio-1.83.0-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:6755ed67cc3e454d51ae9f6e1915b80d3942fa4de956ef48dacd45ab7f40b727"}, + {file = "grpcio-1.83.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5882c1a721b50ce0123ee5e839e1ab059ad72a7ade76cdf2d5bd833b56791acf"}, + {file = "grpcio-1.83.0-cp310-cp310-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:4e3eedfc92b6b9f2960115e7e620cf0cbf80bb7849a51ce3820dc54dfd88b6b9"}, + {file = "grpcio-1.83.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4fcaa7c45c45b4a89e2867d1f1785d9481a788399d915e341ed2eb49aeef9dd4"}, + {file = "grpcio-1.83.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:6b6c666a1d5613ff360c9e90f44665e3a88b25a815209ddbc0917eec281931cb"}, + {file = "grpcio-1.83.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:6be5c807b717be3dd649446f021301fd7907e376318675d2147823071034112a"}, + {file = "grpcio-1.83.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:c834e86d8fd2f03d7e4db49a027f7c5b89c5b88eed305543a5295bd6fee61e40"}, + {file = "grpcio-1.83.0-cp310-cp310-win32.whl", hash = "sha256:35a5b1c192496b6c25956eebfa963468935612206fd2543ac3ce981e6a5e0f03"}, + {file = "grpcio-1.83.0-cp310-cp310-win_amd64.whl", hash = "sha256:8f6c395e493d20c39b29392ca200e9aaeb78d0bc2f04db0c0a7da7ddc939aa57"}, + {file = "grpcio-1.83.0-cp311-cp311-linux_armv7l.whl", hash = "sha256:8ff0b8767ddd62704e0d9571c1890af08d84a3a689ebba1807e62519d0b3277f"}, + {file = "grpcio-1.83.0-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:4772402f43517b4824980be4b3b2274a81eec0004a70009473c31b340d43e223"}, + {file = "grpcio-1.83.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f4cee5fc86e84a0cf7ad1574b454c3320e087c07f55b7df5dc0ac6a873fb90c0"}, + {file = "grpcio-1.83.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:f5e822a7e7d03282f6ad225e710493c48b9057a353358344a5f7c42b2b37618d"}, + {file = "grpcio-1.83.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f5f410d7c2903eabb34789dfd6342eef04af1ad459943936b7e09a9f5bd417b9"}, + {file = "grpcio-1.83.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ee94a4016fdf8699fb1fd8a38652475ff677f1c72074cee44deeeb9a7e95e745"}, + {file = "grpcio-1.83.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:c6444666317338e903093c7c756e6cc88eee59f798cb8dd41e87725bf54e1617"}, + {file = "grpcio-1.83.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:aa074041231f03959cb097dd5517b0677b8ea49215bae01d5710a7b69dd59969"}, + {file = "grpcio-1.83.0-cp311-cp311-win32.whl", hash = "sha256:cb056f6e171c42639a50460b2929c82241fda51f71cf3dcdd68090fe45095a45"}, + {file = "grpcio-1.83.0-cp311-cp311-win_amd64.whl", hash = "sha256:7416952ca770477990257206276999056f8316d79196f2f25942393e58a20b49"}, + {file = "grpcio-1.83.0-cp312-cp312-linux_armv7l.whl", hash = "sha256:28f6c35ac8fcf10e4594f138e468f194360089dde40d126a7033e863fc479930"}, + {file = "grpcio-1.83.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:33898e6a28e4ae598f1577cb1c4fec2a15c033d0ec52b9b45a09610dd045b9da"}, + {file = "grpcio-1.83.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6fb8a1dd0c6f0f931e69e9d0dc6d1c406ed2a44fa963414eafba07b7fb685d16"}, + {file = "grpcio-1.83.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:2b5e75c34842cd9c1b95285ca395c6a569664b81e3ffa6b714125922942abaaf"}, + {file = "grpcio-1.83.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aeb339838db07600481ef869507279b75326c75eac6d10f7afa62a0da1d2bcdd"}, + {file = "grpcio-1.83.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f47d62808b4c0a97b78bff88a6d4ca283a2a492b9a04a87d814af95ca3b9c19c"}, + {file = "grpcio-1.83.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:62003babc444a606dcd1f009cd16391ce23669ae4ad6ec267a873da7937a69f5"}, + {file = "grpcio-1.83.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1aa567f8c3f19850ffd5d2858c9a8ea7c80f0db6c01186b71eb31e923ec984f5"}, + {file = "grpcio-1.83.0-cp312-cp312-win32.whl", hash = "sha256:cb2906c61db4f9c64cc360054b5df70eeb81846228e9e56a4944bd415a63dadc"}, + {file = "grpcio-1.83.0-cp312-cp312-win_amd64.whl", hash = "sha256:1c699bbb20f143c8f2bff219de578aa2dc1f919399d67dc702b038b986ee62df"}, + {file = "grpcio-1.83.0-cp313-cp313-linux_armv7l.whl", hash = "sha256:6662f3b1e07cc7493d437351860dc867bddc6a93c83ecf33bbfdaf0c217ab2d0"}, + {file = "grpcio-1.83.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:74fe6f9e8a35c7dbf32255ee154d15e3e5338a81ed39173d079d594d2e544cd1"}, + {file = "grpcio-1.83.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:10b3fa0475eb572c9a81a6fe37fa16a9c500c0c91cfc148cac15692b7e3c2867"}, + {file = "grpcio-1.83.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:5f20a988480b0f28207f057f7f7ae1313393c3cef0adcfeae8248f9947eaf881"}, + {file = "grpcio-1.83.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7bd82671b39065ba18cd536e9cd45b27ff649053f81ddd2c6a966d595067080f"}, + {file = "grpcio-1.83.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bc60215b5cb9fc8ca72942c498b551ac2305bd08f6ef8d4e3f0d21b64fbecd61"}, + {file = "grpcio-1.83.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:f1c3e5689d4b90987b1d72022bcfe866a9a3dc66197484cf856d96b6150e7f45"}, + {file = "grpcio-1.83.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a21cb4eeeba124443f399be2e8b624943cde864dcbe588cb42e5c483a52a906c"}, + {file = "grpcio-1.83.0-cp313-cp313-win32.whl", hash = "sha256:8fe04f1050a59f875601eb55d42b4f66946fe89817f967e34db1462ccd07dadf"}, + {file = "grpcio-1.83.0-cp313-cp313-win_amd64.whl", hash = "sha256:6e01ecd9d8ef280abe1365138a4dc318f9a5287f4cb1b41d07816f796653f735"}, + {file = "grpcio-1.83.0-cp314-cp314-linux_armv7l.whl", hash = "sha256:3f351629f6ae16ecc0ec3553e586a6763ffd9f6114044286d0cbec3e09241bfa"}, + {file = "grpcio-1.83.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:d05ff664100d429335b93c91b8b34ddf9e94a112205e7fa06dede309e44a4e4c"}, + {file = "grpcio-1.83.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7936f2a56cf04f6514705c0fedf400971de01b6aa1719327e4718f410a765e2b"}, + {file = "grpcio-1.83.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:b0a0be840e51b6b7ee9df9269770faf77bdf4b771053c257c21d12bad607714c"}, + {file = "grpcio-1.83.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:009667eaf3dcd5224c713589cdc98e7ca4ed0ff0b61132c6b276e930eb83a2df"}, + {file = "grpcio-1.83.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:bb669918fd88936b15599caff4160a77ab74bdeb25f2231f6e45b61282d6107b"}, + {file = "grpcio-1.83.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:c19b454d3d3f28db81f2c7c4dbaee96e7f6fd149721733ffe79d6bc530f17404"}, + {file = "grpcio-1.83.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:61007cd08640abc5c54547ee32505474c482cd733a53cb87551ea81faa6350af"}, + {file = "grpcio-1.83.0-cp314-cp314-win32.whl", hash = "sha256:32e11c37f5285b0c6fa3042c05fe06903696689749833fc64e67dec71b9bbe33"}, + {file = "grpcio-1.83.0-cp314-cp314-win_amd64.whl", hash = "sha256:2bb48cb5e6dd005ca12b89ce4b6ac0b48ff3112c747542ee7986ef611a8ca6d9"}, + {file = "grpcio-1.83.0.tar.gz", hash = "sha256:7674587248fbbb2ac6e4eecf83a8a0f3d91a928f941de571acfd3a2f007fbc24"}, +] + +[package.dependencies] +typing-extensions = ">=4.12,<5.0" + +[package.extras] +protobuf = ["grpcio-tools (>=1.83.0)"] + [[package]] name = "idna" version = "3.18" @@ -1610,6 +2083,32 @@ files = [ {file = "pycodestyle-2.11.1.tar.gz", hash = "sha256:41ba0e7afc9752dfb53ced5489e89f8186be00e599e712660695b7a75ff2663f"}, ] +[[package]] +name = "pycparser" +version = "2.23" +description = "C parser in Python" +optional = false +python-versions = ">=3.8" +groups = ["test"] +markers = "python_version < \"3.11\" and python_version != \"3.10\" and platform_python_implementation != \"PyPy\" and implementation_name != \"PyPy\"" +files = [ + {file = "pycparser-2.23-py3-none-any.whl", hash = "sha256:e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934"}, + {file = "pycparser-2.23.tar.gz", hash = "sha256:78816d4f24add8f10a06d6f05b4d424ad9e96cfebf68a4ddc99c65c0720d00c2"}, +] + +[[package]] +name = "pycparser" +version = "3.0" +description = "C parser in Python" +optional = false +python-versions = ">=3.10" +groups = ["test"] +markers = "platform_python_implementation != \"PyPy\" and implementation_name != \"PyPy\" and python_version >= \"3.10\"" +files = [ + {file = "pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992"}, + {file = "pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29"}, +] + [[package]] name = "pydocstyle" version = "6.3.0" @@ -2520,12 +3019,12 @@ version = "4.16.0" description = "Backported and Experimental Type Hints for Python 3.9+" optional = false python-versions = ">=3.9" -groups = ["docs", "lint", "test"] +groups = ["main", "docs", "lint", "test"] files = [ {file = "typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8"}, {file = "typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5"}, ] -markers = {docs = "python_version < \"3.11\"", test = "python_version < \"3.11\""} +markers = {main = "extra == \"grpc\"", docs = "python_version < \"3.11\"", test = "python_version < \"3.11\""} [[package]] name = "urllib3" @@ -2586,7 +3085,10 @@ enabler = ["pytest-enabler (>=2.2)"] test = ["big-O", "jaraco.functools", "jaraco.itertools", "jaraco.test", "more_itertools", "pytest (>=6,!=8.1.*)", "pytest-ignore-flaky"] type = ["pytest-mypy"] +[extras] +grpc = ["grpcio"] + [metadata] lock-version = "2.1" python-versions = ">=3.9,<4.0" -content-hash = "a4d57b452106430e107a209c4d3ebb73c04e2d7710cdcd36b8c61dcdfe906418" +content-hash = "d36552e58d13a7c4a3c057b5153a55a5e96f3a32237a62b647266cf2548b7338" diff --git a/pyproject.toml b/pyproject.toml index 90b0f70..16dc5a6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -2,13 +2,13 @@ name = "nitlsconfig" version = "1.0.0.dev0" license = "MIT" -description = "Python API for reading nitlsconfig configurations" +description = "Python API for reading nitlsconfig configurations and creating gRPC client channels from them" authors = [{name = "NI", email = "opensource@ni.com"}] maintainers = [ {name = "Philip Thong", email = "philip.thong@emerson.com"}, ] readme = "README.md" -keywords = ["nitlsconfig", "tls", "configuration"] +keywords = ["nitlsconfig", "tls", "mtls", "grpc", "configuration"] classifiers = [ "Development Status :: 4 - Beta", "Intended Audience :: Developers", @@ -21,10 +21,16 @@ classifiers = [ "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", ] requires-python = ">=3.9" dynamic = ["dependencies"] +# Reading NI-TLS configuration is pure Python. Only nitlsconfig.grpc_channel +# needs grpcio, so the binary wheel is opt-in. +[project.optional-dependencies] +grpc = ["grpcio>=1.49.0,<2.0"] + [project.urls] repository = "https://github.com/ni/pypi-nitlsconfig" @@ -51,6 +57,7 @@ pytest = ">=7.2" pytest-cov = ">=4.0" pytest-mock = ">=3.0" pytest-env = ">=1.0,<2.0" +cryptography = ">=41.0" [tool.poetry.group.docs] optional = true @@ -85,6 +92,11 @@ files = "src/nitlsconfig/,tests/" namespace_packages = true strict = true +[[tool.mypy.overrides]] +# grpcio does not ship a py.typed marker. +module = "grpc.*" +ignore_missing_imports = true + [tool.bandit] skips = [ "B101", # assert_used diff --git a/src/nitlsconfig/__init__.py b/src/nitlsconfig/__init__.py index b9ea18a..6fafa83 100644 --- a/src/nitlsconfig/__init__.py +++ b/src/nitlsconfig/__init__.py @@ -1,4 +1,18 @@ -"Python package to read settings from nitlsconfig." +"""Python package to read settings from nitlsconfig and build connections from them. + +Reading configuration is pure Python and has no third-party dependencies. The +gRPC channel factory needs grpcio, which is an optional extra:: + + pip install nitlsconfig[grpc] + +The gRPC names below are therefore resolved lazily: importing this package never +imports grpcio, so a caller that only reads NI-TLS configuration does not pay +for a binary dependency it will not use. Additional transports can be added the +same way without changing what a bare install requires. +""" + +from importlib.metadata import version +from typing import TYPE_CHECKING, Any from nitlsconfig.cli import ( CertificateLocation, @@ -17,6 +31,27 @@ KnownServerData, ) +if TYPE_CHECKING: + # Imported eagerly for type checkers and editors, which do not run __getattr__. + from nitlsconfig.grpc_channel import ( + DEFAULT_SERVICE_NAME, + RetryPolicy, + TlsConfigurationError, + create_client_channel, + ) + +__version__ = version("nitlsconfig") + +# Names re-exported from nitlsconfig.grpc_channel, which requires grpcio. +_GRPC_EXPORTS = frozenset( + { + "DEFAULT_SERVICE_NAME", + "RetryPolicy", + "TlsConfigurationError", + "create_client_channel", + } +) + __all__ = [ "__version__", "CertificateLocation", @@ -33,4 +68,22 @@ "InvalidOutputError", "TrustedCertificateData", "KnownServerData", + "DEFAULT_SERVICE_NAME", + "RetryPolicy", + "TlsConfigurationError", + "create_client_channel", ] + + +def __getattr__(name: str) -> Any: + """Resolve gRPC exports on first use, so importing this package does not need grpcio.""" + if name in _GRPC_EXPORTS: + try: + from nitlsconfig import grpc_channel + except ImportError as exc: # pragma: no cover - requires an install without the extra + raise ImportError( + f"nitlsconfig.{name} requires grpcio, which is not installed. " + "Install it with: pip install nitlsconfig[grpc]" + ) from exc + return getattr(grpc_channel, name) + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/src/nitlsconfig/cli.py b/src/nitlsconfig/cli.py index 90846db..ea252ca 100644 --- a/src/nitlsconfig/cli.py +++ b/src/nitlsconfig/cli.py @@ -514,6 +514,25 @@ def certificate_mode(self) -> ClientCertMode: ClientCertMode.Unknown, ) + @property + def server_mode(self) -> ClientServerMode: + "Parse server_mode string into ClientServerMode enum, defaulting to Unknown." + return _parse_enum( + self._value("server_mode"), + ClientServerMode, + ClientServerMode.Unknown, + ) + + @property + def certificate_key_location(self) -> CertificateLocation: + "Return the parsed certificate_key_location from the service configuration." + return self._location("certificate_key_location") + + @property + def certificate_key_contents(self) -> str: + "Return the raw certificate_key_contents string from the service configuration." + return self._value("certificate_key_contents") + @property def known_servers(self) -> list[KnownServerData]: "Return the raw known_servers list from the service configuration." diff --git a/src/nitlsconfig/grpc_channel.py b/src/nitlsconfig/grpc_channel.py new file mode 100644 index 0000000..22e8cf0 --- /dev/null +++ b/src/nitlsconfig/grpc_channel.py @@ -0,0 +1,338 @@ +"""Create gRPC client channels from NI-TLS (nitlsconfig) client configuration. + +Reads the local NI-TLS client configuration for a service and produces a +:class:`grpc.Channel` that is either secured with TLS/mTLS or, when TLS is not +configured, a plain insecure channel. + +The resulting channel is a normal ``grpc.Channel``. It can be handed directly to +any NI gRPC Python API, for example:: + + from nitlsconfig.grpc_channel import create_client_channel + + channel = create_client_channel("localhost", 31763) + options = nidcpower.GrpcSessionOptions(channel, "") + with nidcpower.Session("Dev1", grpc_options=options) as session: + ... + +Channel ownership stays with the caller, matching the NI Python driver APIs, +which never close the channel themselves. ``grpc.Channel`` is already a context +manager, so ``with create_client_channel(...) as channel:`` works as expected. + +A client ``server_mode`` of ``TrustAlways`` is not currently supported and raises +:class:`TlsConfigurationError`. + +A client ``server_mode`` of ``SkipHostnameValidation`` is treated exactly like +``TrustedCertificates``: the server certificate chain is verified *and* the +hostname is checked. gRPC's Python API exposes no way to skip only the hostname +check. Doing so requires a custom certificate verifier, which is available in +C++ as ``grpc::experimental::TlsChannelCredentials`` with a +``HostNameCertificateVerifier``/``NoOpCertificateVerifier`` but is not bound in +grpcio's Python layer, whose TLS surface is limited to +``grpc.ssl_channel_credentials``. The nigrpctls C++ transport ignores this mode +for the same reason, so both implementations agree; only the Thrift transport +honors it. Verifying when asked not to fails closed, so this is safe, but a +caller who sets the mode gets no relaxation of the hostname check. + +When the server certificate's CN/SAN does not match the dialed host, pass +``grpc.ssl_target_name_override`` via ``options`` instead. That substitutes the +name gRPC matches against the certificate; it does not disable verification. +""" + +from __future__ import annotations + +import json +from dataclasses import dataclass +from typing import Any, Optional, Sequence, Tuple + +import grpc + +from nitlsconfig.cli import ( + CertificateLocation, + ClientCertMode, + ClientConfig, + ClientServerMode, + LocationScheme, + NitlsconfigCliError, +) + +__all__ = [ + "DEFAULT_SERVICE_NAME", + "RetryPolicy", + "TlsConfigurationError", + "create_client_channel", +] + +# The nitlsconfig service name registered by the NI gRPC Device Server. It is +# the file stem of ni-grpc-device.client.caps.yml, which grpc-device installs +# into the nitlsconfig client.d directory. +DEFAULT_SERVICE_NAME = "ni-grpc-device" + +# gRPC channel argument that carries a service config JSON document. +_SERVICE_CONFIG_ARG = "grpc.service_config" + +ChannelOptions = Sequence[Tuple[str, Any]] + + +def _format_target(server_address: str, server_port: int) -> str: + """Join an address and port into a gRPC target, bracketing IPv6 literals. + + gRPC requires IPv6 literals in brackets: ``::1:31763`` never connects, + while ``[::1]:31763`` does. An unbracketed address containing a colon can + only be an IPv6 literal, since neither host names nor IPv4 addresses may + contain one. + """ + if ":" in server_address and not server_address.startswith("["): + return f"[{server_address}]:{server_port}" + return f"{server_address}:{server_port}" + + +class TlsConfigurationError(NitlsconfigCliError): + """Raised when the NI-TLS configuration was read successfully but is invalid.""" + + +@dataclass(frozen=True) +class RetryPolicy: + """Client retry behavior, realized as a gRPC service config. + + The mechanism and backoff algorithm are defined by gRFC A6 (gRPC Retry + Design); the values below are this package's defaults. Retries are opt-in: + gRPC configures no retry policy unless one is supplied. + + The policy applies to every method, including non-idempotent ones. + ``UNAVAILABLE`` does not guarantee the server never processed the request, + so a retried operation can be applied twice. It also covers TLS handshake + failures, which gRPC reports as ``UNAVAILABLE``. + """ + + max_attempts: int = 5 + initial_delay_ms: int = 100 + max_delay_ms: int = 1000 + backoff_multiplier: float = 2.0 + + +@dataclass(frozen=True) +class _ClientTlsSettings: + """Validated client TLS settings, ready to be turned into channel credentials. + + The trust anchor *scheme* is deliberately not carried here. gRPC consumes + only the in-memory PEM, and SystemDefault is already represented by empty + ``trusted_contents``, which :func:`_pem_bytes` maps to None. Add the scheme + back if a future caller needs to distinguish the two. + """ + + present_client_cert: bool + certificate_chain_contents: str + private_key_contents: str + trusted_contents: str + + +def _milliseconds_to_duration(milliseconds: int) -> str: + """Format milliseconds as a gRPC duration string, e.g. 100 -> '0.100s'.""" + milliseconds = max(milliseconds, 0) + return f"{milliseconds // 1000}.{milliseconds % 1000:03d}s" + + +def _build_retry_service_config(policy: RetryPolicy) -> Optional[str]: + """Build a gRPC service config JSON document that enables retries. + + Returns None when the policy asks for no retries. gRPC requires + maxAttempts >= 2, strictly positive backoffs, and a strictly positive + backoff multiplier, so anything less means "do not configure retries" + rather than an error. + """ + if ( + policy.max_attempts < 2 + or policy.initial_delay_ms <= 0 + or policy.max_delay_ms <= 0 + or policy.backoff_multiplier <= 0 + ): + return None + + return json.dumps( + { + "methodConfig": [ + { + # An empty method name applies the policy to every method. + "name": [{}], + "retryPolicy": { + "maxAttempts": policy.max_attempts, + "initialBackoff": _milliseconds_to_duration(policy.initial_delay_ms), + "maxBackoff": _milliseconds_to_duration(policy.max_delay_ms), + "backoffMultiplier": policy.backoff_multiplier, + "retryableStatusCodes": ["UNAVAILABLE"], + }, + } + ] + } + ) + + +def _apply_retry_policy( + options: ChannelOptions, policy: Optional[RetryPolicy] +) -> list[Tuple[str, Any]]: + """Return channel options with the retry service config appended, if applicable.""" + channel_options = list(options) + if policy is None: + return channel_options + + # gRPC resolves duplicate entries by taking the first, so a caller-supplied + # service config already takes effect and must not be overridden. + if any(key == _SERVICE_CONFIG_ARG for key, _ in channel_options): + return channel_options + + service_config = _build_retry_service_config(policy) + if service_config is not None: + channel_options.append((_SERVICE_CONFIG_ARG, service_config)) + return channel_options + + +def _require_file_scheme( + location: CertificateLocation, description: str, service_name: str +) -> None: + """Validate that a certificate or key location is a usable File:// path. + + The ni-grpc-device client capabilities declare support for the File scheme + only, so any other scheme is rejected rather than silently ignored. + """ + if location.scheme != LocationScheme.File: + raise TlsConfigurationError( + f"Client {description} must use the File scheme for service " + f"{service_name!r}, got {location.scheme.value!r}." + ) + if not location.path: + raise TlsConfigurationError( + f"TLS is enabled but the client {description} path is missing for " + f"service {service_name!r}." + ) + + +def _load_client_tls_settings(config: ClientConfig) -> Optional[_ClientTlsSettings]: + """Read and validate client TLS settings, or None when TLS is not in use. + + ``server_mode`` is the master switch: when it is Disabled the client uses a + plain connection and ``certificate_mode`` is not consulted at all. + """ + service_name = config.service_name + + server_mode = config.server_mode + if server_mode == ClientServerMode.Disabled: + return None + if server_mode == ClientServerMode.TrustAlways: + # ni-grpc-device.client.caps.yml declares supports_server_mode_trust_always: false, + # so this mode is not offered for this service. + raise TlsConfigurationError( + f"Client server_mode TrustAlways is not supported for service {service_name!r}." + ) + if server_mode == ClientServerMode.Unknown: + raise TlsConfigurationError(f"Unsupported client server_mode for service {service_name!r}.") + + # certificate_mode only decides mTLS versus one-way TLS. + if config.certificate_mode == ClientCertMode.Unknown: + raise TlsConfigurationError( + f"Unsupported client certificate_mode for service {service_name!r}." + ) + present_client_cert = config.certificate_mode != ClientCertMode.Disabled + + certificate_chain_contents = "" + private_key_contents = "" + if present_client_cert: + _require_file_scheme(config.certificate_chain_location, "certificate chain", service_name) + _require_file_scheme(config.certificate_key_location, "certificate key", service_name) + certificate_chain_contents = config.certificate_chain_contents + private_key_contents = config.certificate_key_contents + + # Trust anchors are always required: the client must verify the server. + trusted_location = config.trusted_certificates_location + trusted_available = trusted_location.scheme == LocationScheme.SystemDefault or bool( + trusted_location.path + ) + if not trusted_available: + raise TlsConfigurationError( + "TLS is enabled but the client trusted certificates path is missing for " + f"service {service_name!r}." + ) + + trusted_contents = "" + if trusted_location.scheme != LocationScheme.SystemDefault: + trusted_contents = config.trusted_certificates_contents + + return _ClientTlsSettings( + present_client_cert=present_client_cert, + certificate_chain_contents=certificate_chain_contents, + private_key_contents=private_key_contents, + trusted_contents=trusted_contents, + ) + + +def _pem_bytes(contents: str) -> Optional[bytes]: + """Encode PEM text for gRPC, mapping empty contents to None. + + nitlsconfig represents the SystemDefault trust scheme as empty contents, + meaning "use the platform default certificate store". Python requires None + for that behavior: passing empty bytes would instead configure an empty + trust store and fail every connection. + """ + return contents.encode("utf-8") if contents else None + + +def _make_client_credentials(settings: _ClientTlsSettings) -> grpc.ChannelCredentials: + """Build channel credentials from validated client TLS settings.""" + certificate_chain = None + private_key = None + if settings.present_client_cert: + certificate_chain = _pem_bytes(settings.certificate_chain_contents) + private_key = _pem_bytes(settings.private_key_contents) + + return grpc.ssl_channel_credentials( + root_certificates=_pem_bytes(settings.trusted_contents), + private_key=private_key, + certificate_chain=certificate_chain, + ) + + +def create_client_channel( + server_address: str, + server_port: int, + service_name: str = DEFAULT_SERVICE_NAME, + options: ChannelOptions = (), + retry_policy: Optional[RetryPolicy] = None, +) -> grpc.Channel: + """Create a gRPC channel to ``server_address:server_port`` using NI-TLS configuration. + + Reads the NI-TLS client configuration for ``service_name`` and builds a + channel that verifies the server certificate and, when the configuration + calls for mutual TLS, also presents the client certificate. Falls back to an + insecure channel when the client's ``server_mode`` is Disabled, which is the + default until the machine is configured. + + Args: + server_address: Host name or address of the NI gRPC Device Server. IPv6 + literals may be passed with or without brackets. + server_port: Port of the NI gRPC Device Server. + service_name: nitlsconfig service name to read configuration from. + options: gRPC channel arguments, as ``(key, value)`` pairs. Use this to + tune the channel, for example to raise message size limits or to set + ``grpc.ssl_target_name_override`` when the server certificate's + CN/SAN differs from the dialed host. Channel arguments cannot be + changed after the channel is built, so they must be supplied here. + retry_policy: Optional client retry configuration. When None (the + default) no retry service config is added and gRPC's built-in + behavior applies. Pass ``RetryPolicy()`` for the defaults described + on that class. + + Returns: + A ``grpc.Channel`` owned by the caller. Close it when the last session + using it is done. + + Raises: + TlsConfigurationError: TLS is enabled but the configuration is invalid. + NitlsconfigCliError: The nitlsconfig CLI could not be run or parsed. + """ + target = _format_target(server_address, server_port) + channel_options = _apply_retry_policy(options, retry_policy) + + settings = _load_client_tls_settings(ClientConfig(service_name)) + if settings is None: + return grpc.insecure_channel(target, options=channel_options) + + return grpc.secure_channel(target, _make_client_credentials(settings), options=channel_options) diff --git a/tests/unit/certificates.py b/tests/unit/certificates.py new file mode 100644 index 0000000..d7416af --- /dev/null +++ b/tests/unit/certificates.py @@ -0,0 +1,94 @@ +"""Generate throwaway certificates for TLS handshake tests. + +Everything here is created in memory, at test time, and lives only for the +duration of the test session. No key material is stored in the repository. +""" + +import datetime +import ipaddress +from typing import NamedTuple + +from cryptography import x509 +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import rsa +from cryptography.x509.oid import NameOID + + +class Identity(NamedTuple): + """A certificate and its private key, both PEM encoded.""" + + certificate_pem: bytes + private_key_pem: bytes + + +class CertificateAuthority: + """A self-signed CA that can issue server and client certificates.""" + + def __init__(self, common_name: str) -> None: + """Create a self-signed CA certificate and key.""" + self._key = _new_key() + self._name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, common_name)]) + self._certificate = ( + _base_builder(self._name, self._name, self._key.public_key()) + .add_extension(x509.BasicConstraints(ca=True, path_length=None), critical=True) + .sign(self._key, hashes.SHA256()) + ) + + @property + def certificate_pem(self) -> bytes: + """The CA certificate, used as a trust anchor.""" + return self._certificate.public_bytes(serialization.Encoding.PEM) + + def issue(self, common_name: str, server: bool) -> Identity: + """Issue a leaf certificate signed by this CA. + + Server certificates get localhost SANs so gRPC's hostname verification + succeeds against a channel dialed at localhost. + """ + key = _new_key() + builder = _base_builder( + x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, common_name)]), + self._name, + key.public_key(), + ).add_extension(x509.BasicConstraints(ca=False, path_length=None), critical=True) + + if server: + builder = builder.add_extension( + x509.SubjectAlternativeName( + [ + x509.DNSName("localhost"), + x509.IPAddress(ipaddress.ip_address("127.0.0.1")), + ] + ), + critical=False, + ) + + certificate = builder.sign(self._key, hashes.SHA256()) + return Identity( + certificate_pem=certificate.public_bytes(serialization.Encoding.PEM), + private_key_pem=key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.TraditionalOpenSSL, + encryption_algorithm=serialization.NoEncryption(), + ), + ) + + +def _new_key() -> rsa.RSAPrivateKey: + # 2048 bits keeps generation fast enough to run on every test session. + return rsa.generate_private_key(public_exponent=65537, key_size=2048) + + +def _base_builder( + subject: x509.Name, issuer: x509.Name, public_key: rsa.RSAPublicKey +) -> x509.CertificateBuilder: + now = datetime.datetime.now(datetime.timezone.utc) + return ( + x509.CertificateBuilder() + .subject_name(subject) + .issuer_name(issuer) + .public_key(public_key) + .serial_number(x509.random_serial_number()) + .not_valid_before(now - datetime.timedelta(minutes=5)) + .not_valid_after(now + datetime.timedelta(hours=1)) + ) diff --git a/tests/unit/fake_config.py b/tests/unit/fake_config.py new file mode 100644 index 0000000..fd71f93 --- /dev/null +++ b/tests/unit/fake_config.py @@ -0,0 +1,34 @@ +"""Shared test double for nitlsconfig.cli.ClientConfig.""" + +from typing import Any + +from nitlsconfig.cli import CertificateLocation, ClientCertMode, ClientServerMode, LocationScheme + +SERVICE_NAME = "ni-grpc-device" + +FILE_CERT = CertificateLocation(LocationScheme.File, "cert.pem") +FILE_KEY = CertificateLocation(LocationScheme.File, "key.pem") +FILE_TRUST = CertificateLocation(LocationScheme.File, "trust.pem") +SYSTEM_DEFAULT_TRUST = CertificateLocation(LocationScheme.SystemDefault) + + +class FakeClientConfig: + """Stand-in for ClientConfig that skips the nitlsconfig CLI.""" + + def __init__(self, **overrides: Any) -> None: + """Initialize a configuration with insecure defaults, then apply overrides.""" + self.service_name = SERVICE_NAME + self.server_mode = ClientServerMode.Disabled + self.certificate_mode = ClientCertMode.Disabled + self.certificate_chain_location = CertificateLocation(LocationScheme.Unknown) + self.certificate_chain_contents = "" + self.certificate_key_location = CertificateLocation(LocationScheme.Unknown) + self.certificate_key_contents = "" + self.trusted_certificates_location = CertificateLocation(LocationScheme.Unknown) + self.trusted_certificates_contents = "" + # The defaults above are insecure, so a misspelled override would silently + # leave TLS off and still let the test pass. Reject unknown names instead. + unknown = sorted(set(overrides) - set(self.__dict__)) + if unknown: + raise AttributeError(f"FakeClientConfig has no field(s): {', '.join(unknown)}") + self.__dict__.update(overrides) diff --git a/tests/unit/test_grpc_channel.py b/tests/unit/test_grpc_channel.py new file mode 100644 index 0000000..abb7ee8 --- /dev/null +++ b/tests/unit/test_grpc_channel.py @@ -0,0 +1,370 @@ +"Pytests for nitlsconfig.grpc_channel." + +import json +from typing import Any, Optional, Sequence, Tuple + +import grpc +import pytest +from fake_config import ( + FakeClientConfig, + FILE_CERT, + FILE_KEY, + FILE_TRUST, + SYSTEM_DEFAULT_TRUST, +) + +from nitlsconfig import grpc_channel +from nitlsconfig.cli import ( + CertificateLocation, + ClientCertMode, + ClientServerMode, + LocationScheme, +) + +TARGET = "localhost:31763" + + +class RecordedChannel: + """Records how the channel was created so tests can assert on it.""" + + def __init__( + self, + secure: bool, + target: str, + options: Sequence[Tuple[str, Any]], + credentials: Optional[dict[str, Any]] = None, + ) -> None: + """Record the arguments used to create the channel.""" + self.secure = secure + self.target = target + self.options = list(options) + self.credentials = credentials + + +@pytest.fixture(autouse=True) +def fake_grpc(monkeypatch: pytest.MonkeyPatch) -> None: + """Replace grpc channel construction so no real connection is attempted.""" + + def fake_ssl_channel_credentials(**kwargs: Any) -> dict[str, Any]: + return kwargs + + def fake_secure_channel( + target: str, credentials: dict[str, Any], options: Sequence[Tuple[str, Any]] = () + ) -> RecordedChannel: + return RecordedChannel(True, target, options, credentials) + + def fake_insecure_channel( + target: str, options: Sequence[Tuple[str, Any]] = () + ) -> RecordedChannel: + return RecordedChannel(False, target, options) + + monkeypatch.setattr(grpc, "ssl_channel_credentials", fake_ssl_channel_credentials) + monkeypatch.setattr(grpc, "secure_channel", fake_secure_channel) + monkeypatch.setattr(grpc, "insecure_channel", fake_insecure_channel) + + +def create_channel( + monkeypatch: pytest.MonkeyPatch, config: FakeClientConfig, **kwargs: Any +) -> RecordedChannel: + """Create a channel using the supplied configuration instead of the real CLI.""" + monkeypatch.setattr(grpc_channel, "ClientConfig", lambda service_name: config) + channel = grpc_channel.create_client_channel("localhost", 31763, **kwargs) + assert isinstance(channel, RecordedChannel) + return channel + + +def test_server_mode_disabled_is_insecure( + monkeypatch: pytest.MonkeyPatch, +) -> None: + channel = create_channel(monkeypatch, FakeClientConfig()) + + assert not channel.secure + assert channel.target == TARGET + + +@pytest.mark.parametrize( + ("server_address", "expected_target"), + [ + pytest.param("localhost", "localhost:31763", id="host_name"), + pytest.param("127.0.0.1", "127.0.0.1:31763", id="ipv4"), + pytest.param("::1", "[::1]:31763", id="ipv6_literal"), + pytest.param("[::1]", "[::1]:31763", id="ipv6_already_bracketed"), + ], +) +def test_ipv6_addresses_are_bracketed( + monkeypatch: pytest.MonkeyPatch, server_address: str, expected_target: str +) -> None: + """An unbracketed IPv6 literal such as '::1:31763' never connects. + + Verified against a live gRPC server: the bracketed form connects and the + bare form times out. The failure surfaces at the first RPC rather than at + channel creation, so it is invisible without this assertion. + """ + monkeypatch.setattr(grpc_channel, "ClientConfig", lambda service_name: FakeClientConfig()) + + channel = grpc_channel.create_client_channel(server_address, 31763) + + assert isinstance(channel, RecordedChannel) + assert channel.target == expected_target + + +def test_certificate_mode_does_not_enable_tls( + monkeypatch: pytest.MonkeyPatch, +) -> None: + # server_mode is the master switch: certificate_mode must not enable TLS on its own. + config = FakeClientConfig( + server_mode=ClientServerMode.Disabled, + certificate_mode=ClientCertMode.Managed, + certificate_chain_location=FILE_CERT, + certificate_key_location=FILE_KEY, + ) + + channel = create_channel(monkeypatch, config) + + assert not channel.secure + + +def test_system_default_trust_uses_platform_store( + monkeypatch: pytest.MonkeyPatch, +) -> None: + # None means "use the platform trust store". Empty bytes would build an empty + # trust store and fail every connection. + config = FakeClientConfig( + server_mode=ClientServerMode.TrustedCertificates, + certificate_mode=ClientCertMode.Disabled, + trusted_certificates_location=SYSTEM_DEFAULT_TRUST, + ) + + channel = create_channel(monkeypatch, config) + + assert channel.secure + assert channel.credentials == { + "root_certificates": None, + "private_key": None, + "certificate_chain": None, + } + + +def test_mutual_tls_credentials( + monkeypatch: pytest.MonkeyPatch, +) -> None: + config = FakeClientConfig( + server_mode=ClientServerMode.TrustedCertificates, + certificate_mode=ClientCertMode.Managed, + certificate_chain_location=FILE_CERT, + certificate_chain_contents="CERT", + certificate_key_location=FILE_KEY, + certificate_key_contents="KEY", + trusted_certificates_location=FILE_TRUST, + trusted_certificates_contents="ROOT", + ) + + channel = create_channel(monkeypatch, config) + + assert channel.secure + assert channel.credentials == { + "root_certificates": b"ROOT", + "private_key": b"KEY", + "certificate_chain": b"CERT", + } + + +def test_one_way_tls_credentials( + monkeypatch: pytest.MonkeyPatch, +) -> None: + config = FakeClientConfig( + server_mode=ClientServerMode.TrustedCertificates, + certificate_mode=ClientCertMode.Disabled, + trusted_certificates_location=FILE_TRUST, + trusted_certificates_contents="ROOT", + ) + + channel = create_channel(monkeypatch, config) + + assert channel.credentials is not None + assert channel.credentials["root_certificates"] == b"ROOT" + assert channel.credentials["private_key"] is None + assert channel.credentials["certificate_chain"] is None + + +def test_skip_hostname_validation_matches_trusted_certificates( + monkeypatch: pytest.MonkeyPatch, +) -> None: + # grpc's Python API cannot skip only the hostname check, so this mode is + # deliberately treated as TrustedCertificates. nigrpctls does the same. + def config(server_mode: ClientServerMode) -> FakeClientConfig: + return FakeClientConfig( + server_mode=server_mode, + certificate_mode=ClientCertMode.Disabled, + trusted_certificates_location=FILE_TRUST, + trusted_certificates_contents="ROOT", + ) + + strict = create_channel(monkeypatch, config(ClientServerMode.TrustedCertificates)) + skipped = create_channel(monkeypatch, config(ClientServerMode.SkipHostnameValidation)) + + assert skipped.secure + assert skipped.credentials == strict.credentials + assert skipped.options == strict.options + + +@pytest.mark.parametrize( + "config", + [ + pytest.param( + FakeClientConfig(server_mode=ClientServerMode.TrustAlways), + id="trust_always_unsupported", + ), + pytest.param( + FakeClientConfig(server_mode=ClientServerMode.Unknown), + id="unknown_server_mode", + ), + pytest.param( + FakeClientConfig( + server_mode=ClientServerMode.TrustedCertificates, + certificate_mode=ClientCertMode.Unknown, + trusted_certificates_location=SYSTEM_DEFAULT_TRUST, + ), + id="unknown_certificate_mode", + ), + pytest.param( + FakeClientConfig( + server_mode=ClientServerMode.TrustedCertificates, + certificate_mode=ClientCertMode.Managed, + certificate_chain_location=CertificateLocation(LocationScheme.Directory, "d"), + certificate_key_location=FILE_KEY, + trusted_certificates_location=FILE_TRUST, + ), + id="non_file_certificate_scheme", + ), + pytest.param( + FakeClientConfig( + server_mode=ClientServerMode.TrustedCertificates, + certificate_mode=ClientCertMode.Managed, + certificate_chain_location=CertificateLocation(LocationScheme.File), + certificate_key_location=FILE_KEY, + trusted_certificates_location=FILE_TRUST, + ), + id="empty_certificate_path", + ), + pytest.param( + FakeClientConfig( + server_mode=ClientServerMode.TrustedCertificates, + certificate_mode=ClientCertMode.Disabled, + trusted_certificates_location=CertificateLocation(LocationScheme.File), + ), + id="missing_trust_anchors", + ), + ], +) +def test_invalid_configuration_raises( + monkeypatch: pytest.MonkeyPatch, config: FakeClientConfig +) -> None: + monkeypatch.setattr(grpc_channel, "ClientConfig", lambda service_name: config) + + with pytest.raises(grpc_channel.TlsConfigurationError): + grpc_channel.create_client_channel("localhost", 31763) + + +def test_service_name_is_forwarded( + monkeypatch: pytest.MonkeyPatch, +) -> None: + # The helper discards service_name, so plumbing needs its own check. + requested = [] + + def record_client_config(service_name: str) -> FakeClientConfig: + requested.append(service_name) + return FakeClientConfig() + + monkeypatch.setattr(grpc_channel, "ClientConfig", record_client_config) + + grpc_channel.create_client_channel("localhost", 31763) + grpc_channel.create_client_channel("localhost", 31763, service_name="other-service") + + assert requested == [grpc_channel.DEFAULT_SERVICE_NAME, "other-service"] + + +def test_no_retry_policy_omits_service_config( + monkeypatch: pytest.MonkeyPatch, +) -> None: + channel = create_channel(monkeypatch, FakeClientConfig()) + + assert channel.options == [] + + +def test_retry_policy_service_config( + monkeypatch: pytest.MonkeyPatch, +) -> None: + channel = create_channel( + monkeypatch, FakeClientConfig(), retry_policy=grpc_channel.RetryPolicy() + ) + + (key, value) = channel.options[0] + assert key == "grpc.service_config" + retry_policy = json.loads(value)["methodConfig"][0]["retryPolicy"] + assert retry_policy == { + "maxAttempts": 5, + "initialBackoff": "0.100s", + "maxBackoff": "1.000s", + "backoffMultiplier": 2.0, + "retryableStatusCodes": ["UNAVAILABLE"], + } + + +def test_caller_service_config_wins( + monkeypatch: pytest.MonkeyPatch, +) -> None: + caller_options = [("grpc.service_config", "{}")] + + channel = create_channel( + monkeypatch, + FakeClientConfig(), + options=caller_options, + retry_policy=grpc_channel.RetryPolicy(), + ) + + assert channel.options == caller_options + + +@pytest.mark.parametrize( + "retry_policy", + [ + pytest.param(grpc_channel.RetryPolicy(max_attempts=1), id="single_attempt"), + pytest.param(grpc_channel.RetryPolicy(initial_delay_ms=0), id="no_initial_delay"), + pytest.param(grpc_channel.RetryPolicy(max_delay_ms=0), id="no_max_delay"), + pytest.param(grpc_channel.RetryPolicy(backoff_multiplier=0), id="no_backoff_multiplier"), + ], +) +def test_disabled_retry_policy_omits_service_config( + monkeypatch: pytest.MonkeyPatch, retry_policy: grpc_channel.RetryPolicy +) -> None: + # gRPC rejects these values, so they mean "no retries" rather than an error. + channel = create_channel(monkeypatch, FakeClientConfig(), retry_policy=retry_policy) + + assert channel.options == [] + + +def test_retry_delays_format_as_durations( + monkeypatch: pytest.MonkeyPatch, +) -> None: + channel = create_channel( + monkeypatch, + FakeClientConfig(), + retry_policy=grpc_channel.RetryPolicy(initial_delay_ms=2500, max_delay_ms=90000), + ) + + retry_policy = json.loads(channel.options[0][1])["methodConfig"][0]["retryPolicy"] + assert retry_policy["initialBackoff"] == "2.500s" + assert retry_policy["maxBackoff"] == "90.000s" + + +def test_caller_options_preserved( + monkeypatch: pytest.MonkeyPatch, +) -> None: + channel = create_channel( + monkeypatch, + FakeClientConfig(), + options=[("grpc.max_receive_message_length", 1024)], + ) + + assert channel.options == [("grpc.max_receive_message_length", 1024)] diff --git a/tests/unit/test_grpc_channel_real.py b/tests/unit/test_grpc_channel_real.py new file mode 100644 index 0000000..061e6ee --- /dev/null +++ b/tests/unit/test_grpc_channel_real.py @@ -0,0 +1,112 @@ +"""Tests that build real grpc.Channel objects, with no gRPC mocking. + +Every test in test_grpc_channel.py replaces ``grpc.secure_channel``, +``grpc.insecure_channel`` and ``grpc.ssl_channel_credentials``, so those tests +never exercise gRPC itself. These tests deliberately do not, so that the objects +handed to NI driver APIs are the genuine article. + +gRPC creates channels lazily: no connection, DNS lookup or handshake happens +until the first RPC, so these tests do no I/O and need no server. + +Scope: this file validates that gRPC *accepts* what we build. It cannot validate +certificate material, because gRPC does not parse PEM at channel-creation time +(verified: junk PEM bytes are accepted without error and only fail during the +handshake). Proving that credentials actually authenticate requires a live +server and generated certificates. +""" + +import grpc +import pytest +from fake_config import ( + FakeClientConfig, + FILE_CERT, + FILE_KEY, + FILE_TRUST, + SYSTEM_DEFAULT_TRUST, +) + +from nitlsconfig import grpc_channel +from nitlsconfig.cli import ClientCertMode, ClientServerMode + +# Not real key material. gRPC does not parse these at channel creation; they +# exist only so the TLS code path runs with non-empty contents. +PLACEHOLDER_PEM = "-----BEGIN CERTIFICATE-----\nQUJD\n-----END CERTIFICATE-----\n" + +INSECURE = FakeClientConfig() + +ONE_WAY_TLS = FakeClientConfig( + server_mode=ClientServerMode.TrustedCertificates, + certificate_mode=ClientCertMode.Disabled, + trusted_certificates_location=FILE_TRUST, + trusted_certificates_contents=PLACEHOLDER_PEM, +) + +MUTUAL_TLS = FakeClientConfig( + server_mode=ClientServerMode.TrustedCertificates, + certificate_mode=ClientCertMode.Managed, + certificate_chain_location=FILE_CERT, + certificate_chain_contents=PLACEHOLDER_PEM, + certificate_key_location=FILE_KEY, + certificate_key_contents=PLACEHOLDER_PEM, + trusted_certificates_location=FILE_TRUST, + trusted_certificates_contents=PLACEHOLDER_PEM, +) + +SYSTEM_DEFAULT_TLS = FakeClientConfig( + server_mode=ClientServerMode.TrustedCertificates, + certificate_mode=ClientCertMode.Disabled, + trusted_certificates_location=SYSTEM_DEFAULT_TRUST, +) + + +@pytest.mark.parametrize( + "config", + [ + pytest.param(INSECURE, id="insecure"), + pytest.param(ONE_WAY_TLS, id="one_way_tls"), + pytest.param(SYSTEM_DEFAULT_TLS, id="system_default_trust"), + ], +) +def test_returns_a_usable_grpc_channel( + monkeypatch: pytest.MonkeyPatch, config: FakeClientConfig +) -> None: + """Every configuration produces a real channel with the API drivers rely on.""" + monkeypatch.setattr(grpc_channel, "ClientConfig", lambda service_name: config) + + with grpc_channel.create_client_channel("localhost", 31763) as channel: + # nimi-python and nidaqmx-python pass this straight to GrpcSessionOptions, + # which requires a grpc.Channel and calls these factories on it. + assert isinstance(channel, grpc.Channel) + assert callable(channel.unary_unary) + assert callable(channel.stream_stream) + + +def test_channel_options_are_accepted_by_grpc( + monkeypatch: pytest.MonkeyPatch, capfd: pytest.CaptureFixture[str] +) -> None: + """Our retry service config and caller options are parsed by gRPC without complaint. + + A malformed service config does not raise; gRPC logs + "channel stack builder failed" to stderr from native code and the channel is + silently left without the policy. capfd captures at the file-descriptor + level, so it sees that native output. + + Scope, established by mutation testing: this catches malformed *JSON syntax* + only. gRPC does not validate the service config schema at channel-creation + time, so an unknown key ("methodConfigTYPO") or a bad duration format + ("100" instead of "0.100s") is accepted here and only takes effect - or + fails to - once RPCs flow. The shape of the document is asserted separately + in test_grpc_channel.py. + """ + monkeypatch.setattr(grpc_channel, "ClientConfig", lambda service_name: MUTUAL_TLS) + capfd.readouterr() + + with grpc_channel.create_client_channel( + "localhost", + 31763, + options=[("grpc.max_receive_message_length", 4 * 1024 * 1024)], + retry_policy=grpc_channel.RetryPolicy(), + ) as channel: + assert isinstance(channel, grpc.Channel) + + assert "channel stack builder failed" not in capfd.readouterr().err diff --git a/tests/unit/test_grpc_channel_tls.py b/tests/unit/test_grpc_channel_tls.py new file mode 100644 index 0000000..626087e --- /dev/null +++ b/tests/unit/test_grpc_channel_tls.py @@ -0,0 +1,159 @@ +"""End-to-end mutual TLS tests against a real in-process gRPC server. + +These are the only tests that prove the credentials built from nitlsconfig +configuration actually authenticate. Everything else in the suite stops at +"gRPC accepted our bytes", which is a low bar: gRPC does not parse PEM at +channel-creation time and will happily accept junk that fails at handshake. + +Two facts are asserted: + +* a client configured for mTLS completes a real RPC against a server that + requires and verifies client certificates, and +* the same client refuses a server whose certificate comes from a different CA. + +The second matters most. Without it, a bug that trusted everything would look +identical to a working implementation. + +Certificates are generated in memory per test session; nothing is stored in the +repository. +""" + +from concurrent import futures +from typing import Iterator, Tuple + +import grpc +import pytest +from certificates import CertificateAuthority, Identity +from fake_config import FakeClientConfig, FILE_CERT, FILE_KEY, FILE_TRUST + +from nitlsconfig import grpc_channel +from nitlsconfig.cli import ClientCertMode, ClientServerMode + +_METHOD = "/nitlsconfig.Echo/Say" +_RPC_TIMEOUT_SECONDS = 10 + + +def _identity(data: bytes) -> bytes: + """Serialize and deserialize as raw bytes, so no protobuf schema is needed.""" + return data + + +@pytest.fixture(scope="module") +def certificate_authority() -> CertificateAuthority: + """The CA the client is configured to trust.""" + return CertificateAuthority("nitlsconfig-test-ca") + + +@pytest.fixture(scope="module") +def client_identity(certificate_authority: CertificateAuthority) -> Identity: + """The client certificate presented for mutual TLS.""" + return certificate_authority.issue("nitlsconfig-test-client", server=False) + + +def _serve(server_identity: Identity, client_trust_pem: bytes) -> Tuple[grpc.Server, int]: + """Start a gRPC server that requires and verifies client certificates.""" + server = grpc.server(futures.ThreadPoolExecutor(max_workers=1)) + server.add_generic_rpc_handlers( + ( + grpc.method_handlers_generic_handler( + "nitlsconfig.Echo", + { + "Say": grpc.unary_unary_rpc_method_handler( + lambda request, context: request, + request_deserializer=_identity, + response_serializer=_identity, + ) + }, + ), + ) + ) + credentials = grpc.ssl_server_credentials( + [(server_identity.private_key_pem, server_identity.certificate_pem)], + root_certificates=client_trust_pem, + require_client_auth=True, + ) + # Port 0 lets the OS pick a free port, so parallel or repeated runs cannot collide. + port = server.add_secure_port("localhost:0", credentials) + server.start() + return server, port + + +@pytest.fixture(scope="module") +def trusted_server( + certificate_authority: CertificateAuthority, +) -> Iterator[int]: + """A server whose certificate chains to the CA the client trusts.""" + identity = certificate_authority.issue("localhost", server=True) + server, port = _serve(identity, certificate_authority.certificate_pem) + yield port + server.stop(grace=None) + + +@pytest.fixture(scope="module") +def untrusted_server( + certificate_authority: CertificateAuthority, +) -> Iterator[int]: + """A server whose certificate chains to a CA the client does not trust.""" + rogue = CertificateAuthority("nitlsconfig-rogue-ca") + identity = rogue.issue("localhost", server=True) + # Still trusts our client's CA, so the only thing under test is whether the + # client accepts the server. Otherwise the server would reject us first. + server, port = _serve(identity, certificate_authority.certificate_pem) + yield port + server.stop(grace=None) + + +def _mutual_tls_config( + certificate_authority: CertificateAuthority, client_identity: Identity +) -> FakeClientConfig: + return FakeClientConfig( + server_mode=ClientServerMode.TrustedCertificates, + certificate_mode=ClientCertMode.Managed, + certificate_chain_location=FILE_CERT, + certificate_chain_contents=client_identity.certificate_pem.decode(), + certificate_key_location=FILE_KEY, + certificate_key_contents=client_identity.private_key_pem.decode(), + trusted_certificates_location=FILE_TRUST, + trusted_certificates_contents=certificate_authority.certificate_pem.decode(), + ) + + +def test_mutual_tls_handshake_succeeds( + monkeypatch: pytest.MonkeyPatch, + certificate_authority: CertificateAuthority, + client_identity: Identity, + trusted_server: int, +) -> None: + """A real RPC completes over mutual TLS using credentials we built.""" + config = _mutual_tls_config(certificate_authority, client_identity) + monkeypatch.setattr(grpc_channel, "ClientConfig", lambda service_name: config) + + with grpc_channel.create_client_channel("localhost", trusted_server) as channel: + say = channel.unary_unary( + _METHOD, request_serializer=_identity, response_deserializer=_identity + ) + assert say(b"ping", timeout=_RPC_TIMEOUT_SECONDS) == b"ping" + + +def test_server_from_untrusted_ca_is_rejected( + monkeypatch: pytest.MonkeyPatch, + certificate_authority: CertificateAuthority, + client_identity: Identity, + untrusted_server: int, +) -> None: + """The client refuses a server that does not chain to its trust anchors. + + Without this, an implementation that trusted everything would pass every + other test in the suite. + """ + config = _mutual_tls_config(certificate_authority, client_identity) + monkeypatch.setattr(grpc_channel, "ClientConfig", lambda service_name: config) + + with grpc_channel.create_client_channel("localhost", untrusted_server) as channel: + say = channel.unary_unary( + _METHOD, request_serializer=_identity, response_deserializer=_identity + ) + with pytest.raises(grpc.RpcError) as failure: + say(b"ping", timeout=_RPC_TIMEOUT_SECONDS) + + assert failure.value.code() == grpc.StatusCode.UNAVAILABLE diff --git a/tests/unit/test_nitlsconfig.py b/tests/unit/test_nitlsconfig.py index eb2c562..347f861 100644 --- a/tests/unit/test_nitlsconfig.py +++ b/tests/unit/test_nitlsconfig.py @@ -3,12 +3,14 @@ import json import pathlib import platform -from typing import Mapping, TypedDict +from typing import Mapping, TypedDict, cast +import grpc import pytest import nitlsconfig import nitlsconfig.cli as nitlsconfig_cli +from nitlsconfig import grpc_channel TEST_DIR = pathlib.Path(__file__).resolve().parents[0] CLIENT_FIXTURE_PATH = TEST_DIR / "nitlsconfig_client.json" @@ -107,10 +109,13 @@ def test_client_info() -> None: assert client_info.service_name == "ni-test" assert client_info.certificate_mode == nitlsconfig.ClientCertMode.Unknown + # server_mode is the master TLS switch, so its parsing must be pinned. + assert client_info.server_mode == nitlsconfig.ClientServerMode.Unknown client_info = nitlsconfig.ClientConfig("ni-mqtt") assert client_info.service_name == "ni-mqtt" assert client_info.certificate_mode == nitlsconfig.ClientCertMode.Managed + assert client_info.server_mode == nitlsconfig.ClientServerMode.TrustedCertificates assert client_info.certificate_chain_location.scheme == nitlsconfig.LocationScheme.File assert "cert.pem" in client_info.certificate_chain_location.path assert "BEGIN CERTIFICATE" in client_info.certificate_chain_contents @@ -166,3 +171,38 @@ def test_server_info() -> None: "beta-trusted-certificate" in server_info.trusted_certificates[1].trusted_certificate_contents ) + + +def test_real_config_drives_channel_credentials(monkeypatch: pytest.MonkeyPatch) -> None: + """A real ClientConfig, parsed from CLI output, produces the expected credentials. + + Every other channel test substitutes a fake config, so this is the only one + that exercises the seam: enum parsing, CertificateLocation.from_string and + the contents lookups all feed create_client_channel here. A mis-parsed + server_mode would silently produce an insecure channel and be invisible + elsewhere in the suite. + + ni-mqtt is configured for mutual TLS with SystemDefault trust anchors. + """ + captured: dict[str, object] = {} + real_ssl_channel_credentials = grpc.ssl_channel_credentials + + def spy(**kwargs: object) -> object: + captured.update(kwargs) + return real_ssl_channel_credentials(**kwargs) + + monkeypatch.setattr(grpc, "ssl_channel_credentials", spy) + + with grpc_channel.create_client_channel("localhost", 31763, service_name="ni-mqtt") as channel: + assert isinstance(channel, grpc.Channel) + + # SystemDefault trust anchors must arrive as None, not empty bytes. + assert captured["root_certificates"] is None + assert b"BEGIN CERTIFICATE" in cast(bytes, captured["certificate_chain"]) + assert b"PRIVATE KEY" in cast(bytes, captured["private_key"]) + + +def test_real_config_with_unknown_server_mode_is_rejected() -> None: + """ni-test has no server_mode, which must be rejected rather than silently ignored.""" + with pytest.raises(grpc_channel.TlsConfigurationError): + grpc_channel.create_client_channel("localhost", 31763, service_name="ni-test") diff --git a/tests/unit/test_package_exports.py b/tests/unit/test_package_exports.py new file mode 100644 index 0000000..c259b75 --- /dev/null +++ b/tests/unit/test_package_exports.py @@ -0,0 +1,49 @@ +"""Tests for the package's public import surface. + +grpcio is an optional extra, so nitlsconfig/__init__.py resolves the gRPC names +lazily through a module __getattr__. These tests pin the parts of that contract +that are observable with the extra installed; the CI job +"Check install without extras" covers the missing-grpcio path, which cannot be +reproduced in an environment where grpcio is present. +""" + +import pytest + +import nitlsconfig + + +def test_version_is_exported() -> None: + """__all__ advertises __version__, so it has to exist. + + It previously did not, which made "from nitlsconfig import *" raise. + """ + assert nitlsconfig.__version__ + + +def test_all_names_are_importable() -> None: + """Every name in __all__ must resolve, including the lazy gRPC ones.""" + for name in nitlsconfig.__all__: + assert getattr(nitlsconfig, name) is not None + + +def test_unknown_attribute_raises_attribute_error() -> None: + """The lazy __getattr__ must not turn typos into ImportError.""" + with pytest.raises(AttributeError, match="has no attribute 'not_a_real_name'"): + nitlsconfig.not_a_real_name + + +def test_importing_the_package_does_not_import_grpc(monkeypatch: pytest.MonkeyPatch) -> None: + """Importing nitlsconfig must not pull in grpcio. + + This is what makes the extra worth having. It fails the moment someone adds + a module-level "import grpc" to the configuration-reading code. + """ + monkeypatch.delitem(__import__("sys").modules, "grpc", raising=False) + monkeypatch.delitem(__import__("sys").modules, "nitlsconfig", raising=False) + monkeypatch.delitem(__import__("sys").modules, "nitlsconfig.cli", raising=False) + monkeypatch.delitem(__import__("sys").modules, "nitlsconfig.grpc_channel", raising=False) + + import nitlsconfig as reimported + + assert reimported.ClientConfig is not None + assert "grpc" not in __import__("sys").modules From 423d8f400ac84e10dd01224398a665a0f1a32f1a Mon Sep 17 00:00:00 2001 From: Alex Dubois Date: Thu, 30 Jul 2026 13:24:56 -0500 Subject: [PATCH 2/5] Rename API to create_grpc_client_channel --- .github/workflows/run_unit_tests.yml | 2 +- README.md | 6 +++--- src/nitlsconfig/__init__.py | 6 +++--- src/nitlsconfig/grpc_channel.py | 15 ++++++++++----- tests/unit/test_grpc_channel.py | 10 +++++----- tests/unit/test_grpc_channel_real.py | 4 ++-- tests/unit/test_grpc_channel_tls.py | 4 ++-- tests/unit/test_nitlsconfig.py | 10 ++++++---- 8 files changed, 32 insertions(+), 25 deletions(-) diff --git a/.github/workflows/run_unit_tests.yml b/.github/workflows/run_unit_tests.yml index d1c492a..48fbed5 100644 --- a/.github/workflows/run_unit_tests.yml +++ b/.github/workflows/run_unit_tests.yml @@ -69,7 +69,7 @@ jobs: assert nitlsconfig.ClientConfig is not None try: - nitlsconfig.create_client_channel + nitlsconfig.create_grpc_client_channel except ImportError as exc: assert "pip install nitlsconfig[grpc]" in str(exc), exc else: diff --git a/README.md b/README.md index bd98222..3d1ab6d 100644 --- a/README.md +++ b/README.md @@ -22,14 +22,14 @@ The gRPC channel factory additionally needs grpcio, which is an optional extra: ## Creating a gRPC channel -`create_client_channel` reads the local NI-TLS client configuration and returns a +`create_grpc_client_channel` reads the local NI-TLS client configuration and returns a `grpc.Channel` secured accordingly. Pass it straight to any NI gRPC Python API: ```python import nidcpower import nitlsconfig -with nitlsconfig.create_client_channel("localhost", 31763) as channel: +with nitlsconfig.create_grpc_client_channel("localhost", 31763) as channel: options = nidcpower.GrpcSessionOptions(channel, "") with nidcpower.Session("Dev1", grpc_options=options) as session: ... @@ -42,7 +42,7 @@ channel is owned by the caller - NI driver APIs never close it. Retries are opt-in: ```python -channel = nitlsconfig.create_client_channel( +channel = nitlsconfig.create_grpc_client_channel( "localhost", 31763, retry_policy=nitlsconfig.RetryPolicy() ) ``` diff --git a/src/nitlsconfig/__init__.py b/src/nitlsconfig/__init__.py index 6fafa83..7dfcca5 100644 --- a/src/nitlsconfig/__init__.py +++ b/src/nitlsconfig/__init__.py @@ -37,7 +37,7 @@ DEFAULT_SERVICE_NAME, RetryPolicy, TlsConfigurationError, - create_client_channel, + create_grpc_client_channel, ) __version__ = version("nitlsconfig") @@ -48,7 +48,7 @@ "DEFAULT_SERVICE_NAME", "RetryPolicy", "TlsConfigurationError", - "create_client_channel", + "create_grpc_client_channel", } ) @@ -71,7 +71,7 @@ "DEFAULT_SERVICE_NAME", "RetryPolicy", "TlsConfigurationError", - "create_client_channel", + "create_grpc_client_channel", ] diff --git a/src/nitlsconfig/grpc_channel.py b/src/nitlsconfig/grpc_channel.py index 22e8cf0..4ef7aed 100644 --- a/src/nitlsconfig/grpc_channel.py +++ b/src/nitlsconfig/grpc_channel.py @@ -7,16 +7,21 @@ The resulting channel is a normal ``grpc.Channel``. It can be handed directly to any NI gRPC Python API, for example:: - from nitlsconfig.grpc_channel import create_client_channel + from nitlsconfig.grpc_channel import create_grpc_client_channel - channel = create_client_channel("localhost", 31763) + channel = create_grpc_client_channel("localhost", 31763) options = nidcpower.GrpcSessionOptions(channel, "") with nidcpower.Session("Dev1", grpc_options=options) as session: ... Channel ownership stays with the caller, matching the NI Python driver APIs, which never close the channel themselves. ``grpc.Channel`` is already a context -manager, so ``with create_client_channel(...) as channel:`` works as expected. +manager, so ``with create_grpc_client_channel(...) as channel:`` works as expected. + +This is the Python counterpart of the nigrpctls C++ transport's +``GrpcTransportFactory::createClientChannel``, and deliberately mirrors its +behavior. The name carries the ``grpc`` prefix because this package also +re-exports the factory from its root, alongside future non-gRPC transports. A client ``server_mode`` of ``TrustAlways`` is not currently supported and raises :class:`TlsConfigurationError`. @@ -59,7 +64,7 @@ "DEFAULT_SERVICE_NAME", "RetryPolicy", "TlsConfigurationError", - "create_client_channel", + "create_grpc_client_channel", ] # The nitlsconfig service name registered by the NI gRPC Device Server. It is @@ -290,7 +295,7 @@ def _make_client_credentials(settings: _ClientTlsSettings) -> grpc.ChannelCreden ) -def create_client_channel( +def create_grpc_client_channel( server_address: str, server_port: int, service_name: str = DEFAULT_SERVICE_NAME, diff --git a/tests/unit/test_grpc_channel.py b/tests/unit/test_grpc_channel.py index abb7ee8..25c66a1 100644 --- a/tests/unit/test_grpc_channel.py +++ b/tests/unit/test_grpc_channel.py @@ -68,7 +68,7 @@ def create_channel( ) -> RecordedChannel: """Create a channel using the supplied configuration instead of the real CLI.""" monkeypatch.setattr(grpc_channel, "ClientConfig", lambda service_name: config) - channel = grpc_channel.create_client_channel("localhost", 31763, **kwargs) + channel = grpc_channel.create_grpc_client_channel("localhost", 31763, **kwargs) assert isinstance(channel, RecordedChannel) return channel @@ -102,7 +102,7 @@ def test_ipv6_addresses_are_bracketed( """ monkeypatch.setattr(grpc_channel, "ClientConfig", lambda service_name: FakeClientConfig()) - channel = grpc_channel.create_client_channel(server_address, 31763) + channel = grpc_channel.create_grpc_client_channel(server_address, 31763) assert isinstance(channel, RecordedChannel) assert channel.target == expected_target @@ -263,7 +263,7 @@ def test_invalid_configuration_raises( monkeypatch.setattr(grpc_channel, "ClientConfig", lambda service_name: config) with pytest.raises(grpc_channel.TlsConfigurationError): - grpc_channel.create_client_channel("localhost", 31763) + grpc_channel.create_grpc_client_channel("localhost", 31763) def test_service_name_is_forwarded( @@ -278,8 +278,8 @@ def record_client_config(service_name: str) -> FakeClientConfig: monkeypatch.setattr(grpc_channel, "ClientConfig", record_client_config) - grpc_channel.create_client_channel("localhost", 31763) - grpc_channel.create_client_channel("localhost", 31763, service_name="other-service") + grpc_channel.create_grpc_client_channel("localhost", 31763) + grpc_channel.create_grpc_client_channel("localhost", 31763, service_name="other-service") assert requested == [grpc_channel.DEFAULT_SERVICE_NAME, "other-service"] diff --git a/tests/unit/test_grpc_channel_real.py b/tests/unit/test_grpc_channel_real.py index 061e6ee..7142160 100644 --- a/tests/unit/test_grpc_channel_real.py +++ b/tests/unit/test_grpc_channel_real.py @@ -73,7 +73,7 @@ def test_returns_a_usable_grpc_channel( """Every configuration produces a real channel with the API drivers rely on.""" monkeypatch.setattr(grpc_channel, "ClientConfig", lambda service_name: config) - with grpc_channel.create_client_channel("localhost", 31763) as channel: + with grpc_channel.create_grpc_client_channel("localhost", 31763) as channel: # nimi-python and nidaqmx-python pass this straight to GrpcSessionOptions, # which requires a grpc.Channel and calls these factories on it. assert isinstance(channel, grpc.Channel) @@ -101,7 +101,7 @@ def test_channel_options_are_accepted_by_grpc( monkeypatch.setattr(grpc_channel, "ClientConfig", lambda service_name: MUTUAL_TLS) capfd.readouterr() - with grpc_channel.create_client_channel( + with grpc_channel.create_grpc_client_channel( "localhost", 31763, options=[("grpc.max_receive_message_length", 4 * 1024 * 1024)], diff --git a/tests/unit/test_grpc_channel_tls.py b/tests/unit/test_grpc_channel_tls.py index 626087e..b6cdd67 100644 --- a/tests/unit/test_grpc_channel_tls.py +++ b/tests/unit/test_grpc_channel_tls.py @@ -128,7 +128,7 @@ def test_mutual_tls_handshake_succeeds( config = _mutual_tls_config(certificate_authority, client_identity) monkeypatch.setattr(grpc_channel, "ClientConfig", lambda service_name: config) - with grpc_channel.create_client_channel("localhost", trusted_server) as channel: + with grpc_channel.create_grpc_client_channel("localhost", trusted_server) as channel: say = channel.unary_unary( _METHOD, request_serializer=_identity, response_deserializer=_identity ) @@ -149,7 +149,7 @@ def test_server_from_untrusted_ca_is_rejected( config = _mutual_tls_config(certificate_authority, client_identity) monkeypatch.setattr(grpc_channel, "ClientConfig", lambda service_name: config) - with grpc_channel.create_client_channel("localhost", untrusted_server) as channel: + with grpc_channel.create_grpc_client_channel("localhost", untrusted_server) as channel: say = channel.unary_unary( _METHOD, request_serializer=_identity, response_deserializer=_identity ) diff --git a/tests/unit/test_nitlsconfig.py b/tests/unit/test_nitlsconfig.py index 347f861..c9933aa 100644 --- a/tests/unit/test_nitlsconfig.py +++ b/tests/unit/test_nitlsconfig.py @@ -3,7 +3,7 @@ import json import pathlib import platform -from typing import Mapping, TypedDict, cast +from typing import cast, Mapping, TypedDict import grpc import pytest @@ -178,7 +178,7 @@ def test_real_config_drives_channel_credentials(monkeypatch: pytest.MonkeyPatch) Every other channel test substitutes a fake config, so this is the only one that exercises the seam: enum parsing, CertificateLocation.from_string and - the contents lookups all feed create_client_channel here. A mis-parsed + the contents lookups all feed create_grpc_client_channel here. A mis-parsed server_mode would silently produce an insecure channel and be invisible elsewhere in the suite. @@ -193,7 +193,9 @@ def spy(**kwargs: object) -> object: monkeypatch.setattr(grpc, "ssl_channel_credentials", spy) - with grpc_channel.create_client_channel("localhost", 31763, service_name="ni-mqtt") as channel: + with grpc_channel.create_grpc_client_channel( + "localhost", 31763, service_name="ni-mqtt" + ) as channel: assert isinstance(channel, grpc.Channel) # SystemDefault trust anchors must arrive as None, not empty bytes. @@ -205,4 +207,4 @@ def spy(**kwargs: object) -> object: def test_real_config_with_unknown_server_mode_is_rejected() -> None: """ni-test has no server_mode, which must be rejected rather than silently ignored.""" with pytest.raises(grpc_channel.TlsConfigurationError): - grpc_channel.create_client_channel("localhost", 31763, service_name="ni-test") + grpc_channel.create_grpc_client_channel("localhost", 31763, service_name="ni-test") From b8bc9a26b4f81cd6a09a33f9c8d160c4f9288105 Mon Sep 17 00:00:00 2001 From: Alex Dubois Date: Thu, 30 Jul 2026 13:41:01 -0500 Subject: [PATCH 3/5] docs: stop AutoAPI documenting re-exported names twice The package root re-exports names from nitlsconfig.cli and nitlsconfig.grpc_channel. AutoAPI's default options include imported-members, so those names were documented in two places, making a bare cross-reference ambiguous and failing the -W build in check_docs. --- docs/conf.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/conf.py b/docs/conf.py index 291c730..8ae7d65 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -45,6 +45,10 @@ # tell autoapi to doc the public options autoapi_options = list(autoapi.extension._DEFAULT_OPTIONS) autoapi_options.remove("private-members") # note: remove this to include "_" members in docs +# The package root re-exports names from nitlsconfig.cli and nitlsconfig.grpc_channel. +# Documenting those imports as well would define every name twice, which makes any +# cross-reference to a bare name ambiguous and fails the -W build. +autoapi_options.remove("imported-members") autoapi_dirs = [root_path / "src" / "nitlsconfig"] autoapi_type = "python" autodoc_typehints = "description" From a538ec5d6393a1bc08f92e69f6b3445e14f315f0 Mon Sep 17 00:00:00 2001 From: Alex Dubois Date: Thu, 30 Jul 2026 13:41:01 -0500 Subject: [PATCH 4/5] Remove internal implementation details ahead of public release Drop references to NI's internal C++ TLS transports from module docs and test comments, keeping the customer-relevant rationale. Replace the internal build path prefix in the test fixtures with the install location; tests only assert on leaf file names and schemes, so behavior is unchanged. --- src/nitlsconfig/grpc_channel.py | 19 +++++++------------ tests/unit/nitlsconfig_client.json | 2 +- tests/unit/nitlsconfig_server.json | 2 +- tests/unit/test_grpc_channel.py | 2 +- 4 files changed, 10 insertions(+), 15 deletions(-) diff --git a/src/nitlsconfig/grpc_channel.py b/src/nitlsconfig/grpc_channel.py index 4ef7aed..fa1b010 100644 --- a/src/nitlsconfig/grpc_channel.py +++ b/src/nitlsconfig/grpc_channel.py @@ -18,10 +18,8 @@ which never close the channel themselves. ``grpc.Channel`` is already a context manager, so ``with create_grpc_client_channel(...) as channel:`` works as expected. -This is the Python counterpart of the nigrpctls C++ transport's -``GrpcTransportFactory::createClientChannel``, and deliberately mirrors its -behavior. The name carries the ``grpc`` prefix because this package also -re-exports the factory from its root, alongside future non-gRPC transports. +The name carries the ``grpc`` prefix because this package also re-exports the +factory from its root, alongside any potential future non-gRPC transports. A client ``server_mode`` of ``TrustAlways`` is not currently supported and raises :class:`TlsConfigurationError`. @@ -29,14 +27,11 @@ A client ``server_mode`` of ``SkipHostnameValidation`` is treated exactly like ``TrustedCertificates``: the server certificate chain is verified *and* the hostname is checked. gRPC's Python API exposes no way to skip only the hostname -check. Doing so requires a custom certificate verifier, which is available in -C++ as ``grpc::experimental::TlsChannelCredentials`` with a -``HostNameCertificateVerifier``/``NoOpCertificateVerifier`` but is not bound in -grpcio's Python layer, whose TLS surface is limited to -``grpc.ssl_channel_credentials``. The nigrpctls C++ transport ignores this mode -for the same reason, so both implementations agree; only the Thrift transport -honors it. Verifying when asked not to fails closed, so this is safe, but a -caller who sets the mode gets no relaxation of the hostname check. +check: doing so requires a custom certificate verifier, which grpcio does not +bind in Python, where the TLS surface is limited to +``grpc.ssl_channel_credentials``. Verifying when asked not to fails closed, so +this is safe, but a caller who sets the mode gets no relaxation of the hostname +check. When the server certificate's CN/SAN does not match the dialed host, pass ``grpc.ssl_target_name_override`` via ``options`` instead. That substitutes the diff --git a/tests/unit/nitlsconfig_client.json b/tests/unit/nitlsconfig_client.json index 88401b4..94f2ed9 100644 --- a/tests/unit/nitlsconfig_client.json +++ b/tests/unit/nitlsconfig_client.json @@ -1 +1 @@ -{"client":[{"service_name":"ni-mqtt","version":1,"display_name_en":"NI MQTT TLS Client","certificate_mode":"Managed","certificate_chain_location":"File://P:/dev/ni-central/src/platform_services/abstractions/nitlsconfigd/pypi-nitlsconfig/objects/nitlsconfigtest/nitlsconfig/client.d/ni-mqtt/cert.pem","certificate_chain_contents":"# Common Name: ni-example client 1\r\n-----BEGIN CERTIFICATE-----\r\nMIIDWTCCAkGgAwIBAgIUICQ9fXkCoo+QfISjGYfPwmfFdw0wDQYJKoZIhvcNAQEL\r\nBQAwXjELMAkGA1UEBhMCVVMxDjAMBgNVBAgMBVRleGFzMQ8wDQYDVQQHDAZBdXN0\r\naW4xEDAOBgNVBAoMB0VtZXJzb24xHDAaBgNVBAMME25pLWV4YW1wbGUgY2xpZW50\r\nIDEwIBcNMjYwMzA3MjMxMTI2WhgPMjIyNjAxMTgyMzExMjZaMF4xCzAJBgNVBAYT\r\nAlVTMQ4wDAYDVQQIDAVUZXhhczEPMA0GA1UEBwwGQXVzdGluMRAwDgYDVQQKDAdF\r\nbWVyc29uMRwwGgYDVQQDDBNuaS1leGFtcGxlIGNsaWVudCAxMIIBIjANBgkqhkiG\r\n9w0BAQEFAAOCAQ8AMIIBCgKCAQEAkDcoClWNwOcloFMsRA4fK2CMSYIld7th0zbt\r\nHJorSaPM6+8rgKGRMQi6fbSBu/vMRI+f0OMxHH51FAzqsUzwk5PT+HrXR5kXNn+R\r\nVJbETzMjlc02h2y1cMFITytg5ee6YPyztnjruXgXPRVzwzSUwi0zhz1EB/sQpH3c\r\nvZvzidcXyR9nu+dgR3I1+h+43fR80ERX//AGaYJeyQ1TfzpoY+qFc0wbbeX2I92r\r\nv/9DSn7XFSwHK/8HnA2lQ+6iTIu2FYpzadLzsqYDuWtGGQY7tzRke6VBOdM6VHO8\r\nVCtptxShLlwBBZT9xkDaPbH4wpZ9Nf2+AA9pMg7aFXmm1HOz8wIDAQABow0wCzAJ\r\nBgNVHREEAjAAMA0GCSqGSIb3DQEBCwUAA4IBAQBWdolL2+AQ8a6nRFgTjYHScS12\r\n6HjuFsGUas3wi9CXOWMyUYHQUwu/Q9RYXCrG7yBvMKLM7yHFO4Gofp8A6ECuGy7J\r\nxm4s/yVKU6oGN47HLaHyYrKiyY2MGEO81nrVjLJoChErB8du96f5QuGH9TFUOXcA\r\noSaJJI3VJ5mHSDhTlAXCcpudGRLngmSBPiyvglJO5TDr7PO8q6jwFyKdlOQJeZoh\r\nkUeu01TpZKQYN9oFjjVdxvAKtA7ztkvMkeuu3StxBzyuzYWXbxwwjdYu0SNUkPY5\r\nEdSokl7Ru6Ci1PPHEcs0XSBroh+5l7ujdVp/ZkDtHApaOC34Zsk5wOcEv0PN\r\n-----END CERTIFICATE-----\r\n","certificate_key_location":"File://P:/dev/ni-central/src/platform_services/abstractions/nitlsconfigd/pypi-nitlsconfig/objects/nitlsconfigtest/nitlsconfig/client.d/ni-mqtt/key.pem","certificate_key_contents":"-----BEGIN RSA PRIVATE KEY-----\r\nMIIEpAIBAAKCAQEAkDcoClWNwOcloFMsRA4fK2CMSYIld7th0zbtHJorSaPM6+8r\r\ngKGRMQi6fbSBu/vMRI+f0OMxHH51FAzqsUzwk5PT+HrXR5kXNn+RVJbETzMjlc02\r\nh2y1cMFITytg5ee6YPyztnjruXgXPRVzwzSUwi0zhz1EB/sQpH3cvZvzidcXyR9n\r\nu+dgR3I1+h+43fR80ERX//AGaYJeyQ1TfzpoY+qFc0wbbeX2I92rv/9DSn7XFSwH\r\nK/8HnA2lQ+6iTIu2FYpzadLzsqYDuWtGGQY7tzRke6VBOdM6VHO8VCtptxShLlwB\r\nBZT9xkDaPbH4wpZ9Nf2+AA9pMg7aFXmm1HOz8wIDAQABAoIBAALIVajxYqVMsHCp\r\n+ior/ZT4LWzDqpZnUqlhH20UWm52zx7kQ/oc4/DGwtZnE7O9VYEjJknBAHMUy4yB\r\n/UtDeUbXrNjYAexbO1PtXS/9IreGfJLI3Vr6kXul9w+z/hAhivqRFaSZK68t7oDa\r\nt5uWGaKvfZWH7Jac1QLffNFZv3ay3NKRNhODZWg8KYqeD0FNf73Qq09+PGkXdeJE\r\nKx/FmRsrOT5GfmHQRDX3Csf3gE9UifCm8wWRJ9g/zdyCbDSlvvIlg+/jce98dUcy\r\nXXmXwd227ItHUUlqX0tfPO+mWThVjhnBzwcdmvKp9rQ1uBZDWQ96yOIypjxBoUEs\r\n7oMJSH0CgYEAx6NmVjKHnU8bCAbc+EdvKi55K1R4QPA63SE03lVu5SQWTSJPsBtc\r\n+r5FmdjtnXIXmC4xZsCs/oCNViSgK2/q0Do6gv1NoUf5xBAJTKgeZqldU88HdVKQ\r\nxUrirGlZCLml54oOh+8RcIoVuLW2pRYFNcdKqWkgxrUT+nUjVRewwRUCgYEAuO4k\r\npHiTgQupHcx4h2xcA4LWLsM/Gr2iAdzgk/Ku4/eWl14Y7tqNblesxXmwHIQc5CpR\r\nCcq9jiMqCtUm12rCYnzGdSZ/FOKlnZFc+sP3BkFoEb8rMhgelKvtHYEVnn+NnLqx\r\nH2rO2c72uQfyW+CCbz/DGg8uhY6E8XtANtFaEucCgYEAxbzJ81zPSHf/DT9HBUA0\r\nEnK/n7Rl++Q2Waq3Y/T5B0tsL3Bpv8QKn21xIQZlrMpCJoCye9UiRF7uH0Hdx8ht\r\nq1vRcscUakgRUyqTG3N10Te18fogqAtky5X12NHr1yGS+ziaVffsrSyIgVCzHOcn\r\nt/GyuQQg69CVfkEiAvmZIFUCgYAlyr0AQ4fZWlmwNkX3XuSa7xT4L3xo6ZH/EXVv\r\npquo+ML0og00WsOGZjJvlMOxwCnrUt6GwsDkGDmSRycN5MpeuCMSc9CKoxq3TaqT\r\nsJJ928X5wniAXz94oH3vhp61alm1Ss3BnuEwz8PJ4l7b+dCODZjAbZPTRWHTu51O\r\n1o+yxwKBgQCzXSe+KYsRpXLGPbBZfr5H5NAmpJfv7gmUMjPkS2SNEgG0jxOftG3e\r\nSjRPqvTXXRNMJgW48HB1z7FZ8bzhePwo3zHl4LCDUx6kQfWZ6gfA/GqctXll9+dR\r\nauS1ZVrchbgCIbBMFsFSyYehKAdu+Pnd7x+6Sot0hHZNe/kF/8G6yw==\r\n-----END RSA PRIVATE KEY-----\r\n","server_mode":"TrustedCertificates","trusted_certificates_location":"SystemDefault","trusted_certificates_contents":"","known_servers":[{"display_name_en":"NI MQTT TLS Client","certificate_mode":"Disabled","certificate_chain_location":"File://P:/dev/ni-central/src/platform_services/abstractions/nitlsconfigd/pypi-nitlsconfig/objects/nitlsconfigtest/nitlsconfig/client.d/ni-mqtt/cert.pem","certificate_chain_contents":"# Common Name: ni-example client 1\r\n-----BEGIN CERTIFICATE-----\r\nMIIDWTCCAkGgAwIBAgIUICQ9fXkCoo+QfISjGYfPwmfFdw0wDQYJKoZIhvcNAQEL\r\nBQAwXjELMAkGA1UEBhMCVVMxDjAMBgNVBAgMBVRleGFzMQ8wDQYDVQQHDAZBdXN0\r\naW4xEDAOBgNVBAoMB0VtZXJzb24xHDAaBgNVBAMME25pLWV4YW1wbGUgY2xpZW50\r\nIDEwIBcNMjYwMzA3MjMxMTI2WhgPMjIyNjAxMTgyMzExMjZaMF4xCzAJBgNVBAYT\r\nAlVTMQ4wDAYDVQQIDAVUZXhhczEPMA0GA1UEBwwGQXVzdGluMRAwDgYDVQQKDAdF\r\nbWVyc29uMRwwGgYDVQQDDBNuaS1leGFtcGxlIGNsaWVudCAxMIIBIjANBgkqhkiG\r\n9w0BAQEFAAOCAQ8AMIIBCgKCAQEAkDcoClWNwOcloFMsRA4fK2CMSYIld7th0zbt\r\nHJorSaPM6+8rgKGRMQi6fbSBu/vMRI+f0OMxHH51FAzqsUzwk5PT+HrXR5kXNn+R\r\nVJbETzMjlc02h2y1cMFITytg5ee6YPyztnjruXgXPRVzwzSUwi0zhz1EB/sQpH3c\r\nvZvzidcXyR9nu+dgR3I1+h+43fR80ERX//AGaYJeyQ1TfzpoY+qFc0wbbeX2I92r\r\nv/9DSn7XFSwHK/8HnA2lQ+6iTIu2FYpzadLzsqYDuWtGGQY7tzRke6VBOdM6VHO8\r\nVCtptxShLlwBBZT9xkDaPbH4wpZ9Nf2+AA9pMg7aFXmm1HOz8wIDAQABow0wCzAJ\r\nBgNVHREEAjAAMA0GCSqGSIb3DQEBCwUAA4IBAQBWdolL2+AQ8a6nRFgTjYHScS12\r\n6HjuFsGUas3wi9CXOWMyUYHQUwu/Q9RYXCrG7yBvMKLM7yHFO4Gofp8A6ECuGy7J\r\nxm4s/yVKU6oGN47HLaHyYrKiyY2MGEO81nrVjLJoChErB8du96f5QuGH9TFUOXcA\r\noSaJJI3VJ5mHSDhTlAXCcpudGRLngmSBPiyvglJO5TDr7PO8q6jwFyKdlOQJeZoh\r\nkUeu01TpZKQYN9oFjjVdxvAKtA7ztkvMkeuu3StxBzyuzYWXbxwwjdYu0SNUkPY5\r\nEdSokl7Ru6Ci1PPHEcs0XSBroh+5l7ujdVp/ZkDtHApaOC34Zsk5wOcEv0PN\r\n-----END CERTIFICATE-----\r\n","certificate_key_location":"File://P:/dev/ni-central/src/platform_services/abstractions/nitlsconfigd/pypi-nitlsconfig/objects/nitlsconfigtest/nitlsconfig/client.d/ni-mqtt/key.pem","certificate_key_contents":"-----BEGIN RSA PRIVATE KEY-----\r\nMIIEpAIBAAKCAQEAkDcoClWNwOcloFMsRA4fK2CMSYIld7th0zbtHJorSaPM6+8r\r\ngKGRMQi6fbSBu/vMRI+f0OMxHH51FAzqsUzwk5PT+HrXR5kXNn+RVJbETzMjlc02\r\nh2y1cMFITytg5ee6YPyztnjruXgXPRVzwzSUwi0zhz1EB/sQpH3cvZvzidcXyR9n\r\nu+dgR3I1+h+43fR80ERX//AGaYJeyQ1TfzpoY+qFc0wbbeX2I92rv/9DSn7XFSwH\r\nK/8HnA2lQ+6iTIu2FYpzadLzsqYDuWtGGQY7tzRke6VBOdM6VHO8VCtptxShLlwB\r\nBZT9xkDaPbH4wpZ9Nf2+AA9pMg7aFXmm1HOz8wIDAQABAoIBAALIVajxYqVMsHCp\r\n+ior/ZT4LWzDqpZnUqlhH20UWm52zx7kQ/oc4/DGwtZnE7O9VYEjJknBAHMUy4yB\r\n/UtDeUbXrNjYAexbO1PtXS/9IreGfJLI3Vr6kXul9w+z/hAhivqRFaSZK68t7oDa\r\nt5uWGaKvfZWH7Jac1QLffNFZv3ay3NKRNhODZWg8KYqeD0FNf73Qq09+PGkXdeJE\r\nKx/FmRsrOT5GfmHQRDX3Csf3gE9UifCm8wWRJ9g/zdyCbDSlvvIlg+/jce98dUcy\r\nXXmXwd227ItHUUlqX0tfPO+mWThVjhnBzwcdmvKp9rQ1uBZDWQ96yOIypjxBoUEs\r\n7oMJSH0CgYEAx6NmVjKHnU8bCAbc+EdvKi55K1R4QPA63SE03lVu5SQWTSJPsBtc\r\n+r5FmdjtnXIXmC4xZsCs/oCNViSgK2/q0Do6gv1NoUf5xBAJTKgeZqldU88HdVKQ\r\nxUrirGlZCLml54oOh+8RcIoVuLW2pRYFNcdKqWkgxrUT+nUjVRewwRUCgYEAuO4k\r\npHiTgQupHcx4h2xcA4LWLsM/Gr2iAdzgk/Ku4/eWl14Y7tqNblesxXmwHIQc5CpR\r\nCcq9jiMqCtUm12rCYnzGdSZ/FOKlnZFc+sP3BkFoEb8rMhgelKvtHYEVnn+NnLqx\r\nH2rO2c72uQfyW+CCbz/DGg8uhY6E8XtANtFaEucCgYEAxbzJ81zPSHf/DT9HBUA0\r\nEnK/n7Rl++Q2Waq3Y/T5B0tsL3Bpv8QKn21xIQZlrMpCJoCye9UiRF7uH0Hdx8ht\r\nq1vRcscUakgRUyqTG3N10Te18fogqAtky5X12NHr1yGS+ziaVffsrSyIgVCzHOcn\r\nt/GyuQQg69CVfkEiAvmZIFUCgYAlyr0AQ4fZWlmwNkX3XuSa7xT4L3xo6ZH/EXVv\r\npquo+ML0og00WsOGZjJvlMOxwCnrUt6GwsDkGDmSRycN5MpeuCMSc9CKoxq3TaqT\r\nsJJ928X5wniAXz94oH3vhp61alm1Ss3BnuEwz8PJ4l7b+dCODZjAbZPTRWHTu51O\r\n1o+yxwKBgQCzXSe+KYsRpXLGPbBZfr5H5NAmpJfv7gmUMjPkS2SNEgG0jxOftG3e\r\nSjRPqvTXXRNMJgW48HB1z7FZ8bzhePwo3zHl4LCDUx6kQfWZ6gfA/GqctXll9+dR\r\nauS1ZVrchbgCIbBMFsFSyYehKAdu+Pnd7x+6Sot0hHZNe/kF/8G6yw==\r\n-----END RSA PRIVATE KEY-----\r\n","server_mode":"TrustedCertificates","server_name":"example-host1","trusted_certificates_location":"File://P:/dev/ni-central/src/platform_services/abstractions/nitlsconfigd/pypi-nitlsconfig/objects/nitlsconfigtest/nitlsconfig/client.d/ni-mqtt/servers/example-host1.pem","trusted_certificates_contents":"# Common Name: ni-example server\r\n# Alternative Name: localhost\r\n# Alternative Name: 127.0.0.1\r\n-----BEGIN CERTIFICATE-----\r\nMIIDZjCCAk6gAwIBAgIUDghvYIzCaTcQGQlYnvKB8mJb7+EwDQYJKoZIhvcNAQEL\r\nBQAwXDELMAkGA1UEBhMCVVMxDjAMBgNVBAgMBVRleGFzMQ8wDQYDVQQHDAZBdXN0\r\naW4xEDAOBgNVBAoMB0VtZXJzb24xGjAYBgNVBAMMEW5pLWV4YW1wbGUgc2VydmVy\r\nMCAXDTI2MDMwNzIzMDk0NVoYDzIyMjYwMTE4MjMwOTQ1WjBcMQswCQYDVQQGEwJV\r\nUzEOMAwGA1UECAwFVGV4YXMxDzANBgNVBAcMBkF1c3RpbjEQMA4GA1UECgwHRW1l\r\ncnNvbjEaMBgGA1UEAwwRbmktZXhhbXBsZSBzZXJ2ZXIwggEiMA0GCSqGSIb3DQEB\r\nAQUAA4IBDwAwggEKAoIBAQCv15CSoihVoiYl27dax+4f4O3NmJ6GrUVOLyJ64Zcq\r\nTK66ITNfKxZYO+DVfflZJAoxgxgRVGOTuBBEH625Ur5aN1rHaCLm6KxaZ5A3Xwh9\r\nVoKddc5MrO6P3rlEFtKofvlMXMv6y8FkcW3SaMGkfEGyqb8d/kJfnPnIsBcQ2UWt\r\ngMOcD/QyiaCTFTkz6+ps8bUov3W/WPb0KBfjJcxYToil84LN6I+OvaE9beZp7aNa\r\nlM/WRXyWk0Z2wyxhuPHoAKuwJKDsppM3HagDfHjdj0X8XwYbifO8vPybCooKj/Av\r\ncJbLcAw1z4O1x21tRpqGlw8AG1HZFPHx8BsH27eVBgwRAgMBAAGjHjAcMBoGA1Ud\r\nEQQTMBGCCWxvY2FsaG9zdIcEfwAAATANBgkqhkiG9w0BAQsFAAOCAQEADrfeQ1Dp\r\nLC9Up2ERi6Pc23sEAz4I0wQYYiwuIQQ0T3lo87IKh4VyvbxmfzFhCnoNGgQZE+aG\r\njT37FtwOD9qoFoiunzWQ0TRvvuicsnGcUIupW6ptsjrBulX6LDichGPWro/R11Zx\r\nmpewc2y2Ox+ZPBAQpqqAzXdfmRReJMM/b2QSVcmvWu++gskC+6fUMCpnPh1wL545\r\nTuOPsKTLekwa75odnMfg2I5rhzIdfaCeGVZY5TiT4n8rt+xDPMoevHh/D8DM0Uwy\r\n6hNzqIynbiHHo5op+IR+H/Gwx/ris2QC9dkFJ6alSo3Zj+3aMtShgTQn0fpbVTSO\r\nMA5j/1Lz/fYZtg==\r\n-----END CERTIFICATE-----\r\n"}]},{"service_name":"ni-test","known_servers":[]}]} +{"client":[{"service_name":"ni-mqtt","version":1,"display_name_en":"NI MQTT TLS Client","certificate_mode":"Managed","certificate_chain_location":"File://C:/ProgramData/National Instruments/nitlsconfig/client.d/ni-mqtt/cert.pem","certificate_chain_contents":"# Common Name: ni-example client 1\r\n-----BEGIN CERTIFICATE-----\r\nMIIDWTCCAkGgAwIBAgIUICQ9fXkCoo+QfISjGYfPwmfFdw0wDQYJKoZIhvcNAQEL\r\nBQAwXjELMAkGA1UEBhMCVVMxDjAMBgNVBAgMBVRleGFzMQ8wDQYDVQQHDAZBdXN0\r\naW4xEDAOBgNVBAoMB0VtZXJzb24xHDAaBgNVBAMME25pLWV4YW1wbGUgY2xpZW50\r\nIDEwIBcNMjYwMzA3MjMxMTI2WhgPMjIyNjAxMTgyMzExMjZaMF4xCzAJBgNVBAYT\r\nAlVTMQ4wDAYDVQQIDAVUZXhhczEPMA0GA1UEBwwGQXVzdGluMRAwDgYDVQQKDAdF\r\nbWVyc29uMRwwGgYDVQQDDBNuaS1leGFtcGxlIGNsaWVudCAxMIIBIjANBgkqhkiG\r\n9w0BAQEFAAOCAQ8AMIIBCgKCAQEAkDcoClWNwOcloFMsRA4fK2CMSYIld7th0zbt\r\nHJorSaPM6+8rgKGRMQi6fbSBu/vMRI+f0OMxHH51FAzqsUzwk5PT+HrXR5kXNn+R\r\nVJbETzMjlc02h2y1cMFITytg5ee6YPyztnjruXgXPRVzwzSUwi0zhz1EB/sQpH3c\r\nvZvzidcXyR9nu+dgR3I1+h+43fR80ERX//AGaYJeyQ1TfzpoY+qFc0wbbeX2I92r\r\nv/9DSn7XFSwHK/8HnA2lQ+6iTIu2FYpzadLzsqYDuWtGGQY7tzRke6VBOdM6VHO8\r\nVCtptxShLlwBBZT9xkDaPbH4wpZ9Nf2+AA9pMg7aFXmm1HOz8wIDAQABow0wCzAJ\r\nBgNVHREEAjAAMA0GCSqGSIb3DQEBCwUAA4IBAQBWdolL2+AQ8a6nRFgTjYHScS12\r\n6HjuFsGUas3wi9CXOWMyUYHQUwu/Q9RYXCrG7yBvMKLM7yHFO4Gofp8A6ECuGy7J\r\nxm4s/yVKU6oGN47HLaHyYrKiyY2MGEO81nrVjLJoChErB8du96f5QuGH9TFUOXcA\r\noSaJJI3VJ5mHSDhTlAXCcpudGRLngmSBPiyvglJO5TDr7PO8q6jwFyKdlOQJeZoh\r\nkUeu01TpZKQYN9oFjjVdxvAKtA7ztkvMkeuu3StxBzyuzYWXbxwwjdYu0SNUkPY5\r\nEdSokl7Ru6Ci1PPHEcs0XSBroh+5l7ujdVp/ZkDtHApaOC34Zsk5wOcEv0PN\r\n-----END CERTIFICATE-----\r\n","certificate_key_location":"File://C:/ProgramData/National Instruments/nitlsconfig/client.d/ni-mqtt/key.pem","certificate_key_contents":"-----BEGIN RSA PRIVATE KEY-----\r\nMIIEpAIBAAKCAQEAkDcoClWNwOcloFMsRA4fK2CMSYIld7th0zbtHJorSaPM6+8r\r\ngKGRMQi6fbSBu/vMRI+f0OMxHH51FAzqsUzwk5PT+HrXR5kXNn+RVJbETzMjlc02\r\nh2y1cMFITytg5ee6YPyztnjruXgXPRVzwzSUwi0zhz1EB/sQpH3cvZvzidcXyR9n\r\nu+dgR3I1+h+43fR80ERX//AGaYJeyQ1TfzpoY+qFc0wbbeX2I92rv/9DSn7XFSwH\r\nK/8HnA2lQ+6iTIu2FYpzadLzsqYDuWtGGQY7tzRke6VBOdM6VHO8VCtptxShLlwB\r\nBZT9xkDaPbH4wpZ9Nf2+AA9pMg7aFXmm1HOz8wIDAQABAoIBAALIVajxYqVMsHCp\r\n+ior/ZT4LWzDqpZnUqlhH20UWm52zx7kQ/oc4/DGwtZnE7O9VYEjJknBAHMUy4yB\r\n/UtDeUbXrNjYAexbO1PtXS/9IreGfJLI3Vr6kXul9w+z/hAhivqRFaSZK68t7oDa\r\nt5uWGaKvfZWH7Jac1QLffNFZv3ay3NKRNhODZWg8KYqeD0FNf73Qq09+PGkXdeJE\r\nKx/FmRsrOT5GfmHQRDX3Csf3gE9UifCm8wWRJ9g/zdyCbDSlvvIlg+/jce98dUcy\r\nXXmXwd227ItHUUlqX0tfPO+mWThVjhnBzwcdmvKp9rQ1uBZDWQ96yOIypjxBoUEs\r\n7oMJSH0CgYEAx6NmVjKHnU8bCAbc+EdvKi55K1R4QPA63SE03lVu5SQWTSJPsBtc\r\n+r5FmdjtnXIXmC4xZsCs/oCNViSgK2/q0Do6gv1NoUf5xBAJTKgeZqldU88HdVKQ\r\nxUrirGlZCLml54oOh+8RcIoVuLW2pRYFNcdKqWkgxrUT+nUjVRewwRUCgYEAuO4k\r\npHiTgQupHcx4h2xcA4LWLsM/Gr2iAdzgk/Ku4/eWl14Y7tqNblesxXmwHIQc5CpR\r\nCcq9jiMqCtUm12rCYnzGdSZ/FOKlnZFc+sP3BkFoEb8rMhgelKvtHYEVnn+NnLqx\r\nH2rO2c72uQfyW+CCbz/DGg8uhY6E8XtANtFaEucCgYEAxbzJ81zPSHf/DT9HBUA0\r\nEnK/n7Rl++Q2Waq3Y/T5B0tsL3Bpv8QKn21xIQZlrMpCJoCye9UiRF7uH0Hdx8ht\r\nq1vRcscUakgRUyqTG3N10Te18fogqAtky5X12NHr1yGS+ziaVffsrSyIgVCzHOcn\r\nt/GyuQQg69CVfkEiAvmZIFUCgYAlyr0AQ4fZWlmwNkX3XuSa7xT4L3xo6ZH/EXVv\r\npquo+ML0og00WsOGZjJvlMOxwCnrUt6GwsDkGDmSRycN5MpeuCMSc9CKoxq3TaqT\r\nsJJ928X5wniAXz94oH3vhp61alm1Ss3BnuEwz8PJ4l7b+dCODZjAbZPTRWHTu51O\r\n1o+yxwKBgQCzXSe+KYsRpXLGPbBZfr5H5NAmpJfv7gmUMjPkS2SNEgG0jxOftG3e\r\nSjRPqvTXXRNMJgW48HB1z7FZ8bzhePwo3zHl4LCDUx6kQfWZ6gfA/GqctXll9+dR\r\nauS1ZVrchbgCIbBMFsFSyYehKAdu+Pnd7x+6Sot0hHZNe/kF/8G6yw==\r\n-----END RSA PRIVATE KEY-----\r\n","server_mode":"TrustedCertificates","trusted_certificates_location":"SystemDefault","trusted_certificates_contents":"","known_servers":[{"display_name_en":"NI MQTT TLS Client","certificate_mode":"Disabled","certificate_chain_location":"File://C:/ProgramData/National Instruments/nitlsconfig/client.d/ni-mqtt/cert.pem","certificate_chain_contents":"# Common Name: ni-example client 1\r\n-----BEGIN CERTIFICATE-----\r\nMIIDWTCCAkGgAwIBAgIUICQ9fXkCoo+QfISjGYfPwmfFdw0wDQYJKoZIhvcNAQEL\r\nBQAwXjELMAkGA1UEBhMCVVMxDjAMBgNVBAgMBVRleGFzMQ8wDQYDVQQHDAZBdXN0\r\naW4xEDAOBgNVBAoMB0VtZXJzb24xHDAaBgNVBAMME25pLWV4YW1wbGUgY2xpZW50\r\nIDEwIBcNMjYwMzA3MjMxMTI2WhgPMjIyNjAxMTgyMzExMjZaMF4xCzAJBgNVBAYT\r\nAlVTMQ4wDAYDVQQIDAVUZXhhczEPMA0GA1UEBwwGQXVzdGluMRAwDgYDVQQKDAdF\r\nbWVyc29uMRwwGgYDVQQDDBNuaS1leGFtcGxlIGNsaWVudCAxMIIBIjANBgkqhkiG\r\n9w0BAQEFAAOCAQ8AMIIBCgKCAQEAkDcoClWNwOcloFMsRA4fK2CMSYIld7th0zbt\r\nHJorSaPM6+8rgKGRMQi6fbSBu/vMRI+f0OMxHH51FAzqsUzwk5PT+HrXR5kXNn+R\r\nVJbETzMjlc02h2y1cMFITytg5ee6YPyztnjruXgXPRVzwzSUwi0zhz1EB/sQpH3c\r\nvZvzidcXyR9nu+dgR3I1+h+43fR80ERX//AGaYJeyQ1TfzpoY+qFc0wbbeX2I92r\r\nv/9DSn7XFSwHK/8HnA2lQ+6iTIu2FYpzadLzsqYDuWtGGQY7tzRke6VBOdM6VHO8\r\nVCtptxShLlwBBZT9xkDaPbH4wpZ9Nf2+AA9pMg7aFXmm1HOz8wIDAQABow0wCzAJ\r\nBgNVHREEAjAAMA0GCSqGSIb3DQEBCwUAA4IBAQBWdolL2+AQ8a6nRFgTjYHScS12\r\n6HjuFsGUas3wi9CXOWMyUYHQUwu/Q9RYXCrG7yBvMKLM7yHFO4Gofp8A6ECuGy7J\r\nxm4s/yVKU6oGN47HLaHyYrKiyY2MGEO81nrVjLJoChErB8du96f5QuGH9TFUOXcA\r\noSaJJI3VJ5mHSDhTlAXCcpudGRLngmSBPiyvglJO5TDr7PO8q6jwFyKdlOQJeZoh\r\nkUeu01TpZKQYN9oFjjVdxvAKtA7ztkvMkeuu3StxBzyuzYWXbxwwjdYu0SNUkPY5\r\nEdSokl7Ru6Ci1PPHEcs0XSBroh+5l7ujdVp/ZkDtHApaOC34Zsk5wOcEv0PN\r\n-----END CERTIFICATE-----\r\n","certificate_key_location":"File://C:/ProgramData/National Instruments/nitlsconfig/client.d/ni-mqtt/key.pem","certificate_key_contents":"-----BEGIN RSA PRIVATE KEY-----\r\nMIIEpAIBAAKCAQEAkDcoClWNwOcloFMsRA4fK2CMSYIld7th0zbtHJorSaPM6+8r\r\ngKGRMQi6fbSBu/vMRI+f0OMxHH51FAzqsUzwk5PT+HrXR5kXNn+RVJbETzMjlc02\r\nh2y1cMFITytg5ee6YPyztnjruXgXPRVzwzSUwi0zhz1EB/sQpH3cvZvzidcXyR9n\r\nu+dgR3I1+h+43fR80ERX//AGaYJeyQ1TfzpoY+qFc0wbbeX2I92rv/9DSn7XFSwH\r\nK/8HnA2lQ+6iTIu2FYpzadLzsqYDuWtGGQY7tzRke6VBOdM6VHO8VCtptxShLlwB\r\nBZT9xkDaPbH4wpZ9Nf2+AA9pMg7aFXmm1HOz8wIDAQABAoIBAALIVajxYqVMsHCp\r\n+ior/ZT4LWzDqpZnUqlhH20UWm52zx7kQ/oc4/DGwtZnE7O9VYEjJknBAHMUy4yB\r\n/UtDeUbXrNjYAexbO1PtXS/9IreGfJLI3Vr6kXul9w+z/hAhivqRFaSZK68t7oDa\r\nt5uWGaKvfZWH7Jac1QLffNFZv3ay3NKRNhODZWg8KYqeD0FNf73Qq09+PGkXdeJE\r\nKx/FmRsrOT5GfmHQRDX3Csf3gE9UifCm8wWRJ9g/zdyCbDSlvvIlg+/jce98dUcy\r\nXXmXwd227ItHUUlqX0tfPO+mWThVjhnBzwcdmvKp9rQ1uBZDWQ96yOIypjxBoUEs\r\n7oMJSH0CgYEAx6NmVjKHnU8bCAbc+EdvKi55K1R4QPA63SE03lVu5SQWTSJPsBtc\r\n+r5FmdjtnXIXmC4xZsCs/oCNViSgK2/q0Do6gv1NoUf5xBAJTKgeZqldU88HdVKQ\r\nxUrirGlZCLml54oOh+8RcIoVuLW2pRYFNcdKqWkgxrUT+nUjVRewwRUCgYEAuO4k\r\npHiTgQupHcx4h2xcA4LWLsM/Gr2iAdzgk/Ku4/eWl14Y7tqNblesxXmwHIQc5CpR\r\nCcq9jiMqCtUm12rCYnzGdSZ/FOKlnZFc+sP3BkFoEb8rMhgelKvtHYEVnn+NnLqx\r\nH2rO2c72uQfyW+CCbz/DGg8uhY6E8XtANtFaEucCgYEAxbzJ81zPSHf/DT9HBUA0\r\nEnK/n7Rl++Q2Waq3Y/T5B0tsL3Bpv8QKn21xIQZlrMpCJoCye9UiRF7uH0Hdx8ht\r\nq1vRcscUakgRUyqTG3N10Te18fogqAtky5X12NHr1yGS+ziaVffsrSyIgVCzHOcn\r\nt/GyuQQg69CVfkEiAvmZIFUCgYAlyr0AQ4fZWlmwNkX3XuSa7xT4L3xo6ZH/EXVv\r\npquo+ML0og00WsOGZjJvlMOxwCnrUt6GwsDkGDmSRycN5MpeuCMSc9CKoxq3TaqT\r\nsJJ928X5wniAXz94oH3vhp61alm1Ss3BnuEwz8PJ4l7b+dCODZjAbZPTRWHTu51O\r\n1o+yxwKBgQCzXSe+KYsRpXLGPbBZfr5H5NAmpJfv7gmUMjPkS2SNEgG0jxOftG3e\r\nSjRPqvTXXRNMJgW48HB1z7FZ8bzhePwo3zHl4LCDUx6kQfWZ6gfA/GqctXll9+dR\r\nauS1ZVrchbgCIbBMFsFSyYehKAdu+Pnd7x+6Sot0hHZNe/kF/8G6yw==\r\n-----END RSA PRIVATE KEY-----\r\n","server_mode":"TrustedCertificates","server_name":"example-host1","trusted_certificates_location":"File://C:/ProgramData/National Instruments/nitlsconfig/client.d/ni-mqtt/servers/example-host1.pem","trusted_certificates_contents":"# Common Name: ni-example server\r\n# Alternative Name: localhost\r\n# Alternative Name: 127.0.0.1\r\n-----BEGIN CERTIFICATE-----\r\nMIIDZjCCAk6gAwIBAgIUDghvYIzCaTcQGQlYnvKB8mJb7+EwDQYJKoZIhvcNAQEL\r\nBQAwXDELMAkGA1UEBhMCVVMxDjAMBgNVBAgMBVRleGFzMQ8wDQYDVQQHDAZBdXN0\r\naW4xEDAOBgNVBAoMB0VtZXJzb24xGjAYBgNVBAMMEW5pLWV4YW1wbGUgc2VydmVy\r\nMCAXDTI2MDMwNzIzMDk0NVoYDzIyMjYwMTE4MjMwOTQ1WjBcMQswCQYDVQQGEwJV\r\nUzEOMAwGA1UECAwFVGV4YXMxDzANBgNVBAcMBkF1c3RpbjEQMA4GA1UECgwHRW1l\r\ncnNvbjEaMBgGA1UEAwwRbmktZXhhbXBsZSBzZXJ2ZXIwggEiMA0GCSqGSIb3DQEB\r\nAQUAA4IBDwAwggEKAoIBAQCv15CSoihVoiYl27dax+4f4O3NmJ6GrUVOLyJ64Zcq\r\nTK66ITNfKxZYO+DVfflZJAoxgxgRVGOTuBBEH625Ur5aN1rHaCLm6KxaZ5A3Xwh9\r\nVoKddc5MrO6P3rlEFtKofvlMXMv6y8FkcW3SaMGkfEGyqb8d/kJfnPnIsBcQ2UWt\r\ngMOcD/QyiaCTFTkz6+ps8bUov3W/WPb0KBfjJcxYToil84LN6I+OvaE9beZp7aNa\r\nlM/WRXyWk0Z2wyxhuPHoAKuwJKDsppM3HagDfHjdj0X8XwYbifO8vPybCooKj/Av\r\ncJbLcAw1z4O1x21tRpqGlw8AG1HZFPHx8BsH27eVBgwRAgMBAAGjHjAcMBoGA1Ud\r\nEQQTMBGCCWxvY2FsaG9zdIcEfwAAATANBgkqhkiG9w0BAQsFAAOCAQEADrfeQ1Dp\r\nLC9Up2ERi6Pc23sEAz4I0wQYYiwuIQQ0T3lo87IKh4VyvbxmfzFhCnoNGgQZE+aG\r\njT37FtwOD9qoFoiunzWQ0TRvvuicsnGcUIupW6ptsjrBulX6LDichGPWro/R11Zx\r\nmpewc2y2Ox+ZPBAQpqqAzXdfmRReJMM/b2QSVcmvWu++gskC+6fUMCpnPh1wL545\r\nTuOPsKTLekwa75odnMfg2I5rhzIdfaCeGVZY5TiT4n8rt+xDPMoevHh/D8DM0Uwy\r\n6hNzqIynbiHHo5op+IR+H/Gwx/ris2QC9dkFJ6alSo3Zj+3aMtShgTQn0fpbVTSO\r\nMA5j/1Lz/fYZtg==\r\n-----END CERTIFICATE-----\r\n"}]},{"service_name":"ni-test","known_servers":[]}]} diff --git a/tests/unit/nitlsconfig_server.json b/tests/unit/nitlsconfig_server.json index b3d28d3..a062d3a 100644 --- a/tests/unit/nitlsconfig_server.json +++ b/tests/unit/nitlsconfig_server.json @@ -1 +1 @@ -{"server":[{"service_name":"ni-test","trusted_certificates":[]},{"service_name":"ni-windows","version":6,"display_name_en":"NI Test Service","certificate_mode":"ManagedSelfSigned","certificate_chain_location":"File://P:/dev/ni-central/src/platform_services/abstractions/nitlsconfigd/pypi-nitlsconfig/objects/nitlsconfigtest/nitlsconfig/server.d/ni-windows/cert.pem","certificate_chain_contents":"# Common Name: ni-example server\r\n# Alternative Name: localhost\r\n# Alternative Name: 127.0.0.1\r\n-----BEGIN CERTIFICATE-----\r\nMIIDZjCCAk6gAwIBAgIUDghvYIzCaTcQGQlYnvKB8mJb7+EwDQYJKoZIhvcNAQEL\r\nBQAwXDELMAkGA1UEBhMCVVMxDjAMBgNVBAgMBVRleGFzMQ8wDQYDVQQHDAZBdXN0\r\naW4xEDAOBgNVBAoMB0VtZXJzb24xGjAYBgNVBAMMEW5pLWV4YW1wbGUgc2VydmVy\r\nMCAXDTI2MDMwNzIzMDk0NVoYDzIyMjYwMTE4MjMwOTQ1WjBcMQswCQYDVQQGEwJV\r\nUzEOMAwGA1UECAwFVGV4YXMxDzANBgNVBAcMBkF1c3RpbjEQMA4GA1UECgwHRW1l\r\ncnNvbjEaMBgGA1UEAwwRbmktZXhhbXBsZSBzZXJ2ZXIwggEiMA0GCSqGSIb3DQEB\r\nAQUAA4IBDwAwggEKAoIBAQCv15CSoihVoiYl27dax+4f4O3NmJ6GrUVOLyJ64Zcq\r\nTK66ITNfKxZYO+DVfflZJAoxgxgRVGOTuBBEH625Ur5aN1rHaCLm6KxaZ5A3Xwh9\r\nVoKddc5MrO6P3rlEFtKofvlMXMv6y8FkcW3SaMGkfEGyqb8d/kJfnPnIsBcQ2UWt\r\ngMOcD/QyiaCTFTkz6+ps8bUov3W/WPb0KBfjJcxYToil84LN6I+OvaE9beZp7aNa\r\nlM/WRXyWk0Z2wyxhuPHoAKuwJKDsppM3HagDfHjdj0X8XwYbifO8vPybCooKj/Av\r\ncJbLcAw1z4O1x21tRpqGlw8AG1HZFPHx8BsH27eVBgwRAgMBAAGjHjAcMBoGA1Ud\r\nEQQTMBGCCWxvY2FsaG9zdIcEfwAAATANBgkqhkiG9w0BAQsFAAOCAQEADrfeQ1Dp\r\nLC9Up2ERi6Pc23sEAz4I0wQYYiwuIQQ0T3lo87IKh4VyvbxmfzFhCnoNGgQZE+aG\r\njT37FtwOD9qoFoiunzWQ0TRvvuicsnGcUIupW6ptsjrBulX6LDichGPWro/R11Zx\r\nmpewc2y2Ox+ZPBAQpqqAzXdfmRReJMM/b2QSVcmvWu++gskC+6fUMCpnPh1wL545\r\nTuOPsKTLekwa75odnMfg2I5rhzIdfaCeGVZY5TiT4n8rt+xDPMoevHh/D8DM0Uwy\r\n6hNzqIynbiHHo5op+IR+H/Gwx/ris2QC9dkFJ6alSo3Zj+3aMtShgTQn0fpbVTSO\r\nMA5j/1Lz/fYZtg==\r\n-----END CERTIFICATE-----\r\n","certificate_key_location":"File://P:/dev/ni-central/src/platform_services/abstractions/nitlsconfigd/pypi-nitlsconfig/objects/nitlsconfigtest/nitlsconfig/server.d/ni-windows/key.pem","certificate_key_contents":"-----BEGIN RSA PRIVATE KEY-----\r\nMIIEowIBAAKCAQEAr9eQkqIoVaImJdu3WsfuH+DtzZiehq1FTi8ieuGXKkyuuiEz\r\nXysWWDvg1X35WSQKMYMYEVRjk7gQRB+tuVK+Wjdax2gi5uisWmeQN18IfVaCnXXO\r\nTKzuj965RBbSqH75TFzL+svBZHFt0mjBpHxBsqm/Hf5CX5z5yLAXENlFrYDDnA/0\r\nMomgkxU5M+vqbPG1KL91v1j29CgX4yXMWE6IpfOCzeiPjr2hPW3mae2jWpTP1kV8\r\nlpNGdsMsYbjx6ACrsCSg7KaTNx2oA3x43Y9F/F8GG4nzvLz8mwqKCo/wL3CWy3AM\r\nNc+DtcdtbUaahpcPABtR2RTx8fAbB9u3lQYMEQIDAQABAoIBAAlAdxsq+GKwjU+n\r\nvr8CVtJjMRnknD8nZ9aKMvcKjmu/pQAWsftxcruV23GaLyPYOkfSfyI+yZ42pJb0\r\nAH3IJ2srX02vekoiTvqy+R7bs4B2PDk1DwOgY+0qjZ6CMadjRmJE3efiP3i1od7M\r\nq5cYIVQJW7yUEZmSop8TOkR0/RY4wj95ue/4HyV7re0+SM9fw/26NODlIRFfRKU4\r\nOQyOxvBjRp442RbxQ0qYjApN1wlrb3OnHNEU0vUcJGGJrQ9n7HKuL/szT7zZiziI\r\nayPtYMXm5JodnFuqs43JTLr9zqHRRTsrxOFm0Z/VMbDpfCgQ/IUzoptkpwZ16UCe\r\npvpb/uECgYEA3t6YWvfwf9sdcRMvrVbhC30w4ZQS9yrMcJ6Fhg1osZQCSe1n5MHo\r\nkYPKHY1xtz84DC6FZxcyKnLocZ9FWCP+pkaVs+P7KdR+dTF1Die3A3qReralP8lc\r\n6wUaIE0gtha7wwYzlaS2oy7Op7ZHgXv1/nQCLBX1JI70UO0sGH2yg+ECgYEAyftR\r\nNnHfGyuos8cEUHOZgVfopRkR1nPIf//pOtMWxAm6K97w//idDA+H9i2uXhhnbqMZ\r\nzY1TjFHzEoAJtxNqWxRrrnX+A9fDk5ILmUC9n+MgWN4CBDoyl9NDt0JnUM4myiH0\r\nfIfUeaKgXfQyI4BNHlLfv+CDmBdUxWkpgpc0jjECgYAVgJ5scG14uHg/t50q+S3f\r\nQOXdbb7ir/NnjjPNui92qpQJ8+jJLlg06eUvMr1c87GBo19lkdxaMhnxFG/JUDe6\r\nbSb2YzGRWJoWzn2/rt8+a63rjAGnhNL9LdHWefqE9u6Io2mIr4qbdeGkVrbRbFdQ\r\nhHrNLBUpwM0bnbZaao41oQKBgQClcS6favx1c/h2cVCuWVHmaUxNV1COGT6k4ch3\r\nf4oJP3J4Ft/OuyRgSaRzWA2YU2L9c6lh/h3uNP3mLGxIwiV+vyZIu6s6GJTBHoJT\r\noHzyJwdK42ZBci4YwFqaFFEuFf4d2Vw6WMQIcdXrv2cGP0RrzzQpJcyEWq/8F1yB\r\n9Dp/MQKBgDcL5ctO2erleyBO2qeaeQP+UVByQDH5Ek6PNmQ7wPjoAPDxvAeIHMTe\r\nyirOsEqfNGtebhXxgIzU/EfP/hgafTIwYui78NJqILhYPmN3SpGvSrikf+AS1Fgm\r\nPK3Qhxzc7yq48l0oLha1Lc9FOMAlFLhzYQbO6fX1IWAknhcbaVnF\r\n-----END RSA PRIVATE KEY-----\r\n","client_mode":"ManagedSelfSigned","trusted_certificates_location":"Directory://P:/dev/ni-central/src/platform_services/abstractions/nitlsconfigd/pypi-nitlsconfig/objects/nitlsconfigtest/nitlsconfig/server.d/ni-windows/trusted.d","trusted_certificates_contents":"alpha-trusted-certificate\nbeta-trusted-certificate\n","trusted_certificates_location":"Directory://P:/dev/ni-central/src/platform_services/abstractions/nitlsconfigd/pypi-nitlsconfig/objects/nitlsconfigtest/nitlsconfig/server.d/ni-windows/trusted.d","trusted_certificates":[{"display_name_en":"NI Test Service","trusted_certificate_location":"File://P:/dev/ni-central/src/platform_services/abstractions/nitlsconfigd/pypi-nitlsconfig/objects/nitlsconfigtest/nitlsconfig/server.d/ni-windows/trusted.d/alpha.pem","trusted_certificate_contents":"alpha-trusted-certificate"},{"display_name_en":"NI Test Service","trusted_certificate_location":"File://P:/dev/ni-central/src/platform_services/abstractions/nitlsconfigd/pypi-nitlsconfig/objects/nitlsconfigtest/nitlsconfig/server.d/ni-windows/trusted.d/beta.pem","trusted_certificate_contents":"beta-trusted-certificate"}]}]} +{"server":[{"service_name":"ni-test","trusted_certificates":[]},{"service_name":"ni-windows","version":6,"display_name_en":"NI Test Service","certificate_mode":"ManagedSelfSigned","certificate_chain_location":"File://C:/ProgramData/National Instruments/nitlsconfig/server.d/ni-windows/cert.pem","certificate_chain_contents":"# Common Name: ni-example server\r\n# Alternative Name: localhost\r\n# Alternative Name: 127.0.0.1\r\n-----BEGIN CERTIFICATE-----\r\nMIIDZjCCAk6gAwIBAgIUDghvYIzCaTcQGQlYnvKB8mJb7+EwDQYJKoZIhvcNAQEL\r\nBQAwXDELMAkGA1UEBhMCVVMxDjAMBgNVBAgMBVRleGFzMQ8wDQYDVQQHDAZBdXN0\r\naW4xEDAOBgNVBAoMB0VtZXJzb24xGjAYBgNVBAMMEW5pLWV4YW1wbGUgc2VydmVy\r\nMCAXDTI2MDMwNzIzMDk0NVoYDzIyMjYwMTE4MjMwOTQ1WjBcMQswCQYDVQQGEwJV\r\nUzEOMAwGA1UECAwFVGV4YXMxDzANBgNVBAcMBkF1c3RpbjEQMA4GA1UECgwHRW1l\r\ncnNvbjEaMBgGA1UEAwwRbmktZXhhbXBsZSBzZXJ2ZXIwggEiMA0GCSqGSIb3DQEB\r\nAQUAA4IBDwAwggEKAoIBAQCv15CSoihVoiYl27dax+4f4O3NmJ6GrUVOLyJ64Zcq\r\nTK66ITNfKxZYO+DVfflZJAoxgxgRVGOTuBBEH625Ur5aN1rHaCLm6KxaZ5A3Xwh9\r\nVoKddc5MrO6P3rlEFtKofvlMXMv6y8FkcW3SaMGkfEGyqb8d/kJfnPnIsBcQ2UWt\r\ngMOcD/QyiaCTFTkz6+ps8bUov3W/WPb0KBfjJcxYToil84LN6I+OvaE9beZp7aNa\r\nlM/WRXyWk0Z2wyxhuPHoAKuwJKDsppM3HagDfHjdj0X8XwYbifO8vPybCooKj/Av\r\ncJbLcAw1z4O1x21tRpqGlw8AG1HZFPHx8BsH27eVBgwRAgMBAAGjHjAcMBoGA1Ud\r\nEQQTMBGCCWxvY2FsaG9zdIcEfwAAATANBgkqhkiG9w0BAQsFAAOCAQEADrfeQ1Dp\r\nLC9Up2ERi6Pc23sEAz4I0wQYYiwuIQQ0T3lo87IKh4VyvbxmfzFhCnoNGgQZE+aG\r\njT37FtwOD9qoFoiunzWQ0TRvvuicsnGcUIupW6ptsjrBulX6LDichGPWro/R11Zx\r\nmpewc2y2Ox+ZPBAQpqqAzXdfmRReJMM/b2QSVcmvWu++gskC+6fUMCpnPh1wL545\r\nTuOPsKTLekwa75odnMfg2I5rhzIdfaCeGVZY5TiT4n8rt+xDPMoevHh/D8DM0Uwy\r\n6hNzqIynbiHHo5op+IR+H/Gwx/ris2QC9dkFJ6alSo3Zj+3aMtShgTQn0fpbVTSO\r\nMA5j/1Lz/fYZtg==\r\n-----END CERTIFICATE-----\r\n","certificate_key_location":"File://C:/ProgramData/National Instruments/nitlsconfig/server.d/ni-windows/key.pem","certificate_key_contents":"-----BEGIN RSA PRIVATE KEY-----\r\nMIIEowIBAAKCAQEAr9eQkqIoVaImJdu3WsfuH+DtzZiehq1FTi8ieuGXKkyuuiEz\r\nXysWWDvg1X35WSQKMYMYEVRjk7gQRB+tuVK+Wjdax2gi5uisWmeQN18IfVaCnXXO\r\nTKzuj965RBbSqH75TFzL+svBZHFt0mjBpHxBsqm/Hf5CX5z5yLAXENlFrYDDnA/0\r\nMomgkxU5M+vqbPG1KL91v1j29CgX4yXMWE6IpfOCzeiPjr2hPW3mae2jWpTP1kV8\r\nlpNGdsMsYbjx6ACrsCSg7KaTNx2oA3x43Y9F/F8GG4nzvLz8mwqKCo/wL3CWy3AM\r\nNc+DtcdtbUaahpcPABtR2RTx8fAbB9u3lQYMEQIDAQABAoIBAAlAdxsq+GKwjU+n\r\nvr8CVtJjMRnknD8nZ9aKMvcKjmu/pQAWsftxcruV23GaLyPYOkfSfyI+yZ42pJb0\r\nAH3IJ2srX02vekoiTvqy+R7bs4B2PDk1DwOgY+0qjZ6CMadjRmJE3efiP3i1od7M\r\nq5cYIVQJW7yUEZmSop8TOkR0/RY4wj95ue/4HyV7re0+SM9fw/26NODlIRFfRKU4\r\nOQyOxvBjRp442RbxQ0qYjApN1wlrb3OnHNEU0vUcJGGJrQ9n7HKuL/szT7zZiziI\r\nayPtYMXm5JodnFuqs43JTLr9zqHRRTsrxOFm0Z/VMbDpfCgQ/IUzoptkpwZ16UCe\r\npvpb/uECgYEA3t6YWvfwf9sdcRMvrVbhC30w4ZQS9yrMcJ6Fhg1osZQCSe1n5MHo\r\nkYPKHY1xtz84DC6FZxcyKnLocZ9FWCP+pkaVs+P7KdR+dTF1Die3A3qReralP8lc\r\n6wUaIE0gtha7wwYzlaS2oy7Op7ZHgXv1/nQCLBX1JI70UO0sGH2yg+ECgYEAyftR\r\nNnHfGyuos8cEUHOZgVfopRkR1nPIf//pOtMWxAm6K97w//idDA+H9i2uXhhnbqMZ\r\nzY1TjFHzEoAJtxNqWxRrrnX+A9fDk5ILmUC9n+MgWN4CBDoyl9NDt0JnUM4myiH0\r\nfIfUeaKgXfQyI4BNHlLfv+CDmBdUxWkpgpc0jjECgYAVgJ5scG14uHg/t50q+S3f\r\nQOXdbb7ir/NnjjPNui92qpQJ8+jJLlg06eUvMr1c87GBo19lkdxaMhnxFG/JUDe6\r\nbSb2YzGRWJoWzn2/rt8+a63rjAGnhNL9LdHWefqE9u6Io2mIr4qbdeGkVrbRbFdQ\r\nhHrNLBUpwM0bnbZaao41oQKBgQClcS6favx1c/h2cVCuWVHmaUxNV1COGT6k4ch3\r\nf4oJP3J4Ft/OuyRgSaRzWA2YU2L9c6lh/h3uNP3mLGxIwiV+vyZIu6s6GJTBHoJT\r\noHzyJwdK42ZBci4YwFqaFFEuFf4d2Vw6WMQIcdXrv2cGP0RrzzQpJcyEWq/8F1yB\r\n9Dp/MQKBgDcL5ctO2erleyBO2qeaeQP+UVByQDH5Ek6PNmQ7wPjoAPDxvAeIHMTe\r\nyirOsEqfNGtebhXxgIzU/EfP/hgafTIwYui78NJqILhYPmN3SpGvSrikf+AS1Fgm\r\nPK3Qhxzc7yq48l0oLha1Lc9FOMAlFLhzYQbO6fX1IWAknhcbaVnF\r\n-----END RSA PRIVATE KEY-----\r\n","client_mode":"ManagedSelfSigned","trusted_certificates_location":"Directory://C:/ProgramData/National Instruments/nitlsconfig/server.d/ni-windows/trusted.d","trusted_certificates_contents":"alpha-trusted-certificate\nbeta-trusted-certificate\n","trusted_certificates_location":"Directory://C:/ProgramData/National Instruments/nitlsconfig/server.d/ni-windows/trusted.d","trusted_certificates":[{"display_name_en":"NI Test Service","trusted_certificate_location":"File://C:/ProgramData/National Instruments/nitlsconfig/server.d/ni-windows/trusted.d/alpha.pem","trusted_certificate_contents":"alpha-trusted-certificate"},{"display_name_en":"NI Test Service","trusted_certificate_location":"File://C:/ProgramData/National Instruments/nitlsconfig/server.d/ni-windows/trusted.d/beta.pem","trusted_certificate_contents":"beta-trusted-certificate"}]}]} diff --git a/tests/unit/test_grpc_channel.py b/tests/unit/test_grpc_channel.py index 25c66a1..303ca26 100644 --- a/tests/unit/test_grpc_channel.py +++ b/tests/unit/test_grpc_channel.py @@ -191,7 +191,7 @@ def test_skip_hostname_validation_matches_trusted_certificates( monkeypatch: pytest.MonkeyPatch, ) -> None: # grpc's Python API cannot skip only the hostname check, so this mode is - # deliberately treated as TrustedCertificates. nigrpctls does the same. + # deliberately treated as TrustedCertificates. def config(server_mode: ClientServerMode) -> FakeClientConfig: return FakeClientConfig( server_mode=server_mode, From 778789b08103575b5f4885de4e137b016e1ea9a6 Mon Sep 17 00:00:00 2001 From: Alex Dubois Date: Thu, 30 Jul 2026 15:49:08 -0500 Subject: [PATCH 5/5] Address review bot comments. Fix CI failure --- src/nitlsconfig/__init__.py | 32 ++++++++++------ src/nitlsconfig/grpc_channel.py | 53 +++++++++++++++++++++----- tests/unit/test_grpc_channel.py | 67 +++++++++++++++++++++++++++++++++ tests/unit/test_nitlsconfig.py | 4 +- 4 files changed, 133 insertions(+), 23 deletions(-) diff --git a/src/nitlsconfig/__init__.py b/src/nitlsconfig/__init__.py index 7dfcca5..bc04baa 100644 --- a/src/nitlsconfig/__init__.py +++ b/src/nitlsconfig/__init__.py @@ -12,6 +12,7 @@ """ from importlib.metadata import version +from importlib.util import find_spec from typing import TYPE_CHECKING, Any from nitlsconfig.cli import ( @@ -43,14 +44,14 @@ __version__ = version("nitlsconfig") # Names re-exported from nitlsconfig.grpc_channel, which requires grpcio. -_GRPC_EXPORTS = frozenset( - { - "DEFAULT_SERVICE_NAME", - "RetryPolicy", - "TlsConfigurationError", - "create_grpc_client_channel", - } -) +# A plain list literal, because pyright only tracks __all__ through a small set +# of literal forms; anything computed makes it give up on the export list. +_GRPC_EXPORTS = [ + "DEFAULT_SERVICE_NAME", + "RetryPolicy", + "TlsConfigurationError", + "create_grpc_client_channel", +] __all__ = [ "__version__", @@ -68,12 +69,19 @@ "InvalidOutputError", "TrustedCertificateData", "KnownServerData", - "DEFAULT_SERVICE_NAME", - "RetryPolicy", - "TlsConfigurationError", - "create_grpc_client_channel", ] +# The gRPC names are public API, but only on an install that can supply them. +# Listing them unconditionally would make `from nitlsconfig import *` raise +# ImportError without the grpc extra, since star-import resolves every name in +# __all__. find_spec only locates grpcio; it does not import it, so the lazy +# __getattr__ below still decides when grpcio is actually loaded. +if find_spec("grpc") is not None: + # pyright only tracks __all__ through inline literals, so it cannot follow + # this and warns that the export list may be incomplete. The TYPE_CHECKING + # block above already declares these names for static consumers. + __all__ += _GRPC_EXPORTS # pyright: ignore[reportUnsupportedDunderAll] + def __getattr__(name: str) -> Any: """Resolve gRPC exports on first use, so importing this package does not need grpcio.""" diff --git a/src/nitlsconfig/grpc_channel.py b/src/nitlsconfig/grpc_channel.py index fa1b010..623124e 100644 --- a/src/nitlsconfig/grpc_channel.py +++ b/src/nitlsconfig/grpc_channel.py @@ -89,6 +89,15 @@ def _format_target(server_address: str, server_port: int) -> str: class TlsConfigurationError(NitlsconfigCliError): """Raised when the NI-TLS configuration was read successfully but is invalid.""" + #: Shared remedy text appended to messages whose fix is to provision + #: certificates. Kept in one place so the guidance stays consistent with the + #: wording used elsewhere in the product. + _REMEDY = ( + "Use NI Hardware Manager to verify that certificates are configured and " + "matching on both the host and remote target. Check that the remote target " + "has a compatible TLS enabled configuration with the host." + ) + @dataclass(frozen=True) class RetryPolicy: @@ -206,6 +215,23 @@ def _require_file_scheme( ) +def _require_contents(contents: str, description: str, service_name: str) -> str: + """Validate that configured certificate material is actually present. + + Empty contents mean the material could not be produced (missing, unreadable, + or not yet provisioned), never that the client opted out. Opting out is + expressed by the configuration itself: ``certificate_mode`` Disabled for the + client identity, and the SystemDefault scheme for trust anchors. Neither + reaches this check. + """ + if not contents: + raise TlsConfigurationError( + f"TLS is configured for service {service_name!r} but the client " + f"{description} is missing on this system. {TlsConfigurationError._REMEDY}" + ) + return contents + + def _load_client_tls_settings(config: ClientConfig) -> Optional[_ClientTlsSettings]: """Read and validate client TLS settings, or None when TLS is not in use. @@ -238,23 +264,32 @@ def _load_client_tls_settings(config: ClientConfig) -> Optional[_ClientTlsSettin if present_client_cert: _require_file_scheme(config.certificate_chain_location, "certificate chain", service_name) _require_file_scheme(config.certificate_key_location, "certificate key", service_name) - certificate_chain_contents = config.certificate_chain_contents - private_key_contents = config.certificate_key_contents + certificate_chain_contents = _require_contents( + config.certificate_chain_contents, "certificate chain", service_name + ) + private_key_contents = _require_contents( + config.certificate_key_contents, "certificate key", service_name + ) # Trust anchors are always required: the client must verify the server. + # SystemDefault means "use the platform certificate store" and carries no + # contents. Every other usable scheme (File, Directory) is resolved by + # nitlsconfig into a single PEM bundle, so the scheme itself does not need + # to be special-cased here; only Unknown is rejected. trusted_location = config.trusted_certificates_location - trusted_available = trusted_location.scheme == LocationScheme.SystemDefault or bool( - trusted_location.path - ) - if not trusted_available: + if trusted_location.scheme == LocationScheme.Unknown: raise TlsConfigurationError( - "TLS is enabled but the client trusted certificates path is missing for " - f"service {service_name!r}." + f"TLS is configured for service {service_name!r} but the client trusted " + f"certificates location is missing or unrecognized. {TlsConfigurationError._REMEDY}" ) trusted_contents = "" if trusted_location.scheme != LocationScheme.SystemDefault: - trusted_contents = config.trusted_certificates_contents + # Any scheme other than SystemDefault names specific anchors, so they + # must actually be present. + trusted_contents = _require_contents( + config.trusted_certificates_contents, "trusted certificate bundle", service_name + ) return _ClientTlsSettings( present_client_cert=present_client_cert, diff --git a/tests/unit/test_grpc_channel.py b/tests/unit/test_grpc_channel.py index 303ca26..538fe4f 100644 --- a/tests/unit/test_grpc_channel.py +++ b/tests/unit/test_grpc_channel.py @@ -187,6 +187,24 @@ def test_one_way_tls_credentials( assert channel.credentials["certificate_chain"] is None +def test_directory_trust_anchors_are_supported( + monkeypatch: pytest.MonkeyPatch, +) -> None: + # nitlsconfig resolves a Directory of anchors into one PEM bundle, so Directory + # is a working trust source and must not be rejected as an unsupported scheme. + config = FakeClientConfig( + server_mode=ClientServerMode.TrustedCertificates, + certificate_mode=ClientCertMode.Disabled, + trusted_certificates_location=CertificateLocation(LocationScheme.Directory, "trusted.d"), + trusted_certificates_contents="ROOT_A\nROOT_B", + ) + + channel = create_channel(monkeypatch, config) + + assert channel.credentials is not None + assert channel.credentials["root_certificates"] == b"ROOT_A\nROOT_B" + + def test_skip_hostname_validation_matches_trusted_certificates( monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -255,6 +273,55 @@ def config(server_mode: ClientServerMode) -> FakeClientConfig: ), id="missing_trust_anchors", ), + pytest.param( + FakeClientConfig( + server_mode=ClientServerMode.TrustedCertificates, + certificate_mode=ClientCertMode.Disabled, + trusted_certificates_location=CertificateLocation(LocationScheme.Unknown), + ), + id="unknown_trust_scheme", + ), + # A File trust bundle that produced nothing must fail rather than fall back + # to the platform trust store, which would silently widen trust far beyond + # the configured anchors. + pytest.param( + FakeClientConfig( + server_mode=ClientServerMode.TrustedCertificates, + certificate_mode=ClientCertMode.Disabled, + trusted_certificates_location=FILE_TRUST, + trusted_certificates_contents="", + ), + id="empty_trusted_contents", + ), + # A configured client certificate whose material is empty must fail rather + # than silently downgrade the connection to one-way TLS. Opting out of mTLS + # is expressed by certificate_mode Disabled, not by empty contents. + pytest.param( + FakeClientConfig( + server_mode=ClientServerMode.TrustedCertificates, + certificate_mode=ClientCertMode.Managed, + certificate_chain_location=FILE_CERT, + certificate_chain_contents="", + certificate_key_location=FILE_KEY, + certificate_key_contents="KEY", + trusted_certificates_location=FILE_TRUST, + trusted_certificates_contents="ROOT", + ), + id="empty_certificate_chain_contents", + ), + pytest.param( + FakeClientConfig( + server_mode=ClientServerMode.TrustedCertificates, + certificate_mode=ClientCertMode.Managed, + certificate_chain_location=FILE_CERT, + certificate_chain_contents="CERT", + certificate_key_location=FILE_KEY, + certificate_key_contents="", + trusted_certificates_location=FILE_TRUST, + trusted_certificates_contents="ROOT", + ), + id="empty_certificate_key_contents", + ), ], ) def test_invalid_configuration_raises( diff --git a/tests/unit/test_nitlsconfig.py b/tests/unit/test_nitlsconfig.py index c9933aa..249539b 100644 --- a/tests/unit/test_nitlsconfig.py +++ b/tests/unit/test_nitlsconfig.py @@ -3,7 +3,7 @@ import json import pathlib import platform -from typing import cast, Mapping, TypedDict +from typing import cast, Mapping, Optional, TypedDict import grpc import pytest @@ -187,7 +187,7 @@ def test_real_config_drives_channel_credentials(monkeypatch: pytest.MonkeyPatch) captured: dict[str, object] = {} real_ssl_channel_credentials = grpc.ssl_channel_credentials - def spy(**kwargs: object) -> object: + def spy(**kwargs: Optional[bytes]) -> grpc.ChannelCredentials: captured.update(kwargs) return real_ssl_channel_credentials(**kwargs)