threadsense 0.1.0a0__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.
@@ -0,0 +1,5 @@
1
+ from __future__ import annotations
2
+
3
+ __all__ = ["__version__"]
4
+
5
+ __version__ = "0.1.0a0"
@@ -0,0 +1,6 @@
1
+ from __future__ import annotations
2
+
3
+ from threadsense.cli import main
4
+
5
+ if __name__ == "__main__":
6
+ raise SystemExit(main())
@@ -0,0 +1,246 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import logging
5
+ import threading
6
+ from dataclasses import dataclass
7
+ from http import HTTPStatus
8
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ from threadsense.config import AppConfig
13
+ from threadsense.errors import ApiInputError, ThreadSenseError
14
+ from threadsense.inference import InferenceTask
15
+ from threadsense.observability import DEFAULT_METRICS, MetricsRegistry, TraceContext, observe_stage
16
+ from threadsense.workflows import (
17
+ RedditConnectorFactory,
18
+ analyze_normalized_thread,
19
+ fetch_reddit_thread,
20
+ infer_analysis,
21
+ report_analysis,
22
+ )
23
+ from threadsense.workflows import normalize_reddit_thread as normalize_reddit_workflow
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class ApiServerHandle:
28
+ server: ThreadingHTTPServer
29
+ thread: threading.Thread
30
+
31
+ @property
32
+ def base_url(self) -> str:
33
+ host, port = self.server.server_address[:2]
34
+ resolved_host = host.decode("utf-8") if isinstance(host, bytes) else host
35
+ return f"http://{resolved_host}:{port}"
36
+
37
+
38
+ @dataclass(frozen=True)
39
+ class ServerDependencies:
40
+ config: AppConfig
41
+ logger: logging.Logger
42
+ connector_factory: RedditConnectorFactory
43
+ registry: MetricsRegistry
44
+
45
+
46
+ def start_api_server(
47
+ *,
48
+ config: AppConfig,
49
+ logger: logging.Logger,
50
+ connector_factory: RedditConnectorFactory,
51
+ registry: MetricsRegistry = DEFAULT_METRICS,
52
+ host: str | None = None,
53
+ port: int | None = None,
54
+ ) -> ApiServerHandle:
55
+ dependencies = ServerDependencies(
56
+ config=config,
57
+ logger=logger,
58
+ connector_factory=connector_factory,
59
+ registry=registry,
60
+ )
61
+ server = ThreadingHTTPServer(
62
+ (host or config.api.host, port or config.api.port),
63
+ build_handler(dependencies),
64
+ )
65
+ thread = threading.Thread(target=server.serve_forever, daemon=True)
66
+ thread.start()
67
+ return ApiServerHandle(server=server, thread=thread)
68
+
69
+
70
+ def build_handler(dependencies: ServerDependencies) -> type[BaseHTTPRequestHandler]:
71
+ class ThreadSenseApiHandler(BaseHTTPRequestHandler):
72
+ server_version = "ThreadSenseHTTP/1.0"
73
+
74
+ def do_GET(self) -> None: # noqa: N802
75
+ trace = TraceContext.create(run_id="api", source_name="http")
76
+ if self.path == "/v1/healthz":
77
+ self._write_json(HTTPStatus.OK, {"status": "ready"})
78
+ return
79
+ if self.path == "/v1/metrics":
80
+ with observe_stage(
81
+ registry=dependencies.registry,
82
+ logger=dependencies.logger,
83
+ trace=trace,
84
+ stage="api_metrics",
85
+ ):
86
+ body = dependencies.registry.render_prometheus().encode("utf-8")
87
+ self.send_response(HTTPStatus.OK)
88
+ self.send_header("Content-Type", "text/plain; version=0.0.4")
89
+ self.send_header("Content-Length", str(len(body)))
90
+ self.end_headers()
91
+ self.wfile.write(body)
92
+ return
93
+ self._write_error(
94
+ HTTPStatus.NOT_FOUND,
95
+ ApiInputError("API route does not exist", details={"path": self.path}),
96
+ )
97
+
98
+ def do_POST(self) -> None: # noqa: N802
99
+ trace = TraceContext.create(run_id="api", source_name="http")
100
+ try:
101
+ with observe_stage(
102
+ registry=dependencies.registry,
103
+ logger=dependencies.logger,
104
+ trace=trace,
105
+ stage="api_request",
106
+ labels={"path": self.path},
107
+ ):
108
+ payload = self._read_json_body(dependencies.config.api.max_request_bytes)
109
+ response = self._dispatch(payload, trace)
110
+ except ThreadSenseError as error:
111
+ self._write_error(HTTPStatus.BAD_REQUEST, error)
112
+ return
113
+ self._write_json(HTTPStatus.OK, response)
114
+
115
+ def log_message(self, format: str, *args: object) -> None:
116
+ return
117
+
118
+ def _dispatch(self, payload: dict[str, Any], trace: TraceContext) -> dict[str, Any]:
119
+ if self.path == "/v1/fetch/reddit":
120
+ return fetch_reddit_thread(
121
+ config=dependencies.config,
122
+ logger=dependencies.logger,
123
+ trace=trace,
124
+ url=required_str(payload, "url"),
125
+ output_path=optional_path(payload, "output_path"),
126
+ expand_more=optional_bool(payload, "expand_more", False),
127
+ flat=optional_bool(payload, "flat", False),
128
+ connector_factory=dependencies.connector_factory,
129
+ registry=dependencies.registry,
130
+ )
131
+ if self.path == "/v1/normalize/reddit":
132
+ return normalize_reddit_workflow(
133
+ config=dependencies.config,
134
+ logger=dependencies.logger,
135
+ trace=trace,
136
+ input_path=required_path(payload, "input_path"),
137
+ output_path=optional_path(payload, "output_path"),
138
+ registry=dependencies.registry,
139
+ )
140
+ if self.path == "/v1/analyze/normalized":
141
+ return analyze_normalized_thread(
142
+ config=dependencies.config,
143
+ logger=dependencies.logger,
144
+ trace=trace,
145
+ input_path=required_path(payload, "input_path"),
146
+ output_path=optional_path(payload, "output_path"),
147
+ registry=dependencies.registry,
148
+ )
149
+ if self.path == "/v1/report/analysis":
150
+ return report_analysis(
151
+ config=dependencies.config,
152
+ logger=dependencies.logger,
153
+ trace=trace,
154
+ input_path=required_path(payload, "input_path"),
155
+ output_path=optional_path(payload, "output_path"),
156
+ report_format=optional_str(payload, "format", "markdown"),
157
+ with_summary=optional_bool(payload, "with_summary", False),
158
+ summary_required=optional_bool(payload, "summary_required", False),
159
+ registry=dependencies.registry,
160
+ )
161
+ if self.path == "/v1/infer/analysis":
162
+ return infer_analysis(
163
+ config=dependencies.config,
164
+ logger=dependencies.logger,
165
+ trace=trace,
166
+ input_path=required_path(payload, "input_path"),
167
+ task=InferenceTask(optional_str(payload, "task", "analysis_summary")),
168
+ required=optional_bool(payload, "required", False),
169
+ registry=dependencies.registry,
170
+ )
171
+ raise ApiInputError("API route does not exist", details={"path": self.path})
172
+
173
+ def _read_json_body(self, max_request_bytes: int) -> dict[str, Any]:
174
+ content_length_header = self.headers.get("Content-Length", "0")
175
+ try:
176
+ content_length = int(content_length_header)
177
+ except ValueError as error:
178
+ raise ApiInputError(
179
+ "Content-Length header is invalid",
180
+ details={"content_length": content_length_header},
181
+ ) from error
182
+ if content_length <= 0:
183
+ raise ApiInputError("request body is required")
184
+ if content_length > max_request_bytes:
185
+ raise ApiInputError(
186
+ "request body exceeds max_request_bytes",
187
+ details={
188
+ "content_length": content_length,
189
+ "max_request_bytes": max_request_bytes,
190
+ },
191
+ )
192
+ raw_body = self.rfile.read(content_length)
193
+ try:
194
+ payload = json.loads(raw_body.decode("utf-8"))
195
+ except json.JSONDecodeError as error:
196
+ raise ApiInputError("request body must be valid JSON") from error
197
+ if not isinstance(payload, dict):
198
+ raise ApiInputError("request body must decode to an object")
199
+ return payload
200
+
201
+ def _write_json(self, status_code: HTTPStatus, payload: dict[str, Any]) -> None:
202
+ body = json.dumps(payload, indent=2).encode("utf-8")
203
+ self.send_response(status_code.value)
204
+ self.send_header("Content-Type", "application/json")
205
+ self.send_header("Content-Length", str(len(body)))
206
+ self.end_headers()
207
+ self.wfile.write(body)
208
+
209
+ def _write_error(self, status_code: HTTPStatus, error: ThreadSenseError) -> None:
210
+ self._write_json(status_code, {"status": "error", "error": error.to_dict()})
211
+
212
+ return ThreadSenseApiHandler
213
+
214
+
215
+ def required_str(payload: dict[str, Any], key: str) -> str:
216
+ value = payload.get(key)
217
+ if not isinstance(value, str) or not value:
218
+ raise ApiInputError("request field is invalid", details={"key": key})
219
+ return value
220
+
221
+
222
+ def optional_str(payload: dict[str, Any], key: str, default: str) -> str:
223
+ value = payload.get(key, default)
224
+ if not isinstance(value, str) or not value:
225
+ raise ApiInputError("request field is invalid", details={"key": key})
226
+ return value
227
+
228
+
229
+ def optional_bool(payload: dict[str, Any], key: str, default: bool) -> bool:
230
+ value = payload.get(key, default)
231
+ if not isinstance(value, bool):
232
+ raise ApiInputError("request field is invalid", details={"key": key})
233
+ return value
234
+
235
+
236
+ def required_path(payload: dict[str, Any], key: str) -> Path:
237
+ return Path(required_str(payload, key))
238
+
239
+
240
+ def optional_path(payload: dict[str, Any], key: str) -> Path | None:
241
+ value = payload.get(key)
242
+ if value is None:
243
+ return None
244
+ if not isinstance(value, str) or not value:
245
+ raise ApiInputError("request field is invalid", details={"key": key})
246
+ return Path(value)
@@ -0,0 +1,306 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ from collections.abc import Mapping
5
+ from concurrent.futures import ALL_COMPLETED, FIRST_EXCEPTION, Future, ThreadPoolExecutor, wait
6
+ from dataclasses import asdict, dataclass
7
+ from pathlib import Path
8
+ from time import time
9
+ from typing import Any
10
+
11
+ from threadsense.config import AppConfig
12
+ from threadsense.errors import BatchBoundaryError, ResourceLimitError, SchemaBoundaryError
13
+ from threadsense.observability import DEFAULT_METRICS, MetricsRegistry, TraceContext
14
+ from threadsense.pipeline.storage import read_json, write_json
15
+ from threadsense.workflows import RedditConnectorFactory, run_reddit_pipeline
16
+
17
+ BATCH_MANIFEST_VERSION = 1
18
+
19
+
20
+ @dataclass(frozen=True)
21
+ class BatchJob:
22
+ job_id: str
23
+ source_name: str
24
+ thread_url: str
25
+ expand_more: bool
26
+ flat: bool
27
+ report_format: str
28
+ with_summary: bool
29
+ summary_required: bool
30
+
31
+
32
+ @dataclass(frozen=True)
33
+ class BatchManifest:
34
+ run_name: str
35
+ jobs: list[BatchJob]
36
+ created_at_utc: float
37
+ manifest_version: int
38
+
39
+
40
+ @dataclass(frozen=True)
41
+ class BatchJobResult:
42
+ job_id: str
43
+ source_name: str
44
+ thread_url: str
45
+ status: str
46
+ error: dict[str, Any] | None
47
+ outputs: dict[str, Any] | None
48
+
49
+
50
+ @dataclass(frozen=True)
51
+ class BatchRunResult:
52
+ run_name: str
53
+ manifest_path: str
54
+ started_at_utc: float
55
+ completed_at_utc: float
56
+ total_jobs: int
57
+ succeeded_jobs: int
58
+ failed_jobs: int
59
+ worker_count: int
60
+ jobs: list[BatchJobResult]
61
+ reproducibility: dict[str, Any]
62
+
63
+ def to_dict(self) -> dict[str, Any]:
64
+ return {
65
+ "artifact_kind": "batch_run",
66
+ "schema_version": BATCH_MANIFEST_VERSION,
67
+ "batch_run": asdict(self),
68
+ }
69
+
70
+
71
+ def load_batch_manifest(path: Path) -> BatchManifest:
72
+ payload = read_json(path)
73
+ manifest_version = payload.get("manifest_version")
74
+ if manifest_version != BATCH_MANIFEST_VERSION:
75
+ raise SchemaBoundaryError(
76
+ "batch manifest version is unsupported",
77
+ details={"manifest_version": manifest_version},
78
+ )
79
+ jobs_data = payload.get("jobs")
80
+ if not isinstance(jobs_data, list) or not jobs_data:
81
+ raise SchemaBoundaryError("batch manifest jobs field is invalid")
82
+ jobs = [job_from_dict(item) for item in jobs_data]
83
+ ensure_unique_job_ids(jobs)
84
+ return BatchManifest(
85
+ run_name=required_str(payload, "run_name"),
86
+ jobs=jobs,
87
+ created_at_utc=required_float(payload, "created_at_utc"),
88
+ manifest_version=manifest_version,
89
+ )
90
+
91
+
92
+ def run_batch_manifest(
93
+ *,
94
+ config: AppConfig,
95
+ logger: logging.Logger,
96
+ manifest_path: Path,
97
+ output_path: Path | None,
98
+ connector_factory: RedditConnectorFactory,
99
+ registry: MetricsRegistry = DEFAULT_METRICS,
100
+ ) -> dict[str, Any]:
101
+ manifest = load_batch_manifest(manifest_path)
102
+ validate_manifest_limits(manifest, config.batch.max_jobs)
103
+ worker_count = resolve_worker_count(len(manifest.jobs), config.batch.max_workers)
104
+ started_at_utc = time()
105
+
106
+ with ThreadPoolExecutor(max_workers=worker_count) as executor:
107
+ future_map = {
108
+ executor.submit(
109
+ run_batch_job,
110
+ config=config,
111
+ logger=logger,
112
+ job=job,
113
+ run_name=manifest.run_name,
114
+ connector_factory=connector_factory,
115
+ registry=registry,
116
+ ): job
117
+ for job in manifest.jobs
118
+ }
119
+ done, not_done = wait(
120
+ future_map.keys(),
121
+ return_when=FIRST_EXCEPTION if config.batch.fail_fast else ALL_COMPLETED,
122
+ )
123
+ if config.batch.fail_fast:
124
+ for future in done:
125
+ if future.exception() is not None:
126
+ for pending in not_done:
127
+ pending.cancel()
128
+ break
129
+ job_results = [resolve_job_result(future_map[future], future) for future in future_map]
130
+
131
+ completed_at_utc = time()
132
+ result = BatchRunResult(
133
+ run_name=manifest.run_name,
134
+ manifest_path=str(manifest_path),
135
+ started_at_utc=started_at_utc,
136
+ completed_at_utc=completed_at_utc,
137
+ total_jobs=len(job_results),
138
+ succeeded_jobs=sum(1 for job in job_results if job.status == "ready"),
139
+ failed_jobs=sum(1 for job in job_results if job.status == "error"),
140
+ worker_count=worker_count,
141
+ jobs=sorted(job_results, key=lambda job: job.job_id),
142
+ reproducibility={
143
+ "privacy_mode": config.privacy_mode.value,
144
+ "inference_backend": config.inference_backend.value,
145
+ "runtime_enabled": config.runtime.enabled,
146
+ "runtime_model": config.runtime.model,
147
+ "storage_root": str(config.storage.root_dir),
148
+ "max_workers": config.batch.max_workers,
149
+ "runtime_concurrency": config.limits.runtime_concurrency,
150
+ },
151
+ )
152
+ resolved_output_path = output_path or (
153
+ config.storage.root_dir / config.storage.batch_dirname / f"{manifest.run_name}.json"
154
+ )
155
+ write_json(resolved_output_path, result.to_dict())
156
+ return {
157
+ "status": "ready" if result.failed_jobs == 0 else "degraded",
158
+ "artifact_type": "batch_run",
159
+ "manifest_path": str(manifest_path),
160
+ "output_path": str(resolved_output_path),
161
+ "run_name": manifest.run_name,
162
+ "worker_count": worker_count,
163
+ "total_jobs": result.total_jobs,
164
+ "succeeded_jobs": result.succeeded_jobs,
165
+ "failed_jobs": result.failed_jobs,
166
+ "jobs": [asdict(job_result) for job_result in result.jobs],
167
+ }
168
+
169
+
170
+ def run_batch_job(
171
+ *,
172
+ config: AppConfig,
173
+ logger: logging.Logger,
174
+ job: BatchJob,
175
+ run_name: str,
176
+ connector_factory: RedditConnectorFactory,
177
+ registry: MetricsRegistry,
178
+ ) -> dict[str, Any]:
179
+ if job.source_name != "reddit":
180
+ raise BatchBoundaryError(
181
+ "batch job source is unsupported",
182
+ details={"job_id": job.job_id, "source_name": job.source_name},
183
+ )
184
+ trace = TraceContext.create(
185
+ run_id=run_name,
186
+ source_name=job.source_name,
187
+ job_id=job.job_id,
188
+ )
189
+ return run_reddit_pipeline(
190
+ config=config,
191
+ logger=logger,
192
+ trace=trace,
193
+ url=job.thread_url,
194
+ expand_more=job.expand_more,
195
+ flat=job.flat,
196
+ report_format=job.report_format,
197
+ with_summary=job.with_summary,
198
+ summary_required=job.summary_required,
199
+ connector_factory=connector_factory,
200
+ registry=registry,
201
+ )
202
+
203
+
204
+ def resolve_worker_count(job_count: int, max_workers: int) -> int:
205
+ if job_count <= 0:
206
+ raise ResourceLimitError("batch run must contain at least one job")
207
+ if max_workers <= 0:
208
+ raise ResourceLimitError("batch max_workers must be greater than zero")
209
+ return min(job_count, max_workers)
210
+
211
+
212
+ def validate_manifest_limits(manifest: BatchManifest, max_jobs: int) -> None:
213
+ if len(manifest.jobs) > max_jobs:
214
+ raise ResourceLimitError(
215
+ "batch manifest exceeds configured max_jobs limit",
216
+ details={"job_count": len(manifest.jobs), "max_jobs": max_jobs},
217
+ )
218
+
219
+
220
+ def resolve_job_result(job: BatchJob, future: Future[dict[str, Any]]) -> BatchJobResult:
221
+ try:
222
+ outputs = future.result()
223
+ except Exception as error:
224
+ return BatchJobResult(
225
+ job_id=job.job_id,
226
+ source_name=job.source_name,
227
+ thread_url=job.thread_url,
228
+ status="error",
229
+ error=job_error_payload(error),
230
+ outputs=None,
231
+ )
232
+ return BatchJobResult(
233
+ job_id=job.job_id,
234
+ source_name=job.source_name,
235
+ thread_url=job.thread_url,
236
+ status="ready",
237
+ error=None,
238
+ outputs=outputs,
239
+ )
240
+
241
+
242
+ def job_error_payload(error: Exception) -> dict[str, Any]:
243
+ if isinstance(error, (BatchBoundaryError, ResourceLimitError, SchemaBoundaryError)):
244
+ return error.to_dict()
245
+ to_dict = getattr(error, "to_dict", None)
246
+ if callable(to_dict):
247
+ payload = to_dict()
248
+ if isinstance(payload, dict):
249
+ return payload
250
+ return {
251
+ "type": error.__class__.__name__,
252
+ "code": "unhandled_error",
253
+ "message": str(error),
254
+ "details": {},
255
+ }
256
+
257
+
258
+ def job_from_dict(payload: Mapping[str, Any]) -> BatchJob:
259
+ return BatchJob(
260
+ job_id=required_str(payload, "job_id"),
261
+ source_name=required_str(payload, "source_name"),
262
+ thread_url=required_str(payload, "thread_url"),
263
+ expand_more=required_bool(payload, "expand_more"),
264
+ flat=required_bool(payload, "flat"),
265
+ report_format=required_str(payload, "report_format"),
266
+ with_summary=required_bool(payload, "with_summary"),
267
+ summary_required=required_bool(payload, "summary_required"),
268
+ )
269
+
270
+
271
+ def ensure_unique_job_ids(jobs: list[BatchJob]) -> None:
272
+ job_ids = [job.job_id for job in jobs]
273
+ if len(set(job_ids)) != len(job_ids):
274
+ raise SchemaBoundaryError("batch manifest contains duplicate job_id values")
275
+
276
+
277
+ def required_str(payload: Mapping[str, Any], key: str) -> str:
278
+ value = payload.get(key)
279
+ if not isinstance(value, str) or not value:
280
+ raise SchemaBoundaryError(
281
+ "batch manifest string field is invalid",
282
+ details={"key": key},
283
+ )
284
+ return value
285
+
286
+
287
+ def required_float(payload: Mapping[str, Any], key: str) -> float:
288
+ value = payload.get(key)
289
+ if isinstance(value, int):
290
+ return float(value)
291
+ if not isinstance(value, float):
292
+ raise SchemaBoundaryError(
293
+ "batch manifest float field is invalid",
294
+ details={"key": key},
295
+ )
296
+ return value
297
+
298
+
299
+ def required_bool(payload: Mapping[str, Any], key: str) -> bool:
300
+ value = payload.get(key)
301
+ if not isinstance(value, bool):
302
+ raise SchemaBoundaryError(
303
+ "batch manifest boolean field is invalid",
304
+ details={"key": key},
305
+ )
306
+ return value