Coverage for src/dataknobs_data/records.py: 45%

213 statements  

« prev     ^ index     » next       coverage.py v7.10.3, created at 2025-08-17 19:59 -0500

1import uuid 

2from collections import OrderedDict 

3from collections.abc import Iterator 

4from dataclasses import dataclass, field 

5from typing import Any 

6 

7from .fields import Field, FieldType 

8 

9 

10@dataclass 

11class Record: 

12 """Represents a structured data record with fields and metadata. 

13 

14 The record ID can be accessed via the `id` property, which: 

15 - Returns the explicitly set ID if available 

16 - Falls back to metadata['id'] if present 

17 - Returns None if no ID is set 

18 """ 

19 

20 fields: OrderedDict[str, Field] = field(default_factory=OrderedDict) 

21 metadata: dict[str, Any] = field(default_factory=dict) 

22 _id: str | None = field(default=None, repr=False) 

23 

24 def __init__( 

25 self, 

26 data: dict[str, Any] | OrderedDict[str, Field] | None = None, 

27 metadata: dict[str, Any] | None = None, 

28 id: str | None = None, 

29 ): 

30 """Initialize a record from various data formats. 

31 

32 Args: 

33 data: Can be a dict of field names to values, or an OrderedDict of Field objects 

34 metadata: Optional metadata for the record 

35 id: Optional unique identifier for the record 

36 """ 

37 self.metadata = metadata or {} 

38 self.fields = OrderedDict() 

39 self._id = id 

40 

41 # Process data first to populate fields 

42 if data: 

43 if isinstance(data, OrderedDict) and all( 

44 isinstance(v, Field) for v in data.values() 

45 ): 

46 self.fields = data 

47 else: 

48 for key, value in data.items(): 

49 if isinstance(value, Field): 

50 self.fields[key] = value 

51 else: 

52 self.fields[key] = Field(name=key, value=value) 

53 

54 # Now check for ID from various sources if not explicitly provided 

55 if self._id is None: 

56 # Check metadata 

57 if "id" in self.metadata: 

58 self._id = str(self.metadata["id"]) 

59 # Check fields for id 

60 elif "id" in self.fields: 

61 value = self.get_value("id") 

62 if value is not None: 

63 self._id = str(value) 

64 # Sync to metadata 

65 self.metadata["id"] = self._id 

66 # Check fields for record_id 

67 elif "record_id" in self.fields: 

68 value = self.get_value("record_id") 

69 if value is not None: 

70 self._id = str(value) 

71 # Sync to metadata 

72 self.metadata["id"] = self._id 

73 

74 @property 

75 def id(self) -> str | None: 

76 """Get the record ID. 

77 

78 Checks for ID in the following priority order: 

79 1. Explicitly set ID (_id) 

80 2. ID in metadata 

81 3. ID field in record fields 

82 4. record_id field in record fields (common in DataFrames) 

83 

84 Returns the first ID found, or None if no ID is present. 

85 """ 

86 # 1. Check explicitly set ID 

87 if self._id is not None: 

88 return self._id 

89 

90 # 2. Check metadata 

91 if "id" in self.metadata: 

92 return str(self.metadata["id"]) 

93 

94 # 3. Check for 'id' field 

95 if "id" in self.fields: 

96 value = self.get_value("id") 

97 if value is not None: 

98 return str(value) 

99 

100 # 4. Check for 'record_id' field (common in DataFrames) 

101 if "record_id" in self.fields: 

102 value = self.get_value("record_id") 

103 if value is not None: 

104 return str(value) 

105 

106 return None 

107 

108 @id.setter 

109 def id(self, value: str | None) -> None: 

110 """Set the record ID. 

111 

112 Updates the ID in all locations for consistency: 

113 - Internal _id attribute 

114 - Metadata (for backward compatibility) 

115 - ID field if it exists 

116 """ 

117 self._id = value 

118 

119 # Update metadata for backward compatibility 

120 if value is not None: 

121 self.metadata["id"] = value 

122 elif "id" in self.metadata: 

123 del self.metadata["id"] 

124 

125 # Update ID field if it exists (don't create it if it doesn't) 

