datasette-libfec 0.0.1a4__tar.gz → 0.0.1a5__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 (49) hide show
  1. {datasette_libfec-0.0.1a4 → datasette_libfec-0.0.1a5}/PKG-INFO +2 -2
  2. {datasette_libfec-0.0.1a4 → datasette_libfec-0.0.1a5}/datasette_libfec/__init__.py +5 -1
  3. datasette_libfec-0.0.1a5/datasette_libfec/libfec_client.py +283 -0
  4. datasette_libfec-0.0.1a5/datasette_libfec/libfec_export_rpc_client.py +358 -0
  5. datasette_libfec-0.0.1a5/datasette_libfec/libfec_rpc_client.py +335 -0
  6. datasette_libfec-0.0.1a5/datasette_libfec/libfec_search_rpc_client.py +308 -0
  7. datasette_libfec-0.0.1a5/datasette_libfec/manifest.json +93 -0
  8. datasette_libfec-0.0.1a5/datasette_libfec/page_data.py +87 -0
  9. datasette_libfec-0.0.1a5/datasette_libfec/router.py +3 -0
  10. datasette_libfec-0.0.1a5/datasette_libfec/routes_export.py +125 -0
  11. datasette_libfec-0.0.1a5/datasette_libfec/routes_exports.py +220 -0
  12. datasette_libfec-0.0.1a5/datasette_libfec/routes_pages.py +336 -0
  13. datasette_libfec-0.0.1a5/datasette_libfec/routes_rss.py +411 -0
  14. datasette_libfec-0.0.1a5/datasette_libfec/routes_search.py +77 -0
  15. datasette_libfec-0.0.1a5/datasette_libfec/state.py +6 -0
  16. datasette_libfec-0.0.1a5/datasette_libfec/static/gen/candidate-BEqDafKu.css +1 -0
  17. datasette_libfec-0.0.1a5/datasette_libfec/static/gen/candidate-tqxa29G-.js +3 -0
  18. datasette_libfec-0.0.1a5/datasette_libfec/static/gen/class-C5DDKbJD.js +2 -0
  19. datasette_libfec-0.0.1a5/datasette_libfec/static/gen/committee-Bmki9iKb.css +1 -0
  20. datasette_libfec-0.0.1a5/datasette_libfec/static/gen/committee-DY1GmylW.js +2 -0
  21. datasette_libfec-0.0.1a5/datasette_libfec/static/gen/contest-BbYrzKRg.js +1 -0
  22. datasette_libfec-0.0.1a5/datasette_libfec/static/gen/contest-D4Fj7kGA.css +1 -0
  23. datasette_libfec-0.0.1a5/datasette_libfec/static/gen/each-DkfQbqzj.js +1 -0
  24. datasette_libfec-0.0.1a5/datasette_libfec/static/gen/filing_detail-Ba6_iQwV.css +1 -0
  25. datasette_libfec-0.0.1a5/datasette_libfec/static/gen/filing_detail-D2ib3OM6.js +26 -0
  26. datasette_libfec-0.0.1a5/datasette_libfec/static/gen/index-AHqus2fd.js +9 -0
  27. datasette_libfec-0.0.1a5/datasette_libfec/static/gen/index-client-CDwZ_Ixa.js +1 -0
  28. datasette_libfec-0.0.1a5/datasette_libfec/static/gen/index-jv9_YIKt.css +1 -0
  29. datasette_libfec-0.0.1a5/datasette_libfec/static/gen/load-AXKAVXVj.js +1 -0
  30. datasette_libfec-0.0.1a5/datasette_libfec/templates/libfec_base.html +12 -0
  31. {datasette_libfec-0.0.1a4 → datasette_libfec-0.0.1a5}/datasette_libfec.egg-info/PKG-INFO +2 -2
  32. datasette_libfec-0.0.1a5/datasette_libfec.egg-info/SOURCES.txt +40 -0
  33. {datasette_libfec-0.0.1a4 → datasette_libfec-0.0.1a5}/datasette_libfec.egg-info/requires.txt +1 -1
  34. {datasette_libfec-0.0.1a4 → datasette_libfec-0.0.1a5}/datasette_libfec.egg-info/top_level.txt +1 -0
  35. {datasette_libfec-0.0.1a4 → datasette_libfec-0.0.1a5}/pyproject.toml +2 -2
  36. datasette_libfec-0.0.1a5/scripts/typegen-pagedata.py +6 -0
  37. datasette_libfec-0.0.1a4/datasette_libfec/libfec_client.py +0 -69
  38. datasette_libfec-0.0.1a4/datasette_libfec/manifest.json +0 -11
  39. datasette_libfec-0.0.1a4/datasette_libfec/routes.py +0 -189
  40. datasette_libfec-0.0.1a4/datasette_libfec/static/gen/index-6cjSv2YC.css +0 -1
  41. datasette_libfec-0.0.1a4/datasette_libfec/static/gen/index-CaTQMY-X.js +0 -1
  42. datasette_libfec-0.0.1a4/datasette_libfec/templates/libfec.html +0 -14
  43. datasette_libfec-0.0.1a4/datasette_libfec.egg-info/SOURCES.txt +0 -17
  44. {datasette_libfec-0.0.1a4 → datasette_libfec-0.0.1a5}/LICENSE +0 -0
  45. {datasette_libfec-0.0.1a4 → datasette_libfec-0.0.1a5}/README.md +0 -0
  46. {datasette_libfec-0.0.1a4 → datasette_libfec-0.0.1a5}/datasette_libfec.egg-info/dependency_links.txt +0 -0
  47. {datasette_libfec-0.0.1a4 → datasette_libfec-0.0.1a5}/datasette_libfec.egg-info/entry_points.txt +0 -0
  48. {datasette_libfec-0.0.1a4 → datasette_libfec-0.0.1a5}/setup.cfg +0 -0
  49. {datasette_libfec-0.0.1a4 → datasette_libfec-0.0.1a5}/tests/test_libfec.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: datasette-libfec
