Coverage for src/lexigram/admin/services/import_/service.py: 0%

163 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-08-24 23:18 +0800

1"""Bulk import service for lexigram-admin. 

2 

3Supports CSV and JSON file uploads with: 

4- Column mapping from file headers to resource fields 

5- Row-level validation before commit (preview mode) 

6- Structured error reporting per row 

7- Streaming parse to keep memory usage bounded 

8 

9Usage:: 

10 

11 service = AdminImportService(data_source=ds) 

12 job = await service.parse(raw_bytes, filename="users.csv") 

13 if job.has_errors: 

14 return job # show preview with errors 

15 result = await service.commit(job) 

16""" 

17 

18from __future__ import annotations 

19 

20import csv 

21from dataclasses import dataclass, field 

22import io 

23from typing import Any 

24 

25from lexigram import serialization as json 

26from lexigram.admin.exceptions import AdminError 

27from lexigram.di.decorators import inject 

28from lexigram.logging import get_logger 

29from lexigram.result import Err, Ok, Result 

30 

31logger = get_logger(__name__) 

32 

33 

34# --------------------------------------------------------------------------- 

35# Data structures 

36# --------------------------------------------------------------------------- 

37 

38 

39@dataclass 

40class ImportRowError: 

41 """Validation error for a single import row. 

42 

43 Attributes: 

44 row: 1-indexed row number within the file. 

45 field: Field name that failed validation, or ``"__row__"`` for row-level errors. 

46 message: Human-readable error description. 

47 """ 

48 

49 row: int 

50 field: str 

51 message: str 

52 

53 

54@dataclass 

55class ImportJob: 

56 """Parsed import batch ready for validation or commit. 

57 

58 Attributes: 

59 rows: Parsed rows as dicts keyed by mapped field names. 

60 errors: Per-row validation errors collected during :meth:`AdminImportService.parse`. 

61 column_map: Mapping from source file header → target resource field name. 

62 source_filename: Original uploaded filename. 

63 total_rows: Total number of data rows (excludes header). 

64 """ 

65 

66 rows: list[dict[str, Any]] 

67 errors: list[ImportRowError] 

68 column_map: dict[str, str] 

69 source_filename: str 

70 total_rows: int 

71 

72 @property 

73 def has_errors(self) -> bool: 

74 """True when at least one validation error was found during parse.""" 

75 return bool(self.errors) 

76 

77 @property 

78 def valid_rows(self) -> list[dict[str, Any]]: 

79 """Rows that had no validation errors.""" 

80 error_rows = {e.row for e in self.errors} 

81 return [r for i, r in enumerate(self.rows, start=1) if i not in error_rows] 

82 

83 

84@dataclass 

85class ImportResult: 

86 """Summary returned after a committed import. 

87 

88 Attributes: 

89 created: Number of records successfully inserted. 

90 failed: Number of records that failed during insert. 

91 errors: Errors encountered during commit (row-level). 

92 """ 

93 

94 created: int 

95 failed: int 

96 errors: list[ImportRowError] = field(default_factory=list) 

97 

98 @property 

99 def total(self) -> int: 

100 """Total records attempted.""" 

101 return self.created + self.failed 

102 

103 

104@dataclass 

105class ImportReport: 

106 """Stored failed-import report for post-hoc download. 

107 

108 Persisted by :class:`AdminImportService` whenever a commit has 

109 failed rows, so callers can surface a downloadable error report 

110 (mirrors Filament's "Reviewing failed rows" flow). 

111 

112 Attributes: 

113 id: Unique report identifier. 

114 source_filename: Original uploaded filename. 

115 created_at: ISO-8601 timestamp of report creation. 

116 total_rows: Number of data rows attempted. 

117 failed_rows: Number of rows that failed. 

118 failures: Per-row validation/commit errors. 

119 """ 

120 

121 id: str 

122 source_filename: str 

123 created_at: str 

124 total_rows: int 

125 failed_rows: int 

126 failures: list[ImportRowError] 

127 

