feat: generate Python client wrappers for various host services

Signed-off-by: Deluan <deluan@navidrome.org>
This commit is contained in:
Deluan 2025-12-26 17:59:52 -05:00
parent 37f3b838d2
commit 08b952ef50
22 changed files with 2229 additions and 36 deletions

View File

@ -19,8 +19,12 @@ hostgen -input <dir> -output <dir> -package <name> [-v] [-dry-run] [-host-only]
| `-dry-run` | Parse and validate without writing files | `false` |
| `-host-only` | Generate only host-side wrapper code | `false` |
| `-plugin-only` | Generate only plugin/client-side wrapper code | `false` |
| `-go` | Generate Go client wrappers | `true`* |
| `-python` | Generate Python client wrappers | `false` |
By default, both host and plugin code are generated. Use `-host-only` or `-plugin-only` to generate only one type.
\* `-go` is enabled by default when `-python` is not specified. Use both `-go -python` to generate both.
By default, both host and Go plugin code are generated. Use `-host-only` or `-plugin-only` to generate only one type. Use `-python` to generate Python wrappers.
### Example
@ -192,8 +196,68 @@ Generated files are named `nd_host_<servicename>.go` (lowercase) and placed in t
```
output/
├── subsonicapi_gen.go # Host-side code (for Navidrome)
└── go/
└── nd_host_subsonicapi.go # Plugin-side code (for TinyGo plugins)
├── go/
│ └── nd_host_subsonicapi.go # Plugin-side code (for TinyGo plugins)
└── python/
└── nd_host_subsonicapi.py # Plugin-side code (for Python plugins)
```
### Python Client Code (extism-py WASM)
Generated files are named `nd_host_<servicename>.py` (lowercase) and placed in the `python/` subdirectory of the output directory. These files are intended for use in Navidrome plugins built with extism-py. Each file includes:
- `# Code generated by hostgen. DO NOT EDIT.` header
- Required imports (`dataclasses`, `typing`, `extism`, `json`)
- `HostFunctionError` exception class for error handling
- `@extism.import_fn` declarations for raw host functions
- `@dataclass` types for methods with multiple return values
- Wrapper functions with type hints, docstrings, and snake_case names
#### Python Type Mapping
| Go Type | Python Type |
|-------------------------|-------------|
| `string` | `str` |
| `int`, `int32`, `int64` | `int` |
| `float32`, `float64` | `float` |
| `bool` | `bool` |
| `[]byte` | `bytes` |
| Unknown | `Any` |
#### Python Function Naming
Functions follow PEP 8 snake_case convention:
| Go Method | Python Function |
|-------------------------------|----------------------------------|
| `SubsonicAPI.Call` | `subsonicapi_call()` |
| `Scheduler.ScheduleRecurring` | `scheduler_schedule_recurring()` |
| `Cache.GetString` | `cache_get_string()` |
#### Multi-Value Returns
Methods with multiple return values use dataclasses:
```python
@dataclass
class CacheGetStringResult:
value: str
exists: bool
def cache_get_string(key: str) -> CacheGetStringResult:
...
```
#### Example Python Plugin Usage
```python
from nd_host_subsonicapi import subsonicapi_call, HostFunctionError
try:
response = subsonicapi_call("getAlbumList2?type=random&size=10")
data = json.loads(response)
except HostFunctionError as e:
print(f"API error: {e}")
```
## Troubleshooting

View File

