datasette-libfec 0.0.1a4__tar.gz → 0.0.1a6__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.
- {datasette_libfec-0.0.1a4 → datasette_libfec-0.0.1a6}/PKG-INFO +2 -2
- {datasette_libfec-0.0.1a4 → datasette_libfec-0.0.1a6}/datasette_libfec/__init__.py +18 -2
- datasette_libfec-0.0.1a6/datasette_libfec/libfec_client.py +283 -0
- datasette_libfec-0.0.1a6/datasette_libfec/libfec_export_rpc_client.py +358 -0
- datasette_libfec-0.0.1a6/datasette_libfec/libfec_rpc_client.py +335 -0
- datasette_libfec-0.0.1a6/datasette_libfec/libfec_search_rpc_client.py +308 -0
- datasette_libfec-0.0.1a6/datasette_libfec/manifest.json +93 -0
- datasette_libfec-0.0.1a6/datasette_libfec/page_data.py +87 -0
- datasette_libfec-0.0.1a6/datasette_libfec/router.py +24 -0
- datasette_libfec-0.0.1a6/datasette_libfec/routes_export.py +128 -0
- datasette_libfec-0.0.1a6/datasette_libfec/routes_exports.py +222 -0
- datasette_libfec-0.0.1a6/datasette_libfec/routes_pages.py +341 -0
- datasette_libfec-0.0.1a6/datasette_libfec/routes_rss.py +416 -0
- datasette_libfec-0.0.1a6/datasette_libfec/routes_search.py +78 -0
- datasette_libfec-0.0.1a6/datasette_libfec/state.py +6 -0
- datasette_libfec-0.0.1a6/datasette_libfec/static/gen/candidate-BEqDafKu.css +1 -0
- datasette_libfec-0.0.1a6/datasette_libfec/static/gen/candidate-tqxa29G-.js +3 -0
- datasette_libfec-0.0.1a6/datasette_libfec/static/gen/class-C5DDKbJD.js +2 -0
- datasette_libfec-0.0.1a6/datasette_libfec/static/gen/committee-Bmki9iKb.css +1 -0
- datasette_libfec-0.0.1a6/datasette_libfec/static/gen/committee-DY1GmylW.js +2 -0
- datasette_libfec-0.0.1a6/datasette_libfec/static/gen/contest-BbYrzKRg.js +1 -0
- datasette_libfec-0.0.1a6/datasette_libfec/static/gen/contest-D4Fj7kGA.css +1 -0
- datasette_libfec-0.0.1a6/datasette_libfec/static/gen/each-DkfQbqzj.js +1 -0
- datasette_libfec-0.0.1a6/datasette_libfec/static/gen/filing_detail-Ba6_iQwV.css +1 -0
- datasette_libfec-0.0.1a6/datasette_libfec/static/gen/filing_detail-D2ib3OM6.js +26 -0
- datasette_libfec-0.0.1a6/datasette_libfec/static/gen/index-AHqus2fd.js +9 -0
- datasette_libfec-0.0.1a6/datasette_libfec/static/gen/index-client-CDwZ_Ixa.js +1 -0
- datasette_libfec-0.0.1a6/datasette_libfec/static/gen/index-jv9_YIKt.css +1 -0
- datasette_libfec-0.0.1a6/datasette_libfec/static/gen/load-AXKAVXVj.js +1 -0
- datasette_libfec-0.0.1a6/datasette_libfec/templates/libfec_base.html +12 -0
- {datasette_libfec-0.0.1a4 → datasette_libfec-0.0.1a6}/datasette_libfec.egg-info/PKG-INFO +2 -2
- datasette_libfec-0.0.1a6/datasette_libfec.egg-info/SOURCES.txt +40 -0
- {datasette_libfec-0.0.1a4 → datasette_libfec-0.0.1a6}/datasette_libfec.egg-info/requires.txt +1 -1
- {datasette_libfec-0.0.1a4 → datasette_libfec-0.0.1a6}/datasette_libfec.egg-info/top_level.txt +1 -0
- {datasette_libfec-0.0.1a4 → datasette_libfec-0.0.1a6}/pyproject.toml +2 -2
- datasette_libfec-0.0.1a6/scripts/typegen-pagedata.py +6 -0
- datasette_libfec-0.0.1a4/datasette_libfec/libfec_client.py +0 -69
- datasette_libfec-0.0.1a4/datasette_libfec/manifest.json +0 -11
- datasette_libfec-0.0.1a4/datasette_libfec/routes.py +0 -189
- datasette_libfec-0.0.1a4/datasette_libfec/static/gen/index-6cjSv2YC.css +0 -1
- datasette_libfec-0.0.1a4/datasette_libfec/static/gen/index-CaTQMY-X.js +0 -1
- datasette_libfec-0.0.1a4/datasette_libfec/templates/libfec.html +0 -14
- datasette_libfec-0.0.1a4/datasette_libfec.egg-info/SOURCES.txt +0 -17
- {datasette_libfec-0.0.1a4 → datasette_libfec-0.0.1a6}/LICENSE +0 -0
- {datasette_libfec-0.0.1a4 → datasette_libfec-0.0.1a6}/README.md +0 -0
- {datasette_libfec-0.0.1a4 → datasette_libfec-0.0.1a6}/datasette_libfec.egg-info/dependency_links.txt +0 -0
- {datasette_libfec-0.0.1a4 → datasette_libfec-0.0.1a6}/datasette_libfec.egg-info/entry_points.txt +0 -0
- {datasette_libfec-0.0.1a4 → datasette_libfec-0.0.1a6}/setup.cfg +0 -0
- {datasette_libfec-0.0.1a4 → datasette_libfec-0.0.1a6}/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.
|
|
3
|
+
Version: 0.0.1a6
|
|
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.
|
|
16
|
+
Requires-Dist: libfec>=0.0.19
|
|
17
17
|
Dynamic: license-file
|
|
18
18
|
|
|
19
19
|
# datasette-libfec
|
|
@@ -1,12 +1,17 @@
|
|
|
1
1
|
from pydantic import BaseModel
|
|
2
2
|
from datasette import hookimpl
|
|
3
|
+
from datasette.permissions import Action
|
|
3
4
|
from pathlib import Path
|
|
4
5
|
from textwrap import dedent
|
|
5
6
|
from typing import Optional
|
|
6
7
|
import json
|
|
7
8
|
import os
|
|
8
9
|
|
|
9
|
-
|
|
10
|
+
# Import route modules to trigger route registration on the shared router
|
|
11
|
+
# pylint: disable=unused-import
|
|
12
|
+
from . import routes_rss, routes_export, routes_search, routes_exports, routes_pages
|
|
13
|
+
from .router import router, LIBFEC_ACCESS_NAME
|
|
14
|
+
_ = routes_rss, routes_export, routes_search, routes_exports, routes_pages
|
|
10
15
|
|
|
11
16
|
# https://vite.dev/guide/backend-integration.html
|
|
12
17
|
class ManifestChunk(BaseModel):
|
|
@@ -77,4 +82,15 @@ def extra_template_vars(datasette):
|
|
|
77
82
|
return "\n".join(parts)
|
|
78
83
|
|
|
79
84
|
|
|
80
|
-
return {"datasette_libfec_vite_entry": datasette_libfec_vite_entry}
|
|
85
|
+
return {"datasette_libfec_vite_entry": datasette_libfec_vite_entry}
|
|
86
|
+
|
|
87
|
+
@hookimpl
|
|
88
|
+
def register_actions(datasette):
|
|
89
|
+
return [
|
|
90
|
+
Action(
|
|
91
|
+
name=LIBFEC_ACCESS_NAME,
|
|
92
|
+
description=(
|
|
93
|
+
"Can access libfec pages, features"
|
|
94
|
+
),
|
|
95
|
+
),
|
|
96
|
+
]
|
|
@@ -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
|