Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 8 additions & 9 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,15 +56,14 @@ start docs\_build\index.html

## Running examples

1. First, run the PythonPanelService (not part of this repo, provided seperately)
2. Run the command `poetry run python examples/hello/hello.py`
3. Open http://localhost:42001/panel-service/panels/hello_panel/ in your browser
4. If there is an error about missing imports (especially nipanel), execute this
command (from the nipanel-python directory) to install the dependencies into the venv:
`%localappdata%\Temp\python_panel_service_venv\Scripts\python.exe -m pip install .\[examples,dev]`,
then restart the PythonPanelService and re-run hello.py.

You can see all running panels (and stop them) at: http://localhost:42001/panel-service/
1. Run the **PythonPanelService** (not part of this repo, provided seperately)
2. `poetry install --with examples` to get the dependencies needed for the examples
3. Run the examples with these command(s):
- `poetry run python examples/hello/hello.py`
- `poetry run python examples/all_types/all_types.py`
- `poetry run python examples/simple_graph/simple_graph.py`
- `poetry run python examples/nidaqmx/nidaqmx_continuous_analog_input.py` (requires real or simulated devices)
4. Open http://localhost:42001/panel-service/ in your browser, which will show all running panels

# Debugging on the streamlit side

Expand Down
2 changes: 1 addition & 1 deletion examples/all_types/all_types_panel.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,11 @@

import nipanel

panel = nipanel.get_panel_accessor()

st.set_page_config(page_title="All Types Example", page_icon="📊", layout="wide")
st.title("All Types Example")

panel = nipanel.get_panel_accessor()
for name in all_types_with_values.keys():
col1, col2 = st.columns([0.4, 0.6])

Expand Down
3 changes: 2 additions & 1 deletion examples/hello/hello_panel.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@

import nipanel

panel = nipanel.get_panel_accessor()

st.set_page_config(page_title="Hello World Example", page_icon="📊", layout="wide")
st.title("Hello World Example")

panel = nipanel.get_panel_accessor()
st.write(panel.get_value("hello_string", ""))
8 changes: 6 additions & 2 deletions examples/nidaqmx/nidaqmx_continuous_analog_input.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
panel_script_path = Path(__file__).with_name("nidaqmx_continuous_analog_input_panel.py")
panel = nipanel.create_panel(panel_script_path)

# How to use nidaqmx: https://nidaqmx-python.readthedocs.io/en/stable/
with nidaqmx.Task() as task:
task.ai_channels.add_ai_voltage_chan("Dev1/ai0")
task.ai_channels.add_ai_thrmcpl_chan("Dev1/ai1")
Expand All @@ -18,10 +19,13 @@
)
panel.set_value("sample_rate", task._timing.samp_clk_rate)
task.start()
print(f"Panel URL: {panel.panel_url}")
try:
print(f"\nPress Ctrl + C to stop")
print(f"Press Ctrl + C to stop")
while True:
data = task.read(number_of_samples_per_channel=1000)
data = task.read(
number_of_samples_per_channel=1000 # pyright: ignore[reportArgumentType]
)
panel.set_value("voltage_data", data[0])
panel.set_value("thermocouple_data", data[1])
except KeyboardInterrupt:
Expand Down
8 changes: 5 additions & 3 deletions examples/nidaqmx/nidaqmx_continuous_analog_input_panel.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@

import nipanel

panel = nipanel.get_panel_accessor()

st.set_page_config(page_title="NI-DAQmx Example", page_icon="📈", layout="wide")
st.title("Analog Input - Voltage and Thermocouple in a Single Task")
voltage_tab, thermocouple_tab = st.tabs(["Voltage", "Thermocouple"])

Expand All @@ -21,13 +21,15 @@
unsafe_allow_html=True,
)

panel = nipanel.get_panel_accessor()
thermocouple_data = panel.get_value("thermocouple_data", [0.0])
voltage_data = panel.get_value("voltage_data", [0.0])

sample_rate = panel.get_value("sample_rate", 0)
sample_rate = panel.get_value("sample_rate", 0.0)

st.header("Voltage & Thermocouple")
voltage_therm_graph = {
"animation": False,
"tooltip": {"trigger": "axis"},
"legend": {"data": ["Voltage (V)", "Temperature (C)"]},
"xAxis": {
Expand Down Expand Up @@ -64,7 +66,7 @@
},
],
}
st_echarts(options=voltage_therm_graph, height="400px")
st_echarts(options=voltage_therm_graph, height="400px", key="voltage_therm_graph")

