feat: update Python plugin documentation and usage instructions for host function wrappers

Signed-off-by: Deluan <deluan@navidrome.org>
This commit is contained in:
Deluan 2025-12-26 19:02:13 -05:00
parent cbd74a3a96
commit 9fbcf6ceb3
24 changed files with 178 additions and 274 deletions

View File

@ -248,16 +248,36 @@ def cache_get_string(key: str) -> CacheGetStringResult:
...
```
#### Example Python Plugin Usage
#### Python Plugin Usage
> **Important:** Due to a limitation in extism-py, you cannot directly import the generated Python wrappers.
> The `@extism.import_fn` decorators are only detected when defined in the plugin's main `__init__.py` file.
> Generated Python files serve as **reference/template code** - copy the needed functions into your plugin.
Example of copying the generated wrapper into your plugin's `__init__.py`:
```python
from nd_host_subsonicapi import subsonicapi_call, HostFunctionError
import extism
import json
try:
response = subsonicapi_call("getAlbumList2?type=random&size=10")
data = json.loads(response)
except HostFunctionError as e:
print(f"API error: {e}")
# Copy host function declarations from generated files into your __init__.py
@extism.import_fn("extism:host/user", "subsonicapi_call")
def _host_subsonicapi_call(input_ptr: extism.JsonI64) -> extism.JsonI64:
pass
def subsonicapi_call(endpoint: str) -> str:
"""Call the SubsonicAPI with the given endpoint."""
result = _host_subsonicapi_call(endpoint)
return result
# Now use it in your plugin
@extism.plugin_fn
def my_plugin_function():
try:
response = subsonicapi_call("getAlbumList2?type=random&size=10")
data = json.loads(response)
except Exception as e:
extism.log(extism.LogLevel.Error, f"API error: {e}")
```
## Troubleshooting

View File

@ -3,8 +3,9 @@
# This file contains client wrappers for the {{.Service.Name}} host service.
# It is intended for use in Navidrome plugins built with extism-py.
#
# Usage:
# from nd_host_{{.Service.Name | lower}} import {{range $i, $m := .Service.Methods}}{{if $i}}, {{end}}{{pythonFunc $m}}{{end}}
# IMPORTANT: Due to a limitation in extism-py, you cannot import this file directly.
# The @extism.import_fn decorators are only detected when defined in the plugin's
# main __init__.py file. Copy the needed functions from this file into your plugin.
from dataclasses import dataclass
from typing import Any

View File

@ -3,8 +3,9 @@
# This file contains client wrappers for the Codec host service.
# It is intended for use in Navidrome plugins built with extism-py.
#
# Usage:
# from nd_host_codec import codec_encode
# IMPORTANT: Due to a limitation in extism-py, you cannot import this file directly.
# The @extism.import_fn decorators are only detected when defined in the plugin's
# main __init__.py file. Copy the needed functions from this file into your plugin.
from dataclasses import dataclass
from typing import Any

View File

@ -3,8 +3,9 @@
# This file contains client wrappers for the Counter host service.
# It is intended for use in Navidrome plugins built with extism-py.
#
# Usage:
# from nd_host_counter import counter_count
# IMPORTANT: Due to a limitation in extism-py, you cannot import this file directly.
# The @extism.import_fn decorators are only detected when defined in the plugin's
# main __init__.py file. Copy the needed functions from this file into your plugin.
from dataclasses import dataclass
from typing import Any

View File

@ -3,8 +3,9 @@
# This file contains client wrappers for the Echo host service.
# It is intended for use in Navidrome plugins built with extism-py.
#
# Usage:
# from nd_host_echo import echo_echo
# IMPORTANT: Due to a limitation in extism-py, you cannot import this file directly.
# The @extism.import_fn decorators are only detected when defined in the plugin's
# main __init__.py file. Copy the needed functions from this file into your plugin.
from dataclasses import dataclass
from typing import Any

View File

@ -3,8 +3,9 @@
# This file contains client wrappers for the List host service.
# It is intended for use in Navidrome plugins built with extism-py.
#
# Usage:
# from nd_host_list import list_items
# IMPORTANT: Due to a limitation in extism-py, you cannot import this file directly.
# The @extism.import_fn decorators are only detected when defined in the plugin's
# main __init__.py file. Copy the needed functions from this file into your plugin.
from dataclasses import dataclass
from typing import Any

View File

@ -3,8 +3,9 @@
# This file contains client wrappers for the Math host service.
# It is intended for use in Navidrome plugins built with extism-py.
#
# Usage:
# from nd_host_math import math_add
# IMPORTANT: Due to a limitation in extism-py, you cannot import this file directly.
# The @extism.import_fn decorators are only detected when defined in the plugin's
# main __init__.py file. Copy the needed functions from this file into your plugin.
from dataclasses import dataclass
from typing import Any

View File

@ -3,8 +3,9 @@
# This file contains client wrappers for the Meta host service.
# It is intended for use in Navidrome plugins built with extism-py.
#
# Usage:
# from nd_host_meta import meta_get, meta_set
# IMPORTANT: Due to a limitation in extism-py, you cannot import this file directly.
# The @extism.import_fn decorators are only detected when defined in the plugin's
# main __init__.py file. Copy the needed functions from this file into your plugin.
from dataclasses import dataclass
from typing import Any

View File

@ -3,8 +3,9 @@
# This file contains client wrappers for the Ping host service.
# It is intended for use in Navidrome plugins built with extism-py.
#
# Usage:
# from nd_host_ping import ping_ping
# IMPORTANT: Due to a limitation in extism-py, you cannot import this file directly.
# The @extism.import_fn decorators are only detected when defined in the plugin's
# main __init__.py file. Copy the needed functions from this file into your plugin.
from dataclasses import dataclass
from typing import Any

View File

@ -3,8 +3,9 @@
# This file contains client wrappers for the Search host service.
# It is intended for use in Navidrome plugins built with extism-py.
#
# Usage:
# from nd_host_search import search_find
# IMPORTANT: Due to a limitation in extism-py, you cannot import this file directly.
# The @extism.import_fn decorators are only detected when defined in the plugin's
# main __init__.py file. Copy the needed functions from this file into your plugin.
from dataclasses import dataclass
from typing import Any

View File

@ -3,8 +3,9 @@
# This file contains client wrappers for the Store host service.
# It is intended for use in Navidrome plugins built with extism-py.
#
# Usage:
# from nd_host_store import store_save
# IMPORTANT: Due to a limitation in extism-py, you cannot import this file directly.
# The @extism.import_fn decorators are only detected when defined in the plugin's
# main __init__.py file. Copy the needed functions from this file into your plugin.
from dataclasses import dataclass
from typing import Any

View File

@ -3,8 +3,9 @@
# This file contains client wrappers for the Users host service.
# It is intended for use in Navidrome plugins built with extism-py.
#
# Usage:
# from nd_host_users import users_get
# IMPORTANT: Due to a limitation in extism-py, you cannot import this file directly.
# The @extism.import_fn decorators are only detected when defined in the plugin's
# main __init__.py file. Copy the needed functions from this file into your plugin.
from dataclasses import dataclass
from typing import Any

View File

@ -43,8 +43,10 @@ else
endif
# Python plugin builds (generic rule for any folder with plugin/__init__.py)
$(PYTHON_PLUGINS:%=%.wasm): %.wasm: %/plugin/__init__.py
# Use secondary expansion to get all .py files in the plugin directory as dependencies
.SECONDEXPANSION:
$(PYTHON_PLUGINS:%=%.wasm): %.wasm: $$(wildcard %/plugin/*.py)
ifndef EXTISM_PY
$(error extism-py is not installed. Install from https://github.com/extism/python-pdk)
endif
cd $* && extism-py plugin/__init__.py -o ../$@
cd $* && PYTHONPATH=plugin extism-py plugin/__init__.py -o ../$@

View File

@ -5,8 +5,8 @@ WASM_FILE = nowplaying-py.wasm
build: $(WASM_FILE)
$(WASM_FILE): plugin/__init__.py plugin/nd_host_scheduler.py plugin/nd_host_subsonicapi.py
PYTHONPATH=plugin extism-py plugin/__init__.py -o $(WASM_FILE)
$(WASM_FILE): plugin/__init__.py
extism-py plugin/__init__.py -o $(WASM_FILE)
clean:
rm -f $(WASM_FILE)

View File

@ -4,10 +4,10 @@ A Python example plugin that demonstrates the **Scheduler** and **SubsonicAPI**
## Features
- Uses `scheduler_schedule_recurring` host function to set up a recurring task
- Uses `scheduler_schedulerecurring` host function to set up a recurring task
- Uses `subsonicapi_call` host function to query the `getNowPlaying` API
- Configurable cron expression and user via plugin config
- Uses generated Python host function wrappers from `plugins/host/python/`
- Demonstrates Python host function imports using `@extism.import_fn`
## Prerequisites
@ -29,7 +29,7 @@ make nowplaying-py.wasm
Or directly:
```bash
PYTHONPATH=plugin extism-py plugin/__init__.py -o nowplaying-py.wasm
extism-py plugin/__init__.py -o nowplaying-py.wasm
```
## Installation
@ -86,27 +86,31 @@ Or when no one is playing:
2. **Callback (`nd_scheduler_callback`)**: When the scheduled task fires, calls the SubsonicAPI `getNowPlaying` endpoint and logs the results.
## Using Generated Host Function Wrappers
## Host Function Usage (Python)
This plugin uses the generated Python host function wrappers from `plugins/host/python/`. These wrappers are generated by `hostgen` and provide type-safe, Pythonic interfaces to Navidrome host functions:
This plugin demonstrates how to call Navidrome host functions from Python:
```python
# Import generated host function wrappers
from nd_host_scheduler import scheduler_schedule_recurring, HostFunctionError
from nd_host_subsonicapi import subsonicapi_call
import extism
import json
# Use them directly - no manual JSON marshalling needed!
schedule_id = scheduler_schedule_recurring(
cron_expression="*/1 * * * *",
payload="check",
schedule_i_d="nowplaying-check"
)
# Import the host function
@extism.import_fn("extism:host/user", "subsonicapi_call")
def _subsonicapi_call(offset: int) -> int:
"""Raw host function - returns memory offset."""
...
response_json = subsonicapi_call(uri="getNowPlaying")
```
The wrappers handle:
- JSON marshalling/unmarshalling
- Memory allocation and management
- Error handling (raises `HostFunctionError` on failure)
- Type hints for better IDE support
# Wrapper for JSON marshalling
def subsonicapi_call(uri: str) -> dict:
request = {"uri": uri}
request_bytes = json.dumps(request).encode('utf-8')
request_mem = extism.memory.alloc(request_bytes)
response_offset = _subsonicapi_call(request_mem.offset)
response_mem = extism.memory.find(response_offset)
response = json.loads(extism.memory.string(response_mem))
if response.get("error"):
raise Exception(response["error"])
return json.loads(response.get("responseJSON", "{}"))
```