128 def to_csv(self) -> str: 

129 """Serialize failed rows as CSV (row number, field, message).""" 

130 buffer = io.StringIO() 

131 writer = csv.writer(buffer, lineterminator="\n") 

132 writer.writerow(["row", "field", "message"]) 

133 for err in self.failures: 

134 writer.writerow([err.row, err.field, err.message]) 

135 return buffer.getvalue() 

136 

137 

138# --------------------------------------------------------------------------- 

139# Parsers 

140# --------------------------------------------------------------------------- 

141 

142 

143def _parse_csv( 

144 content: bytes, 

145 *, 

146 column_map: dict[str, str] | None = None, 

147) -> tuple[list[dict[str, Any]], dict[str, str], list[ImportRowError]]: 

148 """Parse CSV bytes into rows and infer column_map if not provided. 

149 

150 Args: 

151 content: Raw file bytes. 

152 column_map: Optional explicit header → field mapping. 

153 If None, headers are used as-is. 

154 

155 Returns: 

156 Tuple of (rows, effective_column_map, parse_errors). 

157 """ 

158 text = content.decode("utf-8-sig", errors="replace") 

159 reader = csv.DictReader(io.StringIO(text)) 

160 headers: list[str] = list(reader.fieldnames) if reader.fieldnames else [] 

161 

162 effective_map: dict[str, str] = dict(column_map or {h: h for h in headers}) 

163 

164 rows: list[dict[str, Any]] = [] 

165 errors: list[ImportRowError] = [] 

166 

167 for _row_num, raw_row in enumerate(reader, start=1): 

168 mapped: dict[str, Any] = {} 

169 for src, dst in effective_map.items(): 

170 val = raw_row.get(src, "").strip() 

171 mapped[dst] = val or None 

172 rows.append(mapped) 

173 

174 return rows, effective_map, errors 

175 

176 

177def _parse_json( 

178 content: bytes, 

179 *, 

180 column_map: dict[str, str] | None = None, 

181) -> tuple[list[dict[str, Any]], dict[str, str], list[ImportRowError]]: 

182 """Parse JSON bytes (array of objects) into rows. 

183 

184 Args: 

185 content: Raw file bytes containing a JSON array. 

186 column_map: Optional key remapping (source_key → target_field). 

187 

188 Returns: 

189 Tuple of (rows, effective_column_map, parse_errors). 

190 """ 

191 errors: list[ImportRowError] = [] 

192 try: 

193 data = json.loads(content.decode("utf-8-sig", errors="replace")) 

194 except json.JSONDecodeError as exc: 

195 errors.append( 

196 ImportRowError(row=0, field="__file__", message=f"Invalid JSON: {exc}") 

197 ) 

198 return [], {}, errors 

199 

200 if not isinstance(data, list): 

201 errors.append( 

202 ImportRowError( 

203 row=0, field="__file__", message="JSON root must be an array of objects" 

204 ) 

205 ) 

206 return [], {}, errors 

207 

208 effective_map: dict[str, str] = column_map or {} 

209 rows: list[dict[str, Any]] = [] 

210 

211 for row_num, item in enumerate(data, start=1): 

212 if not isinstance(item, dict): 

213 errors.append( 

214 ImportRowError( 

215 row=row_num, field="__row__", message="Expected a JSON object" 

216 ) 

217 ) 

218 continue 

219 if effective_map: 

220 mapped: dict[str, Any] = { 

221 dst: item.get(src) for src, dst in effective_map.items() 

222 } 

223 else: 

224 mapped = dict(item) 

225 rows.append(mapped) 

226 

227 return rows, effective_map, errors 

228 

229 

230# --------------------------------------------------------------------------- 

231# Service 

232# --------------------------------------------------------------------------- 

233 

234 

235@inject 

236class AdminImportService: 