voltage_tab.header("Voltage")
with voltage_tab:
Expand Down
2 changes: 1 addition & 1 deletion examples/simple_graph/simple_graph_panel.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,11 @@

import nipanel

panel = nipanel.get_panel_accessor()

st.set_page_config(page_title="Simple Graph Example", page_icon="📈", layout="wide")
st.title("Simple Graph Example")

panel = nipanel.get_panel_accessor()
time_points = panel.get_value("time_points", [0.0])
sine_values = panel.get_value("sine_values", [0.0])
amplitude = panel.get_value("amplitude", 1.0)
Expand Down
335 changes: 165 additions & 170 deletions poetry.lock

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions protos/ni/pythonpanel/v1/python_panel_service.proto
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,9 @@ message StartPanelRequest {

// Absolute path of the panel script's file on disk, or network path to the file
string panel_script_path = 2;

// Path to the python interpreter to use for the panel script.
string python_path = 3;
}

message StartPanelResponse {
Expand Down
5 changes: 4 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,11 @@ protobuf = {version=">=4.21"}
ni-measurement-plugin-sdk = {version=">=2.3"}
typing-extensions = ">=4.13.2"
streamlit = ">=1.24"
streamlit-echarts = ">=0.4.0"
nitypes = {version=">=0.1.0dev2", allow-prereleases=true}
numpy = [
{version=">=1.20.0,<2.0.0", python=">=3.9,<3.10"},
{version=">=2.0.0", python=">=3.10"}
]
debugpy = ">=1.8.1"

[tool.poetry.group.dev.dependencies]
Expand Down
48 changes: 24 additions & 24 deletions src/ni/pythonpanel/v1/python_panel_service_pb2.py

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 5 additions & 1 deletion src/ni/pythonpanel/v1/python_panel_service_pb2.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -19,17 +19,21 @@ class StartPanelRequest(google.protobuf.message.Message):

PANEL_ID_FIELD_NUMBER: builtins.int
PANEL_SCRIPT_PATH_FIELD_NUMBER: builtins.int
PYTHON_PATH_FIELD_NUMBER: builtins.int
panel_id: builtins.str
"""Unique ID of the panel"""
panel_script_path: builtins.str
"""Absolute path of the panel script's file on disk, or network path to the file"""
python_path: builtins.str
"""Path to the python interpreter to use for the panel script."""
def __init__(
self,
*,
panel_id: builtins.str = ...,
panel_script_path: builtins.str = ...,
python_path: builtins.str = ...,
) -> None: ...
def ClearField(self, field_name: typing.Literal["panel_id", b"panel_id", "panel_script_path", b"panel_script_path"]) -> None: ...
def ClearField(self, field_name: typing.Literal["panel_id", b"panel_id", "panel_script_path", b"panel_script_path", "python_path", b"python_path"]) -> None: ...

global___StartPanelRequest = StartPanelRequest

Expand Down
40 changes: 39 additions & 1 deletion src/nipanel/_panel.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
from __future__ import annotations

import sys
from abc import ABC
from pathlib import Path

import grpc
from ni_measurement_plugin_sdk_service.discovery import DiscoveryClient
Expand Down Expand Up @@ -35,7 +37,8 @@ def __init__(
grpc_channel=grpc_channel,
)
self._panel_script_path = panel_script_path
self._panel_url = self._panel_client.start_panel(panel_id, panel_script_path)
python_path = self._get_python_path()
self._panel_url = self._panel_client.start_panel(panel_id, panel_script_path, python_path)

@property
def panel_script_path(self) -> str:
Expand All @@ -46,3 +49,38 @@ def panel_script_path(self) -> str:
def panel_url(self) -> str:
"""Read-only accessor for the panel URL."""
return self._panel_url

def _get_python_path(self) -> str:
"""Get the Python interpreter path for the panel that ensures the same environment."""
if sys.executable is None or sys.executable == "":
raise RuntimeError("Python environment not found")
if getattr(sys, "frozen", False):
raise RuntimeError("Panel cannot be used in a frozen application (e.g., PyInstaller).")

if sys.prefix != sys.base_prefix:
# If we're in a virtual environment, build the path to the Python executable
# On Linux: .venv/bin/python, On Windows: .venv\Scripts\python.exe
if sys.platform.startswith("win"):
python_executable = "python.exe"
bin_dir = "Scripts"
else:
python_executable = "python"
bin_dir = "bin"

# Construct path to the Python in the virtual environment based on sys.prefix
python_path = str(Path(sys.prefix) / bin_dir / python_executable)

# Fall back to sys.executable if the constructed path doesn't exist
if not Path(python_path).exists():
python_path = str(Path(sys.executable).resolve())
else:
# If not in a .venv environment, use sys.executable
python_path = str(Path(sys.executable).resolve())

if sys.prefix not in python_path:
# Ensure the Python path is within the current environment
raise RuntimeError(
f"Python path '{python_path}' does not match the current environment prefix '{sys.prefix}'."
)

return python_path
5 changes: 3 additions & 2 deletions src/nipanel/_panel_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,18 +59,19 @@ def __init__(
self._grpc_channel = grpc_channel
self._stub: PythonPanelServiceStub | None = None

def start_panel(self, panel_id: str, panel_script_path: str) -> str:
def start_panel(self, panel_id: str, panel_script_path: str, python_path: str) -> str:
"""Start the panel.

Args:
panel_id: The ID of the panel to start.
panel_script_path: The path of the panel script file.
python_path: The path to the Python executable.

Returns:
The URL of the panel.
"""
start_panel_request = StartPanelRequest(
panel_id=panel_id, panel_script_path=panel_script_path
panel_id=panel_id, panel_script_path=panel_script_path, python_path=python_path
)
response = self._invoke_with_retry(self._get_stub().StartPanel, start_panel_request)
return response.panel_url
Expand Down
12 changes: 6 additions & 6 deletions tests/unit/test_panel_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@ def test___enumerate_is_empty(fake_panel_channel: grpc.Channel) -> None:
def test___start_panels___enumerate_has_panels(fake_panel_channel: grpc.Channel) -> None:
client = create_panel_client(fake_panel_channel)

client.start_panel("panel1", "uri1")
client.start_panel("panel2", "uri2")
client.start_panel("panel1", "uri1", "python.exe")
client.start_panel("panel2", "uri2", "python.exe")

assert client.enumerate_panels() == {
"panel1": ("http://localhost:50051/panel1", []),
Expand All @@ -26,8 +26,8 @@ def test___start_panels___stop_panel_1_with_reset___enumerate_has_panel_2(
fake_panel_channel: grpc.Channel,
) -> None:
client = create_panel_client(fake_panel_channel)
client.start_panel("panel1", "uri1")
client.start_panel("panel2", "uri2")
client.start_panel("panel1", "uri1", "python.exe")
client.start_panel("panel2", "uri2", "python.exe")

client.stop_panel("panel1", reset=True)

Expand All @@ -40,8 +40,8 @@ def test___start_panels___stop_panel_1_without_reset___enumerate_has_both_panels
fake_panel_channel: grpc.Channel,
) -> None:
client = create_panel_client(fake_panel_channel)
client.start_panel("panel1", "uri1")
client.start_panel("panel2", "uri2")
client.start_panel("panel1", "uri1", "python.exe")
client.start_panel("panel2", "uri2", "python.exe")

client.stop_panel("panel1", reset=False)

Expand Down
9 changes: 9 additions & 0 deletions tests/unit/test_streamlit_panel.py
Original file line number Diff line number Diff line change
Expand Up @@ -350,6 +350,15 @@ def test___panel___panel_is_running_and_in_memory(
assert is_panel_running(panel)


def test___panel___python_path_is_in_venv(
fake_python_panel_service: FakePythonPanelService,
fake_panel_channel: grpc.Channel,
) -> None:
StreamlitPanel("my_panel", "path/to/script", grpc_channel=fake_panel_channel)

assert ".venv" in fake_python_panel_service.servicer.python_path


def is_panel_in_memory(panel: StreamlitPanel) -> bool:
return panel.panel_id in panel._panel_client.enumerate_panels().keys()

Expand Down
7 changes: 7 additions & 0 deletions tests/utils/_fake_python_panel_servicer.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,11 +27,13 @@ def __init__(self) -> None:
self._panel_value_ids: dict[str, dict[str, Any]] = {}
self._fail_next_start_panel = False
self._notification_count: int = 0
self._python_path: str = ""

def StartPanel( # noqa: N802
self, request: StartPanelRequest, context: Any
) -> StartPanelResponse:
"""Trivial implementation for testing."""
self._python_path = request.python_path
if self._fail_next_start_panel:
self._fail_next_start_panel = False
context.abort(grpc.StatusCode.UNAVAILABLE, "Simulated failure")
Expand Down Expand Up @@ -81,6 +83,11 @@ def notification_count(self) -> int:
"""Get the number of notifications sent from SetValue."""
return self._notification_count

@property
def python_path(self) -> str:
"""Get the Python path used to start the panel."""
return self._python_path

def _init_panel(self, panel_id: str) -> None:
if panel_id not in self._panel_ids:
self._panel_ids.append(panel_id)
Expand Down