pineapple-pine 0.7.0__tar.gz → 0.7.2__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.
Files changed (53) hide show
  1. {pineapple_pine-0.7.0 → pineapple_pine-0.7.2}/PKG-INFO +1 -1
  2. {pineapple_pine-0.7.0 → pineapple_pine-0.7.2}/pine/cli/server.py +112 -18
  3. {pineapple_pine-0.7.0 → pineapple_pine-0.7.2}/pineapple_pine.egg-info/PKG-INFO +1 -1
  4. {pineapple_pine-0.7.0 → pineapple_pine-0.7.2}/pineapple_pine.egg-info/SOURCES.txt +2 -1
  5. {pineapple_pine-0.7.0 → pineapple_pine-0.7.2}/pyproject.toml +1 -1
  6. pineapple_pine-0.7.2/tests/test_server.py +196 -0
  7. {pineapple_pine-0.7.0 → pineapple_pine-0.7.2}/README.md +0 -0
  8. {pineapple_pine-0.7.0 → pineapple_pine-0.7.2}/pine/__init__.py +0 -0
  9. {pineapple_pine-0.7.0 → pineapple_pine-0.7.2}/pine/cancellation.py +0 -0
  10. {pineapple_pine-0.7.0 → pineapple_pine-0.7.2}/pine/cli/__init__.py +0 -0
  11. {pineapple_pine-0.7.0 → pineapple_pine-0.7.2}/pine/cli/codegen.py +0 -0
  12. {pineapple_pine-0.7.0 → pineapple_pine-0.7.2}/pine/cli/dag.py +0 -0
  13. {pineapple_pine-0.7.0 → pineapple_pine-0.7.2}/pine/cli/run.py +0 -0
  14. {pineapple_pine-0.7.0 → pineapple_pine-0.7.2}/pine/config.py +0 -0
  15. {pineapple_pine-0.7.0 → pineapple_pine-0.7.2}/pine/dag.py +0 -0
  16. {pineapple_pine-0.7.0 → pineapple_pine-0.7.2}/pine/engine.py +0 -0
  17. {pineapple_pine-0.7.0 → pineapple_pine-0.7.2}/pine/errors.py +0 -0
  18. {pineapple_pine-0.7.0 → pineapple_pine-0.7.2}/pine/frame.py +0 -0
  19. {pineapple_pine-0.7.0 → pineapple_pine-0.7.2}/pine/go_format.py +0 -0
  20. {pineapple_pine-0.7.0 → pineapple_pine-0.7.2}/pine/operator.py +0 -0
  21. {pineapple_pine-0.7.0 → pineapple_pine-0.7.2}/pine/operators/__init__.py +0 -0
  22. {pineapple_pine-0.7.0 → pineapple_pine-0.7.2}/pine/operators/filter_condition.py +0 -0
  23. {pineapple_pine-0.7.0 → pineapple_pine-0.7.2}/pine/operators/filter_paginate.py +0 -0
  24. {pineapple_pine-0.7.0 → pineapple_pine-0.7.2}/pine/operators/filter_truncate.py +0 -0
  25. {pineapple_pine-0.7.0 → pineapple_pine-0.7.2}/pine/operators/merge_dedup.py +0 -0
  26. {pineapple_pine-0.7.0 → pineapple_pine-0.7.2}/pine/operators/observe_log.py +0 -0
  27. {pineapple_pine-0.7.0 → pineapple_pine-0.7.2}/pine/operators/recall_resource.py +0 -0
  28. {pineapple_pine-0.7.0 → pineapple_pine-0.7.2}/pine/operators/recall_static.py +0 -0
  29. {pineapple_pine-0.7.0 → pineapple_pine-0.7.2}/pine/operators/reorder_shuffle.py +0 -0
  30. {pineapple_pine-0.7.0 → pineapple_pine-0.7.2}/pine/operators/reorder_sort.py +0 -0
  31. {pineapple_pine-0.7.0 → pineapple_pine-0.7.2}/pine/operators/transform_by_lua.py +0 -0
  32. {pineapple_pine-0.7.0 → pineapple_pine-0.7.2}/pine/operators/transform_copy.py +0 -0
  33. {pineapple_pine-0.7.0 → pineapple_pine-0.7.2}/pine/operators/transform_dispatch.py +0 -0
  34. {pineapple_pine-0.7.0 → pineapple_pine-0.7.2}/pine/operators/transform_normalize.py +0 -0
  35. {pineapple_pine-0.7.0 → pineapple_pine-0.7.2}/pine/operators/transform_redis_get.py +0 -0
  36. {pineapple_pine-0.7.0 → pineapple_pine-0.7.2}/pine/operators/transform_redis_set.py +0 -0
  37. {pineapple_pine-0.7.0 → pineapple_pine-0.7.2}/pine/operators/transform_remote_pineapple.py +0 -0
  38. {pineapple_pine-0.7.0 → pineapple_pine-0.7.2}/pine/operators/transform_resource_lookup.py +0 -0
  39. {pineapple_pine-0.7.0 → pineapple_pine-0.7.2}/pine/operators/transform_size.py +0 -0
  40. {pineapple_pine-0.7.0 → pineapple_pine-0.7.2}/pine/parallel.py +0 -0
  41. {pineapple_pine-0.7.0 → pineapple_pine-0.7.2}/pine/py.typed +0 -0
  42. {pineapple_pine-0.7.0 → pineapple_pine-0.7.2}/pine/registry.py +0 -0
  43. {pineapple_pine-0.7.0 → pineapple_pine-0.7.2}/pine/result.py +0 -0
  44. {pineapple_pine-0.7.0 → pineapple_pine-0.7.2}/pine/stats.py +0 -0
  45. {pineapple_pine-0.7.0 → pineapple_pine-0.7.2}/pine/visualize.py +0 -0
  46. {pineapple_pine-0.7.0 → pineapple_pine-0.7.2}/pineapple_pine.egg-info/dependency_links.txt +0 -0
  47. {pineapple_pine-0.7.0 → pineapple_pine-0.7.2}/pineapple_pine.egg-info/requires.txt +0 -0
  48. {pineapple_pine-0.7.0 → pineapple_pine-0.7.2}/pineapple_pine.egg-info/top_level.txt +0 -0
  49. {pineapple_pine-0.7.0 → pineapple_pine-0.7.2}/setup.cfg +0 -0
  50. {pineapple_pine-0.7.0 → pineapple_pine-0.7.2}/tests/test_bench.py +0 -0
  51. {pineapple_pine-0.7.0 → pineapple_pine-0.7.2}/tests/test_fixtures.py +0 -0
  52. {pineapple_pine-0.7.0 → pineapple_pine-0.7.2}/tests/test_fuzz.py +0 -0
  53. {pineapple_pine-0.7.0 → pineapple_pine-0.7.2}/tests/test_lua_pool.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pineapple-pine
