datasentry-ai 0.1.0__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.
datasentry/__init__.py ADDED
@@ -0,0 +1,7 @@
1
+ """DataSentry AI — SDK 客户端(23.1)与版本。"""
2
+
3
+ from datasentry.client import DataSentry
4
+
5
+ __version__ = "0.1.0"
6
+
7
+ __all__ = ["DataSentry", "__version__"]
datasentry/api.py ADDED
@@ -0,0 +1,379 @@
1
+ """DataSentry REST API(Step 23,MVP REST 面,22/23 章 HTTP 映射)。
2
+
3
+ 无状态不适用:本 API 是「单工作区门面」——`create_app(project=...)` 绑定一个
4
+ `DataSentry` 实例,所有端点复用同一条导入→扫描→落库→修复闭环(与 CLI/SDK
5
+ 同源)。MVP 只提供同步端点(FastAPI async 并发已覆盖多数用法),异步 Job
6
+ 队列归 V1(ADR-023)。
7
+
8
+ 端点一览:
9
+ GET /health 存活探针
10
+ GET / 端点清单
11
+ POST /scans 扫描文件 → 201 ScanResponse
12
+ GET /scans ScanRun id 列表
13
+ GET /scans/{run_id} ScanRun 详情
14
+ GET /scans/{run_id}/issues Issue 列表
15
+ GET /scans/{run_id}/report 26 章规范 JSON 报告
16
+ GET /scans/{run_id}/score 27 章质量总分
17
+ GET /issues 跨扫描 Issue(severity 过滤)
18
+ POST /scans/{run_id}/repairs/propose 修复提案
19
+ POST /scans/{run_id}/repairs/preview 提案+预览
20
+ POST /scans/{run_id}/repairs/apply 应用修复
21
+ POST /repairs/{run_id}/rollback 回滚
22
+ GET /repairs 修复运行列表
23
+
24
+ 错误映射:FileNotFoundError/KeyError→404、ValueError→400、其余→500,
25
+ body 统一 {"ok": false, "detail": "..."}。
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import os
31
+ from pathlib import Path
32
+
33
+ from fastapi import FastAPI, Form, HTTPException, Query
34
+ from fastapi.responses import HTMLResponse, RedirectResponse, Response
35
+ from pydantic import BaseModel, Field
36
+
37
+ from datasentry import __version__, ui
38
+ from datasentry import client as sdk
39
+ from datasentry_core.models.issue import Issue
40
+ from datasentry_core.models.repair import RepairPreview, RepairProposal, RepairRun
41
+ from datasentry_core.models.scan import DetectorRun, ScanConfig, ScanRun
42
+
43
+
44
+ class ScanRequest(BaseModel):
45
+ """POST /scans 请求体:源文件路径(workspace 相对或绝对)+ 扫描配置。"""
46
+
47
+ path: str
48
+ dataset_id: str | None = None
49
+ detectors: list[str] | None = None
50
+ seed: int = 42
51
+ tags: dict[str, str] = Field(default_factory=dict)
52
+
53
+
54
+ class ScanResponse(BaseModel):
55
+ run: ScanRun
56
+ detector_runs: list[DetectorRun]
57
+ issues: list[Issue]
58
+
59
+
60
+ class ProposeRequest(BaseModel):
61
+ source_path: str
62
+ issue_id: str
63
+
64
+
65
+ class PreviewRequest(BaseModel):
66
+ source_path: str
67
+ issue_id: str
68
+
69
+
70
+ class ApplyRequest(BaseModel):
71
+ source_path: str
72
+ issue_id: str
73
+
74
+
75
+ class HealthResponse(BaseModel):
76
+ ok: bool
77
+ service: str
78
+ version: str
79
+ workspace: str
80
+
81
+
82
+ class ErrorBody(BaseModel):
83
+ ok: bool = False
84
+ detail: str
85
+
86
+
87
+ def _config_from(req: ScanRequest) -> ScanConfig:
88
+ return ScanConfig(
89
+ detectors=req.detectors,
90
+ seed=req.seed,
91
+ scan_tags=req.tags,
92
+ )
93
+
94
+
95
+ def _error(exc: Exception) -> int:
96
+ if isinstance(exc, (FileNotFoundError, KeyError)):
97
+ return 404
98
+ if isinstance(exc, ValueError):
99
+ return 422
100
+ return 500
101
+
102
+
103
+ def _handle(exc: Exception) -> HTTPException:
104
+ return HTTPException(status_code=_error(exc), detail=str(exc))
105
+
106
+
107
+ def _get_issue(client: sdk.DataSentry, issue_id: str) -> Issue | None:
108
+ return client.get_issue(issue_id)
109
+
110
+
111
+ # ---------------------------------------------------------------------------
112
+ # 应用工厂
113
+ # ---------------------------------------------------------------------------
114
+
115
+
116
+ def create_app(project: str | Path | None = None) -> FastAPI:
117
+ """创建绑定给定工作区的应用(默认当前目录或 DATASENTRY_PROJECT)。"""
118
+ if project is None:
119
+ project = os.environ.get("DATASENTRY_PROJECT")
120
+ app = FastAPI(title="DataSentry API", version=__version__)
121
+ client = sdk.DataSentry(project=project)
122
+ app.state.client = client
123
+
124
+ @app.get("/health", response_model=HealthResponse, tags=["meta"])
125
+ def health() -> HealthResponse:
126
+ return HealthResponse(
127
+ ok=True,
128
+ service="datasentry",
129
+ version=__version__,
130
+ workspace=str(client.workspace),
131
+ )
132
+
133
+ @app.get("/", tags=["meta"])
134
+ def root() -> dict[str, object]:
135
+ return {
136
+ "service": "datasentry",
137
+ "version": __version__,
138
+ "endpoints": list(_ENDPOINTS),
139
+ }
140
+
141
+ @app.get("/scans", tags=["scans"])
142
+ def list_scan_ids() -> list[str]:
143
+ return [scan.id for scan in client.list_scan_runs()]
144
+
145
+ @app.post("/scans", response_model=ScanResponse, tags=["scans"], status_code=201)
146
+ def create_scan(req: ScanRequest) -> ScanResponse:
147
+ try:
148
+ scan, runs, issues = client.scan_file(
149
+ req.path,
150
+ dataset_id=req.dataset_id,
151
+ config=_config_from(req),
152
+ )
153
+ except Exception as exc:
154
+ raise _handle(exc) from exc
155
+ return ScanResponse(run=scan, detector_runs=runs, issues=issues)
156
+
157
+ @app.get("/scans/{run_id}", response_model=ScanRun, tags=["scans"])
158
+ def get_scan(run_id: str) -> ScanRun:
159
+ try:
160
+ scan = client.get_scan(run_id)
161
+ except Exception as exc:
162
+ raise _handle(exc) from exc
163
+ if scan is None:
164
+ raise HTTPException(status_code=404, detail=f"scan run not found: {run_id}")
165
+ return scan
166
+
167
+ @app.get("/scans/{run_id}/issues", response_model=list[Issue], tags=["scans"])
168
+ def get_scan_issues(run_id: str) -> list[Issue]:
169
+ return client.list_issues(scan_run_id=run_id)
170
+
171
+ @app.get("/scans/{run_id}/report", tags=["scans"])
172
+ def get_report(run_id: str) -> dict[str, object]:
173
+ try:
174
+ return client.export_report(run_id)
175
+ except Exception as exc:
176
+ raise _handle(exc) from exc
177
+
178
+ @app.get("/scans/{run_id}/score", tags=["scans"])
179
+ def get_score(run_id: str) -> dict[str, object]:
180
+ try:
181
+ score = client.quality_score(run_id)
182
+ except Exception as exc:
183
+ raise _handle(exc) from exc
184
+ if score is None:
185
+ raise HTTPException(status_code=404, detail="quality score unavailable")
186
+ return score.model_dump()
187
+
188
+ @app.get("/issues", response_model=list[Issue], tags=["issues"])
189
+ def list_all_issues(
190
+ severity_at_least: str | None = Query(default=None),
191
+ ) -> list[Issue]:
192
+ return client.list_issues(severity_at_least=severity_at_least)
193
+
194
+ # ---- 修复端点(15 章 / ADR-020;source_path 为待修复源文件) -------
195
+
196
+ @app.post(
197
+ "/scans/{run_id}/repairs/propose",
198
+ response_model=RepairProposal | None,
199
+ tags=["repairs"],
200
+ )
201
+ def repair_propose(run_id: str, req: ProposeRequest) -> RepairProposal | None:
202
+ try:
203
+ return client.repair_propose(req.issue_id, req.source_path)
204
+ except Exception as exc:
205
+ raise _handle(exc) from exc
206
+
207
+ @app.post(
208
+ "/scans/{run_id}/repairs/preview",
209
+ tags=["repairs"],
210
+ )
211
+ def repair_preview(run_id: str, req: PreviewRequest) -> dict[str, object] | None:
212
+ try:
213
+ result = client.repair_preview(req.issue_id, req.source_path)
214
+ except Exception as exc:
215
+ raise _handle(exc) from exc
216
+ if result is None:
217
+ return None
218
+ proposal, preview = result
219
+ return {
220
+ "proposal": proposal.model_dump(),
221
+ "preview": preview.model_dump(),
222
+ }
223
+
224
+ @app.post(
225
+ "/scans/{run_id}/repairs/apply",
226
+ response_model=RepairRun,
227
+ tags=["repairs"],
228
+ )
229
+ def repair_apply(run_id: str, req: ApplyRequest) -> RepairRun:
230
+ try:
231
+ return client.repair_apply(req.issue_id, req.source_path)
232
+ except Exception as exc:
233
+ raise _handle(exc) from exc
234
+
235
+ @app.post(
236
+ "/repairs/{run_id}/rollback",
237
+ response_model=RepairRun,
238
+ tags=["repairs"],
239
+ )
240
+ def repair_rollback(run_id: str) -> RepairRun:
241
+ try:
242
+ return client.repair_rollback(run_id)
243
+ except Exception as exc:
244
+ raise _handle(exc) from exc
245
+
246
+ @app.get("/repairs", response_model=list[RepairRun], tags=["repairs"])
247
+ def list_repair_runs() -> list[RepairRun]:
248
+ return client.list_repair_runs()
249
+
250
+ # ---- Web UI(Step 24:服务端渲染核心页) ----------------------------
251
+
252
+ @app.get("/ui", response_class=HTMLResponse, tags=["ui"])
253
+ @app.get("/ui/", response_class=HTMLResponse, tags=["ui"])
254
+ def ui_home() -> HTMLResponse:
255
+ return HTMLResponse(ui.render_home(client.list_scan_runs()))
256
+
257
+ @app.post("/ui/scans", response_class=HTMLResponse, tags=["ui"])
258
+ def ui_create_scan(path: str = Form()) -> Response:
259
+ try:
260
+ scan, _runs, _issues = client.scan_file(path)
261
+ except Exception as exc:
262
+ return HTMLResponse(ui.render_error("Scan failed", str(exc)), status_code=404)
263
+ return RedirectResponse(url=f"/ui/scans/{scan.id}", status_code=303)
264
+
265
+ @app.get("/ui/scans", response_class=HTMLResponse, tags=["ui"])
266
+ def ui_scans_list() -> HTMLResponse:
267
+ return HTMLResponse(ui.render_home(client.list_scan_runs()))
268
+
269
+ @app.get("/ui/trends", response_class=HTMLResponse, tags=["ui"])
270
+ def ui_trends() -> HTMLResponse:
271
+ from datasentry.trends import build_trends
272
+
273
+ return HTMLResponse(ui.render_trends(build_trends(client.list_scan_runs())))
274
+
275
+ @app.get("/ui/scans/{run_id}", response_class=HTMLResponse, tags=["ui"])
276
+ def ui_scan_detail(run_id: str, severity: str | None = Query(default=None)) -> HTMLResponse:
277
+ scan = client.get_scan(run_id)
278
+ if scan is None:
279
+ return HTMLResponse(
280
+ ui.render_error("Scan not found", f"scan run: {run_id}"), status_code=404
281
+ )
282
+ issues = client.list_issues(scan_run_id=run_id, severity_at_least=severity)
283
+ return HTMLResponse(ui.render_scan_detail(scan, issues, severity_filter=severity))
284
+
285
+ @app.get(
286
+ "/ui/scans/{run_id}/issues/{issue_id}",
287
+ response_class=HTMLResponse,
288
+ tags=["ui"],
289
+ )
290
+ def ui_workbench(run_id: str, issue_id: str) -> HTMLResponse:
291
+ issue = _get_issue(client, issue_id)
292
+ if issue is None:
293
+ return HTMLResponse(ui.render_error("Issue not found", issue_id), status_code=404)
294
+ return HTMLResponse(ui.render_workbench(issue, run_id=run_id))
295
+
296
+ @app.post(
297
+ "/ui/scans/{run_id}/issues/{issue_id}",
298
+ response_class=HTMLResponse,
299
+ tags=["ui"],
300
+ )
301
+ def ui_workbench_action(
302
+ run_id: str,
303
+ issue_id: str,
304
+ source_path: str = Form(),
305
+ action: str = Form(),
306
+ ) -> HTMLResponse:
307
+ issue = _get_issue(client, issue_id)
308
+ if issue is None:
309
+ return HTMLResponse(ui.render_error("Issue not found", issue_id), status_code=404)
310
+ error: str | None = None
311
+ proposal: RepairProposal | None = None
312
+ preview: RepairPreview | None = None
313
+ run: RepairRun | None = None
314
+ try:
315
+ if action == "propose":
316
+ proposal = client.repair_propose(issue_id, source_path)
317
+ if proposal is not None:
318
+ pair = client.repair_preview(issue_id, source_path)
319
+ if pair is not None:
320
+ preview = pair[1]
321
+ else:
322
+ error = "no repair proposal available for this issue"
323
+ elif action == "apply":
324
+ run = client.repair_apply(issue_id, source_path)
325
+ else:
326
+ error = f"unknown action: {action}"
327
+ except Exception as exc:
328
+ error = str(exc)
329
+ return HTMLResponse(
330
+ ui.render_workbench(
331
+ issue,
332
+ run_id=run_id,
333
+ source_path=source_path,
334
+ proposal=proposal,
335
+ preview=preview,
336
+ run=run,
337
+ error=error,
338
+ )
339
+ )
340
+
341
+ @app.post(
342
+ "/ui/scans/{run_id}/repairs/{repair_run_id}/rollback",
343
+ response_class=HTMLResponse,
344
+ tags=["ui"],
345
+ )
346
+ def ui_rollback(run_id: str, repair_run_id: str) -> Response:
347
+ try:
348
+ client.repair_rollback(repair_run_id)
349
+ except Exception as exc:
350
+ return HTMLResponse(ui.render_error("Rollback failed", str(exc)), status_code=404)
351
+ return RedirectResponse(url=f"/ui/scans/{run_id}", status_code=303)
352
+
353
+ return app
354
+
355
+
356
+ _ENDPOINTS = frozenset(
357
+ {
358
+ "GET /health",
359
+ "GET /scans",
360
+ "POST /scans",
361
+ "GET /scans/{run_id}",
362
+ "GET /scans/{run_id}/issues",
363
+ "GET /scans/{run_id}/report",
364
+ "GET /scans/{run_id}/score",
365
+ "GET /issues",
366
+ "POST /scans/{run_id}/repairs/propose",
367
+ "POST /scans/{run_id}/repairs/preview",
368
+ "POST /scans/{run_id}/repairs/apply",
369
+ "POST /repairs/{run_id}/rollback",
370
+ "GET /repairs",
371
+ }
372
+ )
373
+
374
+
375
+ def main() -> None:
376
+ """启动 API 服务(容器/开发入口,默认 0.0.0.0:8000)。"""
377
+ import uvicorn
378
+
379
+ uvicorn.run(create_app(), host="0.0.0.0", port=8000)