3
- Version: 0.0.1a4
3
+ Version: 0.0.1a5
4
4
  Author: Alex Garcia
5
5
  License-Expression: Apache-2.0
6
6
  Project-URL: Homepage, https://github.com/datasette/datasette-libfec
@@ -13,7 +13,7 @@ Description-Content-Type: text/markdown
13
13
  License-File: LICENSE
14
14
  Requires-Dist: datasette>=1a23
15
15
  Requires-Dist: datasette-plugin-router>=0.0.1a2
16
- Requires-Dist: libfec>=0.0.18
16
+ Requires-Dist: libfec>=0.0.19
17
17
  Dynamic: license-file
18
18
 
19
19
  # datasette-libfec
@@ -6,7 +6,11 @@ from typing import Optional
6
6
  import json
7
7
  import os
8
8
 
9
- from .routes import router
9
+ # Import route modules to trigger route registration on the shared router
10
+ # pylint: disable=unused-import
11
+ from . import routes_rss, routes_export, routes_search, routes_exports, routes_pages
12
+ from .router import router
13
+ _ = routes_rss, routes_export, routes_search, routes_exports, routes_pages
10
14
 
11
15
  # https://vite.dev/guide/backend-integration.html
12
16
  class ManifestChunk(BaseModel):
@@ -0,0 +1,283 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import sys
5
+ import asyncio
6
+ import subprocess
7
+ import time
8
+ from pathlib import Path
9
+ from typing import Optional, TYPE_CHECKING, List
10
+
11
+ if TYPE_CHECKING:
12
+ from .libfec_rpc_client import LibfecRpcClient
13
+ from .libfec_export_rpc_client import LibfecExportRpcClient
14
+
15
+
16
+ class LibfecClient:
17
+ def __init__(self):
18
+ bin_path = os.environ.get("DATASETTE_LIBFEC_BIN_PATH")
19
+ if bin_path:
20
+ self.libfec_path = Path(bin_path)
21
+ else:
22
+ self.libfec_path = Path(sys.executable).parent / 'libfec'
23
+
24
+ async def _run_libfec_command_async(self, args):
25
+ """Async command execution - doesn't block event loop"""
26
+ print(f"Running async: {args}")
27
+ process = await asyncio.create_subprocess_exec(
28
+ str(self.libfec_path),
29
+ *args,
30
+ stdout=asyncio.subprocess.PIPE,
31
+ stderr=asyncio.subprocess.PIPE
32
+ )
33
+ stdout, stderr = await process.communicate()
34
+
35
+ if process.returncode != 0:
36
+ raise Exception(f"libfec error: {stderr.decode()}")
37
+ return stdout.decode()
38
+
39
+ async def export(self, committee_id: str, cycle: int, output_db: str) -> str:
40
+ """Export FEC data (async - won't block event loop)"""
41
+ return await self._run_libfec_command_async([
42
+ 'export', committee_id,
43
+ '--election', str(cycle),
44
+ '-o', output_db
45
+ ])
46
+
47
+ async def rss_watch(self, output_db: str, state: Optional[str] = None, cover_only: bool = True):
48
+ """Run single RSS watch command (async - won't block event loop)"""
49
+ args = ['rss', '--since', '1 day']
50
+ if cover_only:
51
+ args.append('--cover-only')
52
+ args.extend(['-x', output_db])
53
+ if state:
54
+ args.extend(['--state', state])
55
+ return await self._run_libfec_command_async(args)
56
+
57
+ async def rss_watch_with_progress(
58
+ self,
59
+ output_db: str,
60
+ state: Optional[str],
61
+ cover_only: bool,
62
+ watcher_state: RssWatcherState
63
+ ) -> None:
64
+ """
65
+ Run RSS watch using RPC mode with real-time progress tracking.
66
+
67
+ Updates watcher_state with progress information from RPC notifications.
68
+ """
69
+ from .libfec_rpc_client import LibfecRpcClient, RpcError
70
+
71
+ # Reset progress state
72
+ watcher_state.phase = "idle"
73
+ watcher_state.exported_count = 0
74
+ watcher_state.total_count = 0
75
+ watcher_state.current_filing_id = None
76
+ watcher_state.feed_title = None
77
+ watcher_state.feed_last_modified = None
78
+ watcher_state.error_message = None
79
+ watcher_state.error_code = None
80
+ watcher_state.error_data = None
81
+ watcher_state.sync_start_time = time.time()
82
+
83
+ # Use RPC mode
84
+ rpc_client = LibfecRpcClient(str(self.libfec_path))
85
+ watcher_state.rpc_client = rpc_client
86
+
87
+ def on_progress(notification: dict) -> None:
88
+ """Progress callback - updates watcher_state from RPC notifications"""
89
+ method = notification.get("method")
90
+ params = notification.get("params", {})
91
+
92
+ if method == "sync/progress":
93
+ watcher_state.phase = params.get("phase", "idle")
94
+ watcher_state.exported_count = params.get("exported_count", 0)
95
+ watcher_state.total_count = params.get("total_count", 0)
96
+ watcher_state.current_filing_id = params.get("current_filing_id")
97
+ watcher_state.feed_title = params.get("feed_title")
98
+ watcher_state.feed_last_modified = params.get("feed_last_modified")
99
+
100
+ # Update currently_syncing based on phase
101
+ watcher_state.currently_syncing = watcher_state.phase in (
102
+ "fetching", "exporting"
103
+ )
104
+
105
+ try:
106
+ watcher_state.currently_syncing = True
107
+ await rpc_client.start_process()
108
+
109
+ result = await rpc_client.sync_start(
110
+ since="1 day",
111
+ state=state,
112
+ cover_only=cover_only,
113
+ output_path=output_db,
114
+ progress_callback=on_progress
115
+ )
116
+
117
+ # Mark as complete
118
+ watcher_state.phase = "complete"
119
+ print(f"RSS sync complete: {result}")
120
+
121
+ except RpcError as e:
122
+ watcher_state.phase = "error"
123
+ watcher_state.error_message = e.message
124
+ watcher_state.error_code = e.code
125
+ watcher_state.error_data = str(e.data) if e.data else None
126
+ print(f"RPC error: {e}, data: {e.data}")
127
+
128
+ except asyncio.TimeoutError:
129
+ watcher_state.phase = "error"
130
+ watcher_state.error_message = "Sync timed out after 5 minutes"
131
+ print("RSS sync timeout")
132
+
133
+ except Exception as e:
134
+ watcher_state.phase = "error"
135
+ watcher_state.error_message = str(e)
136
+ print(f"RSS sync error: {e}")
137
+
138
+ finally:
139
+ watcher_state.currently_syncing = False
140
+ try:
141
+ await rpc_client.shutdown()
142
+ except Exception as e:
143
+ print(f"Error shutting down RPC client: {e}")
144
+ try:
145
+ await rpc_client.terminate()
146
+ except Exception:
147
+ pass
148
+ watcher_state.rpc_client = None
149
+
150
+ async def export_with_progress(
151
+ self,
152
+ output_db: str,
153
+ filings: Optional[List[str]],
154
+ cycle: Optional[int],
155
+ cover_only: bool,
156
+ clobber: bool,
157
+ export_state: ExportState
158
+ ) -> None:
159
+ """
160
+ Run export using RPC mode with real-time progress tracking.
161
+
162
+ Updates export_state with progress information from RPC notifications.
163
+ """
164
+ from .libfec_export_rpc_client import LibfecExportRpcClient, RpcError
165
+
166
+ # Reset progress state
167
+ export_state.phase = "idle"
168
+ export_state.completed = 0
169
+ export_state.total = 0
170
+ export_state.current_filing_id = None
171
+ export_state.current = None
172
+ export_state.total_exported = None
173
+ export_state.warnings = []
174
+ export_state.error_message = None
175
+ export_state.export_start_time = time.time()
176
+
177
+ # Use RPC mode
178
+ rpc_client = LibfecExportRpcClient(str(self.libfec_path), output_db)
179
+ export_state.rpc_client = rpc_client
180
+
181
+ def on_progress(notification: dict) -> None:
182
+ """Progress callback - updates export_state from RPC notifications"""
183
+ method = notification.get("method")
184
+ params = notification.get("params", {})
185
+
186
+ if method == "export/progress":
187
+ export_state.phase = params.get("phase", "idle")
188
+ export_state.completed = params.get("completed", 0)
189
+ export_state.total = params.get("total", 0)
190
+ export_state.current_filing_id = params.get("current_filing_id")
191
+ export_state.current = params.get("current")
192
+ export_state.total_exported = params.get("total_exported")
193
+ export_state.warnings = params.get("warnings", [])
194
+ if params.get("error_message"):
195
+ export_state.error_message = params["error_message"]
196
+
197
+ try:
198
+ export_state.running = True
199
+ await rpc_client.start_process()
200
+
201
+ result = await rpc_client.export_start(
202
+ filings=filings,
203
+ cycle=cycle,
204
+ cover_only=cover_only,
205
+ clobber=clobber,
206
+ progress_callback=on_progress
207
+ )
208
+
209
+ # Mark as complete
210
+ export_state.phase = "complete"
211
+ print(f"Export complete: {result}")
212
+
213
+ except RpcError as e:
214
+ export_state.phase = "error"
215
+ export_state.error_message = str(e.data) if e.data else e.message
216
+ print(f"RPC error: {e}")
217
+
218
+ except asyncio.TimeoutError:
219
+ export_state.phase = "error"
220
+ export_state.error_message = "Export timed out after 5 minutes"
221
+ print("Export timeout")
222
+
223
+ except Exception as e:
224
+ export_state.phase = "error"
225
+ export_state.error_message = str(e)
226
+ print(f"Export error: {e}")
227
+
228
+ finally:
229
+ export_state.running = False
230
+ try:
231
+ await rpc_client.shutdown()
232
+ except Exception as e:
233
+ print(f"Error shutting down RPC client: {e}")
234
+ try:
235
+ await rpc_client.terminate()
236
+ except Exception:
237
+ pass
238
+ export_state.rpc_client = None
239
+
240
+
241
+ # RSS watcher state
242
+ class RssWatcherState:
243
+ def __init__(self):
244
+ self.task: Optional[asyncio.Task] = None
245
+ self.running = False
246
+ self.interval = 60
247
+ self.state: Optional[str] = None
248
+ self.cover_only = True
249
+ self.output_db: Optional[str] = None
250
+ self.next_sync_time: Optional[float] = None
251
+ self.currently_syncing = False
252
+
253
+ # Progress tracking fields for RPC mode
254
+ self.phase: str = "idle" # idle|fetching|exporting|complete|canceled|error
255
+ self.exported_count: int = 0
256
+ self.total_count: int = 0
257
+ self.current_filing_id: Optional[str] = None
258
+ self.feed_title: Optional[str] = None
259
+ self.feed_last_modified: Optional[str] = None
260
+ self.error_message: Optional[str] = None
261
+ self.error_code: Optional[int] = None
262
+ self.error_data: Optional[str] = None
263
+ self.sync_start_time: Optional[float] = None
264
+ self.rpc_client: Optional["LibfecRpcClient"] = None
265
+
266
+
267
+ # Export state
268
+ class ExportState:
269
+ def __init__(self):
270
+ self.export_id: Optional[str] = None
271
+ self.running = False
272
+
273
+ # Progress tracking fields for export RPC mode
274
+ self.phase: str = "idle" # idle|sourcing|downloading_bulk|exporting|complete|canceled|error
275
+ self.completed: int = 0
276
+ self.total: int = 0
277
+ self.current_filing_id: Optional[str] = None
278
+ self.current: Optional[str] = None # For bulk download phase
279
+ self.total_exported: Optional[int] = None
280
+ self.warnings: List[str] = []
281
+ self.error_message: Optional[str] = None
282
+ self.export_start_time: Optional[float] = None
283
+ self.rpc_client: Optional["LibfecExportRpcClient"] = None
@@ -0,0 +1,358 @@
1
+ """
2
+ JSON-RPC 2.0 client for libfec export --rpc mode.
3
+
4
+ Manages libfec export 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, List
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 LibfecExportRpcClient:
25
+ """
26
+ Manages libfec export --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, output_db: str):
34
+ self.libfec_path = libfec_path
35
+ self.output_db = output_db
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.progress_callback: Optional[Callable] = None
41
+ self.completion_future: Optional[asyncio.Future] = None
42
+ self.ready_future: Optional[asyncio.Future] = None
43
+
44
+ async def start_process(self) -> None:
45
+ """Spawn libfec export --rpc subprocess"""
46
+ if self.process is not None:
47
+ raise RuntimeError("Process already started")
48
+
49
+ logger.info(f"Starting libfec export --rpc: {self.libfec_path} -o {self.output_db}")
50
+
51
+ # Create ready future to wait for ready notification
52
+ self.ready_future = asyncio.Future()
53
+
54
+ self.process = await asyncio.create_subprocess_exec(
55
+ self.libfec_path,
56
+ "export",
57
+ "--rpc",
58
+ "-o",
59
+ self.output_db,
60
+ stdin=asyncio.subprocess.PIPE,
61
+ stdout=asyncio.subprocess.PIPE,
62
+ stderr=asyncio.subprocess.PIPE,
63
+ )
64
+
65
+ # Start listening for messages
66
+ self.listen_task = asyncio.create_task(self._listen_for_messages())
67
+
68
+ # Wait for ready notification with timeout
69
+ try:
70
+ await asyncio.wait_for(self.ready_future, timeout=5.0)
71
+ logger.info("RPC server ready")
72
+ except asyncio.TimeoutError:
73
+ logger.error("Timeout waiting for ready notification")
74
+ await self.terminate()
75
+ raise RuntimeError("libfec process did not send ready notification")
76
+
77
+ async def _listen_for_messages(self) -> None:
78
+ """
79
+ Read stdout line-by-line (JSONL protocol).
80
+ Route responses to waiting futures by request ID.
81
+ Route notifications to progress_callback.
82
+ """
83
+ if not self.process or not self.process.stdout:
84
+ return
85
+
86
+ try:
87
+ while True:
88
+ line = await self.process.stdout.readline()
89
+ if not line:
90
+ # EOF - process died
91
+ logger.error("libfec process stdout EOF (process crashed?)")
92
+ # Cancel all pending requests
93
+ for future in self.pending_requests.values():
94
+ if not future.done():
95
+ future.set_exception(
96
+ RuntimeError("libfec process terminated unexpectedly")
97
+ )
98
+ # Cancel completion future if waiting
99
+ if self.completion_future and not self.completion_future.done():
100
+ self.completion_future.set_exception(
101
+ RuntimeError("libfec process terminated before export completed")
102
+ )
103
+ break
104
+
105
+ try:
106
+ msg = json.loads(line.decode())
107
+ except json.JSONDecodeError as e:
108
+ logger.error(f"Invalid JSON from libfec: {line!r} - {e}")
109
+ continue
110
+
111
+ # Check if response or notification
112
+ if "id" in msg:
113
+ # Response - match to pending request
114
+ request_id = msg["id"]
115
+ future = self.pending_requests.get(request_id)
116
+
117
+ if future and not future.done():
118
+ if "error" in msg:
119
+ err = msg["error"]
120
+ future.set_exception(
121
+ RpcError(
122
+ err.get("code", -1),
123
+ err.get("message", "Unknown error"),
124
+ err.get("data")
125
+ )
126
+ )
127
+ elif "result" in msg:
128
+ future.set_result(msg["result"])
129
+ else:
130
+ future.set_exception(
131
+ RuntimeError(f"Invalid RPC response: {msg}")
132
+ )
133
+ else:
134
+ # Notification - deliver to callback
135
+ if "method" in msg:
136
+ # Check if this is a ready notification
137
+ if msg.get("method") == "ready":
138
+ if self.ready_future and not self.ready_future.done():
139
+ self.ready_future.set_result(msg.get("params", {}))
140
+
141
+ # Check if this is a completion notification
142
+ elif msg.get("method") == "export/progress":
143
+ params = msg.get("params", {})
144
+ phase = params.get("phase")
145
+
146
+ # Resolve completion future on terminal phases
147
+ if phase in ("complete", "canceled", "error"):
148
+ if self.completion_future and not self.completion_future.done():
149
+ self.completion_future.set_result(params)
150
+
151
+ # Deliver to callback
152
+ if self.progress_callback:
153
+ try:
154
+ self.progress_callback(msg)
155
+ except Exception as e:
156
+ logger.error(f"Progress callback error: {e}", exc_info=True)
157
+
158
+ except asyncio.CancelledError:
159
+ logger.debug("Message listener cancelled")
160
+ raise
161
+ except Exception as e:
162
+ logger.error(f"Error in message listener: {e}", exc_info=True)
163
+
164
+ async def send_request(
165
+ self,
166
+ method: str,
167
+ params: Optional[dict] = None,
168
+ timeout: float = 5.0
169
+ ) -> Any:
170
+ """
171
+ Send JSON-RPC request via stdin, wait for response.
172
+
173
+ Args:
174
+ method: RPC method name
175
+ params: Method parameters
176
+ timeout: Response timeout in seconds
177
+
178
+ Returns:
179
+ Result from response
180
+
181
+ Raises:
182
+ RpcError: On JSON-RPC error response
183
+ TimeoutError: On timeout
184
+ RuntimeError: On process errors
185
+ """
186
+ if not self.process or not self.process.stdin:
187
+ raise RuntimeError("Process not started")
188
+
189
+ self.request_id += 1
190
+ request = {
191
+ "jsonrpc": "2.0",
192
+ "id": self.request_id,
193
+ "method": method,
194
+ }
195
+ if params is not None:
196
+ request["params"] = params
197
+
198
+ # Create future for response
199
+ future: asyncio.Future = asyncio.Future()
200
+ self.pending_requests[self.request_id] = future
201
+
202
+ try:
203
+ # Send request
204
+ request_json = json.dumps(request) + "\n"
205
+ self.process.stdin.write(request_json.encode())
206
+ await self.process.stdin.drain()
207
+
208
+ logger.debug(f"Sent RPC request: {method} (id={self.request_id})")
209
+
210
+ # Wait for response with timeout
211
+ result = await asyncio.wait_for(future, timeout=timeout)
212
+ return result
213
+
214
+ except asyncio.TimeoutError:
215
+ logger.error(f"RPC request timeout: {method}")
216
+ raise TimeoutError(f"RPC request timed out: {method}")
217
+ finally:
218
+ # Clean up pending request
219
+ self.pending_requests.pop(self.request_id, None)
220
+
221
+ async def export_start(
222
+ self,
223
+ filings: Optional[List[str]],
224
+ cycle: Optional[int],
225
+ cover_only: bool,
226
+ clobber: bool,
227
+ progress_callback: Callable,
228
+ write_metadata: bool = True,
229
+ ) -> dict:
230
+ """
231
+ Start export operation with progress tracking.
232
+
233
+ Args:
234
+ filings: Filing IDs, committee IDs, or candidate IDs to export
235
+ cycle: Election cycle year (e.g., 2024)
236
+ cover_only: Only export cover pages
237
+ clobber: Overwrite existing database content
238
+ progress_callback: Called with export/progress notifications
239
+ write_metadata: Whether to write metadata tables in output database
240
+
241
+ Returns:
242
+ Final export result
243
+
244
+ Raises:
245
+ RpcError: On export errors
246
+ TimeoutError: On export timeout (300s)
247
+ """
248
+ self.progress_callback = progress_callback
249
+
250
+ params = {}
251
+ if filings is not None:
252
+ params["filings"] = filings
253
+ if cycle is not None:
254
+ params["cycle"] = cycle
255
+ if cover_only:
256
+ params["cover_only"] = cover_only
257
+ if clobber:
258
+ params["clobber"] = clobber
259
+ if write_metadata:
260
+ params["write_metadata"] = write_metadata
261
+
262
+ # Create completion future to wait for final notification
263
+ self.completion_future = asyncio.Future()
264
+
265
+ # Send export/start request (just starts the export)
266
+ logger.info(f"Sending export/start with params: {params}")
267
+ start_result = await self.send_request("export/start", params, timeout=30.0)
268
+ logger.info(f"Export started: {start_result}")
269
+
270
+ # Keep sending export/status requests to keep the RPC server processing exports
271
+ async def poll_status():
272
+ while self.completion_future and not self.completion_future.done():
273
+ try:
274
+ await asyncio.sleep(0.5) # Poll every 500ms
275
+ if self.completion_future and not self.completion_future.done():
276
+ await self.send_request("export/status", timeout=5.0)
277
+ except Exception as e:
278
+ logger.debug(f"Status poll error (expected on completion): {e}")
279
+ break
280
+
281
+ # Start status polling task
282
+ poll_task = asyncio.create_task(poll_status())
283
+
284
+ # Wait for completion notification with 300s timeout
285
+ try:
286
+ completion_result = await asyncio.wait_for(self.completion_future, timeout=300.0)
287
+
288
+ # Check if export completed with error
289
+ if completion_result.get("phase") == "error":
290
+ error_msg = completion_result.get("error_message", "Unknown error")
291
+ error_code = completion_result.get("error_code", -1)
292
+ error_data = completion_result.get("error_data")
293
+ raise RpcError(error_code, error_msg, error_data)
294
+
295
+ return completion_result
296
+ except asyncio.TimeoutError:
297
+ logger.error("Export did not complete within 300 seconds")
298
+ raise TimeoutError("Export timed out after 5 minutes")
299
+ finally:
300
+ # Cancel polling task
301
+ poll_task.cancel()
302
+ try:
303
+ await poll_task
304
+ except asyncio.CancelledError:
305
+ pass
306
+
307
+ async def export_cancel(self) -> dict:
308
+ """Cancel in-progress export"""
309
+ return await self.send_request("export/cancel", timeout=5.0)
310
+
311
+ async def shutdown(self) -> None:
312
+ """Gracefully shutdown RPC process"""
313
+ if not self.process:
314
+ return
315
+
316
+ try:
317
+ await self.send_request("shutdown", timeout=2.0)
318
+ except Exception as e:
319
+ logger.warning(f"Shutdown request failed: {e}")
320
+
321
+ # Wait for process to exit
322
+ try:
323
+ await asyncio.wait_for(self.process.wait(), timeout=5.0)
324
+ except asyncio.TimeoutError:
325
+ logger.warning("Process did not exit after shutdown, terminating")
326
+ await self.terminate()
327
+
328
+ # Cancel listener task
329
+ if self.listen_task and not self.listen_task.done():
330
+ self.listen_task.cancel()
331
+ try:
332
+ await self.listen_task
333
+ except asyncio.CancelledError:
334
+ pass
335
+
336
+ self.process = None
337
+
338
+ async def terminate(self) -> None:
339
+ """Force kill the process (for cleanup)"""
340
+ if self.process:
341
+ try:
342
+ self.process.terminate()
343
+ await asyncio.wait_for(self.process.wait(), timeout=2.0)
344
+ except asyncio.TimeoutError:
345
+ logger.warning("Process did not terminate, killing")
346
+ self.process.kill()
347
+ await self.process.wait()
348
+ except Exception as e:
349
+ logger.error(f"Error terminating process: {e}")
350
+
351
+ if self.listen_task and not self.listen_task.done():
352
+ self.listen_task.cancel()
353
+ try:
354
+ await self.listen_task
355
+ except asyncio.CancelledError:
356
+ pass
357
+
358
+ self.process = None