237 """Service that parses, validates, and commits bulk imports. 

238 

239 Supports CSV and JSON files. Validation is performed during 

240 :meth:`parse` so callers can show a preview before committing. 

241 

242 Args: 

243 data_source: An IDataSource-compatible instance that the 

244 committed rows will be written to via ``create()``. 

245 required_fields: Field names that must be non-empty on every row. 

246 max_rows: Maximum number of rows allowed per import (0 = unlimited). 

247 """ 

248 

249 def __init__( 

250 self, 

251 data_source: Any, 

252 *, 

253 required_fields: list[str] | None = None, 

254 max_rows: int = 10_000, 

255 allowed_fields: set[str] | None = None, 

256 ) -> None: 

257 self._data_source = data_source 

258 self._required_fields: list[str] = required_fields or [] 

259 self._max_rows = max_rows 

260 # Mass-assignment guard: when set, rows carrying keys outside this 

261 # allowlist are rejected during validation. 

262 self._allowed_fields = allowed_fields 

263 self._reports: list[ImportReport] = [] 

264 

265 # ------------------------------------------------------------------ 

266 # Failed-import reports 

267 # ------------------------------------------------------------------ 

268 

269 def reports(self) -> list[ImportReport]: 

270 """Return all stored failed-import reports (most recent last). 

271 

272 Returns: 

273 Copy of the in-memory report list; empty when no imports 

274 have failed. 

275 """ 

276 return list(self._reports) 

277 

278 def get_report(self, report_id: str) -> ImportReport | None: 

279 """Look up a stored failed-import report by id. 

280 

281 Args: 

282 report_id: Report identifier from :meth:`reports`. 

283 

284 Returns: 

285 The matching report, or None when unknown. 

286 """ 

287 for report in self._reports: 

288 if report.id == report_id: 

289 return report 

290 return None 

291 

292 def delete_report(self, report_id: str) -> bool: 

293 """Remove a stored failed-import report. 

294 

295 Args: 

296 report_id: Report identifier from :meth:`reports`. 

297 

298 Returns: 

299 True when the report was removed, False when unknown. 

300 """ 

301 for index, report in enumerate(self._reports): 

302 if report.id == report_id: 

303 del self._reports[index] 

304 return True 

305 return False 

306 

307 def _store_report(self, job: ImportJob, result: ImportResult) -> None: 

308 """Persist a failed-import report for the given commit result.""" 

309 from lexigram.identity import ambient as identity 

310 from lexigram.primitives import clock 

311 

312 self._reports.append( 

313 ImportReport( 

314 id=identity.new_uuid(), 

315 source_filename=job.source_filename, 

316 created_at=clock.now().isoformat(), 

317 total_rows=len(job.rows), 

318 failed_rows=result.failed, 

319 failures=[*job.errors, *result.errors], 

320 ) 

321 ) 

322 

323 # ------------------------------------------------------------------ 

324 # Public API 

325 # ------------------------------------------------------------------ 

326 

327 async def parse( 

328 self, 

329 content: bytes, 

330 filename: str, 

331 *, 

332 column_map: dict[str, str] | None = None, 

333 ) -> Result[ImportJob, AdminError]: 

334 """Parse uploaded file bytes and return a validated ImportJob. 

335 

336 Validation is non-destructive — nothing is written to the data source. 

337 

338 Args: 

339 content: Raw file bytes. 

340 filename: Original filename; used to detect format (csv / json). 

341 column_map: Optional explicit header-to-field mapping. 

342 CSV: ``{"CSV Header": "model_field"}`` 

343 JSON: ``{"json_key": "model_field"}`` 

344 Defaults to identity mapping (header == field). 

345 

346 Returns: 

347 ``Result[ImportJob, AdminError]`` — Ok even when rows have 

348 validation errors (so callers can show the preview). 

349 Err only on file-level failures (wrong format, size limit). 

350 """ 

351 lower = filename.lower() 

352 if lower.endswith(".csv"): 

353 rows, effective_map, parse_errors = _parse_csv( 

354 content, column_map=column_map 

355 ) 

356 elif lower.endswith((".json", ".jsonl")): 

357 rows, effective_map, parse_errors = _parse_json( 

358 content, column_map=column_map 

359 ) 

