datasette-libfec 0.0.1a4__py3-none-any.whl → 0.0.1a5__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.
- datasette_libfec/__init__.py +5 -1
- datasette_libfec/libfec_client.py +225 -11
- datasette_libfec/libfec_export_rpc_client.py +358 -0
- datasette_libfec/libfec_rpc_client.py +335 -0
- datasette_libfec/libfec_search_rpc_client.py +308 -0
- datasette_libfec/manifest.json +84 -2
- datasette_libfec/page_data.py +87 -0
- datasette_libfec/router.py +3 -0
- datasette_libfec/routes_export.py +125 -0
- datasette_libfec/routes_exports.py +220 -0
- datasette_libfec/routes_pages.py +336 -0
- datasette_libfec/routes_rss.py +411 -0
- datasette_libfec/routes_search.py +77 -0
- datasette_libfec/state.py +6 -0
- datasette_libfec/static/gen/candidate-BEqDafKu.css +1 -0
- datasette_libfec/static/gen/candidate-tqxa29G-.js +3 -0
- datasette_libfec/static/gen/class-C5DDKbJD.js +2 -0
- datasette_libfec/static/gen/committee-Bmki9iKb.css +1 -0
- datasette_libfec/static/gen/committee-DY1GmylW.js +2 -0
- datasette_libfec/static/gen/contest-BbYrzKRg.js +1 -0
- datasette_libfec/static/gen/contest-D4Fj7kGA.css +1 -0
- datasette_libfec/static/gen/each-DkfQbqzj.js +1 -0
- datasette_libfec/static/gen/filing_detail-Ba6_iQwV.css +1 -0
- datasette_libfec/static/gen/filing_detail-D2ib3OM6.js +26 -0
- datasette_libfec/static/gen/index-AHqus2fd.js +9 -0
- datasette_libfec/static/gen/index-client-CDwZ_Ixa.js +1 -0
- datasette_libfec/static/gen/index-jv9_YIKt.css +1 -0
- datasette_libfec/static/gen/load-AXKAVXVj.js +1 -0
- datasette_libfec/templates/libfec_base.html +12 -0
- {datasette_libfec-0.0.1a4.dist-info → datasette_libfec-0.0.1a5.dist-info}/METADATA +2 -2
- datasette_libfec-0.0.1a5.dist-info/RECORD +37 -0
- {datasette_libfec-0.0.1a4.dist-info → datasette_libfec-0.0.1a5.dist-info}/top_level.txt +1 -0
- scripts/typegen-pagedata.py +6 -0
- datasette_libfec/routes.py +0 -189
- datasette_libfec/static/gen/index-6cjSv2YC.css +0 -1
- datasette_libfec/static/gen/index-CaTQMY-X.js +0 -1
- datasette_libfec/templates/libfec.html +0 -14
- datasette_libfec-0.0.1a4.dist-info/RECORD +0 -14
- {datasette_libfec-0.0.1a4.dist-info → datasette_libfec-0.0.1a5.dist-info}/WHEEL +0 -0
- {datasette_libfec-0.0.1a4.dist-info → datasette_libfec-0.0.1a5.dist-info}/entry_points.txt +0 -0
- {datasette_libfec-0.0.1a4.dist-info → datasette_libfec-0.0.1a5.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
|