3
- Version: 0.7.0
3
+ Version: 0.7.2
4
4
  Summary: Pineapple pipeline engine — Python runtime
5
5
  Author-email: Liam Huang <liam0205@hotmail.com>
6
6
  License-Expression: Apache-2.0
@@ -7,7 +7,7 @@ import threading
7
7
  import time
8
8
  from http.server import BaseHTTPRequestHandler, HTTPServer
9
9
  from pathlib import Path
10
- from typing import Any
10
+ from typing import Any, Callable
11
11
 
12
12
  from pine.engine import Engine, StaticResourceProvider
13
13
  from pine.errors import ConfigError, RegistryError, ValidationError
@@ -15,6 +15,8 @@ from pine.go_format import go_json_marshal
15
15
 
16
16
  _DEFAULT_MAX_BODY = 10 * 1024 * 1024 # 10MB
17
17
 
18
+ Middleware = Callable[["_PineHandler", Callable[[], None]], None]
19
+
18
20
 
19
21
  class _ServerState:
20
22
  """Thread-safe mutable server state (engine + reload stats)."""
@@ -53,31 +55,53 @@ class _ServerState:
53
55
  class _PineHandler(BaseHTTPRequestHandler):
54
56
  state: _ServerState
55
57
  max_body: int
58
+ middlewares: list[Middleware]
56
59
 
57
60
  def log_message(self, format, *args):
58
61
  pass
59
62
 