360 else: 

361 return Err( 

362 AdminError( 

363 message=f"Unsupported file format: {filename!r}. Use .csv or .json." 

364 ) 

365 ) 

366 

367 if parse_errors and not rows: 

368 return Err(AdminError(message=parse_errors[0].message)) 

369 

370 if self._max_rows and len(rows) > self._max_rows: 

371 return Err( 

372 AdminError( 

373 message=f"File contains {len(rows):,} rows which exceeds the limit of {self._max_rows:,}." 

374 ) 

375 ) 

376 

377 # Row-level validation 

378 validation_errors = list(parse_errors) 

379 validation_errors.extend(self._validate_rows(rows)) 

380 

381 job = ImportJob( 

382 rows=rows, 

383 errors=validation_errors, 

384 column_map=effective_map, 

385 source_filename=filename, 

386 total_rows=len(rows), 

387 ) 

388 logger.info( 

389 "Import parsed: filename=%s rows=%d errors=%d", 

390 filename, 

391 len(rows), 

392 len(validation_errors), 

393 ) 

394 return Ok(job) 

395 

396 async def commit(self, job: ImportJob) -> Result[ImportResult, AdminError]: 

397 """Write valid rows from *job* to the data source. 

398 

399 Rows with errors are skipped. Remaining rows are inserted one at a 

400 time; individual insert failures are collected and do not abort the 

401 rest of the batch. 

402 

403 Args: 

404 job: A parsed ImportJob (from :meth:`parse`). 

405 

406 Returns: 

407 ``Result[ImportResult, AdminError]`` — Ok with counts and any 

408 per-row commit errors. 

409 """ 

410 created = 0 

411 failed = 0 

412 commit_errors: list[ImportRowError] = [] 

413 error_row_set = {e.row for e in job.errors} 

414 

415 for row_num, row in enumerate(job.rows, start=1): 

416 if row_num in error_row_set: 

417 failed += 1 

418 continue 

419 try: 

420 await self._data_source.create(row) 

421 created += 1 

422 except (ValueError, TypeError, KeyError, RuntimeError) as exc: 

423 failed += 1 

424 commit_errors.append( 

425 ImportRowError(row=row_num, field="__row__", message=str(exc)) 

426 ) 

427 logger.warning("Import commit error at row %d: %s", row_num, exc) 

428 

429 result = ImportResult(created=created, failed=failed, errors=commit_errors) 

430 if result.failed: 

431 self._store_report(job, result) 

432 logger.info( 

433 "Import committed: filename=%s created=%d failed=%d", 

434 job.source_filename, 

435 created, 

436 failed, 

437 ) 

438 return Ok(result) 

439 

440 # ------------------------------------------------------------------ 

441 # Internal helpers 

442 # ------------------------------------------------------------------ 

443 

444 def _validate_rows(self, rows: list[dict[str, Any]]) -> list[ImportRowError]: 

445 """Run required-field validation over all rows. 

446 

447 Args: 

448 rows: Parsed rows to validate. 

449 

450 Returns: 

451 List of ImportRowError for any violations found. 

452 """ 

453 errors: list[ImportRowError] = [] 

454 for row_num, row in enumerate(rows, start=1): 

455 if self._allowed_fields is not None: 

456 for key in row: 

457 if key not in self._allowed_fields: 

458 errors.append( 

459 ImportRowError( 

460 row=row_num, 

461 field=key, 

462 message=f"Unknown field '{key}' (not importable)", 

463 ) 

464 ) 

465 for field_name in self._required_fields: 

466 val = row.get(field_name) 

467 if val is None or (isinstance(val, str) and not val.strip()): 

468 errors.append( 

469 ImportRowError( 

470 row=row_num, 

471 field=field_name, 

472 message=f"'{field_name}' is required", 

473 ) 

474 ) 

475 return errors 

476 

477 

478__all__ = [ 

479 "AdminImportService", 

480 "ImportJob", 

481 "ImportResult", 

482 "ImportRowError", 

483]