From 08b952ef50af616c8e7c33290cae47d25a6d2656 Mon Sep 17 00:00:00 2001 From: Deluan Date: Fri, 26 Dec 2025 17:59:52 -0500 Subject: [PATCH] feat: generate Python client wrappers for various host services Signed-off-by: Deluan --- plugins/cmd/hostgen/README.md | 70 ++- plugins/cmd/hostgen/integration_test.go | 174 ++++++- plugins/cmd/hostgen/internal/generator.go | 53 +++ .../cmd/hostgen/internal/generator_test.go | 203 ++++++++ .../internal/templates/client_py.py.tmpl | 94 ++++ plugins/cmd/hostgen/internal/types.go | 55 +++ plugins/cmd/hostgen/main.go | 67 ++- .../hostgen/testdata/codec_client_expected.py | 51 ++ .../testdata/counter_client_expected.py | 51 ++ .../hostgen/testdata/echo_client_expected.py | 51 ++ .../hostgen/testdata/list_client_expected.py | 53 +++ .../hostgen/testdata/math_client_expected.py | 53 +++ .../hostgen/testdata/meta_client_expected.py | 80 ++++ .../hostgen/testdata/ping_client_expected.py | 41 ++ .../testdata/search_client_expected.py | 61 +++ .../hostgen/testdata/store_client_expected.py | 51 ++ .../hostgen/testdata/users_client_expected.py | 53 +++ plugins/host/python/nd_host_artwork.py | 182 +++++++ plugins/host/python/nd_host_cache.py | 446 ++++++++++++++++++ plugins/host/python/nd_host_scheduler.py | 142 ++++++ plugins/host/python/nd_host_subsonicapi.py | 54 +++ plugins/host/python/nd_host_websocket.py | 180 +++++++ 22 files changed, 2229 insertions(+), 36 deletions(-) create mode 100644 plugins/cmd/hostgen/internal/templates/client_py.py.tmpl create mode 100644 plugins/cmd/hostgen/testdata/codec_client_expected.py create mode 100644 plugins/cmd/hostgen/testdata/counter_client_expected.py create mode 100644 plugins/cmd/hostgen/testdata/echo_client_expected.py create mode 100644 plugins/cmd/hostgen/testdata/list_client_expected.py create mode 100644 plugins/cmd/hostgen/testdata/math_client_expected.py create mode 100644 plugins/cmd/hostgen/testdata/meta_client_expected.py create mode 100644 plugins/cmd/hostgen/testdata/ping_client_expected.py create mode 100644 plugins/cmd/hostgen/testdata/search_client_expected.py create mode 100644 plugins/cmd/hostgen/testdata/store_client_expected.py create mode 100644 plugins/cmd/hostgen/testdata/users_client_expected.py create mode 100644 plugins/host/python/nd_host_artwork.py create mode 100644 plugins/host/python/nd_host_cache.py create mode 100644 plugins/host/python/nd_host_scheduler.py create mode 100644 plugins/host/python/nd_host_subsonicapi.py create mode 100644 plugins/host/python/nd_host_websocket.py diff --git a/plugins/cmd/hostgen/README.md b/plugins/cmd/hostgen/README.md index 240deda5d..0a59c5325 100644 --- a/plugins/cmd/hostgen/README.md +++ b/plugins/cmd/hostgen/README.md @@ -19,8 +19,12 @@ hostgen -input -output -package [-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_.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_.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 diff --git a/plugins/cmd/hostgen/integration_test.go b/plugins/cmd/hostgen/integration_test.go index 6385ee48f..2730f0470 100644 --- a/plugins/cmd/hostgen/integration_test.go +++ b/plugins/cmd/hostgen/integration_test.go @@ -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"{}"`)) + }) }) }) diff --git a/plugins/cmd/hostgen/internal/generator.go b/plugins/cmd/hostgen/internal/generator.go index 4b609dcc4..d96614098 100644 --- a/plugins/cmd/hostgen/internal/generator.go +++ b/plugins/cmd/hostgen/internal/generator.go @@ -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" + } +} diff --git a/plugins/cmd/hostgen/internal/generator_test.go b/plugins/cmd/hostgen/internal/generator_test.go index 8b449527f..5d3c40ae3 100644 --- a/plugins/cmd/hostgen/internal/generator_test.go +++ b/plugins/cmd/hostgen/internal/generator_test.go @@ -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 diff --git a/plugins/cmd/hostgen/internal/templates/client_py.py.tmpl b/plugins/cmd/hostgen/internal/templates/client_py.py.tmpl new file mode 100644 index 000000000..8ae577e0e --- /dev/null +++ b/plugins/cmd/hostgen/internal/templates/client_py.py.tmpl @@ -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}} diff --git a/plugins/cmd/hostgen/internal/types.go b/plugins/cmd/hostgen/internal/types.go index 8cea8d696..9bcc7a190 100644 --- a/plugins/cmd/hostgen/internal/types.go +++ b/plugins/cmd/hostgen/internal/types.go @@ -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) +} diff --git a/plugins/cmd/hostgen/main.go b/plugins/cmd/hostgen/main.go index 4b3d600cf..c57c12536 100644 --- a/plugins/cmd/hostgen/main.go +++ b/plugins/cmd/hostgen/main.go @@ -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 } diff --git a/plugins/cmd/hostgen/testdata/codec_client_expected.py b/plugins/cmd/hostgen/testdata/codec_client_expected.py new file mode 100644 index 000000000..6e17a684a --- /dev/null +++ b/plugins/cmd/hostgen/testdata/codec_client_expected.py @@ -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"") diff --git a/plugins/cmd/hostgen/testdata/counter_client_expected.py b/plugins/cmd/hostgen/testdata/counter_client_expected.py new file mode 100644 index 000000000..4bce404a3 --- /dev/null +++ b/plugins/cmd/hostgen/testdata/counter_client_expected.py @@ -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) diff --git a/plugins/cmd/hostgen/testdata/echo_client_expected.py b/plugins/cmd/hostgen/testdata/echo_client_expected.py new file mode 100644 index 000000000..6de0d2c21 --- /dev/null +++ b/plugins/cmd/hostgen/testdata/echo_client_expected.py @@ -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", "") diff --git a/plugins/cmd/hostgen/testdata/list_client_expected.py b/plugins/cmd/hostgen/testdata/list_client_expected.py new file mode 100644 index 000000000..0be0e2a76 --- /dev/null +++ b/plugins/cmd/hostgen/testdata/list_client_expected.py @@ -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) diff --git a/plugins/cmd/hostgen/testdata/math_client_expected.py b/plugins/cmd/hostgen/testdata/math_client_expected.py new file mode 100644 index 000000000..dfaa895a2 --- /dev/null +++ b/plugins/cmd/hostgen/testdata/math_client_expected.py @@ -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) diff --git a/plugins/cmd/hostgen/testdata/meta_client_expected.py b/plugins/cmd/hostgen/testdata/meta_client_expected.py new file mode 100644 index 000000000..f900d0811 --- /dev/null +++ b/plugins/cmd/hostgen/testdata/meta_client_expected.py @@ -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"]) + diff --git a/plugins/cmd/hostgen/testdata/ping_client_expected.py b/plugins/cmd/hostgen/testdata/ping_client_expected.py new file mode 100644 index 000000000..503892778 --- /dev/null +++ b/plugins/cmd/hostgen/testdata/ping_client_expected.py @@ -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"]) + diff --git a/plugins/cmd/hostgen/testdata/search_client_expected.py b/plugins/cmd/hostgen/testdata/search_client_expected.py new file mode 100644 index 000000000..9befe7ab4 --- /dev/null +++ b/plugins/cmd/hostgen/testdata/search_client_expected.py @@ -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), + ) diff --git a/plugins/cmd/hostgen/testdata/store_client_expected.py b/plugins/cmd/hostgen/testdata/store_client_expected.py new file mode 100644 index 000000000..fd8ca5e40 --- /dev/null +++ b/plugins/cmd/hostgen/testdata/store_client_expected.py @@ -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", "") diff --git a/plugins/cmd/hostgen/testdata/users_client_expected.py b/plugins/cmd/hostgen/testdata/users_client_expected.py new file mode 100644 index 000000000..67927d950 --- /dev/null +++ b/plugins/cmd/hostgen/testdata/users_client_expected.py @@ -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) diff --git a/plugins/host/python/nd_host_artwork.py b/plugins/host/python/nd_host_artwork.py new file mode 100644 index 000000000..a41762cee --- /dev/null +++ b/plugins/host/python/nd_host_artwork.py @@ -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", "") diff --git a/plugins/host/python/nd_host_cache.py b/plugins/host/python/nd_host_cache.py new file mode 100644 index 000000000..8a91cfea9 --- /dev/null +++ b/plugins/host/python/nd_host_cache.py @@ -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"]) + diff --git a/plugins/host/python/nd_host_scheduler.py b/plugins/host/python/nd_host_scheduler.py new file mode 100644 index 000000000..f7b8e320b --- /dev/null +++ b/plugins/host/python/nd_host_scheduler.py @@ -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"]) + diff --git a/plugins/host/python/nd_host_subsonicapi.py b/plugins/host/python/nd_host_subsonicapi.py new file mode 100644 index 000000000..baabad0ec --- /dev/null +++ b/plugins/host/python/nd_host_subsonicapi.py @@ -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", "") diff --git a/plugins/host/python/nd_host_websocket.py b/plugins/host/python/nd_host_websocket.py new file mode 100644 index 000000000..37fff3d1f --- /dev/null +++ b/plugins/host/python/nd_host_websocket.py @@ -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"]) +