60
- def do_GET(self):
63
+ def _dispatch(self):
64
+ """Route request to internal handlers or 404."""
61
65
  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()
66
+ method = self.command
67
+
68
+ if method == "GET":
69
+ if path == "/health":
70
+ self._json_response(200, {"status": "ok"})
71
+ elif path == "/stats":
72
+ self._handle_stats()
73
+ elif path == "/dag":
74
+ self._handle_dag()
75
+ elif path == "/execute":
76
+ self._method_not_allowed()
77
+ else:
78
+ self._json_response(404, {"error": "not found"})
79
+ elif method == "POST":
80
+ if path == "/execute":
81
+ self._handle_execute()
82
+ elif path in ("/health", "/stats", "/dag"):
83
+ self._method_not_allowed()
84
+ else:
85
+ self._json_response(404, {"error": "not found"})
70
86
  else:
71
- self._json_response(404, {"error": "not found"})
87
+ self._method_not_allowed()
88
+
89
+ def _run_middleware_chain(self):
90
+ """Execute middleware chain, then dispatch."""
91
+ chain = self.middlewares
92
+
93
+ def build_next(idx: int) -> Callable[[], None]:
94
+ if idx >= len(chain):
95
+ return self._dispatch
96
+ return lambda: chain[idx](self, build_next(idx + 1))
97
+
98
+ build_next(0)()
99
+
100
+ def do_GET(self):
101
+ self._run_middleware_chain()
72
102
 
73
103
  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"})
104
+ self._run_middleware_chain()
81
105
 
82
106
  def _method_not_allowed(self):
83
107
  self._json_response(405, {"error": "method not allowed"})
