squadron-sdk 0.1.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,47 @@
1
+ name: Publish
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ tags: ["v*"]
7
+ workflow_dispatch:
8
+
9
+ jobs:
10
+ build:
11
+ name: Build distribution
12
+ runs-on: ubuntu-latest
13
+ steps:
14
+ - uses: actions/checkout@v4
15
+
16
+ - uses: actions/setup-python@v5
17
+ with:
18
+ python-version: "3.12"
19
+
20
+ - name: Install build tooling
21
+ run: pip install build
22
+
23
+ - name: Build sdist + wheel
24
+ run: python -m build
25
+
26
+ - uses: actions/upload-artifact@v4
27
+ with:
28
+ name: dist
29
+ path: dist/
30
+
31
+ publish-pypi:
32
+ name: Publish to PyPI
33
+ needs: build
34
+ if: startsWith(github.ref, 'refs/tags/')
35
+ runs-on: ubuntu-latest
36
+ environment:
37
+ name: pypi
38
+ url: https://pypi.org/p/squadron-sdk
39
+ permissions:
40
+ id-token: write
41
+ steps:
42
+ - uses: actions/download-artifact@v4
43
+ with:
44
+ name: dist
45
+ path: dist/
46
+
47
+ - uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,7 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.egg-info/
4
+ .pytest_cache/
5
+ build/
6
+ dist/
7
+ *.pyc
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Max Lund
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,243 @@
1
+ Metadata-Version: 2.4
2
+ Name: squadron-sdk
3
+ Version: 0.1.1
4
+ Summary: Python SDK for writing Squadron tool plugins (wire-compatible with the Go squadron-sdk)
5
+ Author: Max Lund
6
+ License-Expression: MIT
7
+ License-File: LICENSE
8
+ Requires-Python: >=3.10
9
+ Requires-Dist: grpclib[protobuf]>=0.4.7
10
+ Requires-Dist: protobuf>=4.25
11
+ Requires-Dist: pydantic>=2.6
12
+ Requires-Dist: python-plugin>=0.1.0
13
+ Provides-Extra: dev
14
+ Requires-Dist: grpcio-tools>=1.60; extra == 'dev'
15
+ Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
16
+ Requires-Dist: pytest-timeout>=2.2; extra == 'dev'
17
+ Requires-Dist: pytest>=7; extra == 'dev'
18
+ Description-Content-Type: text/markdown
19
+
20
+ # squadron-sdk (Python)
21
+
22
+ Python SDK for writing [Squadron](https://github.com/mlund01/squadron) tool
23
+ plugins. Wire-compatible with the Go
24
+ [`squadron-sdk`](https://github.com/mlund01/squadron-sdk): a host built against
25
+ either SDK can launch plugins built against either SDK, in either language.
26
+
27
+ Built on [pyplugin](https://github.com/mlund01/py-plugin), the byte-for-byte
28
+ Python port of HashiCorp's go-plugin (including AutoMTLS with ECDSA P-521).
29
+
30
+ ## Quick start
31
+
32
+ ```python
33
+ # plugin.py
34
+ from typing import Literal
35
+ from pydantic import Field
36
+ from squadron_sdk import Squadron
37
+
38
+ app = Squadron()
39
+
40
+ @app.configure
41
+ def setup(settings: dict[str, str]) -> None:
42
+ app.prefix = settings.get("prefix", "")
43
+
44
+ @app.tool
45
+ async def echo(
46
+ message: str = Field(..., description="Text to echo back."),
47
+ repeat: int = Field(1, ge=1, le=100),
48
+ ) -> dict:
49
+ """Echo a message back, prefixed with the configured prefix."""
50
+ return {"echo": (app.prefix + message) * repeat}
51
+
52
+ @app.tool
53
+ def reverse(s: str, mode: Literal["chars", "words"] = "chars") -> str:
54
+ """Reverse a string by characters or words."""
55
+ return " ".join(reversed(s.split())) if mode == "words" else s[::-1]
56
+
57
+ if __name__ == "__main__":
58
+ app.serve()
59
+ ```
60
+
61
+ That's the whole plugin. The host gets:
62
+
63
+ - a `ToolPlugin.ListTools` response with `echo` and `reverse`,
64
+ - a JSON Schema derived from your type hints (including `Field(...)` metadata,
65
+ `Literal` enums, defaults, validators, nested pydantic models, …),
66
+ - input validation on every `Call`,
67
+ - automatic JSON serialization of return values.
68
+
69
+ Sync and async tool functions both work. Tool name defaults to the function
70
+ name and the description defaults to the docstring; override either with
71
+ `@app.tool(name="...", description="...")`.
72
+
73
+ ## Typed returns
74
+
75
+ The return type annotation is reflected into a JSON Schema and shipped as
76
+ the tool's `output_schema` — same machinery as the input. Plain `str`
77
+ returns pass through unwrapped (the LLM sees `hello` rather than
78
+ `"hello"`); everything else is JSON-marshaled via pydantic, so `BaseModel`,
79
+ dataclasses, `list[T]`, `dict[K, V]`, `Literal`, etc. all work.
80
+
81
+ ```python
82
+ class Item(BaseModel):
83
+ name: str
84
+ count: int
85
+
86
+ @app.tool
87
+ def make_item(name: str) -> Item:
88
+ return Item(name=name, count=3)
89
+ # wire: {"name":"x","count":3}
90
+ # output_schema: {"type":"object","properties":{"name":{"type":"string"},"count":{"type":"integer"}},"required":["name","count"]}
91
+
92
+ @app.tool
93
+ def upper(s: str) -> str:
94
+ return s.upper()
95
+ # wire: HI
96
+ # output_schema: {"type":"string"}
97
+ ```
98
+
99
+ The output schema flows over the wire and is available to LLM SDKs that
100
+ support per-tool output schemas — symmetric with the input schema.
101
+
102
+ ## What gets generated
103
+
104
+ For the `echo` tool above, the schema sent to the host looks like:
105
+
106
+ ```json
107
+ {
108
+ "type": "object",
109
+ "properties": {
110
+ "message": {"type": "string", "description": "Text to echo back."},
111
+ "repeat": {"type": "integer", "default": 1, "maximum": 100, "minimum": 1}
112
+ },
113
+ "required": ["message"]
114
+ }
115
+ ```
116
+
117
+ Nested pydantic models, `Literal[...]`, `list[T]`, `dict[K, V]`, `Annotated`,
118
+ optional fields with defaults — all the usual pydantic conveniences are
119
+ available because we go through `pydantic.create_model` and ship the
120
+ schema verbatim.
121
+
122
+ ## Calling from a Python host
123
+
124
+ ```python
125
+ import asyncio, sys
126
+ from pyplugin import Client, ClientConfig
127
+ from squadron_sdk import HANDSHAKE, PLUGIN_KEY, ToolPlugin
128
+
129
+ async def main():
130
+ async with Client(ClientConfig(
131
+ handshake_config=HANDSHAKE,
132
+ plugins={PLUGIN_KEY: ToolPlugin()},
133
+ cmd=[sys.executable, "plugin.py"],
134
+ )) as client:
135
+ tool = client.dispense(PLUGIN_KEY)
136
+ await tool.configure({"prefix": "hi: "})
137
+ for info in await tool.list_tools():
138
+ print(info.name, info.description)
139
+ print(await tool.call("echo", '{"message":"world"}'))
140
+
141
+ asyncio.run(main())
142
+ ```
143
+
144
+ A complete runnable example lives in [`examples/echo/`](examples/echo/).
145
+
146
+ ## Splitting tools across files
147
+
148
+ Two patterns work — pick whichever fits.
149
+
150
+ ### Shared app instance
151
+
152
+ A standalone Python app that owns its own tools: just import the same `app`
153
+ everywhere and decorate as you go.
154
+
155
+ ```python
156
+ # myplugin/app.py
157
+ from squadron_sdk import Squadron
158
+ app = Squadron()
159
+
160
+ # myplugin/tools/database.py
161
+ from myplugin.app import app
162
+
163
+ @app.tool
164
+ async def query(sql: str) -> dict: ...
165
+
166
+ # myplugin/main.py
167
+ from myplugin.app import app
168
+ from myplugin.tools import database # registration happens at import time
169
+
170
+ if __name__ == "__main__":
171
+ app.serve()
172
+ ```
173
+
174
+ ### Explicit `ToolGroup`
175
+
176
+ Better when tools are a reusable unit (a library, a swappable bundle, or
177
+ just clearly-bounded functionality). Tools in a group can read app-level
178
+ state via `group.app`, which is set when you `include` the group:
179
+
180
+ ```python
181
+ # myplugin/tools/text.py
182
+ from squadron_sdk import ToolGroup
183
+
184
+ text_tools = ToolGroup()
185
+
186
+ @text_tools.tool
187
+ def shout(s: str) -> str:
188
+ return text_tools.app.prefix + s.upper()
189
+
190
+ # myplugin/main.py
191
+ from squadron_sdk import Squadron
192
+ from myplugin.tools.text import text_tools
193
+
194
+ app = Squadron()
195
+
196
+ @app.configure
197
+ def setup(settings):
198
+ app.prefix = settings.get("prefix", "")
199
+
200
+ app.include(text_tools) # text_tools.app is now `app`
201
+ app.include(text_tools, prefix="t_") # or namespace: t_shout
202
+ app.serve()
203
+ ```
204
+
205
+ `ToolGroup` is just a tool registry — same `@tool` decorator, no
206
+ `@configure` or `.serve()`. Tool collisions raise on registration or
207
+ `include`. A group can only be included into one app.
208
+
209
+ ## Low-level API
210
+
211
+ If you need fully dynamic tools (e.g. discovered at runtime from a remote
212
+ schema), implement [`ToolProvider`](src/squadron_sdk/interface.py) directly
213
+ and call `serve(provider)`. `Squadron` is a thin layer over `ToolProvider`
214
+ that handles the registration plumbing.
215
+
216
+ ## Wire compatibility
217
+
218
+ Same handshake (`SQUAD_PLUGIN` / `squadron-tool-plugin-v1`, protocol
219
+ version 1) and protobuf service (`plugin.ToolPlugin`) as the Go SDK. A Go
220
+ Squadron host can launch a Python plugin built with this package, and a
221
+ Python host built with `pyplugin` can launch a Go plugin built with the Go
222
+ SDK.
223
+
224
+ The proto file lives at
225
+ [`src/squadron_sdk/proto/plugin.proto`](src/squadron_sdk/proto/plugin.proto)
226
+ and is identical to the Go SDK's. Regenerate the stubs with:
227
+
228
+ ```bash
229
+ python scripts/gen_protos.py
230
+ ```
231
+
232
+ ## Development
233
+
234
+ ```bash
235
+ python -m venv .venv
236
+ source .venv/bin/activate
237
+ pip install -e '.[dev]'
238
+ pytest
239
+ ```
240
+
241
+ ## License
242
+
243
+ MIT.
@@ -0,0 +1,224 @@
1
+ # squadron-sdk (Python)
2
+
3
+ Python SDK for writing [Squadron](https://github.com/mlund01/squadron) tool
4
+ plugins. Wire-compatible with the Go
5
+ [`squadron-sdk`](https://github.com/mlund01/squadron-sdk): a host built against
6
+ either SDK can launch plugins built against either SDK, in either language.
7
+
8
+ Built on [pyplugin](https://github.com/mlund01/py-plugin), the byte-for-byte
9
+ Python port of HashiCorp's go-plugin (including AutoMTLS with ECDSA P-521).
10
+
11
+ ## Quick start
12
+
13
+ ```python
14
+ # plugin.py
15
+ from typing import Literal
16
+ from pydantic import Field
17
+ from squadron_sdk import Squadron
18
+
19
+ app = Squadron()
20
+
21
+ @app.configure
22
+ def setup(settings: dict[str, str]) -> None:
23
+ app.prefix = settings.get("prefix", "")
24
+
25
+ @app.tool
26
+ async def echo(
27
+ message: str = Field(..., description="Text to echo back."),
28
+ repeat: int = Field(1, ge=1, le=100),
29
+ ) -> dict:
30
+ """Echo a message back, prefixed with the configured prefix."""
31
+ return {"echo": (app.prefix + message) * repeat}
32
+
33
+ @app.tool
34
+ def reverse(s: str, mode: Literal["chars", "words"] = "chars") -> str:
35
+ """Reverse a string by characters or words."""
36
+ return " ".join(reversed(s.split())) if mode == "words" else s[::-1]
37
+
38
+ if __name__ == "__main__":
39
+ app.serve()
40
+ ```
41
+
42
+ That's the whole plugin. The host gets:
43
+
44
+ - a `ToolPlugin.ListTools` response with `echo` and `reverse`,
45
+ - a JSON Schema derived from your type hints (including `Field(...)` metadata,
46
+ `Literal` enums, defaults, validators, nested pydantic models, …),
47
+ - input validation on every `Call`,
48
+ - automatic JSON serialization of return values.
49
+
50
+ Sync and async tool functions both work. Tool name defaults to the function
51
+ name and the description defaults to the docstring; override either with
52
+ `@app.tool(name="...", description="...")`.
53
+
54
+ ## Typed returns
55
+
56
+ The return type annotation is reflected into a JSON Schema and shipped as
57
+ the tool's `output_schema` — same machinery as the input. Plain `str`
58
+ returns pass through unwrapped (the LLM sees `hello` rather than
59
+ `"hello"`); everything else is JSON-marshaled via pydantic, so `BaseModel`,
60
+ dataclasses, `list[T]`, `dict[K, V]`, `Literal`, etc. all work.
61
+
62
+ ```python
63
+ class Item(BaseModel):
64
+ name: str
65
+ count: int
66
+
67
+ @app.tool
68
+ def make_item(name: str) -> Item:
69
+ return Item(name=name, count=3)
70
+ # wire: {"name":"x","count":3}
71
+ # output_schema: {"type":"object","properties":{"name":{"type":"string"},"count":{"type":"integer"}},"required":["name","count"]}
72
+
73
+ @app.tool
74
+ def upper(s: str) -> str:
75
+ return s.upper()
76
+ # wire: HI
77
+ # output_schema: {"type":"string"}
78
+ ```
79
+
80
+ The output schema flows over the wire and is available to LLM SDKs that
81
+ support per-tool output schemas — symmetric with the input schema.
82
+
83
+ ## What gets generated
84
+
85
+ For the `echo` tool above, the schema sent to the host looks like:
86
+
87
+ ```json
88
+ {
89
+ "type": "object",
90
+ "properties": {
91
+ "message": {"type": "string", "description": "Text to echo back."},
92
+ "repeat": {"type": "integer", "default": 1, "maximum": 100, "minimum": 1}
93
+ },
94
+ "required": ["message"]
95
+ }
96
+ ```
97
+
98
+ Nested pydantic models, `Literal[...]`, `list[T]`, `dict[K, V]`, `Annotated`,
99
+ optional fields with defaults — all the usual pydantic conveniences are
100
+ available because we go through `pydantic.create_model` and ship the
101
+ schema verbatim.
102
+
103
+ ## Calling from a Python host
104
+
105
+ ```python
106
+ import asyncio, sys
107
+ from pyplugin import Client, ClientConfig
108
+ from squadron_sdk import HANDSHAKE, PLUGIN_KEY, ToolPlugin
109
+
110
+ async def main():
111
+ async with Client(ClientConfig(
112
+ handshake_config=HANDSHAKE,
113
+ plugins={PLUGIN_KEY: ToolPlugin()},
114
+ cmd=[sys.executable, "plugin.py"],
115
+ )) as client:
116
+ tool = client.dispense(PLUGIN_KEY)
117
+ await tool.configure({"prefix": "hi: "})
118
+ for info in await tool.list_tools():
119
+ print(info.name, info.description)
120
+ print(await tool.call("echo", '{"message":"world"}'))
121
+
122
+ asyncio.run(main())
123
+ ```
124
+
125
+ A complete runnable example lives in [`examples/echo/`](examples/echo/).
126
+
127
+ ## Splitting tools across files
128
+
129
+ Two patterns work — pick whichever fits.
130
+
131
+ ### Shared app instance
132
+
133
+ A standalone Python app that owns its own tools: just import the same `app`
134
+ everywhere and decorate as you go.
135
+
136
+ ```python
137
+ # myplugin/app.py
138
+ from squadron_sdk import Squadron
139
+ app = Squadron()
140
+
141
+ # myplugin/tools/database.py
142
+ from myplugin.app import app
143
+
144
+ @app.tool
145
+ async def query(sql: str) -> dict: ...
146
+
147
+ # myplugin/main.py
148
+ from myplugin.app import app
149
+ from myplugin.tools import database # registration happens at import time
150
+
151
+ if __name__ == "__main__":
152
+ app.serve()
153
+ ```
154
+
155
+ ### Explicit `ToolGroup`
156
+
157
+ Better when tools are a reusable unit (a library, a swappable bundle, or
158
+ just clearly-bounded functionality). Tools in a group can read app-level
159
+ state via `group.app`, which is set when you `include` the group:
160
+
161
+ ```python
162
+ # myplugin/tools/text.py
163
+ from squadron_sdk import ToolGroup
164
+
165
+ text_tools = ToolGroup()
166
+
167
+ @text_tools.tool
168
+ def shout(s: str) -> str:
169
+ return text_tools.app.prefix + s.upper()
170
+
171
+ # myplugin/main.py
172
+ from squadron_sdk import Squadron
173
+ from myplugin.tools.text import text_tools
174
+
175
+ app = Squadron()
176
+
177
+ @app.configure
178
+ def setup(settings):
179
+ app.prefix = settings.get("prefix", "")
180
+
181
+ app.include(text_tools) # text_tools.app is now `app`
182
+ app.include(text_tools, prefix="t_") # or namespace: t_shout
183
+ app.serve()
184
+ ```
185
+
186
+ `ToolGroup` is just a tool registry — same `@tool` decorator, no
187
+ `@configure` or `.serve()`. Tool collisions raise on registration or
188
+ `include`. A group can only be included into one app.
189
+
190
+ ## Low-level API
191
+
192
+ If you need fully dynamic tools (e.g. discovered at runtime from a remote
193
+ schema), implement [`ToolProvider`](src/squadron_sdk/interface.py) directly
194
+ and call `serve(provider)`. `Squadron` is a thin layer over `ToolProvider`
195
+ that handles the registration plumbing.
196
+
197
+ ## Wire compatibility
198
+
199
+ Same handshake (`SQUAD_PLUGIN` / `squadron-tool-plugin-v1`, protocol
200
+ version 1) and protobuf service (`plugin.ToolPlugin`) as the Go SDK. A Go
201
+ Squadron host can launch a Python plugin built with this package, and a
202
+ Python host built with `pyplugin` can launch a Go plugin built with the Go
203
+ SDK.
204
+
205
+ The proto file lives at
206
+ [`src/squadron_sdk/proto/plugin.proto`](src/squadron_sdk/proto/plugin.proto)
207
+ and is identical to the Go SDK's. Regenerate the stubs with:
208
+
209
+ ```bash
210
+ python scripts/gen_protos.py
211
+ ```
212
+
213
+ ## Development
214
+
215
+ ```bash
216
+ python -m venv .venv
217
+ source .venv/bin/activate
218
+ pip install -e '.[dev]'
219
+ pytest
220
+ ```
221
+
222
+ ## License
223
+
224
+ MIT.
@@ -0,0 +1,15 @@
1
+ """Plugin used by tests to verify configure() errors propagate to the host."""
2
+ from __future__ import annotations
3
+
4
+ from squadron_sdk import Squadron
5
+
6
+ app = Squadron()
7
+
8
+
9
+ @app.configure
10
+ def fail(settings: dict[str, str]) -> None:
11
+ raise RuntimeError("boom")
12
+
13
+
14
+ if __name__ == "__main__":
15
+ app.serve()
@@ -0,0 +1,36 @@
1
+ """Sample host that launches the echo plugin and exercises every tool."""
2
+ from __future__ import annotations
3
+
4
+ import asyncio
5
+ import json
6
+ import sys
7
+ from pathlib import Path
8
+
9
+ from pyplugin import Client, ClientConfig
10
+
11
+ from squadron_sdk import HANDSHAKE, PLUGIN_KEY, ToolPlugin
12
+
13
+ PLUGIN_SCRIPT = Path(__file__).resolve().parent / "plugin.py"
14
+
15
+
16
+ async def main() -> None:
17
+ config = ClientConfig(
18
+ handshake_config=HANDSHAKE,
19
+ plugins={PLUGIN_KEY: ToolPlugin()},
20
+ cmd=[sys.executable, str(PLUGIN_SCRIPT)],
21
+ )
22
+ async with Client(config) as client:
23
+ tool = client.dispense(PLUGIN_KEY)
24
+ await tool.configure({"prefix": "[echo] "})
25
+
26
+ for info in await tool.list_tools():
27
+ print(f"\n{info.name}: {info.description}")
28
+ print(" schema:", json.dumps(info.schema, indent=2))
29
+
30
+ print("\necho:", await tool.call("echo", json.dumps({"message": "hi", "repeat": 2})))
31
+ print("count:", await tool.call("count", json.dumps({"text": "the quick brown fox"})))
32
+ print("reverse:", await tool.call("reverse", json.dumps({"s": "hello world", "mode": "words"})))
33
+
34
+
35
+ if __name__ == "__main__":
36
+ asyncio.run(main())
@@ -0,0 +1,35 @@
1
+ from __future__ import annotations
2
+
3
+ import sys
4
+ from pathlib import Path
5
+
6
+ if __package__ is None:
7
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
8
+
9
+ from pydantic import Field
10
+
11
+ from squadron_sdk import Squadron
12
+ from tools.text import text_tools
13
+
14
+ app = Squadron()
15
+
16
+
17
+ @app.configure
18
+ def setup(settings: dict[str, str]) -> None:
19
+ app.prefix = settings.get("prefix", "")
20
+
21
+
22
+ @app.tool
23
+ async def echo(
24
+ message: str = Field(..., description="Text to echo back."),
25
+ repeat: int = Field(1, ge=1, le=100, description="How many times to repeat."),
26
+ ) -> dict:
27
+ """Echo a message back, prefixed with the configured prefix."""
28
+ return {"echo": (app.prefix + message) * repeat}
29
+
30
+
31
+ app.include(text_tools)
32
+
33
+
34
+ if __name__ == "__main__":
35
+ app.serve()
File without changes
@@ -0,0 +1,23 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Literal
4
+
5
+ from squadron_sdk import ToolGroup
6
+
7
+ text_tools = ToolGroup()
8
+
9
+
10
+ @text_tools.tool
11
+ def reverse(s: str, mode: Literal["chars", "words"] = "chars") -> str:
12
+ """Reverse a string by characters or by words. Honors the app's prefix."""
13
+ out = " ".join(reversed(s.split())) if mode == "words" else s[::-1]
14
+ return text_tools.app.prefix + out
15
+
16
+
17
+ @text_tools.tool(name="count")
18
+ def count_words(text: str) -> dict:
19
+ """Count letters and words in a string."""
20
+ return {
21
+ "letters": sum(1 for ch in text if ch.isalpha()),
22
+ "words": len(text.split()),
23
+ }
@@ -0,0 +1,35 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.21"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "squadron-sdk"
7
+ version = "0.1.1"
8
+ description = "Python SDK for writing Squadron tool plugins (wire-compatible with the Go squadron-sdk)"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = "MIT"
12
+ authors = [{ name = "Max Lund" }]
13
+ dependencies = [
14
+ "python-plugin>=0.1.0",
15
+ "grpclib[protobuf]>=0.4.7",
16
+ "protobuf>=4.25",
17
+ "pydantic>=2.6",
18
+ ]
19
+
20
+ [project.optional-dependencies]
21
+ dev = [
22
+ "grpcio-tools>=1.60",
23
+ "pytest>=7",
24
+ "pytest-asyncio>=0.23",
25
+ "pytest-timeout>=2.2",
26
+ ]
27
+
28
+ [tool.hatch.build.targets.wheel]
29
+ packages = ["src/squadron_sdk"]
30
+
31
+ [tool.pytest.ini_options]
32
+ testpaths = ["tests"]
33
+ timeout = 30
34
+ addopts = "-q"
35
+ asyncio_mode = "auto"