Skip to content

Commit b6a7249

Browse files
committed
feat(python): add nemo-relay-plugin worker SDK package
Signed-off-by: Will Killian <wkillian@nvidia.com>
1 parent 2702a57 commit b6a7249

25 files changed

Lines changed: 5831 additions & 70 deletions

.github/workflows/ci_check.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -84,10 +84,10 @@ jobs:
8484
cache-bin: false
8585
save-if: false
8686

87-
- name: Install cargo-deny
87+
- name: Install cargo-deny and just
8888
uses: taiki-e/install-action@c070f87102a1c75b3183910f391c1cb887fe13c8 # v2.77.6
8989
with:
90-
tool: cargo-deny@${{ steps.ci-config.outputs.cargo_deny_version }}
90+
tool: cargo-deny@${{ steps.ci-config.outputs.cargo_deny_version }},just@${{ steps.ci-config.outputs.just_version }}
9191

9292
- name: Install cargo-about
9393
uses: taiki-e/install-action@c070f87102a1c75b3183910f391c1cb887fe13c8 # v2.77.6

.github/workflows/ci_python.yml

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -268,6 +268,24 @@ jobs:
268268
path: ${{ env.NEMO_RELAY_CI_WORKSPACE_TMP }}/wheels/*.whl
269269
if-no-files-found: error
270270

271+
- name: Package Python plugin SDK wheel
272+
if: ${{ matrix.platform == 'linux-amd64' }}
273+
working-directory: ${{ env.NEMO_RELAY_CI_WORKSPACE }}
274+
run: |
275+
set -e
276+
just \
277+
--set output_dir "${{ env.NEMO_RELAY_CI_WORKSPACE_TMP }}" \
278+
--set ref_name "${NEMO_RELAY_PACKAGE_VERSION}" \
279+
package-python-plugin
280+
281+
- name: Upload Python plugin SDK wheel artifact
282+
if: ${{ matrix.platform == 'linux-amd64' }}
283+
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
284+
with:
285+
name: python-plugin-wheel
286+
path: ${{ env.NEMO_RELAY_CI_WORKSPACE_TMP }}/plugin-wheels/*.whl
287+
if-no-files-found: error
288+
271289
- name: Prune uv cache
272290
working-directory: ${{ env.NEMO_RELAY_CI_WORKSPACE }}
273291
run: uv cache prune --ci

.pre-commit-config.yaml

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,10 +52,16 @@ repos:
5252
hooks:
5353
- id: ty
5454
name: ty (type check)
55-
entry: uv run ty check . --exclude docs/** --exclude fern/** --exclude third_party/** --exclude ./examples/** --exclude .cache/** --exclude .claude/**
55+
entry: uv run ty check . --extra-search-path python/plugin/src --exclude docs/** --exclude fern/** --exclude third_party/** --exclude ./examples/** --exclude .cache/** --exclude .claude/** --exclude python/plugin/src/nemo_relay_plugin/_proto/**
5656
language: system
5757
types: [python]
5858
pass_filenames: false
59+
- id: ty-python-plugin-example
60+
name: ty (Python plugin example)
61+
entry: uv run ty check examples/python-grpc-worker-plugin/worker.py --extra-search-path python/plugin/src
62+
language: system
63+
files: '^(examples/python-grpc-worker-plugin/worker\.py|python/plugin/src/nemo_relay_plugin/.*\.py)$'
64+
pass_filenames: false
5965

6066
# Documentation — external link validation
6167
- repo: https://github.com/lycheeverse/lychee.git
@@ -105,6 +111,13 @@ repos:
105111
files: '^(pyproject\.toml|uv\.lock)$'
106112
pass_filenames: false
107113

114+
- id: python-worker-proto-check
115+
name: Python worker protobuf stubs are up to date
116+
entry: just check-python-worker-proto
117+
language: system
118+
files: '^(crates/worker-proto/proto/nemo/relay/worker/v1/plugin_worker\.proto|python/plugin/src/nemo_relay_plugin/_proto/plugin_worker_pb2(_grpc)?\.py|justfile)$'
119+
pass_filenames: false
120+
108121
- id: node-lockfile-check
109122
name: package-lock.json is up to date
110123
entry: bash -c 'npm install --package-lock-only --ignore-scripts --audit=false --fund=false'

ATTRIBUTIONS-Python.md

Lines changed: 632 additions & 1 deletion
Large diffs are not rendered by default.

codecov.yml

Lines changed: 15 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -54,18 +54,6 @@ component_management:
5454
threshold: 0.5%
5555
base: auto
5656
if_ci_failed: error
57-
- component_id: plugin_sdk
58-
name: Plugin SDK
59-
paths:
60-
- "crates/plugin/src"
61-
- "crates/worker-proto/src"
62-
- "crates/worker/src"
63-
statuses:
64-
- type: project
65-
target: 95%
66-
threshold: 0.5%
67-
base: auto
68-
if_ci_failed: error
6957
- component_id: shared_types
7058
name: Shared DTO Types
7159
paths:
@@ -128,6 +116,20 @@ component_management:
128116
threshold: 0.5%
129117
base: auto
130118
if_ci_failed: error
119+
- component_id: plugin_sdk
120+
name: Dynamic Plugin SDKs
121+
paths:
122+
- "crates/types/src"
123+
- "crates/plugin/src"
124+
- "crates/worker-proto/src"
125+
- "crates/worker/src"
126+
- "python/plugin/src/nemo_relay_plugin"
127+
statuses:
128+
- type: project
129+
target: 95%
130+
threshold: 0.5%
131+
base: auto
132+
if_ci_failed: error
131133

132134
comment:
133135
after_n_builds: 22
@@ -157,6 +159,7 @@ ignore:
157159
- "target/"
158160
- "**/*.d.ts"
159161
- "**/*.pyi"
162+
- "python/plugin/src/nemo_relay_plugin/_proto/**"
160163
- "python/nemo_relay/lib_native*.dylib.dSYM/**"
161164
# WebAssembly Rust wrappers are covered through wasm-pack execution and
162165
# reported through generated package JavaScript coverage.
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
# SPDX-License-Identifier: Apache-2.0
3+
4+
.venv/
5+
__pycache__/
6+
*.py[cod]
7+
*.egg-info/
8+
nemo/
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
<!--
2+
SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
3+
SPDX-License-Identifier: Apache-2.0
4+
-->
5+
6+
# Python gRPC Worker Plugin
7+
8+
This example shows a Python worker plugin using the `nemo-relay-plugin` SDK. It
9+
registers a tool request intercept, emits a mark event through the host runtime,
10+
and returns a mutated JSON tool request.
11+
12+
## Set Up
13+
14+
From this directory:
15+
16+
```bash
17+
python3 -m venv .venv
18+
. .venv/bin/activate
19+
python -m pip install -e ../../python/plugin -e .
20+
```
21+
22+
The SDK package owns the generated protobuf stubs and gRPC server setup.
23+
24+
## Register With Relay
25+
26+
Point the CLI at this manifest and enable it:
27+
28+
```bash
29+
nemo-relay plugins add ./relay-plugin.toml
30+
nemo-relay plugins enable examples.python_grpc_worker
31+
```
32+
33+
When launching the gateway, point Relay at the Python interpreter that has
34+
`grpcio` installed:
35+
36+
```bash
37+
NEMO_RELAY_PYTHON="$PWD/.venv/bin/python" nemo-relay gateway
38+
```
39+
40+
You can also reference the manifest manually from `plugins.toml`:
41+
42+
```toml
43+
[[plugins.dynamic]]
44+
manifest = "./examples/python-grpc-worker-plugin/relay-plugin.toml"
45+
config = { tag = "demo" }
46+
```
47+
48+
The worker process is started by Relay. Do not run `worker.py` directly unless
49+
you also provide the worker socket, host socket, activation ID, plugin ID, and
50+
activation token environment variables that Relay normally supplies.
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
# SPDX-License-Identifier: Apache-2.0
3+
4+
[build-system]
5+
requires = ["setuptools>=68"]
6+
build-backend = "setuptools.build_meta"
7+
8+
[project]
9+
name = "nemo-relay-python-grpc-worker-example"
10+
version = "0.1.0"
11+
description = "Example Python gRPC worker plugin for NeMo Relay"
12+
requires-python = ">=3.11"
13+
dependencies = [
14+
"nemo-relay-plugin>=0.5.0",
15+
]
16+
17+
[tool.setuptools]
18+
py-modules = ["worker"]
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
# SPDX-License-Identifier: Apache-2.0
3+
4+
manifest_version = 1
5+
6+
[plugin]
7+
id = "examples.python_grpc_worker"
8+
kind = "worker"
9+
10+
[compat]
11+
relay = ">=0.5,<1.0"
12+
worker_protocol = "grpc-v1"
13+
14+
[defaults]
15+
enabled = false
16+
17+
[capabilities]
18+
items = ["plugin_worker"]
19+
20+
[load]
21+
runtime = "python"
22+
entrypoint = "worker:main"
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
# SPDX-License-Identifier: Apache-2.0
3+
4+
"""Example Python worker plugin using the nemo-relay-plugin SDK."""
5+
6+
from __future__ import annotations
7+
8+
from typing import Any
9+
10+
from nemo_relay_plugin import ConfigDiagnostic, DiagnosticLevel, Json, PluginContext, WorkerPlugin, serve_plugin
11+
12+
13+
class ExamplePythonWorker(WorkerPlugin):
14+
"""Small worker plugin that tags tool request JSON and emits a host mark."""
15+
16+
plugin_id = "examples.python_grpc_worker"
17+
18+
def validate(self, config: Json) -> list[ConfigDiagnostic | dict[str, Any]]:
19+
if isinstance(config, dict) and config.get("reject") is True:
20+
return [
21+
ConfigDiagnostic(
22+
level=DiagnosticLevel.ERROR,
23+
code="examples.python_grpc_worker.rejected",
24+
component=self.plugin_id,
25+
field="reject",
26+
message="Python gRPC worker rejection requested",
27+
)
28+
]
29+
if isinstance(config, dict) and "tag" in config and not isinstance(config["tag"], str):
30+
return [
31+
ConfigDiagnostic(
32+
level=DiagnosticLevel.ERROR,
33+
code="examples.python_grpc_worker.invalid_tag",
34+
component=self.plugin_id,
35+
field="tag",
36+
message="tag must be a string",
37+
)
38+
]
39+
return []
40+
41+
def register(self, ctx: PluginContext, config: Json) -> None:
42+
tag = config.get("tag", "python_grpc_worker") if isinstance(config, dict) else "python_grpc_worker"
43+
44+
async def tag_tool_request(tool_name: str, args: Json) -> Json:
45+
tagged_args = _tag_json(args, tag)
46+
await ctx.runtime.emit_mark(
47+
"examples.python_grpc_worker.tool_request",
48+
{"tool_name": tool_name, "source": "python-grpc-worker", "tag": tag},
49+
)
50+
return tagged_args
51+
52+
ctx.register_tool_request_intercept("tag_tool_request", tag_tool_request)
53+
54+
55+
def _tag_json(value: Json, tag: str) -> Json:
56+
if not isinstance(value, dict):
57+
raise TypeError("configured tool request tagging requires a JSON object")
58+
if tag in value:
59+
raise ValueError(f"tool request already contains configured tag {tag!r}")
60+
return {**value, tag: True}
61+
62+
63+
async def main() -> None:
64+
"""Entrypoint referenced by relay-plugin.toml."""
65+
await serve_plugin(ExamplePythonWorker())
66+
67+
68+
if __name__ == "__main__":
69+
import asyncio
70+
71+
asyncio.run(main())

0 commit comments

Comments
 (0)