126 if "id" in self.fields and value is not None: 

127 self.fields["id"].value = value 

128 

129 # Update record_id field if it exists (common in DataFrames) 

130 if "record_id" in self.fields and value is not None: 

131 self.fields["record_id"].value = value 

132 

133 def generate_id(self) -> str: 

134 """Generate and set a new UUID for this record. 

135 

136 Returns: 

137 The generated UUID string 

138 """ 

139 new_id = str(uuid.uuid4()) 

140 self.id = new_id 

141 return new_id 

142 

143 def get_field(self, name: str) -> Field | None: 

144 """Get a field by name.""" 

145 return self.fields.get(name) 

146 

147 def get_value(self, name: str, default: Any = None) -> Any: 

148 """Get a field's value by name, supporting dot-notation for nested paths. 

149 

150 Args: 

151 name: Field name or dot-notation path (e.g., "metadata.type") 

152 default: Default value if field not found 

153 

154 Returns: 

155 The field value or default 

156 """ 

157 # Check if this is a nested path 

158 if "." in name: 

159 return self.get_nested_value(name, default) 

160 

161 # Simple field lookup 

162 field = self.get_field(name) 

163 return field.value if field else default 

164 

165 def get_nested_value(self, path: str, default: Any = None) -> Any: 

166 """Get a value from a nested path using dot notation. 

167 

168 Supports paths like: 

169 - "metadata.type" - access metadata dict 

170 - "fields.temperature" - access field values 

171 - "metadata.config.timeout" - nested dict access 

172 

173 Args: 

174 path: Dot-notation path to the value 

175 default: Default value if path not found 

176 

177 Returns: 

178 The value at the path or default 

179 """ 

180 parts = path.split(".", 1) 

181 if len(parts) == 1: 

182 # No more nesting, get the value 

183 return self.get_value(parts[0], default) 

184 

185 root, remaining = parts 

186 

187 # Handle special root paths 

188 if root == "metadata": 

189 # Navigate through metadata dict 

190 if not self.metadata: 

191 return default 

192 return self._traverse_dict(self.metadata, remaining, default) 

193 elif root == "fields": 

194 # Get field value by name 

195 if "." in remaining: 

196 # Nested path within field value (if it's a dict) 

197 field_name, field_path = remaining.split(".", 1) 

198 field_value = self.get_value(field_name, None) 

199 if isinstance(field_value, dict): 

200 return self._traverse_dict(field_value, field_path, default) 

201 return default 

202 else: 

203 # Simple field access 

204 return self.get_value(remaining, default) 

205 else: 

206 # Check if it's a field containing a dict 

207 field_value = self.get_value(root, None) 

208 if isinstance(field_value, dict): 

209 return self._traverse_dict(field_value, remaining, default) 

210 return default 

211 

212 def _traverse_dict(self, data: dict, path: str, default: Any = None) -> Any: 

213 """Traverse a dictionary using dot notation. 

214 

215 Args: 

216 data: Dictionary to traverse 

217 path: Dot-notation path 

218 default: Default value if path not found 

219 

220 Returns: 

221 Value at path or default 

222 """ 

223 parts = path.split(".") 

224 current = data 

225 

226 for part in parts: 

227 if isinstance(current, dict) and part in current: 

228 current = current[part] 

229 else: 

230 return default 

231 

232 return current 

233 

234 def set_field( 

235 self, 

236 name: str, 

237 value: Any, 

238 field_type: FieldType | None = None, 

239 field_metadata: dict[str, Any] | None = None, 

240 ) -> None: 

241 """Set or update a field.""" 

242 self.fields[name] = Field( 

243 name=name, value=value, type=field_type, metadata=field_metadata or {} 

244 ) 

245 

246 def remove_field(self, name: str) -> bool: 

247 """Remove a field by name. Returns True if field was removed.""" 

248 if name in self.fields: 

249 del self.fields[name] 

250 return True 

251 return False 

252 

253 def has_field(self, name: str) -> bool: 

254 """Check if a field exists.""" 

255 return name in self.fields 

256 

257 def field_names(self) -> list[str]: 

258 """Get list of field names.""" 

