pie-client 0.1.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,29 @@
1
+ Metadata-Version: 2.4
2
+ Name: pie-client
3
+ Version: 0.1.0
4
+ Summary: Pie Client
5
+ Author-email: In Gim <in.gim@yale.edu>
6
+ License-Expression: Apache-2.0
7
+ Project-URL: Homepage, https://github.com/pie-project/pie
8
+ Requires-Python: >=3.8
9
+ Description-Content-Type: text/markdown
10
+ Requires-Dist: websockets==11.0.3
11
+ Requires-Dist: msgpack==0.5.6
12
+ Requires-Dist: blake3==1.0.4
13
+
14
+ # PIE Python Client
15
+
16
+ A concise Python toolkit to interact with the Symphony server:
17
+
18
+ - Upload LIP (\*.wasm) binaries
19
+ - Check if a LIP is already uploaded
20
+ - Launch a LIP on Symphony
21
+ - Interact with running LIP instances in real-time (send/receive messages, terminate, etc.)
22
+
23
+ ## Installation
24
+ ```bash
25
+ pip install -r requirements.txt
26
+ ```
27
+
28
+ ## Example
29
+ Refer to [main.py](./main.py) for a usage example.
@@ -0,0 +1,16 @@
1
+ # PIE Python Client
2
+
3
+ A concise Python toolkit to interact with the Symphony server:
4
+
5
+ - Upload LIP (\*.wasm) binaries
6
+ - Check if a LIP is already uploaded
7
+ - Launch a LIP on Symphony
8
+ - Interact with running LIP instances in real-time (send/receive messages, terminate, etc.)
9
+
10
+ ## Installation
11
+ ```bash
12
+ pip install -r requirements.txt
13
+ ```
14
+
15
+ ## Example
16
+ Refer to [main.py](./main.py) for a usage example.
@@ -0,0 +1,2 @@
1
+ # re-export client.py
2
+ from .client import *
@@ -0,0 +1,365 @@
1
+ import asyncio
2
+ import msgpack
3
+ import websockets
4
+ import blake3
5
+ import subprocess
6
+ import tempfile
7
+ from pathlib import Path
8
+ import uuid
9
+ from enum import Enum
10
+
11
+
12
+ class Event(Enum):
13
+ """Enumeration for events received from an instance."""
14
+ Message = 0
15
+ Completed = 1
16
+ Aborted = 2
17
+ Exception = 3
18
+ ServerError = 4
19
+ OutOfResources = 5
20
+ Blob = 6 # Represents a binary data blob
21
+
22
+
23
+ class Instance:
24
+ """Represents a running instance of a program on the server."""
25
+
26
+ def __init__(self, client, instance_id: str):
27
+ self.client = client
28
+ self.instance_id = instance_id
29
+ self.event_queue = self.client.inst_event_queues.get(instance_id)
30
+ if self.event_queue is None:
31
+ raise Exception(f"Internal error: No event queue for instance {instance_id}")
32
+
33
+ async def send(self, message: str):
34
+ """Send a string message to the instance."""
35
+ await self.client.signal_instance(self.instance_id, message)
36
+
37
+ async def upload_blob(self, blob_bytes: bytes):
38
+ """Upload a blob of binary data to the instance."""
39
+ await self.client.upload_blob(self.instance_id, blob_bytes)
40
+
41
+ async def recv(self) -> tuple[Event, str | bytes]:
42
+ """
43
+ Receive an event from the instance. Blocks until an event is available.
44
+ Returns a tuple of (Event, message), where message can be a string or bytes.
45
+ """
46
+ if self.event_queue is None:
47
+ raise Exception("Event queue is not available for this instance.")
48
+ event_code, msg = await self.event_queue.get()
49
+
50
+ event = Event(event_code)
51
+ return event, msg
52
+
53
+ async def terminate(self):
54
+ """Request termination of the instance."""
55
+ await self.client.terminate_instance(self.instance_id)
56
+
57
+
58
+ class PieClient:
59
+ """
60
+ An asynchronous client for interacting with the Pie WebSocket server.
61
+ This client is designed to be used as an async context manager.
62
+ """
63
+
64
+ def __init__(self, server_uri: str):
65
+ """
66
+ Initialize the client.
67
+ :param server_uri: The WebSocket server URI (e.g., "ws://127.0.0.1:8080").
68
+ """
69
+ self.server_uri = server_uri
70
+ self.ws = None
71
+ self.listener_task = None
72
+ self.corr_id_counter = 0
73
+ self.pending_requests = {}
74
+ self.inst_event_queues = {}
75
+ self.pending_downloads = {} # For reassembling blob chunks
76
+
77
+ # 🔻 FIX 1/3: Buffer for early events to prevent race conditions.
78
+ self.orphan_events = {}
79
+
80
+ async def __aenter__(self):
81
+ """Enter the async context, establishing the connection."""
82
+ await self.connect()
83
+ return self
84
+
85
+ async def __aexit__(self, exc_type, exc_val, exc_tb):
86
+ """Exit the async context, closing the connection cleanly."""
87
+ await self.close()
88
+
89
+ async def connect(self):
90
+ """Establish a WebSocket connection and start the background listener."""
91
+ self.ws = await websockets.connect(self.server_uri)
92
+ print(f"[PieClient] Connected to {self.server_uri}")
93
+ self.listener_task = asyncio.create_task(self._listen_to_server())
94
+
95
+ async def _listen_to_server(self):
96
+ """Background task to receive and process all incoming server messages."""
97
+ try:
98
+ async for raw_msg in self.ws:
99
+ if isinstance(raw_msg, bytes):
100
+ try:
101
+ message = msgpack.unpackb(raw_msg, raw=False)
102
+ await self._process_server_message(message)
103
+ except msgpack.UnpackException as e:
104
+ print(f"[PieClient] Failed to decode messagepack: {e}")
105
+ else:
106
+ print(f"[PieClient] Received unexpected non-binary message: {raw_msg}")
107
+ except websockets.ConnectionClosedOK:
108
+ print("[PieClient] Connection closed normally.")
109
+ except websockets.ConnectionClosedError as e:
110
+ print(f"[PieClient] Connection closed with error: {e}")
111
+ except Exception as e:
112
+ print(f"[PieClient] Listener task encountered an unexpected error: {e}")
113
+
114
+ async def _process_server_message(self, message: dict):
115
+ """Route incoming server messages based on their type."""
116
+ msg_type = message.get("type")
117
+ if msg_type == "response":
118
+ corr_id = message.get("corr_id")
119
+ if corr_id in self.pending_requests:
120
+ future = self.pending_requests.pop(corr_id)
121
+ future.set_result((message.get("successful"), message.get("result")))
122
+
123
+ elif msg_type == "instance_event":
124
+ # 🔻 FIX 2/3: Buffer orphan events instead of discarding them.
125
+ instance_id = message.get("instance_id")
126
+ event_tuple = (message.get("event"), message.get("message"))
127
+
128
+ if instance_id in self.inst_event_queues:
129
+ # Queue exists, proceed as normal
130
+ await self.inst_event_queues[instance_id].put(event_tuple)
131
+ else:
132
+ # Queue doesn't exist yet, buffer the event
133
+ if instance_id not in self.orphan_events:
134
+ self.orphan_events[instance_id] = []
135
+ self.orphan_events[instance_id].append(event_tuple)
136
+
137
+ elif msg_type == "download_blob":
138
+ await self._handle_blob_chunk(message)
139
+ elif msg_type == "server_event":
140
+ print(f"[PieClient] Received server event: {message.get('message')}")
141
+ else:
142
+ print(f"[PieClient] Received unknown message type: {msg_type}")
143
+
144
+ async def _handle_blob_chunk(self, message: dict):
145
+ """Processes a chunk of a blob sent from the server, ensuring sequential order."""
146
+ blob_hash = message.get("blob_hash")
147
+ instance_id = message.get("instance_id")
148
+ chunk_index = message.get("chunk_index")
149
+ total_chunks = message.get("total_chunks")
150
+
151
+ if instance_id not in self.inst_event_queues:
152
+ return # Ignore blobs for unknown/terminated instances
153
+
154
+ # Initialize download on the first chunk (index 0)
155
+ if blob_hash not in self.pending_downloads:
156
+ if chunk_index != 0:
157
+ print(f"[PieClient] Received non-zero first chunk for blob {blob_hash}. Discarding.")
158
+ return
159
+ self.pending_downloads[blob_hash] = {
160
+ "buffer": bytearray(),
161
+ "total_chunks": total_chunks,
162
+ "next_chunk_index": 1,
163
+ "instance_id": instance_id,
164
+ }
165
+
166
+ download = self.pending_downloads[blob_hash]
167
+
168
+ # Validate chunk consistency and order
169
+ if total_chunks != download["total_chunks"] or chunk_index != download["next_chunk_index"] - 1:
170
+ error_msg = "Chunk count mismatch" if total_chunks != download["total_chunks"] else "Out-of-order chunk"
171
+ print(f"[PieClient] {error_msg} for blob {blob_hash}. Aborting download.")
172
+ del self.pending_downloads[blob_hash]
173
+ return
174
+
175
+ download["buffer"].extend(message.get("chunk_data"))
176
+ download["next_chunk_index"] += 1
177
+
178
+ # If all chunks are received, finalize the download
179
+ if download["next_chunk_index"] == download["total_chunks"]:
180
+ completed_blob = bytes(download["buffer"])
181
+ computed_hash = blake3.blake3(completed_blob).hexdigest()
182
+ if computed_hash == blob_hash:
183
+ await self.inst_event_queues[instance_id].put((Event.Blob.value, completed_blob))
184
+ else:
185
+ print(f"[PieClient] Blob hash mismatch for instance {instance_id}. Expected {blob_hash}, got {computed_hash}. Discarding.")
186
+
187
+ del self.pending_downloads[blob_hash]
188
+
189
+ async def close(self):
190
+ """Gracefully close the WebSocket connection and shut down background tasks."""
191
+ if self.ws and not self.ws.closed:
192
+ await self.ws.close()
193
+ if self.listener_task:
194
+ try:
195
+ self.listener_task.cancel()
196
+ await self.listener_task
197
+ except asyncio.CancelledError:
198
+ pass # Expected on cancellation
199
+ print("[PieClient] Client has been shut down.")
200
+
201
+ def _get_next_corr_id(self):
202
+ """Generate a unique correlation ID for a request."""
203
+ self.corr_id_counter += 1
204
+ return self.corr_id_counter
205
+
206
+ async def _send_msg_and_wait(self, msg: dict) -> tuple[bool, str]:
207
+ """Send a message that expects a response and wait for it."""
208
+ corr_id = self._get_next_corr_id()
209
+ msg["corr_id"] = corr_id
210
+ future = asyncio.get_event_loop().create_future()
211
+ self.pending_requests[corr_id] = future
212
+ encoded = msgpack.packb(msg, use_bin_type=True)
213
+ await self.ws.send(encoded)
214
+ return await future
215
+
216
+ async def authenticate(self, token: str) -> tuple[bool, str]:
217
+ """Authenticate the client with the server using a token."""
218
+ msg = {"type": "authenticate", "token": token}
219
+ successful, result = await self._send_msg_and_wait(msg)
220
+ if successful:
221
+ print("[PieClient] Authenticated successfully.")
222
+ else:
223
+ print(f"[PieClient] Authentication failed: {result}")
224
+ return successful, result
225
+
226
+ async def query(self, subject: str, record: str) -> tuple[bool, str]:
227
+ """Send a generic query to the server."""
228
+ msg = {"type": "query", "subject": subject, "record": record}
229
+ return await self._send_msg_and_wait(msg)
230
+
231
+ async def program_exists(self, program_hash: str) -> bool:
232
+ """Check if a program with the given hash exists on the server."""
233
+ successful, result = await self.query("program_exists", program_hash)
234
+ if successful:
235
+ return result == "true"
236
+ raise Exception(f"Query for program_exists failed: {result}")
237
+
238
+ async def _upload_chunked(self, data_bytes: bytes, msg_template: dict):
239
+ """Internal helper to handle generic chunked uploads."""
240
+ data_hash = msg_template.get("program_hash") or msg_template.get("blob_hash")
241
+ upload_type = msg_template["type"]
242
+
243
+ chunk_size = 256 * 1024
244
+ total_size = len(data_bytes)
245
+ # An empty upload is still one chunk of zero bytes
246
+ total_chunks = (total_size + chunk_size - 1) // chunk_size if total_size > 0 else 1
247
+
248
+ corr_id = self._get_next_corr_id()
249
+ msg_template["corr_id"] = corr_id
250
+ msg_template["total_chunks"] = total_chunks
251
+
252
+ if total_size == 0:
253
+ msg = msg_template.copy()
254
+ msg.update({"chunk_index": 0, "chunk_data": b''})
255
+ await self.ws.send(msgpack.packb(msg, use_bin_type=True))
256
+ else:
257
+ for chunk_index in range(total_chunks):
258
+ start = chunk_index * chunk_size
259
+ end = min(start + chunk_size, total_size)
260
+ msg = msg_template.copy()
261
+ msg.update({"chunk_index": chunk_index, "chunk_data": data_bytes[start:end]})
262
+ await self.ws.send(msgpack.packb(msg, use_bin_type=True))
263
+
264
+ future = asyncio.get_event_loop().create_future()
265
+ self.pending_requests[corr_id] = future
266
+ successful, result = await future
267
+
268
+ if not successful:
269
+ raise Exception(f"{upload_type.replace('_', ' ').title()} failed: {result}")
270
+
271
+ print(f"[PieClient] {upload_type.replace('_', ' ').title()} successful for hash: {data_hash}")
272
+ return result
273
+
274
+ async def upload_program(self, program_bytes: bytes):
275
+ """Upload a program to the server in chunks."""
276
+ program_hash = blake3.blake3(program_bytes).hexdigest()
277
+ template = {"type": "upload_program", "program_hash": program_hash}
278
+ await self._upload_chunked(program_bytes, template)
279
+
280
+ async def upload_blob(self, instance_id: str, blob_bytes: bytes):
281
+ """Upload a blob of data to a specific instance in chunks."""
282
+ blob_hash = blake3.blake3(blob_bytes).hexdigest()
283
+ template = {"type": "upload_blob", "instance_id": instance_id, "blob_hash": blob_hash}
284
+ await self._upload_chunked(blob_bytes, template)
285
+
286
+ async def launch_instance(self, program_hash: str, arguments: list[str] = None) -> Instance:
287
+ """Launch an instance of a program."""
288
+ msg = {"type": "launch_instance", "program_hash": program_hash, "arguments": arguments or []}
289
+ successful, result = await self._send_msg_and_wait(msg)
290
+ if successful:
291
+ instance_id = result
292
+ # Create the queue as before
293
+ queue = asyncio.Queue()
294
+ self.inst_event_queues[instance_id] = queue
295
+
296
+ # 🔻 FIX 3/3: Check for and replay any events that arrived early.
297
+ if instance_id in self.orphan_events:
298
+ early_events = self.orphan_events.pop(instance_id)
299
+ for event_tuple in early_events:
300
+ await queue.put(event_tuple)
301
+
302
+ return Instance(self, instance_id)
303
+ raise Exception(f"Failed to launch instance: {result}")
304
+
305
+ async def launch_server_instance(self, program_hash: str, port: int, arguments: list[str] = None):
306
+ """Launch a server instance of a program on a specific port."""
307
+ msg = {"type": "launch_server_instance", "port": port, "program_hash": program_hash, "arguments": arguments or []}
308
+ successful, result = await self._send_msg_and_wait(msg)
309
+ if not successful:
310
+ raise Exception(f"Failed to launch server instance: {result}")
311
+
312
+ async def signal_instance(self, instance_id: str, message: str):
313
+ """Send a signal/message to a running instance (fire-and-forget)."""
314
+ msg = {"type": "signal_instance", "instance_id": instance_id, "message": message}
315
+ await self.ws.send(msgpack.packb(msg, use_bin_type=True))
316
+
317
+ async def terminate_instance(self, instance_id: str):
318
+ """Request the server to terminate a running instance (fire-and-forget)."""
319
+ msg = {"type": "terminate_instance", "instance_id": instance_id}
320
+ await self.ws.send(msgpack.packb(msg, use_bin_type=True))
321
+
322
+
323
+ def _compile_rust_sync(rust_code: str, cargo_toml_content: str, package_name: str) -> bytes:
324
+ """[Internal Synchronous Helper] Compiles rust code in a temporary directory."""
325
+ with tempfile.TemporaryDirectory() as temp_dir:
326
+ project_path = Path(temp_dir)
327
+ (project_path / "src").mkdir()
328
+ (project_path / "Cargo.toml").write_text(cargo_toml_content)
329
+ (project_path / "src" / "lib.rs").write_text(rust_code)
330
+ command = ["cargo", "build", "--target", "wasm32-wasip2", "--release"]
331
+ try:
332
+ print(f"🚀 Compiling crate '{package_name}'...")
333
+ subprocess.run(command, cwd=project_path, check=True, capture_output=True, text=True)
334
+ except FileNotFoundError:
335
+ raise RuntimeError("Error: `cargo` not found. Is Rust installed? Try: `rustup target add wasm32-wasip2`")
336
+ except subprocess.CalledProcessError as e:
337
+ raise RuntimeError(f"❌ Rust compilation failed.\n--- COMPILER OUTPUT ---\n{e.stderr}")
338
+ wasm_file_name = f"{package_name.replace('-', '_')}.wasm"
339
+ wasm_path = project_path / "target" / "wasm32-wasip2" / "release" / wasm_file_name
340
+ if not wasm_path.exists():
341
+ raise RuntimeError(f"Build succeeded but could not find WASM file at {wasm_path}")
342
+ print("✅ Compilation successful! Reading WASM binary.")
343
+ return wasm_path.read_bytes()
344
+
345
+
346
+ async def compile_program(source: str | Path, dependencies: list[str]) -> bytes:
347
+ """Compiles Rust source into a WASM binary and returns the bytes."""
348
+ if isinstance(source, Path) or (isinstance(source, str) and source.endswith('.rs')):
349
+ rust_code = Path(source).read_text()
350
+ else:
351
+ rust_code = source
352
+ package_name = f"pie-temp-crate-{uuid.uuid4().hex[:8]}"
353
+ deps_str = "\n".join(dependencies)
354
+ cargo_toml_content = f"""
355
+ [package]
356
+ name = "{package_name}"
357
+ version = "0.1.0"
358
+ edition = "2021"
359
+ [lib]
360
+ crate-type = ["cdylib"]
361
+ [dependencies]
362
+ {deps_str}
363
+ """
364
+ loop = asyncio.get_running_loop()
365
+ return await loop.run_in_executor(None, _compile_rust_sync, rust_code, cargo_toml_content, package_name)
@@ -0,0 +1,29 @@
1
+ Metadata-Version: 2.4
2
+ Name: pie-client
3
+ Version: 0.1.0
4
+ Summary: Pie Client
5
+ Author-email: In Gim <in.gim@yale.edu>
6
+ License-Expression: Apache-2.0
7
+ Project-URL: Homepage, https://github.com/pie-project/pie
8
+ Requires-Python: >=3.8
9
+ Description-Content-Type: text/markdown
10
+ Requires-Dist: websockets==11.0.3
11
+ Requires-Dist: msgpack==0.5.6
12
+ Requires-Dist: blake3==1.0.4
13
+
14
+ # PIE Python Client
15
+
16
+ A concise Python toolkit to interact with the Symphony server:
17
+
18
+ - Upload LIP (\*.wasm) binaries
19
+ - Check if a LIP is already uploaded
20
+ - Launch a LIP on Symphony
21
+ - Interact with running LIP instances in real-time (send/receive messages, terminate, etc.)
22
+
23
+ ## Installation
24
+ ```bash
25
+ pip install -r requirements.txt
26
+ ```
27
+
28
+ ## Example
29
+ Refer to [main.py](./main.py) for a usage example.
@@ -0,0 +1,9 @@
1
+ README.md
2
+ pyproject.toml
3
+ pie/__init__.py
4
+ pie/client.py
5
+ pie_client.egg-info/PKG-INFO
6
+ pie_client.egg-info/SOURCES.txt
7
+ pie_client.egg-info/dependency_links.txt
8
+ pie_client.egg-info/requires.txt
9
+ pie_client.egg-info/top_level.txt
@@ -0,0 +1,3 @@
1
+ websockets==11.0.3
2
+ msgpack==0.5.6
3
+ blake3==1.0.4
@@ -0,0 +1,26 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61.0"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "pie-client"
7
+ version = "0.1.0"
8
+ description = "Pie Client"
9
+ readme = "README.md"
10
+ requires-python = ">=3.8"
11
+ license = "Apache-2.0"
12
+ authors = [
13
+ { name = "In Gim", email = "in.gim@yale.edu" },
14
+ ]
15
+ dependencies = [
16
+ "websockets==11.0.3",
17
+ "msgpack==0.5.6",
18
+ "blake3==1.0.4",
19
+ ]
20
+
21
+ [project.urls]
22
+ Homepage = "https://github.com/pie-project/pie"
23
+
24
+ [tool.setuptools]
25
+ packages = ["pie"]
26
+ license-files = []
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+