@ -205,15 +205,16 @@ type ServiceB interface {
Describe("code generation", func() {
DescribeTable("generates correct host and client output",
func(serviceFile, hostExpectedFile, clientExpectedFile string) {
func(serviceFile, hostExpectedFile, goClientExpectedFile, pyClientExpectedFile string) {
serviceCode := readTestdata(serviceFile)
hostExpected := readTestdata(hostExpectedFile)
clientExpected := readTestdata(clientExpectedFile)
goClientExpected := readTestdata(goClientExpectedFile)
pyClientExpected := readTestdata(pyClientExpectedFile)
Expect(os.WriteFile(filepath.Join(testDir, "service.go"), []byte(serviceCode), 0600)).To(Succeed())
// Generate both host and client code in one run
cmd := exec.Command(hostgenBin, "-input", testDir, "-output", outputDir, "-package", "testpkg")
// Generate host and both Go and Python client code
cmd := exec.Command(hostgenBin, "-input", testDir, "-output", outputDir, "-package", "testpkg", "-go", "-python")
output, err := cmd.CombinedOutput()
Expect(err).ToNot(HaveOccurred(), "Command failed: %s", output)
@ -223,7 +224,7 @@ type ServiceB interface {
var hostFiles []string
for _, e := range entries {
if e.Name() != "go" && !e.IsDir() {
if e.Name() != "go" && e.Name() != "python" && !e.IsDir() {
hostFiles = append(hostFiles, e.Name())
}
}
@ -240,53 +241,64 @@ type ServiceB interface {
Expect(string(formattedHostActual)).To(Equal(string(formattedHostExpected)), "Host code mismatch")
// Verify client code
// Verify Go client code
goDir := filepath.Join(outputDir, "go")
clientEntries, err := os.ReadDir(goDir)
goClientEntries, err := os.ReadDir(goDir)
Expect(err).ToNot(HaveOccurred())
Expect(clientEntries).To(HaveLen(1), "Expected exactly one client file")
Expect(goClientEntries).To(HaveLen(1), "Expected exactly one Go client file")
clientActual, err := os.ReadFile(filepath.Join(goDir, clientEntries[0].Name()))
goClientActual, err := os.ReadFile(filepath.Join(goDir, goClientEntries[0].Name()))
Expect(err).ToNot(HaveOccurred())
formattedClientActual, err := format.Source(clientActual)
Expect(err).ToNot(HaveOccurred(), "Generated client code is not valid Go:\n%s", clientActual)
formattedGoClientActual, err := format.Source(goClientActual)
Expect(err).ToNot(HaveOccurred(), "Generated Go client code is not valid Go:\n%s", goClientActual)
formattedClientExpected, err := format.Source([]byte(clientExpected))
Expect(err).ToNot(HaveOccurred(), "Expected client code is not valid Go")
formattedGoClientExpected, err := format.Source([]byte(goClientExpected))
Expect(err).ToNot(HaveOccurred(), "Expected Go client code is not valid Go")
Expect(string(formattedClientActual)).To(Equal(string(formattedClientExpected)), "Client code mismatch")
Expect(string(formattedGoClientActual)).To(Equal(string(formattedGoClientExpected)), "Go client code mismatch")
// Verify Python client code
pythonDir := filepath.Join(outputDir, "python")
pyClientEntries, err := os.ReadDir(pythonDir)
Expect(err).ToNot(HaveOccurred())
Expect(pyClientEntries).To(HaveLen(1), "Expected exactly one Python client file")
pyClientActual, err := os.ReadFile(filepath.Join(pythonDir, pyClientEntries[0].Name()))
Expect(err).ToNot(HaveOccurred())
Expect(string(pyClientActual)).To(Equal(pyClientExpected), "Python client code mismatch")
},
Entry("simple string params",
"echo_service.go", "echo_expected.go", "echo_client_expected.go"),
"echo_service.go", "echo_expected.go", "echo_client_expected.go", "echo_client_expected.py"),
Entry("multiple simple params (int32)",
"math_service.go", "math_expected.go", "math_client_expected.go"),
"math_service.go", "math_expected.go", "math_client_expected.go", "math_client_expected.py"),
Entry("struct param with request type",
"store_service.go", "store_expected.go", "store_client_expected.go"),
"store_service.go", "store_expected.go", "store_client_expected.go", "store_client_expected.py"),
Entry("mixed simple and complex params",
"list_service.go", "list_expected.go", "list_client_expected.go"),
"list_service.go", "list_expected.go", "list_client_expected.go", "list_client_expected.py"),
Entry("method without error",
"counter_service.go", "counter_expected.go", "counter_client_expected.go"),
"counter_service.go", "counter_expected.go", "counter_client_expected.go", "counter_client_expected.py"),
Entry("no params, error only",
"ping_service.go", "ping_expected.go", "ping_client_expected.go"),
"ping_service.go", "ping_expected.go", "ping_client_expected.go", "ping_client_expected.py"),
Entry("map and interface types",
"meta_service.go", "meta_expected.go", "meta_client_expected.go"),
"meta_service.go", "meta_expected.go", "meta_client_expected.go", "meta_client_expected.py"),
Entry("pointer types",
"users_service.go", "users_expected.go", "users_client_expected.go"),
"users_service.go", "users_expected.go", "users_client_expected.go", "users_client_expected.py"),
Entry("multiple returns",
"search_service.go", "search_expected.go", "search_client_expected.go"),
"search_service.go", "search_expected.go", "search_client_expected.go", "search_client_expected.py"),
Entry("bytes",
"codec_service.go", "codec_expected.go", "codec_client_expected.go"),
"codec_service.go", "codec_expected.go", "codec_client_expected.go", "codec_client_expected.py"),
)
It("generates compilable host code for comprehensive service", func() {
@ -398,6 +410,120 @@ type Filter2 struct {
// Verify .wasm file was created
Expect(filepath.Join(clientDir, "plugin.wasm")).To(BeAnExistingFile())
})
It("generates Python client code with -python flag", func() {
serviceCode := `package testpkg
import "context"
//nd:hostservice name=Test permission=test
type TestService interface {
//nd:hostfunc
DoAction(ctx context.Context, input string) (output string, err error)
}
`
Expect(os.WriteFile(filepath.Join(testDir, "service.go"), []byte(serviceCode), 0600)).To(Succeed())
cmd := exec.Command(hostgenBin, "-input", testDir, "-output", outputDir, "-package", "testpkg", "-python", "-plugin-only")
output, err := cmd.CombinedOutput()
Expect(err).ToNot(HaveOccurred(), "Command failed: %s", output)
// Verify Python client code exists
pythonDir := filepath.Join(outputDir, "python")
Expect(pythonDir).To(BeADirectory())
pythonFile := filepath.Join(pythonDir, "nd_host_test.py")
Expect(pythonFile).To(BeAnExistingFile())
content, err := os.ReadFile(pythonFile)
Expect(err).ToNot(HaveOccurred())
contentStr := string(content)
Expect(contentStr).To(ContainSubstring("Code generated by hostgen. DO NOT EDIT."))
Expect(contentStr).To(ContainSubstring("class HostFunctionError(Exception):"))
Expect(contentStr).To(ContainSubstring(`@extism.import_fn("extism:host/user", "test_doaction")`))
Expect(contentStr).To(ContainSubstring("def test_do_action(input: str) -> str:"))
})
It("generates both Go and Python client code with -go -python flags", func() {
serviceCode := `package testpkg
import "context"
//nd:hostservice name=Test permission=test
type TestService interface {
//nd:hostfunc
DoAction(ctx context.Context, input string) (output string, err error)
}
`
Expect(os.WriteFile(filepath.Join(testDir, "service.go"), []byte(serviceCode), 0600)).To(Succeed())
cmd := exec.Command(hostgenBin, "-input", testDir, "-output", outputDir, "-package", "testpkg", "-go", "-python", "-plugin-only")
output, err := cmd.CombinedOutput()
Expect(err).ToNot(HaveOccurred(), "Command failed: %s", output)
// Verify both Go and Python client code exist
goDir := filepath.Join(outputDir, "go")
Expect(goDir).To(BeADirectory())
Expect(filepath.Join(goDir, "nd_host_test.go")).To(BeAnExistingFile())
pythonDir := filepath.Join(outputDir, "python")
Expect(pythonDir).To(BeADirectory())
Expect(filepath.Join(pythonDir, "nd_host_test.py")).To(BeAnExistingFile())
})
It("generates Python code with dataclass for multi-value returns", func() {
serviceCode := `package testpkg
import "context"
//nd:hostservice name=Cache permission=cache
type CacheService interface {
//nd:hostfunc
GetString(ctx context.Context, key string) (value string, exists bool, err error)
}
`
Expect(os.WriteFile(filepath.Join(testDir, "service.go"), []byte(serviceCode), 0600)).To(Succeed())
cmd := exec.Command(hostgenBin, "-input", testDir, "-output", outputDir, "-package", "testpkg", "-python", "-plugin-only")
output, err := cmd.CombinedOutput()
Expect(err).ToNot(HaveOccurred(), "Command failed: %s", output)
content, err := os.ReadFile(filepath.Join(outputDir, "python", "nd_host_cache.py"))
Expect(err).ToNot(HaveOccurred())
contentStr := string(content)
Expect(contentStr).To(ContainSubstring("@dataclass"))
Expect(contentStr).To(ContainSubstring("class CacheGetStringResult:"))
Expect(contentStr).To(ContainSubstring("value: str"))
Expect(contentStr).To(ContainSubstring("exists: bool"))
Expect(contentStr).To(ContainSubstring("def cache_get_string(key: str) -> CacheGetStringResult:"))
})
It("generates Python code for methods with no parameters", func() {
serviceCode := `package testpkg
import "context"
//nd:hostservice name=Test permission=test
type TestService interface {
//nd:hostfunc
Ping(ctx context.Context) (status string, err error)
}
`
Expect(os.WriteFile(filepath.Join(testDir, "service.go"), []byte(serviceCode), 0600)).To(Succeed())
cmd := exec.Command(hostgenBin, "-input", testDir, "-output", outputDir, "-package", "testpkg", "-python", "-plugin-only")
output, err := cmd.CombinedOutput()
Expect(err).ToNot(HaveOccurred(), "Command failed: %s", output)
content, err := os.ReadFile(filepath.Join(outputDir, "python", "nd_host_test.py"))
Expect(err).ToNot(HaveOccurred())
contentStr := string(content)
Expect(contentStr).To(ContainSubstring("def test_ping() -> str:"))
Expect(contentStr).To(ContainSubstring(`request_bytes = b"{}"`))
})
})
})

