From c971b3a6ec9e065a49b661d0a9660c3a4b258473 Mon Sep 17 00:00:00 2001 From: Mike Prosser Date: Wed, 23 Apr 2025 15:58:34 -0500 Subject: [PATCH 01/17] first draft (breaks tests) --- CONTRIBUTING.md | 2 +- poetry.lock | 255 +++++++++++++++++- pyproject.toml | 8 +- .../v1/python_panel_service_pb2.pyi | 144 ++++++++++ src/nipanel/_panel.py | 20 +- src/nipanel/_streamlit_panel.py | 12 +- 6 files changed, 420 insertions(+), 21 deletions(-) create mode 100644 src/ni/pythonpanel/v1/python_panel_service_pb2.pyi diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f675222..9fae7ae 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -22,7 +22,7 @@ See [GitHub's official documentation](https://help.github.com/articles/using-pul # Getting Started This is the command to generate the files in /src/ni/pythonpanel/v1/: -`poetry run python -m grpc_tools.protoc --proto_path=protos --python_out=src/ --grpc_python_out=src/ ni/pythonpanel/v1/python_panel_service.proto` +`poetry run python -m grpc_tools.protoc --proto_path=protos --python_out=src/ --grpc_python_out=src/ --plugin=protoc-gen-mypy=.venv\Scripts\protoc-gen-mypy.exe --mypy_out=src/ ni/pythonpanel/v1/python_panel_service.proto` # Testing diff --git a/poetry.lock b/poetry.lock index fa70539..05d9e23 100644 --- a/poetry.lock +++ b/poetry.lock @@ -85,6 +85,26 @@ files = [ [package.dependencies] colorama = {version = "*", markers = "platform_system == \"Windows\""} +[[package]] +name = "click-option-group" +version = "0.5.7" +description = "Option groups missing in Click" +optional = false +python-versions = ">=3.7" +files = [ + {file = "click_option_group-0.5.7-py3-none-any.whl", hash = "sha256:96b9f52f397ef4d916f81929bd6c1f85e89046c7a401a64e72a61ae74ad35c24"}, + {file = "click_option_group-0.5.7.tar.gz", hash = "sha256:8dc780be038712fc12c9fecb3db4fe49e0d0723f9c171d7cda85c20369be693c"}, +] + +[package.dependencies] +click = ">=7.0" + +[package.extras] +dev = ["pre-commit", "pytest"] +docs = ["m2r2", "pallets-sphinx-themes", "sphinx"] +test = ["pytest"] +test-cov = ["pytest", "pytest-cov"] + [[package]] name = "colorama" version = "0.4.6" @@ -174,6 +194,20 @@ tomli = {version = "*", optional = true, markers = "python_full_version <= \"3.1 [package.extras] toml = ["tomli"] +[[package]] +name = "deprecation" +version = "2.1.0" +description = "A library to handle automated deprecations" +optional = false +python-versions = "*" +files = [ + {file = "deprecation-2.1.0-py2.py3-none-any.whl", hash = "sha256:a10811591210e1fb0e768a8c25517cabeabcba6f0bf96564f8ff45189f90b14a"}, + {file = "deprecation-2.1.0.tar.gz", hash = "sha256:72b3bde64e5d778694b0cf68178aed03d15e15477116add3fb773e581f9518ff"}, +] + +[package.dependencies] +packaging = "*" + [[package]] name = "exceptiongroup" version = "1.2.2" @@ -554,6 +588,25 @@ files = [ colors = ["colorama"] plugins = ["setuptools"] +[[package]] +name = "mako" +version = "1.3.10" +description = "A super-fast templating language that borrows the best ideas from the existing templating languages." +optional = false +python-versions = ">=3.8" +files = [ + {file = "mako-1.3.10-py3-none-any.whl", hash = "sha256:baef24a52fc4fc514a0887ac600f9f1cff3d82c61d4d700a1fa84d597b88db59"}, + {file = "mako-1.3.10.tar.gz", hash = "sha256:99579a6f39583fa7e5630a28c3c1f440e4e97a414b80372649c0ce338da2ea28"}, +] + +[package.dependencies] +MarkupSafe = ">=0.9.2" + +[package.extras] +babel = ["Babel"] +lingua = ["lingua"] +testing = ["pytest"] + [[package]] name = "markdown-it-py" version = "3.0.0" @@ -578,6 +631,76 @@ profiling = ["gprof2dot"] rtd = ["jupyter_sphinx", "mdit-py-plugins", "myst-parser", "pyyaml", "sphinx", "sphinx-copybutton", "sphinx-design", "sphinx_book_theme"] testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions"] +[[package]] +name = "markupsafe" +version = "3.0.2" +description = "Safely add untrusted strings to HTML/XML markup." +optional = false +python-versions = ">=3.9" +files = [ + {file = "MarkupSafe-3.0.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7e94c425039cde14257288fd61dcfb01963e658efbc0ff54f5306b06054700f8"}, + {file = "MarkupSafe-3.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9e2d922824181480953426608b81967de705c3cef4d1af983af849d7bd619158"}, + {file = "MarkupSafe-3.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:38a9ef736c01fccdd6600705b09dc574584b89bea478200c5fbf112a6b0d5579"}, + {file = "MarkupSafe-3.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bbcb445fa71794da8f178f0f6d66789a28d7319071af7a496d4d507ed566270d"}, + {file = "MarkupSafe-3.0.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:57cb5a3cf367aeb1d316576250f65edec5bb3be939e9247ae594b4bcbc317dfb"}, + {file = "MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:3809ede931876f5b2ec92eef964286840ed3540dadf803dd570c3b7e13141a3b"}, + {file = "MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e07c3764494e3776c602c1e78e298937c3315ccc9043ead7e685b7f2b8d47b3c"}, + {file = "MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b424c77b206d63d500bcb69fa55ed8d0e6a3774056bdc4839fc9298a7edca171"}, + {file = "MarkupSafe-3.0.2-cp310-cp310-win32.whl", hash = "sha256:fcabf5ff6eea076f859677f5f0b6b5c1a51e70a376b0579e0eadef8db48c6b50"}, + {file = "MarkupSafe-3.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:6af100e168aa82a50e186c82875a5893c5597a0c1ccdb0d8b40240b1f28b969a"}, + {file = "MarkupSafe-3.0.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:9025b4018f3a1314059769c7bf15441064b2207cb3f065e6ea1e7359cb46db9d"}, + {file = "MarkupSafe-3.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:93335ca3812df2f366e80509ae119189886b0f3c2b81325d39efdb84a1e2ae93"}, + {file = "MarkupSafe-3.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2cb8438c3cbb25e220c2ab33bb226559e7afb3baec11c4f218ffa7308603c832"}, + {file = "MarkupSafe-3.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a123e330ef0853c6e822384873bef7507557d8e4a082961e1defa947aa59ba84"}, + {file = "MarkupSafe-3.0.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1e084f686b92e5b83186b07e8a17fc09e38fff551f3602b249881fec658d3eca"}, + {file = "MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d8213e09c917a951de9d09ecee036d5c7d36cb6cb7dbaece4c71a60d79fb9798"}, + {file = "MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:5b02fb34468b6aaa40dfc198d813a641e3a63b98c2b05a16b9f80b7ec314185e"}, + {file = "MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0bff5e0ae4ef2e1ae4fdf2dfd5b76c75e5c2fa4132d05fc1b0dabcd20c7e28c4"}, + {file = "MarkupSafe-3.0.2-cp311-cp311-win32.whl", hash = "sha256:6c89876f41da747c8d3677a2b540fb32ef5715f97b66eeb0c6b66f5e3ef6f59d"}, + {file = "MarkupSafe-3.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:70a87b411535ccad5ef2f1df5136506a10775d267e197e4cf531ced10537bd6b"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:9778bd8ab0a994ebf6f84c2b949e65736d5575320a17ae8984a77fab08db94cf"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:846ade7b71e3536c4e56b386c2a47adf5741d2d8b94ec9dc3e92e5e1ee1e2225"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1c99d261bd2d5f6b59325c92c73df481e05e57f19837bdca8413b9eac4bd8028"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e17c96c14e19278594aa4841ec148115f9c7615a47382ecb6b82bd8fea3ab0c8"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:88416bd1e65dcea10bc7569faacb2c20ce071dd1f87539ca2ab364bf6231393c"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2181e67807fc2fa785d0592dc2d6206c019b9502410671cc905d132a92866557"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:52305740fe773d09cffb16f8ed0427942901f00adedac82ec8b67752f58a1b22"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ad10d3ded218f1039f11a75f8091880239651b52e9bb592ca27de44eed242a48"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-win32.whl", hash = "sha256:0f4ca02bea9a23221c0182836703cbf8930c5e9454bacce27e767509fa286a30"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:8e06879fc22a25ca47312fbe7c8264eb0b662f6db27cb2d3bbbc74b1df4b9b87"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ba9527cdd4c926ed0760bc301f6728ef34d841f405abf9d4f959c478421e4efd"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f8b3d067f2e40fe93e1ccdd6b2e1d16c43140e76f02fb1319a05cf2b79d99430"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:569511d3b58c8791ab4c2e1285575265991e6d8f8700c7be0e88f86cb0672094"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15ab75ef81add55874e7ab7055e9c397312385bd9ced94920f2802310c930396"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f3818cb119498c0678015754eba762e0d61e5b52d34c8b13d770f0719f7b1d79"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cdb82a876c47801bb54a690c5ae105a46b392ac6099881cdfb9f6e95e4014c6a"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:cabc348d87e913db6ab4aa100f01b08f481097838bdddf7c7a84b7575b7309ca"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:444dcda765c8a838eaae23112db52f1efaf750daddb2d9ca300bcae1039adc5c"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-win32.whl", hash = "sha256:bcf3e58998965654fdaff38e58584d8937aa3096ab5354d493c77d1fdd66d7a1"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:e6a2a455bd412959b57a172ce6328d2dd1f01cb2135efda2e4576e8a23fa3b0f"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:b5a6b3ada725cea8a5e634536b1b01c30bcdcd7f9c6fff4151548d5bf6b3a36c"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a904af0a6162c73e3edcb969eeeb53a63ceeb5d8cf642fade7d39e7963a22ddb"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4aa4e5faecf353ed117801a068ebab7b7e09ffb6e1d5e412dc852e0da018126c"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c0ef13eaeee5b615fb07c9a7dadb38eac06a0608b41570d8ade51c56539e509d"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d16a81a06776313e817c951135cf7340a3e91e8c1ff2fac444cfd75fffa04afe"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6381026f158fdb7c72a168278597a5e3a5222e83ea18f543112b2662a9b699c5"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:3d79d162e7be8f996986c064d1c7c817f6df3a77fe3d6859f6f9e7be4b8c213a"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:131a3c7689c85f5ad20f9f6fb1b866f402c445b220c19fe4308c0b147ccd2ad9"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-win32.whl", hash = "sha256:ba8062ed2cf21c07a9e295d5b8a2a5ce678b913b45fdf68c32d95d6c1291e0b6"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-win_amd64.whl", hash = "sha256:e444a31f8db13eb18ada366ab3cf45fd4b31e4db1236a4448f68778c1d1a5a2f"}, + {file = "MarkupSafe-3.0.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:eaa0a10b7f72326f1372a713e73c3f739b524b3af41feb43e4921cb529f5929a"}, + {file = "MarkupSafe-3.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:48032821bbdf20f5799ff537c7ac3d1fba0ba032cfc06194faffa8cda8b560ff"}, + {file = "MarkupSafe-3.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1a9d3f5f0901fdec14d8d2f66ef7d035f2157240a433441719ac9a3fba440b13"}, + {file = "MarkupSafe-3.0.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:88b49a3b9ff31e19998750c38e030fc7bb937398b1f78cfa599aaef92d693144"}, + {file = "MarkupSafe-3.0.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cfad01eed2c2e0c01fd0ecd2ef42c492f7f93902e39a42fc9ee1692961443a29"}, + {file = "MarkupSafe-3.0.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:1225beacc926f536dc82e45f8a4d68502949dc67eea90eab715dea3a21c1b5f0"}, + {file = "MarkupSafe-3.0.2-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:3169b1eefae027567d1ce6ee7cae382c57fe26e82775f460f0b2778beaad66c0"}, + {file = "MarkupSafe-3.0.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:eb7972a85c54febfb25b5c4b4f3af4dcc731994c7da0d8a0b4a6eb0640e1d178"}, + {file = "MarkupSafe-3.0.2-cp39-cp39-win32.whl", hash = "sha256:8c4e8c3ce11e1f92f6536ff07154f9d49677ebaaafc32db9db4620bc11ed480f"}, + {file = "MarkupSafe-3.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:6e296a513ca3d94054c2c881cc913116e90fd030ad1c656b3869762b754f5f8a"}, + {file = "markupsafe-3.0.2.tar.gz", hash = "sha256:ee55d3edf80167e48ea11a923c7386f4669df67d7994554387f84e7d8b0a2bf0"}, +] + [[package]] name = "mccabe" version = "0.7.0" @@ -655,13 +778,13 @@ reports = ["lxml"] [[package]] name = "mypy-extensions" -version = "1.0.0" +version = "1.1.0" description = "Type system extensions for programs checked with the mypy type checker." optional = false -python-versions = ">=3.5" +python-versions = ">=3.8" files = [ - {file = "mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d"}, - {file = "mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782"}, + {file = "mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505"}, + {file = "mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558"}, ] [[package]] @@ -679,6 +802,70 @@ files = [ protobuf = ">=4.25.3" types-protobuf = ">=4.24" +[[package]] +name = "ni-measurement-plugin-sdk" +version = "2.3.0" +description = "Measurement Plug-In SDK for Python" +optional = false +python-versions = "<4.0,>=3.9" +files = [ + {file = "ni_measurement_plugin_sdk-2.3.0-py3-none-any.whl", hash = "sha256:079211ef7ec26786dca6f455b65dc52d0298d9ab90e6935671ea43bdbbc033ae"}, + {file = "ni_measurement_plugin_sdk-2.3.0.tar.gz", hash = "sha256:f4d8340053bef399ff943c6b90663f1c64f2cb2dcc742d0b630c6b60e05502e3"}, +] + +[package.dependencies] +ni-measurement-plugin-sdk-generator = "*" +ni-measurement-plugin-sdk-service = "*" + +[[package]] +name = "ni-measurement-plugin-sdk-generator" +version = "2.3.0" +description = "Measurement Plug-In Code Generator for Python" +optional = false +python-versions = "<4.0,>=3.9" +files = [ + {file = "ni_measurement_plugin_sdk_generator-2.3.0-py3-none-any.whl", hash = "sha256:dc6c5f3490d4f54662e3457023731ecfa0875c70c3cc0392ad1227a7738cb8f9"}, + {file = "ni_measurement_plugin_sdk_generator-2.3.0.tar.gz", hash = "sha256:1c138642b9c8c4c355affe9742e812bafdba3afe9b26e9fe365e9e1bccd804a1"}, +] + +[package.dependencies] +black = ">=24.8.0" +click = ">=8.1.3" +click-option-group = ">=0.5.6" +grpcio = ">=1.49.1,<2.0.0" +Mako = ">=1.2.1,<2.0.0" +ni-measurement-plugin-sdk-service = ">=2.3.0,<3.0.0" +protobuf = ">=4.21" + +[[package]] +name = "ni-measurement-plugin-sdk-service" +version = "2.3.0" +description = "Measurement Plug-In Support for Python" +optional = false +python-versions = "<4.0,>=3.9" +files = [ + {file = "ni_measurement_plugin_sdk_service-2.3.0-py3-none-any.whl", hash = "sha256:044ae7533b58d7620dd36658e32a4e71f0dd530372bcd993e6851a2f6df7e577"}, + {file = "ni_measurement_plugin_sdk_service-2.3.0.tar.gz", hash = "sha256:2adeb183541cd5d858e4272d7a196afa0896f8484d8ea7c46b11f0049c6631b0"}, +] + +[package.dependencies] +deprecation = ">=2.1" +grpcio = ">=1.49.1,<2.0.0" +protobuf = ">=4.21" +python-decouple = ">=3.8" +pywin32 = {version = ">=303", markers = "sys_platform == \"win32\""} +traceloggingdynamic = {version = ">=1.0", markers = "sys_platform == \"win32\""} + +[package.extras] +drivers = ["nidaqmx[grpc] (>=0.8.0)", "nidcpower[grpc] (>=1.4.4)", "nidigital[grpc] (>=1.4.4)", "nidmm[grpc] (>=1.4.4)", "nifgen[grpc] (>=1.4.4)", "niscope[grpc] (>=1.4.4)", "niswitch[grpc] (>=1.4.4)"] +nidaqmx = ["nidaqmx[grpc] (>=0.8.0)"] +nidcpower = ["nidcpower[grpc] (>=1.4.4)"] +nidigital = ["nidigital[grpc] (>=1.4.4)"] +nidmm = ["nidmm[grpc] (>=1.4.4)"] +nifgen = ["nifgen[grpc] (>=1.4.4)"] +niscope = ["niscope[grpc] (>=1.4.4)"] +niswitch = ["niswitch[grpc] (>=1.4.4)"] + [[package]] name = "ni-python-styleguide" version = "0.4.6" @@ -711,13 +898,13 @@ toml = ">=0.10.1" [[package]] name = "packaging" -version = "24.2" +version = "25.0" description = "Core utilities for Python packages" optional = false python-versions = ">=3.8" files = [ - {file = "packaging-24.2-py3-none-any.whl", hash = "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759"}, - {file = "packaging-24.2.tar.gz", hash = "sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f"}, + {file = "packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484"}, + {file = "packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f"}, ] [[package]] @@ -962,6 +1149,42 @@ pytest = ">=6.2.5" [package.extras] dev = ["pre-commit", "pytest-asyncio", "tox"] +[[package]] +name = "python-decouple" +version = "3.8" +description = "Strict separation of settings from code." +optional = false +python-versions = "*" +files = [ + {file = "python-decouple-3.8.tar.gz", hash = "sha256:ba6e2657d4f376ecc46f77a3a615e058d93ba5e465c01bbe57289bfb7cce680f"}, + {file = "python_decouple-3.8-py3-none-any.whl", hash = "sha256:d0d45340815b25f4de59c974b855bb38d03151d81b037d9e3f463b0c9f8cbd66"}, +] + +[[package]] +name = "pywin32" +version = "310" +description = "Python for Window Extensions" +optional = false +python-versions = "*" +files = [ + {file = "pywin32-310-cp310-cp310-win32.whl", hash = "sha256:6dd97011efc8bf51d6793a82292419eba2c71cf8e7250cfac03bba284454abc1"}, + {file = "pywin32-310-cp310-cp310-win_amd64.whl", hash = "sha256:c3e78706e4229b915a0821941a84e7ef420bf2b77e08c9dae3c76fd03fd2ae3d"}, + {file = "pywin32-310-cp310-cp310-win_arm64.whl", hash = "sha256:33babed0cf0c92a6f94cc6cc13546ab24ee13e3e800e61ed87609ab91e4c8213"}, + {file = "pywin32-310-cp311-cp311-win32.whl", hash = "sha256:1e765f9564e83011a63321bb9d27ec456a0ed90d3732c4b2e312b855365ed8bd"}, + {file = "pywin32-310-cp311-cp311-win_amd64.whl", hash = "sha256:126298077a9d7c95c53823934f000599f66ec9296b09167810eb24875f32689c"}, + {file = "pywin32-310-cp311-cp311-win_arm64.whl", hash = "sha256:19ec5fc9b1d51c4350be7bb00760ffce46e6c95eaf2f0b2f1150657b1a43c582"}, + {file = "pywin32-310-cp312-cp312-win32.whl", hash = "sha256:8a75a5cc3893e83a108c05d82198880704c44bbaee4d06e442e471d3c9ea4f3d"}, + {file = "pywin32-310-cp312-cp312-win_amd64.whl", hash = "sha256:bf5c397c9a9a19a6f62f3fb821fbf36cac08f03770056711f765ec1503972060"}, + {file = "pywin32-310-cp312-cp312-win_arm64.whl", hash = "sha256:2349cc906eae872d0663d4d6290d13b90621eaf78964bb1578632ff20e152966"}, + {file = "pywin32-310-cp313-cp313-win32.whl", hash = "sha256:5d241a659c496ada3253cd01cfaa779b048e90ce4b2b38cd44168ad555ce74ab"}, + {file = "pywin32-310-cp313-cp313-win_amd64.whl", hash = "sha256:667827eb3a90208ddbdcc9e860c81bde63a135710e21e4cb3348968e4bd5249e"}, + {file = "pywin32-310-cp313-cp313-win_arm64.whl", hash = "sha256:e308f831de771482b7cf692a1f308f8fca701b2d8f9dde6cc440c7da17e47b33"}, + {file = "pywin32-310-cp38-cp38-win32.whl", hash = "sha256:0867beb8addefa2e3979d4084352e4ac6e991ca45373390775f7084cc0209b9c"}, + {file = "pywin32-310-cp38-cp38-win_amd64.whl", hash = "sha256:30f0a9b3138fb5e07eb4973b7077e1883f558e40c578c6925acc7a94c34eaa36"}, + {file = "pywin32-310-cp39-cp39-win32.whl", hash = "sha256:851c8d927af0d879221e616ae1f66145253537bbdd321a77e8ef701b443a9a1a"}, + {file = "pywin32-310-cp39-cp39-win_amd64.whl", hash = "sha256:96867217335559ac619f00ad70e513c0fcf84b8a3af9fc2bba3b59b97da70475"}, +] + [[package]] name = "pyyaml" version = "6.0.2" @@ -1045,13 +1268,13 @@ jupyter = ["ipywidgets (>=7.5.1,<9)"] [[package]] name = "setuptools" -version = "78.1.0" +version = "79.0.0" description = "Easily download, build, install, upgrade, and uninstall Python packages" optional = false python-versions = ">=3.9" files = [ - {file = "setuptools-78.1.0-py3-none-any.whl", hash = "sha256:3e386e96793c8702ae83d17b853fb93d3e09ef82ec62722e61da5cd22376dcd8"}, - {file = "setuptools-78.1.0.tar.gz", hash = "sha256:18fd474d4a82a5f83dac888df697af65afa82dec7323d09c3e37d1f14288da54"}, + {file = "setuptools-79.0.0-py3-none-any.whl", hash = "sha256:b9ab3a104bedb292323f53797b00864e10e434a3ab3906813a7169e4745b912a"}, + {file = "setuptools-79.0.0.tar.gz", hash = "sha256:9828422e7541213b0aacb6e10bbf9dd8febeaa45a48570e09b6d100e063fc9f9"}, ] [package.extras] @@ -1140,6 +1363,16 @@ files = [ {file = "tomli-2.2.1.tar.gz", hash = "sha256:cd45e1dc79c835ce60f7404ec8119f2eb06d38b1deba146f07ced3bbc44505ff"}, ] +[[package]] +name = "traceloggingdynamic" +version = "1.0.0" +description = "Generates Event Tracing for Windows events using TraceLogging" +optional = false +python-versions = ">=3.6" +files = [ + {file = "traceloggingdynamic-1.0.0.tar.gz", hash = "sha256:09b6129438b99432733de18519017a7eed8285aeaa08c0cff6de45ac60e04b75"}, +] + [[package]] name = "types-protobuf" version = "5.29.1.20250403" @@ -1165,4 +1398,4 @@ files = [ [metadata] lock-version = "2.0" python-versions = "^3.9" -content-hash = "fd9bc2d84675b939af2bdcf8e59e811696ae14a87152f37faf4b39a4901f6d06" +content-hash = "d20f56a1dbd95350705ea8c3d55b4f6fe9f3f9708f3d825be398155b66c07edf" diff --git a/pyproject.toml b/pyproject.toml index 35e76f5..b517073 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,6 +10,7 @@ packages = [{ include = "nipanel", from = "src" }, { include = "ni", from = "src python = "^3.9" grpcio = {version=">=1.49.0,<2.0"} protobuf = {version=">=4.21"} +ni-measurement-plugin-sdk = {version=">=2.3"} [tool.poetry.group.lint.dependencies] bandit = { version = ">=1.7", extras = ["toml"] } @@ -55,4 +56,9 @@ skips = [ [tool.pytest.ini_options] addopts = "--doctest-modules --strict-markers" -testpaths = ["src/nipanel", "tests"] \ No newline at end of file +testpaths = ["src/nipanel", "tests"] + +[tool.black] +extend-exclude = ''' +/src/ni/pythonpanel/v1/ +''' \ No newline at end of file diff --git a/src/ni/pythonpanel/v1/python_panel_service_pb2.pyi b/src/ni/pythonpanel/v1/python_panel_service_pb2.pyi new file mode 100644 index 0000000..919ea75 --- /dev/null +++ b/src/ni/pythonpanel/v1/python_panel_service_pb2.pyi @@ -0,0 +1,144 @@ +""" +@generated by mypy-protobuf. Do not edit manually! +isort:skip_file +""" + +import builtins +import google.protobuf.any_pb2 +import google.protobuf.descriptor +import google.protobuf.message +import typing + +DESCRIPTOR: google.protobuf.descriptor.FileDescriptor + +@typing.final +class ConnectRequest(google.protobuf.message.Message): + DESCRIPTOR: google.protobuf.descriptor.Descriptor + + PANEL_ID_FIELD_NUMBER: builtins.int + PANEL_URI_FIELD_NUMBER: builtins.int + panel_id: builtins.str + """Unique ID of the panel""" + panel_uri: builtins.str + """Absolute path of the panel's file on disk, or network path to the file""" + def __init__( + self, + *, + panel_id: builtins.str = ..., + panel_uri: builtins.str = ..., + ) -> None: ... + def ClearField(self, field_name: typing.Literal["panel_id", b"panel_id", "panel_uri", b"panel_uri"]) -> None: ... + +global___ConnectRequest = ConnectRequest + +@typing.final +class ConnectResponse(google.protobuf.message.Message): + DESCRIPTOR: google.protobuf.descriptor.Descriptor + + def __init__( + self, + ) -> None: ... + +global___ConnectResponse = ConnectResponse + +@typing.final +class DisconnectRequest(google.protobuf.message.Message): + DESCRIPTOR: google.protobuf.descriptor.Descriptor + + PANEL_ID_FIELD_NUMBER: builtins.int + panel_id: builtins.str + """Unique ID of the panel""" + def __init__( + self, + *, + panel_id: builtins.str = ..., + ) -> None: ... + def ClearField(self, field_name: typing.Literal["panel_id", b"panel_id"]) -> None: ... + +global___DisconnectRequest = DisconnectRequest + +@typing.final +class DisconnectResponse(google.protobuf.message.Message): + DESCRIPTOR: google.protobuf.descriptor.Descriptor + + def __init__( + self, + ) -> None: ... + +global___DisconnectResponse = DisconnectResponse + +@typing.final +class GetValueRequest(google.protobuf.message.Message): + DESCRIPTOR: google.protobuf.descriptor.Descriptor + + PANEL_ID_FIELD_NUMBER: builtins.int + VALUE_ID_FIELD_NUMBER: builtins.int + panel_id: builtins.str + """Unique ID of the panel""" + value_id: builtins.str + """Unique ID of value""" + def __init__( + self, + *, + panel_id: builtins.str = ..., + value_id: builtins.str = ..., + ) -> None: ... + def ClearField(self, field_name: typing.Literal["panel_id", b"panel_id", "value_id", b"value_id"]) -> None: ... + +global___GetValueRequest = GetValueRequest + +@typing.final +class GetValueResponse(google.protobuf.message.Message): + DESCRIPTOR: google.protobuf.descriptor.Descriptor + + VALUE_FIELD_NUMBER: builtins.int + @property + def value(self) -> google.protobuf.any_pb2.Any: + """The value""" + + def __init__( + self, + *, + value: google.protobuf.any_pb2.Any | None = ..., + ) -> None: ... + def HasField(self, field_name: typing.Literal["value", b"value"]) -> builtins.bool: ... + def ClearField(self, field_name: typing.Literal["value", b"value"]) -> None: ... + +global___GetValueResponse = GetValueResponse + +@typing.final +class SetValueRequest(google.protobuf.message.Message): + DESCRIPTOR: google.protobuf.descriptor.Descriptor + + PANEL_ID_FIELD_NUMBER: builtins.int + VALUE_ID_FIELD_NUMBER: builtins.int + VALUE_FIELD_NUMBER: builtins.int + panel_id: builtins.str + """Unique ID of the panel""" + value_id: builtins.str + """Unique ID of the value""" + @property + def value(self) -> google.protobuf.any_pb2.Any: + """The value""" + + def __init__( + self, + *, + panel_id: builtins.str = ..., + value_id: builtins.str = ..., + value: google.protobuf.any_pb2.Any | None = ..., + ) -> None: ... + def HasField(self, field_name: typing.Literal["value", b"value"]) -> builtins.bool: ... + def ClearField(self, field_name: typing.Literal["panel_id", b"panel_id", "value", b"value", "value_id", b"value_id"]) -> None: ... + +global___SetValueRequest = SetValueRequest + +@typing.final +class SetValueResponse(google.protobuf.message.Message): + DESCRIPTOR: google.protobuf.descriptor.Descriptor + + def __init__( + self, + ) -> None: ... + +global___SetValueResponse = SetValueResponse diff --git a/src/nipanel/_panel.py b/src/nipanel/_panel.py index faa4acc..e79dbce 100644 --- a/src/nipanel/_panel.py +++ b/src/nipanel/_panel.py @@ -5,6 +5,9 @@ from types import TracebackType from typing import Optional, Type, TYPE_CHECKING +import grpc +from ni.pythonpanel.v1.python_panel_service_pb2 import ConnectRequest +from ni.pythonpanel.v1.python_panel_service_pb2 import DisconnectRequest from ni.pythonpanel.v1.python_panel_service_pb2_grpc import PythonPanelServiceStub if TYPE_CHECKING: @@ -55,13 +58,20 @@ def __exit__( def connect(self) -> None: """Connect to the panel and open it.""" - # TODO: AB#3095680 - Use gRPC pool management, create the _stub, and call _stub.Connect - self._resolve_service_location() + # TODO: use the channel pool + channel = grpc.insecure_channel(self._get_channel_url()) + self._stub = PythonPanelServiceStub(channel) + connect_request = ConnectRequest( + panel_id=self._panel_id, panel_uri=self._panel_uri + ) + self._stub.Connect(connect_request) def disconnect(self) -> None: """Disconnect from the panel (does not close the panel).""" - # TODO: AB#3095680 - Use gRPC pool management, call _stub.Disconnect - pass + disconnect_request = DisconnectRequest(self._panel_id) + self._stub.Disconnect(disconnect_request) + self._stub = None + # TODO: channel pool cleanup? def get_value(self, value_id: str) -> object: """Get the value for a control on the panel. @@ -86,6 +96,6 @@ def set_value(self, value_id: str, value: object) -> None: pass @abstractmethod - def _resolve_service_location(self) -> str: + def _get_channel_url(self) -> str: """Resolve the service location for the panel.""" raise NotImplementedError diff --git a/src/nipanel/_streamlit_panel.py b/src/nipanel/_streamlit_panel.py index 344f20e..b471023 100644 --- a/src/nipanel/_streamlit_panel.py +++ b/src/nipanel/_streamlit_panel.py @@ -1,4 +1,6 @@ from nipanel._panel import Panel +from ni_measurement_plugin_sdk_service.discovery import DiscoveryClient +from ni_measurement_plugin_sdk_service.grpc.channelpool import GrpcChannelPool class StreamlitPanel(Panel): @@ -18,6 +20,10 @@ def __init__(self, panel_id: str, streamlit_script_uri: str) -> None: """ super().__init__(panel_id, streamlit_script_uri) - def _resolve_service_location(self) -> str: - # TODO: AB#3095680 - resolve to the Streamlit PythonPanelService - return "" + def _get_channel_url(self) -> str: + with GrpcChannelPool() as grpc_channel_pool: + discovery_client = DiscoveryClient(grpc_channel_pool=grpc_channel_pool) + service_location = discovery_client.resolve_service( + "ni.pythonpanel.v1.PythonPanelService" + ) + return service_location.insecure_address From 394b74a445012f0e0e7cffcf95b53ad2d211c4b6 Mon Sep 17 00:00:00 2001 From: Mike Prosser Date: Wed, 23 Apr 2025 17:11:08 -0500 Subject: [PATCH 02/17] PanelNotFoundError when gRPC status code is NOT_FOUND --- src/nipanel/_panel.py | 31 ++++++++++++++++++++++----- src/nipanel/_panel_not_found_error.py | 8 +++++++ src/nipanel/_streamlit_panel.py | 3 ++- 3 files changed, 36 insertions(+), 6 deletions(-) create mode 100644 src/nipanel/_panel_not_found_error.py diff --git a/src/nipanel/_panel.py b/src/nipanel/_panel.py index e79dbce..3c1d07e 100644 --- a/src/nipanel/_panel.py +++ b/src/nipanel/_panel.py @@ -5,11 +5,13 @@ from types import TracebackType from typing import Optional, Type, TYPE_CHECKING -import grpc +from grpc import RpcError, StatusCode, insecure_channel from ni.pythonpanel.v1.python_panel_service_pb2 import ConnectRequest from ni.pythonpanel.v1.python_panel_service_pb2 import DisconnectRequest from ni.pythonpanel.v1.python_panel_service_pb2_grpc import PythonPanelServiceStub +from nipanel._panel_not_found_error import PanelNotFoundError + if TYPE_CHECKING: if sys.version_info >= (3, 11): from typing import Self @@ -59,17 +61,36 @@ def __exit__( def connect(self) -> None: """Connect to the panel and open it.""" # TODO: use the channel pool - channel = grpc.insecure_channel(self._get_channel_url()) + channel = insecure_channel(self._get_channel_url()) self._stub = PythonPanelServiceStub(channel) + connect_request = ConnectRequest( panel_id=self._panel_id, panel_uri=self._panel_uri ) - self._stub.Connect(connect_request) + + try: + self._stub.Connect(connect_request) + except RpcError as e: + if e.code() == StatusCode.NOT_FOUND: + raise PanelNotFoundError(self._panel_id, self._panel_uri) from e + else: + raise def disconnect(self) -> None: """Disconnect from the panel (does not close the panel).""" - disconnect_request = DisconnectRequest(self._panel_id) - self._stub.Disconnect(disconnect_request) + disconnect_request = DisconnectRequest(panel_id=self._panel_id) + + if self._stub is None: + raise RuntimeError("connect() must be called before disconnect()") + + try: + self._stub.Disconnect(disconnect_request) + except RpcError as e: + if e.code() == StatusCode.NOT_FOUND: + raise PanelNotFoundError(self._panel_id, self._panel_uri) from e + else: + raise + self._stub = None # TODO: channel pool cleanup? diff --git a/src/nipanel/_panel_not_found_error.py b/src/nipanel/_panel_not_found_error.py new file mode 100644 index 0000000..ad29294 --- /dev/null +++ b/src/nipanel/_panel_not_found_error.py @@ -0,0 +1,8 @@ +class PanelNotFoundError(Exception): + """Exception raised when a panel is not found.""" + + def __init__(self, panel_id: str, panel_uri: str): + """Initialize the exception with panel ID and URI.""" + super().__init__(f"Panel not found: {panel_id} - {panel_uri}") + self.panel_id = panel_id + self.panel_uri = panel_uri diff --git a/src/nipanel/_streamlit_panel.py b/src/nipanel/_streamlit_panel.py index b471023..b9ccac7 100644 --- a/src/nipanel/_streamlit_panel.py +++ b/src/nipanel/_streamlit_panel.py @@ -1,7 +1,8 @@ -from nipanel._panel import Panel from ni_measurement_plugin_sdk_service.discovery import DiscoveryClient from ni_measurement_plugin_sdk_service.grpc.channelpool import GrpcChannelPool +from nipanel._panel import Panel + class StreamlitPanel(Panel): """This class allows you to connect to a Streamlit panel and specify values for its controls.""" From 5d5404fde68071da2c93e8b513ca7ff5604013c8 Mon Sep 17 00:00:00 2001 From: Mike Prosser Date: Thu, 24 Apr 2025 10:24:52 -0500 Subject: [PATCH 03/17] fix linter issues --- CONTRIBUTING.md | 2 +- poetry.lock | 46 ++++-- pyproject.toml | 4 + .../v1/python_panel_service_pb2_grpc.pyi | 146 ++++++++++++++++++ src/nipanel/_panel.py | 12 +- tests/unit/test_panel.py | 1 - 6 files changed, 185 insertions(+), 26 deletions(-) create mode 100644 src/ni/pythonpanel/v1/python_panel_service_pb2_grpc.pyi diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9fae7ae..92fc228 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -22,7 +22,7 @@ See [GitHub's official documentation](https://help.github.com/articles/using-pul # Getting Started This is the command to generate the files in /src/ni/pythonpanel/v1/: -`poetry run python -m grpc_tools.protoc --proto_path=protos --python_out=src/ --grpc_python_out=src/ --plugin=protoc-gen-mypy=.venv\Scripts\protoc-gen-mypy.exe --mypy_out=src/ ni/pythonpanel/v1/python_panel_service.proto` +`poetry run python -m grpc_tools.protoc --proto_path=protos --python_out=src/ --grpc_python_out=src/ --plugin=protoc-gen-mypy=.venv\Scripts\protoc-gen-mypy.exe --mypy_out=src/ --mypy_grpc_out=src/ ni/pythonpanel/v1/python_panel_service.proto` # Testing diff --git a/poetry.lock b/poetry.lock index 05d9e23..c3404f9 100644 --- a/poetry.lock +++ b/poetry.lock @@ -303,6 +303,20 @@ files = [ pycodestyle = "*" setuptools = "*" +[[package]] +name = "grpc-stubs" +version = "1.53.0.5" +description = "Mypy stubs for gRPC" +optional = false +python-versions = ">=3.6" +files = [ + {file = "grpc-stubs-1.53.0.5.tar.gz", hash = "sha256:3e1b642775cbc3e0c6332cfcedfccb022176db87e518757bef3a1241397be406"}, + {file = "grpc_stubs-1.53.0.5-py3-none-any.whl", hash = "sha256:04183fb65a1b166a1febb9627e3d9647d3926ccc2dfe049fe7b6af243428dbe1"}, +] + +[package.dependencies] +grpcio = "*" + [[package]] name = "grpcio" version = "1.71.0" @@ -979,22 +993,22 @@ testing = ["pytest", "pytest-benchmark"] [[package]] name = "protobuf" -version = "4.25.6" +version = "4.25.7" description = "" optional = false python-versions = ">=3.8" files = [ - {file = "protobuf-4.25.6-cp310-abi3-win32.whl", hash = "sha256:61df6b5786e2b49fc0055f636c1e8f0aff263808bb724b95b164685ac1bcc13a"}, - {file = "protobuf-4.25.6-cp310-abi3-win_amd64.whl", hash = "sha256:b8f837bfb77513fe0e2f263250f423217a173b6d85135be4d81e96a4653bcd3c"}, - {file = "protobuf-4.25.6-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:6d4381f2417606d7e01750e2729fe6fbcda3f9883aa0c32b51d23012bded6c91"}, - {file = "protobuf-4.25.6-cp37-abi3-manylinux2014_aarch64.whl", hash = "sha256:5dd800da412ba7f6f26d2c08868a5023ce624e1fdb28bccca2dc957191e81fb5"}, - {file = "protobuf-4.25.6-cp37-abi3-manylinux2014_x86_64.whl", hash = "sha256:4434ff8bb5576f9e0c78f47c41cdf3a152c0b44de475784cd3fd170aef16205a"}, - {file = "protobuf-4.25.6-cp38-cp38-win32.whl", hash = "sha256:8bad0f9e8f83c1fbfcc34e573352b17dfce7d0519512df8519994168dc015d7d"}, - {file = "protobuf-4.25.6-cp38-cp38-win_amd64.whl", hash = "sha256:b6905b68cde3b8243a198268bb46fbec42b3455c88b6b02fb2529d2c306d18fc"}, - {file = "protobuf-4.25.6-cp39-cp39-win32.whl", hash = "sha256:3f3b0b39db04b509859361ac9bca65a265fe9342e6b9406eda58029f5b1d10b2"}, - {file = "protobuf-4.25.6-cp39-cp39-win_amd64.whl", hash = "sha256:6ef2045f89d4ad8d95fd43cd84621487832a61d15b49500e4c1350e8a0ef96be"}, - {file = "protobuf-4.25.6-py3-none-any.whl", hash = "sha256:07972021c8e30b870cfc0863409d033af940213e0e7f64e27fe017b929d2c9f7"}, - {file = "protobuf-4.25.6.tar.gz", hash = "sha256:f8cfbae7c5afd0d0eaccbe73267339bff605a2315860bb1ba08eb66670a9a91f"}, + {file = "protobuf-4.25.7-cp310-abi3-win32.whl", hash = "sha256:dc582cf1a73a6b40aa8e7704389b8d8352da616bc8ed5c6cc614bdd0b5ce3f7a"}, + {file = "protobuf-4.25.7-cp310-abi3-win_amd64.whl", hash = "sha256:cd873dbddb28460d1706ff4da2e7fac175f62f2a0bebc7b33141f7523c5a2399"}, + {file = "protobuf-4.25.7-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:4c899f09b0502eb39174c717ccf005b844ea93e31137c167ddcacf3e09e49610"}, + {file = "protobuf-4.25.7-cp37-abi3-manylinux2014_aarch64.whl", hash = "sha256:6d2f5dede3d112e573f0e5f9778c0c19d9f9e209727abecae1d39db789f522c6"}, + {file = "protobuf-4.25.7-cp37-abi3-manylinux2014_x86_64.whl", hash = "sha256:d41fb7ae72a25fcb79b2d71e4247f0547a02e8185ed51587c22827a87e5736ed"}, + {file = "protobuf-4.25.7-cp38-cp38-win32.whl", hash = "sha256:237db80000865851eac3c6e9d5597c0dfb0b2700d642ec48ed80b6ffe7b8729c"}, + {file = "protobuf-4.25.7-cp38-cp38-win_amd64.whl", hash = "sha256:ea41b75edb0f1110050a60e653820d9acc70b6fb471013971535f412addbb0d0"}, + {file = "protobuf-4.25.7-cp39-cp39-win32.whl", hash = "sha256:2f738d4f341186e697c4cdd0e03143ee5cf6cf523790748e61273a51997494c3"}, + {file = "protobuf-4.25.7-cp39-cp39-win_amd64.whl", hash = "sha256:3629b34b65f6204b17adf4ffe21adc8e85f6c6c0bc2baf3fb001b0d343edaebb"}, + {file = "protobuf-4.25.7-py3-none-any.whl", hash = "sha256:e9d969f5154eaeab41404def5dcf04e62162178f4b9de98b2d3c1c70f5f84810"}, + {file = "protobuf-4.25.7.tar.gz", hash = "sha256:28f65ae8c14523cc2c76c1e91680958700d3eac69f45c96512c12c63d9a38807"}, ] [[package]] @@ -1268,13 +1282,13 @@ jupyter = ["ipywidgets (>=7.5.1,<9)"] [[package]] name = "setuptools" -version = "79.0.0" +version = "79.0.1" description = "Easily download, build, install, upgrade, and uninstall Python packages" optional = false python-versions = ">=3.9" files = [ - {file = "setuptools-79.0.0-py3-none-any.whl", hash = "sha256:b9ab3a104bedb292323f53797b00864e10e434a3ab3906813a7169e4745b912a"}, - {file = "setuptools-79.0.0.tar.gz", hash = "sha256:9828422e7541213b0aacb6e10bbf9dd8febeaa45a48570e09b6d100e063fc9f9"}, + {file = "setuptools-79.0.1-py3-none-any.whl", hash = "sha256:e147c0549f27767ba362f9da434eab9c5dc0045d5304feb602a0af001089fc51"}, + {file = "setuptools-79.0.1.tar.gz", hash = "sha256:128ce7b8f33c3079fd1b067ecbb4051a66e8526e7b65f6cec075dfc650ddfa88"}, ] [package.extras] @@ -1398,4 +1412,4 @@ files = [ [metadata] lock-version = "2.0" python-versions = "^3.9" -content-hash = "d20f56a1dbd95350705ea8c3d55b4f6fe9f3f9708f3d825be398155b66c07edf" +content-hash = "681a64f5d10bd10d379d6dcfd3984a20e57bae9ecd298965676528cf5258d81c" diff --git a/pyproject.toml b/pyproject.toml index b517073..dfb6840 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,6 +12,10 @@ grpcio = {version=">=1.49.0,<2.0"} protobuf = {version=">=4.21"} ni-measurement-plugin-sdk = {version=">=2.3"} +[tool.poetry.group.dev.dependencies] +grpc-stubs = "^1.53" +types-protobuf = ">=4.21" + [tool.poetry.group.lint.dependencies] bandit = { version = ">=1.7", extras = ["toml"] } ni-python-styleguide = ">=0.4.1" diff --git a/src/ni/pythonpanel/v1/python_panel_service_pb2_grpc.pyi b/src/ni/pythonpanel/v1/python_panel_service_pb2_grpc.pyi new file mode 100644 index 0000000..97ac81a --- /dev/null +++ b/src/ni/pythonpanel/v1/python_panel_service_pb2_grpc.pyi @@ -0,0 +1,146 @@ +""" +@generated by mypy-protobuf. Do not edit manually! +isort:skip_file +""" + +import abc +import collections.abc +import grpc +import grpc.aio +import ni.pythonpanel.v1.python_panel_service_pb2 +import typing + +_T = typing.TypeVar("_T") + +class _MaybeAsyncIterator(collections.abc.AsyncIterator[_T], collections.abc.Iterator[_T], metaclass=abc.ABCMeta): ... + +class _ServicerContext(grpc.ServicerContext, grpc.aio.ServicerContext): # type: ignore[misc, type-arg] + ... + +class PythonPanelServiceStub: + """Service interface for connecting to python panels""" + + def __init__(self, channel: typing.Union[grpc.Channel, grpc.aio.Channel]) -> None: ... + Connect: grpc.UnaryUnaryMultiCallable[ + ni.pythonpanel.v1.python_panel_service_pb2.ConnectRequest, + ni.pythonpanel.v1.python_panel_service_pb2.ConnectResponse, + ] + """Connect to a panel and open it + Status Codes for errors: + - NOT_FOUND: the file for the panel was not found + """ + + Disconnect: grpc.UnaryUnaryMultiCallable[ + ni.pythonpanel.v1.python_panel_service_pb2.DisconnectRequest, + ni.pythonpanel.v1.python_panel_service_pb2.DisconnectResponse, + ] + """Disconnect from a panel (does not close the panel) + Status Codes for errors: + - NOT_FOUND: the panel with the specified id was not found + """ + + GetValue: grpc.UnaryUnaryMultiCallable[ + ni.pythonpanel.v1.python_panel_service_pb2.GetValueRequest, + ni.pythonpanel.v1.python_panel_service_pb2.GetValueResponse, + ] + """Get a value for a control on the panel + Status Codes for errors: + - NOT_FOUND: the panel with the specified id was not found + """ + + SetValue: grpc.UnaryUnaryMultiCallable[ + ni.pythonpanel.v1.python_panel_service_pb2.SetValueRequest, + ni.pythonpanel.v1.python_panel_service_pb2.SetValueResponse, + ] + """Set a value for a control on the panel + Status Codes for errors: + - NOT_FOUND: the panel with the specified id was not found + """ + +class PythonPanelServiceAsyncStub: + """Service interface for connecting to python panels""" + + Connect: grpc.aio.UnaryUnaryMultiCallable[ + ni.pythonpanel.v1.python_panel_service_pb2.ConnectRequest, + ni.pythonpanel.v1.python_panel_service_pb2.ConnectResponse, + ] + """Connect to a panel and open it + Status Codes for errors: + - NOT_FOUND: the file for the panel was not found + """ + + Disconnect: grpc.aio.UnaryUnaryMultiCallable[ + ni.pythonpanel.v1.python_panel_service_pb2.DisconnectRequest, + ni.pythonpanel.v1.python_panel_service_pb2.DisconnectResponse, + ] + """Disconnect from a panel (does not close the panel) + Status Codes for errors: + - NOT_FOUND: the panel with the specified id was not found + """ + + GetValue: grpc.aio.UnaryUnaryMultiCallable[ + ni.pythonpanel.v1.python_panel_service_pb2.GetValueRequest, + ni.pythonpanel.v1.python_panel_service_pb2.GetValueResponse, + ] + """Get a value for a control on the panel + Status Codes for errors: + - NOT_FOUND: the panel with the specified id was not found + """ + + SetValue: grpc.aio.UnaryUnaryMultiCallable[ + ni.pythonpanel.v1.python_panel_service_pb2.SetValueRequest, + ni.pythonpanel.v1.python_panel_service_pb2.SetValueResponse, + ] + """Set a value for a control on the panel + Status Codes for errors: + - NOT_FOUND: the panel with the specified id was not found + """ + +class PythonPanelServiceServicer(metaclass=abc.ABCMeta): + """Service interface for connecting to python panels""" + + @abc.abstractmethod + def Connect( + self, + request: ni.pythonpanel.v1.python_panel_service_pb2.ConnectRequest, + context: _ServicerContext, + ) -> typing.Union[ni.pythonpanel.v1.python_panel_service_pb2.ConnectResponse, collections.abc.Awaitable[ni.pythonpanel.v1.python_panel_service_pb2.ConnectResponse]]: + """Connect to a panel and open it + Status Codes for errors: + - NOT_FOUND: the file for the panel was not found + """ + + @abc.abstractmethod + def Disconnect( + self, + request: ni.pythonpanel.v1.python_panel_service_pb2.DisconnectRequest, + context: _ServicerContext, + ) -> typing.Union[ni.pythonpanel.v1.python_panel_service_pb2.DisconnectResponse, collections.abc.Awaitable[ni.pythonpanel.v1.python_panel_service_pb2.DisconnectResponse]]: + """Disconnect from a panel (does not close the panel) + Status Codes for errors: + - NOT_FOUND: the panel with the specified id was not found + """ + + @abc.abstractmethod + def GetValue( + self, + request: ni.pythonpanel.v1.python_panel_service_pb2.GetValueRequest, + context: _ServicerContext, + ) -> typing.Union[ni.pythonpanel.v1.python_panel_service_pb2.GetValueResponse, collections.abc.Awaitable[ni.pythonpanel.v1.python_panel_service_pb2.GetValueResponse]]: + """Get a value for a control on the panel + Status Codes for errors: + - NOT_FOUND: the panel with the specified id was not found + """ + + @abc.abstractmethod + def SetValue( + self, + request: ni.pythonpanel.v1.python_panel_service_pb2.SetValueRequest, + context: _ServicerContext, + ) -> typing.Union[ni.pythonpanel.v1.python_panel_service_pb2.SetValueResponse, collections.abc.Awaitable[ni.pythonpanel.v1.python_panel_service_pb2.SetValueResponse]]: + """Set a value for a control on the panel + Status Codes for errors: + - NOT_FOUND: the panel with the specified id was not found + """ + +def add_PythonPanelServiceServicer_to_server(servicer: PythonPanelServiceServicer, server: typing.Union[grpc.Server, grpc.aio.Server]) -> None: ... diff --git a/src/nipanel/_panel.py b/src/nipanel/_panel.py index 3c1d07e..c30645c 100644 --- a/src/nipanel/_panel.py +++ b/src/nipanel/_panel.py @@ -3,11 +3,10 @@ import sys from abc import ABC, abstractmethod from types import TracebackType -from typing import Optional, Type, TYPE_CHECKING +from typing import TYPE_CHECKING, Optional, Type from grpc import RpcError, StatusCode, insecure_channel -from ni.pythonpanel.v1.python_panel_service_pb2 import ConnectRequest -from ni.pythonpanel.v1.python_panel_service_pb2 import DisconnectRequest +from ni.pythonpanel.v1.python_panel_service_pb2 import ConnectRequest, DisconnectRequest from ni.pythonpanel.v1.python_panel_service_pb2_grpc import PythonPanelServiceStub from nipanel._panel_not_found_error import PanelNotFoundError @@ -63,10 +62,7 @@ def connect(self) -> None: # TODO: use the channel pool channel = insecure_channel(self._get_channel_url()) self._stub = PythonPanelServiceStub(channel) - - connect_request = ConnectRequest( - panel_id=self._panel_id, panel_uri=self._panel_uri - ) + connect_request = ConnectRequest(panel_id=self._panel_id, panel_uri=self._panel_uri) try: self._stub.Connect(connect_request) @@ -82,7 +78,7 @@ def disconnect(self) -> None: if self._stub is None: raise RuntimeError("connect() must be called before disconnect()") - + try: self._stub.Disconnect(disconnect_request) except RpcError as e: diff --git a/tests/unit/test_panel.py b/tests/unit/test_panel.py index ec14b35..5cff3a8 100644 --- a/tests/unit/test_panel.py +++ b/tests/unit/test_panel.py @@ -20,7 +20,6 @@ def test___connected_panel___set_value___gets_same_value() -> None: def test___with_panel___set_value___gets_same_value() -> None: with nipanel.StreamlitPanel("my_panel", "path/to/script") as panel: - panel.set_value("test_id", "test_value") # TODO: AB#3095681 - change asserted value to test_value From d0835f1c0f9e1ffec12f71ba6e9c145ec5e229fa Mon Sep 17 00:00:00 2001 From: Mike Prosser Date: Thu, 24 Apr 2025 11:11:15 -0500 Subject: [PATCH 04/17] create a basic fake server and passing basic tests for it --- tests/unit/test_fake_server.py | 62 +++++++++++++++++++++++ tests/utils/_fake_python_panel_service.py | 43 ++++++++++++++++ 2 files changed, 105 insertions(+) create mode 100644 tests/unit/test_fake_server.py create mode 100644 tests/utils/_fake_python_panel_service.py diff --git a/tests/unit/test_fake_server.py b/tests/unit/test_fake_server.py new file mode 100644 index 0000000..282276c --- /dev/null +++ b/tests/unit/test_fake_server.py @@ -0,0 +1,62 @@ +from concurrent import futures +import grpc +import pytest +from google.protobuf.any_pb2 import Any +from ni.pythonpanel.v1.python_panel_service_pb2_grpc import PythonPanelServiceStub +from tests.utils._fake_python_panel_service import FakePythonPanelService +from ni.pythonpanel.v1.python_panel_service_pb2 import ( + ConnectRequest, + DisconnectRequest, + GetValueRequest, + SetValueRequest, +) + + +@pytest.fixture +def grpc_server(): + # Create an in-process gRPC server + server = grpc.server(futures.ThreadPoolExecutor(max_workers=10)) + servicer = FakePythonPanelService() + from ni.pythonpanel.v1.python_panel_service_pb2_grpc import ( + add_PythonPanelServiceServicer_to_server, + ) + add_PythonPanelServiceServicer_to_server(servicer, server) + port = server.add_insecure_port("[::]:0") # Bind to an available port + server.start() + yield server, port + server.stop(None) + + +@pytest.fixture +def grpc_client(grpc_server): + _, port = grpc_server + channel = grpc.insecure_channel(f"localhost:{port}") + yield PythonPanelServiceStub(channel) + channel.close() + + +def test_connect(grpc_client): + request = ConnectRequest(panel_id="test_panel", panel_uri="path/to/panel") + response = grpc_client.Connect(request) + assert response is not None # Ensure response is returned + + +def test_disconnect(grpc_client): + request = DisconnectRequest(panel_id="test_panel") + response = grpc_client.Disconnect(request) + assert response is not None # Ensure response is returned + + +def test_get_value(grpc_client): + request = GetValueRequest(panel_id="test_panel", value_id="test_value") + response = grpc_client.GetValue(request) + assert response is not None # Ensure response is returned + assert isinstance(response.value, Any) # Ensure the value is of type `Any` + + +def test_set_value(grpc_client): + value = Any() + value.value = b"test_data" + request = SetValueRequest(panel_id="test_panel", value_id="test_value", value=value) + response = grpc_client.SetValue(request) + assert response is not None # Ensure response is returned \ No newline at end of file diff --git a/tests/utils/_fake_python_panel_service.py b/tests/utils/_fake_python_panel_service.py new file mode 100644 index 0000000..e1e648f --- /dev/null +++ b/tests/utils/_fake_python_panel_service.py @@ -0,0 +1,43 @@ +from concurrent import futures +import grpc +import time +import google.protobuf.any_pb2 as any_pb2 +from ni.pythonpanel.v1.python_panel_service_pb2 import ConnectRequest, ConnectResponse, DisconnectRequest, DisconnectResponse, GetValueRequest, GetValueResponse, SetValueRequest, SetValueResponse +from ni.pythonpanel.v1.python_panel_service_pb2_grpc import PythonPanelServiceServicer, add_PythonPanelServiceServicer_to_server + +class FakePythonPanelService(PythonPanelServiceServicer): + def Connect(self, request, context): + # Basic implementation for testing + print(f"Connecting to panel: {request.panel_id} at {request.panel_uri}") + return ConnectResponse() + + def Disconnect(self, request, context): + # Basic implementation for testing + print(f"Disconnecting from panel: {request.panel_id}") + return DisconnectResponse() + + def GetValue(self, request, context): + # Basic implementation for testing + print(f"Getting value for panel: {request.panel_id}, value_id: {request.value_id}") + value = any_pb2.Any() # Placeholder for actual value + return GetValueResponse(value=value) + + def SetValue(self, request, context): + # Basic implementation for testing + print(f"Setting value for panel: {request.panel_id}, value_id: {request.value_id}, value: {request.value}") + return SetValueResponse() + +def serve(): + server = grpc.server(futures.ThreadPoolExecutor(max_workers=10)) + add_PythonPanelServiceServicer_to_server(FakePythonPanelService(), server) + server.add_insecure_port('[::]:50051') # TODO: do we need to find a free port? + server.start() + print("Server is running on port 50051...") + try: + while True: + time.sleep(86400) # Keep the server running + except KeyboardInterrupt: + server.stop(0) + +if __name__ == '__main__': + serve() \ No newline at end of file From fc049cc8c3aa3f3adcab7eece0fa621582fc96f1 Mon Sep 17 00:00:00 2001 From: Mike Prosser Date: Thu, 24 Apr 2025 13:31:07 -0500 Subject: [PATCH 05/17] use FakePanel to fix failing tests --- tests/unit/test_fake_server.py | 24 +++++---- tests/unit/test_panel.py | 42 ++++++++++++--- tests/utils/_fake_panel.py | 22 ++++++++ tests/utils/_fake_python_panel_service.py | 63 +++++++++++++++-------- 4 files changed, 114 insertions(+), 37 deletions(-) create mode 100644 tests/utils/_fake_panel.py diff --git a/tests/unit/test_fake_server.py b/tests/unit/test_fake_server.py index 282276c..a126c89 100644 --- a/tests/unit/test_fake_server.py +++ b/tests/unit/test_fake_server.py @@ -1,25 +1,29 @@ from concurrent import futures +from typing import Generator + import grpc import pytest from google.protobuf.any_pb2 import Any -from ni.pythonpanel.v1.python_panel_service_pb2_grpc import PythonPanelServiceStub -from tests.utils._fake_python_panel_service import FakePythonPanelService from ni.pythonpanel.v1.python_panel_service_pb2 import ( ConnectRequest, DisconnectRequest, GetValueRequest, SetValueRequest, ) +from ni.pythonpanel.v1.python_panel_service_pb2_grpc import PythonPanelServiceStub + +from tests.utils._fake_python_panel_service import FakePythonPanelService @pytest.fixture -def grpc_server(): +def grpc_server() -> Generator[tuple[grpc.Server, int], Any, None]: # Create an in-process gRPC server server = grpc.server(futures.ThreadPoolExecutor(max_workers=10)) servicer = FakePythonPanelService() from ni.pythonpanel.v1.python_panel_service_pb2_grpc import ( add_PythonPanelServiceServicer_to_server, ) + add_PythonPanelServiceServicer_to_server(servicer, server) port = server.add_insecure_port("[::]:0") # Bind to an available port server.start() @@ -28,35 +32,37 @@ def grpc_server(): @pytest.fixture -def grpc_client(grpc_server): +def grpc_client( + grpc_server: tuple[grpc.Server, int], +) -> Generator[PythonPanelServiceStub, Any, None]: _, port = grpc_server channel = grpc.insecure_channel(f"localhost:{port}") yield PythonPanelServiceStub(channel) channel.close() -def test_connect(grpc_client): +def test___connect___gets_response(grpc_client: PythonPanelServiceStub) -> None: request = ConnectRequest(panel_id="test_panel", panel_uri="path/to/panel") response = grpc_client.Connect(request) assert response is not None # Ensure response is returned -def test_disconnect(grpc_client): +def test___disconnect___gets_response(grpc_client: PythonPanelServiceStub) -> None: request = DisconnectRequest(panel_id="test_panel") response = grpc_client.Disconnect(request) assert response is not None # Ensure response is returned -def test_get_value(grpc_client): +def test___get_value___gets_response(grpc_client: PythonPanelServiceStub) -> None: request = GetValueRequest(panel_id="test_panel", value_id="test_value") response = grpc_client.GetValue(request) assert response is not None # Ensure response is returned assert isinstance(response.value, Any) # Ensure the value is of type `Any` -def test_set_value(grpc_client): +def test___set_value___gets_response(grpc_client: PythonPanelServiceStub) -> None: value = Any() value.value = b"test_data" request = SetValueRequest(panel_id="test_panel", value_id="test_value", value=value) response = grpc_client.SetValue(request) - assert response is not None # Ensure response is returned \ No newline at end of file + assert response is not None # Ensure response is returned diff --git a/tests/unit/test_panel.py b/tests/unit/test_panel.py index 5cff3a8..be6644c 100644 --- a/tests/unit/test_panel.py +++ b/tests/unit/test_panel.py @@ -1,14 +1,41 @@ -import nipanel +from concurrent import futures +from typing import Generator +import grpc +import pytest +from google.protobuf.any_pb2 import Any -def test___streamlit_panel___has_panel_id_and_panel_uri() -> None: - panel = nipanel.StreamlitPanel("my_panel", "path/to/script") +from tests.utils._fake_panel import FakePanel +from tests.utils._fake_python_panel_service import FakePythonPanelService + + +@pytest.fixture +def grpc_server() -> Generator[tuple[grpc.Server, int], Any, None]: + # Create an in-process gRPC server + server = grpc.server(futures.ThreadPoolExecutor(max_workers=10)) + servicer = FakePythonPanelService() + from ni.pythonpanel.v1.python_panel_service_pb2_grpc import ( + add_PythonPanelServiceServicer_to_server, + ) + + add_PythonPanelServiceServicer_to_server(servicer, server) + port = server.add_insecure_port("[::]:0") # Bind to an available port + server.start() + yield server, port + server.stop(None) + + +def test___panel___has_panel_id_and_panel_uri() -> None: + panel = FakePanel(0, "my_panel", "path/to/script") assert panel.panel_id == "my_panel" assert panel.panel_uri == "path/to/script" -def test___connected_panel___set_value___gets_same_value() -> None: - panel = nipanel.StreamlitPanel("my_panel", "path/to/script") +def test___connected_panel___set_value___gets_same_value( + grpc_server: tuple[grpc.Server, int], +) -> None: + _, port = grpc_server + panel = FakePanel(port, "my_panel", "path/to/script") panel.connect() panel.set_value("test_id", "test_value") @@ -18,8 +45,9 @@ def test___connected_panel___set_value___gets_same_value() -> None: panel.disconnect() -def test___with_panel___set_value___gets_same_value() -> None: - with nipanel.StreamlitPanel("my_panel", "path/to/script") as panel: +def test___with_panel___set_value___gets_same_value(grpc_server: tuple[grpc.Server, int]) -> None: + _, port = grpc_server + with FakePanel(port, "my_panel", "path/to/script") as panel: panel.set_value("test_id", "test_value") # TODO: AB#3095681 - change asserted value to test_value diff --git a/tests/utils/_fake_panel.py b/tests/utils/_fake_panel.py new file mode 100644 index 0000000..7d7150c --- /dev/null +++ b/tests/utils/_fake_panel.py @@ -0,0 +1,22 @@ +from nipanel._panel import Panel + + +class FakePanel(Panel): + """This class allows you to connect to the FakePythonPanelService, for testing.""" + + def __init__(self, port: int, panel_id: str, panel_uri: str) -> None: + """Create a fake panel, for testing. + + Args: + port: The port number for the gRPC server. + panel_id: A unique identifier for the panel. + panel_uri: The file path of the panel script. + + Returns: + A new FakePanel instance. + """ + super().__init__(panel_id, panel_uri) + self.port = port + + def _get_channel_url(self) -> str: + return f"localhost:{self.port}" diff --git a/tests/utils/_fake_python_panel_service.py b/tests/utils/_fake_python_panel_service.py index e1e648f..d70225a 100644 --- a/tests/utils/_fake_python_panel_service.py +++ b/tests/utils/_fake_python_panel_service.py @@ -1,36 +1,56 @@ -from concurrent import futures -import grpc import time +from concurrent import futures +from typing import Any + import google.protobuf.any_pb2 as any_pb2 -from ni.pythonpanel.v1.python_panel_service_pb2 import ConnectRequest, ConnectResponse, DisconnectRequest, DisconnectResponse, GetValueRequest, GetValueResponse, SetValueRequest, SetValueResponse -from ni.pythonpanel.v1.python_panel_service_pb2_grpc import PythonPanelServiceServicer, add_PythonPanelServiceServicer_to_server +import grpc +from ni.pythonpanel.v1.python_panel_service_pb2 import ( + ConnectRequest, + ConnectResponse, + DisconnectRequest, + DisconnectResponse, + GetValueRequest, + GetValueResponse, + SetValueRequest, + SetValueResponse, +) +from ni.pythonpanel.v1.python_panel_service_pb2_grpc import ( + PythonPanelServiceServicer, + add_PythonPanelServiceServicer_to_server, +) + class FakePythonPanelService(PythonPanelServiceServicer): - def Connect(self, request, context): - # Basic implementation for testing - print(f"Connecting to panel: {request.panel_id} at {request.panel_uri}") + """Fake implementation of the PythonPanelService for testing.""" + + _values = {"test_value": any_pb2.Any()} + + def Connect(self, request: ConnectRequest, context: Any) -> ConnectResponse: # noqa: N802 + """Just a trivial implementation for testing.""" return ConnectResponse() - def Disconnect(self, request, context): - # Basic implementation for testing - print(f"Disconnecting from panel: {request.panel_id}") + def Disconnect( # noqa: N802 + self, request: DisconnectRequest, context: Any + ) -> DisconnectResponse: + """Just a trivial implementation for testing.""" return DisconnectResponse() - def GetValue(self, request, context): - # Basic implementation for testing - print(f"Getting value for panel: {request.panel_id}, value_id: {request.value_id}") - value = any_pb2.Any() # Placeholder for actual value + def GetValue(self, request: GetValueRequest, context: Any) -> GetValueResponse: # noqa: N802 + """Just a trivial implementation for testing.""" + value = self._values[request.value_id] return GetValueResponse(value=value) - def SetValue(self, request, context): - # Basic implementation for testing - print(f"Setting value for panel: {request.panel_id}, value_id: {request.value_id}, value: {request.value}") + def SetValue(self, request: SetValueRequest, context: Any) -> SetValueResponse: # noqa: N802 + """Just a trivial implementation for testing.""" + self._values[request.value_id] = request.value return SetValueResponse() -def serve(): + +def serve() -> None: + """Run the gRPC server.""" server = grpc.server(futures.ThreadPoolExecutor(max_workers=10)) add_PythonPanelServiceServicer_to_server(FakePythonPanelService(), server) - server.add_insecure_port('[::]:50051') # TODO: do we need to find a free port? + server.add_insecure_port("[::]:50051") # TODO: do we need to find a free port? server.start() print("Server is running on port 50051...") try: @@ -39,5 +59,6 @@ def serve(): except KeyboardInterrupt: server.stop(0) -if __name__ == '__main__': - serve() \ No newline at end of file + +if __name__ == "__main__": + serve() From a6c76d68054e703fe3ff515d25c11555cdab833e Mon Sep 17 00:00:00 2001 From: Mike Prosser Date: Thu, 24 Apr 2025 14:05:11 -0500 Subject: [PATCH 06/17] move fixture to conftest.py --- tests/conftest.py | 38 +++++++++++ tests/unit/test_fake_python_panel_servicer.py | 41 +++++++++++ tests/unit/test_fake_server.py | 68 ------------------- tests/unit/test_panel.py | 32 ++------- ...vice.py => _fake_python_panel_servicer.py} | 6 +- 5 files changed, 88 insertions(+), 97 deletions(-) create mode 100644 tests/conftest.py create mode 100644 tests/unit/test_fake_python_panel_servicer.py delete mode 100644 tests/unit/test_fake_server.py rename tests/utils/{_fake_python_panel_service.py => _fake_python_panel_servicer.py} (93%) diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..40d3a6d --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,38 @@ +"""Fixtures for testing gRPC services.""" + +from concurrent import futures +from typing import Any, Generator + +import grpc +import pytest +from ni.pythonpanel.v1.python_panel_service_pb2_grpc import ( + PythonPanelServiceStub, + add_PythonPanelServiceServicer_to_server, +) + +from tests.utils._fake_python_panel_servicer import FakePythonPanelServicer + + +@pytest.fixture +def fake_python_panel_service() -> Generator[tuple[grpc.Server, int], Any, None]: + """Fixture to create a FakePythonPanelServicer for testing.""" + # Create an in-process gRPC server + server = grpc.server(futures.ThreadPoolExecutor(max_workers=10)) + servicer = FakePythonPanelServicer() + + add_PythonPanelServiceServicer_to_server(servicer, server) + port = server.add_insecure_port("[::]:0") # Bind to an available port + server.start() + yield server, port + server.stop(None) + + +@pytest.fixture +def fake_python_panel_service_stub( + fake_python_panel_service: tuple[grpc.Server, int], +) -> Generator[PythonPanelServiceStub, Any, None]: + """Fixture to create a gRPC stub for the FakePythonPanelService.""" + _, port = fake_python_panel_service + channel = grpc.insecure_channel(f"localhost:{port}") + yield PythonPanelServiceStub(channel) + channel.close() diff --git a/tests/unit/test_fake_python_panel_servicer.py b/tests/unit/test_fake_python_panel_servicer.py new file mode 100644 index 0000000..b8ad3c1 --- /dev/null +++ b/tests/unit/test_fake_python_panel_servicer.py @@ -0,0 +1,41 @@ +from google.protobuf.any_pb2 import Any +from ni.pythonpanel.v1.python_panel_service_pb2 import ( + ConnectRequest, + DisconnectRequest, + GetValueRequest, + SetValueRequest, +) +from ni.pythonpanel.v1.python_panel_service_pb2_grpc import PythonPanelServiceStub + + +def test___connect___gets_response(fake_python_panel_service_stub: PythonPanelServiceStub) -> None: + request = ConnectRequest(panel_id="test_panel", panel_uri="path/to/panel") + response = fake_python_panel_service_stub.Connect(request) + assert response is not None # Ensure response is returned + + +def test___disconnect___gets_response( + fake_python_panel_service_stub: PythonPanelServiceStub, +) -> None: + request = DisconnectRequest(panel_id="test_panel") + response = fake_python_panel_service_stub.Disconnect(request) + assert response is not None # Ensure response is returned + + +def test___get_value___gets_response( + fake_python_panel_service_stub: PythonPanelServiceStub, +) -> None: + request = GetValueRequest(panel_id="test_panel", value_id="test_value") + response = fake_python_panel_service_stub.GetValue(request) + assert response is not None # Ensure response is returned + assert isinstance(response.value, Any) # Ensure the value is of type `Any` + + +def test___set_value___gets_response( + fake_python_panel_service_stub: PythonPanelServiceStub, +) -> None: + value = Any() + value.value = b"test_data" + request = SetValueRequest(panel_id="test_panel", value_id="test_value", value=value) + response = fake_python_panel_service_stub.SetValue(request) + assert response is not None # Ensure response is returned diff --git a/tests/unit/test_fake_server.py b/tests/unit/test_fake_server.py deleted file mode 100644 index a126c89..0000000 --- a/tests/unit/test_fake_server.py +++ /dev/null @@ -1,68 +0,0 @@ -from concurrent import futures -from typing import Generator - -import grpc -import pytest -from google.protobuf.any_pb2 import Any -from ni.pythonpanel.v1.python_panel_service_pb2 import ( - ConnectRequest, - DisconnectRequest, - GetValueRequest, - SetValueRequest, -) -from ni.pythonpanel.v1.python_panel_service_pb2_grpc import PythonPanelServiceStub - -from tests.utils._fake_python_panel_service import FakePythonPanelService - - -@pytest.fixture -def grpc_server() -> Generator[tuple[grpc.Server, int], Any, None]: - # Create an in-process gRPC server - server = grpc.server(futures.ThreadPoolExecutor(max_workers=10)) - servicer = FakePythonPanelService() - from ni.pythonpanel.v1.python_panel_service_pb2_grpc import ( - add_PythonPanelServiceServicer_to_server, - ) - - add_PythonPanelServiceServicer_to_server(servicer, server) - port = server.add_insecure_port("[::]:0") # Bind to an available port - server.start() - yield server, port - server.stop(None) - - -@pytest.fixture -def grpc_client( - grpc_server: tuple[grpc.Server, int], -) -> Generator[PythonPanelServiceStub, Any, None]: - _, port = grpc_server - channel = grpc.insecure_channel(f"localhost:{port}") - yield PythonPanelServiceStub(channel) - channel.close() - - -def test___connect___gets_response(grpc_client: PythonPanelServiceStub) -> None: - request = ConnectRequest(panel_id="test_panel", panel_uri="path/to/panel") - response = grpc_client.Connect(request) - assert response is not None # Ensure response is returned - - -def test___disconnect___gets_response(grpc_client: PythonPanelServiceStub) -> None: - request = DisconnectRequest(panel_id="test_panel") - response = grpc_client.Disconnect(request) - assert response is not None # Ensure response is returned - - -def test___get_value___gets_response(grpc_client: PythonPanelServiceStub) -> None: - request = GetValueRequest(panel_id="test_panel", value_id="test_value") - response = grpc_client.GetValue(request) - assert response is not None # Ensure response is returned - assert isinstance(response.value, Any) # Ensure the value is of type `Any` - - -def test___set_value___gets_response(grpc_client: PythonPanelServiceStub) -> None: - value = Any() - value.value = b"test_data" - request = SetValueRequest(panel_id="test_panel", value_id="test_value", value=value) - response = grpc_client.SetValue(request) - assert response is not None # Ensure response is returned diff --git a/tests/unit/test_panel.py b/tests/unit/test_panel.py index be6644c..6519428 100644 --- a/tests/unit/test_panel.py +++ b/tests/unit/test_panel.py @@ -1,28 +1,6 @@ -from concurrent import futures -from typing import Generator - import grpc -import pytest -from google.protobuf.any_pb2 import Any from tests.utils._fake_panel import FakePanel -from tests.utils._fake_python_panel_service import FakePythonPanelService - - -@pytest.fixture -def grpc_server() -> Generator[tuple[grpc.Server, int], Any, None]: - # Create an in-process gRPC server - server = grpc.server(futures.ThreadPoolExecutor(max_workers=10)) - servicer = FakePythonPanelService() - from ni.pythonpanel.v1.python_panel_service_pb2_grpc import ( - add_PythonPanelServiceServicer_to_server, - ) - - add_PythonPanelServiceServicer_to_server(servicer, server) - port = server.add_insecure_port("[::]:0") # Bind to an available port - server.start() - yield server, port - server.stop(None) def test___panel___has_panel_id_and_panel_uri() -> None: @@ -32,9 +10,9 @@ def test___panel___has_panel_id_and_panel_uri() -> None: def test___connected_panel___set_value___gets_same_value( - grpc_server: tuple[grpc.Server, int], + fake_python_panel_service: tuple[grpc.Server, int], ) -> None: - _, port = grpc_server + _, port = fake_python_panel_service panel = FakePanel(port, "my_panel", "path/to/script") panel.connect() @@ -45,8 +23,10 @@ def test___connected_panel___set_value___gets_same_value( panel.disconnect() -def test___with_panel___set_value___gets_same_value(grpc_server: tuple[grpc.Server, int]) -> None: - _, port = grpc_server +def test___with_panel___set_value___gets_same_value( + fake_python_panel_service: tuple[grpc.Server, int], +) -> None: + _, port = fake_python_panel_service with FakePanel(port, "my_panel", "path/to/script") as panel: panel.set_value("test_id", "test_value") diff --git a/tests/utils/_fake_python_panel_service.py b/tests/utils/_fake_python_panel_servicer.py similarity index 93% rename from tests/utils/_fake_python_panel_service.py rename to tests/utils/_fake_python_panel_servicer.py index d70225a..8c5c2d3 100644 --- a/tests/utils/_fake_python_panel_service.py +++ b/tests/utils/_fake_python_panel_servicer.py @@ -20,8 +20,8 @@ ) -class FakePythonPanelService(PythonPanelServiceServicer): - """Fake implementation of the PythonPanelService for testing.""" +class FakePythonPanelServicer(PythonPanelServiceServicer): + """Fake implementation of the PythonPanelServicer for testing.""" _values = {"test_value": any_pb2.Any()} @@ -49,7 +49,7 @@ def SetValue(self, request: SetValueRequest, context: Any) -> SetValueResponse: def serve() -> None: """Run the gRPC server.""" server = grpc.server(futures.ThreadPoolExecutor(max_workers=10)) - add_PythonPanelServiceServicer_to_server(FakePythonPanelService(), server) + add_PythonPanelServiceServicer_to_server(FakePythonPanelServicer(), server) server.add_insecure_port("[::]:50051") # TODO: do we need to find a free port? server.start() print("Server is running on port 50051...") From bb5ea949cb0713a1e4d52b082e848925e437d48f Mon Sep 17 00:00:00 2001 From: Mike Prosser Date: Thu, 24 Apr 2025 14:08:42 -0500 Subject: [PATCH 07/17] remove unused serve() function --- tests/utils/_fake_python_panel_servicer.py | 26 +--------------------- 1 file changed, 1 insertion(+), 25 deletions(-) diff --git a/tests/utils/_fake_python_panel_servicer.py b/tests/utils/_fake_python_panel_servicer.py index 8c5c2d3..0f968b7 100644 --- a/tests/utils/_fake_python_panel_servicer.py +++ b/tests/utils/_fake_python_panel_servicer.py @@ -1,9 +1,6 @@ -import time -from concurrent import futures from typing import Any import google.protobuf.any_pb2 as any_pb2 -import grpc from ni.pythonpanel.v1.python_panel_service_pb2 import ( ConnectRequest, ConnectResponse, @@ -14,10 +11,7 @@ SetValueRequest, SetValueResponse, ) -from ni.pythonpanel.v1.python_panel_service_pb2_grpc import ( - PythonPanelServiceServicer, - add_PythonPanelServiceServicer_to_server, -) +from ni.pythonpanel.v1.python_panel_service_pb2_grpc import PythonPanelServiceServicer class FakePythonPanelServicer(PythonPanelServiceServicer): @@ -44,21 +38,3 @@ def SetValue(self, request: SetValueRequest, context: Any) -> SetValueResponse: """Just a trivial implementation for testing.""" self._values[request.value_id] = request.value return SetValueResponse() - - -def serve() -> None: - """Run the gRPC server.""" - server = grpc.server(futures.ThreadPoolExecutor(max_workers=10)) - add_PythonPanelServiceServicer_to_server(FakePythonPanelServicer(), server) - server.add_insecure_port("[::]:50051") # TODO: do we need to find a free port? - server.start() - print("Server is running on port 50051...") - try: - while True: - time.sleep(86400) # Keep the server running - except KeyboardInterrupt: - server.stop(0) - - -if __name__ == "__main__": - serve() From a7e4914ef76317ec3c5faf3651fc45d014e8197e Mon Sep 17 00:00:00 2001 From: Mike Prosser Date: Thu, 24 Apr 2025 14:27:39 -0500 Subject: [PATCH 08/17] cleanup --- src/nipanel/_panel.py | 4 ++-- src/nipanel/_streamlit_panel.py | 2 +- tests/unit/test_fake_python_panel_servicer.py | 9 ++++++--- tests/unit/test_panel.py | 8 ++++---- tests/utils/{_fake_panel.py => _port_panel.py} | 8 ++++---- 5 files changed, 17 insertions(+), 14 deletions(-) rename tests/utils/{_fake_panel.py => _port_panel.py} (65%) diff --git a/src/nipanel/_panel.py b/src/nipanel/_panel.py index c30645c..5bd2957 100644 --- a/src/nipanel/_panel.py +++ b/src/nipanel/_panel.py @@ -60,7 +60,7 @@ def __exit__( def connect(self) -> None: """Connect to the panel and open it.""" # TODO: use the channel pool - channel = insecure_channel(self._get_channel_url()) + channel = insecure_channel(self._resolve_service_address()) self._stub = PythonPanelServiceStub(channel) connect_request = ConnectRequest(panel_id=self._panel_id, panel_uri=self._panel_uri) @@ -113,6 +113,6 @@ def set_value(self, value_id: str, value: object) -> None: pass @abstractmethod - def _get_channel_url(self) -> str: + def _resolve_service_address(self) -> str: """Resolve the service location for the panel.""" raise NotImplementedError diff --git a/src/nipanel/_streamlit_panel.py b/src/nipanel/_streamlit_panel.py index b9ccac7..dfbbe93 100644 --- a/src/nipanel/_streamlit_panel.py +++ b/src/nipanel/_streamlit_panel.py @@ -21,7 +21,7 @@ def __init__(self, panel_id: str, streamlit_script_uri: str) -> None: """ super().__init__(panel_id, streamlit_script_uri) - def _get_channel_url(self) -> str: + def _resolve_service_address(self) -> str: with GrpcChannelPool() as grpc_channel_pool: discovery_client = DiscoveryClient(grpc_channel_pool=grpc_channel_pool) service_location = discovery_client.resolve_service( diff --git a/tests/unit/test_fake_python_panel_servicer.py b/tests/unit/test_fake_python_panel_servicer.py index b8ad3c1..65e6c72 100644 --- a/tests/unit/test_fake_python_panel_servicer.py +++ b/tests/unit/test_fake_python_panel_servicer.py @@ -11,6 +11,7 @@ def test___connect___gets_response(fake_python_panel_service_stub: PythonPanelServiceStub) -> None: request = ConnectRequest(panel_id="test_panel", panel_uri="path/to/panel") response = fake_python_panel_service_stub.Connect(request) + assert response is not None # Ensure response is returned @@ -19,6 +20,7 @@ def test___disconnect___gets_response( ) -> None: request = DisconnectRequest(panel_id="test_panel") response = fake_python_panel_service_stub.Disconnect(request) + assert response is not None # Ensure response is returned @@ -27,6 +29,7 @@ def test___get_value___gets_response( ) -> None: request = GetValueRequest(panel_id="test_panel", value_id="test_value") response = fake_python_panel_service_stub.GetValue(request) + assert response is not None # Ensure response is returned assert isinstance(response.value, Any) # Ensure the value is of type `Any` @@ -34,8 +37,8 @@ def test___get_value___gets_response( def test___set_value___gets_response( fake_python_panel_service_stub: PythonPanelServiceStub, ) -> None: - value = Any() - value.value = b"test_data" - request = SetValueRequest(panel_id="test_panel", value_id="test_value", value=value) + test_value = Any(value=b"test_data") + request = SetValueRequest(panel_id="test_panel", value_id="test_value", value=test_value) response = fake_python_panel_service_stub.SetValue(request) + assert response is not None # Ensure response is returned diff --git a/tests/unit/test_panel.py b/tests/unit/test_panel.py index 6519428..9e76769 100644 --- a/tests/unit/test_panel.py +++ b/tests/unit/test_panel.py @@ -1,10 +1,10 @@ import grpc -from tests.utils._fake_panel import FakePanel +from tests.utils._port_panel import PortPanel def test___panel___has_panel_id_and_panel_uri() -> None: - panel = FakePanel(0, "my_panel", "path/to/script") + panel = PortPanel(0, "my_panel", "path/to/script") assert panel.panel_id == "my_panel" assert panel.panel_uri == "path/to/script" @@ -13,7 +13,7 @@ def test___connected_panel___set_value___gets_same_value( fake_python_panel_service: tuple[grpc.Server, int], ) -> None: _, port = fake_python_panel_service - panel = FakePanel(port, "my_panel", "path/to/script") + panel = PortPanel(port, "my_panel", "path/to/script") panel.connect() panel.set_value("test_id", "test_value") @@ -27,7 +27,7 @@ def test___with_panel___set_value___gets_same_value( fake_python_panel_service: tuple[grpc.Server, int], ) -> None: _, port = fake_python_panel_service - with FakePanel(port, "my_panel", "path/to/script") as panel: + with PortPanel(port, "my_panel", "path/to/script") as panel: panel.set_value("test_id", "test_value") # TODO: AB#3095681 - change asserted value to test_value diff --git a/tests/utils/_fake_panel.py b/tests/utils/_port_panel.py similarity index 65% rename from tests/utils/_fake_panel.py rename to tests/utils/_port_panel.py index 7d7150c..9db922e 100644 --- a/tests/utils/_fake_panel.py +++ b/tests/utils/_port_panel.py @@ -1,11 +1,11 @@ from nipanel._panel import Panel -class FakePanel(Panel): - """This class allows you to connect to the FakePythonPanelService, for testing.""" +class PortPanel(Panel): + """This class allows you to connect to the PythonPanelService with a specified port, for testing.""" def __init__(self, port: int, panel_id: str, panel_uri: str) -> None: - """Create a fake panel, for testing. + """Create a panel and connect to a specified port, for testing. Args: port: The port number for the gRPC server. @@ -18,5 +18,5 @@ def __init__(self, port: int, panel_id: str, panel_uri: str) -> None: super().__init__(panel_id, panel_uri) self.port = port - def _get_channel_url(self) -> str: + def _resolve_service_address(self) -> str: return f"localhost:{self.port}" From c7f5f78c6b4d6f891413844bd6b126dbd8e95501 Mon Sep 17 00:00:00 2001 From: Mike Prosser Date: Thu, 24 Apr 2025 14:50:38 -0500 Subject: [PATCH 09/17] use a channel pool --- src/nipanel/_panel.py | 13 ++++++++----- tests/utils/_port_panel.py | 6 +++--- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/src/nipanel/_panel.py b/src/nipanel/_panel.py index 5bd2957..27d6e46 100644 --- a/src/nipanel/_panel.py +++ b/src/nipanel/_panel.py @@ -5,9 +5,10 @@ from types import TracebackType from typing import TYPE_CHECKING, Optional, Type -from grpc import RpcError, StatusCode, insecure_channel +from grpc import RpcError, StatusCode from ni.pythonpanel.v1.python_panel_service_pb2 import ConnectRequest, DisconnectRequest from ni.pythonpanel.v1.python_panel_service_pb2_grpc import PythonPanelServiceStub +from ni_measurement_plugin_sdk_service.grpc.channelpool import GrpcChannelPool from nipanel._panel_not_found_error import PanelNotFoundError @@ -21,14 +22,16 @@ class Panel(ABC): """This class allows you to connect to a panel and specify values for its controls.""" + _channel_pool: GrpcChannelPool _stub: PythonPanelServiceStub | None _panel_id: str _panel_uri: str - __slots__ = ["_stub", "_panel_id", "_panel_uri", "__weakref__"] + __slots__ = ["_channel_pool", "_stub", "_panel_id", "_panel_uri", "__weakref__"] def __init__(self, panel_id: str, panel_uri: str) -> None: """Initialize the panel.""" + self._channel_pool = GrpcChannelPool() self._panel_id = panel_id self._panel_uri = panel_uri @@ -59,8 +62,8 @@ def __exit__( def connect(self) -> None: """Connect to the panel and open it.""" - # TODO: use the channel pool - channel = insecure_channel(self._resolve_service_address()) + address = self._resolve_service_address() + channel = self._channel_pool.get_channel(address) self._stub = PythonPanelServiceStub(channel) connect_request = ConnectRequest(panel_id=self._panel_id, panel_uri=self._panel_uri) @@ -88,7 +91,7 @@ def disconnect(self) -> None: raise self._stub = None - # TODO: channel pool cleanup? + self._channel_pool.close() def get_value(self, value_id: str) -> object: """Get the value for a control on the panel. diff --git a/tests/utils/_port_panel.py b/tests/utils/_port_panel.py index 9db922e..cbf85a3 100644 --- a/tests/utils/_port_panel.py +++ b/tests/utils/_port_panel.py @@ -2,10 +2,10 @@ class PortPanel(Panel): - """This class allows you to connect to the PythonPanelService with a specified port, for testing.""" + """This class allows you to connect to the PythonPanelService with a specified port.""" def __init__(self, port: int, panel_id: str, panel_uri: str) -> None: - """Create a panel and connect to a specified port, for testing. + """Create a panel and connect to a specified port. Args: port: The port number for the gRPC server. @@ -13,7 +13,7 @@ def __init__(self, port: int, panel_id: str, panel_uri: str) -> None: panel_uri: The file path of the panel script. Returns: - A new FakePanel instance. + A new PortPanel instance. """ super().__init__(panel_id, panel_uri) self.port = port From d97110662d2fad4c45f9b36e473c8e5fbe1e5250 Mon Sep 17 00:00:00 2001 From: Mike Prosser Date: Thu, 24 Apr 2025 16:38:10 -0500 Subject: [PATCH 10/17] add a retry if connect fails, and remove PanelNotFoundError --- src/nipanel/_panel.py | 31 +++++++++------------- src/nipanel/_panel_not_found_error.py | 8 ------ tests/unit/test_panel.py | 29 ++++++++++++++++++++ tests/utils/_fake_python_panel_servicer.py | 9 +++++++ 4 files changed, 50 insertions(+), 27 deletions(-) delete mode 100644 src/nipanel/_panel_not_found_error.py diff --git a/src/nipanel/_panel.py b/src/nipanel/_panel.py index 27d6e46..2dec79a 100644 --- a/src/nipanel/_panel.py +++ b/src/nipanel/_panel.py @@ -1,17 +1,16 @@ from __future__ import annotations import sys +import time from abc import ABC, abstractmethod from types import TracebackType from typing import TYPE_CHECKING, Optional, Type -from grpc import RpcError, StatusCode +from grpc import RpcError from ni.pythonpanel.v1.python_panel_service_pb2 import ConnectRequest, DisconnectRequest from ni.pythonpanel.v1.python_panel_service_pb2_grpc import PythonPanelServiceStub from ni_measurement_plugin_sdk_service.grpc.channelpool import GrpcChannelPool -from nipanel._panel_not_found_error import PanelNotFoundError - if TYPE_CHECKING: if sys.version_info >= (3, 11): from typing import Self @@ -22,6 +21,8 @@ class Panel(ABC): """This class allows you to connect to a panel and specify values for its controls.""" + RETRY_WAIT_TIME = 1 # time in seconds to wait before retrying connection + _channel_pool: GrpcChannelPool _stub: PythonPanelServiceStub | None _panel_id: str @@ -32,6 +33,7 @@ class Panel(ABC): def __init__(self, panel_id: str, panel_uri: str) -> None: """Initialize the panel.""" self._channel_pool = GrpcChannelPool() + self._stub = None self._panel_id = panel_id self._panel_uri = panel_uri @@ -65,31 +67,22 @@ def connect(self) -> None: address = self._resolve_service_address() channel = self._channel_pool.get_channel(address) self._stub = PythonPanelServiceStub(channel) - connect_request = ConnectRequest(panel_id=self._panel_id, panel_uri=self._panel_uri) + connect_request = ConnectRequest(panel_id=self._panel_id, panel_uri=self._panel_uri) try: self._stub.Connect(connect_request) - except RpcError as e: - if e.code() == StatusCode.NOT_FOUND: - raise PanelNotFoundError(self._panel_id, self._panel_uri) from e - else: - raise + except RpcError: + # retry the connection if it fails, but only once + time.sleep(self.RETRY_WAIT_TIME) + self._stub.Connect(connect_request) def disconnect(self) -> None: """Disconnect from the panel (does not close the panel).""" - disconnect_request = DisconnectRequest(panel_id=self._panel_id) - if self._stub is None: raise RuntimeError("connect() must be called before disconnect()") - try: - self._stub.Disconnect(disconnect_request) - except RpcError as e: - if e.code() == StatusCode.NOT_FOUND: - raise PanelNotFoundError(self._panel_id, self._panel_uri) from e - else: - raise - + disconnect_request = DisconnectRequest(panel_id=self._panel_id) + self._stub.Disconnect(disconnect_request) self._stub = None self._channel_pool.close() diff --git a/src/nipanel/_panel_not_found_error.py b/src/nipanel/_panel_not_found_error.py deleted file mode 100644 index ad29294..0000000 --- a/src/nipanel/_panel_not_found_error.py +++ /dev/null @@ -1,8 +0,0 @@ -class PanelNotFoundError(Exception): - """Exception raised when a panel is not found.""" - - def __init__(self, panel_id: str, panel_uri: str): - """Initialize the exception with panel ID and URI.""" - super().__init__(f"Panel not found: {panel_id} - {panel_uri}") - self.panel_id = panel_id - self.panel_uri = panel_uri diff --git a/tests/unit/test_panel.py b/tests/unit/test_panel.py index 9e76769..fc14088 100644 --- a/tests/unit/test_panel.py +++ b/tests/unit/test_panel.py @@ -1,5 +1,7 @@ import grpc +import pytest +from tests.utils._fake_python_panel_servicer import FakePythonPanelServicer from tests.utils._port_panel import PortPanel @@ -32,3 +34,30 @@ def test___with_panel___set_value___gets_same_value( # TODO: AB#3095681 - change asserted value to test_value assert panel.get_value("test_id") == "placeholder value" + + +def test___new_panel___disconnect___raises_runtime_error( + fake_python_panel_service: tuple[grpc.Server, int], +) -> None: + _, port = fake_python_panel_service + panel = PortPanel(port, "my_panel", "path/to/script") + + with pytest.raises(RuntimeError): + panel.disconnect() + + +def test___first_connect_fails___connect___gets_value( + fake_python_panel_service: tuple[grpc.Server, int], +) -> None: + """Test that panel.connect() will automatically retry once.""" + # Simulate a failure on the first connect attempt + FakePythonPanelServicer.fail_next_connect() + _, port = fake_python_panel_service + panel = PortPanel(port, "my_panel", "path/to/script") + + panel.connect() + + panel.set_value("test_id", "test_value") + # TODO: AB#3095681 - change asserted value to test_value + assert panel.get_value("test_id") == "placeholder value" + panel.disconnect() diff --git a/tests/utils/_fake_python_panel_servicer.py b/tests/utils/_fake_python_panel_servicer.py index 0f968b7..b8b8efc 100644 --- a/tests/utils/_fake_python_panel_servicer.py +++ b/tests/utils/_fake_python_panel_servicer.py @@ -18,9 +18,13 @@ class FakePythonPanelServicer(PythonPanelServiceServicer): """Fake implementation of the PythonPanelServicer for testing.""" _values = {"test_value": any_pb2.Any()} + _fail_next_connect = False def Connect(self, request: ConnectRequest, context: Any) -> ConnectResponse: # noqa: N802 """Just a trivial implementation for testing.""" + if self._fail_next_connect: + self._fail_next_connect = False + raise ValueError("Simulate a failure to Connect.") return ConnectResponse() def Disconnect( # noqa: N802 @@ -38,3 +42,8 @@ def SetValue(self, request: SetValueRequest, context: Any) -> SetValueResponse: """Just a trivial implementation for testing.""" self._values[request.value_id] = request.value return SetValueResponse() + + @classmethod + def fail_next_connect(cls) -> None: + """Set whether the Connect method should fail the next time it is called.""" + cls._fail_next_connect = True From 426aa5468f28dfc1cc134485863bdf488fbc2667 Mon Sep 17 00:00:00 2001 From: Mike Prosser Date: Thu, 24 Apr 2025 16:58:56 -0500 Subject: [PATCH 11/17] cleanup --- src/nipanel/_streamlit_panel.py | 8 ++++---- tests/conftest.py | 17 ++++++++--------- tests/unit/test_panel.py | 13 ++++++------- tests/utils/_fake_python_panel_servicer.py | 13 ++++++------- 4 files changed, 24 insertions(+), 27 deletions(-) diff --git a/src/nipanel/_streamlit_panel.py b/src/nipanel/_streamlit_panel.py index dfbbe93..98cbb67 100644 --- a/src/nipanel/_streamlit_panel.py +++ b/src/nipanel/_streamlit_panel.py @@ -7,6 +7,8 @@ class StreamlitPanel(Panel): """This class allows you to connect to a Streamlit panel and specify values for its controls.""" + PYTHON_PANEL_SERVICE = "ni.pythonpanel.v1.PythonPanelService" + __slots__ = () def __init__(self, panel_id: str, streamlit_script_uri: str) -> None: @@ -24,7 +26,5 @@ def __init__(self, panel_id: str, streamlit_script_uri: str) -> None: def _resolve_service_address(self) -> str: with GrpcChannelPool() as grpc_channel_pool: discovery_client = DiscoveryClient(grpc_channel_pool=grpc_channel_pool) - service_location = discovery_client.resolve_service( - "ni.pythonpanel.v1.PythonPanelService" - ) - return service_location.insecure_address + service_location = discovery_client.resolve_service(self.PYTHON_PANEL_SERVICE) + return service_location.insecure_address diff --git a/tests/conftest.py b/tests/conftest.py index 40d3a6d..0be73e4 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,4 +1,4 @@ -"""Fixtures for testing gRPC services.""" +"""Fixtures for testing.""" from concurrent import futures from typing import Any, Generator @@ -14,24 +14,23 @@ @pytest.fixture -def fake_python_panel_service() -> Generator[tuple[grpc.Server, int], Any, None]: +def fake_python_panel_service() -> Generator[tuple[FakePythonPanelServicer, int], Any, None]: """Fixture to create a FakePythonPanelServicer for testing.""" - # Create an in-process gRPC server - server = grpc.server(futures.ThreadPoolExecutor(max_workers=10)) + thread_pool = futures.ThreadPoolExecutor(max_workers=10) + server = grpc.server(thread_pool) servicer = FakePythonPanelServicer() - add_PythonPanelServiceServicer_to_server(servicer, server) - port = server.add_insecure_port("[::]:0") # Bind to an available port + port = server.add_insecure_port("[::]:0") server.start() - yield server, port + yield servicer, port server.stop(None) @pytest.fixture def fake_python_panel_service_stub( - fake_python_panel_service: tuple[grpc.Server, int], + fake_python_panel_service: tuple[FakePythonPanelServicer, int], ) -> Generator[PythonPanelServiceStub, Any, None]: - """Fixture to create a gRPC stub for the FakePythonPanelService.""" + """Fixture to attach a PythonPanelSericeStub to a FakePythonPanelService.""" _, port = fake_python_panel_service channel = grpc.insecure_channel(f"localhost:{port}") yield PythonPanelServiceStub(channel) diff --git a/tests/unit/test_panel.py b/tests/unit/test_panel.py index fc14088..8abdeca 100644 --- a/tests/unit/test_panel.py +++ b/tests/unit/test_panel.py @@ -1,4 +1,3 @@ -import grpc import pytest from tests.utils._fake_python_panel_servicer import FakePythonPanelServicer @@ -12,7 +11,7 @@ def test___panel___has_panel_id_and_panel_uri() -> None: def test___connected_panel___set_value___gets_same_value( - fake_python_panel_service: tuple[grpc.Server, int], + fake_python_panel_service: tuple[FakePythonPanelServicer, int], ) -> None: _, port = fake_python_panel_service panel = PortPanel(port, "my_panel", "path/to/script") @@ -26,7 +25,7 @@ def test___connected_panel___set_value___gets_same_value( def test___with_panel___set_value___gets_same_value( - fake_python_panel_service: tuple[grpc.Server, int], + fake_python_panel_service: tuple[FakePythonPanelServicer, int], ) -> None: _, port = fake_python_panel_service with PortPanel(port, "my_panel", "path/to/script") as panel: @@ -37,7 +36,7 @@ def test___with_panel___set_value___gets_same_value( def test___new_panel___disconnect___raises_runtime_error( - fake_python_panel_service: tuple[grpc.Server, int], + fake_python_panel_service: tuple[FakePythonPanelServicer, int], ) -> None: _, port = fake_python_panel_service panel = PortPanel(port, "my_panel", "path/to/script") @@ -47,12 +46,12 @@ def test___new_panel___disconnect___raises_runtime_error( def test___first_connect_fails___connect___gets_value( - fake_python_panel_service: tuple[grpc.Server, int], + fake_python_panel_service: tuple[FakePythonPanelServicer, int], ) -> None: """Test that panel.connect() will automatically retry once.""" + servicer, port = fake_python_panel_service # Simulate a failure on the first connect attempt - FakePythonPanelServicer.fail_next_connect() - _, port = fake_python_panel_service + servicer.fail_next_connect() panel = PortPanel(port, "my_panel", "path/to/script") panel.connect() diff --git a/tests/utils/_fake_python_panel_servicer.py b/tests/utils/_fake_python_panel_servicer.py index b8b8efc..37f9457 100644 --- a/tests/utils/_fake_python_panel_servicer.py +++ b/tests/utils/_fake_python_panel_servicer.py @@ -21,7 +21,7 @@ class FakePythonPanelServicer(PythonPanelServiceServicer): _fail_next_connect = False def Connect(self, request: ConnectRequest, context: Any) -> ConnectResponse: # noqa: N802 - """Just a trivial implementation for testing.""" + """Trivial implementation for testing.""" if self._fail_next_connect: self._fail_next_connect = False raise ValueError("Simulate a failure to Connect.") @@ -30,20 +30,19 @@ def Connect(self, request: ConnectRequest, context: Any) -> ConnectResponse: # def Disconnect( # noqa: N802 self, request: DisconnectRequest, context: Any ) -> DisconnectResponse: - """Just a trivial implementation for testing.""" + """Trivial implementation for testing.""" return DisconnectResponse() def GetValue(self, request: GetValueRequest, context: Any) -> GetValueResponse: # noqa: N802 - """Just a trivial implementation for testing.""" + """Trivial implementation for testing.""" value = self._values[request.value_id] return GetValueResponse(value=value) def SetValue(self, request: SetValueRequest, context: Any) -> SetValueResponse: # noqa: N802 - """Just a trivial implementation for testing.""" + """Trivial implementation for testing.""" self._values[request.value_id] = request.value return SetValueResponse() - @classmethod - def fail_next_connect(cls) -> None: + def fail_next_connect(self) -> None: """Set whether the Connect method should fail the next time it is called.""" - cls._fail_next_connect = True + self._fail_next_connect = True From 74847dcd223acbdaecd28d66847d3d57d8715f80 Mon Sep 17 00:00:00 2001 From: Mike Prosser Date: Fri, 25 Apr 2025 11:14:01 -0500 Subject: [PATCH 12/17] refactor to use PanelClient --- src/nipanel/_panel.py | 46 ++++-------- src/nipanel/_panel_client.py | 81 ++++++++++++++++++++++ src/nipanel/_streamlit_panel.py | 11 ++- tests/unit/test_panel.py | 12 ---- tests/utils/_fake_python_panel_servicer.py | 3 +- tests/utils/_port_panel.py | 4 +- 6 files changed, 103 insertions(+), 54 deletions(-) create mode 100644 src/nipanel/_panel_client.py diff --git a/src/nipanel/_panel.py b/src/nipanel/_panel.py index 2dec79a..a40fefb 100644 --- a/src/nipanel/_panel.py +++ b/src/nipanel/_panel.py @@ -1,15 +1,13 @@ from __future__ import annotations import sys -import time from abc import ABC, abstractmethod from types import TracebackType from typing import TYPE_CHECKING, Optional, Type -from grpc import RpcError -from ni.pythonpanel.v1.python_panel_service_pb2 import ConnectRequest, DisconnectRequest -from ni.pythonpanel.v1.python_panel_service_pb2_grpc import PythonPanelServiceStub -from ni_measurement_plugin_sdk_service.grpc.channelpool import GrpcChannelPool +from ni_measurement_plugin_sdk_service.discovery import DiscoveryClient + +from nipanel._panel_client import PanelClient if TYPE_CHECKING: if sys.version_info >= (3, 11): @@ -21,19 +19,15 @@ class Panel(ABC): """This class allows you to connect to a panel and specify values for its controls.""" - RETRY_WAIT_TIME = 1 # time in seconds to wait before retrying connection - - _channel_pool: GrpcChannelPool - _stub: PythonPanelServiceStub | None + _panel_client: PanelClient _panel_id: str _panel_uri: str - __slots__ = ["_channel_pool", "_stub", "_panel_id", "_panel_uri", "__weakref__"] + __slots__ = ["_panel_client", "_panel_id", "_panel_uri", "__weakref__"] def __init__(self, panel_id: str, panel_uri: str) -> None: """Initialize the panel.""" - self._channel_pool = GrpcChannelPool() - self._stub = None + self._panel_client = PanelClient(self._resolve_service_address) self._panel_id = panel_id self._panel_uri = panel_uri @@ -64,27 +58,11 @@ def __exit__( def connect(self) -> None: """Connect to the panel and open it.""" - address = self._resolve_service_address() - channel = self._channel_pool.get_channel(address) - self._stub = PythonPanelServiceStub(channel) - - connect_request = ConnectRequest(panel_id=self._panel_id, panel_uri=self._panel_uri) - try: - self._stub.Connect(connect_request) - except RpcError: - # retry the connection if it fails, but only once - time.sleep(self.RETRY_WAIT_TIME) - self._stub.Connect(connect_request) + self._panel_client.connect(self._panel_id, self._panel_uri) def disconnect(self) -> None: """Disconnect from the panel (does not close the panel).""" - if self._stub is None: - raise RuntimeError("connect() must be called before disconnect()") - - disconnect_request = DisconnectRequest(panel_id=self._panel_id) - self._stub.Disconnect(disconnect_request) - self._stub = None - self._channel_pool.close() + self._panel_client.disconnect(self._panel_id) def get_value(self, value_id: str) -> object: """Get the value for a control on the panel. @@ -95,7 +73,7 @@ def get_value(self, value_id: str) -> object: Returns: The value """ - # TODO: AB#3095681 - get the Any from _stub.GetValue and convert it to the correct type + # TODO: AB#3095681 - get the Any from _client.get_value and convert it to the correct type return "placeholder value" def set_value(self, value_id: str, value: object) -> None: @@ -105,10 +83,10 @@ def set_value(self, value_id: str, value: object) -> None: value_id: The id of the value value: The value """ - # TODO: AB#3095681 - Convert the value to an Any and pass it to _stub.SetValue + # TODO: AB#3095681 - Convert the value to an Any and pass it to _client.set_value pass @abstractmethod - def _resolve_service_address(self) -> str: - """Resolve the service location for the panel.""" + def _resolve_service_address(self, discovery_client: DiscoveryClient) -> str: + """Resolve the service address for the panel.""" raise NotImplementedError diff --git a/src/nipanel/_panel_client.py b/src/nipanel/_panel_client.py new file mode 100644 index 0000000..2dae5f0 --- /dev/null +++ b/src/nipanel/_panel_client.py @@ -0,0 +1,81 @@ +"""Client for accessing the NI Python Panel Service.""" + +from __future__ import annotations + +import logging +import threading +from typing import Any, Callable + +import grpc +from ni.pythonpanel.v1.python_panel_service_pb2 import ConnectRequest, DisconnectRequest +from ni.pythonpanel.v1.python_panel_service_pb2_grpc import PythonPanelServiceStub +from ni_measurement_plugin_sdk_service.discovery import DiscoveryClient +from ni_measurement_plugin_sdk_service.grpc.channelpool import GrpcChannelPool + +_logger = logging.getLogger(__name__) + + +class PanelClient: + """Client for accessing the NI Python Panel Service.""" + + def __init__( + self, + resolve_service_address_fn: Callable[[DiscoveryClient], str], + *, + discovery_client: DiscoveryClient | None = None, + grpc_channel: grpc.Channel | None = None, + grpc_channel_pool: GrpcChannelPool | None = None, + ) -> None: + """Initialize the panel client. + + Args: + resolve_service_address_fn: A function to resolve the service location. + discovery_client: An optional discovery client. + grpc_channel: An optional panel gRPC channel. + grpc_channel_pool: An optional gRPC channel pool. + """ + self._initialization_lock = threading.Lock() + self._resolve_service_address_fn = resolve_service_address_fn + self._discovery_client = discovery_client + self._grpc_channel_pool = grpc_channel_pool + self._stub: PythonPanelServiceStub | None = None + + if grpc_channel is not None: + self._stub = PythonPanelServiceStub(grpc_channel) + + def _get_stub(self) -> PythonPanelServiceStub: + if self._stub is None: + with self._initialization_lock: + if self._grpc_channel_pool is None: + _logger.debug("Creating unshared GrpcChannelPool.") + self._grpc_channel_pool = GrpcChannelPool() + if self._discovery_client is None: + _logger.debug("Creating unshared DiscoveryClient.") + self._discovery_client = DiscoveryClient( + grpc_channel_pool=self._grpc_channel_pool + ) + if self._stub is None: + service_address = self._resolve_service_address_fn(self._discovery_client) + channel = self._grpc_channel_pool.get_channel(service_address) + self._stub = PythonPanelServiceStub(channel) + return self._stub + + def _invoke_with_retry(self, method: Callable[..., Any], *args: Any, **kwargs: Any) -> Any: + """Invoke a gRPC method with retry logic.""" + try: + return method(*args, **kwargs) + except grpc.RpcError as e: + if e.code() == grpc.StatusCode.UNAVAILABLE or e.code() == grpc.StatusCode.UNKNOWN: + # if the service is unavailable, we can retry the connection + self._stub = None + return method(*args, **kwargs) + + def connect(self, panel_id: str, panel_uri: str) -> None: + """Connect to the panel and open it.""" + connect_request = ConnectRequest(panel_id=panel_id, panel_uri=panel_uri) + self._invoke_with_retry(self._get_stub().Connect, connect_request) + + def disconnect(self, panel_id: str) -> None: + """Disconnect from the panel (does not close the panel).""" + disconnect_request = DisconnectRequest(panel_id=panel_id) + self._invoke_with_retry(self._get_stub().Disconnect, disconnect_request) diff --git a/src/nipanel/_streamlit_panel.py b/src/nipanel/_streamlit_panel.py index 98cbb67..ea78017 100644 --- a/src/nipanel/_streamlit_panel.py +++ b/src/nipanel/_streamlit_panel.py @@ -1,5 +1,4 @@ from ni_measurement_plugin_sdk_service.discovery import DiscoveryClient -from ni_measurement_plugin_sdk_service.grpc.channelpool import GrpcChannelPool from nipanel._panel import Panel @@ -23,8 +22,8 @@ def __init__(self, panel_id: str, streamlit_script_uri: str) -> None: """ super().__init__(panel_id, streamlit_script_uri) - def _resolve_service_address(self) -> str: - with GrpcChannelPool() as grpc_channel_pool: - discovery_client = DiscoveryClient(grpc_channel_pool=grpc_channel_pool) - service_location = discovery_client.resolve_service(self.PYTHON_PANEL_SERVICE) - return service_location.insecure_address + def _resolve_service_address(self, discovery_client: DiscoveryClient) -> str: + service_location = discovery_client.resolve_service( + provided_interface=self.PYTHON_PANEL_SERVICE, service_class=self.PYTHON_PANEL_SERVICE + ) + return service_location.insecure_address diff --git a/tests/unit/test_panel.py b/tests/unit/test_panel.py index 8abdeca..01e1aeb 100644 --- a/tests/unit/test_panel.py +++ b/tests/unit/test_panel.py @@ -1,5 +1,3 @@ -import pytest - from tests.utils._fake_python_panel_servicer import FakePythonPanelServicer from tests.utils._port_panel import PortPanel @@ -35,16 +33,6 @@ def test___with_panel___set_value___gets_same_value( assert panel.get_value("test_id") == "placeholder value" -def test___new_panel___disconnect___raises_runtime_error( - fake_python_panel_service: tuple[FakePythonPanelServicer, int], -) -> None: - _, port = fake_python_panel_service - panel = PortPanel(port, "my_panel", "path/to/script") - - with pytest.raises(RuntimeError): - panel.disconnect() - - def test___first_connect_fails___connect___gets_value( fake_python_panel_service: tuple[FakePythonPanelServicer, int], ) -> None: diff --git a/tests/utils/_fake_python_panel_servicer.py b/tests/utils/_fake_python_panel_servicer.py index 37f9457..9895e99 100644 --- a/tests/utils/_fake_python_panel_servicer.py +++ b/tests/utils/_fake_python_panel_servicer.py @@ -1,6 +1,7 @@ from typing import Any import google.protobuf.any_pb2 as any_pb2 +import grpc from ni.pythonpanel.v1.python_panel_service_pb2 import ( ConnectRequest, ConnectResponse, @@ -24,7 +25,7 @@ def Connect(self, request: ConnectRequest, context: Any) -> ConnectResponse: # """Trivial implementation for testing.""" if self._fail_next_connect: self._fail_next_connect = False - raise ValueError("Simulate a failure to Connect.") + context.abort(grpc.StatusCode.UNAVAILABLE, "Simulated connection failure") return ConnectResponse() def Disconnect( # noqa: N802 diff --git a/tests/utils/_port_panel.py b/tests/utils/_port_panel.py index cbf85a3..46093ab 100644 --- a/tests/utils/_port_panel.py +++ b/tests/utils/_port_panel.py @@ -1,3 +1,5 @@ +from ni_measurement_plugin_sdk_service.discovery import DiscoveryClient + from nipanel._panel import Panel @@ -18,5 +20,5 @@ def __init__(self, port: int, panel_id: str, panel_uri: str) -> None: super().__init__(panel_id, panel_uri) self.port = port - def _resolve_service_address(self) -> str: + def _resolve_service_address(self, discovery_client: DiscoveryClient) -> str: return f"localhost:{self.port}" From e3bc460c2a9d31a246908b14241b30e9103f05e7 Mon Sep 17 00:00:00 2001 From: Mike Prosser Date: Fri, 25 Apr 2025 15:20:34 -0500 Subject: [PATCH 13/17] misc feedback and FakePythonPanelService --- CONTRIBUTING.md | 2 +- poetry.lock | 27 +++++++++++- pyproject.toml | 11 +++-- src/nipanel/_panel.py | 10 ++--- tests/conftest.py | 43 +++++++++++-------- tests/unit/test_fake_python_panel_servicer.py | 20 +++++---- tests/unit/test_panel.py | 22 +++++----- tests/utils/__init__.py | 1 + tests/utils/_fake_python_panel_service.py | 42 ++++++++++++++++++ 9 files changed, 126 insertions(+), 52 deletions(-) create mode 100644 tests/utils/__init__.py create mode 100644 tests/utils/_fake_python_panel_service.py diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 92fc228..43ce056 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -22,7 +22,7 @@ See [GitHub's official documentation](https://help.github.com/articles/using-pul # Getting Started This is the command to generate the files in /src/ni/pythonpanel/v1/: -`poetry run python -m grpc_tools.protoc --proto_path=protos --python_out=src/ --grpc_python_out=src/ --plugin=protoc-gen-mypy=.venv\Scripts\protoc-gen-mypy.exe --mypy_out=src/ --mypy_grpc_out=src/ ni/pythonpanel/v1/python_panel_service.proto` +`poetry run python -m grpc_tools.protoc --proto_path=protos --python_out=src/ --grpc_python_out=src/ --mypy_out=src/ --mypy_grpc_out=src/ ni/pythonpanel/v1/python_panel_service.proto` # Testing diff --git a/poetry.lock b/poetry.lock index c3404f9..6b9e391 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1174,6 +1174,20 @@ files = [ {file = "python_decouple-3.8-py3-none-any.whl", hash = "sha256:d0d45340815b25f4de59c974b855bb38d03151d81b037d9e3f463b0c9f8cbd66"}, ] +[[package]] +name = "pyupgrade" +version = "3.19.1" +description = "A tool to automatically upgrade syntax for newer versions." +optional = false +python-versions = ">=3.9" +files = [ + {file = "pyupgrade-3.19.1-py2.py3-none-any.whl", hash = "sha256:8c5b0bfacae5ff30fa136a53eb7f22c34ba007450d4099e9da8089dabb9e67c9"}, + {file = "pyupgrade-3.19.1.tar.gz", hash = "sha256:d10e8c5f54b8327211828769e98d95d95e4715de632a3414f1eef3f51357b9e2"}, +] + +[package.dependencies] +tokenize-rt = ">=6.1.0" + [[package]] name = "pywin32" version = "310" @@ -1325,6 +1339,17 @@ files = [ [package.dependencies] pbr = ">=2.0.0" +[[package]] +name = "tokenize-rt" +version = "6.1.0" +description = "A wrapper around the stdlib `tokenize` which roundtrips." +optional = false +python-versions = ">=3.9" +files = [ + {file = "tokenize_rt-6.1.0-py2.py3-none-any.whl", hash = "sha256:d706141cdec4aa5f358945abe36b911b8cbdc844545da99e811250c0cee9b6fc"}, + {file = "tokenize_rt-6.1.0.tar.gz", hash = "sha256:e8ee836616c0877ab7c7b54776d2fefcc3bde714449a206762425ae114b53c86"}, +] + [[package]] name = "toml" version = "0.10.2" @@ -1412,4 +1437,4 @@ files = [ [metadata] lock-version = "2.0" python-versions = "^3.9" -content-hash = "681a64f5d10bd10d379d6dcfd3984a20e57bae9ecd298965676528cf5258d81c" +content-hash = "0d4eda35e32c3ecd2e11d879f6afe5408d9fdd4defa8c9e02a92ee05a7971dea" diff --git a/pyproject.toml b/pyproject.toml index dfb6840..4b67be8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,6 +15,7 @@ ni-measurement-plugin-sdk = {version=">=2.3"} [tool.poetry.group.dev.dependencies] grpc-stubs = "^1.53" types-protobuf = ">=4.21" +pyupgrade = "^3.19.1" [tool.poetry.group.lint.dependencies] bandit = { version = ">=1.7", extras = ["toml"] } @@ -41,6 +42,9 @@ build-backend = "poetry.core.masonry.api" [tool.ni-python-styleguide] extend_exclude = ".tox,docs,src/ni/pythonpanel/v1" +[tool.black] +extend-exclude = '\.tox/|docs/|src/ni/pythonpanel/v1/' + [tool.mypy] files = "examples/,src/nipanel/,tests/" namespace_packages = true @@ -60,9 +64,4 @@ skips = [ [tool.pytest.ini_options] addopts = "--doctest-modules --strict-markers" -testpaths = ["src/nipanel", "tests"] - -[tool.black] -extend-exclude = ''' -/src/ni/pythonpanel/v1/ -''' \ No newline at end of file +testpaths = ["src/nipanel", "tests"] \ No newline at end of file diff --git a/src/nipanel/_panel.py b/src/nipanel/_panel.py index a40fefb..91cd863 100644 --- a/src/nipanel/_panel.py +++ b/src/nipanel/_panel.py @@ -3,7 +3,7 @@ import sys from abc import ABC, abstractmethod from types import TracebackType -from typing import TYPE_CHECKING, Optional, Type +from typing import TYPE_CHECKING from ni_measurement_plugin_sdk_service.discovery import DiscoveryClient @@ -48,10 +48,10 @@ def __enter__(self) -> Self: def __exit__( self, - exctype: Optional[Type[BaseException]], - excinst: Optional[BaseException], - exctb: Optional[TracebackType], - ) -> Optional[bool]: + exctype: type[BaseException] | None, + excinst: BaseException | None, + exctb: TracebackType | None, + ) -> bool | None: """Exit the runtime context related to this object.""" self.disconnect() return None diff --git a/tests/conftest.py b/tests/conftest.py index 0be73e4..5003731 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,37 +1,42 @@ """Fixtures for testing.""" +from collections.abc import Generator from concurrent import futures -from typing import Any, Generator import grpc import pytest from ni.pythonpanel.v1.python_panel_service_pb2_grpc import ( PythonPanelServiceStub, - add_PythonPanelServiceServicer_to_server, ) -from tests.utils._fake_python_panel_servicer import FakePythonPanelServicer +from tests.utils._fake_python_panel_service import FakePythonPanelService @pytest.fixture -def fake_python_panel_service() -> Generator[tuple[FakePythonPanelServicer, int], Any, None]: +def fake_python_panel_service() -> Generator[FakePythonPanelService]: """Fixture to create a FakePythonPanelServicer for testing.""" - thread_pool = futures.ThreadPoolExecutor(max_workers=10) - server = grpc.server(thread_pool) - servicer = FakePythonPanelServicer() - add_PythonPanelServiceServicer_to_server(servicer, server) - port = server.add_insecure_port("[::]:0") - server.start() - yield servicer, port - server.stop(None) + with futures.ThreadPoolExecutor(max_workers=10) as thread_pool: + service = FakePythonPanelService() + service.start(thread_pool) + yield service + service.stop() @pytest.fixture -def fake_python_panel_service_stub( - fake_python_panel_service: tuple[FakePythonPanelServicer, int], -) -> Generator[PythonPanelServiceStub, Any, None]: - """Fixture to attach a PythonPanelSericeStub to a FakePythonPanelService.""" - _, port = fake_python_panel_service - channel = grpc.insecure_channel(f"localhost:{port}") - yield PythonPanelServiceStub(channel) +def grpc_channel_to_fake_panel_service( + fake_python_panel_service: FakePythonPanelService, +) -> Generator[grpc.Channel]: + """Fixture to get a channel to the FakePythonPanelService.""" + service = fake_python_panel_service + channel = grpc.insecure_channel(f"localhost:{service.port}") + yield channel channel.close() + + +@pytest.fixture +def python_panel_service_stub( + grpc_channel_to_fake_panel_service: grpc.Channel, +) -> Generator[PythonPanelServiceStub]: + """Fixture to get a PythonPanelServiceStub, attached to a FakePythonPanelService.""" + channel = grpc_channel_to_fake_panel_service + yield PythonPanelServiceStub(channel) diff --git a/tests/unit/test_fake_python_panel_servicer.py b/tests/unit/test_fake_python_panel_servicer.py index 65e6c72..f7e4171 100644 --- a/tests/unit/test_fake_python_panel_servicer.py +++ b/tests/unit/test_fake_python_panel_servicer.py @@ -1,4 +1,5 @@ from google.protobuf.any_pb2 import Any +from google.protobuf.wrappers_pb2 import StringValue from ni.pythonpanel.v1.python_panel_service_pb2 import ( ConnectRequest, DisconnectRequest, @@ -8,37 +9,38 @@ from ni.pythonpanel.v1.python_panel_service_pb2_grpc import PythonPanelServiceStub -def test___connect___gets_response(fake_python_panel_service_stub: PythonPanelServiceStub) -> None: +def test___connect___gets_response(python_panel_service_stub: PythonPanelServiceStub) -> None: request = ConnectRequest(panel_id="test_panel", panel_uri="path/to/panel") - response = fake_python_panel_service_stub.Connect(request) + response = python_panel_service_stub.Connect(request) assert response is not None # Ensure response is returned def test___disconnect___gets_response( - fake_python_panel_service_stub: PythonPanelServiceStub, + python_panel_service_stub: PythonPanelServiceStub, ) -> None: request = DisconnectRequest(panel_id="test_panel") - response = fake_python_panel_service_stub.Disconnect(request) + response = python_panel_service_stub.Disconnect(request) assert response is not None # Ensure response is returned def test___get_value___gets_response( - fake_python_panel_service_stub: PythonPanelServiceStub, + python_panel_service_stub: PythonPanelServiceStub, ) -> None: request = GetValueRequest(panel_id="test_panel", value_id="test_value") - response = fake_python_panel_service_stub.GetValue(request) + response = python_panel_service_stub.GetValue(request) assert response is not None # Ensure response is returned assert isinstance(response.value, Any) # Ensure the value is of type `Any` def test___set_value___gets_response( - fake_python_panel_service_stub: PythonPanelServiceStub, + python_panel_service_stub: PythonPanelServiceStub, ) -> None: - test_value = Any(value=b"test_data") + test_value = Any() + test_value.Pack(StringValue(value="test_value")) request = SetValueRequest(panel_id="test_panel", value_id="test_value", value=test_value) - response = fake_python_panel_service_stub.SetValue(request) + response = python_panel_service_stub.SetValue(request) assert response is not None # Ensure response is returned diff --git a/tests/unit/test_panel.py b/tests/unit/test_panel.py index 01e1aeb..3c73e80 100644 --- a/tests/unit/test_panel.py +++ b/tests/unit/test_panel.py @@ -1,4 +1,4 @@ -from tests.utils._fake_python_panel_servicer import FakePythonPanelServicer +from tests.utils._fake_python_panel_service import FakePythonPanelService from tests.utils._port_panel import PortPanel @@ -9,10 +9,10 @@ def test___panel___has_panel_id_and_panel_uri() -> None: def test___connected_panel___set_value___gets_same_value( - fake_python_panel_service: tuple[FakePythonPanelServicer, int], + fake_python_panel_service: FakePythonPanelService, ) -> None: - _, port = fake_python_panel_service - panel = PortPanel(port, "my_panel", "path/to/script") + service = fake_python_panel_service + panel = PortPanel(service.port, "my_panel", "path/to/script") panel.connect() panel.set_value("test_id", "test_value") @@ -23,10 +23,10 @@ def test___connected_panel___set_value___gets_same_value( def test___with_panel___set_value___gets_same_value( - fake_python_panel_service: tuple[FakePythonPanelServicer, int], + fake_python_panel_service: FakePythonPanelService, ) -> None: - _, port = fake_python_panel_service - with PortPanel(port, "my_panel", "path/to/script") as panel: + service = fake_python_panel_service + with PortPanel(service.port, "my_panel", "path/to/script") as panel: panel.set_value("test_id", "test_value") # TODO: AB#3095681 - change asserted value to test_value @@ -34,13 +34,13 @@ def test___with_panel___set_value___gets_same_value( def test___first_connect_fails___connect___gets_value( - fake_python_panel_service: tuple[FakePythonPanelServicer, int], + fake_python_panel_service: FakePythonPanelService, ) -> None: """Test that panel.connect() will automatically retry once.""" - servicer, port = fake_python_panel_service + service = fake_python_panel_service # Simulate a failure on the first connect attempt - servicer.fail_next_connect() - panel = PortPanel(port, "my_panel", "path/to/script") + service.servicer.fail_next_connect() + panel = PortPanel(service.port, "my_panel", "path/to/script") panel.connect() diff --git a/tests/utils/__init__.py b/tests/utils/__init__.py new file mode 100644 index 0000000..b2ea535 --- /dev/null +++ b/tests/utils/__init__.py @@ -0,0 +1 @@ +"""Test Utilities for the Panel.""" diff --git a/tests/utils/_fake_python_panel_service.py b/tests/utils/_fake_python_panel_service.py new file mode 100644 index 0000000..43e8f5a --- /dev/null +++ b/tests/utils/_fake_python_panel_service.py @@ -0,0 +1,42 @@ +from concurrent import futures + +import grpc +from ni.pythonpanel.v1.python_panel_service_pb2_grpc import ( + add_PythonPanelServiceServicer_to_server, +) + +from tests.utils._fake_python_panel_servicer import FakePythonPanelServicer + + +class FakePythonPanelService: + """Encapsulates a fake PythonPanelService with a gRPC server for testing.""" + + _server: grpc.Server + _port: int + _servicer: FakePythonPanelServicer + + def __init__(self) -> None: + """Initialize the fake PythonPanelService.""" + self._servicer = FakePythonPanelServicer() + + def start(self, thread_pool: futures.ThreadPoolExecutor) -> None: + """Start the gRPC server and return the port it is bound to.""" + self._server = grpc.server(thread_pool) + add_PythonPanelServiceServicer_to_server(self._servicer, self._server) + self._port = self._server.add_insecure_port("[::1]:0") + self._server.start() + + def stop(self) -> None: + """Stop the gRPC server.""" + if self._server: + self._server.stop(None) + + @property + def servicer(self) -> FakePythonPanelServicer: + """Get the servicer instance.""" + return self._servicer + + @property + def port(self) -> int: + """Get the port the server is bound to.""" + return self._port From 13cc155f7193f83408c0fd026cbbd617aa5d6c1b Mon Sep 17 00:00:00 2001 From: Mike Prosser Date: Fri, 25 Apr 2025 15:33:38 -0500 Subject: [PATCH 14/17] PanelClient cleanup --- src/nipanel/_panel_client.py | 31 +++++++++++++++++++------------ 1 file changed, 19 insertions(+), 12 deletions(-) diff --git a/src/nipanel/_panel_client.py b/src/nipanel/_panel_client.py index 2dae5f0..bb8b9da 100644 --- a/src/nipanel/_panel_client.py +++ b/src/nipanel/_panel_client.py @@ -4,13 +4,14 @@ import logging import threading -from typing import Any, Callable +from typing import Callable import grpc from ni.pythonpanel.v1.python_panel_service_pb2 import ConnectRequest, DisconnectRequest from ni.pythonpanel.v1.python_panel_service_pb2_grpc import PythonPanelServiceStub from ni_measurement_plugin_sdk_service.discovery import DiscoveryClient from ni_measurement_plugin_sdk_service.grpc.channelpool import GrpcChannelPool +from typing_extensions import ParamSpec, TypeVar _logger = logging.getLogger(__name__) @@ -43,6 +44,16 @@ def __init__( if grpc_channel is not None: self._stub = PythonPanelServiceStub(grpc_channel) + def connect(self, panel_id: str, panel_uri: str) -> None: + """Connect to the panel and open it.""" + connect_request = ConnectRequest(panel_id=panel_id, panel_uri=panel_uri) + self._invoke_with_retry(self._get_stub().Connect, connect_request) + + def disconnect(self, panel_id: str) -> None: + """Disconnect from the panel (does not close the panel).""" + disconnect_request = DisconnectRequest(panel_id=panel_id) + self._invoke_with_retry(self._get_stub().Disconnect, disconnect_request) + def _get_stub(self) -> PythonPanelServiceStub: if self._stub is None: with self._initialization_lock: @@ -60,7 +71,12 @@ def _get_stub(self) -> PythonPanelServiceStub: self._stub = PythonPanelServiceStub(channel) return self._stub - def _invoke_with_retry(self, method: Callable[..., Any], *args: Any, **kwargs: Any) -> Any: + _T = TypeVar("_T") + _P = ParamSpec("_P") + + def _invoke_with_retry( + self, method: Callable[_P, _T], *args: _P.args, **kwargs: _P.kwargs + ) -> _T: """Invoke a gRPC method with retry logic.""" try: return method(*args, **kwargs) @@ -69,13 +85,4 @@ def _invoke_with_retry(self, method: Callable[..., Any], *args: Any, **kwargs: A # if the service is unavailable, we can retry the connection self._stub = None return method(*args, **kwargs) - - def connect(self, panel_id: str, panel_uri: str) -> None: - """Connect to the panel and open it.""" - connect_request = ConnectRequest(panel_id=panel_id, panel_uri=panel_uri) - self._invoke_with_retry(self._get_stub().Connect, connect_request) - - def disconnect(self, panel_id: str) -> None: - """Disconnect from the panel (does not close the panel).""" - disconnect_request = DisconnectRequest(panel_id=panel_id) - self._invoke_with_retry(self._get_stub().Disconnect, disconnect_request) + raise From 2e1aeb218520db1bf5a16fb285c7f4c4e6627cde Mon Sep 17 00:00:00 2001 From: Mike Prosser Date: Fri, 25 Apr 2025 16:21:18 -0500 Subject: [PATCH 15/17] use channel parameter for testing instead of PortPanel, and move provided_interface and service_class to Panel. --- src/nipanel/_panel.py | 25 +++++++---- src/nipanel/_panel_client.py | 41 +++++++++++-------- src/nipanel/_streamlit_panel.py | 21 ++++++---- tests/conftest.py | 10 ++--- ...r.py => test_python_panel_service_stub.py} | 0 ...{test_panel.py => test_streamlit_panel.py} | 24 ++++++----- tests/utils/_port_panel.py | 24 ----------- 7 files changed, 71 insertions(+), 74 deletions(-) rename tests/unit/{test_fake_python_panel_servicer.py => test_python_panel_service_stub.py} (100%) rename tests/unit/{test_panel.py => test_streamlit_panel.py} (60%) delete mode 100644 tests/utils/_port_panel.py diff --git a/src/nipanel/_panel.py b/src/nipanel/_panel.py index 91cd863..f0a09c4 100644 --- a/src/nipanel/_panel.py +++ b/src/nipanel/_panel.py @@ -1,11 +1,11 @@ from __future__ import annotations import sys -from abc import ABC, abstractmethod +from abc import ABC from types import TracebackType from typing import TYPE_CHECKING -from ni_measurement_plugin_sdk_service.discovery import DiscoveryClient +import grpc from nipanel._panel_client import PanelClient @@ -25,9 +25,21 @@ class Panel(ABC): __slots__ = ["_panel_client", "_panel_id", "_panel_uri", "__weakref__"] - def __init__(self, panel_id: str, panel_uri: str) -> None: + def __init__( + self, + *, + panel_id: str, + panel_uri: str, + provided_interface: str, + service_class: str, + grpc_channel: grpc.Channel | None = None, + ) -> None: """Initialize the panel.""" - self._panel_client = PanelClient(self._resolve_service_address) + self._panel_client = PanelClient( + provided_interface=provided_interface, + service_class=service_class, + grpc_channel=grpc_channel, + ) self._panel_id = panel_id self._panel_uri = panel_uri @@ -85,8 +97,3 @@ def set_value(self, value_id: str, value: object) -> None: """ # TODO: AB#3095681 - Convert the value to an Any and pass it to _client.set_value pass - - @abstractmethod - def _resolve_service_address(self, discovery_client: DiscoveryClient) -> str: - """Resolve the service address for the panel.""" - raise NotImplementedError diff --git a/src/nipanel/_panel_client.py b/src/nipanel/_panel_client.py index bb8b9da..8f62138 100644 --- a/src/nipanel/_panel_client.py +++ b/src/nipanel/_panel_client.py @@ -21,8 +21,9 @@ class PanelClient: def __init__( self, - resolve_service_address_fn: Callable[[DiscoveryClient], str], *, + provided_interface: str, + service_class: str, discovery_client: DiscoveryClient | None = None, grpc_channel: grpc.Channel | None = None, grpc_channel_pool: GrpcChannelPool | None = None, @@ -30,20 +31,20 @@ def __init__( """Initialize the panel client. Args: - resolve_service_address_fn: A function to resolve the service location. + provided_interface: The interface provided by the service. + service_class: The class of the service. discovery_client: An optional discovery client. grpc_channel: An optional panel gRPC channel. grpc_channel_pool: An optional gRPC channel pool. """ self._initialization_lock = threading.Lock() - self._resolve_service_address_fn = resolve_service_address_fn + self._provided_interface = provided_interface + self._service_class = service_class self._discovery_client = discovery_client self._grpc_channel_pool = grpc_channel_pool + self._grpc_channel = grpc_channel self._stub: PythonPanelServiceStub | None = None - if grpc_channel is not None: - self._stub = PythonPanelServiceStub(grpc_channel) - def connect(self, panel_id: str, panel_uri: str) -> None: """Connect to the panel and open it.""" connect_request = ConnectRequest(panel_id=panel_id, panel_uri=panel_uri) @@ -56,18 +57,24 @@ def disconnect(self, panel_id: str) -> None: def _get_stub(self) -> PythonPanelServiceStub: if self._stub is None: - with self._initialization_lock: - if self._grpc_channel_pool is None: - _logger.debug("Creating unshared GrpcChannelPool.") - self._grpc_channel_pool = GrpcChannelPool() - if self._discovery_client is None: - _logger.debug("Creating unshared DiscoveryClient.") - self._discovery_client = DiscoveryClient( - grpc_channel_pool=self._grpc_channel_pool + if self._grpc_channel is not None: + self._stub = PythonPanelServiceStub(self._grpc_channel) + else: + with self._initialization_lock: + if self._grpc_channel_pool is None: + _logger.debug("Creating unshared GrpcChannelPool.") + self._grpc_channel_pool = GrpcChannelPool() + if self._discovery_client is None: + _logger.debug("Creating unshared DiscoveryClient.") + self._discovery_client = DiscoveryClient( + grpc_channel_pool=self._grpc_channel_pool + ) + + service_location = self._discovery_client.resolve_service( + provided_interface=self._provided_interface, + service_class=self._service_class, ) - if self._stub is None: - service_address = self._resolve_service_address_fn(self._discovery_client) - channel = self._grpc_channel_pool.get_channel(service_address) + channel = self._grpc_channel_pool.get_channel(service_location.insecure_address) self._stub = PythonPanelServiceStub(channel) return self._stub diff --git a/src/nipanel/_streamlit_panel.py b/src/nipanel/_streamlit_panel.py index ea78017..291ef4b 100644 --- a/src/nipanel/_streamlit_panel.py +++ b/src/nipanel/_streamlit_panel.py @@ -1,4 +1,6 @@ -from ni_measurement_plugin_sdk_service.discovery import DiscoveryClient +from __future__ import annotations + +import grpc from nipanel._panel import Panel @@ -10,20 +12,23 @@ class StreamlitPanel(Panel): __slots__ = () - def __init__(self, panel_id: str, streamlit_script_uri: str) -> None: + def __init__( + self, panel_id: str, streamlit_script_uri: str, *, grpc_channel: grpc.Channel | None = None + ) -> None: """Create a panel using a Streamlit script for the user interface. Args: panel_id: A unique identifier for the panel. streamlit_script_uri: The file path of the Streamlit script. + grpc_channel: An optional gRPC channel to use for communication with the panel service. Returns: A new StreamlitPanel instance. """ - super().__init__(panel_id, streamlit_script_uri) - - def _resolve_service_address(self, discovery_client: DiscoveryClient) -> str: - service_location = discovery_client.resolve_service( - provided_interface=self.PYTHON_PANEL_SERVICE, service_class=self.PYTHON_PANEL_SERVICE + super().__init__( + panel_id=panel_id, + panel_uri=streamlit_script_uri, + provided_interface=self.PYTHON_PANEL_SERVICE, + service_class=self.PYTHON_PANEL_SERVICE, + grpc_channel=grpc_channel, ) - return service_location.insecure_address diff --git a/tests/conftest.py b/tests/conftest.py index 5003731..bb365ee 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -23,20 +23,20 @@ def fake_python_panel_service() -> Generator[FakePythonPanelService]: @pytest.fixture -def grpc_channel_to_fake_panel_service( +def grpc_channel_and_fake_panel_service( fake_python_panel_service: FakePythonPanelService, -) -> Generator[grpc.Channel]: +) -> Generator[tuple[grpc.Channel, FakePythonPanelService]]: """Fixture to get a channel to the FakePythonPanelService.""" service = fake_python_panel_service channel = grpc.insecure_channel(f"localhost:{service.port}") - yield channel + yield channel, service channel.close() @pytest.fixture def python_panel_service_stub( - grpc_channel_to_fake_panel_service: grpc.Channel, + grpc_channel_and_fake_panel_service: tuple[grpc.Channel, FakePythonPanelService], ) -> Generator[PythonPanelServiceStub]: """Fixture to get a PythonPanelServiceStub, attached to a FakePythonPanelService.""" - channel = grpc_channel_to_fake_panel_service + channel, _ = grpc_channel_and_fake_panel_service yield PythonPanelServiceStub(channel) diff --git a/tests/unit/test_fake_python_panel_servicer.py b/tests/unit/test_python_panel_service_stub.py similarity index 100% rename from tests/unit/test_fake_python_panel_servicer.py rename to tests/unit/test_python_panel_service_stub.py diff --git a/tests/unit/test_panel.py b/tests/unit/test_streamlit_panel.py similarity index 60% rename from tests/unit/test_panel.py rename to tests/unit/test_streamlit_panel.py index 3c73e80..6ba84bc 100644 --- a/tests/unit/test_panel.py +++ b/tests/unit/test_streamlit_panel.py @@ -1,18 +1,20 @@ +import grpc + +from nipanel._streamlit_panel import StreamlitPanel from tests.utils._fake_python_panel_service import FakePythonPanelService -from tests.utils._port_panel import PortPanel def test___panel___has_panel_id_and_panel_uri() -> None: - panel = PortPanel(0, "my_panel", "path/to/script") + panel = StreamlitPanel("my_panel", "path/to/script") assert panel.panel_id == "my_panel" assert panel.panel_uri == "path/to/script" def test___connected_panel___set_value___gets_same_value( - fake_python_panel_service: FakePythonPanelService, + grpc_channel_and_fake_panel_service: tuple[grpc.Channel, FakePythonPanelService], ) -> None: - service = fake_python_panel_service - panel = PortPanel(service.port, "my_panel", "path/to/script") + channel, _ = grpc_channel_and_fake_panel_service + panel = StreamlitPanel("my_panel", "path/to/script", grpc_channel=channel) panel.connect() panel.set_value("test_id", "test_value") @@ -23,10 +25,10 @@ def test___connected_panel___set_value___gets_same_value( def test___with_panel___set_value___gets_same_value( - fake_python_panel_service: FakePythonPanelService, + grpc_channel_and_fake_panel_service: tuple[grpc.Channel, FakePythonPanelService], ) -> None: - service = fake_python_panel_service - with PortPanel(service.port, "my_panel", "path/to/script") as panel: + channel, _ = grpc_channel_and_fake_panel_service + with StreamlitPanel("my_panel", "path/to/script", grpc_channel=channel) as panel: panel.set_value("test_id", "test_value") # TODO: AB#3095681 - change asserted value to test_value @@ -34,13 +36,13 @@ def test___with_panel___set_value___gets_same_value( def test___first_connect_fails___connect___gets_value( - fake_python_panel_service: FakePythonPanelService, + grpc_channel_and_fake_panel_service: tuple[grpc.Channel, FakePythonPanelService], ) -> None: """Test that panel.connect() will automatically retry once.""" - service = fake_python_panel_service + channel, service = grpc_channel_and_fake_panel_service # Simulate a failure on the first connect attempt service.servicer.fail_next_connect() - panel = PortPanel(service.port, "my_panel", "path/to/script") + panel = StreamlitPanel("my_panel", "path/to/script", grpc_channel=channel) panel.connect() diff --git a/tests/utils/_port_panel.py b/tests/utils/_port_panel.py deleted file mode 100644 index 46093ab..0000000 --- a/tests/utils/_port_panel.py +++ /dev/null @@ -1,24 +0,0 @@ -from ni_measurement_plugin_sdk_service.discovery import DiscoveryClient - -from nipanel._panel import Panel - - -class PortPanel(Panel): - """This class allows you to connect to the PythonPanelService with a specified port.""" - - def __init__(self, port: int, panel_id: str, panel_uri: str) -> None: - """Create a panel and connect to a specified port. - - Args: - port: The port number for the gRPC server. - panel_id: A unique identifier for the panel. - panel_uri: The file path of the panel script. - - Returns: - A new PortPanel instance. - """ - super().__init__(panel_id, panel_uri) - self.port = port - - def _resolve_service_address(self, discovery_client: DiscoveryClient) -> str: - return f"localhost:{self.port}" From 5b0cf66803a108e7fc6548cbead118af3d0f2db7 Mon Sep 17 00:00:00 2001 From: Mike Prosser Date: Fri, 25 Apr 2025 21:22:40 -0500 Subject: [PATCH 16/17] misc feedback --- poetry.lock | 27 +-------------------------- pyproject.toml | 1 - src/nipanel/_panel.py | 6 ++++++ src/nipanel/_panel_client.py | 16 ++++++++++------ src/nipanel/_streamlit_panel.py | 12 +++++++++++- 5 files changed, 28 insertions(+), 34 deletions(-) diff --git a/poetry.lock b/poetry.lock index 6b9e391..c3404f9 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1174,20 +1174,6 @@ files = [ {file = "python_decouple-3.8-py3-none-any.whl", hash = "sha256:d0d45340815b25f4de59c974b855bb38d03151d81b037d9e3f463b0c9f8cbd66"}, ] -[[package]] -name = "pyupgrade" -version = "3.19.1" -description = "A tool to automatically upgrade syntax for newer versions." -optional = false -python-versions = ">=3.9" -files = [ - {file = "pyupgrade-3.19.1-py2.py3-none-any.whl", hash = "sha256:8c5b0bfacae5ff30fa136a53eb7f22c34ba007450d4099e9da8089dabb9e67c9"}, - {file = "pyupgrade-3.19.1.tar.gz", hash = "sha256:d10e8c5f54b8327211828769e98d95d95e4715de632a3414f1eef3f51357b9e2"}, -] - -[package.dependencies] -tokenize-rt = ">=6.1.0" - [[package]] name = "pywin32" version = "310" @@ -1339,17 +1325,6 @@ files = [ [package.dependencies] pbr = ">=2.0.0" -[[package]] -name = "tokenize-rt" -version = "6.1.0" -description = "A wrapper around the stdlib `tokenize` which roundtrips." -optional = false -python-versions = ">=3.9" -files = [ - {file = "tokenize_rt-6.1.0-py2.py3-none-any.whl", hash = "sha256:d706141cdec4aa5f358945abe36b911b8cbdc844545da99e811250c0cee9b6fc"}, - {file = "tokenize_rt-6.1.0.tar.gz", hash = "sha256:e8ee836616c0877ab7c7b54776d2fefcc3bde714449a206762425ae114b53c86"}, -] - [[package]] name = "toml" version = "0.10.2" @@ -1437,4 +1412,4 @@ files = [ [metadata] lock-version = "2.0" python-versions = "^3.9" -content-hash = "0d4eda35e32c3ecd2e11d879f6afe5408d9fdd4defa8c9e02a92ee05a7971dea" +content-hash = "681a64f5d10bd10d379d6dcfd3984a20e57bae9ecd298965676528cf5258d81c" diff --git a/pyproject.toml b/pyproject.toml index 4b67be8..88c7ca5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,7 +15,6 @@ ni-measurement-plugin-sdk = {version=">=2.3"} [tool.poetry.group.dev.dependencies] grpc-stubs = "^1.53" types-protobuf = ">=4.21" -pyupgrade = "^3.19.1" [tool.poetry.group.lint.dependencies] bandit = { version = ">=1.7", extras = ["toml"] } diff --git a/src/nipanel/_panel.py b/src/nipanel/_panel.py index f0a09c4..91a1c46 100644 --- a/src/nipanel/_panel.py +++ b/src/nipanel/_panel.py @@ -6,6 +6,8 @@ from typing import TYPE_CHECKING import grpc +from ni_measurement_plugin_sdk_service.discovery import DiscoveryClient +from ni_measurement_plugin_sdk_service.grpc.channelpool import GrpcChannelPool from nipanel._panel_client import PanelClient @@ -32,12 +34,16 @@ def __init__( panel_uri: str, provided_interface: str, service_class: str, + discovery_client: DiscoveryClient | None = None, + grpc_channel_pool: GrpcChannelPool | None = None, grpc_channel: grpc.Channel | None = None, ) -> None: """Initialize the panel.""" self._panel_client = PanelClient( provided_interface=provided_interface, service_class=service_class, + discovery_client=discovery_client, + grpc_channel_pool=grpc_channel_pool, grpc_channel=grpc_channel, ) self._panel_id = panel_id diff --git a/src/nipanel/_panel_client.py b/src/nipanel/_panel_client.py index 8f62138..4b97440 100644 --- a/src/nipanel/_panel_client.py +++ b/src/nipanel/_panel_client.py @@ -4,14 +4,21 @@ import logging import threading -from typing import Callable +from typing import TYPE_CHECKING, Callable, TypeVar import grpc from ni.pythonpanel.v1.python_panel_service_pb2 import ConnectRequest, DisconnectRequest from ni.pythonpanel.v1.python_panel_service_pb2_grpc import PythonPanelServiceStub from ni_measurement_plugin_sdk_service.discovery import DiscoveryClient from ni_measurement_plugin_sdk_service.grpc.channelpool import GrpcChannelPool -from typing_extensions import ParamSpec, TypeVar + +_T = TypeVar("_T") + +if TYPE_CHECKING: + from typing_extensions import ParamSpec + + _P = ParamSpec("_P") + _logger = logging.getLogger(__name__) @@ -25,8 +32,8 @@ def __init__( provided_interface: str, service_class: str, discovery_client: DiscoveryClient | None = None, - grpc_channel: grpc.Channel | None = None, grpc_channel_pool: GrpcChannelPool | None = None, + grpc_channel: grpc.Channel | None = None, ) -> None: """Initialize the panel client. @@ -78,9 +85,6 @@ def _get_stub(self) -> PythonPanelServiceStub: self._stub = PythonPanelServiceStub(channel) return self._stub - _T = TypeVar("_T") - _P = ParamSpec("_P") - def _invoke_with_retry( self, method: Callable[_P, _T], *args: _P.args, **kwargs: _P.kwargs ) -> _T: diff --git a/src/nipanel/_streamlit_panel.py b/src/nipanel/_streamlit_panel.py index 291ef4b..aac55c9 100644 --- a/src/nipanel/_streamlit_panel.py +++ b/src/nipanel/_streamlit_panel.py @@ -1,6 +1,8 @@ from __future__ import annotations import grpc +from ni_measurement_plugin_sdk_service.discovery import DiscoveryClient +from ni_measurement_plugin_sdk_service.grpc.channelpool import GrpcChannelPool from nipanel._panel import Panel @@ -13,7 +15,13 @@ class StreamlitPanel(Panel): __slots__ = () def __init__( - self, panel_id: str, streamlit_script_uri: str, *, grpc_channel: grpc.Channel | None = None + self, + panel_id: str, + streamlit_script_uri: str, + *, + discovery_client: DiscoveryClient | None = None, + grpc_channel_pool: GrpcChannelPool | None = None, + grpc_channel: grpc.Channel | None = None, ) -> None: """Create a panel using a Streamlit script for the user interface. @@ -30,5 +38,7 @@ def __init__( panel_uri=streamlit_script_uri, provided_interface=self.PYTHON_PANEL_SERVICE, service_class=self.PYTHON_PANEL_SERVICE, + discovery_client=discovery_client, + grpc_channel_pool=grpc_channel_pool, grpc_channel=grpc_channel, ) From 5f873b7fc2dbda803d8bcae628f922daaa34ee40 Mon Sep 17 00:00:00 2001 From: Mike Prosser Date: Fri, 25 Apr 2025 21:34:54 -0500 Subject: [PATCH 17/17] don't return tuples from fixtures --- tests/conftest.py | 10 +++++----- tests/unit/test_streamlit_panel.py | 14 ++++++++------ 2 files changed, 13 insertions(+), 11 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index bb365ee..580df51 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -23,20 +23,20 @@ def fake_python_panel_service() -> Generator[FakePythonPanelService]: @pytest.fixture -def grpc_channel_and_fake_panel_service( +def grpc_channel_for_fake_panel_service( fake_python_panel_service: FakePythonPanelService, -) -> Generator[tuple[grpc.Channel, FakePythonPanelService]]: +) -> Generator[grpc.Channel]: """Fixture to get a channel to the FakePythonPanelService.""" service = fake_python_panel_service channel = grpc.insecure_channel(f"localhost:{service.port}") - yield channel, service + yield channel channel.close() @pytest.fixture def python_panel_service_stub( - grpc_channel_and_fake_panel_service: tuple[grpc.Channel, FakePythonPanelService], + grpc_channel_for_fake_panel_service: grpc.Channel, ) -> Generator[PythonPanelServiceStub]: """Fixture to get a PythonPanelServiceStub, attached to a FakePythonPanelService.""" - channel, _ = grpc_channel_and_fake_panel_service + channel = grpc_channel_for_fake_panel_service yield PythonPanelServiceStub(channel) diff --git a/tests/unit/test_streamlit_panel.py b/tests/unit/test_streamlit_panel.py index 6ba84bc..723da17 100644 --- a/tests/unit/test_streamlit_panel.py +++ b/tests/unit/test_streamlit_panel.py @@ -11,9 +11,9 @@ def test___panel___has_panel_id_and_panel_uri() -> None: def test___connected_panel___set_value___gets_same_value( - grpc_channel_and_fake_panel_service: tuple[grpc.Channel, FakePythonPanelService], + grpc_channel_for_fake_panel_service: grpc.Channel, ) -> None: - channel, _ = grpc_channel_and_fake_panel_service + channel = grpc_channel_for_fake_panel_service panel = StreamlitPanel("my_panel", "path/to/script", grpc_channel=channel) panel.connect() @@ -25,9 +25,9 @@ def test___connected_panel___set_value___gets_same_value( def test___with_panel___set_value___gets_same_value( - grpc_channel_and_fake_panel_service: tuple[grpc.Channel, FakePythonPanelService], + grpc_channel_for_fake_panel_service: grpc.Channel, ) -> None: - channel, _ = grpc_channel_and_fake_panel_service + channel = grpc_channel_for_fake_panel_service with StreamlitPanel("my_panel", "path/to/script", grpc_channel=channel) as panel: panel.set_value("test_id", "test_value") @@ -36,10 +36,12 @@ def test___with_panel___set_value___gets_same_value( def test___first_connect_fails___connect___gets_value( - grpc_channel_and_fake_panel_service: tuple[grpc.Channel, FakePythonPanelService], + fake_python_panel_service: FakePythonPanelService, + grpc_channel_for_fake_panel_service: grpc.Channel, ) -> None: """Test that panel.connect() will automatically retry once.""" - channel, service = grpc_channel_and_fake_panel_service + channel = grpc_channel_for_fake_panel_service + service = fake_python_panel_service # Simulate a failure on the first connect attempt service.servicer.fail_next_connect() panel = StreamlitPanel("my_panel", "path/to/script", grpc_channel=channel)