259 return list(self.fields.keys()) 

260 

261 def field_count(self) -> int: 

262 """Get the number of fields.""" 

263 return len(self.fields) 

264 

265 def __getitem__(self, key: str | int) -> Any: 

266 """Get field value by name or field by index. 

267 

268 For string keys, returns the field value directly (dict-like access). 

269 For integer keys, returns the Field object at that index for backward compatibility. 

270 """ 

271 if isinstance(key, str): 

272 if key not in self.fields: 

273 raise KeyError(f"Field '{key}' not found") 

274 return self.fields[key].value 

275 elif isinstance(key, int): 

276 field_list = list(self.fields.values()) 

277 if key < 0 or key >= len(field_list): 

278 raise IndexError(f"Field index {key} out of range") 

279 return field_list[key] 

280 else: 

281 raise TypeError(f"Key must be str or int, got {type(key)}") 

282 

283 def __setitem__(self, key: str, value: Field | Any) -> None: 

284 """Set field by name. 

285 

286 Can accept either a Field object or a raw value. 

287 When given a raw value, creates a new Field automatically. 

288 """ 

289 if isinstance(value, Field): 

290 self.fields[key] = value 

291 else: 

292 self.set_field(key, value) 

293 

294 def __delitem__(self, key: str) -> None: 

295 """Delete field by name.""" 

296 if key not in self.fields: 

297 raise KeyError(f"Field '{key}' not found") 

298 del self.fields[key] 

299 

300 def __contains__(self, key: str) -> bool: 

301 """Check if field exists.""" 

302 return key in self.fields 

303 

304 def __iter__(self) -> Iterator[str]: 

305 """Iterate over field names.""" 

306 return iter(self.fields) 

307 

308 def __len__(self) -> int: 

309 """Get number of fields.""" 

310 return len(self.fields) 

311 

312 def validate(self) -> bool: 

313 """Validate all fields in the record.""" 

314 return all(field.validate() for field in self.fields.values()) 

315 

316 def get_field_object(self, key: str) -> Field: 

317 """Get the Field object by name. 

318 

319 Use this method when you need access to the Field object itself, 

320 not just its value. 

321 

322 Args: 

323 key: Field name 

324 

325 Returns: 

326 The Field object 

327 

328 Raises: 

329 KeyError: If field not found 

330 """ 

331 if key not in self.fields: 

332 raise KeyError(f"Field '{key}' not found") 

333 return self.fields[key] 

334 

335 def __getattr__(self, name: str) -> Any: 

336 """Get field value by attribute access. 

337 

338 Provides convenient attribute-style access to field values. 

339 Falls back to normal attribute access for non-field attributes. 

340 

341 Args: 

342 name: Attribute/field name 

343 

344 Returns: 

345 Field value if field exists, otherwise raises AttributeError 

346 """ 

347 # Avoid infinite recursion for special attributes 

348 if name.startswith("_") or name in ("fields", "metadata", "id"): 

349 raise AttributeError( 

350 f"'{type(self).__name__}' object has no attribute '{name}'" 

351 ) 

352 

353 # Check if it's a field 

354 if hasattr(self, "fields") and name in self.fields: 

355 return self.fields[name].value 

356 

357 raise AttributeError(f"'{type(self).__name__}' object has no field '{name}'") 

358 

359 def __setattr__(self, name: str, value: Any) -> None: 

360 """Set field value by attribute access. 

361 

362 Allows setting field values using attribute syntax. 

363 Special attributes (fields, metadata, _id) are handled normally. 

364 

365 Args: 

366 name: Attribute/field name 

367 value: Value to set 

368 """ 

369 # Handle special attributes normally 

370 if name in ("fields", "metadata", "_id") or name.startswith("_"): 

371 super().__setattr__(name, value) 

372 elif hasattr(self, "fields") and name in self.fields: 

373 # Update existing field value 

374 self.fields[name].value = value 

375 else: 

376 # For new fields during normal operation, create them 

377 # But during __init__, we need to use normal attribute setting 

378 if hasattr(self, "fields"): 

379 self.set_field(name, value) 

380 else: 

