Coverage for src/dataknobs_data/pandas/metadata.py: 0%

122 statements  

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

1"""Metadata preservation for DataKnobs-Pandas conversions.""" 

2 

3from dataclasses import dataclass 

4from enum import Enum 

5from typing import Any, Dict, List, Optional 

6 

7import pandas as pd 

8 

9from dataknobs_data.records import Record 

10from dataknobs_data.fields import Field, FieldType 

11 

12 

13class MetadataStrategy(Enum): 

14 """Strategy for handling metadata during conversion.""" 

15 NONE = "none" # Don't preserve metadata 

16 ATTRS = "attrs" # Store in DataFrame.attrs 

17 COLUMNS = "columns" # Store as additional columns 

18 MULTI_INDEX = "multi_index" # Use multi-level column index 

19 

20 

21@dataclass 

22class MetadataConfig: 

23 """Configuration for metadata handling.""" 

24 strategy: MetadataStrategy = MetadataStrategy.ATTRS 

25 include_record_metadata: bool = True 

26 include_field_metadata: bool = True 

27 metadata_prefix: str = "_meta_" 

28 preserve_record_ids: bool = True 

29 

30 

31class MetadataHandler: 

32 """Handles metadata preservation during conversions.""" 

33 

34 def __init__(self, config: Optional[MetadataConfig] = None): 

35 """Initialize metadata handler. 

36  

37 Args: 

38 config: Metadata configuration 

39 """ 

40 self.config = config or MetadataConfig() 

41 

42 def extract_metadata_from_records(self, records: List[Record]) -> Dict[str, Any]: 

43 """Extract metadata from records. 

44  

45 Args: 

46 records: List of records 

47  

48 Returns: 

49 Dictionary of metadata 

50 """ 

51 metadata = { 

52 "record_count": len(records), 

53 "has_record_ids": all(r.id for r in records), 

54 "field_names": self._get_all_field_names(records), 

55 "field_types": self._get_field_types(records), 

56 } 

57 

58 if self.config.include_record_metadata: 

59 metadata["record_metadata"] = self._extract_record_metadata(records) 

60 

61 if self.config.include_field_metadata: 

62 metadata["field_metadata"] = self._extract_field_metadata(records) 

63 

64 return metadata 

65 

66 def apply_metadata_to_dataframe( 

67 self, 

68 df: pd.DataFrame, 

69 metadata: Dict[str, Any], 

70 records: Optional[List[Record]] = None 

71 ) -> pd.DataFrame: 

72 """Apply metadata to DataFrame based on strategy. 

73  

74 Args: 

75 df: Target DataFrame 

76 metadata: Metadata to apply 

77 records: Original records (for additional metadata) 

78  

79 Returns: 

80 DataFrame with metadata 

81 """ 

82 if self.config.strategy == MetadataStrategy.NONE: 

83 return df 

84 

85 elif self.config.strategy == MetadataStrategy.ATTRS: 

86 df.attrs.update(metadata) 

87 if records and self.config.preserve_record_ids: 

88 record_ids = [r.id for r in records] 

89 df.attrs["record_ids"] = record_ids 

90 

91 elif self.config.strategy == MetadataStrategy.COLUMNS: 

92 # Add metadata as columns 

93 if self.config.include_record_metadata and records: 

94 for key, values in self._get_record_metadata_columns(records).items(): 

95 col_name = f"{self.config.metadata_prefix}{key}" 

96 df[col_name] = values 

97 

98 elif self.config.strategy == MetadataStrategy.MULTI_INDEX: 

99 # Create multi-level column index with metadata 

100 if "field_types" in metadata: 

101 arrays = [ 

102 df.columns.tolist(), 

103 [metadata["field_types"].get(col, "unknown") for col in df.columns] 

104 ] 

105 df.columns = pd.MultiIndex.from_arrays( 

106 arrays, 

107 names=["field_name", "field_type"] 

108 ) 

109 

110 return df 

111 

112 def extract_metadata_from_dataframe(self, df: pd.DataFrame) -> Dict[str, Any]: 

113 """Extract metadata from DataFrame. 

114  

115 Args: 

116 df: Source DataFrame 

117  

118 Returns: 

119 Dictionary of metadata 

120 """ 

121 metadata = {} 

122 

123 if self.config.strategy == MetadataStrategy.ATTRS: 

124 metadata.update(df.attrs) 

125 

126 elif self.config.strategy == MetadataStrategy.COLUMNS: 

127 # Extract from metadata columns 

128 meta_cols = [col for col in df.columns if col.startswith(self.config.metadata_prefix)] 