View File

@ -34,6 +34,17 @@ func clientFuncMap(svc Service) template.FuncMap {
}
}
// pythonFuncMap returns the template functions for Python client code generation.
func pythonFuncMap(svc Service) template.FuncMap {
return template.FuncMap{
"lower": strings.ToLower,
"exportName": func(m Method) string { return m.FunctionName(svc.ExportPrefix()) },
"pythonFunc": func(m Method) string { return m.PythonFunctionName(svc.ExportPrefix()) },
"pythonResultType": func(m Method) string { return m.PythonResultTypeName(svc.Name) },
"pythonDefault": pythonDefaultValue,
}
}
// GenerateHost generates the host function wrapper code for a service.
func GenerateHost(svc Service, pkgName string) ([]byte, error) {
tmplContent, err := templatesFS.ReadFile("templates/host.go.tmpl")
@ -101,3 +112,45 @@ func formatDoc(doc string) string {
}
return strings.Join(result, "\n")
}
// GenerateClientPython generates Python client wrapper code for plugins.
func GenerateClientPython(svc Service) ([]byte, error) {
tmplContent, err := templatesFS.ReadFile("templates/client_py.py.tmpl")
if err != nil {
return nil, fmt.Errorf("reading Python client template: %w", err)
}
tmpl, err := template.New("client_py").Funcs(pythonFuncMap(svc)).Parse(string(tmplContent))
if err != nil {
return nil, fmt.Errorf("parsing template: %w", err)
}
data := templateData{
Service: svc,
}
var buf bytes.Buffer
if err := tmpl.Execute(&buf, data); err != nil {
return nil, fmt.Errorf("executing template: %w", err)
}
return buf.Bytes(), nil
}
// pythonDefaultValue returns a Python default value for response.get() calls.
func pythonDefaultValue(p Param) string {
switch p.Type {
case "string":
return `, ""`
case "int", "int32", "int64":
return ", 0"
case "float32", "float64":
return ", 0.0"
case "bool":
return ", False"
case "[]byte":
return ", b\"\""
default:
return ", None"
}
}

View File

