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 +8 -0
- pine/cancellation.py +20 -0
- pine/cli/__init__.py +0 -0
- pine/cli/codegen.py +67 -0
- pine/cli/dag.py +62 -0
- pine/cli/run.py +97 -0
- pine/cli/server.py +346 -0
- pine/config.py +304 -0
- pine/dag.py +218 -0
- pine/engine.py +681 -0
- pine/errors.py +31 -0
- pine/frame.py +237 -0
- pine/go_format.py +181 -0
- pine/operator.py +346 -0
- pine/operators/__init__.py +412 -0
- pine/operators/filter_condition.py +29 -0
- pine/operators/filter_paginate.py +48 -0
- pine/operators/filter_truncate.py +35 -0
- pine/operators/merge_dedup.py +38 -0
- pine/operators/observe_log.py +53 -0
- pine/operators/recall_resource.py +53 -0
- pine/operators/recall_static.py +38 -0
- pine/operators/reorder_shuffle.py +92 -0
- pine/operators/reorder_sort.py +62 -0
- pine/operators/transform_by_lua.py +308 -0
- pine/operators/transform_copy.py +58 -0
- pine/operators/transform_dispatch.py +29 -0
- pine/operators/transform_normalize.py +59 -0
- pine/operators/transform_redis_get.py +138 -0
- pine/operators/transform_redis_set.py +147 -0
- pine/operators/transform_remote_pineapple.py +224 -0
- pine/operators/transform_resource_lookup.py +87 -0
- pine/operators/transform_size.py +25 -0
- pine/parallel.py +88 -0
- pine/py.typed +0 -0
- pine/registry.py +88 -0
- pine/result.py +32 -0
- pine/stats.py +98 -0
- pine/visualize.py +184 -0
- pineapple_pine-0.7.0.dist-info/METADATA +78 -0
- pineapple_pine-0.7.0.dist-info/RECORD +43 -0
- pineapple_pine-0.7.0.dist-info/WHEEL +5 -0
- pineapple_pine-0.7.0.dist-info/top_level.txt +1 -0
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()
|