pineapple-pine 0.7.0__py3-none-any.whl

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.
pine/__init__.py ADDED
@@ -0,0 +1,8 @@
1
+ from pine.engine import Engine
2
+ from pine.errors import ConfigError, OperatorException, PanicError, ValidationError
3
+ from pine.registry import Registry
4
+
5
+ __all__ = [
6
+ "Engine", "Registry", "ConfigError", "OperatorException",
7
+ "PanicError", "ValidationError",
8
+ ]
pine/cancellation.py ADDED
@@ -0,0 +1,20 @@
1
+ import threading
2
+
3
+
4
+ class CancellationToken:
5
+ def __init__(self, parent: "CancellationToken | None" = None):
6
+ self._cancelled = threading.Event()
7
+ self._parent = parent
8
+
9
+ def cancel(self):
10
+ self._cancelled.set()
11
+
12
+ def is_cancelled(self) -> bool:
13
+ if self._cancelled.is_set():
14
+ return True
15
+ if self._parent is not None:
16
+ return self._parent.is_cancelled()
17
+ return False
18
+
19
+ def child(self) -> "CancellationToken":
20
+ return CancellationToken(parent=self)
pine/cli/__init__.py ADDED
File without changes
pine/cli/codegen.py ADDED
@@ -0,0 +1,67 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import sys
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ from pine.registry import Registry
9
+
10
+
11
+ def main():
12
+ from pine.operators import ensure_registered
13
+ ensure_registered()
14
+
15
+ output_dir = ""
16
+ schema_json_path = ""
17
+ export_schema = ""
18
+ args = sys.argv[1:]
19
+ i = 0
20
+ while i < len(args):
21
+ if args[i] == "-output" and i + 1 < len(args):
22
+ i += 1
23
+ output_dir = args[i]
24
+ elif args[i] == "-schema-json" and i + 1 < len(args):
25
+ i += 1
26
+ schema_json_path = args[i]
27
+ elif args[i] == "--export-schema" and i + 1 < len(args):
28
+ i += 1
29
+ export_schema = args[i]
30
+ i += 1
31
+
32
+ if export_schema or schema_json_path:
33
+ out_path = export_schema or schema_json_path
34
+ schemas = Registry.global_instance().schemas()
35
+ schema_list: list[dict[str, Any]] = []
36
+ for schema in schemas:
37
+ params: dict[str, Any] = {}
38
+ for pname, pspec in schema.params.items():
39
+ params[pname] = {
40
+ "Type": pspec.type,
41
+ "Required": pspec.required,
42
+ "Default": pspec.default_value,
43
+ "Description": pspec.description,
44
+ }
45
+ schema_list.append({
46
+ "Name": schema.name,
47
+ "Type": schema.type.value,
48
+ "Description": schema.description,
49
+ "Params": params,
50
+ })
51
+ Path(out_path).write_text(
52
+ json.dumps(schema_list, indent=2, ensure_ascii=False)
53
+ )
54
+ return
55
+
56
+ if not output_dir:
57
+ print("Usage: Codegen --export-schema <path> | -schema-json <path> | -output <dir>",
58
+ file=sys.stderr)
59
+ sys.exit(1)
60
+
61
+ # TODO: codegen Python output (generate operators.py, resources.py, __init__.py)
62
+ print(f"codegen output to {output_dir} not yet implemented", file=sys.stderr)
63
+ sys.exit(1)
64
+
65
+
66
+ if __name__ == "__main__":
67
+ main()
pine/cli/dag.py ADDED
@@ -0,0 +1,62 @@
1
+ from __future__ import annotations
2
+
3
+ import sys
4
+ from pathlib import Path
5
+
6
+
7
+ def main():
8
+ from pine.operators import ensure_registered
9
+ ensure_registered()
10
+
11
+ config_path = ""
12
+ format_ = "dot"
13
+ collapse = 0
14
+
15
+ args = sys.argv[1:]
16
+ i = 0
17
+ while i < len(args):
18
+ if args[i] == "-config" and i + 1 < len(args):
19
+ i += 1
20
+ config_path = args[i]
21
+ elif args[i] == "-format" and i + 1 < len(args):
22
+ i += 1
23
+ format_ = args[i]
24
+ elif args[i] == "-collapse" and i + 1 < len(args):
25
+ i += 1
26
+ collapse = int(args[i])
27
+ i += 1
28
+
29
+ if not config_path:
30
+ print(
31
+ "Usage: RenderDAGCli -config <path> [-format dot|mermaid] [-collapse N]",
32
+ file=sys.stderr,
33
+ )
34
+ sys.exit(1)
35
+
36
+ try:
37
+ data = Path(config_path).read_bytes()
38
+ except IOError as e:
39
+ print(f"error reading config: {e}", file=sys.stderr)
40
+ sys.exit(1)
41
+
42
+ from pine.engine import Engine, StaticResourceProvider
43
+ from pine.errors import ConfigError, RegistryError
44
+
45
+ try:
46
+ rp = StaticResourceProvider({})
47
+ engine = Engine.create(data, resource_provider=rp)
48
+ except (ConfigError, RegistryError) as e:
49
+ print(f"error creating engine: {e}", file=sys.stderr)
50
+ sys.exit(1)
51
+
52
+ try:
53
+ output = engine.render_dag(format_, collapse)
54
+ except ValueError as e:
55
+ print(f"error rendering DAG: {e}", file=sys.stderr)
56
+ sys.exit(1)
57
+
58
+ sys.stdout.write(output)
59
+
60
+
61
+ if __name__ == "__main__":
62
+ main()
pine/cli/run.py ADDED
@@ -0,0 +1,97 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import sys
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ from pine.go_format import go_json_marshal_indent
9
+
10
+
11
+ def main():
12
+ from pine.operators import ensure_registered
13
+ ensure_registered()
14
+
15
+ config_path = ""
16
+ request_path = ""
17
+ resources_path = ""
18
+
19
+ args = sys.argv[1:]
20
+ i = 0
21
+ while i < len(args):
22
+ if args[i] == "-config" and i + 1 < len(args):
23
+ i += 1
24
+ config_path = args[i]
25
+ elif args[i] == "-request" and i + 1 < len(args):
26
+ i += 1
27
+ request_path = args[i]
28
+ elif args[i] == "-static-resources" and i + 1 < len(args):
29
+ i += 1
30
+ resources_path = args[i]
31
+ i += 1
32
+
33
+ if not config_path or not request_path:
34
+ print(
35
+ "Usage: RunCli -config <pipeline.json> -request <request.json> "
36
+ "[-static-resources <resources.json>]",
37
+ file=sys.stderr,
38
+ )
39
+ sys.exit(1)
40
+
41
+ try:
42
+ config_data = Path(config_path).read_bytes()
43
+ except IOError as e:
44
+ print(f"error reading config: {e}", file=sys.stderr)
45
+ sys.exit(1)
46
+
47
+ try:
48
+ request_data = Path(request_path).read_bytes()
49
+ except IOError as e:
50
+ print(f"error reading request: {e}", file=sys.stderr)
51
+ sys.exit(1)
52
+
53
+ resource_provider = None
54
+ if resources_path:
55
+ try:
56
+ res_data = Path(resources_path).read_bytes()
57
+ resources = json.loads(res_data)
58
+ from pine.engine import StaticResourceProvider
59
+ resource_provider = StaticResourceProvider(resources)
60
+ except IOError as e:
61
+ print(f"error reading static resources: {e}", file=sys.stderr)
62
+ sys.exit(1)
63
+
64
+ from pine.engine import Engine
65
+ from pine.errors import ConfigError, RegistryError
66
+
67
+ try:
68
+ engine = Engine.create(config_data, resource_provider=resource_provider)
69
+ except (ConfigError, RegistryError) as e:
70
+ print(f"error creating engine: {e}", file=sys.stderr)
71
+ sys.exit(1)
72
+
73
+ try:
74
+ req = json.loads(request_data)
75
+ except json.JSONDecodeError as e:
76
+ print(f"error parsing request: {e}", file=sys.stderr)
77
+ sys.exit(1)
78
+
79
+ common = req.get("common", {})
80
+ items = req.get("items", [])
81
+
82
+ result = engine.execute(common, items)
83
+
84
+ if result.error is not None:
85
+ print(f"execution error: {result.error}", file=sys.stderr)
86
+ sys.exit(1)
87
+
88
+ output: dict[str, Any] = {}
89
+ output["common"] = result.common
90
+ output["items"] = result.items
91
+
92
+ json_str = go_json_marshal_indent(output)
93
+ print(json_str)
94
+
95
+
96
+ if __name__ == "__main__":
97
+ main()
pine/cli/server.py ADDED
@@ -0,0 +1,346 @@
1
+ """Pine HTTP server -- compatible with Go/Java pineapple-server."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import sys
6
+ import threading
7
+ import time
8
+ from http.server import BaseHTTPRequestHandler, HTTPServer
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ from pine.engine import Engine, StaticResourceProvider
13
+ from pine.errors import ConfigError, RegistryError, ValidationError
14
+ from pine.go_format import go_json_marshal
15
+
16
+ _DEFAULT_MAX_BODY = 10 * 1024 * 1024 # 10MB
17
+
18
+
19
+ class _ServerState:
20
+ """Thread-safe mutable server state (engine + reload stats)."""
21
+
22
+ def __init__(self, engine: Engine):
23
+ self._lock = threading.Lock()
24
+ self._engine = engine
25
+ self.reload_count = 0
26
+ self.reload_error_count = 0
27
+ self.last_reload_duration_ns = 0
28
+
29
+ @property
30
+ def engine(self) -> Engine:
31
+ with self._lock:
32
+ return self._engine
33
+
34
+ def swap_engine(self, new_engine: Engine, duration_ns: int):
35
+ with self._lock:
36
+ self._engine = new_engine
37
+ self.reload_count += 1
38
+ self.last_reload_duration_ns = duration_ns
39
+
40
+ def record_reload_error(self):
41
+ with self._lock:
42
+ self.reload_error_count += 1
43
+
44
+ def server_stats(self) -> dict[str, Any]:
45
+ with self._lock:
46
+ return {
47
+ "last_reload_duration_ns": self.last_reload_duration_ns,
48
+ "reload_count": self.reload_count,
49
+ "reload_error_count": self.reload_error_count,
50
+ }
51
+
52
+
53
+ class _PineHandler(BaseHTTPRequestHandler):
54
+ state: _ServerState
55
+ max_body: int
56
+
57
+ def log_message(self, format, *args):
58
+ pass
59
+
60
+ def do_GET(self):
61
+ path = self.path.split("?")[0]
62
+ if path == "/health":
63
+ self._json_response(200, {"status": "ok"})
64
+ elif path == "/stats":
65
+ self._handle_stats()
66
+ elif path == "/dag":
67
+ self._handle_dag()
68
+ elif path == "/execute":
69
+ self._method_not_allowed()
70
+ else:
71
+ self._json_response(404, {"error": "not found"})
72
+
73
+ def do_POST(self):
74
+ path = self.path.split("?")[0]
75
+ if path == "/execute":
76
+ self._handle_execute()
77
+ elif path in ("/health", "/stats", "/dag"):
78
+ self._method_not_allowed()
79
+ else:
80
+ self._json_response(404, {"error": "not found"})
81
+
82
+ def _method_not_allowed(self):
83
+ self._json_response(405, {"error": "method not allowed"})
84
+
85
+ def _handle_execute(self):
86
+ content_length = int(self.headers.get("Content-Length", 0))
87
+ if content_length > self.max_body:
88
+ self._json_response(413, {"error": "request body too large"})
89
+ return
90
+
91
+ body = self.rfile.read(content_length)
92
+ if len(body) > self.max_body:
93
+ self._json_response(413, {"error": "request body too large"})
94
+ return
95
+
96
+ try:
97
+ req = json.loads(body)
98
+ except (json.JSONDecodeError, ValueError) as e:
99
+ self._json_response(400, {"error": f"invalid request: {e}"})
100
+ return
101
+
102
+ if not isinstance(req, dict):
103
+ self._json_response(400, {"error": "invalid request: expected JSON object"})
104
+ return
105
+
106
+ common = req.get("common")
107
+ items = req.get("items")
108
+
109
+ return_trace = False
110
+ if common and isinstance(common, dict):
111
+ return_trace = common.pop("_return_trace", False) is True
112
+
113
+ try:
114
+ result = self.state.engine.execute(common, items)
115
+ except ValidationError as e:
116
+ error_msg = f"pine: validation error: {e}"
117
+ resp: dict[str, Any] = {"common": None, "items": None, "error": error_msg}
118
+ self._json_response(400, resp)
119
+ return
120
+
121
+ resp = _build_response(result, return_trace)
122
+
123
+ if result.error is not None:
124
+ self._json_response(500, resp)
125
+ else:
126
+ self._json_response(200, resp)
127
+
128
+ def _handle_stats(self):
129
+ engine = self.state.engine
130
+ stats = engine.stats()
131
+ sched = engine.scheduler_stats()
132
+ custom = engine.operator_custom_stats()
133
+
134
+ resp: dict[str, Any] = {
135
+ "operators": stats,
136
+ "scheduler": sched,
137
+ "server": self.state.server_stats(),
138
+ }
139
+
140
+ if custom:
141
+ resp["operator_detail"] = custom
142
+
143
+ self._json_response(200, resp)
144
+
145
+ def _handle_dag(self):
146
+ params = _parse_query(self.path)
147
+ fmt = params.get("format", "dot")
148
+ collapse_str = params.get("collapse", "0")
149
+
150
+ try:
151
+ collapse = int(collapse_str)
152
+ if collapse < 0:
153
+ raise ValueError()
154
+ except ValueError:
155
+ self._json_response(400, {"error": "collapse must be a non-negative integer"})
156
+ return
157
+
158
+ try:
159
+ output = self.state.engine.render_dag(fmt, collapse)
160
+ except (ValidationError, ValueError) as e:
161
+ self._json_response(400, {"error": str(e)})
162
+ return
163
+
164
+ if fmt == "mermaid":
165
+ ct = "text/plain; charset=utf-8"
166
+ else:
167
+ ct = "text/vnd.graphviz; charset=utf-8"
168
+
169
+ self.send_response(200)
170
+ self.send_header("Content-Type", ct)
171
+ encoded = output.encode("utf-8")
172
+ self.send_header("Content-Length", str(len(encoded)))
173
+ self.end_headers()
174
+ self.wfile.write(encoded)
175
+
176
+ def _json_response(self, status: int, obj: Any):
177
+ body = go_json_marshal(obj) + "\n"
178
+ encoded = body.encode("utf-8")
179
+ self.send_response(status)
180
+ self.send_header("Content-Type", "application/json")
181
+ self.send_header("Content-Length", str(len(encoded)))
182
+ self.end_headers()
183
+ self.wfile.write(encoded)
184
+
185
+
186
+ def _build_response(result: Any, return_trace: bool) -> dict[str, Any]:
187
+ resp: dict[str, Any] = {
188
+ "common": result.common,
189
+ "items": result.items,
190
+ }
191
+
192
+ if result.warnings:
193
+ resp["warnings"] = [
194
+ f'operator "{w.operator}": {w.err}' for w in result.warnings
195
+ ]
196
+
197
+ if return_trace and result.trace:
198
+ trace_list = []
199
+ for t in result.trace:
200
+ if t.duration_ns == 0 and not t.skipped:
201
+ continue
202
+ entry: dict[str, Any] = {
203
+ "name": t.name,
204
+ "duration_ms": t.duration_ns / 1_000_000.0,
205
+ }
206
+ if t.skipped:
207
+ entry["skipped"] = True
208
+ if t.input_snapshot is not None:
209
+ entry["input_snapshot"] = t.input_snapshot
210
+ if t.output_snapshot is not None:
211
+ entry["output_snapshot"] = t.output_snapshot
212
+ trace_list.append(entry)
213
+ if trace_list:
214
+ resp["trace"] = trace_list
215
+
216
+ if result.error is not None:
217
+ resp["error"] = str(result.error)
218
+
219
+ return resp
220
+
221
+
222
+ def _parse_query(path: str) -> dict[str, str]:
223
+ params: dict[str, str] = {}
224
+ if "?" in path:
225
+ query = path.split("?", 1)[1]
226
+ for part in query.split("&"):
227
+ if "=" in part:
228
+ k, v = part.split("=", 1)
229
+ params[k] = v
230
+ else:
231
+ params[part] = ""
232
+ return params
233
+
234
+
235
+ def _watch_config(state: _ServerState, config_path: str, resource_provider: Any,
236
+ stop_event: threading.Event):
237
+ """Poll config file for changes and hot-reload the engine."""
238
+ path = Path(config_path)
239
+ try:
240
+ last_mod = path.stat().st_mtime
241
+ except OSError:
242
+ last_mod = 0.0
243
+
244
+ while not stop_event.is_set():
245
+ if stop_event.wait(timeout=2):
246
+ break
247
+ try:
248
+ cur_mod = path.stat().st_mtime
249
+ except OSError:
250
+ continue
251
+ if cur_mod <= last_mod:
252
+ continue
253
+ last_mod = cur_mod
254
+ start_ns = time.perf_counter_ns()
255
+ try:
256
+ data = path.read_bytes()
257
+ new_engine = Engine.create(data, resource_provider=resource_provider)
258
+ duration_ns = time.perf_counter_ns() - start_ns
259
+ state.swap_engine(new_engine, duration_ns)
260
+ print(f"config reloaded from {config_path}", file=sys.stderr)
261
+ except Exception as e:
262
+ state.record_reload_error()
263
+ print(f"config reload failed: {e}", file=sys.stderr)
264
+
265
+
266
+ def main():
267
+ from pine.operators import ensure_registered
268
+ ensure_registered()
269
+
270
+ config_path = ""
271
+ addr = ":8080"
272
+ max_body = _DEFAULT_MAX_BODY
273
+ resources_path = ""
274
+
275
+ args = sys.argv[1:]
276
+ i = 0
277
+ while i < len(args):
278
+ if args[i] == "-config" and i + 1 < len(args):
279
+ i += 1
280
+ config_path = args[i]
281
+ elif args[i] == "-addr" and i + 1 < len(args):
282
+ i += 1
283
+ addr = args[i]
284
+ elif args[i] == "-max-body-size" and i + 1 < len(args):
285
+ i += 1
286
+ max_body = int(args[i])
287
+ elif args[i] == "-static-resources" and i + 1 < len(args):
288
+ i += 1
289
+ resources_path = args[i]
290
+ i += 1
291
+
292
+ if not config_path:
293
+ print("Usage: server -config <path> [-addr :8080] [-max-body-size 10485760]",
294
+ file=sys.stderr)
295
+ sys.exit(1)
296
+
297
+ config_data = Path(config_path).read_bytes()
298
+
299
+ resource_provider = None
300
+ if resources_path:
301
+ res_data = json.loads(Path(resources_path).read_bytes())
302
+ resource_provider = StaticResourceProvider(res_data)
303
+
304
+ try:
305
+ engine = Engine.create(config_data, resource_provider=resource_provider)
306
+ except (ConfigError, RegistryError) as e:
307
+ print(f"error creating engine: {e}", file=sys.stderr)
308
+ sys.exit(1)
309
+
310
+ state = _ServerState(engine)
311
+
312
+ host = ""
313
+ port = 8080
314
+ if addr.startswith(":"):
315
+ port = int(addr[1:])
316
+ else:
317
+ parts = addr.rsplit(":", 1)
318
+ host = parts[0]
319
+ port = int(parts[1]) if len(parts) > 1 else 8080
320
+
321
+ handler = type("Handler", (_PineHandler,), {
322
+ "state": state,
323
+ "max_body": max_body,
324
+ })
325
+
326
+ stop_event = threading.Event()
327
+
328
+ watcher = threading.Thread(
329
+ target=_watch_config,
330
+ args=(state, config_path, resource_provider, stop_event),
331
+ daemon=True,
332
+ )
333
+ watcher.start()
334
+
335
+ server = HTTPServer((host, port), handler)
336
+ print(f"Pine server listening on {addr}", file=sys.stderr)
337
+
338
+ try:
339
+ server.serve_forever()
340
+ except KeyboardInterrupt:
341
+ stop_event.set()
342
+ server.shutdown()
343
+
344
+
345
+ if __name__ == "__main__":
346
+ main()