381 super().__setattr__(name, value) 

382 

383 def to_dict( 

384 self, 

385 include_metadata: bool = False, 

386 flatten: bool = True, 

387 include_field_objects: bool = True, 

388 ) -> dict[str, Any]: 

389 """Convert record to dictionary. 

390 

391 Args: 

392 include_metadata: Whether to include metadata in the output 

393 flatten: If True (default), return just field values; if False, return structured format 

394 include_field_objects: If True and not flattened, return full Field objects 

395 

396 Returns: 

397 Dictionary representation of the record 

398 """ 

399 if flatten: 

400 # Simple dict with just values (default behavior for ergonomics) 

401 result = {name: field.value for name, field in self.fields.items()} 

402 if self.id: 

403 result["_id"] = self.id 

404 if include_metadata and self.metadata: 

405 result["_metadata"] = self.metadata 

406 else: 

407 # Structured format for serialization 

408 if include_field_objects: 

409 result = { 

410 "fields": { 

411 name: field.to_dict() for name, field in self.fields.items() 

412 } 

413 } 

414 else: 

415 result = { 

416 "fields": {name: field.value for name, field in self.fields.items()} 

417 } 

418 if self.id: 

419 result["id"] = self.id 

420 if include_metadata: 

421 result["metadata"] = self.metadata 

422 return result 

423 

424 @classmethod 

425 def from_dict(cls, data: dict[str, Any]) -> "Record": 

426 """Create a record from a dictionary representation.""" 

427 if "fields" in data: 

428 fields = OrderedDict() 

429 for name, field_data in data["fields"].items(): 

430 if isinstance(field_data, dict) and "value" in field_data: 

431 fields[name] = Field.from_dict(field_data) 

432 else: 

433 fields[name] = Field(name=name, value=field_data) 

434 metadata = data.get("metadata", {}) 

435 record_id = data.get("id") or data.get("_id") 

436 return cls(data=fields, metadata=metadata, id=record_id) 

437 else: 

438 # Check for _id in flattened format 

439 record_id = data.pop("_id", None) if "_id" in data else None 

440 return cls(data=data, id=record_id) 

441 

442 def copy(self, deep: bool = True) -> "Record": 

443 """Create a copy of the record. 

444 

445 Args: 

446 deep: If True, create deep copies of fields and metadata 

447 """ 

448 if deep: 

449 import copy 

450 

451 new_fields = OrderedDict() 

452 for name, field in self.fields.items(): 

453 new_fields[name] = Field( 

454 name=field.name, 

455 value=copy.deepcopy(field.value), 

456 type=field.type, 

457 metadata=copy.deepcopy(field.metadata), 

458 ) 

459 new_metadata = copy.deepcopy(self.metadata) 

460 else: 

461 new_fields = OrderedDict(self.fields) 

462 new_metadata = self.metadata.copy() 

463 

464 return Record(data=new_fields, metadata=new_metadata, id=self.id) 

465 

466 def project(self, field_names: list[str]) -> "Record": 

467 """Create a new record with only specified fields.""" 

468 projected_fields = OrderedDict() 

469 for name in field_names: 

470 if name in self.fields: 

471 projected_fields[name] = self.fields[name] 

472 return Record(data=projected_fields, metadata=self.metadata.copy(), id=self.id) 

473 

474 def merge(self, other: "Record", overwrite: bool = True) -> "Record": 

475 """Merge another record into this one. 

476 

477 Args: 

478 other: The record to merge 

479 overwrite: If True, overwrite existing fields; if False, keep existing 

480 

481 Returns: 

482 A new merged record 

483 """ 

484 merged_fields = OrderedDict(self.fields) 

485 for name, field_obj in other.fields.items(): 

486 if overwrite or name not in merged_fields: 

487 merged_fields[name] = field_obj 

488 

489 merged_metadata = self.metadata.copy() 

490 if overwrite: 

491 merged_metadata.update(other.metadata) 

492 

493 # Use the ID from this record, or from other if this doesn't have one 

494 merged_id = self.id if self.id else other.id 

495 

496 return Record(data=merged_fields, metadata=merged_metadata, id=merged_id)