@@ -263,6 +287,75 @@ def _watch_config(state: _ServerState, config_path: str, resource_provider: Any,
263
287
  print(f"config reload failed: {e}", file=sys.stderr)
264
288
 
265
289
 
290
+ class PineServer:
291
+ """Programmatic Pine server with middleware support.
292
+
293
+ Usage:
294
+ server = PineServer(config_path, port=9000)
295
+ server.add_middleware(my_middleware)
296
+ server.start() # blocks
297
+ """
298
+
299
+ def __init__(self, config_path: str, *, port: int = 8080, host: str = "",
300
+ max_body: int = _DEFAULT_MAX_BODY,
301
+ resource_provider: Any = None):
302
+ from pine.operators import ensure_registered
303
+ ensure_registered()
304
+
305
+ self._config_path = config_path
306
+ self._host = host
307
+ self._port = port
308
+ self._max_body = max_body
309
+ self._resource_provider = resource_provider
310
+ self._middlewares: list[Middleware] = []
311
+ self._started = False
312
+ self._server: HTTPServer | None = None
313
+ self._stop_event = threading.Event()
314
+
315
+ def add_middleware(self, mw: Middleware):
316
+ if self._started:
317
+ raise RuntimeError("cannot add middleware after server has started")
318
+ self._middlewares.append(mw)
319
+
320
+ def start(self):
321
+ """Start the server (blocking)."""
322
+ self._started = True
323
+ config_data = Path(self._config_path).read_bytes()
324
+
325
+ try:
326
+ engine = Engine.create(config_data, resource_provider=self._resource_provider)
327
+ except (ConfigError, RegistryError) as e:
328
+ raise RuntimeError(f"error creating engine: {e}") from e
329
+
330
+ state = _ServerState(engine)
331
+
332
+ handler = type("Handler", (_PineHandler,), {
333
+ "state": state,
334
+ "max_body": self._max_body,
335
+ "middlewares": list(self._middlewares),
336
+ })
337
+
338
+ watcher = threading.Thread(
339
+ target=_watch_config,
340
+ args=(state, self._config_path, self._resource_provider, self._stop_event),
341
+ daemon=True,
342
+ )
343
+ watcher.start()
344
+
345
+ self._server = HTTPServer((self._host, self._port), handler)
346
+ print(f"Pine server listening on :{self._port}", file=sys.stderr)
347
+
348
+ try:
349
+ self._server.serve_forever()
350
+ except KeyboardInterrupt:
351
+ self.stop()
352
+
353
+ def stop(self):
354
+ self._stop_event.set()
355
+ if self._server:
356
+ self._server.shutdown()
357
+
358
+
266
359
  def main():
267
360
  from pine.operators import ensure_registered
268
361
  ensure_registered()
@@ -321,6 +414,7 @@ def main():
321
414
  handler = type("Handler", (_PineHandler,), {
322
415
  "state": state,
323
416
  "max_body": max_body,
417
+ "middlewares": [],
324
418
  })
325
419
 
326
420
  stop_event = threading.Event()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pineapple-pine
3
- Version: 0.7.0
3
+ Version: 0.7.2
4
4
  Summary: Pineapple pipeline engine — Python runtime
5
5
  Author-email: Liam Huang <liam0205@hotmail.com>
6
6
  License-Expression: Apache-2.0
@@ -47,4 +47,5 @@ pineapple_pine.egg-info/top_level.txt
47
47
  tests/test_bench.py
48
48
  tests/test_fixtures.py
49
49
  tests/test_fuzz.py
50
- tests/test_lua_pool.py
50
+ tests/test_lua_pool.py
51
+ tests/test_server.py
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "pineapple-pine"
3
- version = "0.7.0"
3
+ version = "0.7.2"
4
4
  description = "Pineapple pipeline engine — Python runtime"
5
5
  requires-python = ">=3.11"
6
6
  license = "Apache-2.0"
@@ -0,0 +1,196 @@
1
+ """Server extensibility tests for pine-python.
2
+
3
+ Verifies:
4
+ - Unknown paths return JSON 404 with {"error": "not found"}
5
+ - Middleware can intercept custom paths (e.g. /metrics)
6
+ - Method not allowed returns JSON 405
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import threading
12
+ import time
13
+ from http.client import HTTPConnection
14
+ from pathlib import Path
15
+ from typing import Callable
16
+
17
+ import pytest
18
+ from pine.cli.server import PineServer, _PineHandler
19
+
20
+ FIXTURES_ROOT = Path(__file__).parent.parent.parent / "fixtures"
21
+
22
+
23
+ def _make_config() -> str:
24
+ """Create a minimal temp config file and return its path."""
25
+ cfg = {
26
+ "pipeline_config": {
27
+ "operators": {
28
+ "noop": {
29
+ "type_name": "transform_copy",
30
+ "direction": "common_to_common",
31
+ "$metadata": {
32
+ "common_input": ["x"],
33
+ "common_output": ["y"],
34
+ "item_input": [],
35
+ "item_output": [],
36
+ },
37
+ }
38
+ }
39
+ },
40
+ "pipeline_group": {"main": {"pipeline": ["noop"]}},
41
+ "flow_contract": {
42
+ "common_input": ["x"],
43
+ "item_input": [],
44
+ "common_output": ["x", "y"],
45
+ "item_output": [],
46
+ },
47
+ }
48
+ import tempfile
49
+
50
+ f = tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False)
51
+ json.dump(cfg, f)
52
+ f.close()
53
+ return f.name
54
+
55
+
56
+ def _find_free_port() -> int:
57
+ import socket
58
+
59
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
60
+ s.bind(("", 0))
61
+ return s.getsockname()[1]
62
+
63
+
64
+ def _wait_ready(port: int, timeout: float = 5.0):
65
+ deadline = time.time() + timeout
66
+ while time.time() < deadline:
67
+ try:
68
+ conn = HTTPConnection("localhost", port, timeout=1)
69
+ conn.request("GET", "/health")
70
+ resp = conn.getresponse()
71
+ if resp.status == 200:
72
+ conn.close()
73
+ return
74
+ conn.close()
75
+ except (ConnectionRefusedError, OSError):
76
+ pass
77
+ time.sleep(0.1)
78
+ raise RuntimeError(f"Server on port {port} not ready within {timeout}s")
79
+
80
+
81
+ class TestServerNotFound:
82
+ """Unknown paths must return JSON 404."""
83
+
84
+ @pytest.fixture(autouse=True)
85
+ def server(self):
86
+ config_path = _make_config()
87
+ port = _find_free_port()
88
+ srv = PineServer(config_path, port=port)
89
+ t = threading.Thread(target=srv.start, daemon=True)
90
+ t.start()
91
+ _wait_ready(port)
92
+ self.port = port
93
+ self.srv = srv
94
+ yield
95
+ srv.stop()
96
+ Path(config_path).unlink(missing_ok=True)
97
+
98
+ def test_unknown_path_returns_json_404(self):
99
+ conn = HTTPConnection("localhost", self.port)
100
+ conn.request("GET", "/unknown-path")
101
+ resp = conn.getresponse()
102
+ assert resp.status == 404
103
+ body = json.loads(resp.read())
104
+ assert body == {"error": "not found"}
105
+ assert "application/json" in resp.getheader("Content-Type", "")
106
+ conn.close()
107
+
108
+ def test_post_unknown_path_returns_json_404(self):
109
+ conn = HTTPConnection("localhost", self.port)
110
+ conn.request("POST", "/does-not-exist")
111
+ resp = conn.getresponse()
112
+ assert resp.status == 404
113
+ body = json.loads(resp.read())
114
+ assert body == {"error": "not found"}
115
+ conn.close()
116
+
117
+ def test_method_not_allowed_json(self):
118
+ conn = HTTPConnection("localhost", self.port)
119
+ conn.request("POST", "/health")
120
+ resp = conn.getresponse()
121
+ assert resp.status == 405
122
+ body = json.loads(resp.read())
123
+ assert body == {"error": "method not allowed"}
124
+ conn.close()
125
+
126
+
127
+ class TestServerMiddleware:
128
+ """Middleware can intercept custom paths."""
129
+
130
+ @pytest.fixture(autouse=True)
131
+ def server(self):
132
+ config_path = _make_config()
133
+ port = _find_free_port()
134
+ srv = PineServer(config_path, port=port)
135
+
136
+ def metrics_middleware(handler: _PineHandler, next_fn: Callable[[], None]):
137
+ path = handler.path.split("?")[0]
138
+ if path == "/metrics":
139
+ handler._json_response(200, {"custom": True})
140
+ return
141
+ next_fn()
142
+
143
+ srv.add_middleware(metrics_middleware)
144
+ t = threading.Thread(target=srv.start, daemon=True)
145
+ t.start()
146
+ _wait_ready(port)
147
+ self.port = port
148
+ self.srv = srv
149
+ yield
150
+ srv.stop()
151
+ Path(config_path).unlink(missing_ok=True)
152
+
153
+ def test_middleware_intercepts_custom_path(self):
154
+ conn = HTTPConnection("localhost", self.port)
155
+ conn.request("GET", "/metrics")
156
+ resp = conn.getresponse()
157
+ assert resp.status == 200
158
+ body = json.loads(resp.read())
159
+ assert body["custom"] is True
160
+ conn.close()
161
+
162
+ def test_known_path_still_works(self):
163
+ conn = HTTPConnection("localhost", self.port)
164
+ conn.request("GET", "/health")
165
+ resp = conn.getresponse()
166
+ assert resp.status == 200
167
+ body = json.loads(resp.read())
168
+ assert body["status"] == "ok"
169
+ conn.close()
170
+
171
+ def test_unknown_path_not_intercepted(self):
172
+ conn = HTTPConnection("localhost", self.port)
173
+ conn.request("GET", "/other")
174
+ resp = conn.getresponse()
175
+ assert resp.status == 404
176
+ body = json.loads(resp.read())
177
+ assert body == {"error": "not found"}
178
+ conn.close()
179
+
180
+
181
+ class TestServerMiddlewareCannotAddAfterStart:
182
+ """Adding middleware after start raises RuntimeError."""
183
+
184
+ def test_add_middleware_after_start_raises(self):
185
+ config_path = _make_config()
186
+ port = _find_free_port()
187
+ srv = PineServer(config_path, port=port)
188
+ t = threading.Thread(target=srv.start, daemon=True)
189
+ t.start()
190
+ _wait_ready(port)
191
+ try:
192
+ with pytest.raises(RuntimeError, match="cannot add middleware"):
193
+ srv.add_middleware(lambda h, n: n())
194
+ finally:
195
+ srv.stop()
196
+ Path(config_path).unlink(missing_ok=True)
File without changes
File without changes