@ -310,6 +310,209 @@ var _ = Describe("Generator", func() {
})
})
Describe("Python type and name helpers", func() {
Describe("ToPythonType", func() {
It("should map Go types to Python types", func() {
Expect(ToPythonType("string")).To(Equal("str"))
Expect(ToPythonType("int")).To(Equal("int"))
Expect(ToPythonType("int32")).To(Equal("int"))
Expect(ToPythonType("int64")).To(Equal("int"))
Expect(ToPythonType("float32")).To(Equal("float"))
Expect(ToPythonType("float64")).To(Equal("float"))
Expect(ToPythonType("bool")).To(Equal("bool"))
Expect(ToPythonType("[]byte")).To(Equal("bytes"))
Expect(ToPythonType("unknown")).To(Equal("Any"))
})
})
Describe("ToSnakeCase", func() {
It("should convert PascalCase to snake_case", func() {
Expect(ToSnakeCase("ScheduleRecurring")).To(Equal("schedule_recurring"))
Expect(ToSnakeCase("GetString")).To(Equal("get_string"))
Expect(ToSnakeCase("ID")).To(Equal("i_d"))
Expect(ToSnakeCase("simple")).To(Equal("simple"))
})
})
Describe("Method.PythonFunctionName", func() {
It("should generate snake_case function name with service prefix", func() {
m := Method{Name: "GetString"}
Expect(m.PythonFunctionName("cache")).To(Equal("cache_get_string"))
})
})
Describe("Param.PythonType", func() {
It("should return Python type for parameter", func() {
p := NewParam("value", "string")
Expect(p.PythonType()).To(Equal("str"))
})
})
Describe("Param.PythonName", func() {
It("should return snake_case name for parameter", func() {
p := NewParam("ttlSeconds", "int64")
Expect(p.PythonName()).To(Equal("ttl_seconds"))
})
})
})
Describe("GenerateClientPython", func() {
It("should generate valid Python code for a simple service", func() {
svc := Service{
Name: "SubsonicAPI",
Permission: "subsonicapi",
Interface: "SubsonicAPIService",
Methods: []Method{
{
Name: "Call",
HasError: true,
Params: []Param{NewParam("uri", "string")},
Returns: []Param{NewParam("responseJSON", "string")},
},
},
}
code, err := GenerateClientPython(svc)
Expect(err).NotTo(HaveOccurred())
codeStr := string(code)
// Check for generated header
Expect(codeStr).To(ContainSubstring("Code generated by hostgen. DO NOT EDIT."))
// Check for imports
Expect(codeStr).To(ContainSubstring("from dataclasses import dataclass"))
Expect(codeStr).To(ContainSubstring("import extism"))
Expect(codeStr).To(ContainSubstring("import json"))
// Check for exception class
Expect(codeStr).To(ContainSubstring("class HostFunctionError(Exception):"))
// Check for raw import function
Expect(codeStr).To(ContainSubstring(`@extism.import_fn("extism:host/user", "subsonicapi_call")`))
Expect(codeStr).To(ContainSubstring("def _subsonicapi_call(offset: int) -> int:"))
// Check for wrapper function with type hints
Expect(codeStr).To(ContainSubstring("def subsonicapi_call(uri: str) -> str:"))
// Check for error handling
Expect(codeStr).To(ContainSubstring("raise HostFunctionError(response["))
})
It("should generate dataclass for multi-value returns", func() {
svc := Service{
Name: "Cache",
Permission: "cache",
Interface: "CacheService",
Methods: []Method{
{
Name: "GetString",
HasError: true,
Params: []Param{NewParam("key", "string")},
Returns: []Param{
NewParam("value", "string"),
NewParam("exists", "bool"),
},
},
},
}
code, err := GenerateClientPython(svc)
Expect(err).NotTo(HaveOccurred())
codeStr := string(code)
// Check for dataclass
Expect(codeStr).To(ContainSubstring("@dataclass"))
Expect(codeStr).To(ContainSubstring("class CacheGetStringResult:"))
Expect(codeStr).To(ContainSubstring("value: str"))
Expect(codeStr).To(ContainSubstring("exists: bool"))
// Check that function returns dataclass
Expect(codeStr).To(ContainSubstring("def cache_get_string(key: str) -> CacheGetStringResult:"))
Expect(codeStr).To(ContainSubstring("return CacheGetStringResult("))
})
It("should handle methods with no parameters", func() {
svc := Service{
Name: "Test",
Permission: "test",
Interface: "TestService",
Methods: []Method{
{
Name: "NoParams",
HasError: true,
Returns: []Param{NewParam("result", "string")},
},
},
}
code, err := GenerateClientPython(svc)
Expect(err).NotTo(HaveOccurred())
codeStr := string(code)
// Function with no params
Expect(codeStr).To(ContainSubstring("def test_no_params() -> str:"))
// Empty request
Expect(codeStr).To(ContainSubstring(`request_bytes = b"{}"`))
})
It("should handle methods with no return values", func() {
svc := Service{
Name: "Test",
Permission: "test",
Interface: "TestService",
Methods: []Method{
{
Name: "NoReturn",
HasError: true,
Params: []Param{NewParam("input", "string")},
},
},
}
code, err := GenerateClientPython(svc)
Expect(err).NotTo(HaveOccurred())
codeStr := string(code)
// Function returns None
Expect(codeStr).To(ContainSubstring("def test_no_return(input: str) -> None:"))
})
It("should generate correct Python defaults for different types", func() {
svc := Service{
Name: "Test",
Permission: "test",
Interface: "TestService",
Methods: []Method{
{
Name: "AllTypes",
HasError: true,
Returns: []Param{
NewParam("strVal", "string"),
NewParam("intVal", "int64"),
NewParam("floatVal", "float64"),
NewParam("boolVal", "bool"),
},
},
},
}
code, err := GenerateClientPython(svc)
Expect(err).NotTo(HaveOccurred())
codeStr := string(code)
// Check defaults in response.get() calls
Expect(codeStr).To(ContainSubstring(`response.get("strVal", "")`))
Expect(codeStr).To(ContainSubstring(`response.get("intVal", 0)`))
Expect(codeStr).To(ContainSubstring(`response.get("floatVal", 0.0)`))
Expect(codeStr).To(ContainSubstring(`response.get("boolVal", False)`))
})
})
Describe("Integration", func() {
It("should generate compilable code from parsed source", func() {
// This is an integration test that verifies the full pipeline

View File

@ -0,0 +1,94 @@
# Code generated by hostgen. DO NOT EDIT.
#
# 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}}
from dataclasses import dataclass
from typing import Any
import extism
import json
class HostFunctionError(Exception):
"""Raised when a host function returns an error."""
pass
{{- /* Generate raw host function imports */ -}}
{{range .Service.Methods}}
@extism.import_fn("extism:host/user", "{{exportName .}}")
def _{{exportName .}}(offset: int) -> int:
"""Raw host function - do not call directly."""
...
{{- end}}
{{- /* Generate dataclasses for multi-value returns */ -}}
{{range .Service.Methods}}
{{- if .NeedsResultClass}}
@dataclass
class {{pythonResultType .}}:
"""Result type for {{pythonFunc .}}."""
{{- range .Returns}}
{{.PythonName}}: {{.PythonType}}
{{- end}}
{{- end}}
{{- end}}
{{- /* Generate wrapper functions */ -}}
{{range .Service.Methods}}
def {{pythonFunc .}}({{range $i, $p := .Params}}{{if $i}}, {{end}}{{$p.PythonName}}: {{$p.PythonType}}{{end}}){{if .NeedsResultClass}} -> {{pythonResultType .}}{{else if .HasReturns}} -> {{(index .Returns 0).PythonType}}{{else}} -> None{{end}}:
"""{{if .Doc}}{{.Doc}}{{else}}Call the {{exportName .}} host function.{{end}}
{{- if .HasParams}}
Args:
{{- range .Params}}
{{.PythonName}}: {{.PythonType}} parameter.
{{- end}}
{{- end}}
{{- if .HasReturns}}
Returns:
{{- if .NeedsResultClass}}
{{pythonResultType .}} containing{{range .Returns}} {{.PythonName}},{{end}}.
{{- else}}
{{(index .Returns 0).PythonType}}: The result value.
{{- end}}
{{- end}}
Raises:
HostFunctionError: If the host function returns an error.
"""
{{- if .HasParams}}
request = {
{{- range .Params}}
"{{.JSONName}}": {{.PythonName}},
{{- end}}
}
request_bytes = json.dumps(request).encode("utf-8")
{{- else}}
request_bytes = b"{}"
{{- end}}
request_mem = extism.memory.alloc(request_bytes)
response_offset = _{{exportName .}}(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"])
{{if .NeedsResultClass}}
return {{pythonResultType .}}(
{{- range .Returns}}
{{.PythonName}}=response.get("{{.JSONName}}"{{pythonDefault .}}),
{{- end}}
)
{{- else if .HasReturns}}
return response.get("{{(index .Returns 0).JSONName}}"{{pythonDefault (index .Returns 0)}})
{{- end}}
{{- end}}

View File

@ -85,3 +85,58 @@ func toJSONName(name string) string {
// Simple conversion: lowercase first letter
return strings.ToLower(name[:1]) + name[1:]
}
// ToPythonType converts a Go type to its Python equivalent.
func ToPythonType(goType string) string {
switch goType {
case "string":
return "str"
case "int", "int32", "int64":
return "int"
case "float32", "float64":
return "float"
case "bool":
return "bool"
case "[]byte":
return "bytes"
default:
return "Any"
}
}
// ToSnakeCase converts a PascalCase or camelCase string to snake_case.
func ToSnakeCase(s string) string {
var result strings.Builder
for i, r := range s {
if i > 0 && r >= 'A' && r <= 'Z' {
result.WriteByte('_')
}
result.WriteRune(r)
}
return strings.ToLower(result.String())
}
// PythonFunctionName returns the Python function name for a method.
func (m Method) PythonFunctionName(servicePrefix string) string {
return ToSnakeCase(servicePrefix + m.Name)
}
// PythonResultTypeName returns the Python dataclass name for multi-value returns.
func (m Method) PythonResultTypeName(serviceName string) string {
return serviceName + m.Name + "Result"
}
// NeedsResultClass returns true if the method needs a dataclass for returns.
func (m Method) NeedsResultClass() bool {
return len(m.Returns) > 1
}
// PythonType returns the Python type for this parameter.
func (p Param) PythonType() string {
return ToPythonType(p.Type)
}
// PythonName returns the snake_case Python name for this parameter.
func (p Param) PythonName() string {
return ToSnakeCase(p.Name)
}

View File

@ -11,6 +11,8 @@
// -package Output package name (default: inferred from output directory)
// -host-only Generate only host-side code (default: false)
// -plugin-only Generate only plugin/client-side code (default: false)
// -go Generate Go client wrappers (default: true when not using -python)
// -python Generate Python client wrappers (default: false)
// -v Verbose output
// -dry-run Preview generated code without writing files
package main
@ -33,6 +35,8 @@ func main() {
pkgName = flag.String("package", "", "Output package name (default: inferred from output directory)")
hostOnly = flag.Bool("host-only", false, "Generate only host-side code")
pluginOnly = flag.Bool("plugin-only", false, "Generate only plugin/client-side code")
goClient = flag.Bool("go", false, "Generate Go client wrappers")
pyClient = flag.Bool("python", false, "Generate Python client wrappers")
verbose = flag.Bool("v", false, "Verbose output")
dryRun = flag.Bool("dry-run", false, "Preview generated code without writing files")
)
@ -67,14 +71,20 @@ func main() {
// Determine what to generate
generateHost := !*pluginOnly
generateClient := !*hostOnly
// Default: generate Go clients if no language flag is specified
// If -python is specified without -go, only generate Python
// If -go is specified, generate Go
// If both are specified, generate both
generateGoClient := !*hostOnly && (*goClient || !*pyClient)
generatePyClient := !*hostOnly && *pyClient
if *verbose {
fmt.Printf("Input directory: %s\n", absInput)
fmt.Printf("Output directory: %s\n", absOutput)
fmt.Printf("Package name: %s\n", *pkgName)
fmt.Printf("Generate host code: %v\n", generateHost)
fmt.Printf("Generate client code: %v\n", generateClient)
fmt.Printf("Generate Go client code: %v\n", generateGoClient)
fmt.Printf("Generate Python client code: %v\n", generatePyClient)
}
// Parse source files
@ -108,10 +118,18 @@ func main() {
}
}
// Generate client-side code
if generateClient {
if err := generateClientCode(svc, absOutput, *dryRun, *verbose); err != nil {
fmt.Fprintf(os.Stderr, "Error generating client code for %s: %v\n", svc.Name, err)
// Generate Go client-side code
if generateGoClient {
if err := generateGoClientCode(svc, absOutput, *dryRun, *verbose); err != nil {
fmt.Fprintf(os.Stderr, "Error generating Go client code for %s: %v\n", svc.Name, err)
os.Exit(1)
}
}
// Generate Python client-side code
if generatePyClient {
if err := generatePythonClientCode(svc, absOutput, *dryRun, *verbose); err != nil {
fmt.Fprintf(os.Stderr, "Error generating Python client code for %s: %v\n", svc.Name, err)
os.Exit(1)
}
}
@ -147,8 +165,8 @@ func generateHostCode(svc internal.Service, pkgName, outputDir string, dryRun, v
return nil
}
// generateClientCode generates client-side code for a service.
func generateClientCode(svc internal.Service, outputDir string, dryRun, verbose bool) error {
// generateGoClientCode generates Go client-side code for a service.
func generateGoClientCode(svc internal.Service, outputDir string, dryRun, verbose bool) error {
code, err := internal.GenerateClientGo(svc)
if err != nil {
return fmt.Errorf("generating code: %w", err)
@ -178,7 +196,38 @@ func generateClientCode(svc internal.Service, outputDir string, dryRun, verbose
}
if verbose {
fmt.Printf("Generated client code: %s\n", clientFile)
fmt.Printf("Generated Go client code: %s\n", clientFile)
}
return nil
}
// generatePythonClientCode generates Python client-side code for a service.
func generatePythonClientCode(svc internal.Service, outputDir string, dryRun, verbose bool) error {
code, err := internal.GenerateClientPython(svc)
if err != nil {
return fmt.Errorf("generating code: %w", err)
}
// Python code goes in python/ subdirectory
clientDir := filepath.Join(outputDir, "python")
clientFile := filepath.Join(clientDir, "nd_host_"+strings.ToLower(svc.Name)+".py")
if dryRun {
fmt.Printf("=== %s ===\n%s\n", clientFile, code)
return nil
}
// Create python/ subdirectory if needed
if err := os.MkdirAll(clientDir, 0755); err != nil {
return fmt.Errorf("creating python client directory: %w", err)
}
if err := os.WriteFile(clientFile, code, 0600); err != nil {
return fmt.Errorf("writing file: %w", err)
}
if verbose {
fmt.Printf("Generated Python client code: %s\n", clientFile)
}
return nil
}

View File

@ -0,0 +1,51 @@
# Code generated by hostgen. DO NOT EDIT.
#
# 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
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", "codec_encode")
def _codec_encode(offset: int) -> int:
"""Raw host function - do not call directly."""
...
def codec_encode(data: bytes) -> bytes:
"""Call the codec_encode host function.
Args:
data: bytes parameter.
Returns:
bytes: The result value.
Raises:
HostFunctionError: If the host function returns an error.
"""
request = {
"data": data,
}
request_bytes = json.dumps(request).encode("utf-8")
request_mem = extism.memory.alloc(request_bytes)
response_offset = _codec_encode(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("result", b"")

View File

@ -0,0 +1,51 @@
# Code generated by hostgen. DO NOT EDIT.
#
# 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
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", "counter_count")
def _counter_count(offset: int) -> int:
"""Raw host function - do not call directly."""
...
def counter_count(name: str) -> int:
"""Call the counter_count host function.
Args:
name: str parameter.
Returns:
int: The result value.
Raises:
HostFunctionError: If the host function returns an error.
"""
request = {
"name": name,
}
request_bytes = json.dumps(request).encode("utf-8")
request_mem = extism.memory.alloc(request_bytes)
response_offset = _counter_count(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("value", 0)

View File

@ -0,0 +1,51 @@
# Code generated by hostgen. DO NOT EDIT.
#
# 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
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", "echo_echo")
def _echo_echo(offset: int) -> int:
"""Raw host function - do not call directly."""
...
def echo_echo(message: str) -> str:
"""Call the echo_echo host function.
Args:
message: str parameter.
Returns:
str: The result value.
Raises:
HostFunctionError: If the host function returns an error.
"""
request = {
"message": message,
}
request_bytes = json.dumps(request).encode("utf-8")
request_mem = extism.memory.alloc(request_bytes)
response_offset = _echo_echo(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("reply", "")

View File

@ -0,0 +1,53 @@
# Code generated by hostgen. DO NOT EDIT.
#
# 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
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", "list_items")
def _list_items(offset: int) -> int:
"""Raw host function - do not call directly."""
...
def list_items(name: str, filter: Any) -> int:
"""Call the list_items host function.
Args:
name: str parameter.
filter: Any parameter.
Returns:
int: The result value.
Raises:
HostFunctionError: If the host function returns an error.
"""
request = {
"name": name,
"filter": filter,
}
request_bytes = json.dumps(request).encode("utf-8")
request_mem = extism.memory.alloc(request_bytes)
response_offset = _list_items(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("count", 0)

View File

@ -0,0 +1,53 @@
# Code generated by hostgen. DO NOT EDIT.
#
# 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
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", "math_add")
def _math_add(offset: int) -> int:
"""Raw host function - do not call directly."""
...
def math_add(a: int, b: int) -> int:
"""Call the math_add host function.
Args:
a: int parameter.
b: int parameter.
Returns:
int: The result value.
Raises:
HostFunctionError: If the host function returns an error.
"""
request = {
"a": a,
"b": b,
}
request_bytes = json.dumps(request).encode("utf-8")
request_mem = extism.memory.alloc(request_bytes)
response_offset = _math_add(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("result", 0)

View File

@ -0,0 +1,80 @@
# Code generated by hostgen. DO NOT EDIT.
#
# 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
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", "meta_get")
def _meta_get(offset: int) -> int:
"""Raw host function - do not call directly."""
...
@extism.import_fn("extism:host/user", "meta_set")
def _meta_set(offset: int) -> int:
"""Raw host function - do not call directly."""
...
def meta_get(key: str) -> Any:
"""Call the meta_get host function.
Args:
key: str parameter.
Returns:
Any: The result value.
Raises:
HostFunctionError: If the host function returns an error.
"""
request = {
"key": key,
}
request_bytes = json.dumps(request).encode("utf-8")
request_mem = extism.memory.alloc(request_bytes)
response_offset = _meta_get(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("value", None)
def meta_set(data: Any) -> None:
"""Call the meta_set host function.
Args:
data: Any parameter.
Raises:
HostFunctionError: If the host function returns an error.
"""
request = {
"data": data,
}
request_bytes = json.dumps(request).encode("utf-8")
request_mem = extism.memory.alloc(request_bytes)
response_offset = _meta_set(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

@ -0,0 +1,41 @@
# Code generated by hostgen. DO NOT EDIT.
#
# 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
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", "ping_ping")
def _ping_ping(offset: int) -> int:
"""Raw host function - do not call directly."""
...
def ping_ping() -> None:
"""Call the ping_ping host function.
Raises:
HostFunctionError: If the host function returns an error.
"""
request_bytes = b"{}"
request_mem = extism.memory.alloc(request_bytes)
response_offset = _ping_ping(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

@ -0,0 +1,61 @@
# Code generated by hostgen. DO NOT EDIT.
#
# 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
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", "search_find")
def _search_find(offset: int) -> int:
"""Raw host function - do not call directly."""
...
@dataclass
class SearchFindResult:
"""Result type for search_find."""
results: Any
total: int
def search_find(query: str) -> SearchFindResult:
"""Call the search_find host function.
Args:
query: str parameter.
Returns:
SearchFindResult containing results, total,.
Raises:
HostFunctionError: If the host function returns an error.
"""
request = {
"query": query,
}
request_bytes = json.dumps(request).encode("utf-8")
request_mem = extism.memory.alloc(request_bytes)
response_offset = _search_find(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 SearchFindResult(
results=response.get("results", None),
total=response.get("total", 0),
)

View File

@ -0,0 +1,51 @@
# Code generated by hostgen. DO NOT EDIT.
#
# 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
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", "store_save")
def _store_save(offset: int) -> int:
"""Raw host function - do not call directly."""
...
def store_save(item: Any) -> str:
"""Call the store_save host function.
Args:
item: Any parameter.
Returns:
str: The result value.
Raises:
HostFunctionError: If the host function returns an error.
"""
request = {
"item": item,
}
request_bytes = json.dumps(request).encode("utf-8")
request_mem = extism.memory.alloc(request_bytes)
response_offset = _store_save(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("id", "")

View File

@ -0,0 +1,53 @@
# Code generated by hostgen. DO NOT EDIT.
#
# 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
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", "users_get")
def _users_get(offset: int) -> int:
"""Raw host function - do not call directly."""
...
def users_get(id: Any, filter: Any) -> Any:
"""Call the users_get host function.
Args:
id: Any parameter.
filter: Any parameter.
Returns:
Any: The result value.
Raises:
HostFunctionError: If the host function returns an error.
"""
request = {
"id": id,
"filter": filter,
}
request_bytes = json.dumps(request).encode("utf-8")
request_mem = extism.memory.alloc(request_bytes)
response_offset = _users_get(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("result", None)

View File

@ -0,0 +1,182 @@
# Code generated by hostgen. DO NOT EDIT.
#
# 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
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", "artwork_getartisturl")
def _artwork_getartisturl(offset: int) -> int:
"""Raw host function - do not call directly."""
...
@extism.import_fn("extism:host/user", "artwork_getalbumurl")
def _artwork_getalbumurl(offset: int) -> int:
"""Raw host function - do not call directly."""
...
@extism.import_fn("extism:host/user", "artwork_gettrackurl")
def _artwork_gettrackurl(offset: int) -> int:
"""Raw host function - do not call directly."""
...
@extism.import_fn("extism:host/user", "artwork_getplaylisturl")
def _artwork_getplaylisturl(offset: int) -> int:
"""Raw host function - do not call directly."""
...
def artwork_get_artist_url(id: str, size: int) -> str:
"""GetArtistUrl generates a public URL for an artist's artwork.
Parameters:
- id: The artist's unique identifier
- size: Desired image size in pixels (0 for original size)
Returns the public URL for the artwork, or an error if generation fails.
Args:
id: str parameter.
size: int parameter.
Returns:
str: The result value.
Raises:
HostFunctionError: If the host function returns an error.
"""
request = {
"id": id,
"size": size,
}
request_bytes = json.dumps(request).encode("utf-8")
request_mem = extism.memory.alloc(request_bytes)
response_offset = _artwork_getartisturl(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("url", "")
def artwork_get_album_url(id: str, size: int) -> str:
"""GetAlbumUrl generates a public URL for an album's artwork.
Parameters:
- id: The album's unique identifier
- size: Desired image size in pixels (0 for original size)
Returns the public URL for the artwork, or an error if generation fails.
Args:
id: str parameter.
size: int parameter.
Returns:
str: The result value.
Raises:
HostFunctionError: If the host function returns an error.
"""
request = {
"id": id,
"size": size,
}
request_bytes = json.dumps(request).encode("utf-8")
request_mem = extism.memory.alloc(request_bytes)
response_offset = _artwork_getalbumurl(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("url", "")
def artwork_get_track_url(id: str, size: int) -> str:
"""GetTrackUrl generates a public URL for a track's artwork.
Parameters:
- id: The track's (media file) unique identifier
- size: Desired image size in pixels (0 for original size)
Returns the public URL for the artwork, or an error if generation fails.
Args:
id: str parameter.
size: int parameter.
Returns:
str: The result value.
Raises:
HostFunctionError: If the host function returns an error.
"""
request = {
"id": id,
"size": size,
}
request_bytes = json.dumps(request).encode("utf-8")
request_mem = extism.memory.alloc(request_bytes)
response_offset = _artwork_gettrackurl(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("url", "")
def artwork_get_playlist_url(id: str, size: int) -> str:
"""GetPlaylistUrl generates a public URL for a playlist's artwork.
Parameters:
- id: The playlist's unique identifier
- size: Desired image size in pixels (0 for original size)
Returns the public URL for the artwork, or an error if generation fails.
Args:
id: str parameter.
size: int parameter.
Returns:
str: The result value.
Raises:
HostFunctionError: If the host function returns an error.
"""
request = {
"id": id,
"size": size,
}
request_bytes = json.dumps(request).encode("utf-8")
request_mem = extism.memory.alloc(request_bytes)
response_offset = _artwork_getplaylisturl(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("url", "")

View File

@ -0,0 +1,446 @@
# Code generated by hostgen. DO NOT EDIT.
#
# 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
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", "cache_setstring")
def _cache_setstring(offset: int) -> int:
"""Raw host function - do not call directly."""
...
@extism.import_fn("extism:host/user", "cache_getstring")
def _cache_getstring(offset: int) -> int:
"""Raw host function - do not call directly."""
...
@extism.import_fn("extism:host/user", "cache_setint")
def _cache_setint(offset: int) -> int:
"""Raw host function - do not call directly."""
...
@extism.import_fn("extism:host/user", "cache_getint")
def _cache_getint(offset: int) -> int:
"""Raw host function - do not call directly."""
...
@extism.import_fn("extism:host/user", "cache_setfloat")
def _cache_setfloat(offset: int) -> int:
"""Raw host function - do not call directly."""
...
@extism.import_fn("extism:host/user", "cache_getfloat")
def _cache_getfloat(offset: int) -> int:
"""Raw host function - do not call directly."""
...
@extism.import_fn("extism:host/user", "cache_setbytes")
def _cache_setbytes(offset: int) -> int:
"""Raw host function - do not call directly."""
...
@extism.import_fn("extism:host/user", "cache_getbytes")
def _cache_getbytes(offset: int) -> int:
"""Raw host function - do not call directly."""
...
@extism.import_fn("extism:host/user", "cache_has")
def _cache_has(offset: int) -> int:
"""Raw host function - do not call directly."""
...
@extism.import_fn("extism:host/user", "cache_remove")
def _cache_remove(offset: int) -> int:
"""Raw host function - do not call directly."""
...
@dataclass
class CacheGetStringResult:
"""Result type for cache_get_string."""
value: str
exists: bool
@dataclass
class CacheGetIntResult:
"""Result type for cache_get_int."""
value: int
exists: bool
@dataclass
class CacheGetFloatResult:
"""Result type for cache_get_float."""
value: float
exists: bool
@dataclass
class CacheGetBytesResult:
"""Result type for cache_get_bytes."""
value: bytes
exists: bool
def cache_set_string(key: str, value: str, ttl_seconds: int) -> None:
"""SetString stores a string value in the cache.
Parameters:
- key: The cache key (will be namespaced with plugin ID)
- value: The string value to store
- ttlSeconds: Time-to-live in seconds (0 uses default of 24 hours)
Returns an error if the operation fails.
Args:
key: str parameter.
value: str parameter.
ttl_seconds: int parameter.
Raises:
HostFunctionError: If the host function returns an error.
"""
request = {
"key": key,
"value": value,
"ttlSeconds": ttl_seconds,
}
request_bytes = json.dumps(request).encode("utf-8")
request_mem = extism.memory.alloc(request_bytes)
response_offset = _cache_setstring(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"])
def cache_get_string(key: str) -> CacheGetStringResult:
"""GetString retrieves a string value from the cache.
Parameters:
- key: The cache key (will be namespaced with plugin ID)
Returns the value and whether the key exists. If the key doesn't exist
or the stored value is not a string, exists will be false.
Args:
key: str parameter.
Returns:
CacheGetStringResult containing value, exists,.
Raises:
HostFunctionError: If the host function returns an error.
"""
request = {
"key": key,
}
request_bytes = json.dumps(request).encode("utf-8")
request_mem = extism.memory.alloc(request_bytes)
response_offset = _cache_getstring(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 CacheGetStringResult(
value=response.get("value", ""),
exists=response.get("exists", False),
)
def cache_set_int(key: str, value: int, ttl_seconds: int) -> None:
"""SetInt stores an integer value in the cache.
Parameters:
- key: The cache key (will be namespaced with plugin ID)
- value: The integer value to store
- ttlSeconds: Time-to-live in seconds (0 uses default of 24 hours)
Returns an error if the operation fails.
Args:
key: str parameter.
value: int parameter.
ttl_seconds: int parameter.
Raises:
HostFunctionError: If the host function returns an error.
"""
request = {
"key": key,
"value": value,
"ttlSeconds": ttl_seconds,
}
request_bytes = json.dumps(request).encode("utf-8")
request_mem = extism.memory.alloc(request_bytes)
response_offset = _cache_setint(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"])
def cache_get_int(key: str) -> CacheGetIntResult:
"""GetInt retrieves an integer value from the cache.
Parameters:
- key: The cache key (will be namespaced with plugin ID)
Returns the value and whether the key exists. If the key doesn't exist
or the stored value is not an integer, exists will be false.
Args:
key: str parameter.
Returns:
CacheGetIntResult containing value, exists,.
Raises:
HostFunctionError: If the host function returns an error.
"""
request = {
"key": key,
}
request_bytes = json.dumps(request).encode("utf-8")
request_mem = extism.memory.alloc(request_bytes)
response_offset = _cache_getint(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 CacheGetIntResult(
value=response.get("value", 0),
exists=response.get("exists", False),
)
def cache_set_float(key: str, value: float, ttl_seconds: int) -> None:
"""SetFloat stores a float value in the cache.
Parameters:
- key: The cache key (will be namespaced with plugin ID)
- value: The float value to store
- ttlSeconds: Time-to-live in seconds (0 uses default of 24 hours)
Returns an error if the operation fails.
Args:
key: str parameter.
value: float parameter.
ttl_seconds: int parameter.
Raises:
HostFunctionError: If the host function returns an error.
"""
request = {
"key": key,
"value": value,
"ttlSeconds": ttl_seconds,
}
request_bytes = json.dumps(request).encode("utf-8")
request_mem = extism.memory.alloc(request_bytes)
response_offset = _cache_setfloat(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"])
def cache_get_float(key: str) -> CacheGetFloatResult:
"""GetFloat retrieves a float value from the cache.
Parameters:
- key: The cache key (will be namespaced with plugin ID)
Returns the value and whether the key exists. If the key doesn't exist
or the stored value is not a float, exists will be false.
Args:
key: str parameter.
Returns:
CacheGetFloatResult containing value, exists,.
Raises:
HostFunctionError: If the host function returns an error.
"""
request = {
"key": key,
}
request_bytes = json.dumps(request).encode("utf-8")
request_mem = extism.memory.alloc(request_bytes)
response_offset = _cache_getfloat(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 CacheGetFloatResult(
value=response.get("value", 0.0),
exists=response.get("exists", False),
)
def cache_set_bytes(key: str, value: bytes, ttl_seconds: int) -> None:
"""SetBytes stores a byte slice in the cache.
Parameters:
- key: The cache key (will be namespaced with plugin ID)
- value: The byte slice to store
- ttlSeconds: Time-to-live in seconds (0 uses default of 24 hours)
Returns an error if the operation fails.
Args:
key: str parameter.
value: bytes parameter.
ttl_seconds: int parameter.
Raises:
HostFunctionError: If the host function returns an error.
"""
request = {
"key": key,
"value": value,
"ttlSeconds": ttl_seconds,
}
request_bytes = json.dumps(request).encode("utf-8")
request_mem = extism.memory.alloc(request_bytes)
response_offset = _cache_setbytes(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"])
def cache_get_bytes(key: str) -> CacheGetBytesResult:
"""GetBytes retrieves a byte slice from the cache.
Parameters:
- key: The cache key (will be namespaced with plugin ID)
Returns the value and whether the key exists. If the key doesn't exist
or the stored value is not a byte slice, exists will be false.
Args:
key: str parameter.
Returns:
CacheGetBytesResult containing value, exists,.
Raises:
HostFunctionError: If the host function returns an error.
"""
request = {
"key": key,
}
request_bytes = json.dumps(request).encode("utf-8")
request_mem = extism.memory.alloc(request_bytes)
response_offset = _cache_getbytes(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 CacheGetBytesResult(
value=response.get("value", b""),
exists=response.get("exists", False),
)
def cache_has(key: str) -> bool:
"""Has checks if a key exists in the cache.
Parameters:
- key: The cache key (will be namespaced with plugin ID)
Returns true if the key exists and has not expired.
Args:
key: str parameter.
Returns:
bool: The result value.
Raises:
HostFunctionError: If the host function returns an error.
"""
request = {
"key": key,
}
request_bytes = json.dumps(request).encode("utf-8")
request_mem = extism.memory.alloc(request_bytes)
response_offset = _cache_has(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("exists", False)
def cache_remove(key: str) -> None:
"""Remove deletes a value from the cache.
Parameters:
- key: The cache key (will be namespaced with plugin ID)
Returns an error if the operation fails. Does not return an error if the key doesn't exist.
Args:
key: str parameter.
Raises:
HostFunctionError: If the host function returns an error.
"""
request = {
"key": key,
}
request_bytes = json.dumps(request).encode("utf-8")
request_mem = extism.memory.alloc(request_bytes)
response_offset = _cache_remove(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

@ -0,0 +1,142 @@
# 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

@ -0,0 +1,54 @@
# 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

@ -0,0 +1,180 @@
# Code generated by hostgen. DO NOT EDIT.
#
# 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
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", "websocket_connect")
def _websocket_connect(offset: int) -> int:
"""Raw host function - do not call directly."""
...
@extism.import_fn("extism:host/user", "websocket_sendtext")
def _websocket_sendtext(offset: int) -> int:
"""Raw host function - do not call directly."""
...
@extism.import_fn("extism:host/user", "websocket_sendbinary")
def _websocket_sendbinary(offset: int) -> int:
"""Raw host function - do not call directly."""
...
@extism.import_fn("extism:host/user", "websocket_closeconnection")
def _websocket_closeconnection(offset: int) -> int:
"""Raw host function - do not call directly."""
...
def websocket_connect(url: str, headers: Any, connection_i_d: str) -> str:
"""Connect establishes a WebSocket connection to the specified URL.
Plugins that use this function must also implement the WebSocketCallback capability
to receive incoming messages and connection events.
Parameters:
- url: The WebSocket URL to connect to (ws:// or wss://)
- headers: Optional HTTP headers to include in the handshake request
- connectionID: Optional unique identifier for the connection. If empty, one will be generated
Returns the connection ID that can be used to send messages or close the connection,
or an error if the connection fails.
Args:
url: str parameter.
headers: Any parameter.
connection_i_d: str parameter.
Returns:
str: The result value.
Raises:
HostFunctionError: If the host function returns an error.
"""
request = {
"url": url,
"headers": headers,
"connectionID": connection_i_d,
}
request_bytes = json.dumps(request).encode("utf-8")
request_mem = extism.memory.alloc(request_bytes)
response_offset = _websocket_connect(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("newConnectionID", "")
def websocket_send_text(connection_i_d: str, message: str) -> None:
"""SendText sends a text message over an established WebSocket connection.
Parameters:
- connectionID: The connection identifier returned by Connect
- message: The text message to send
Returns an error if the connection is not found or if sending fails.
Args:
connection_i_d: str parameter.
message: str parameter.
Raises:
HostFunctionError: If the host function returns an error.
"""
request = {
"connectionID": connection_i_d,
"message": message,
}
request_bytes = json.dumps(request).encode("utf-8")
request_mem = extism.memory.alloc(request_bytes)
response_offset = _websocket_sendtext(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"])
def websocket_send_binary(connection_i_d: str, data: bytes) -> None:
"""SendBinary sends binary data over an established WebSocket connection.
Parameters:
- connectionID: The connection identifier returned by Connect
- data: The binary data to send
Returns an error if the connection is not found or if sending fails.
Args:
connection_i_d: str parameter.
data: bytes parameter.
Raises:
HostFunctionError: If the host function returns an error.
"""
request = {
"connectionID": connection_i_d,
"data": data,
}
request_bytes = json.dumps(request).encode("utf-8")
request_mem = extism.memory.alloc(request_bytes)
response_offset = _websocket_sendbinary(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"])
def websocket_close_connection(connection_i_d: str, code: int, reason: str) -> None:
"""CloseConnection gracefully closes a WebSocket connection.
Parameters:
- connectionID: The connection identifier returned by Connect
- code: WebSocket close status code (e.g., 1000 for normal closure)
- reason: Optional human-readable reason for closing
Returns an error if the connection is not found or if closing fails.
Args:
connection_i_d: str parameter.
code: int parameter.
reason: str parameter.
Raises:
HostFunctionError: If the host function returns an error.
"""
request = {
"connectionID": connection_i_d,
"code": code,
"reason": reason,
}
request_bytes = json.dumps(request).encode("utf-8")
request_mem = extism.memory.alloc(request_bytes)
response_offset = _websocket_closeconnection(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"])