runqy-python 0.2.0__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,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Publikey
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,176 @@
1
+ Metadata-Version: 2.1
2
+ Name: runqy-python
3
+ Version: 0.2.0
4
+ Summary: Python SDK for runqy - write distributed task handlers with simple decorators
5
+ Author: Publikey
6
+ Project-URL: Documentation, https://docs.runqy.com
7
+ Project-URL: Repository, https://github.com/Publikey/runqy-python
8
+ Project-URL: Issues, https://github.com/Publikey/runqy-python/issues
9
+ Keywords: task-queue,distributed,worker,redis,async,client
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.8
15
+ Classifier: Programming Language :: Python :: 3.9
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
20
+ Requires-Python: >=3.8
21
+ Description-Content-Type: text/markdown
22
+ License-File: LICENSE
23
+
24
+ <p align="center">
25
+ <img src="assets/logo.svg" alt="runqy logo" width="80" height="80">
26
+ </p>
27
+
28
+ <h1 align="center">runqy-python</h1>
29
+
30
+ <p align="center">
31
+ Python SDK for <a href="https://runqy.com">runqy</a> - write distributed task handlers with simple decorators.
32
+ <br>
33
+ <a href="https://docs.runqy.com"><strong>Documentation</strong></a> · <a href="https://runqy.com"><strong>Website</strong></a>
34
+ </p>
35
+
36
+ ## Installation
37
+
38
+ ```bash
39
+ pip install runqy-python
40
+ ```
41
+
42
+ ## Task Handlers
43
+
44
+ Create tasks that run on [runqy-worker](https://github.com/publikey/runqy-worker) using simple decorators:
45
+
46
+ ### Simple Task
47
+
48
+ ```python
49
+ from runqy_python import task, run
50
+
51
+ @task
52
+ def process(payload: dict) -> dict:
53
+ return {"message": "Hello!", "received": payload}
54
+
55
+ if __name__ == "__main__":
56
+ run()
57
+ ```
58
+
59
+ ### With Model Loading
60
+
61
+ For ML inference tasks, use `@load` to load models once at startup:
62
+
63
+ ```python
64
+ from runqy_python import task, load, run
65
+
66
+ @load
67
+ def setup():
68
+ """Runs once before ready signal. Return value is passed to @task as ctx."""
69
+ model = load_heavy_model() # Load weights, etc.
70
+ return {"model": model}
71
+
72
+ @task
73
+ def process(payload: dict, ctx: dict) -> dict:
74
+ """Process tasks using the loaded model."""
75
+ prediction = ctx["model"].predict(payload["input"])
76
+ return {"prediction": prediction}
77
+
78
+ if __name__ == "__main__":
79
+ run()
80
+ ```
81
+
82
+ ### One-Shot Tasks
83
+
84
+ For lightweight tasks that don't need to stay loaded in memory, use `run_once()`:
85
+
86
+ ```python
87
+ from runqy_python import task, run_once
88
+
89
+ @task
90
+ def process(payload: dict) -> dict:
91
+ return {"result": payload["x"] * 2}
92
+
93
+ if __name__ == "__main__":
94
+ run_once() # Process one task and exit
95
+ ```
96
+
97
+ | Function | Behavior | Use case |
98
+ |----------|----------|----------|
99
+ | `run()` | Loops forever, handles many tasks | ML inference (expensive load) |
100
+ | `run_once()` | Handles ONE task, exits | Lightweight tasks |
101
+
102
+ ## Protocol
103
+
104
+ The SDK handles the runqy-worker stdin/stdout JSON protocol:
105
+
106
+ 1. **Load phase**: Calls `@load` function (if registered)
107
+ 2. **Ready signal**: Sends `{"status": "ready"}` after load completes
108
+ 3. **Task input**: Reads JSON from stdin: `{"task_id": "...", "payload": {...}}`
109
+ 4. **Response**: Writes JSON to stdout: `{"task_id": "...", "result": {...}, "error": null, "retry": false}`
110
+
111
+ ## Client (Optional)
112
+
113
+ The SDK also includes a client for enqueuing tasks to a runqy server:
114
+
115
+ ```python
116
+ from runqy_python import RunqyClient
117
+
118
+ client = RunqyClient("http://localhost:3000", api_key="your-api-key")
119
+
120
+ # Enqueue a task
121
+ task = client.enqueue("inference_default", {"input": "hello"})
122
+ print(f"Task ID: {task.task_id}")
123
+
124
+ # Check result
125
+ result = client.get_task(task.task_id)
126
+ print(f"State: {result.state}, Result: {result.result}")
127
+ ```
128
+
129
+ Or use the convenience function:
130
+
131
+ ```python
132
+ from runqy_python import enqueue
133
+
134
+ task = enqueue(
135
+ "inference_default",
136
+ {"input": "hello"},
137
+ server_url="http://localhost:3000",
138
+ api_key="your-api-key"
139
+ )
140
+ ```
141
+
142
+ ### Client API
143
+
144
+ **RunqyClient(server_url, api_key, timeout=30)**
145
+ - `server_url`: Base URL of the runqy server
146
+ - `api_key`: API key for authentication
147
+ - `timeout`: Default request timeout in seconds
148
+
149
+ **client.enqueue(queue, payload, timeout=300)**
150
+ - `queue`: Queue name (e.g., `"inference_default"`)
151
+ - `payload`: Task payload as a dict
152
+ - `timeout`: Task execution timeout in seconds
153
+ - Returns: `TaskInfo` with `task_id`, `queue`, `state`
154
+
155
+ **client.get_task(task_id)**
156
+ - `task_id`: Task ID from enqueue
157
+ - Returns: `TaskInfo` with `task_id`, `queue`, `state`, `result`, `error`
158
+
159
+ ### Exceptions
160
+
161
+ - `RunqyError`: Base exception for all client errors
162
+ - `AuthenticationError`: Invalid or missing API key
163
+ - `TaskNotFoundError`: Task ID doesn't exist
164
+
165
+ ## Development
166
+
167
+ ```bash
168
+ # Install in editable mode
169
+ pip install -e .
170
+
171
+ # Test task execution
172
+ echo '{"task_id":"t1","payload":{"foo":"bar"}}' | python your_model.py
173
+
174
+ # Test client import
175
+ python -c "from runqy_python import RunqyClient; print('OK')"
176
+ ```
@@ -0,0 +1,153 @@
1
+ <p align="center">
2
+ <img src="assets/logo.svg" alt="runqy logo" width="80" height="80">
3
+ </p>
4
+
5
+ <h1 align="center">runqy-python</h1>
6
+
7
+ <p align="center">
8
+ Python SDK for <a href="https://runqy.com">runqy</a> - write distributed task handlers with simple decorators.
9
+ <br>
10
+ <a href="https://docs.runqy.com"><strong>Documentation</strong></a> · <a href="https://runqy.com"><strong>Website</strong></a>
11
+ </p>
12
+
13
+ ## Installation
14
+
15
+ ```bash
16
+ pip install runqy-python
17
+ ```
18
+
19
+ ## Task Handlers
20
+
21
+ Create tasks that run on [runqy-worker](https://github.com/publikey/runqy-worker) using simple decorators:
22
+
23
+ ### Simple Task
24
+
25
+ ```python
26
+ from runqy_python import task, run
27
+
28
+ @task
29
+ def process(payload: dict) -> dict:
30
+ return {"message": "Hello!", "received": payload}
31
+
32
+ if __name__ == "__main__":
33
+ run()
34
+ ```
35
+
36
+ ### With Model Loading
37
+
38
+ For ML inference tasks, use `@load` to load models once at startup:
39
+
40
+ ```python
41
+ from runqy_python import task, load, run
42
+
43
+ @load
44
+ def setup():
45
+ """Runs once before ready signal. Return value is passed to @task as ctx."""
46
+ model = load_heavy_model() # Load weights, etc.
47
+ return {"model": model}
48
+
49
+ @task
50
+ def process(payload: dict, ctx: dict) -> dict:
51
+ """Process tasks using the loaded model."""
52
+ prediction = ctx["model"].predict(payload["input"])
53
+ return {"prediction": prediction}
54
+
55
+ if __name__ == "__main__":
56
+ run()
57
+ ```
58
+
59
+ ### One-Shot Tasks
60
+
61
+ For lightweight tasks that don't need to stay loaded in memory, use `run_once()`:
62
+
63
+ ```python
64
+ from runqy_python import task, run_once
65
+
66
+ @task
67
+ def process(payload: dict) -> dict:
68
+ return {"result": payload["x"] * 2}
69
+
70
+ if __name__ == "__main__":
71
+ run_once() # Process one task and exit
72
+ ```
73
+
74
+ | Function | Behavior | Use case |
75
+ |----------|----------|----------|
76
+ | `run()` | Loops forever, handles many tasks | ML inference (expensive load) |
77
+ | `run_once()` | Handles ONE task, exits | Lightweight tasks |
78
+
79
+ ## Protocol
80
+
81
+ The SDK handles the runqy-worker stdin/stdout JSON protocol:
82
+
83
+ 1. **Load phase**: Calls `@load` function (if registered)
84
+ 2. **Ready signal**: Sends `{"status": "ready"}` after load completes
85
+ 3. **Task input**: Reads JSON from stdin: `{"task_id": "...", "payload": {...}}`
86
+ 4. **Response**: Writes JSON to stdout: `{"task_id": "...", "result": {...}, "error": null, "retry": false}`
87
+
88
+ ## Client (Optional)
89
+
90
+ The SDK also includes a client for enqueuing tasks to a runqy server:
91
+
92
+ ```python
93
+ from runqy_python import RunqyClient
94
+
95
+ client = RunqyClient("http://localhost:3000", api_key="your-api-key")
96
+
97
+ # Enqueue a task
98
+ task = client.enqueue("inference_default", {"input": "hello"})
99
+ print(f"Task ID: {task.task_id}")
100
+
101
+ # Check result
102
+ result = client.get_task(task.task_id)
103
+ print(f"State: {result.state}, Result: {result.result}")
104
+ ```
105
+
106
+ Or use the convenience function:
107
+
108
+ ```python
109
+ from runqy_python import enqueue
110
+
111
+ task = enqueue(
112
+ "inference_default",
113
+ {"input": "hello"},
114
+ server_url="http://localhost:3000",
115
+ api_key="your-api-key"
116
+ )
117
+ ```
118
+
119
+ ### Client API
120
+
121
+ **RunqyClient(server_url, api_key, timeout=30)**
122
+ - `server_url`: Base URL of the runqy server
123
+ - `api_key`: API key for authentication
124
+ - `timeout`: Default request timeout in seconds
125
+
126
+ **client.enqueue(queue, payload, timeout=300)**
127
+ - `queue`: Queue name (e.g., `"inference_default"`)
128
+ - `payload`: Task payload as a dict
129
+ - `timeout`: Task execution timeout in seconds
130
+ - Returns: `TaskInfo` with `task_id`, `queue`, `state`
131
+
132
+ **client.get_task(task_id)**
133
+ - `task_id`: Task ID from enqueue
134
+ - Returns: `TaskInfo` with `task_id`, `queue`, `state`, `result`, `error`
135
+
136
+ ### Exceptions
137
+
138
+ - `RunqyError`: Base exception for all client errors
139
+ - `AuthenticationError`: Invalid or missing API key
140
+ - `TaskNotFoundError`: Task ID doesn't exist
141
+
142
+ ## Development
143
+
144
+ ```bash
145
+ # Install in editable mode
146
+ pip install -e .
147
+
148
+ # Test task execution
149
+ echo '{"task_id":"t1","payload":{"foo":"bar"}}' | python your_model.py
150
+
151
+ # Test client import
152
+ python -c "from runqy_python import RunqyClient; print('OK')"
153
+ ```
@@ -0,0 +1,35 @@
1
+ [project]
2
+ name = "runqy-python"
3
+ version = "0.2.0"
4
+ description = "Python SDK for runqy - write distributed task handlers with simple decorators"
5
+ readme = "README.md"
6
+ requires-python = ">=3.8"
7
+ dependencies = []
8
+ authors = [
9
+ {name = "Publikey"}
10
+ ]
11
+ keywords = ["task-queue", "distributed", "worker", "redis", "async", "client"]
12
+ classifiers = [
13
+ "Development Status :: 4 - Beta",
14
+ "Intended Audience :: Developers",
15
+ "License :: OSI Approved :: MIT License",
16
+ "Programming Language :: Python :: 3",
17
+ "Programming Language :: Python :: 3.8",
18
+ "Programming Language :: Python :: 3.9",
19
+ "Programming Language :: Python :: 3.10",
20
+ "Programming Language :: Python :: 3.11",
21
+ "Programming Language :: Python :: 3.12",
22
+ "Topic :: Software Development :: Libraries :: Python Modules",
23
+ ]
24
+
25
+ [project.urls]
26
+ Documentation = "https://docs.runqy.com"
27
+ Repository = "https://github.com/Publikey/runqy-python"
28
+ Issues = "https://github.com/Publikey/runqy-python/issues"
29
+
30
+ [build-system]
31
+ requires = ["setuptools>=61.0,<75.0", "wheel"]
32
+ build-backend = "setuptools.build_meta"
33
+
34
+ [tool.setuptools.packages.find]
35
+ where = ["src"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,32 @@
1
+ """runqy-python: Python SDK for runqy - write distributed task handlers with simple decorators."""
2
+
3
+ # Task execution (for workers)
4
+ from .decorator import task, load
5
+ from .runner import run, run_once
6
+
7
+ # Client (for enqueuing tasks)
8
+ from .client import (
9
+ RunqyClient,
10
+ TaskInfo,
11
+ RunqyError,
12
+ AuthenticationError,
13
+ TaskNotFoundError,
14
+ enqueue,
15
+ )
16
+
17
+ __all__ = [
18
+ # Task execution
19
+ "task",
20
+ "load",
21
+ "run",
22
+ "run_once",
23
+ # Client
24
+ "RunqyClient",
25
+ "TaskInfo",
26
+ "RunqyError",
27
+ "AuthenticationError",
28
+ "TaskNotFoundError",
29
+ "enqueue",
30
+ ]
31
+
32
+ __version__ = "0.2.0"
@@ -0,0 +1,240 @@
1
+ """Client for enqueuing tasks to runqy server."""
2
+
3
+ import json
4
+ import urllib.request
5
+ import urllib.error
6
+ from dataclasses import dataclass
7
+ from typing import Any, Dict, Optional
8
+
9
+
10
+ class RunqyError(Exception):
11
+ """Base exception for runqy client errors."""
12
+ pass
13
+
14
+
15
+ class AuthenticationError(RunqyError):
16
+ """Raised when API key is invalid or missing."""
17
+ pass
18
+
19
+
20
+ class TaskNotFoundError(RunqyError):
21
+ """Raised when a task is not found."""
22
+ pass
23
+
24
+
25
+ @dataclass
26
+ class TaskInfo:
27
+ """Task information returned from server.
28
+
29
+ Attributes:
30
+ task_id: Unique identifier for the task
31
+ queue: Queue name the task was submitted to
32
+ state: Task state (pending, active, completed, etc.)
33
+ result: Task result data (if completed)
34
+ error: Error message (if failed)
35
+ payload: Original task payload
36
+ """
37
+ task_id: str
38
+ queue: str
39
+ state: str
40
+ result: Optional[Any] = None
41
+ error: Optional[str] = None
42
+ payload: Optional[Dict[str, Any]] = None
43
+
44
+
45
+ class RunqyClient:
46
+ """Client for interacting with runqy server.
47
+
48
+ Example:
49
+ client = RunqyClient("http://localhost:3000", api_key="your-api-key")
50
+
51
+ # Enqueue a task
52
+ task = client.enqueue("inference_default", {"input": "hello"})
53
+ print(f"Task ID: {task.task_id}")
54
+
55
+ # Check result
56
+ result = client.get_task(task.task_id)
57
+ print(f"State: {result.state}, Result: {result.result}")
58
+ """
59
+
60
+ def __init__(self, server_url: str, api_key: str, timeout: int = 30):
61
+ """Initialize the client.
62
+
63
+ Args:
64
+ server_url: Base URL of the runqy server (e.g., "http://localhost:3000")
65
+ api_key: API key for authentication
66
+ timeout: Default request timeout in seconds
67
+ """
68
+ self.server_url = server_url.rstrip("/")
69
+ self.api_key = api_key
70
+ self.timeout = timeout
71
+
72
+ def _request(
73
+ self,
74
+ method: str,
75
+ path: str,
76
+ data: Optional[Dict[str, Any]] = None,
77
+ timeout: Optional[int] = None
78
+ ) -> Dict[str, Any]:
79
+ """Make an HTTP request to the server.
80
+
81
+ Args:
82
+ method: HTTP method (GET, POST, etc.)
83
+ path: API path (e.g., "/queue/add")
84
+ data: Request body data (for POST)
85
+ timeout: Request timeout in seconds
86
+
87
+ Returns:
88
+ Parsed JSON response
89
+
90
+ Raises:
91
+ AuthenticationError: If API key is invalid
92
+ RunqyError: For other HTTP errors
93
+ """
94
+ url = f"{self.server_url}{path}"
95
+ headers = {
96
+ "Authorization": f"Bearer {self.api_key}",
97
+ "Content-Type": "application/json",
98
+ }
99
+
100
+ body = None
101
+ if data is not None:
102
+ body = json.dumps(data).encode("utf-8")
103
+
104
+ req = urllib.request.Request(url, data=body, headers=headers, method=method)
105
+
106
+ try:
107
+ with urllib.request.urlopen(req, timeout=timeout or self.timeout) as resp:
108
+ response_data = resp.read().decode("utf-8")
109
+ return json.loads(response_data) if response_data else {}
110
+ except urllib.error.HTTPError as e:
111
+ response_body = e.read().decode("utf-8") if e.fp else ""
112
+
113
+ if e.code == 401:
114
+ raise AuthenticationError(f"Authentication failed: {response_body}")
115
+ elif e.code == 404:
116
+ raise TaskNotFoundError(f"Task not found: {response_body}")
117
+ else:
118
+ raise RunqyError(f"HTTP {e.code}: {response_body}")
119
+ except urllib.error.URLError as e:
120
+ raise RunqyError(f"Connection error: {e.reason}")
121
+
122
+ def enqueue(
123
+ self,
124
+ queue: str,
125
+ payload: Dict[str, Any],
126
+ timeout: int = 300
127
+ ) -> TaskInfo:
128
+ """Enqueue a task to a queue.
129
+
130
+ Args:
131
+ queue: Queue name (e.g., "inference_default")
132
+ payload: Task payload data
133
+ timeout: Task execution timeout in seconds (default: 300)
134
+
135
+ Returns:
136
+ TaskInfo with task_id and initial state
137
+
138
+ Raises:
139
+ AuthenticationError: If API key is invalid
140
+ RunqyError: For other errors
141
+
142
+ Example:
143
+ task = client.enqueue("inference_default", {"input": "hello"})
144
+ print(f"Enqueued task: {task.task_id}")
145
+ """
146
+ data = {
147
+ "queue": queue,
148
+ "timeout": timeout,
149
+ "data": payload,
150
+ }
151
+
152
+ response = self._request("POST", "/queue/add", data)
153
+
154
+ info = response.get("info", {})
155
+ return TaskInfo(
156
+ task_id=info.get("id", ""),
157
+ queue=info.get("queue", queue),
158
+ state=info.get("state", "pending"),
159
+ payload=payload,
160
+ )
161
+
162
+ def get_task(self, task_id: str) -> TaskInfo:
163
+ """Get task status and result.
164
+
165
+ Args:
166
+ task_id: Task ID returned from enqueue()
167
+
168
+ Returns:
169
+ TaskInfo with current state and result (if completed)
170
+
171
+ Raises:
172
+ TaskNotFoundError: If task doesn't exist
173
+ RunqyError: For other errors
174
+
175
+ Example:
176
+ result = client.get_task(task.task_id)
177
+ if result.state == "completed":
178
+ print(f"Result: {result.result}")
179
+ """
180
+ response = self._request("GET", f"/queue/{task_id}")
181
+
182
+ info = response.get("Info", response.get("info", {}))
183
+
184
+ # Parse result if it's a string (JSON)
185
+ result = info.get("Result", info.get("result"))
186
+ if isinstance(result, str) and result:
187
+ try:
188
+ result = json.loads(result)
189
+ except json.JSONDecodeError:
190
+ pass # Keep as string if not valid JSON
191
+
192
+ # Parse payload if it's a string (JSON)
193
+ payload = info.get("Payload", info.get("payload"))
194
+ if isinstance(payload, str) and payload:
195
+ try:
196
+ payload = json.loads(payload)
197
+ except json.JSONDecodeError:
198
+ pass # Keep as string if not valid JSON
199
+
200
+ return TaskInfo(
201
+ task_id=info.get("ID", info.get("id", task_id)),
202
+ queue=info.get("Queue", info.get("queue", "")),
203
+ state=info.get("State", info.get("state", "")),
204
+ result=result,
205
+ error=info.get("LastErr", info.get("last_err")),
206
+ payload=payload,
207
+ )
208
+
209
+
210
+ def enqueue(
211
+ queue: str,
212
+ payload: Dict[str, Any],
213
+ server_url: str,
214
+ api_key: str,
215
+ timeout: int = 300
216
+ ) -> TaskInfo:
217
+ """Quick enqueue without creating a client instance.
218
+
219
+ Args:
220
+ queue: Queue name (e.g., "inference_default")
221
+ payload: Task payload data
222
+ server_url: Base URL of the runqy server
223
+ api_key: API key for authentication
224
+ timeout: Task execution timeout in seconds (default: 300)
225
+
226
+ Returns:
227
+ TaskInfo with task_id and initial state
228
+
229
+ Example:
230
+ from runqy_python import enqueue
231
+
232
+ task = enqueue(
233
+ "inference_default",
234
+ {"input": "hello"},
235
+ server_url="http://localhost:3000",
236
+ api_key="your-api-key"
237
+ )
238
+ print(f"Task ID: {task.task_id}")
239
+ """
240
+ return RunqyClient(server_url, api_key).enqueue(queue, payload, timeout)
@@ -0,0 +1,53 @@
1
+ """Task decorators for registering handler and load functions."""
2
+
3
+ _registered_handler = None
4
+ _registered_loader = None
5
+
6
+
7
+ def task(func):
8
+ """Decorator to register a function as the task handler.
9
+
10
+ Usage:
11
+ @task
12
+ def process(payload: dict) -> dict:
13
+ return {"result": "..."}
14
+
15
+ # With context from @load:
16
+ @task
17
+ def process(payload: dict, ctx: dict) -> dict:
18
+ return ctx["model"].predict(payload)
19
+ """
20
+ global _registered_handler
21
+ _registered_handler = func
22
+ return func
23
+
24
+
25
+ def load(func):
26
+ """Decorator to register a function that runs once at startup.
27
+
28
+ The load function is called before the ready signal is sent.
29
+ Its return value is passed as the second argument (ctx) to the task handler.
30
+
31
+ Usage:
32
+ @load
33
+ def setup():
34
+ model = load_heavy_model()
35
+ return {"model": model}
36
+
37
+ @task
38
+ def process(payload: dict, ctx: dict) -> dict:
39
+ return ctx["model"].predict(payload)
40
+ """
41
+ global _registered_loader
42
+ _registered_loader = func
43
+ return func
44
+
45
+
46
+ def get_handler():
47
+ """Get the registered task handler."""
48
+ return _registered_handler
49
+
50
+
51
+ def get_loader():
52
+ """Get the registered load function."""
53
+ return _registered_loader
@@ -0,0 +1,127 @@
1
+ """Runner loop for processing tasks from runqy-worker."""
2
+
3
+ import sys
4
+ import json
5
+ from .decorator import get_handler, get_loader
6
+
7
+
8
+ def run():
9
+ """Main loop: load, ready signal, read tasks, call handler, write responses.
10
+
11
+ This function:
12
+ 1. Calls the @load function if registered (for model loading, etc.)
13
+ 2. Sends {"status": "ready"} to signal readiness to runqy-worker
14
+ 3. Reads JSON task requests from stdin (one per line)
15
+ 4. Calls the registered @task handler with the payload (and context if @load was used)
16
+ 5. Writes JSON responses to stdout
17
+ """
18
+ handler = get_handler()
19
+ if handler is None:
20
+ raise RuntimeError("No task handler registered. Use @task decorator.")
21
+
22
+ # Run load function if registered (before ready signal)
23
+ loader = get_loader()
24
+ ctx = None
25
+ if loader is not None:
26
+ ctx = loader()
27
+
28
+ # Ready signal
29
+ print(json.dumps({"status": "ready"}))
30
+ sys.stdout.flush()
31
+
32
+ # Process tasks from stdin
33
+ for line in sys.stdin:
34
+ line = line.strip()
35
+ if not line:
36
+ continue
37
+
38
+ task_id = "unknown"
39
+ try:
40
+ task_data = json.loads(line)
41
+ task_id = task_data.get("task_id", "unknown")
42
+ payload = task_data.get("payload", {})
43
+
44
+ # Call handler with or without context
45
+ if ctx is not None:
46
+ result = handler(payload, ctx)
47
+ else:
48
+ result = handler(payload)
49
+
50
+ response = {
51
+ "task_id": task_id,
52
+ "result": result,
53
+ "error": None,
54
+ "retry": False
55
+ }
56
+ except Exception as e:
57
+ response = {
58
+ "task_id": task_id,
59
+ "result": None,
60
+ "error": str(e),
61
+ "retry": False
62
+ }
63
+
64
+ print(json.dumps(response))
65
+ sys.stdout.flush()
66
+
67
+
68
+ def run_once():
69
+ """Process a single task from stdin and exit.
70
+
71
+ Use this for lightweight tasks that don't need to stay loaded in memory.
72
+
73
+ Flow:
74
+ 1. Calls @load function if registered
75
+ 2. Sends {"status": "ready"}
76
+ 3. Reads ONE JSON task from stdin
77
+ 4. Calls @task handler
78
+ 5. Writes response to stdout
79
+ 6. Exits
80
+ """
81
+ handler = get_handler()
82
+ if handler is None:
83
+ raise RuntimeError("No task handler registered. Use @task decorator.")
84
+
85
+ # Run load function if registered (before ready signal)
86
+ loader = get_loader()
87
+ ctx = None
88
+ if loader is not None:
89
+ ctx = loader()
90
+
91
+ # Ready signal
92
+ print(json.dumps({"status": "ready"}))
93
+ sys.stdout.flush()
94
+
95
+ # Read ONE task
96
+ line = sys.stdin.readline().strip()
97
+ if not line:
98
+ return
99
+
100
+ task_id = "unknown"
101
+ try:
102
+ task_data = json.loads(line)
103
+ task_id = task_data.get("task_id", "unknown")
104
+ payload = task_data.get("payload", {})
105
+
106
+ # Call handler with or without context
107
+ if ctx is not None:
108
+ result = handler(payload, ctx)
109
+ else:
110
+ result = handler(payload)
111
+
112
+ response = {
113
+ "task_id": task_id,
114
+ "result": result,
115
+ "error": None,
116
+ "retry": False
117
+ }
118
+ except Exception as e:
119
+ response = {
120
+ "task_id": task_id,
121
+ "result": None,
122
+ "error": str(e),
123
+ "retry": False
124
+ }
125
+
126
+ print(json.dumps(response))
127
+ sys.stdout.flush()
@@ -0,0 +1,176 @@
1
+ Metadata-Version: 2.1
2
+ Name: runqy-python
3
+ Version: 0.2.0
4
+ Summary: Python SDK for runqy - write distributed task handlers with simple decorators
5
+ Author: Publikey
6
+ Project-URL: Documentation, https://docs.runqy.com
7
+ Project-URL: Repository, https://github.com/Publikey/runqy-python
8
+ Project-URL: Issues, https://github.com/Publikey/runqy-python/issues
9
+ Keywords: task-queue,distributed,worker,redis,async,client
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.8
15
+ Classifier: Programming Language :: Python :: 3.9
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
20
+ Requires-Python: >=3.8
21
+ Description-Content-Type: text/markdown
22
+ License-File: LICENSE
23
+
24
+ <p align="center">
25
+ <img src="assets/logo.svg" alt="runqy logo" width="80" height="80">
26
+ </p>
27
+
28
+ <h1 align="center">runqy-python</h1>
29
+
30
+ <p align="center">
31
+ Python SDK for <a href="https://runqy.com">runqy</a> - write distributed task handlers with simple decorators.
32
+ <br>
33
+ <a href="https://docs.runqy.com"><strong>Documentation</strong></a> · <a href="https://runqy.com"><strong>Website</strong></a>
34
+ </p>
35
+
36
+ ## Installation
37
+
38
+ ```bash
39
+ pip install runqy-python
40
+ ```
41
+
42
+ ## Task Handlers
43
+
44
+ Create tasks that run on [runqy-worker](https://github.com/publikey/runqy-worker) using simple decorators:
45
+
46
+ ### Simple Task
47
+
48
+ ```python
49
+ from runqy_python import task, run
50
+
51
+ @task
52
+ def process(payload: dict) -> dict:
53
+ return {"message": "Hello!", "received": payload}
54
+
55
+ if __name__ == "__main__":
56
+ run()
57
+ ```
58
+
59
+ ### With Model Loading
60
+
61
+ For ML inference tasks, use `@load` to load models once at startup:
62
+
63
+ ```python
64
+ from runqy_python import task, load, run
65
+
66
+ @load
67
+ def setup():
68
+ """Runs once before ready signal. Return value is passed to @task as ctx."""
69
+ model = load_heavy_model() # Load weights, etc.
70
+ return {"model": model}
71
+
72
+ @task
73
+ def process(payload: dict, ctx: dict) -> dict:
74
+ """Process tasks using the loaded model."""
75
+ prediction = ctx["model"].predict(payload["input"])
76
+ return {"prediction": prediction}
77
+
78
+ if __name__ == "__main__":
79
+ run()
80
+ ```
81
+
82
+ ### One-Shot Tasks
83
+
84
+ For lightweight tasks that don't need to stay loaded in memory, use `run_once()`:
85
+
86
+ ```python
87
+ from runqy_python import task, run_once
88
+
89
+ @task
90
+ def process(payload: dict) -> dict:
91
+ return {"result": payload["x"] * 2}
92
+
93
+ if __name__ == "__main__":
94
+ run_once() # Process one task and exit
95
+ ```
96
+
97
+ | Function | Behavior | Use case |
98
+ |----------|----------|----------|
99
+ | `run()` | Loops forever, handles many tasks | ML inference (expensive load) |
100
+ | `run_once()` | Handles ONE task, exits | Lightweight tasks |
101
+
102
+ ## Protocol
103
+
104
+ The SDK handles the runqy-worker stdin/stdout JSON protocol:
105
+
106
+ 1. **Load phase**: Calls `@load` function (if registered)
107
+ 2. **Ready signal**: Sends `{"status": "ready"}` after load completes
108
+ 3. **Task input**: Reads JSON from stdin: `{"task_id": "...", "payload": {...}}`
109
+ 4. **Response**: Writes JSON to stdout: `{"task_id": "...", "result": {...}, "error": null, "retry": false}`
110
+
111
+ ## Client (Optional)
112
+
113
+ The SDK also includes a client for enqueuing tasks to a runqy server:
114
+
115
+ ```python
116
+ from runqy_python import RunqyClient
117
+
118
+ client = RunqyClient("http://localhost:3000", api_key="your-api-key")
119
+
120
+ # Enqueue a task
121
+ task = client.enqueue("inference_default", {"input": "hello"})
122
+ print(f"Task ID: {task.task_id}")
123
+
124
+ # Check result
125
+ result = client.get_task(task.task_id)
126
+ print(f"State: {result.state}, Result: {result.result}")
127
+ ```
128
+
129
+ Or use the convenience function:
130
+
131
+ ```python
132
+ from runqy_python import enqueue
133
+
134
+ task = enqueue(
135
+ "inference_default",
136
+ {"input": "hello"},
137
+ server_url="http://localhost:3000",
138
+ api_key="your-api-key"
139
+ )
140
+ ```
141
+
142
+ ### Client API
143
+
144
+ **RunqyClient(server_url, api_key, timeout=30)**
145
+ - `server_url`: Base URL of the runqy server
146
+ - `api_key`: API key for authentication
147
+ - `timeout`: Default request timeout in seconds
148
+
149
+ **client.enqueue(queue, payload, timeout=300)**
150
+ - `queue`: Queue name (e.g., `"inference_default"`)
151
+ - `payload`: Task payload as a dict
152
+ - `timeout`: Task execution timeout in seconds
153
+ - Returns: `TaskInfo` with `task_id`, `queue`, `state`
154
+
155
+ **client.get_task(task_id)**
156
+ - `task_id`: Task ID from enqueue
157
+ - Returns: `TaskInfo` with `task_id`, `queue`, `state`, `result`, `error`
158
+
159
+ ### Exceptions
160
+
161
+ - `RunqyError`: Base exception for all client errors
162
+ - `AuthenticationError`: Invalid or missing API key
163
+ - `TaskNotFoundError`: Task ID doesn't exist
164
+
165
+ ## Development
166
+
167
+ ```bash
168
+ # Install in editable mode
169
+ pip install -e .
170
+
171
+ # Test task execution
172
+ echo '{"task_id":"t1","payload":{"foo":"bar"}}' | python your_model.py
173
+
174
+ # Test client import
175
+ python -c "from runqy_python import RunqyClient; print('OK')"
176
+ ```
@@ -0,0 +1,11 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ src/runqy_python/__init__.py
5
+ src/runqy_python/client.py
6
+ src/runqy_python/decorator.py
7
+ src/runqy_python/runner.py
8
+ src/runqy_python.egg-info/PKG-INFO
9
+ src/runqy_python.egg-info/SOURCES.txt
10
+ src/runqy_python.egg-info/dependency_links.txt
11
+ src/runqy_python.egg-info/top_level.txt
@@ -0,0 +1 @@
1
+ runqy_python