datasette-libfec 0.0.1a4__py3-none-any.whl → 0.0.1a6__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.
Files changed (41) hide show
  1. datasette_libfec/__init__.py +18 -2
  2. datasette_libfec/libfec_client.py +225 -11
  3. datasette_libfec/libfec_export_rpc_client.py +358 -0
  4. datasette_libfec/libfec_rpc_client.py +335 -0
  5. datasette_libfec/libfec_search_rpc_client.py +308 -0
  6. datasette_libfec/manifest.json +84 -2
  7. datasette_libfec/page_data.py +87 -0
  8. datasette_libfec/router.py +24 -0
  9. datasette_libfec/routes_export.py +128 -0
  10. datasette_libfec/routes_exports.py +222 -0
  11. datasette_libfec/routes_pages.py +341 -0
  12. datasette_libfec/routes_rss.py +416 -0
  13. datasette_libfec/routes_search.py +78 -0
  14. datasette_libfec/state.py +6 -0
  15. datasette_libfec/static/gen/candidate-BEqDafKu.css +1 -0
  16. datasette_libfec/static/gen/candidate-tqxa29G-.js +3 -0
  17. datasette_libfec/static/gen/class-C5DDKbJD.js +2 -0
  18. datasette_libfec/static/gen/committee-Bmki9iKb.css +1 -0
  19. datasette_libfec/static/gen/committee-DY1GmylW.js +2 -0
  20. datasette_libfec/static/gen/contest-BbYrzKRg.js +1 -0
  21. datasette_libfec/static/gen/contest-D4Fj7kGA.css +1 -0
  22. datasette_libfec/static/gen/each-DkfQbqzj.js +1 -0
  23. datasette_libfec/static/gen/filing_detail-Ba6_iQwV.css +1 -0
  24. datasette_libfec/static/gen/filing_detail-D2ib3OM6.js +26 -0
  25. datasette_libfec/static/gen/index-AHqus2fd.js +9 -0
  26. datasette_libfec/static/gen/index-client-CDwZ_Ixa.js +1 -0
  27. datasette_libfec/static/gen/index-jv9_YIKt.css +1 -0
  28. datasette_libfec/static/gen/load-AXKAVXVj.js +1 -0
  29. datasette_libfec/templates/libfec_base.html +12 -0
  30. {datasette_libfec-0.0.1a4.dist-info → datasette_libfec-0.0.1a6.dist-info}/METADATA +2 -2
  31. datasette_libfec-0.0.1a6.dist-info/RECORD +37 -0
  32. {datasette_libfec-0.0.1a4.dist-info → datasette_libfec-0.0.1a6.dist-info}/top_level.txt +1 -0
  33. scripts/typegen-pagedata.py +6 -0
  34. datasette_libfec/routes.py +0 -189
  35. datasette_libfec/static/gen/index-6cjSv2YC.css +0 -1
  36. datasette_libfec/static/gen/index-CaTQMY-X.js +0 -1
  37. datasette_libfec/templates/libfec.html +0 -14
  38. datasette_libfec-0.0.1a4.dist-info/RECORD +0 -14
  39. {datasette_libfec-0.0.1a4.dist-info → datasette_libfec-0.0.1a6.dist-info}/WHEEL +0 -0
  40. {datasette_libfec-0.0.1a4.dist-info → datasette_libfec-0.0.1a6.dist-info}/entry_points.txt +0 -0
  41. {datasette_libfec-0.0.1a4.dist-info → datasette_libfec-0.0.1a6.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,335 @@
1
+ """
2
+ JSON-RPC 2.0 client for libfec rss --rpc mode.
3
+
4
+ Manages libfec subprocess lifecycle using JSONL protocol over stdin/stdout.
5
+ """
6
+
7
+ import asyncio
8
+ import json
9
+ import logging
10
+ from typing import Any, Callable, Optional
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+
15
+ class RpcError(Exception):
16
+ """JSON-RPC error response"""
17
+ def __init__(self, code: int, message: str, data: Any = None):
18
+ self.code = code
19
+ self.message = message
20
+ self.data = data
21
+ super().__init__(f"RPC Error {code}: {message}")
22
+
23
+
24
+ class LibfecRpcClient:
25
+ """
26
+ Manages libfec rss --rpc process lifecycle (one-shot per sync).
27
+
28
+ Uses JSON-RPC 2.0 over JSONL protocol:
29
+ - Requests/responses: JSON objects with id field
30
+ - Notifications: JSON objects without id field
31
+ """
32
+
33
+ def __init__(self, libfec_path: str):
34
+ self.libfec_path = libfec_path
35
+ self.process: Optional[asyncio.subprocess.Process] = None
36
+ self.request_id = 0
37
+ self.pending_requests: dict[int, asyncio.Future] = {}
38
+ self.listen_task: Optional[asyncio.Task] = None
39
+ self.progress_callback: Optional[Callable] = None
40
+ self.completion_future: Optional[asyncio.Future] = None
41
+
42
+ async def start_process(self) -> None:
43
+ """Spawn libfec rss --rpc subprocess"""
44
+ if self.process is not None:
45
+ raise RuntimeError("Process already started")
46
+
47
+ logger.info(f"Starting libfec rss --rpc: {self.libfec_path}")
48
+
49
+ self.process = await asyncio.create_subprocess_exec(
50
+ self.libfec_path,
51
+ "rss",
52
+ "--rpc",
53
+ stdin=asyncio.subprocess.PIPE,
54
+ stdout=asyncio.subprocess.PIPE,
55
+ stderr=asyncio.subprocess.PIPE,
56
+ )
57
+
58
+ # Start listening for messages
59
+ self.listen_task = asyncio.create_task(self._listen_for_messages())
60
+
61
+ async def _listen_for_messages(self) -> None:
62
+ """
63
+ Read stdout line-by-line (JSONL protocol).
64
+ Route responses to waiting futures by request ID.
65
+ Route notifications to progress_callback.
66
+ """
67
+ if not self.process or not self.process.stdout:
68
+ return
69
+
70
+ try:
71
+ while True:
72
+ line = await self.process.stdout.readline()
73
+ if not line:
74
+ # EOF - process died
75
+ logger.error("libfec process stdout EOF (process crashed?)")
76
+ # Cancel all pending requests
77
+ for future in self.pending_requests.values():
78
+ if not future.done():
79
+ future.set_exception(
80
+ RuntimeError("libfec process terminated unexpectedly")
81
+ )
82
+ # Cancel completion future if waiting
83
+ if self.completion_future and not self.completion_future.done():
84
+ self.completion_future.set_exception(
85
+ RuntimeError("libfec process terminated before sync completed")
86
+ )
87
+ break
88
+
89
+ try:
90
+ msg = json.loads(line.decode())
91
+ except json.JSONDecodeError as e:
92
+ logger.error(f"Invalid JSON from libfec: {line!r} - {e}")
93
+ continue
94
+
95
+ # Check if response or notification
96
+ if "id" in msg:
97
+ # Response - match to pending request
98
+ request_id = msg["id"]
99
+ future = self.pending_requests.get(request_id)
100
+
101
+ if future and not future.done():
102
+ if "error" in msg:
103
+ err = msg["error"]
104
+ future.set_exception(
105
+ RpcError(
106
+ err.get("code", -1),
107
+ err.get("message", "Unknown error"),
108
+ err.get("data")
109
+ )
110
+ )
111
+ elif "result" in msg:
112
+ future.set_result(msg["result"])
113
+ else:
114
+ future.set_exception(
115
+ RuntimeError(f"Invalid RPC response: {msg}")
116
+ )
117
+ else:
118
+ # Notification - deliver to callback
119
+ if "method" in msg:
120
+ # Check if this is a completion notification
121
+ if msg.get("method") == "sync/progress":
122
+ params = msg.get("params", {})
123
+ phase = params.get("phase")
124
+
125
+ # Resolve completion future on terminal phases
126
+ if phase in ("complete", "canceled", "error"):
127
+ if self.completion_future and not self.completion_future.done():
128
+ self.completion_future.set_result(params)
129
+
130
+ # Deliver to callback
131
+ if self.progress_callback:
132
+ try:
133
+ self.progress_callback(msg)
134
+ except Exception as e:
135
+ logger.error(f"Progress callback error: {e}", exc_info=True)
136
+
137
+ except asyncio.CancelledError:
138
+ logger.debug("Message listener cancelled")
139
+ raise
140
+ except Exception as e:
141
+ logger.error(f"Error in message listener: {e}", exc_info=True)
142
+
143
+ async def send_request(
144
+ self,
145
+ method: str,
146
+ params: Optional[dict] = None,
147
+ timeout: float = 5.0
148
+ ) -> Any:
149
+ """
150
+ Send JSON-RPC request via stdin, wait for response.
151
+
152
+ Args:
153
+ method: RPC method name
154
+ params: Method parameters
155
+ timeout: Response timeout in seconds
156
+
157
+ Returns:
158
+ Result from response
159
+
160
+ Raises:
161
+ RpcError: On JSON-RPC error response
162
+ TimeoutError: On timeout
163
+ RuntimeError: On process errors
164
+ """
165
+ if not self.process or not self.process.stdin:
166
+ raise RuntimeError("Process not started")
167
+
168
+ self.request_id += 1
169
+ request = {
170
+ "jsonrpc": "2.0",
171
+ "id": self.request_id,
172
+ "method": method,
173
+ }
174
+ if params is not None:
175
+ request["params"] = params
176
+
177
+ # Create future for response
178
+ future: asyncio.Future = asyncio.Future()
179
+ self.pending_requests[self.request_id] = future
180
+
181
+ try:
182
+ # Send request
183
+ request_json = json.dumps(request) + "\n"
184
+ self.process.stdin.write(request_json.encode())
185
+ await self.process.stdin.drain()
186
+
187
+ logger.debug(f"Sent RPC request: {method} (id={self.request_id})")
188
+
189
+ # Wait for response with timeout
190
+ result = await asyncio.wait_for(future, timeout=timeout)
191
+ return result
192
+
193
+ except asyncio.TimeoutError:
194
+ logger.error(f"RPC request timeout: {method}")
195
+ raise TimeoutError(f"RPC request timed out: {method}")
196
+ finally:
197
+ # Clean up pending request
198
+ self.pending_requests.pop(self.request_id, None)
199
+
200
+ async def sync_start(
201
+ self,
202
+ since: Optional[str],
203
+ state: Optional[str],
204
+ cover_only: bool,
205
+ output_path: str,
206
+ progress_callback: Callable,
207
+ write_metadata: bool = True
208
+ ) -> dict:
209
+ """
210
+ Start RSS sync with progress tracking.
211
+
212
+ Args:
213
+ since: ISO timestamp or relative time (e.g., "2 hours ago")
214
+ state: Two-letter state code filter (e.g., "CA")
215
+ cover_only: Only import cover pages
216
+ output_path: Path to output SQLite database
217
+ progress_callback: Called with sync/progress notifications
218
+ write_metadata: Whether to write metadata to the database
219
+
220
+ Returns:
221
+ Final sync result
222
+
223
+ Raises:
224
+ RpcError: On sync errors
225
+ TimeoutError: On sync timeout (300s)
226
+ """
227
+ self.progress_callback = progress_callback
228
+
229
+ params = {
230
+ "export_path": output_path,
231
+ "cover_only": cover_only,
232
+ "write_metadata": write_metadata,
233
+ }
234
+ if since is not None:
235
+ params["since"] = since
236
+ if state is not None:
237
+ params["state"] = state
238
+
239
+ # Create completion future to wait for final notification
240
+ self.completion_future = asyncio.Future()
241
+
242
+ # Send sync/start request (just starts the sync)
243
+ start_result = await self.send_request("sync/start", params, timeout=10.0)
244
+ logger.debug(f"Sync started: {start_result}")
245
+
246
+ # Keep sending sync/status requests to keep the RPC server processing exports
247
+ # The RPC server processes up to 10 exports, then waits for stdin input
248
+ async def poll_status():
249
+ while self.completion_future and not self.completion_future.done():
250
+ try:
251
+ await asyncio.sleep(0.5) # Poll every 500ms
252
+ if self.completion_future and not self.completion_future.done():
253
+ await self.send_request("sync/status", timeout=5.0)
254
+ except Exception as e:
255
+ logger.debug(f"Status poll error (expected on completion): {e}")
256
+ break
257
+
258
+ # Start status polling task
259
+ poll_task = asyncio.create_task(poll_status())
260
+
261
+ # Wait for completion notification with 300s timeout
262
+ try:
263
+ completion_result = await asyncio.wait_for(self.completion_future, timeout=300.0)
264
+
265
+ # Check if sync completed with error
266
+ if completion_result.get("phase") == "error":
267
+ error_msg = completion_result.get("error_message", "Unknown error")
268
+ error_code = completion_result.get("error_code", -1)
269
+ error_data = completion_result.get("error_data")
270
+ raise RpcError(error_code, error_msg, error_data)
271
+
272
+ return completion_result
273
+ except asyncio.TimeoutError:
274
+ logger.error("Sync did not complete within 300 seconds")
275
+ raise TimeoutError("Sync timed out after 5 minutes")
276
+ finally:
277
+ # Cancel polling task
278
+ poll_task.cancel()
279
+ try:
280
+ await poll_task
281
+ except asyncio.CancelledError:
282
+ pass
283
+
284
+ async def sync_cancel(self) -> dict:
285
+ """Cancel in-progress sync"""
286
+ return await self.send_request("sync/cancel", timeout=5.0)
287
+
288
+ async def shutdown(self) -> None:
289
+ """Gracefully shutdown RPC process"""
290
+ if not self.process:
291
+ return
292
+
293
+ try:
294
+ await self.send_request("shutdown", timeout=2.0)
295
+ except Exception as e:
296
+ logger.warning(f"Shutdown request failed: {e}")
297
+
298
+ # Wait for process to exit
299
+ try:
300
+ await asyncio.wait_for(self.process.wait(), timeout=5.0)
301
+ except asyncio.TimeoutError:
302
+ logger.warning("Process did not exit after shutdown, terminating")
303
+ await self.terminate()
304
+
305
+ # Cancel listener task
306
+ if self.listen_task and not self.listen_task.done():
307
+ self.listen_task.cancel()
308
+ try:
309
+ await self.listen_task
310
+ except asyncio.CancelledError:
311
+ pass
312
+
313
+ self.process = None
314
+
315
+ async def terminate(self) -> None:
316
+ """Force kill the process (for cleanup)"""
317
+ if self.process:
318
+ try:
319
+ self.process.terminate()
320
+ await asyncio.wait_for(self.process.wait(), timeout=2.0)
321
+ except asyncio.TimeoutError:
322
+ logger.warning("Process did not terminate, killing")
323
+ self.process.kill()
324
+ await self.process.wait()
325
+ except Exception as e:
326
+ logger.error(f"Error terminating process: {e}")
327
+
328
+ if self.listen_task and not self.listen_task.done():
329
+ self.listen_task.cancel()
330
+ try:
331
+ await self.listen_task
332
+ except asyncio.CancelledError:
333
+ pass
334
+
335
+ self.process = None
@@ -0,0 +1,308 @@
1
+ """
2
+ JSON-RPC 2.0 client for libfec search --rpc mode.
3
+
4
+ Manages libfec search subprocess lifecycle using JSONL protocol over stdin/stdout.
5
+ """
6
+
7
+ import asyncio
8
+ import json
9
+ import logging
10
+ from typing import Any, Optional
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+
15
+ class RpcError(Exception):
16
+ """JSON-RPC error response"""
17
+ def __init__(self, code: int, message: str, data: Any = None):
18
+ self.code = code
19
+ self.message = message
20
+ self.data = data
21
+ super().__init__(f"RPC Error {code}: {message}")
22
+
23
+
24
+ class LibfecSearchRpcClient:
25
+ """
26
+ Manages libfec search --rpc process lifecycle.
27
+
28
+ Uses JSON-RPC 2.0 over JSONL protocol:
29
+ - Requests/responses: JSON objects with id field
30
+ - Notifications: JSON objects without id field
31
+ """
32
+
33
+ def __init__(self, libfec_path: str, cycle: int = 2026):
34
+ self.libfec_path = libfec_path
35
+ self.cycle = cycle
36
+ self.process: Optional[asyncio.subprocess.Process] = None
37
+ self.request_id = 0
38
+ self.pending_requests: dict[int, asyncio.Future] = {}
39
+ self.listen_task: Optional[asyncio.Task] = None
40
+ self.ready_future: Optional[asyncio.Future] = None
41
+
42
+ async def start_process(self) -> None:
43
+ """Spawn libfec search --rpc subprocess"""
44
+ if self.process is not None:
45
+ raise RuntimeError("Process already started")
46
+
47
+ logger.info(f"Starting libfec search --rpc: {self.libfec_path} --cycle {self.cycle}")
48
+
49
+ # Create ready future to wait for ready notification
50
+ self.ready_future = asyncio.Future()
51
+
52
+ self.process = await asyncio.create_subprocess_exec(
53
+ self.libfec_path,
54
+ "search",
55
+ "--rpc",
56
+ "--cycle",
57
+ str(self.cycle),
58
+ stdin=asyncio.subprocess.PIPE,
59
+ stdout=asyncio.subprocess.PIPE,
60
+ stderr=asyncio.subprocess.PIPE,
61
+ )
62
+
63
+ # Start listening for messages
64
+ self.listen_task = asyncio.create_task(self._listen_for_messages())
65
+
66
+ # Wait for ready notification with timeout
67
+ try:
68
+ await asyncio.wait_for(self.ready_future, timeout=5.0)
69
+ logger.info("Search RPC server ready")
70
+ except asyncio.TimeoutError:
71
+ logger.error("Timeout waiting for ready notification")
72
+ await self.terminate()
73
+ raise RuntimeError("libfec search process did not send ready notification")
74
+
75
+ async def _listen_for_messages(self) -> None:
76
+ """
77
+ Read stdout line-by-line (JSONL protocol).
78
+ Route responses to waiting futures by request ID.
79
+ """
80
+ if not self.process or not self.process.stdout:
81
+ return
82
+
83
+ try:
84
+ while True:
85
+ line = await self.process.stdout.readline()
86
+ if not line:
87
+ # EOF - process died
88
+ logger.error("libfec search process stdout EOF (process crashed?)")
89
+ # Cancel all pending requests
90
+ for future in self.pending_requests.values():
91
+ if not future.done():
92
+ future.set_exception(
93
+ RuntimeError("libfec search process terminated unexpectedly")
94
+ )
95
+ break
96
+
97
+ try:
98
+ msg = json.loads(line.decode())
99
+ except json.JSONDecodeError as e:
100
+ logger.error(f"Invalid JSON from libfec search: {line!r} - {e}")
101
+ continue
102
+
103
+ # Check if response or notification
104
+ if "id" in msg:
105
+ # Response - match to pending request
106
+ request_id = msg["id"]
107
+ future = self.pending_requests.get(request_id)
108
+
109
+ if future and not future.done():
110
+ if "error" in msg:
111
+ err = msg["error"]
112
+ future.set_exception(
113
+ RpcError(
114
+ err.get("code", -1),
115
+ err.get("message", "Unknown error"),
116
+ err.get("data")
117
+ )
118
+ )
119
+ elif "result" in msg:
120
+ future.set_result(msg["result"])
121
+ else:
122
+ future.set_exception(
123
+ RuntimeError(f"Invalid RPC response: {msg}")
124
+ )
125
+ else:
126
+ # Notification
127
+ if "method" in msg:
128
+ # Check if this is a ready notification
129
+ if msg.get("method") == "ready":
130
+ if self.ready_future and not self.ready_future.done():
131
+ self.ready_future.set_result(msg.get("params", {}))
132
+
133
+ except asyncio.CancelledError:
134
+ logger.debug("Message listener cancelled")
135
+ raise
136
+ except Exception as e:
137
+ logger.error(f"Error in message listener: {e}", exc_info=True)
138
+
139
+ async def send_request(
140
+ self,
141
+ method: str,
142
+ params: Optional[dict] = None,
143
+ timeout: float = 10.0
144
+ ) -> Any:
145
+ """
146
+ Send JSON-RPC request via stdin, wait for response.
147
+
148
+ Args:
149
+ method: RPC method name
150
+ params: Method parameters
151
+ timeout: Response timeout in seconds
152
+
153
+ Returns:
154
+ Result from response
155
+
156
+ Raises:
157
+ RpcError: On JSON-RPC error response
158
+ TimeoutError: On timeout
159
+ RuntimeError: On process errors
160
+ """
161
+ if not self.process or not self.process.stdin:
162
+ raise RuntimeError("Process not started")
163
+
164
+ self.request_id += 1
165
+ request = {
166
+ "jsonrpc": "2.0",
167
+ "id": self.request_id,
168
+ "method": method,
169
+ }
170
+ if params is not None:
171
+ request["params"] = params
172
+
173
+ # Create future for response
174
+ future: asyncio.Future = asyncio.Future()
175
+ self.pending_requests[self.request_id] = future
176
+
177
+ try:
178
+ # Send request
179
+ request_json = json.dumps(request) + "\n"
180
+ self.process.stdin.write(request_json.encode())
181
+ await self.process.stdin.drain()
182
+
183
+ logger.debug(f"Sent search RPC request: {method} (id={self.request_id})")
184
+
185
+ # Wait for response with timeout
186
+ result = await asyncio.wait_for(future, timeout=timeout)
187
+ return result
188
+
189
+ except asyncio.TimeoutError:
190
+ logger.error(f"Search RPC request timeout: {method}")
191
+ raise TimeoutError(f"Search RPC request timed out: {method}")
192
+ finally:
193
+ # Clean up pending request
194
+ self.pending_requests.pop(self.request_id, None)
195
+
196
+ async def search_query(
197
+ self,
198
+ query: str,
199
+ cycle: Optional[int] = None,
200
+ limit: int = 100
201
+ ) -> dict:
202
+ """
203
+ Search for candidates and committees.
204
+
205
+ Args:
206
+ query: Search query string
207
+ cycle: Election cycle year (uses default if omitted)
208
+ limit: Max results per category
209
+
210
+ Returns:
211
+ Search results with candidates and committees
212
+ """
213
+ params = {"query": query, "limit": limit}
214
+ if cycle is not None:
215
+ params["cycle"] = cycle
216
+
217
+ return await self.send_request("search/query", params)
218
+
219
+ async def get_candidate(
220
+ self,
221
+ candidate_id: str,
222
+ cycle: Optional[int] = None
223
+ ) -> dict:
224
+ """
225
+ Get detailed candidate information.
226
+
227
+ Args:
228
+ candidate_id: FEC candidate ID
229
+ cycle: Election cycle year
230
+
231
+ Returns:
232
+ Candidate details
233
+ """
234
+ params = {"candidate_id": candidate_id}
235
+ if cycle is not None:
236
+ params["cycle"] = cycle
237
+
238
+ return await self.send_request("search/candidate", params)
239
+
240
+ async def get_committee(
241
+ self,
242
+ committee_id: str,
243
+ cycle: Optional[int] = None
244
+ ) -> dict:
245
+ """
246
+ Get detailed committee information.
247
+
248
+ Args:
249
+ committee_id: FEC committee ID
250
+ cycle: Election cycle year
251
+
252
+ Returns:
253
+ Committee details
254
+ """
255
+ params = {"committee_id": committee_id}
256
+ if cycle is not None:
257
+ params["cycle"] = cycle
258
+
259
+ return await self.send_request("search/committee", params)
260
+
261
+ async def shutdown(self) -> None:
262
+ """Gracefully shutdown search RPC process"""
263
+ if not self.process:
264
+ return
265
+
266
+ try:
267
+ await self.send_request("shutdown", timeout=2.0)
268
+ except Exception as e:
269
+ logger.warning(f"Shutdown request failed: {e}")
270
+
271
+ # Wait for process to exit
272
+ try:
273
+ await asyncio.wait_for(self.process.wait(), timeout=5.0)
274
+ except asyncio.TimeoutError:
275
+ logger.warning("Process did not exit after shutdown, terminating")
276
+ await self.terminate()
277
+
278
+ # Cancel listener task
279
+ if self.listen_task and not self.listen_task.done():
280
+ self.listen_task.cancel()
281
+ try:
282
+ await self.listen_task
283
+ except asyncio.CancelledError:
284
+ pass
285
+
286
+ self.process = None
287
+
288
+ async def terminate(self) -> None:
289
+ """Force kill the process (for cleanup)"""
290
+ if self.process:
291
+ try:
292
+ self.process.terminate()
293
+ await asyncio.wait_for(self.process.wait(), timeout=2.0)
294
+ except asyncio.TimeoutError:
295
+ logger.warning("Process did not terminate, killing")
296
+ self.process.kill()
297
+ await self.process.wait()
298
+ except Exception as e:
299
+ logger.error(f"Error terminating process: {e}")
300
+
301
+ if self.listen_task and not self.listen_task.done():
302
+ self.listen_task.cancel()
303
+ try:
304
+ await self.listen_task
305
+ except asyncio.CancelledError:
306
+ pass
307
+
308
+ self.process = None