Coverage for src/dataknobs_data/pandas/type_mapper.py: 0%
255 statements
« prev ^ index » next coverage.py v7.10.3, created at 2025-08-17 18:57 -0500
« prev ^ index » next coverage.py v7.10.3, created at 2025-08-17 18:57 -0500
1"""Type mapping between DataKnobs Field types and Pandas dtypes."""
3import json
4from dataclasses import dataclass
5from datetime import datetime, date, time
6from typing import Any, Dict, Optional, Type, Union
8import numpy as np
9import pandas as pd
10from pandas.api.types import is_datetime64_any_dtype, is_numeric_dtype, is_bool_dtype
12from dataknobs_data.fields import FieldType
15@dataclass
16class PandasTypeMapping:
17 """Mapping configuration for type conversion."""
18 field_type: FieldType
19 pandas_dtype: Union[str, Type, np.dtype]
20 nullable: bool = True
21 converter: Optional[callable] = None
22 reverse_converter: Optional[callable] = None
25class TypeMapper:
26 """Handles type mapping between DataKnobs Field types and Pandas dtypes."""
28 def __init__(self):
29 """Initialize type mapper with default mappings."""
30 self._init_mappings()
32 def _init_mappings(self):
33 """Initialize type mappings."""
34 self._field_to_pandas: Dict[FieldType, PandasTypeMapping] = {
35 FieldType.STRING: PandasTypeMapping(
36 field_type=FieldType.STRING,
37 pandas_dtype="string", # pd.StringDtype()
38 nullable=True
39 ),
40 FieldType.INTEGER: PandasTypeMapping(
41 field_type=FieldType.INTEGER,
42 pandas_dtype="Int64", # pd.Int64Dtype()
43 nullable=True
44 ),
45 FieldType.FLOAT: PandasTypeMapping(
46 field_type=FieldType.FLOAT,
47 pandas_dtype="Float64", # pd.Float64Dtype()
48 nullable=True
49 ),
50 FieldType.BOOLEAN: PandasTypeMapping(
51 field_type=FieldType.BOOLEAN,
52 pandas_dtype="boolean", # pd.BooleanDtype()
53 nullable=True
54 ),
55 FieldType.DATETIME: PandasTypeMapping(
56 field_type=FieldType.DATETIME,
57 pandas_dtype="datetime64[ns]",
58 nullable=True,
59 converter=self._to_datetime,
60 reverse_converter=self._from_datetime
61 ),
62 FieldType.JSON: PandasTypeMapping(
63 field_type=FieldType.JSON,
64 pandas_dtype="object",
65 nullable=True,
66 converter=self._to_json_object,
67 reverse_converter=self._from_json_object
68 ),
69 FieldType.BINARY: PandasTypeMapping(
70 field_type=FieldType.BINARY,
71 pandas_dtype="object",
72 nullable=True
73 ),
74 FieldType.TEXT: PandasTypeMapping(
75 field_type=FieldType.TEXT,
76 pandas_dtype="string",
77 nullable=True
78 ),
79 }
81 # Reverse mapping from pandas to field types
82 self._pandas_to_field: Dict[str, FieldType] = {
83 "string": FieldType.STRING,
84 "int64": FieldType.INTEGER,
85 "float64": FieldType.FLOAT,
86 "boolean": FieldType.BOOLEAN,
87 "datetime64[ns]": FieldType.DATETIME,
88 "object": FieldType.STRING, # Default object to STRING, not JSON
89 }
91 def field_type_to_pandas(self, field_type: FieldType) -> Union[str, Type, np.dtype]:
92 """Convert FieldType to pandas dtype.
94 Args:
95 field_type: DataKnobs FieldType
97 Returns:
98 Corresponding pandas dtype
99 """
100 mapping = self._field_to_pandas.get(field_type)
101 if mapping:
102 return mapping.pandas_dtype
103 return "object" # Default fallback
105 def pandas_to_field_type(self, dtype: Union[str, np.dtype, Type]) -> FieldType:
106 """Infer FieldType from pandas dtype.
108 Args:
109 dtype: Pandas dtype
111 Returns:
112 Corresponding FieldType
113 """
114 dtype_str = str(dtype).lower()
116 # Direct mapping
117 if dtype_str in self._pandas_to_field:
118 return self._pandas_to_field[dtype_str]
120 # Infer from dtype categories
121 if "int" in dtype_str:
122 return FieldType.INTEGER
123 elif "float" in dtype_str:
124 return FieldType.FLOAT
125 elif "bool" in dtype_str:
126 return FieldType.BOOLEAN
127 elif "datetime" in dtype_str or "timestamp" in dtype_str:
128 return FieldType.DATETIME
129 elif dtype_str == "string":
130 return FieldType.STRING
131 elif dtype_str == "object":
132 return FieldType.STRING
133 elif "bytes" in dtype_str:
134 return FieldType.BINARY
136 return FieldType.STRING # Default fallback
138 def convert_value_to_pandas(self, value: Any, field_type: FieldType) -> Any:
139 """Convert a field value to pandas-compatible format.
141 Args:
142 value: Value to convert
143 field_type: Source field type
145 Returns:
146 Pandas-compatible value
147 """
148 if value is None:
149 return pd.NA
151 mapping = self._field_to_pandas.get(field_type)
152 if mapping and mapping.converter:
153 return mapping.converter(value)
155 return value
157 def convert_value_from_pandas(self, value: Any, field_type: FieldType) -> Any:
158 """Convert a pandas value to field-compatible format.
160 Args:
161 value: Pandas value
162 field_type: Target field type
164 Returns:
165 Field-compatible value
166 """
167 # Handle pandas NA/NaN/None
168 # Use try-except to handle arrays and other special cases
169 try:
170 if pd.isna(value):
171 return None
172 except (TypeError, ValueError):
173 # pd.isna can fail on arrays/lists
174 pass
176 mapping = self._field_to_pandas.get(field_type)
177 if mapping and mapping.reverse_converter:
178 return mapping.reverse_converter(value)
180 # Handle numpy types
181 if isinstance(value, (np.integer, np.floating, np.bool_)):
182 return value.item()
184 return value
186 def infer_field_type_from_value(self, value: Any) -> FieldType:
187 """Infer FieldType from a Python value.
189 Args:
190 value: Value to analyze
192 Returns:
193 Inferred FieldType
194 """
195 if value is None:
196 return FieldType.STRING # Default for null
198 # Check for pandas NA separately to avoid array ambiguity
199 try:
200 if pd.isna(value):
201 return FieldType.STRING
202 except (TypeError, ValueError):
203 # pd.isna can fail on some types like lists
204 pass
206 if isinstance(value, bool) or isinstance(value, np.bool_):
207 return FieldType.BOOLEAN
208 elif isinstance(value, (int, np.integer)):
209 return FieldType.INTEGER
210 elif isinstance(value, (float, np.floating)):
211 return FieldType.FLOAT
212 elif isinstance(value, (datetime, pd.Timestamp)):
213 return FieldType.DATETIME
214 elif isinstance(value, bytes):
215 return FieldType.BINARY
216 elif isinstance(value, (dict, list)):
217 return FieldType.JSON
218 elif isinstance(value, str):
219 if len(value) > 1000:
220 return FieldType.TEXT
221 return FieldType.STRING
223 return FieldType.JSON # Complex objects as JSON
225 def cast_series(self, series: pd.Series, field_type: FieldType) -> pd.Series:
226 """Cast a pandas Series to the appropriate dtype for a FieldType.
228 Args:
229 series: Series to cast
230 field_type: Target field type
232 Returns:
233 Casted Series
234 """
235 target_dtype = self.field_type_to_pandas(field_type)
237 try:
238 # Special handling for datetime
239 if field_type == FieldType.DATETIME:
240 return pd.to_datetime(series, errors='coerce')
242 # Special handling for JSON
243 if field_type == FieldType.JSON:
244 return series.apply(self._ensure_json_serializable)
246 # Standard casting
247 return series.astype(target_dtype)
248 except (TypeError, ValueError):
249 # If casting fails, return as object dtype
250 return series.astype("object")
252 @staticmethod
253 def _to_datetime(value: Any) -> pd.Timestamp:
254 """Convert value to pandas Timestamp."""
255 if isinstance(value, str):
256 return pd.Timestamp(value)
257 elif isinstance(value, datetime):
258 return pd.Timestamp(value)
259 elif isinstance(value, (int, float)):
260 # Assume Unix timestamp
261 return pd.Timestamp(value, unit='s')
262 return value
264 @staticmethod
265 def _from_datetime(value: Any) -> datetime:
266 """Convert pandas Timestamp to datetime."""
267 if isinstance(value, pd.Timestamp):
268 return value.to_pydatetime()
269 elif isinstance(value, str):
270 return pd.Timestamp(value).to_pydatetime()
271 return value
273 @staticmethod
274 def _to_json_object(value: Any) -> Any:
275 """Ensure value is JSON-serializable object."""
276 if isinstance(value, str):
277 try:
278 return json.loads(value)
279 except (json.JSONDecodeError, TypeError):
280 return value
281 return value
283 @staticmethod
284 def _from_json_object(value: Any) -> Any:
285 """Convert object to JSON-compatible format."""
286 if isinstance(value, (dict, list)):
287 return value
288 elif isinstance(value, str):
289 try:
290 return json.loads(value)
291 except (json.JSONDecodeError, TypeError):
292 return value
293 return value
295 @staticmethod
296 def _ensure_json_serializable(value: Any) -> Any:
297 """Ensure value is JSON-serializable."""
298 if pd.isna(value):
299 return None
300 if isinstance(value, (dict, list)):
301 return value
302 if isinstance(value, str):
303 try:
304 return json.loads(value)
305 except (json.JSONDecodeError, TypeError):
306 return value
307 # Convert other types to string representation
308 return str(value)
310 def infer_field_type(self, series: pd.Series) -> str:
311 """Infer field type from a pandas Series.
313 Args:
314 series: Series to analyze
316 Returns:
317 Field type string
318 """
319 # Remove nulls for analysis
320 non_null = series.dropna()
322 if len(non_null) == 0:
323 return "string" # Default for empty
325 # Check dtypes
326 if is_bool_dtype(non_null):
327 return "boolean"
328 elif is_datetime64_any_dtype(non_null):
329 return "datetime"
330 elif is_numeric_dtype(non_null):
331 # Check if all values are integers
332 if non_null.apply(lambda x: isinstance(x, (int, np.integer)) or (isinstance(x, float) and x.is_integer())).all():
333 return "integer"
334 else:
335 return "number"
336 else:
337 # Check values for special types
338 sample = non_null.iloc[0] if len(non_null) > 0 else None
339 if sample is not None:
340 if isinstance(sample, (datetime, pd.Timestamp)):
341 return "datetime"
342 elif isinstance(sample, (pd.Timestamp, datetime)):
343 return "datetime"
344 elif isinstance(sample, pd._libs.tslibs.timestamps.Timestamp):
345 return "datetime"
346 elif isinstance(sample, (pd._libs.tslibs.nattype.NaTType)):
347 return "datetime"
348 elif hasattr(sample, '__class__') and 'date' in sample.__class__.__name__.lower():
349 return "date"
350 elif hasattr(sample, '__class__') and 'time' in sample.__class__.__name__.lower():
351 return "time"
353 return "string" # Default
355 def get_pandas_dtype(self, field_type: str) -> str:
356 """Get pandas dtype for a field type string.
358 Args:
359 field_type: Field type string
361 Returns:
362 Pandas dtype string
363 """
364 dtype_map = {
365 "string": "object",
366 "integer": "int64",
367 "number": "float64",
368 "float": "float64",
369 "boolean": "bool",
370 "datetime": "datetime64[ns]",
371 "date": "object",
372 "time": "object",
373 "json": "object",
374 "binary": "object",
375 "text": "object",
376 }
377 return dtype_map.get(field_type.lower(), "object")
379 def convert_value(self, value: Any, target_type: str) -> Any:
380 """Convert a value to target type.
382 Args:
383 value: Value to convert
384 target_type: Target type string
386 Returns:
387 Converted value
388 """
389 if value is None or pd.isna(value):
390 return None
392 target_type = target_type.lower()
394 if target_type == "integer":
395 if isinstance(value, str):
396 return int(float(value))
397 return int(value)
398 elif target_type == "number" or target_type == "float":
399 return float(value)
400 elif target_type == "string":
401 return str(value)
402 elif target_type == "boolean":
403 if isinstance(value, str):
404 return value.lower() in ('true', '1', 'yes')
405 return bool(value)
406 elif target_type == "datetime":
407 if isinstance(value, str):
408 return pd.Timestamp(value)
409 return value
410 elif target_type == "date":
411 if isinstance(value, str):
412 return pd.Timestamp(value).date()
413 elif hasattr(value, 'date'):
414 return value.date()
415 return value
416 elif target_type == "time":
417 if isinstance(value, str):
418 return pd.Timestamp(value).time()
419 elif hasattr(value, 'time'):
420 return value.time()
421 return value
423 return value
425 def cast_dataframe_dtypes(self, df: pd.DataFrame, dtype_map: Dict[str, str]) -> pd.DataFrame:
426 """Cast DataFrame columns to specified dtypes.
428 Args:
429 df: DataFrame to cast
430 dtype_map: Dictionary of column: dtype
432 Returns:
433 DataFrame with casted dtypes
434 """
435 result_df = df.copy()
437 for col, dtype in dtype_map.items():
438 if col in result_df.columns:
439 try:
440 if dtype == "string":
441 # Use string dtype for nullable strings
442 result_df[col] = result_df[col].astype("string")
443 else:
444 result_df[col] = result_df[col].astype(dtype)
445 except (TypeError, ValueError):
446 # If casting fails, leave as is
447 pass
449 return result_df
451 def normalize_timezone(self, series: pd.Series, target_tz: str) -> pd.Series:
452 """Normalize timezone for datetime series.
454 Args:
455 series: Datetime series
456 target_tz: Target timezone
458 Returns:
459 Series with normalized timezone
460 """
461 if not is_datetime64_any_dtype(series):
462 # Try to convert to datetime first
463 series = pd.to_datetime(series, errors='coerce')
465 # If series is timezone-naive, localize it
466 if series.dt.tz is None:
467 return series.dt.tz_localize(target_tz)
468 else:
469 # If timezone-aware, convert to target timezone
470 return series.dt.tz_convert(target_tz)
472 def get_optimal_dtype(self, series: pd.Series) -> str:
473 """Determine optimal dtype for a Series based on its values.
475 Args:
476 series: Series to analyze
478 Returns:
479 Optimal dtype string
480 """
481 # Remove nulls for analysis
482 non_null = series.dropna()
484 if len(non_null) == 0:
485 return "string" # Default for empty
487 # Try to infer the best dtype
488 try:
489 # Check for boolean
490 if non_null.apply(lambda x: isinstance(x, bool)).all():
491 return "bool"
493 # Check for integer
494 if non_null.apply(lambda x: isinstance(x, (int, np.integer)) or (isinstance(x, float) and x.is_integer())).all():
495 # Determine the smallest int type that can hold the values
496 min_val = non_null.min()
497 max_val = non_null.max()
499 if min_val >= -128 and max_val <= 127:
500 return "int8"
501 elif min_val >= -32768 and max_val <= 32767:
502 return "int16"
503 elif min_val >= -2147483648 and max_val <= 2147483647:
504 return "int32"
505 else:
506 return "int64"
508 # Check for float
509 if non_null.apply(lambda x: isinstance(x, (int, float, np.number))).all():
510 # For floats, prefer float32 for small ranges
511 max_val = non_null.abs().max()
512 if max_val <= 3.4e38:
513 return "float32"
514 else:
515 return "float64"
517 # Check for datetime
518 try:
519 pd.to_datetime(non_null)
520 return "datetime64[ns]"
521 except (ValueError, TypeError):
522 pass
524 # Default to object for strings and mixed types
525 return "object"
526 except Exception:
527 return "object"