View File

@ -17,28 +17,86 @@
import extism
import json
# Import generated host function wrappers
from nd_host_scheduler import scheduler_schedule_recurring, HostFunctionError
from nd_host_subsonicapi import subsonicapi_call as _subsonicapi_call_raw
# Schedule ID for our recurring task
SCHEDULE_ID = "nowplaying-check"
# =============================================================================
# Host Function Imports
# =============================================================================
# These are custom host functions provided by Navidrome.
# We import them using the extism:host/user namespace.
@extism.import_fn("extism:host/user", "scheduler_schedulerecurring")
def _scheduler_schedulerecurring(offset: int) -> int:
"""Raw host function - do not call directly."""
...
@extism.import_fn("extism:host/user", "subsonicapi_call")
def _subsonicapi_call(offset: int) -> int:
"""Raw host function - do not call directly."""
...
# =============================================================================
# Host Function Wrappers
# =============================================================================
# These wrappers handle JSON marshalling/unmarshalling and memory management.
# They were copied from plugins/host/python due to extism-py limitations.
def scheduler_schedule_recurring(cron_expression: str, payload: str, schedule_id: str) -> str:
"""Schedule a recurring task using a cron expression.
Args:
cron_expression: Cron format (e.g., "*/1 * * * *" for every minute)
payload: Data to pass to the callback
schedule_id: Unique identifier for the schedule
Returns:
The schedule ID (same as input or auto-generated)
"""
request = {
"cronExpression": cron_expression,
"payload": payload,
"scheduleID": schedule_id
}
request_bytes = json.dumps(request).encode('utf-8')
request_mem = extism.memory.alloc(request_bytes)
response_offset = _scheduler_schedulerecurring(request_mem.offset)
response_mem = extism.memory.find(response_offset)
response = json.loads(extism.memory.string(response_mem))
if response.get("error"):
raise Exception(response["error"])
return response.get("newScheduleID", schedule_id)
def subsonicapi_call(uri: str) -> dict:
"""Call a Subsonic API endpoint and parse the response.
This is a convenience wrapper around the generated subsonicapi_call
that parses the JSON response string into a dict.
"""Call a Subsonic API endpoint.
Args:
uri: API path (e.g., "getNowPlaying")
Returns:
Parsed JSON response from the API
"""
response_json = _subsonicapi_call_raw(uri)
return json.loads(response_json) if response_json else {}
request = {"uri": uri}
request_bytes = json.dumps(request).encode('utf-8')
request_mem = extism.memory.alloc(request_bytes)
response_offset = _subsonicapi_call(request_mem.offset)
response_mem = extism.memory.find(response_offset)
response = json.loads(extism.memory.string(response_mem))
if response.get("error"):
raise Exception(response["error"])
# Parse the nested JSON response
response_json = response.get("responseJSON", "{}")
return json.loads(response_json)
# =============================================================================

View File

@ -1,142 +0,0 @@
# Code generated by hostgen. DO NOT EDIT.
#
# This file contains client wrappers for the Scheduler host service.
# It is intended for use in Navidrome plugins built with extism-py.
#
# Usage:
# from nd_host_scheduler import scheduler_schedule_one_time, scheduler_schedule_recurring, scheduler_cancel_schedule
from dataclasses import dataclass
from typing import Any
import extism
import json
class HostFunctionError(Exception):
"""Raised when a host function returns an error."""
pass
@extism.import_fn("extism:host/user", "scheduler_scheduleonetime")
def _scheduler_scheduleonetime(offset: int) -> int:
"""Raw host function - do not call directly."""
...
@extism.import_fn("extism:host/user", "scheduler_schedulerecurring")
def _scheduler_schedulerecurring(offset: int) -> int:
"""Raw host function - do not call directly."""
...
@extism.import_fn("extism:host/user", "scheduler_cancelschedule")
def _scheduler_cancelschedule(offset: int) -> int:
"""Raw host function - do not call directly."""
...
def scheduler_schedule_one_time(delay_seconds: int, payload: str, schedule_i_d: str) -> str:
"""ScheduleOneTime schedules a one-time event to be triggered after the specified delay.
Plugins that use this function must also implement the SchedulerCallback capability
Parameters:
- delaySeconds: Number of seconds to wait before triggering the event
- payload: Data to be passed to the scheduled event handler
- scheduleID: Optional unique identifier for the scheduled job. If empty, one will be generated
Returns the schedule ID that can be used to cancel the job, or an error if scheduling fails.
Args:
delay_seconds: int parameter.
payload: str parameter.
schedule_i_d: str parameter.
Returns:
str: The result value.
Raises:
HostFunctionError: If the host function returns an error.
"""
request = {
"delaySeconds": delay_seconds,
"payload": payload,
"scheduleID": schedule_i_d,
}
request_bytes = json.dumps(request).encode("utf-8")
request_mem = extism.memory.alloc(request_bytes)
response_offset = _scheduler_scheduleonetime(request_mem.offset)
response_mem = extism.memory.find(response_offset)
response = json.loads(extism.memory.string(response_mem))
if response.get("error"):
raise HostFunctionError(response["error"])
return response.get("newScheduleID", "")
def scheduler_schedule_recurring(cron_expression: str, payload: str, schedule_i_d: str) -> str:
"""ScheduleRecurring schedules a recurring event using a cron expression.
Plugins that use this function must also implement the SchedulerCallback capability
Parameters:
- cronExpression: Standard cron format expression (e.g., "0 0 * * *" for daily at midnight)
- payload: Data to be passed to each scheduled event handler invocation
- scheduleID: Optional unique identifier for the scheduled job. If empty, one will be generated
Returns the schedule ID that can be used to cancel the job, or an error if scheduling fails.
Args:
cron_expression: str parameter.
payload: str parameter.
schedule_i_d: str parameter.
Returns:
str: The result value.
Raises:
HostFunctionError: If the host function returns an error.
"""
request = {
"cronExpression": cron_expression,
"payload": payload,
"scheduleID": schedule_i_d,
}
request_bytes = json.dumps(request).encode("utf-8")
request_mem = extism.memory.alloc(request_bytes)
response_offset = _scheduler_schedulerecurring(request_mem.offset)
response_mem = extism.memory.find(response_offset)
response = json.loads(extism.memory.string(response_mem))
if response.get("error"):
raise HostFunctionError(response["error"])
return response.get("newScheduleID", "")
def scheduler_cancel_schedule(schedule_i_d: str) -> None:
"""CancelSchedule cancels a scheduled job identified by its schedule ID.
This works for both one-time and recurring schedules. Once cancelled, the job will not trigger
any future events.
Returns an error if the schedule ID is not found or if cancellation fails.
Args:
schedule_i_d: str parameter.
Raises:
HostFunctionError: If the host function returns an error.
"""
request = {
"scheduleID": schedule_i_d,
}
request_bytes = json.dumps(request).encode("utf-8")
request_mem = extism.memory.alloc(request_bytes)
response_offset = _scheduler_cancelschedule(request_mem.offset)
response_mem = extism.memory.find(response_offset)
response = json.loads(extism.memory.string(response_mem))
if response.get("error"):
raise HostFunctionError(response["error"])

View File

@ -1,54 +0,0 @@
# Code generated by hostgen. DO NOT EDIT.
#
# This file contains client wrappers for the SubsonicAPI host service.
# It is intended for use in Navidrome plugins built with extism-py.
#
# Usage:
# from nd_host_subsonicapi import subsonicapi_call
from dataclasses import dataclass
from typing import Any
import extism
import json
class HostFunctionError(Exception):
"""Raised when a host function returns an error."""
pass
@extism.import_fn("extism:host/user", "subsonicapi_call")
def _subsonicapi_call(offset: int) -> int:
"""Raw host function - do not call directly."""
...
def subsonicapi_call(uri: str) -> str:
"""Call executes a Subsonic API request and returns the JSON response.
The uri parameter should be the Subsonic API path without the server prefix,
e.g., "getAlbumList2?type=random&size=10". The response is returned as raw JSON.
Args:
uri: str parameter.
Returns:
str: The result value.
Raises:
HostFunctionError: If the host function returns an error.
"""
request = {
"uri": uri,
}
request_bytes = json.dumps(request).encode("utf-8")
request_mem = extism.memory.alloc(request_bytes)
response_offset = _subsonicapi_call(request_mem.offset)
response_mem = extism.memory.find(response_offset)
response = json.loads(extism.memory.string(response_mem))
if response.get("error"):
raise HostFunctionError(response["error"])
return response.get("responseJSON", "")

View File

@ -37,5 +37,5 @@
// Generated files follow the pattern <servicename>_gen.go and include a header comment
// indicating they should not be edited manually.
//
//go:generate go run ../cmd/hostgen -input=. -output=.
//go:generate go run ../cmd/hostgen -input=. -output=. -python -go
package host

View File

@ -3,8 +3,9 @@
# This file contains client wrappers for the Artwork host service.
# It is intended for use in Navidrome plugins built with extism-py.
#
# Usage:
# from nd_host_artwork import artwork_get_artist_url, artwork_get_album_url, artwork_get_track_url, artwork_get_playlist_url
# IMPORTANT: Due to a limitation in extism-py, you cannot import this file directly.
# The @extism.import_fn decorators are only detected when defined in the plugin's
# main __init__.py file. Copy the needed functions from this file into your plugin.
from dataclasses import dataclass
from typing import Any

View File

@ -3,8 +3,9 @@
# This file contains client wrappers for the Cache host service.
# It is intended for use in Navidrome plugins built with extism-py.
#
# Usage:
# from nd_host_cache import cache_set_string, cache_get_string, cache_set_int, cache_get_int, cache_set_float, cache_get_float, cache_set_bytes, cache_get_bytes, cache_has, cache_remove
# IMPORTANT: Due to a limitation in extism-py, you cannot import this file directly.
# The @extism.import_fn decorators are only detected when defined in the plugin's
# main __init__.py file. Copy the needed functions from this file into your plugin.
from dataclasses import dataclass
from typing import Any

View File

@ -3,8 +3,9 @@
# This file contains client wrappers for the Scheduler host service.
# It is intended for use in Navidrome plugins built with extism-py.
#
# Usage:
# from nd_host_scheduler import scheduler_schedule_one_time, scheduler_schedule_recurring, scheduler_cancel_schedule
# IMPORTANT: Due to a limitation in extism-py, you cannot import this file directly.
# The @extism.import_fn decorators are only detected when defined in the plugin's
# main __init__.py file. Copy the needed functions from this file into your plugin.
from dataclasses import dataclass
from typing import Any

View File

@ -3,8 +3,9 @@
# This file contains client wrappers for the SubsonicAPI host service.
# It is intended for use in Navidrome plugins built with extism-py.
#
# Usage:
# from nd_host_subsonicapi import subsonicapi_call
# IMPORTANT: Due to a limitation in extism-py, you cannot import this file directly.
# The @extism.import_fn decorators are only detected when defined in the plugin's
# main __init__.py file. Copy the needed functions from this file into your plugin.
from dataclasses import dataclass
from typing import Any

View File

@ -3,8 +3,9 @@
# This file contains client wrappers for the WebSocket host service.
# It is intended for use in Navidrome plugins built with extism-py.
#
# Usage:
# from nd_host_websocket import websocket_connect, websocket_send_text, websocket_send_binary, websocket_close_connection
# IMPORTANT: Due to a limitation in extism-py, you cannot import this file directly.
# The @extism.import_fn decorators are only detected when defined in the plugin's
# main __init__.py file. Copy the needed functions from this file into your plugin.
from dataclasses import dataclass
from typing import Any