129 for col in meta_cols: 

130 key = col.replace(self.config.metadata_prefix, "") 

131 metadata[key] = df[col].tolist() 

132 

133 elif self.config.strategy == MetadataStrategy.MULTI_INDEX: 

134 # Extract from multi-level index 

135 if isinstance(df.columns, pd.MultiIndex): 

136 metadata["field_names"] = df.columns.get_level_values(0).tolist() 

137 if df.columns.nlevels > 1: 

138 metadata["field_types"] = df.columns.get_level_values(1).tolist() 

139 

140 return metadata 

141 

142 def create_records_with_metadata( 

143 self, 

144 df: pd.DataFrame, 

145 base_records: List[Record], 

146 metadata: Optional[Dict[str, Any]] = None 

147 ) -> List[Record]: 

148 """Create records with preserved metadata. 

149  

150 Args: 

151 df: Source DataFrame 

152 base_records: Base records from conversion 

153 metadata: Additional metadata 

154  

155 Returns: 

156 Records with metadata 

157 """ 

158 if not metadata: 

159 metadata = self.extract_metadata_from_dataframe(df) 

160 

161 # Apply record IDs if preserved 

162 if "record_ids" in metadata and len(metadata["record_ids"]) == len(base_records): 

163 for record, record_id in zip(base_records, metadata["record_ids"]): 

164 if record_id: 

165 record.id = record_id 

166 

167 # Apply record metadata if present 

168 if "record_metadata" in metadata: 

169 record_meta = metadata["record_metadata"] 

170 for i, record in enumerate(base_records): 

171 if i < len(record_meta) and record_meta[i]: 

172 record.metadata = record_meta[i] 

173 

174 # Apply field metadata if present 

175 if "field_metadata" in metadata: 

176 field_meta = metadata["field_metadata"] 

177 for record in base_records: 

178 for field_name, field in record.fields.items(): 

179 if field_name in field_meta: 

180 field.metadata = field_meta[field_name] 

181 

182 return base_records 

183 

184 def _get_all_field_names(self, records: List[Record]) -> List[str]: 

185 """Get all unique field names from records.""" 

186 field_names = set() 

187 for record in records: 

188 field_names.update(record.fields.keys()) 

189 return sorted(field_names) 

190 

191 def _get_field_types(self, records: List[Record]) -> Dict[str, str]: 

192 """Get field types from records.""" 

193 field_types = {} 

194 for record in records: 

195 for field_name, field in record.fields.items(): 

196 if field_name not in field_types and field.type: 

197 field_types[field_name] = field.type.value 

198 return field_types 

199 

200 def _extract_record_metadata(self, records: List[Record]) -> List[Dict[str, Any]]: 

201 """Extract metadata from each record.""" 

202 return [r.metadata if r.metadata else {} for r in records] 

203 

204 def _extract_field_metadata(self, records: List[Record]) -> Dict[str, Dict[str, Any]]: 

205 """Extract metadata from fields.""" 

206 field_metadata = {} 

207 for record in records: 

208 for field_name, field in record.fields.items(): 

209 if field.metadata and field_name not in field_metadata: 

210 field_metadata[field_name] = field.metadata 

211 return field_metadata 

212 

213 def _get_record_metadata_columns(self, records: List[Record]) -> Dict[str, List]: 

214 """Get record metadata as column data.""" 

215 columns = {} 

216 

217 # Collect all metadata keys 

218 all_keys = set() 

219 for record in records: 

220 if record.metadata: 

221 all_keys.update(record.metadata.keys()) 

222 

223 # Create column for each metadata key 

224 for key in all_keys: 

225 values = [] 

226 for record in records: 

227 value = record.metadata.get(key) if record.metadata else None 

228 values.append(value) 

229 columns[key] = values 

230 

231 return columns 

232 

233 def clean_dataframe_columns(self, df: pd.DataFrame) -> pd.DataFrame: 

234 """Remove metadata columns from DataFrame. 

235  

236 Args: 

237 df: DataFrame to clean 

238  

239 Returns: 

240 DataFrame without metadata columns 

241 """ 

242 if self.config.strategy == MetadataStrategy.COLUMNS: 

243 # Remove metadata columns 

244 meta_cols = [col for col in df.columns if col.startswith(self.config.metadata_prefix)] 

245 return df.drop(columns=meta_cols) 

246 

247 elif self.config.strategy == MetadataStrategy.MULTI_INDEX: 

248 # Flatten multi-index to single level 

249 if isinstance(df.columns, pd.MultiIndex): 

250 df.columns = df.columns.get_level_values(0) 

251 

252 return df