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

123 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-08-11 02:25 +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# --------------------------------------------------------------------------- 

105# Parsers 

106# --------------------------------------------------------------------------- 

107 

108 

109def _parse_csv( 

110 content: bytes, 

111 *, 

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

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

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

115 

116 Args: 

117 content: Raw file bytes. 

118 column_map: Optional explicit header → field mapping. 

119 If None, headers are used as-is. 

120 

121 Returns: 

122 Tuple of (rows, effective_column_map, parse_errors). 

123 """ 

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

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

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

127 

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

129 

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

131 errors: list[ImportRowError] = [] 

132 

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

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

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

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

137 mapped[dst] = val or None 

138 rows.append(mapped) 

139 

140 return rows, effective_map, errors 

141 

142 

143def _parse_json( 

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 JSON bytes (array of objects) into rows. 

149 

150 Args: 

151 content: Raw file bytes containing a JSON array. 

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

153 

154 Returns: 

155 Tuple of (rows, effective_column_map, parse_errors). 

156 """ 

157 errors: list[ImportRowError] = [] 

158 try: 

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

160 except json.JSONDecodeError as exc: 

161 errors.append( 

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

163 ) 

164 return [], {}, errors 

165 

166 if not isinstance(data, list): 

167 errors.append( 

168 ImportRowError( 

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

170 ) 

171 ) 

172 return [], {}, errors 

173 

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

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

176 

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

178 if not isinstance(item, dict): 

179 errors.append( 

180 ImportRowError( 

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

182 ) 

183 ) 

184 continue 

185 if effective_map: 

186 mapped: dict[str, Any] = { 

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

188 } 

189 else: 

190 mapped = dict(item) 

191 rows.append(mapped) 

192 

193 return rows, effective_map, errors 

194 

195 

196# --------------------------------------------------------------------------- 

197# Service 

198# --------------------------------------------------------------------------- 

199 

200 

201@inject 

202class AdminImportService: 

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

204 

205 Supports CSV and JSON files. Validation is performed during 

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

207 

208 Args: 

209 data_source: An IDataSource-compatible instance that the 

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

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

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

213 """ 

214 

215 def __init__( 

216 self, 

217 data_source: Any, 

218 *, 

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

220 max_rows: int = 10_000, 

221 ) -> None: 

222 self._data_source = data_source 

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

224 self._max_rows = max_rows 

225 

226 # ------------------------------------------------------------------ 

227 # Public API 

228 # ------------------------------------------------------------------ 

229 

230 async def parse( 

231 self, 

232 content: bytes, 

233 filename: str, 

234 *, 

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

236 ) -> Result[ImportJob, AdminError]: 

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

238 

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

240 

241 Args: 

242 content: Raw file bytes. 

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

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

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

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

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

248 

249 Returns: 

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

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

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

253 """ 

254 lower = filename.lower() 

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

256 rows, effective_map, parse_errors = _parse_csv( 

257 content, column_map=column_map 

258 ) 

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

260 rows, effective_map, parse_errors = _parse_json( 

261 content, column_map=column_map 

262 ) 

263 else: 

264 return Err( 

265 AdminError( 

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

267 ) 

268 ) 

269 

270 if parse_errors and not rows: 

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

272 

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

274 return Err( 

275 AdminError( 

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

277 ) 

278 ) 

279 

280 # Row-level validation 

281 validation_errors = list(parse_errors) 

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

283 

284 job = ImportJob( 

285 rows=rows, 

286 errors=validation_errors, 

287 column_map=effective_map, 

288 source_filename=filename, 

289 total_rows=len(rows), 

290 ) 

291 logger.info( 

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

293 filename, 

294 len(rows), 

295 len(validation_errors), 

296 ) 

297 return Ok(job) 

298 

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

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

301 

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

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

304 rest of the batch. 

305 

306 Args: 

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

308 

309 Returns: 

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

311 per-row commit errors. 

312 """ 

313 created = 0 

314 failed = 0 

315 commit_errors: list[ImportRowError] = [] 

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

317 

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

319 if row_num in error_row_set: 

320 failed += 1 

321 continue 

322 try: 

323 await self._data_source.create(row) 

324 created += 1 

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

326 failed += 1 

327 commit_errors.append( 

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

329 ) 

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

331 

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

333 logger.info( 

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

335 job.source_filename, 

336 created, 

337 failed, 

338 ) 

339 return Ok(result) 

340 

341 # ------------------------------------------------------------------ 

342 # Internal helpers 

343 # ------------------------------------------------------------------ 

344 

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

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

347 

348 Args: 

349 rows: Parsed rows to validate. 

350 

351 Returns: 

352 List of ImportRowError for any violations found. 

353 """ 

354 errors: list[ImportRowError] = [] 

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

356 for field_name in self._required_fields: 

357 val = row.get(field_name) 

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

359 errors.append( 

360 ImportRowError( 

361 row=row_num, 

362 field=field_name, 

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

364 ) 

365 ) 

366 return errors 

367 

368 

369__all__ = [ 

370 "AdminImportService", 

371 "ImportJob", 

372 "ImportResult", 

373 "ImportRowError", 

374]