meerschaum.utils.dtypes
Utility functions for working with data types.
1#! /usr/bin/env python3 2# -*- coding: utf-8 -*- 3# vim:fenc=utf-8 4 5""" 6Utility functions for working with data types. 7""" 8 9import traceback 10import json 11import uuid 12import time 13import struct 14from datetime import timezone, datetime, date, timedelta 15from decimal import Decimal, Context, InvalidOperation, ROUND_HALF_UP 16 17import meerschaum as mrsm 18from meerschaum.utils.typing import Dict, Union, Any, Optional, Tuple 19from meerschaum.utils.warnings import warn 20from meerschaum._internal.static import STATIC_CONFIG as _STATIC_CONFIG 21 22MRSM_ALIAS_DTYPES: Dict[str, str] = { 23 'decimal': 'numeric', 24 'Decimal': 'numeric', 25 'number': 'numeric', 26 'jsonl': 'json', 27 'JSON': 'json', 28 'binary': 'bytes', 29 'blob': 'bytes', 30 'varbinary': 'bytes', 31 'bytea': 'bytes', 32 'guid': 'uuid', 33 'UUID': 'uuid', 34 'geom': 'geometry', 35 'geog': 'geography', 36 'boolean': 'bool', 37 'day': 'date', 38} 39MRSM_PD_DTYPES: Dict[Union[str, None], str] = { 40 'json': 'object', 41 'numeric': 'object', 42 'geometry': 'object', 43 'geography': 'object', 44 'uuid': 'object', 45 'date': 'date32[day][pyarrow]', 46 'datetime': 'datetime64[us, UTC]', 47 'bool': 'bool[pyarrow]', 48 'int': 'int64[pyarrow]', 49 'int8': 'int8[pyarrow]', 50 'int16': 'int16[pyarrow]', 51 'int32': 'int32[pyarrow]', 52 'int64': 'int64[pyarrow]', 53 'str': 'string', 54 'bytes': 'binary[pyarrow]', 55 None: 'object', 56} 57 58MRSM_PRECISION_UNITS_SCALARS: Dict[str, Union[int, float]] = { 59 'nanosecond': 1_000_000_000, 60 'microsecond': 1_000_000, 61 'millisecond': 1000, 62 'second': 1, 63 'minute': (1 / 60), 64 'hour': (1 / 3600), 65 'day': (1 / 86400), 66} 67 68MRSM_PRECISION_UNITS_ALIASES: Dict[str, str] = { 69 'ns': 'nanosecond', 70 'us': 'microsecond', 71 'ms': 'millisecond', 72 's': 'second', 73 'sec': 'second', 74 'm': 'minute', 75 'min': 'minute', 76 'h': 'hour', 77 'hr': 'hour', 78 'd': 'day', 79 'D': 'day', 80} 81MRSM_PRECISION_UNITS_ABBREVIATIONS: Dict[str, str] = { 82 'nanosecond': 'ns', 83 'microsecond': 'us', 84 'millisecond': 'ms', 85 'second': 's', 86 'minute': 'min', 87 'hour': 'hr', 88 'day': 'D', 89} 90 91 92def to_pandas_dtype(dtype: str) -> str: 93 """ 94 Cast a supported Meerschaum dtype to a Pandas dtype. 95 """ 96 known_dtype = MRSM_PD_DTYPES.get(dtype, None) 97 if known_dtype is not None: 98 return known_dtype 99 100 alias_dtype = MRSM_ALIAS_DTYPES.get(dtype, None) 101 if alias_dtype is not None: 102 return MRSM_PD_DTYPES[alias_dtype] 103 104 if dtype.startswith('numeric'): 105 return MRSM_PD_DTYPES['numeric'] 106 107 if dtype.startswith('geometry'): 108 return MRSM_PD_DTYPES['geometry'] 109 110 if dtype.startswith('geography'): 111 return MRSM_PD_DTYPES['geography'] 112 113 ### NOTE: Kind of a hack, but if the first word of the given dtype is in all caps, 114 ### treat it as a SQL db type. 115 if dtype.split(' ')[0].isupper(): 116 from meerschaum.utils.dtypes.sql import get_pd_type_from_db_type 117 return get_pd_type_from_db_type(dtype) 118 119 from meerschaum.utils.packages import attempt_import 120 _ = attempt_import('pyarrow', lazy=False) 121 pandas = attempt_import('pandas', lazy=False) 122 123 try: 124 return str(pandas.api.types.pandas_dtype(dtype)) 125 except Exception: 126 warn( 127 f"Invalid dtype '{dtype}', will use 'object' instead:\n" 128 + f"{traceback.format_exc()}", 129 stack=False, 130 ) 131 return 'object' 132 133 134def are_dtypes_equal( 135 ldtype: Union[str, Dict[str, str]], 136 rdtype: Union[str, Dict[str, str]], 137) -> bool: 138 """ 139 Determine whether two dtype strings may be considered 140 equivalent to avoid unnecessary conversions. 141 142 Parameters 143 ---------- 144 ldtype: Union[str, Dict[str, str]] 145 The left dtype to compare. 146 May also provide a dtypes dictionary. 147 148 rdtype: Union[str, Dict[str, str]] 149 The right dtype to compare. 150 May also provide a dtypes dictionary. 151 152 Returns 153 ------- 154 A `bool` indicating whether the two dtypes are to be considered equivalent. 155 """ 156 if isinstance(ldtype, dict) and isinstance(rdtype, dict): 157 lkeys = sorted([str(k) for k in ldtype.keys()]) 158 rkeys = sorted([str(k) for k in rdtype.keys()]) 159 for lkey, rkey in zip(lkeys, rkeys): 160 if lkey != rkey: 161 return False 162 ltype = ldtype[lkey] 163 rtype = rdtype[rkey] 164 if not are_dtypes_equal(ltype, rtype): 165 return False 166 return True 167 168 try: 169 if ldtype == rdtype: 170 return True 171 except Exception: 172 warn(f"Exception when comparing dtypes, returning False:\n{traceback.format_exc()}") 173 return False 174 175 ### Sometimes pandas dtype objects are passed. 176 ldtype = str(ldtype).split('[', maxsplit=1)[0] 177 rdtype = str(rdtype).split('[', maxsplit=1)[0] 178 179 if ldtype in MRSM_ALIAS_DTYPES: 180 ldtype = MRSM_ALIAS_DTYPES[ldtype] 181 182 if rdtype in MRSM_ALIAS_DTYPES: 183 rdtype = MRSM_ALIAS_DTYPES[rdtype] 184 185 json_dtypes = ('json', 'object') 186 if ldtype in json_dtypes and rdtype in json_dtypes: 187 return True 188 189 numeric_dtypes = ('numeric', 'decimal', 'object') 190 if ( 191 ldtype in numeric_dtypes or ldtype.startswith('decimal') 192 ) and ( 193 rdtype in numeric_dtypes or rdtype.startswith('decimal') 194 ): 195 return True 196 197 uuid_dtypes = ('uuid', 'object') 198 if ldtype in uuid_dtypes and rdtype in uuid_dtypes: 199 return True 200 201 bytes_dtypes = ('bytes', 'object', 'binary', 'large_binary') 202 if ldtype in bytes_dtypes and rdtype in bytes_dtypes: 203 return True 204 205 geometry_dtypes = ('geometry', 'object', 'geography') 206 if ldtype in geometry_dtypes and rdtype in geometry_dtypes: 207 return True 208 209 if ldtype.lower() == rdtype.lower(): 210 return True 211 212 datetime_dtypes = ('datetime', 'timestamp') 213 ldtype_found_dt_prefix = False 214 rdtype_found_dt_prefix = False 215 for dt_prefix in datetime_dtypes: 216 ldtype_found_dt_prefix = (dt_prefix in ldtype.lower()) or ldtype_found_dt_prefix 217 rdtype_found_dt_prefix = (dt_prefix in rdtype.lower()) or rdtype_found_dt_prefix 218 if ldtype_found_dt_prefix and rdtype_found_dt_prefix: 219 return True 220 221 string_dtypes = ('str', 'string', 'large_string', 'string_view', 'object') 222 if ldtype in string_dtypes and rdtype in string_dtypes: 223 return True 224 225 int_dtypes = ( 226 'int', 'int64', 'int32', 'int16', 'int8', 227 'uint', 'uint64', 'uint32', 'uint16', 'uint8', 228 ) 229 int_substrings = ('int',) 230 if ldtype.lower() in int_dtypes and rdtype.lower() in int_dtypes: 231 return True 232 for substring in int_substrings: 233 if substring in ldtype.lower() and substring in rdtype.lower(): 234 return True 235 236 float_dtypes = ('float', 'float64', 'float32', 'float16', 'float128', 'double') 237 if ldtype.lower() in float_dtypes and rdtype.lower() in float_dtypes: 238 return True 239 240 bool_dtypes = ('bool', 'boolean') 241 if ldtype in bool_dtypes and rdtype in bool_dtypes: 242 return True 243 244 date_dtypes = ( 245 'date', 'date32', 'date32[pyarrow]', 'date32[day][pyarrow]', 246 'date64', 'date64[pyarrow]', 'date64[ms][pyarrow]', 247 ) 248 if ldtype in date_dtypes and rdtype in date_dtypes: 249 return True 250 251 return False 252 253 254def is_dtype_numeric(dtype: str) -> bool: 255 """ 256 Determine whether a given `dtype` string 257 should be considered compatible with the Meerschaum dtype `numeric`. 258 259 Parameters 260 ---------- 261 dtype: str 262 The pandas-like dtype string. 263 264 Returns 265 ------- 266 A bool indicating the dtype is compatible with `numeric`. 267 """ 268 dtype_lower = dtype.lower() 269 270 acceptable_substrings = ('numeric', 'float', 'double', 'int') 271 for substring in acceptable_substrings: 272 if substring in dtype_lower: 273 return True 274 275 return False 276 277 278def attempt_cast_to_numeric( 279 value: Any, 280 quantize: bool = False, 281 precision: Optional[int] = None, 282 scale: Optional[int] = None, 283)-> Any: 284 """ 285 Given a value, attempt to coerce it into a numeric (Decimal). 286 287 Parameters 288 ---------- 289 value: Any 290 The value to be cast to a Decimal. 291 292 quantize: bool, default False 293 If `True`, quantize the decimal to the specified precision and scale. 294 295 precision: Optional[int], default None 296 If `quantize` is `True`, use this precision. 297 298 scale: Optional[int], default None 299 If `quantize` is `True`, use this scale. 300 301 Returns 302 ------- 303 A `Decimal` if possible, or `value`. 304 """ 305 if isinstance(value, Decimal): 306 if quantize and precision and scale: 307 return quantize_decimal(value, precision, scale) 308 return value 309 try: 310 if value_is_null(value): 311 return Decimal('NaN') 312 313 dec = Decimal(str(value)) 314 if not quantize or not precision or not scale: 315 return dec 316 return quantize_decimal(dec, precision, scale) 317 except Exception: 318 return value 319 320 321def attempt_cast_to_uuid(value: Any) -> Any: 322 """ 323 Given a value, attempt to coerce it into a UUID (`uuid4`). 324 """ 325 if isinstance(value, uuid.UUID): 326 return value 327 try: 328 return ( 329 uuid.UUID(str(value)) 330 if not value_is_null(value) 331 else None 332 ) 333 except Exception: 334 return value 335 336 337def attempt_cast_to_bytes(value: Any) -> Any: 338 """ 339 Given a value, attempt to coerce it into a bytestring. 340 """ 341 if isinstance(value, bytes): 342 return value 343 try: 344 return ( 345 deserialize_bytes_string(str(value)) 346 if not value_is_null(value) 347 else None 348 ) 349 except Exception: 350 return value 351 352 353def attempt_cast_to_geometry(value: Any) -> Any: 354 """ 355 Given a value, attempt to coerce it into a `shapely` (`geometry`) object. 356 """ 357 typ = str(type(value)) 358 if 'pandas' in typ and 'Series' in typ: 359 if 'GeoSeries' in typ: 360 return value 361 362 gpd = mrsm.attempt_import('geopandas', lazy=False) 363 if len(value) == 0: 364 return gpd.GeoSeries([]) 365 366 ix = value.first_valid_index() 367 if ix is None: 368 try: 369 return gpd.GeoSeries(value) 370 except Exception: 371 traceback.print_exc() 372 return gpd.GeoSeries(attempt_cast_to_geometry(val) for val in value) 373 374 sample_val = value[ix] 375 sample_typ = str(type(sample_val)) 376 if 'shapely' in sample_typ: 377 try: 378 return gpd.GeoSeries(value) 379 except Exception: 380 traceback.print_exc() 381 return gpd.GeoSeries(attempt_cast_to_geometry(val) for val in value) 382 383 sample_is_gpkg = geometry_is_gpkg(sample_val) 384 if sample_is_gpkg: 385 try: 386 value = value.apply(lambda x: gpkg_wkb_to_wkb(x)[0]) 387 except Exception: 388 traceback.print_exc() 389 return gpd.GeoSeries(attempt_cast_to_geometry(val) for val in value) 390 391 sample_is_wkt = geometry_is_wkt(sample_val) if not sample_is_gpkg else False 392 try: 393 return ( 394 gpd.GeoSeries.from_wkt(value) 395 if sample_is_wkt 396 else gpd.GeoSeries.from_wkb(value) 397 ) 398 except Exception: 399 traceback.print_exc() 400 return gpd.GeoSeries(attempt_cast_to_geometry(val) for val in value) 401 402 if 'shapely' in typ: 403 return value 404 405 shapely, shapely_wkt, shapely_wkb = mrsm.attempt_import( 406 'shapely', 407 'shapely.wkt', 408 'shapely.wkb', 409 lazy=False, 410 ) 411 412 if isinstance(value, (dict, list)): 413 try: 414 return shapely.from_geojson(json.dumps(value)) 415 except Exception: 416 return value 417 418 value_is_gpkg = geometry_is_gpkg(value) 419 if value_is_gpkg: 420 try: 421 wkb_data, _, _ = gpkg_wkb_to_wkb(value) 422 return shapely_wkb.loads(wkb_data) 423 except Exception: 424 return value 425 426 value_is_wkt = geometry_is_wkt(value) 427 if value_is_wkt is None: 428 return value 429 430 try: 431 return ( 432 shapely_wkt.loads(value) 433 if value_is_wkt 434 else shapely_wkb.loads(value) 435 ) 436 except Exception: 437 pass 438 439 return value 440 441 442def geometry_is_wkt(value: Union[str, bytes]) -> Union[bool, None]: 443 """ 444 Determine whether an input value should be treated as WKT or WKB geometry data. 445 446 Parameters 447 ---------- 448 value: Union[str, bytes] 449 The input data to be parsed into geometry data. 450 451 Returns 452 ------- 453 A `bool` (`True` if `value` is WKT and `False` if it should be treated as WKB). 454 Return `None` if `value` should be parsed as neither. 455 """ 456 import re 457 if not isinstance(value, (str, bytes)): 458 return None 459 460 if isinstance(value, bytes): 461 return False 462 463 wkt_pattern = r'^\s*(POINT|LINESTRING|POLYGON|MULTIPOINT|MULTILINESTRING|MULTIPOLYGON|GEOMETRYCOLLECTION)\s*\(.*\)\s*$' 464 if re.match(wkt_pattern, value, re.IGNORECASE): 465 return True 466 467 if all(c in '0123456789ABCDEFabcdef' for c in value) and len(value) % 2 == 0: 468 return False 469 470 return None 471 472 473def geometry_is_gpkg(value: bytes) -> bool: 474 """ 475 Return whether the input `value` is formatted as GeoPackage WKB. 476 """ 477 if not isinstance(value, bytes) or len(value) < 2: 478 return False 479 480 return value[0:2] == b'GP' 481 482def gpkg_wkb_to_wkb(gpkg_wkb_bytes: bytes) -> Tuple[bytes, int, bytes]: 483 """ 484 Converts GeoPackage WKB to standard WKB by removing the header. 485 486 Parameters 487 ---------- 488 gpkg_wkb_bytes: bytes 489 The GeoPackage WKB byte string. 490 491 Returns 492 ------- 493 A tuple containing the standard WKB bytes, SRID, and flags. 494 """ 495 magic_number = gpkg_wkb_bytes[0:2] 496 if magic_number != b'GP': 497 raise ValueError("Invalid GeoPackage WKB header: missing magic number.") 498 499 try: 500 header = gpkg_wkb_bytes[0:8] 501 header_vals = struct.unpack('<ccBBi', header) 502 flags = header_vals[-2] 503 srid = header_vals[-1] 504 except struct.error: 505 header = gpkg_wkb_bytes[0:6] 506 header_vals = struct.unpack('<ccBBh', header) 507 flags = header_vals[-2] 508 srid = header_vals[-1] 509 510 envelope_type = (flags >> 1) & 0x07 511 envelope_sizes = { 512 0: 0, 513 1: 32, 514 2: 48, 515 3: 48, 516 4: 64, 517 } 518 header_length = 8 + envelope_sizes.get(envelope_type, 0) 519 standard_wkb_bytes = gpkg_wkb_bytes[header_length:] 520 return standard_wkb_bytes, srid, flags 521 522 523def value_is_null(value: Any) -> bool: 524 """ 525 Determine if a value is a null-like string. 526 """ 527 return str(value).lower() in ('none', 'nan', 'na', 'nat', 'natz', '', '<na>') 528 529 530def none_if_null(value: Any) -> Any: 531 """ 532 Return `None` if a value is a null-like string. 533 """ 534 return (None if value_is_null(value) else value) 535 536 537def quantize_decimal(x: Decimal, precision: int, scale: int) -> Decimal: 538 """ 539 Quantize a given `Decimal` to a known scale and precision. 540 541 Parameters 542 ---------- 543 x: Decimal 544 The `Decimal` to be quantized. 545 546 precision: int 547 The total number of significant digits. 548 549 scale: int 550 The number of significant digits after the decimal point. 551 552 Returns 553 ------- 554 A `Decimal` quantized to the specified scale and precision. 555 """ 556 precision_decimal = Decimal(('1' * (precision - scale)) + '.' + ('1' * scale)) 557 try: 558 return x.quantize(precision_decimal, context=Context(prec=precision), rounding=ROUND_HALF_UP) 559 except InvalidOperation: 560 pass 561 562 raise ValueError(f"Cannot quantize value '{x}' to {precision=}, {scale=}.") 563 564 565def serialize_decimal( 566 x: Any, 567 quantize: bool = False, 568 precision: Optional[int] = None, 569 scale: Optional[int] = None, 570) -> Any: 571 """ 572 Return a quantized string of an input decimal. 573 574 Parameters 575 ---------- 576 x: Any 577 The potential decimal to be serialized. 578 579 quantize: bool, default False 580 If `True`, quantize the incoming Decimal to the specified scale and precision 581 before serialization. 582 583 precision: Optional[int], default None 584 The precision of the decimal to be quantized. 585 586 scale: Optional[int], default None 587 The scale of the decimal to be quantized. 588 589 Returns 590 ------- 591 A string of the input decimal or the input if not a Decimal. 592 """ 593 if not isinstance(x, Decimal): 594 return x 595 596 if value_is_null(x): 597 return None 598 599 if quantize and scale and precision: 600 x = quantize_decimal(x, precision, scale) 601 602 return f"{x:f}" 603 604 605def coerce_timezone( 606 dt: Any, 607 strip_utc: bool = False, 608) -> Any: 609 """ 610 Given a `datetime`, pandas `Timestamp` or `Series` of `Timestamp`, 611 return a UTC timestamp (strip timezone if `strip_utc` is `True`. 612 """ 613 if dt is None: 614 return None 615 616 if isinstance(dt, int): 617 return dt 618 619 if isinstance(dt, str): 620 dateutil_parser = mrsm.attempt_import('dateutil.parser') 621 try: 622 dt = dateutil_parser.parse(dt) 623 except Exception: 624 return dt 625 626 dt_is_series = hasattr(dt, 'dtype') and hasattr(dt, '__module__') 627 if dt_is_series: 628 pandas = mrsm.attempt_import('pandas', lazy=False) 629 dt_timezone = getattr(getattr(dt, 'dt', None), 'tz', None) 630 631 if dt_timezone is not None: 632 utc_dt = dt if str(dt_timezone).lower() == 'utc' else dt.dt.tz_convert(timezone.utc) 633 return utc_dt.dt.tz_localize(None) if strip_utc else utc_dt 634 635 if ( 636 pandas.api.types.is_datetime64_any_dtype(dt) 637 and dt_timezone is None 638 and strip_utc 639 ): 640 return dt 641 642 dt_series = to_datetime(dt, coerce_utc=False) 643 if dt_series.dt.tz is None: 644 dt_series = dt_series.dt.tz_localize(timezone.utc) 645 if strip_utc: 646 try: 647 if dt_series.dt.tz is not None: 648 dt_series = dt_series.dt.tz_localize(None) 649 except Exception: 650 pass 651 652 return dt_series 653 654 if dt.tzinfo is None: 655 if strip_utc: 656 return dt 657 return dt.replace(tzinfo=timezone.utc) 658 659 utc_dt = dt.astimezone(timezone.utc) 660 if strip_utc: 661 return utc_dt.replace(tzinfo=None) 662 return utc_dt 663 664 665def to_datetime( 666 dt_val: Any, 667 as_pydatetime: bool = False, 668 coerce_utc: bool = True, 669 precision_unit: Optional[str] = None, 670) -> Any: 671 """ 672 Wrap `pd.to_datetime()` and add support for out-of-bounds values. 673 674 Parameters 675 ---------- 676 dt_val: Any 677 The value to coerce to Pandas Timestamps. 678 679 as_pydatetime: bool, default False 680 If `True`, return a Python datetime object. 681 682 coerce_utc: bool, default True 683 If `True`, ensure the value has UTC tzinfo. 684 685 precision_unit: Optional[str], default None 686 If provided, enforce the provided precision unit. 687 """ 688 pandas, dateutil_parser = mrsm.attempt_import('pandas', 'dateutil.parser', lazy=False) 689 dt_is_series = hasattr(dt_val, 'dtype') and hasattr(dt_val, '__module__') 690 enforce_precision = precision_unit is not None 691 precision_unit = precision_unit or 'microsecond' 692 true_precision_unit = MRSM_PRECISION_UNITS_ALIASES.get(precision_unit, precision_unit) 693 precision_abbreviation = MRSM_PRECISION_UNITS_ABBREVIATIONS.get(true_precision_unit, None) 694 if not precision_abbreviation: 695 raise ValueError(f"Invalid precision '{precision_unit}'.") 696 697 def parse(x: Any) -> Any: 698 try: 699 return dateutil_parser.parse(x) 700 except Exception: 701 return x 702 703 def check_dtype(dtype_to_check: str, with_utc: bool = True) -> bool: 704 dtype_check_against = ( 705 f"datetime64[{precision_abbreviation}, UTC]" 706 if with_utc 707 else f"datetime64[{precision_abbreviation}]" 708 ) 709 return ( 710 dtype_to_check == dtype_check_against 711 if enforce_precision 712 else ( 713 dtype_to_check.startswith('datetime64[') 714 and ( 715 ('utc' in dtype_to_check.lower()) 716 if with_utc 717 else ('utc' not in dtype_to_check.lower()) 718 ) 719 ) 720 ) 721 722 if isinstance(dt_val, pandas.Timestamp): 723 dt_val_to_return = dt_val if not as_pydatetime else dt_val.to_pydatetime() 724 return ( 725 coerce_timezone(dt_val_to_return) 726 if coerce_utc 727 else dt_val_to_return 728 ) 729 730 if dt_is_series: 731 changed_tz = False 732 original_tz = None 733 dtype = str(getattr(dt_val, 'dtype', 'object')) 734 if ( 735 are_dtypes_equal(dtype, 'datetime') 736 and 'utc' not in dtype.lower() 737 and hasattr(dt_val, 'dt') 738 ): 739 original_tz = dt_val.dt.tz 740 dt_val = dt_val.dt.tz_localize(timezone.utc) 741 changed_tz = True 742 dtype = str(getattr(dt_val, 'dtype', 'object')) 743 try: 744 new_dt_series = ( 745 dt_val 746 if check_dtype(dtype, with_utc=True) 747 else dt_val.astype(f"datetime64[{precision_abbreviation}, UTC]") 748 ) 749 except pandas.errors.OutOfBoundsDatetime: 750 try: 751 next_precision = get_next_precision_unit(true_precision_unit) 752 next_precision_abbrevation = MRSM_PRECISION_UNITS_ABBREVIATIONS[next_precision] 753 new_dt_series = dt_val.astype(f"datetime64[{next_precision_abbrevation}, UTC]") 754 except Exception: 755 new_dt_series = None 756 except ValueError: 757 new_dt_series = None 758 except TypeError: 759 try: 760 new_dt_series = ( 761 new_dt_series 762 if check_dtype(str(getattr(new_dt_series, 'dtype', None)), with_utc=False) 763 else dt_val.astype(f"datetime64[{precision_abbreviation}]") 764 ) 765 except Exception: 766 new_dt_series = None 767 768 if new_dt_series is None: 769 new_dt_series = dt_val.apply(lambda x: parse(str(x))) 770 771 if coerce_utc: 772 return coerce_timezone(new_dt_series) 773 774 if changed_tz: 775 new_dt_series = new_dt_series.dt.tz_localize(original_tz) 776 return new_dt_series 777 778 try: 779 new_dt_val = pandas.to_datetime(dt_val, utc=True, format='ISO8601') 780 if new_dt_val.unit != precision_abbreviation: 781 new_dt_val = new_dt_val.as_unit(precision_abbreviation) 782 if as_pydatetime: 783 return new_dt_val.to_pydatetime() 784 return new_dt_val 785 except (pandas.errors.OutOfBoundsDatetime, ValueError): 786 pass 787 788 new_dt_val = parse(dt_val) 789 if not coerce_utc: 790 return new_dt_val 791 return coerce_timezone(new_dt_val) 792 793 794def serialize_bytes(data: bytes) -> str: 795 """ 796 Return the given bytes as a base64-encoded string. 797 """ 798 import base64 799 if not isinstance(data, bytes) and value_is_null(data): 800 return data 801 return base64.b64encode(data).decode('utf-8') 802 803 804def serialize_geometry( 805 geom: Any, 806 geometry_format: str = 'wkb_hex', 807 srid: Optional[int] = None, 808) -> Union[str, Dict[str, Any], bytes, None]: 809 """ 810 Serialize geometry data as WKB, WKB (hex), GPKG-WKB, WKT, or GeoJSON. 811 812 Parameters 813 ---------- 814 geom: Any 815 The potential geometry data to be serialized. 816 817 geometry_format: str, default 'wkb_hex' 818 The serialization format for geometry data. 819 Accepted formats are `wkb`, `wkb_hex`, `wkt`, `geojson`, and `gpkg_wkb`. 820 821 srid: Optional[int], default None 822 If provided, use this as the source CRS when serializing to GeoJSON. 823 824 Returns 825 ------- 826 A string containing the geometry data, or bytes, or a dictionary, or None. 827 """ 828 if value_is_null(geom): 829 return None 830 831 shapely, shapely_ops, pyproj, np = mrsm.attempt_import( 832 'shapely', 'shapely.ops', 'pyproj', 'numpy', 833 lazy=False, 834 ) 835 if geometry_format == 'geojson': 836 if srid: 837 transformer = pyproj.Transformer.from_crs(f"EPSG:{srid}", "EPSG:4326", always_xy=True) 838 geom = shapely_ops.transform(transformer.transform, geom) 839 geojson_str = shapely.to_geojson(geom) 840 return json.loads(geojson_str) 841 842 if not hasattr(geom, 'wkb_hex'): 843 return str(geom) 844 845 byte_order = 1 if np.little_endian else 0 846 847 if geometry_format.startswith("wkb"): 848 return shapely.to_wkb(geom, hex=(geometry_format=="wkb_hex"), include_srid=True) 849 850 if geometry_format == 'gpkg_wkb': 851 wkb_data = shapely.to_wkb(geom, hex=False, byte_order=byte_order) 852 flags = ( 853 ((byte_order & 0x01) | (0x20)) 854 if geom.is_empty 855 else (byte_order & 0x01) 856 ) 857 srid_val = srid or -1 858 header = struct.pack( 859 '<ccBBi', 860 b'G', b'P', 861 0, 862 flags, 863 srid_val 864 ) 865 return header + wkb_data 866 867 return shapely.to_wkt(geom) 868 869 870def deserialize_geometry(geom_wkb: Union[str, bytes]): 871 """ 872 Deserialize a WKB string into a shapely geometry object. 873 """ 874 shapely = mrsm.attempt_import('shapely', lazy=False) 875 return shapely.wkb.loads(geom_wkb) 876 877 878def project_geometry(geom, srid: int, to_srid: int = 4326): 879 """ 880 Project a shapely geometry object to a new CRS (SRID). 881 """ 882 pyproj, shapely_ops = mrsm.attempt_import('pyproj', 'shapely.ops', lazy=False) 883 transformer = pyproj.Transformer.from_crs(f"EPSG:{srid}", f"EPSG:{to_srid}", always_xy=True) 884 return shapely_ops.transform(transformer.transform, geom) 885 886 887def deserialize_bytes_string(data: Optional[str], force_hex: bool = False) -> Union[bytes, None]: 888 """ 889 Given a serialized ASCII string of bytes data, return the original bytes. 890 The input data may either be base64- or hex-encoded. 891 892 Parameters 893 ---------- 894 data: Optional[str] 895 The string to be deserialized into bytes. 896 May be base64- or hex-encoded (prefixed with `'\\x'`). 897 898 force_hex: bool = False 899 If `True`, treat the input string as hex-encoded. 900 If `data` does not begin with the prefix `'\\x'`, set `force_hex` to `True`. 901 This will still strip the leading `'\\x'` prefix if present. 902 903 Returns 904 ------- 905 The original bytes used to produce the encoded string `data`. 906 """ 907 if not isinstance(data, str) and value_is_null(data): 908 return data 909 910 import binascii 911 import base64 912 913 is_hex = force_hex or data.startswith('\\x') 914 915 if is_hex: 916 if data.startswith('\\x'): 917 data = data[2:] 918 return binascii.unhexlify(data) 919 920 return base64.b64decode(data) 921 922 923def deserialize_base64(data: str) -> bytes: 924 """ 925 Return the original bytestring from the given base64-encoded string. 926 """ 927 import base64 928 return base64.b64decode(data) 929 930 931def encode_bytes_for_bytea(data: bytes, with_prefix: bool = True) -> Union[str, None]: 932 """ 933 Return the given bytes as a hex string for PostgreSQL's `BYTEA` type. 934 """ 935 import binascii 936 if not isinstance(data, bytes) and value_is_null(data): 937 return data 938 return ('\\x' if with_prefix else '') + binascii.hexlify(data).decode('utf-8') 939 940 941def serialize_datetime(dt: datetime) -> Union[str, None]: 942 """ 943 Serialize a datetime object into JSON (ISO format string). 944 945 Examples 946 -------- 947 >>> import json 948 >>> from datetime import datetime 949 >>> json.dumps({'a': datetime(2022, 1, 1)}, default=json_serialize_datetime) 950 '{"a": "2022-01-01T00:00:00Z"}' 951 952 """ 953 if not hasattr(dt, 'isoformat'): 954 return None 955 956 tz_suffix = 'Z' if getattr(dt, 'tzinfo', None) is None else '' 957 return dt.isoformat() + tz_suffix 958 959 960def serialize_date(d: date) -> Union[str, None]: 961 """ 962 Serialize a date object into its ISO representation. 963 """ 964 return d.isoformat() if hasattr(d, 'isoformat') else None 965 966 967def json_serialize_value(x: Any, default_to_str: bool = True) -> Union[str, None]: 968 """ 969 Serialize the given value to a JSON value. Accounts for datetimes, bytes, decimals, etc. 970 971 Parameters 972 ---------- 973 x: Any 974 The value to serialize. 975 976 default_to_str: bool, default True 977 If `True`, return a string of `x` if x is not a designated type. 978 Otherwise return x. 979 980 Returns 981 ------- 982 A serialized version of x, or x. 983 """ 984 if isinstance(x, (mrsm.Pipe, mrsm.connectors.Connector)): 985 return x.meta 986 987 if hasattr(x, 'tzinfo'): 988 return serialize_datetime(x) 989 990 if hasattr(x, 'isoformat'): 991 return serialize_date(x) 992 993 if isinstance(x, bytes): 994 return serialize_bytes(x) 995 996 if isinstance(x, Decimal): 997 return serialize_decimal(x) 998 999 if 'shapely' in str(type(x)): 1000 return serialize_geometry(x) 1001 1002 if value_is_null(x): 1003 return None 1004 1005 if isinstance(x, (dict, list, tuple)): 1006 return json.dumps(x, default=json_serialize_value, separators=(',', ':')) 1007 1008 return str(x) if default_to_str else x 1009 1010 1011def get_geometry_type_srid( 1012 dtype: str = 'geometry', 1013 default_type: str = 'geometry', 1014 default_srid: Union[int, str] = 0, 1015) -> Tuple[str, Union[int, str, None]]: 1016 """ 1017 Given the specified geometry `dtype`, return a tuple in the form (type, SRID). 1018 1019 Parameters 1020 ---------- 1021 dtype: Optional[str], default None 1022 Optionally provide a specific `geometry` syntax (e.g. `geometry[MultiLineString, 4326]`). 1023 You may specify a supported `shapely` geometry type and an SRID in the dtype modifier: 1024 1025 - `Point` 1026 - `LineString` 1027 - `LinearRing` 1028 - `Polygon` 1029 - `MultiPoint` 1030 - `MultiLineString` 1031 - `MultiPolygon` 1032 - `GeometryCollection` 1033 1034 Returns 1035 ------- 1036 A tuple in the form (type, SRID). 1037 Defaults to `(default_type, default_srid)`. 1038 1039 Examples 1040 -------- 1041 >>> from meerschaum.utils.dtypes import get_geometry_type_srid 1042 >>> get_geometry_type_srid() 1043 ('geometry', 4326) 1044 >>> get_geometry_type_srid('geometry[]') 1045 ('geometry', 4326) 1046 >>> get_geometry_type_srid('geometry[Point, 0]') 1047 ('Point', 0) 1048 >>> get_geometry_type_srid('geometry[0, Point]') 1049 ('Point', 0) 1050 >>> get_geometry_type_srid('geometry[0]') 1051 ('geometry', 0) 1052 >>> get_geometry_type_srid('geometry[MULTILINESTRING, 4326]') 1053 ('MultiLineString', 4326) 1054 >>> get_geometry_type_srid('geography') 1055 ('geometry', 0) 1056 >>> get_geometry_type_srid('geography[POINT]') 1057 ('Point', 0) 1058 >>> get_geometry_type_srid('geometry[POINT, ESRI:102003]') 1059 ('Point', 'ESRI:102003') 1060 """ 1061 from meerschaum.utils.misc import is_int 1062 ### NOTE: PostGIS syntax must also be parsed. 1063 dtype = dtype.replace('(', '[').replace(')', ']') 1064 bare_dtype = dtype.split('[', maxsplit=1)[0] 1065 modifier = dtype.split(bare_dtype, maxsplit=1)[-1].lstrip('[').rstrip(']') 1066 if not modifier: 1067 return default_type, default_srid 1068 1069 parts = [ 1070 part.split('=')[-1].strip() 1071 for part in modifier.split(',') 1072 ] 1073 parts_casted = [ 1074 ( 1075 int(part) 1076 if is_int(part) 1077 else part 1078 ) 1079 for part in parts 1080 ] 1081 1082 srid = default_srid 1083 geometry_type = default_type 1084 1085 for part in parts_casted: 1086 if isinstance(part, int) or ':' in str(part): 1087 srid = part 1088 break 1089 1090 for part in parts_casted: 1091 if isinstance(part, str) and part != srid: 1092 geometry_type = part 1093 break 1094 1095 return geometry_type, srid 1096 1097 1098def datetime_to_int( 1099 dt: 'Union[datetime, int]', 1100 precision_unit: str = 'microsecond', 1101) -> int: 1102 """ 1103 Convert a timezone-aware (or naive UTC) `datetime` into an integer epoch value 1104 at the given precision unit. 1105 1106 Parameters 1107 ---------- 1108 dt: Union[datetime, int] 1109 The datetime to convert. Naive datetimes are assumed to be UTC. 1110 Integers are returned as-is. 1111 1112 precision_unit: str, default 'microsecond' 1113 The precision of the epoch value (e.g. `'millisecond'`, `'second'`). 1114 Aliases (`'ms'`, `'s'`, etc.) are accepted. 1115 1116 Returns 1117 ------- 1118 An integer epoch value at the requested precision. 1119 1120 Examples 1121 -------- 1122 >>> from datetime import datetime, timezone 1123 >>> datetime_to_int(datetime(2026, 5, 30, tzinfo=timezone.utc), 'millisecond') 1124 1779840000000 1125 """ 1126 if isinstance(dt, int): 1127 return dt 1128 1129 true_precision_unit = MRSM_PRECISION_UNITS_ALIASES.get(precision_unit, precision_unit) 1130 if true_precision_unit not in MRSM_PRECISION_UNITS_SCALARS: 1131 from meerschaum.utils.misc import items_str 1132 raise ValueError( 1133 f"Unknown precision unit '{precision_unit}'. " 1134 "Accepted values are " 1135 f"{items_str(list(MRSM_PRECISION_UNITS_SCALARS) + list(MRSM_PRECISION_UNITS_ALIASES))}." 1136 ) 1137 1138 dt = coerce_timezone(dt) 1139 return int(dt.timestamp() * MRSM_PRECISION_UNITS_SCALARS[true_precision_unit]) 1140 1141 1142def get_current_timestamp( 1143 precision_unit: str = _STATIC_CONFIG['dtypes']['datetime']['default_precision_unit'], 1144 precision_interval: int = 1, 1145 round_to: str = 'down', 1146 as_pandas: bool = False, 1147 as_int: bool = False, 1148 unit: str = _STATIC_CONFIG['dtypes']['datetime']['default_precision_unit'], 1149 interval: int = 1, 1150 _now: Union[datetime, int, None] = None, 1151) -> 'Union[datetime, pd.Timestamp, int]': 1152 """ 1153 Return the current UTC timestamp to nanosecond precision. 1154 1155 Parameters 1156 ---------- 1157 precision_unit: str, default 'us' 1158 The precision of the timestamp to be returned. 1159 Valid values are the following: 1160 - `ns` / `nanosecond` 1161 - `us` / `microsecond` 1162 - `ms` / `millisecond` 1163 - `s` / `sec` / `second` 1164 - `m` / `min` / `minute` 1165 - `h` / `hr` / `hour` 1166 - `d` / `day` 1167 1168 precision_interval: int, default 1 1169 Round the timestamp to the `precision_interval` units. 1170 For example, `precision='minute'` and `precision_interval=15` will round to 15-minute intervals. 1171 Note: `precision_interval` must be 1 when `precision='nanosecond'`. 1172 1173 round_to: str, default 'down' 1174 The direction to which to round the timestamp. 1175 Available options are `down`, `up`, and `closest`. 1176 1177 as_pandas: bool, default False 1178 If `True`, return a Pandas Timestamp. 1179 This is always true if `unit` is `nanosecond`. 1180 1181 as_int: bool, default False 1182 If `True`, return the timestamp to an integer. 1183 Overrides `as_pandas`. 1184 1185 unit: str, default 'us' 1186 Alias for `precision_unit`. 1187 1188 interval: int, default 1 1189 Alias for `precision_interval`. 1190 1191 Returns 1192 ------- 1193 A Pandas Timestamp, datetime object, or integer with precision to the provided unit. 1194 1195 Examples 1196 -------- 1197 >>> get_current_timestamp('ns') 1198 Timestamp('2025-07-17 17:59:16.423644369+0000', tz='UTC') 1199 >>> get_current_timestamp('ms') 1200 Timestamp('2025-07-17 17:59:16.424000+0000', tz='UTC') 1201 """ 1202 default_unit = _STATIC_CONFIG['dtypes']['datetime']['default_precision_unit'] 1203 if unit != precision_unit and precision_unit == default_unit: 1204 precision_unit = unit 1205 1206 if interval != precision_interval and precision_interval == 1: 1207 precision_interval = interval 1208 1209 true_precision_unit = MRSM_PRECISION_UNITS_ALIASES.get(precision_unit, precision_unit) 1210 if true_precision_unit not in MRSM_PRECISION_UNITS_SCALARS: 1211 from meerschaum.utils.misc import items_str 1212 raise ValueError( 1213 f"Unknown precision unit '{precision_unit}'. " 1214 "Accepted values are " 1215 f"{items_str(list(MRSM_PRECISION_UNITS_SCALARS) + list(MRSM_PRECISION_UNITS_ALIASES))}." 1216 ) 1217 1218 if not as_int: 1219 as_pandas = as_pandas or true_precision_unit == 'nanosecond' 1220 pd = mrsm.attempt_import('pandas', lazy=False) if as_pandas else None 1221 1222 if true_precision_unit == 'nanosecond': 1223 if precision_interval != 1: 1224 warn("`precision_interval` must be 1 for nanosecond precision.") 1225 now_ts = time.time_ns() if not isinstance(_now, int) else _now 1226 if as_int: 1227 return now_ts 1228 return pd.to_datetime(now_ts, unit='ns', utc=True) 1229 1230 now = datetime.now(timezone.utc) if not isinstance(_now, datetime) else _now 1231 delta = timedelta(**{true_precision_unit + 's': precision_interval}) 1232 rounded_now = round_time(now, delta, to=round_to) 1233 1234 if as_int: 1235 return datetime_to_int(rounded_now, true_precision_unit) 1236 1237 ts_val = ( 1238 pd.to_datetime(rounded_now, utc=True) 1239 if as_pandas 1240 else rounded_now 1241 ) 1242 1243 if not as_pandas: 1244 return ts_val 1245 1246 as_unit_precisions = ('microsecond', 'millisecond', 'second') 1247 if true_precision_unit not in as_unit_precisions: 1248 return ts_val 1249 1250 return ts_val.as_unit(MRSM_PRECISION_UNITS_ABBREVIATIONS[true_precision_unit]) 1251 1252 1253def is_dtype_special(type_: str) -> bool: 1254 """ 1255 Return whether a dtype should be treated as a special Meerschaum dtype. 1256 This is not the same as a Meerschaum alias. 1257 """ 1258 true_type = MRSM_ALIAS_DTYPES.get(type_, type_) 1259 if true_type in ( 1260 'uuid', 1261 'json', 1262 'bytes', 1263 'numeric', 1264 'datetime', 1265 'geometry', 1266 'geography', 1267 'date', 1268 'bool', 1269 ): 1270 return True 1271 1272 if are_dtypes_equal(true_type, 'datetime'): 1273 return True 1274 1275 if are_dtypes_equal(true_type, 'date'): 1276 return True 1277 1278 if true_type.startswith('numeric'): 1279 return True 1280 1281 if true_type.startswith('bool'): 1282 return True 1283 1284 if true_type.startswith('geometry'): 1285 return True 1286 1287 if true_type.startswith('geography'): 1288 return True 1289 1290 return False 1291 1292 1293def get_next_precision_unit(precision_unit: str, decrease: bool = True) -> str: 1294 """ 1295 Get the next precision string in order of value. 1296 1297 Parameters 1298 ---------- 1299 precision_unit: str 1300 The precision string (`'nanosecond'`, `'ms'`, etc.). 1301 1302 decrease: bool, defaul True 1303 If `True` return the precision unit which is lower (e.g. `nanosecond` -> `millisecond`). 1304 If `False`, return the precision unit which is higher. 1305 1306 Returns 1307 ------- 1308 A `precision` string which is lower or higher than the given precision unit. 1309 1310 Examples 1311 -------- 1312 >>> get_next_precision_unit('nanosecond') 1313 'microsecond' 1314 >>> get_next_precision_unit('ms') 1315 'second' 1316 >>> get_next_precision_unit('hour', decrease=False) 1317 'minute' 1318 """ 1319 true_precision_unit = MRSM_PRECISION_UNITS_ALIASES.get(precision_unit, precision_unit) 1320 precision_scalar = MRSM_PRECISION_UNITS_SCALARS.get(true_precision_unit, None) 1321 if not precision_scalar: 1322 raise ValueError(f"Invalid precision unit '{precision_unit}'.") 1323 1324 precisions = sorted( 1325 list(MRSM_PRECISION_UNITS_SCALARS), 1326 key=lambda p: MRSM_PRECISION_UNITS_SCALARS[p] 1327 ) 1328 1329 precision_index = precisions.index(true_precision_unit) 1330 new_precision_index = precision_index + (-1 if decrease else 1) 1331 if new_precision_index < 0 or new_precision_index >= len(precisions): 1332 raise ValueError(f"No precision {'below' if decrease else 'above'} '{precision_unit}'.") 1333 1334 return precisions[new_precision_index] 1335 1336 1337def round_time( 1338 dt: Optional[datetime] = None, 1339 date_delta: Optional[timedelta] = None, 1340 to: 'str' = 'down' 1341) -> datetime: 1342 """ 1343 Round a datetime object to a multiple of a timedelta. 1344 1345 Parameters 1346 ---------- 1347 dt: Optional[datetime], default None 1348 If `None`, grab the current UTC datetime. 1349 1350 date_delta: Optional[timedelta], default None 1351 If `None`, use a delta of 1 minute. 1352 1353 to: 'str', default 'down' 1354 Available options are `'up'`, `'down'`, and `'closest'`. 1355 1356 Returns 1357 ------- 1358 A rounded `datetime` object. 1359 1360 Examples 1361 -------- 1362 >>> round_time(datetime(2022, 1, 1, 12, 15, 57, 200)) 1363 datetime.datetime(2022, 1, 1, 12, 15) 1364 >>> round_time(datetime(2022, 1, 1, 12, 15, 57, 200), to='up') 1365 datetime.datetime(2022, 1, 1, 12, 16) 1366 >>> round_time(datetime(2022, 1, 1, 12, 15, 57, 200), timedelta(hours=1)) 1367 datetime.datetime(2022, 1, 1, 12, 0) 1368 >>> round_time( 1369 ... datetime(2022, 1, 1, 12, 15, 57, 200), 1370 ... timedelta(hours=1), 1371 ... to = 'closest' 1372 ... ) 1373 datetime.datetime(2022, 1, 1, 12, 0) 1374 >>> round_time( 1375 ... datetime(2022, 1, 1, 12, 45, 57, 200), 1376 ... datetime.timedelta(hours=1), 1377 ... to = 'closest' 1378 ... ) 1379 datetime.datetime(2022, 1, 1, 13, 0) 1380 1381 """ 1382 from decimal import Decimal, ROUND_HALF_UP, ROUND_DOWN, ROUND_UP 1383 if date_delta is None: 1384 date_delta = timedelta(minutes=1) 1385 1386 if dt is None: 1387 dt = datetime.now(timezone.utc).replace(tzinfo=None) 1388 1389 def get_total_microseconds(td: timedelta) -> int: 1390 return (td.days * 86400 + td.seconds) * 1_000_000 + td.microseconds 1391 1392 round_to_microseconds = get_total_microseconds(date_delta) 1393 if round_to_microseconds == 0: 1394 return dt 1395 1396 dt_delta_from_min = dt.replace(tzinfo=None) - datetime.min 1397 dt_total_microseconds = get_total_microseconds(dt_delta_from_min) 1398 1399 dt_dec = Decimal(dt_total_microseconds) 1400 round_to_dec = Decimal(round_to_microseconds) 1401 1402 div = dt_dec / round_to_dec 1403 if to == 'down': 1404 num_intervals = div.to_integral_value(rounding=ROUND_DOWN) 1405 elif to == 'up': 1406 num_intervals = div.to_integral_value(rounding=ROUND_UP) 1407 else: 1408 num_intervals = div.to_integral_value(rounding=ROUND_HALF_UP) 1409 1410 rounded_dt_total_microseconds = num_intervals * round_to_dec 1411 adjustment_microseconds = int(rounded_dt_total_microseconds) - dt_total_microseconds 1412 1413 return dt + timedelta(microseconds=adjustment_microseconds)
93def to_pandas_dtype(dtype: str) -> str: 94 """ 95 Cast a supported Meerschaum dtype to a Pandas dtype. 96 """ 97 known_dtype = MRSM_PD_DTYPES.get(dtype, None) 98 if known_dtype is not None: 99 return known_dtype 100 101 alias_dtype = MRSM_ALIAS_DTYPES.get(dtype, None) 102 if alias_dtype is not None: 103 return MRSM_PD_DTYPES[alias_dtype] 104 105 if dtype.startswith('numeric'): 106 return MRSM_PD_DTYPES['numeric'] 107 108 if dtype.startswith('geometry'): 109 return MRSM_PD_DTYPES['geometry'] 110 111 if dtype.startswith('geography'): 112 return MRSM_PD_DTYPES['geography'] 113 114 ### NOTE: Kind of a hack, but if the first word of the given dtype is in all caps, 115 ### treat it as a SQL db type. 116 if dtype.split(' ')[0].isupper(): 117 from meerschaum.utils.dtypes.sql import get_pd_type_from_db_type 118 return get_pd_type_from_db_type(dtype) 119 120 from meerschaum.utils.packages import attempt_import 121 _ = attempt_import('pyarrow', lazy=False) 122 pandas = attempt_import('pandas', lazy=False) 123 124 try: 125 return str(pandas.api.types.pandas_dtype(dtype)) 126 except Exception: 127 warn( 128 f"Invalid dtype '{dtype}', will use 'object' instead:\n" 129 + f"{traceback.format_exc()}", 130 stack=False, 131 ) 132 return 'object'
Cast a supported Meerschaum dtype to a Pandas dtype.
135def are_dtypes_equal( 136 ldtype: Union[str, Dict[str, str]], 137 rdtype: Union[str, Dict[str, str]], 138) -> bool: 139 """ 140 Determine whether two dtype strings may be considered 141 equivalent to avoid unnecessary conversions. 142 143 Parameters 144 ---------- 145 ldtype: Union[str, Dict[str, str]] 146 The left dtype to compare. 147 May also provide a dtypes dictionary. 148 149 rdtype: Union[str, Dict[str, str]] 150 The right dtype to compare. 151 May also provide a dtypes dictionary. 152 153 Returns 154 ------- 155 A `bool` indicating whether the two dtypes are to be considered equivalent. 156 """ 157 if isinstance(ldtype, dict) and isinstance(rdtype, dict): 158 lkeys = sorted([str(k) for k in ldtype.keys()]) 159 rkeys = sorted([str(k) for k in rdtype.keys()]) 160 for lkey, rkey in zip(lkeys, rkeys): 161 if lkey != rkey: 162 return False 163 ltype = ldtype[lkey] 164 rtype = rdtype[rkey] 165 if not are_dtypes_equal(ltype, rtype): 166 return False 167 return True 168 169 try: 170 if ldtype == rdtype: 171 return True 172 except Exception: 173 warn(f"Exception when comparing dtypes, returning False:\n{traceback.format_exc()}") 174 return False 175 176 ### Sometimes pandas dtype objects are passed. 177 ldtype = str(ldtype).split('[', maxsplit=1)[0] 178 rdtype = str(rdtype).split('[', maxsplit=1)[0] 179 180 if ldtype in MRSM_ALIAS_DTYPES: 181 ldtype = MRSM_ALIAS_DTYPES[ldtype] 182 183 if rdtype in MRSM_ALIAS_DTYPES: 184 rdtype = MRSM_ALIAS_DTYPES[rdtype] 185 186 json_dtypes = ('json', 'object') 187 if ldtype in json_dtypes and rdtype in json_dtypes: 188 return True 189 190 numeric_dtypes = ('numeric', 'decimal', 'object') 191 if ( 192 ldtype in numeric_dtypes or ldtype.startswith('decimal') 193 ) and ( 194 rdtype in numeric_dtypes or rdtype.startswith('decimal') 195 ): 196 return True 197 198 uuid_dtypes = ('uuid', 'object') 199 if ldtype in uuid_dtypes and rdtype in uuid_dtypes: 200 return True 201 202 bytes_dtypes = ('bytes', 'object', 'binary', 'large_binary') 203 if ldtype in bytes_dtypes and rdtype in bytes_dtypes: 204 return True 205 206 geometry_dtypes = ('geometry', 'object', 'geography') 207 if ldtype in geometry_dtypes and rdtype in geometry_dtypes: 208 return True 209 210 if ldtype.lower() == rdtype.lower(): 211 return True 212 213 datetime_dtypes = ('datetime', 'timestamp') 214 ldtype_found_dt_prefix = False 215 rdtype_found_dt_prefix = False 216 for dt_prefix in datetime_dtypes: 217 ldtype_found_dt_prefix = (dt_prefix in ldtype.lower()) or ldtype_found_dt_prefix 218 rdtype_found_dt_prefix = (dt_prefix in rdtype.lower()) or rdtype_found_dt_prefix 219 if ldtype_found_dt_prefix and rdtype_found_dt_prefix: 220 return True 221 222 string_dtypes = ('str', 'string', 'large_string', 'string_view', 'object') 223 if ldtype in string_dtypes and rdtype in string_dtypes: 224 return True 225 226 int_dtypes = ( 227 'int', 'int64', 'int32', 'int16', 'int8', 228 'uint', 'uint64', 'uint32', 'uint16', 'uint8', 229 ) 230 int_substrings = ('int',) 231 if ldtype.lower() in int_dtypes and rdtype.lower() in int_dtypes: 232 return True 233 for substring in int_substrings: 234 if substring in ldtype.lower() and substring in rdtype.lower(): 235 return True 236 237 float_dtypes = ('float', 'float64', 'float32', 'float16', 'float128', 'double') 238 if ldtype.lower() in float_dtypes and rdtype.lower() in float_dtypes: 239 return True 240 241 bool_dtypes = ('bool', 'boolean') 242 if ldtype in bool_dtypes and rdtype in bool_dtypes: 243 return True 244 245 date_dtypes = ( 246 'date', 'date32', 'date32[pyarrow]', 'date32[day][pyarrow]', 247 'date64', 'date64[pyarrow]', 'date64[ms][pyarrow]', 248 ) 249 if ldtype in date_dtypes and rdtype in date_dtypes: 250 return True 251 252 return False
Determine whether two dtype strings may be considered equivalent to avoid unnecessary conversions.
Parameters
- ldtype (Union[str, Dict[str, str]]): The left dtype to compare. May also provide a dtypes dictionary.
- rdtype (Union[str, Dict[str, str]]): The right dtype to compare. May also provide a dtypes dictionary.
Returns
- A
boolindicating whether the two dtypes are to be considered equivalent.
255def is_dtype_numeric(dtype: str) -> bool: 256 """ 257 Determine whether a given `dtype` string 258 should be considered compatible with the Meerschaum dtype `numeric`. 259 260 Parameters 261 ---------- 262 dtype: str 263 The pandas-like dtype string. 264 265 Returns 266 ------- 267 A bool indicating the dtype is compatible with `numeric`. 268 """ 269 dtype_lower = dtype.lower() 270 271 acceptable_substrings = ('numeric', 'float', 'double', 'int') 272 for substring in acceptable_substrings: 273 if substring in dtype_lower: 274 return True 275 276 return False
Determine whether a given dtype string
should be considered compatible with the Meerschaum dtype numeric.
Parameters
- dtype (str): The pandas-like dtype string.
Returns
- A bool indicating the dtype is compatible with
numeric.
279def attempt_cast_to_numeric( 280 value: Any, 281 quantize: bool = False, 282 precision: Optional[int] = None, 283 scale: Optional[int] = None, 284)-> Any: 285 """ 286 Given a value, attempt to coerce it into a numeric (Decimal). 287 288 Parameters 289 ---------- 290 value: Any 291 The value to be cast to a Decimal. 292 293 quantize: bool, default False 294 If `True`, quantize the decimal to the specified precision and scale. 295 296 precision: Optional[int], default None 297 If `quantize` is `True`, use this precision. 298 299 scale: Optional[int], default None 300 If `quantize` is `True`, use this scale. 301 302 Returns 303 ------- 304 A `Decimal` if possible, or `value`. 305 """ 306 if isinstance(value, Decimal): 307 if quantize and precision and scale: 308 return quantize_decimal(value, precision, scale) 309 return value 310 try: 311 if value_is_null(value): 312 return Decimal('NaN') 313 314 dec = Decimal(str(value)) 315 if not quantize or not precision or not scale: 316 return dec 317 return quantize_decimal(dec, precision, scale) 318 except Exception: 319 return value
Given a value, attempt to coerce it into a numeric (Decimal).
Parameters
- value (Any): The value to be cast to a Decimal.
- quantize (bool, default False):
If
True, quantize the decimal to the specified precision and scale. - precision (Optional[int], default None):
If
quantizeisTrue, use this precision. - scale (Optional[int], default None):
If
quantizeisTrue, use this scale.
Returns
- A
Decimalif possible, orvalue.
322def attempt_cast_to_uuid(value: Any) -> Any: 323 """ 324 Given a value, attempt to coerce it into a UUID (`uuid4`). 325 """ 326 if isinstance(value, uuid.UUID): 327 return value 328 try: 329 return ( 330 uuid.UUID(str(value)) 331 if not value_is_null(value) 332 else None 333 ) 334 except Exception: 335 return value
Given a value, attempt to coerce it into a UUID (uuid4).
338def attempt_cast_to_bytes(value: Any) -> Any: 339 """ 340 Given a value, attempt to coerce it into a bytestring. 341 """ 342 if isinstance(value, bytes): 343 return value 344 try: 345 return ( 346 deserialize_bytes_string(str(value)) 347 if not value_is_null(value) 348 else None 349 ) 350 except Exception: 351 return value
Given a value, attempt to coerce it into a bytestring.
354def attempt_cast_to_geometry(value: Any) -> Any: 355 """ 356 Given a value, attempt to coerce it into a `shapely` (`geometry`) object. 357 """ 358 typ = str(type(value)) 359 if 'pandas' in typ and 'Series' in typ: 360 if 'GeoSeries' in typ: 361 return value 362 363 gpd = mrsm.attempt_import('geopandas', lazy=False) 364 if len(value) == 0: 365 return gpd.GeoSeries([]) 366 367 ix = value.first_valid_index() 368 if ix is None: 369 try: 370 return gpd.GeoSeries(value) 371 except Exception: 372 traceback.print_exc() 373 return gpd.GeoSeries(attempt_cast_to_geometry(val) for val in value) 374 375 sample_val = value[ix] 376 sample_typ = str(type(sample_val)) 377 if 'shapely' in sample_typ: 378 try: 379 return gpd.GeoSeries(value) 380 except Exception: 381 traceback.print_exc() 382 return gpd.GeoSeries(attempt_cast_to_geometry(val) for val in value) 383 384 sample_is_gpkg = geometry_is_gpkg(sample_val) 385 if sample_is_gpkg: 386 try: 387 value = value.apply(lambda x: gpkg_wkb_to_wkb(x)[0]) 388 except Exception: 389 traceback.print_exc() 390 return gpd.GeoSeries(attempt_cast_to_geometry(val) for val in value) 391 392 sample_is_wkt = geometry_is_wkt(sample_val) if not sample_is_gpkg else False 393 try: 394 return ( 395 gpd.GeoSeries.from_wkt(value) 396 if sample_is_wkt 397 else gpd.GeoSeries.from_wkb(value) 398 ) 399 except Exception: 400 traceback.print_exc() 401 return gpd.GeoSeries(attempt_cast_to_geometry(val) for val in value) 402 403 if 'shapely' in typ: 404 return value 405 406 shapely, shapely_wkt, shapely_wkb = mrsm.attempt_import( 407 'shapely', 408 'shapely.wkt', 409 'shapely.wkb', 410 lazy=False, 411 ) 412 413 if isinstance(value, (dict, list)): 414 try: 415 return shapely.from_geojson(json.dumps(value)) 416 except Exception: 417 return value 418 419 value_is_gpkg = geometry_is_gpkg(value) 420 if value_is_gpkg: 421 try: 422 wkb_data, _, _ = gpkg_wkb_to_wkb(value) 423 return shapely_wkb.loads(wkb_data) 424 except Exception: 425 return value 426 427 value_is_wkt = geometry_is_wkt(value) 428 if value_is_wkt is None: 429 return value 430 431 try: 432 return ( 433 shapely_wkt.loads(value) 434 if value_is_wkt 435 else shapely_wkb.loads(value) 436 ) 437 except Exception: 438 pass 439 440 return value
Given a value, attempt to coerce it into a shapely (geometry) object.
443def geometry_is_wkt(value: Union[str, bytes]) -> Union[bool, None]: 444 """ 445 Determine whether an input value should be treated as WKT or WKB geometry data. 446 447 Parameters 448 ---------- 449 value: Union[str, bytes] 450 The input data to be parsed into geometry data. 451 452 Returns 453 ------- 454 A `bool` (`True` if `value` is WKT and `False` if it should be treated as WKB). 455 Return `None` if `value` should be parsed as neither. 456 """ 457 import re 458 if not isinstance(value, (str, bytes)): 459 return None 460 461 if isinstance(value, bytes): 462 return False 463 464 wkt_pattern = r'^\s*(POINT|LINESTRING|POLYGON|MULTIPOINT|MULTILINESTRING|MULTIPOLYGON|GEOMETRYCOLLECTION)\s*\(.*\)\s*$' 465 if re.match(wkt_pattern, value, re.IGNORECASE): 466 return True 467 468 if all(c in '0123456789ABCDEFabcdef' for c in value) and len(value) % 2 == 0: 469 return False 470 471 return None
Determine whether an input value should be treated as WKT or WKB geometry data.
Parameters
- value (Union[str, bytes]): The input data to be parsed into geometry data.
Returns
- A
bool(Trueifvalueis WKT andFalseif it should be treated as WKB). - Return
Noneifvalueshould be parsed as neither.
474def geometry_is_gpkg(value: bytes) -> bool: 475 """ 476 Return whether the input `value` is formatted as GeoPackage WKB. 477 """ 478 if not isinstance(value, bytes) or len(value) < 2: 479 return False 480 481 return value[0:2] == b'GP'
Return whether the input value is formatted as GeoPackage WKB.
483def gpkg_wkb_to_wkb(gpkg_wkb_bytes: bytes) -> Tuple[bytes, int, bytes]: 484 """ 485 Converts GeoPackage WKB to standard WKB by removing the header. 486 487 Parameters 488 ---------- 489 gpkg_wkb_bytes: bytes 490 The GeoPackage WKB byte string. 491 492 Returns 493 ------- 494 A tuple containing the standard WKB bytes, SRID, and flags. 495 """ 496 magic_number = gpkg_wkb_bytes[0:2] 497 if magic_number != b'GP': 498 raise ValueError("Invalid GeoPackage WKB header: missing magic number.") 499 500 try: 501 header = gpkg_wkb_bytes[0:8] 502 header_vals = struct.unpack('<ccBBi', header) 503 flags = header_vals[-2] 504 srid = header_vals[-1] 505 except struct.error: 506 header = gpkg_wkb_bytes[0:6] 507 header_vals = struct.unpack('<ccBBh', header) 508 flags = header_vals[-2] 509 srid = header_vals[-1] 510 511 envelope_type = (flags >> 1) & 0x07 512 envelope_sizes = { 513 0: 0, 514 1: 32, 515 2: 48, 516 3: 48, 517 4: 64, 518 } 519 header_length = 8 + envelope_sizes.get(envelope_type, 0) 520 standard_wkb_bytes = gpkg_wkb_bytes[header_length:] 521 return standard_wkb_bytes, srid, flags
Converts GeoPackage WKB to standard WKB by removing the header.
Parameters
- gpkg_wkb_bytes (bytes): The GeoPackage WKB byte string.
Returns
- A tuple containing the standard WKB bytes, SRID, and flags.
524def value_is_null(value: Any) -> bool: 525 """ 526 Determine if a value is a null-like string. 527 """ 528 return str(value).lower() in ('none', 'nan', 'na', 'nat', 'natz', '', '<na>')
Determine if a value is a null-like string.
531def none_if_null(value: Any) -> Any: 532 """ 533 Return `None` if a value is a null-like string. 534 """ 535 return (None if value_is_null(value) else value)
Return None if a value is a null-like string.
538def quantize_decimal(x: Decimal, precision: int, scale: int) -> Decimal: 539 """ 540 Quantize a given `Decimal` to a known scale and precision. 541 542 Parameters 543 ---------- 544 x: Decimal 545 The `Decimal` to be quantized. 546 547 precision: int 548 The total number of significant digits. 549 550 scale: int 551 The number of significant digits after the decimal point. 552 553 Returns 554 ------- 555 A `Decimal` quantized to the specified scale and precision. 556 """ 557 precision_decimal = Decimal(('1' * (precision - scale)) + '.' + ('1' * scale)) 558 try: 559 return x.quantize(precision_decimal, context=Context(prec=precision), rounding=ROUND_HALF_UP) 560 except InvalidOperation: 561 pass 562 563 raise ValueError(f"Cannot quantize value '{x}' to {precision=}, {scale=}.")
Quantize a given Decimal to a known scale and precision.
Parameters
- x (Decimal):
The
Decimalto be quantized. - precision (int): The total number of significant digits.
- scale (int): The number of significant digits after the decimal point.
Returns
- A
Decimalquantized to the specified scale and precision.
566def serialize_decimal( 567 x: Any, 568 quantize: bool = False, 569 precision: Optional[int] = None, 570 scale: Optional[int] = None, 571) -> Any: 572 """ 573 Return a quantized string of an input decimal. 574 575 Parameters 576 ---------- 577 x: Any 578 The potential decimal to be serialized. 579 580 quantize: bool, default False 581 If `True`, quantize the incoming Decimal to the specified scale and precision 582 before serialization. 583 584 precision: Optional[int], default None 585 The precision of the decimal to be quantized. 586 587 scale: Optional[int], default None 588 The scale of the decimal to be quantized. 589 590 Returns 591 ------- 592 A string of the input decimal or the input if not a Decimal. 593 """ 594 if not isinstance(x, Decimal): 595 return x 596 597 if value_is_null(x): 598 return None 599 600 if quantize and scale and precision: 601 x = quantize_decimal(x, precision, scale) 602 603 return f"{x:f}"
Return a quantized string of an input decimal.
Parameters
- x (Any): The potential decimal to be serialized.
- quantize (bool, default False):
If
True, quantize the incoming Decimal to the specified scale and precision before serialization. - precision (Optional[int], default None): The precision of the decimal to be quantized.
- scale (Optional[int], default None): The scale of the decimal to be quantized.
Returns
- A string of the input decimal or the input if not a Decimal.
606def coerce_timezone( 607 dt: Any, 608 strip_utc: bool = False, 609) -> Any: 610 """ 611 Given a `datetime`, pandas `Timestamp` or `Series` of `Timestamp`, 612 return a UTC timestamp (strip timezone if `strip_utc` is `True`. 613 """ 614 if dt is None: 615 return None 616 617 if isinstance(dt, int): 618 return dt 619 620 if isinstance(dt, str): 621 dateutil_parser = mrsm.attempt_import('dateutil.parser') 622 try: 623 dt = dateutil_parser.parse(dt) 624 except Exception: 625 return dt 626 627 dt_is_series = hasattr(dt, 'dtype') and hasattr(dt, '__module__') 628 if dt_is_series: 629 pandas = mrsm.attempt_import('pandas', lazy=False) 630 dt_timezone = getattr(getattr(dt, 'dt', None), 'tz', None) 631 632 if dt_timezone is not None: 633 utc_dt = dt if str(dt_timezone).lower() == 'utc' else dt.dt.tz_convert(timezone.utc) 634 return utc_dt.dt.tz_localize(None) if strip_utc else utc_dt 635 636 if ( 637 pandas.api.types.is_datetime64_any_dtype(dt) 638 and dt_timezone is None 639 and strip_utc 640 ): 641 return dt 642 643 dt_series = to_datetime(dt, coerce_utc=False) 644 if dt_series.dt.tz is None: 645 dt_series = dt_series.dt.tz_localize(timezone.utc) 646 if strip_utc: 647 try: 648 if dt_series.dt.tz is not None: 649 dt_series = dt_series.dt.tz_localize(None) 650 except Exception: 651 pass 652 653 return dt_series 654 655 if dt.tzinfo is None: 656 if strip_utc: 657 return dt 658 return dt.replace(tzinfo=timezone.utc) 659 660 utc_dt = dt.astimezone(timezone.utc) 661 if strip_utc: 662 return utc_dt.replace(tzinfo=None) 663 return utc_dt
Given a datetime, pandas Timestamp or Series of Timestamp,
return a UTC timestamp (strip timezone if strip_utc is True.
666def to_datetime( 667 dt_val: Any, 668 as_pydatetime: bool = False, 669 coerce_utc: bool = True, 670 precision_unit: Optional[str] = None, 671) -> Any: 672 """ 673 Wrap `pd.to_datetime()` and add support for out-of-bounds values. 674 675 Parameters 676 ---------- 677 dt_val: Any 678 The value to coerce to Pandas Timestamps. 679 680 as_pydatetime: bool, default False 681 If `True`, return a Python datetime object. 682 683 coerce_utc: bool, default True 684 If `True`, ensure the value has UTC tzinfo. 685 686 precision_unit: Optional[str], default None 687 If provided, enforce the provided precision unit. 688 """ 689 pandas, dateutil_parser = mrsm.attempt_import('pandas', 'dateutil.parser', lazy=False) 690 dt_is_series = hasattr(dt_val, 'dtype') and hasattr(dt_val, '__module__') 691 enforce_precision = precision_unit is not None 692 precision_unit = precision_unit or 'microsecond' 693 true_precision_unit = MRSM_PRECISION_UNITS_ALIASES.get(precision_unit, precision_unit) 694 precision_abbreviation = MRSM_PRECISION_UNITS_ABBREVIATIONS.get(true_precision_unit, None) 695 if not precision_abbreviation: 696 raise ValueError(f"Invalid precision '{precision_unit}'.") 697 698 def parse(x: Any) -> Any: 699 try: 700 return dateutil_parser.parse(x) 701 except Exception: 702 return x 703 704 def check_dtype(dtype_to_check: str, with_utc: bool = True) -> bool: 705 dtype_check_against = ( 706 f"datetime64[{precision_abbreviation}, UTC]" 707 if with_utc 708 else f"datetime64[{precision_abbreviation}]" 709 ) 710 return ( 711 dtype_to_check == dtype_check_against 712 if enforce_precision 713 else ( 714 dtype_to_check.startswith('datetime64[') 715 and ( 716 ('utc' in dtype_to_check.lower()) 717 if with_utc 718 else ('utc' not in dtype_to_check.lower()) 719 ) 720 ) 721 ) 722 723 if isinstance(dt_val, pandas.Timestamp): 724 dt_val_to_return = dt_val if not as_pydatetime else dt_val.to_pydatetime() 725 return ( 726 coerce_timezone(dt_val_to_return) 727 if coerce_utc 728 else dt_val_to_return 729 ) 730 731 if dt_is_series: 732 changed_tz = False 733 original_tz = None 734 dtype = str(getattr(dt_val, 'dtype', 'object')) 735 if ( 736 are_dtypes_equal(dtype, 'datetime') 737 and 'utc' not in dtype.lower() 738 and hasattr(dt_val, 'dt') 739 ): 740 original_tz = dt_val.dt.tz 741 dt_val = dt_val.dt.tz_localize(timezone.utc) 742 changed_tz = True 743 dtype = str(getattr(dt_val, 'dtype', 'object')) 744 try: 745 new_dt_series = ( 746 dt_val 747 if check_dtype(dtype, with_utc=True) 748 else dt_val.astype(f"datetime64[{precision_abbreviation}, UTC]") 749 ) 750 except pandas.errors.OutOfBoundsDatetime: 751 try: 752 next_precision = get_next_precision_unit(true_precision_unit) 753 next_precision_abbrevation = MRSM_PRECISION_UNITS_ABBREVIATIONS[next_precision] 754 new_dt_series = dt_val.astype(f"datetime64[{next_precision_abbrevation}, UTC]") 755 except Exception: 756 new_dt_series = None 757 except ValueError: 758 new_dt_series = None 759 except TypeError: 760 try: 761 new_dt_series = ( 762 new_dt_series 763 if check_dtype(str(getattr(new_dt_series, 'dtype', None)), with_utc=False) 764 else dt_val.astype(f"datetime64[{precision_abbreviation}]") 765 ) 766 except Exception: 767 new_dt_series = None 768 769 if new_dt_series is None: 770 new_dt_series = dt_val.apply(lambda x: parse(str(x))) 771 772 if coerce_utc: 773 return coerce_timezone(new_dt_series) 774 775 if changed_tz: 776 new_dt_series = new_dt_series.dt.tz_localize(original_tz) 777 return new_dt_series 778 779 try: 780 new_dt_val = pandas.to_datetime(dt_val, utc=True, format='ISO8601') 781 if new_dt_val.unit != precision_abbreviation: 782 new_dt_val = new_dt_val.as_unit(precision_abbreviation) 783 if as_pydatetime: 784 return new_dt_val.to_pydatetime() 785 return new_dt_val 786 except (pandas.errors.OutOfBoundsDatetime, ValueError): 787 pass 788 789 new_dt_val = parse(dt_val) 790 if not coerce_utc: 791 return new_dt_val 792 return coerce_timezone(new_dt_val)
Wrap pd.to_datetime() and add support for out-of-bounds values.
Parameters
- dt_val (Any): The value to coerce to Pandas Timestamps.
- as_pydatetime (bool, default False):
If
True, return a Python datetime object. - coerce_utc (bool, default True):
If
True, ensure the value has UTC tzinfo. - precision_unit (Optional[str], default None): If provided, enforce the provided precision unit.
795def serialize_bytes(data: bytes) -> str: 796 """ 797 Return the given bytes as a base64-encoded string. 798 """ 799 import base64 800 if not isinstance(data, bytes) and value_is_null(data): 801 return data 802 return base64.b64encode(data).decode('utf-8')
Return the given bytes as a base64-encoded string.
805def serialize_geometry( 806 geom: Any, 807 geometry_format: str = 'wkb_hex', 808 srid: Optional[int] = None, 809) -> Union[str, Dict[str, Any], bytes, None]: 810 """ 811 Serialize geometry data as WKB, WKB (hex), GPKG-WKB, WKT, or GeoJSON. 812 813 Parameters 814 ---------- 815 geom: Any 816 The potential geometry data to be serialized. 817 818 geometry_format: str, default 'wkb_hex' 819 The serialization format for geometry data. 820 Accepted formats are `wkb`, `wkb_hex`, `wkt`, `geojson`, and `gpkg_wkb`. 821 822 srid: Optional[int], default None 823 If provided, use this as the source CRS when serializing to GeoJSON. 824 825 Returns 826 ------- 827 A string containing the geometry data, or bytes, or a dictionary, or None. 828 """ 829 if value_is_null(geom): 830 return None 831 832 shapely, shapely_ops, pyproj, np = mrsm.attempt_import( 833 'shapely', 'shapely.ops', 'pyproj', 'numpy', 834 lazy=False, 835 ) 836 if geometry_format == 'geojson': 837 if srid: 838 transformer = pyproj.Transformer.from_crs(f"EPSG:{srid}", "EPSG:4326", always_xy=True) 839 geom = shapely_ops.transform(transformer.transform, geom) 840 geojson_str = shapely.to_geojson(geom) 841 return json.loads(geojson_str) 842 843 if not hasattr(geom, 'wkb_hex'): 844 return str(geom) 845 846 byte_order = 1 if np.little_endian else 0 847 848 if geometry_format.startswith("wkb"): 849 return shapely.to_wkb(geom, hex=(geometry_format=="wkb_hex"), include_srid=True) 850 851 if geometry_format == 'gpkg_wkb': 852 wkb_data = shapely.to_wkb(geom, hex=False, byte_order=byte_order) 853 flags = ( 854 ((byte_order & 0x01) | (0x20)) 855 if geom.is_empty 856 else (byte_order & 0x01) 857 ) 858 srid_val = srid or -1 859 header = struct.pack( 860 '<ccBBi', 861 b'G', b'P', 862 0, 863 flags, 864 srid_val 865 ) 866 return header + wkb_data 867 868 return shapely.to_wkt(geom)
Serialize geometry data as WKB, WKB (hex), GPKG-WKB, WKT, or GeoJSON.
Parameters
- geom (Any): The potential geometry data to be serialized.
- geometry_format (str, default 'wkb_hex'):
The serialization format for geometry data.
Accepted formats are
wkb,wkb_hex,wkt,geojson, andgpkg_wkb. - srid (Optional[int], default None): If provided, use this as the source CRS when serializing to GeoJSON.
Returns
- A string containing the geometry data, or bytes, or a dictionary, or None.
871def deserialize_geometry(geom_wkb: Union[str, bytes]): 872 """ 873 Deserialize a WKB string into a shapely geometry object. 874 """ 875 shapely = mrsm.attempt_import('shapely', lazy=False) 876 return shapely.wkb.loads(geom_wkb)
Deserialize a WKB string into a shapely geometry object.
879def project_geometry(geom, srid: int, to_srid: int = 4326): 880 """ 881 Project a shapely geometry object to a new CRS (SRID). 882 """ 883 pyproj, shapely_ops = mrsm.attempt_import('pyproj', 'shapely.ops', lazy=False) 884 transformer = pyproj.Transformer.from_crs(f"EPSG:{srid}", f"EPSG:{to_srid}", always_xy=True) 885 return shapely_ops.transform(transformer.transform, geom)
Project a shapely geometry object to a new CRS (SRID).
888def deserialize_bytes_string(data: Optional[str], force_hex: bool = False) -> Union[bytes, None]: 889 """ 890 Given a serialized ASCII string of bytes data, return the original bytes. 891 The input data may either be base64- or hex-encoded. 892 893 Parameters 894 ---------- 895 data: Optional[str] 896 The string to be deserialized into bytes. 897 May be base64- or hex-encoded (prefixed with `'\\x'`). 898 899 force_hex: bool = False 900 If `True`, treat the input string as hex-encoded. 901 If `data` does not begin with the prefix `'\\x'`, set `force_hex` to `True`. 902 This will still strip the leading `'\\x'` prefix if present. 903 904 Returns 905 ------- 906 The original bytes used to produce the encoded string `data`. 907 """ 908 if not isinstance(data, str) and value_is_null(data): 909 return data 910 911 import binascii 912 import base64 913 914 is_hex = force_hex or data.startswith('\\x') 915 916 if is_hex: 917 if data.startswith('\\x'): 918 data = data[2:] 919 return binascii.unhexlify(data) 920 921 return base64.b64decode(data)
Given a serialized ASCII string of bytes data, return the original bytes. The input data may either be base64- or hex-encoded.
Parameters
- data (Optional[str]):
The string to be deserialized into bytes.
May be base64- or hex-encoded (prefixed with
'\x'). - force_hex (bool = False):
If
True, treat the input string as hex-encoded. Ifdatadoes not begin with the prefix'\x', setforce_hextoTrue. This will still strip the leading'\x'prefix if present.
Returns
- The original bytes used to produce the encoded string
data.
924def deserialize_base64(data: str) -> bytes: 925 """ 926 Return the original bytestring from the given base64-encoded string. 927 """ 928 import base64 929 return base64.b64decode(data)
Return the original bytestring from the given base64-encoded string.
932def encode_bytes_for_bytea(data: bytes, with_prefix: bool = True) -> Union[str, None]: 933 """ 934 Return the given bytes as a hex string for PostgreSQL's `BYTEA` type. 935 """ 936 import binascii 937 if not isinstance(data, bytes) and value_is_null(data): 938 return data 939 return ('\\x' if with_prefix else '') + binascii.hexlify(data).decode('utf-8')
Return the given bytes as a hex string for PostgreSQL's BYTEA type.
942def serialize_datetime(dt: datetime) -> Union[str, None]: 943 """ 944 Serialize a datetime object into JSON (ISO format string). 945 946 Examples 947 -------- 948 >>> import json 949 >>> from datetime import datetime 950 >>> json.dumps({'a': datetime(2022, 1, 1)}, default=json_serialize_datetime) 951 '{"a": "2022-01-01T00:00:00Z"}' 952 953 """ 954 if not hasattr(dt, 'isoformat'): 955 return None 956 957 tz_suffix = 'Z' if getattr(dt, 'tzinfo', None) is None else '' 958 return dt.isoformat() + tz_suffix
Serialize a datetime object into JSON (ISO format string).
Examples
>>> import json
>>> from datetime import datetime
>>> json.dumps({'a': datetime(2022, 1, 1)}, default=json_serialize_datetime)
'{"a": "2022-01-01T00:00:00Z"}'
961def serialize_date(d: date) -> Union[str, None]: 962 """ 963 Serialize a date object into its ISO representation. 964 """ 965 return d.isoformat() if hasattr(d, 'isoformat') else None
Serialize a date object into its ISO representation.
968def json_serialize_value(x: Any, default_to_str: bool = True) -> Union[str, None]: 969 """ 970 Serialize the given value to a JSON value. Accounts for datetimes, bytes, decimals, etc. 971 972 Parameters 973 ---------- 974 x: Any 975 The value to serialize. 976 977 default_to_str: bool, default True 978 If `True`, return a string of `x` if x is not a designated type. 979 Otherwise return x. 980 981 Returns 982 ------- 983 A serialized version of x, or x. 984 """ 985 if isinstance(x, (mrsm.Pipe, mrsm.connectors.Connector)): 986 return x.meta 987 988 if hasattr(x, 'tzinfo'): 989 return serialize_datetime(x) 990 991 if hasattr(x, 'isoformat'): 992 return serialize_date(x) 993 994 if isinstance(x, bytes): 995 return serialize_bytes(x) 996 997 if isinstance(x, Decimal): 998 return serialize_decimal(x) 999 1000 if 'shapely' in str(type(x)): 1001 return serialize_geometry(x) 1002 1003 if value_is_null(x): 1004 return None 1005 1006 if isinstance(x, (dict, list, tuple)): 1007 return json.dumps(x, default=json_serialize_value, separators=(',', ':')) 1008 1009 return str(x) if default_to_str else x
Serialize the given value to a JSON value. Accounts for datetimes, bytes, decimals, etc.
Parameters
- x (Any): The value to serialize.
- default_to_str (bool, default True):
If
True, return a string ofxif x is not a designated type. Otherwise return x.
Returns
- A serialized version of x, or x.
1012def get_geometry_type_srid( 1013 dtype: str = 'geometry', 1014 default_type: str = 'geometry', 1015 default_srid: Union[int, str] = 0, 1016) -> Tuple[str, Union[int, str, None]]: 1017 """ 1018 Given the specified geometry `dtype`, return a tuple in the form (type, SRID). 1019 1020 Parameters 1021 ---------- 1022 dtype: Optional[str], default None 1023 Optionally provide a specific `geometry` syntax (e.g. `geometry[MultiLineString, 4326]`). 1024 You may specify a supported `shapely` geometry type and an SRID in the dtype modifier: 1025 1026 - `Point` 1027 - `LineString` 1028 - `LinearRing` 1029 - `Polygon` 1030 - `MultiPoint` 1031 - `MultiLineString` 1032 - `MultiPolygon` 1033 - `GeometryCollection` 1034 1035 Returns 1036 ------- 1037 A tuple in the form (type, SRID). 1038 Defaults to `(default_type, default_srid)`. 1039 1040 Examples 1041 -------- 1042 >>> from meerschaum.utils.dtypes import get_geometry_type_srid 1043 >>> get_geometry_type_srid() 1044 ('geometry', 4326) 1045 >>> get_geometry_type_srid('geometry[]') 1046 ('geometry', 4326) 1047 >>> get_geometry_type_srid('geometry[Point, 0]') 1048 ('Point', 0) 1049 >>> get_geometry_type_srid('geometry[0, Point]') 1050 ('Point', 0) 1051 >>> get_geometry_type_srid('geometry[0]') 1052 ('geometry', 0) 1053 >>> get_geometry_type_srid('geometry[MULTILINESTRING, 4326]') 1054 ('MultiLineString', 4326) 1055 >>> get_geometry_type_srid('geography') 1056 ('geometry', 0) 1057 >>> get_geometry_type_srid('geography[POINT]') 1058 ('Point', 0) 1059 >>> get_geometry_type_srid('geometry[POINT, ESRI:102003]') 1060 ('Point', 'ESRI:102003') 1061 """ 1062 from meerschaum.utils.misc import is_int 1063 ### NOTE: PostGIS syntax must also be parsed. 1064 dtype = dtype.replace('(', '[').replace(')', ']') 1065 bare_dtype = dtype.split('[', maxsplit=1)[0] 1066 modifier = dtype.split(bare_dtype, maxsplit=1)[-1].lstrip('[').rstrip(']') 1067 if not modifier: 1068 return default_type, default_srid 1069 1070 parts = [ 1071 part.split('=')[-1].strip() 1072 for part in modifier.split(',') 1073 ] 1074 parts_casted = [ 1075 ( 1076 int(part) 1077 if is_int(part) 1078 else part 1079 ) 1080 for part in parts 1081 ] 1082 1083 srid = default_srid 1084 geometry_type = default_type 1085 1086 for part in parts_casted: 1087 if isinstance(part, int) or ':' in str(part): 1088 srid = part 1089 break 1090 1091 for part in parts_casted: 1092 if isinstance(part, str) and part != srid: 1093 geometry_type = part 1094 break 1095 1096 return geometry_type, srid
Given the specified geometry dtype, return a tuple in the form (type, SRID).
Parameters
dtype (Optional[str], default None): Optionally provide a specific
geometrysyntax (e.g.geometry[MultiLineString, 4326]). You may specify a supportedshapelygeometry type and an SRID in the dtype modifier:PointLineStringLinearRingPolygonMultiPointMultiLineStringMultiPolygonGeometryCollection
Returns
- A tuple in the form (type, SRID).
- Defaults to
(default_type, default_srid).
Examples
>>> from meerschaum.utils.dtypes import get_geometry_type_srid
>>> get_geometry_type_srid()
('geometry', 4326)
>>> get_geometry_type_srid('geometry[]')
('geometry', 4326)
>>> get_geometry_type_srid('geometry[Point, 0]')
('Point', 0)
>>> get_geometry_type_srid('geometry[0, Point]')
('Point', 0)
>>> get_geometry_type_srid('geometry[0]')
('geometry', 0)
>>> get_geometry_type_srid('geometry[MULTILINESTRING, 4326]')
('MultiLineString', 4326)
>>> get_geometry_type_srid('geography')
('geometry', 0)
>>> get_geometry_type_srid('geography[POINT]')
('Point', 0)
>>> get_geometry_type_srid('geometry[POINT, ESRI:102003]')
('Point', 'ESRI:102003')
1099def datetime_to_int( 1100 dt: 'Union[datetime, int]', 1101 precision_unit: str = 'microsecond', 1102) -> int: 1103 """ 1104 Convert a timezone-aware (or naive UTC) `datetime` into an integer epoch value 1105 at the given precision unit. 1106 1107 Parameters 1108 ---------- 1109 dt: Union[datetime, int] 1110 The datetime to convert. Naive datetimes are assumed to be UTC. 1111 Integers are returned as-is. 1112 1113 precision_unit: str, default 'microsecond' 1114 The precision of the epoch value (e.g. `'millisecond'`, `'second'`). 1115 Aliases (`'ms'`, `'s'`, etc.) are accepted. 1116 1117 Returns 1118 ------- 1119 An integer epoch value at the requested precision. 1120 1121 Examples 1122 -------- 1123 >>> from datetime import datetime, timezone 1124 >>> datetime_to_int(datetime(2026, 5, 30, tzinfo=timezone.utc), 'millisecond') 1125 1779840000000 1126 """ 1127 if isinstance(dt, int): 1128 return dt 1129 1130 true_precision_unit = MRSM_PRECISION_UNITS_ALIASES.get(precision_unit, precision_unit) 1131 if true_precision_unit not in MRSM_PRECISION_UNITS_SCALARS: 1132 from meerschaum.utils.misc import items_str 1133 raise ValueError( 1134 f"Unknown precision unit '{precision_unit}'. " 1135 "Accepted values are " 1136 f"{items_str(list(MRSM_PRECISION_UNITS_SCALARS) + list(MRSM_PRECISION_UNITS_ALIASES))}." 1137 ) 1138 1139 dt = coerce_timezone(dt) 1140 return int(dt.timestamp() * MRSM_PRECISION_UNITS_SCALARS[true_precision_unit])
Convert a timezone-aware (or naive UTC) datetime into an integer epoch value
at the given precision unit.
Parameters
- dt (Union[datetime, int]): The datetime to convert. Naive datetimes are assumed to be UTC. Integers are returned as-is.
- precision_unit (str, default 'microsecond'):
The precision of the epoch value (e.g.
'millisecond','second'). Aliases ('ms','s', etc.) are accepted.
Returns
- An integer epoch value at the requested precision.
Examples
>>> from datetime import datetime, timezone
>>> datetime_to_int(datetime(2026, 5, 30, tzinfo=timezone.utc), 'millisecond')
1779840000000
1143def get_current_timestamp( 1144 precision_unit: str = _STATIC_CONFIG['dtypes']['datetime']['default_precision_unit'], 1145 precision_interval: int = 1, 1146 round_to: str = 'down', 1147 as_pandas: bool = False, 1148 as_int: bool = False, 1149 unit: str = _STATIC_CONFIG['dtypes']['datetime']['default_precision_unit'], 1150 interval: int = 1, 1151 _now: Union[datetime, int, None] = None, 1152) -> 'Union[datetime, pd.Timestamp, int]': 1153 """ 1154 Return the current UTC timestamp to nanosecond precision. 1155 1156 Parameters 1157 ---------- 1158 precision_unit: str, default 'us' 1159 The precision of the timestamp to be returned. 1160 Valid values are the following: 1161 - `ns` / `nanosecond` 1162 - `us` / `microsecond` 1163 - `ms` / `millisecond` 1164 - `s` / `sec` / `second` 1165 - `m` / `min` / `minute` 1166 - `h` / `hr` / `hour` 1167 - `d` / `day` 1168 1169 precision_interval: int, default 1 1170 Round the timestamp to the `precision_interval` units. 1171 For example, `precision='minute'` and `precision_interval=15` will round to 15-minute intervals. 1172 Note: `precision_interval` must be 1 when `precision='nanosecond'`. 1173 1174 round_to: str, default 'down' 1175 The direction to which to round the timestamp. 1176 Available options are `down`, `up`, and `closest`. 1177 1178 as_pandas: bool, default False 1179 If `True`, return a Pandas Timestamp. 1180 This is always true if `unit` is `nanosecond`. 1181 1182 as_int: bool, default False 1183 If `True`, return the timestamp to an integer. 1184 Overrides `as_pandas`. 1185 1186 unit: str, default 'us' 1187 Alias for `precision_unit`. 1188 1189 interval: int, default 1 1190 Alias for `precision_interval`. 1191 1192 Returns 1193 ------- 1194 A Pandas Timestamp, datetime object, or integer with precision to the provided unit. 1195 1196 Examples 1197 -------- 1198 >>> get_current_timestamp('ns') 1199 Timestamp('2025-07-17 17:59:16.423644369+0000', tz='UTC') 1200 >>> get_current_timestamp('ms') 1201 Timestamp('2025-07-17 17:59:16.424000+0000', tz='UTC') 1202 """ 1203 default_unit = _STATIC_CONFIG['dtypes']['datetime']['default_precision_unit'] 1204 if unit != precision_unit and precision_unit == default_unit: 1205 precision_unit = unit 1206 1207 if interval != precision_interval and precision_interval == 1: 1208 precision_interval = interval 1209 1210 true_precision_unit = MRSM_PRECISION_UNITS_ALIASES.get(precision_unit, precision_unit) 1211 if true_precision_unit not in MRSM_PRECISION_UNITS_SCALARS: 1212 from meerschaum.utils.misc import items_str 1213 raise ValueError( 1214 f"Unknown precision unit '{precision_unit}'. " 1215 "Accepted values are " 1216 f"{items_str(list(MRSM_PRECISION_UNITS_SCALARS) + list(MRSM_PRECISION_UNITS_ALIASES))}." 1217 ) 1218 1219 if not as_int: 1220 as_pandas = as_pandas or true_precision_unit == 'nanosecond' 1221 pd = mrsm.attempt_import('pandas', lazy=False) if as_pandas else None 1222 1223 if true_precision_unit == 'nanosecond': 1224 if precision_interval != 1: 1225 warn("`precision_interval` must be 1 for nanosecond precision.") 1226 now_ts = time.time_ns() if not isinstance(_now, int) else _now 1227 if as_int: 1228 return now_ts 1229 return pd.to_datetime(now_ts, unit='ns', utc=True) 1230 1231 now = datetime.now(timezone.utc) if not isinstance(_now, datetime) else _now 1232 delta = timedelta(**{true_precision_unit + 's': precision_interval}) 1233 rounded_now = round_time(now, delta, to=round_to) 1234 1235 if as_int: 1236 return datetime_to_int(rounded_now, true_precision_unit) 1237 1238 ts_val = ( 1239 pd.to_datetime(rounded_now, utc=True) 1240 if as_pandas 1241 else rounded_now 1242 ) 1243 1244 if not as_pandas: 1245 return ts_val 1246 1247 as_unit_precisions = ('microsecond', 'millisecond', 'second') 1248 if true_precision_unit not in as_unit_precisions: 1249 return ts_val 1250 1251 return ts_val.as_unit(MRSM_PRECISION_UNITS_ABBREVIATIONS[true_precision_unit])
Return the current UTC timestamp to nanosecond precision.
Parameters
- precision_unit (str, default 'us'):
The precision of the timestamp to be returned.
Valid values are the following:
-
ns/nanosecond-us/microsecond-ms/millisecond-s/sec/second-m/min/minute-h/hr/hour-d/day - precision_interval (int, default 1):
Round the timestamp to the
precision_intervalunits. For example,precision='minute'andprecision_interval=15will round to 15-minute intervals. Note:precision_intervalmust be 1 whenprecision='nanosecond'. - round_to (str, default 'down'):
The direction to which to round the timestamp.
Available options are
down,up, andclosest. - as_pandas (bool, default False):
If
True, return a Pandas Timestamp. This is always true ifunitisnanosecond. - as_int (bool, default False):
If
True, return the timestamp to an integer. Overridesas_pandas. - unit (str, default 'us'):
Alias for
precision_unit. - interval (int, default 1):
Alias for
precision_interval.
Returns
- A Pandas Timestamp, datetime object, or integer with precision to the provided unit.
Examples
>>> get_current_timestamp('ns')
Timestamp('2025-07-17 17:59:16.423644369+0000', tz='UTC')
>>> get_current_timestamp('ms')
Timestamp('2025-07-17 17:59:16.424000+0000', tz='UTC')
1254def is_dtype_special(type_: str) -> bool: 1255 """ 1256 Return whether a dtype should be treated as a special Meerschaum dtype. 1257 This is not the same as a Meerschaum alias. 1258 """ 1259 true_type = MRSM_ALIAS_DTYPES.get(type_, type_) 1260 if true_type in ( 1261 'uuid', 1262 'json', 1263 'bytes', 1264 'numeric', 1265 'datetime', 1266 'geometry', 1267 'geography', 1268 'date', 1269 'bool', 1270 ): 1271 return True 1272 1273 if are_dtypes_equal(true_type, 'datetime'): 1274 return True 1275 1276 if are_dtypes_equal(true_type, 'date'): 1277 return True 1278 1279 if true_type.startswith('numeric'): 1280 return True 1281 1282 if true_type.startswith('bool'): 1283 return True 1284 1285 if true_type.startswith('geometry'): 1286 return True 1287 1288 if true_type.startswith('geography'): 1289 return True 1290 1291 return False
Return whether a dtype should be treated as a special Meerschaum dtype. This is not the same as a Meerschaum alias.
1294def get_next_precision_unit(precision_unit: str, decrease: bool = True) -> str: 1295 """ 1296 Get the next precision string in order of value. 1297 1298 Parameters 1299 ---------- 1300 precision_unit: str 1301 The precision string (`'nanosecond'`, `'ms'`, etc.). 1302 1303 decrease: bool, defaul True 1304 If `True` return the precision unit which is lower (e.g. `nanosecond` -> `millisecond`). 1305 If `False`, return the precision unit which is higher. 1306 1307 Returns 1308 ------- 1309 A `precision` string which is lower or higher than the given precision unit. 1310 1311 Examples 1312 -------- 1313 >>> get_next_precision_unit('nanosecond') 1314 'microsecond' 1315 >>> get_next_precision_unit('ms') 1316 'second' 1317 >>> get_next_precision_unit('hour', decrease=False) 1318 'minute' 1319 """ 1320 true_precision_unit = MRSM_PRECISION_UNITS_ALIASES.get(precision_unit, precision_unit) 1321 precision_scalar = MRSM_PRECISION_UNITS_SCALARS.get(true_precision_unit, None) 1322 if not precision_scalar: 1323 raise ValueError(f"Invalid precision unit '{precision_unit}'.") 1324 1325 precisions = sorted( 1326 list(MRSM_PRECISION_UNITS_SCALARS), 1327 key=lambda p: MRSM_PRECISION_UNITS_SCALARS[p] 1328 ) 1329 1330 precision_index = precisions.index(true_precision_unit) 1331 new_precision_index = precision_index + (-1 if decrease else 1) 1332 if new_precision_index < 0 or new_precision_index >= len(precisions): 1333 raise ValueError(f"No precision {'below' if decrease else 'above'} '{precision_unit}'.") 1334 1335 return precisions[new_precision_index]
Get the next precision string in order of value.
Parameters
- precision_unit (str):
The precision string (
'nanosecond','ms', etc.). - decrease (bool, defaul True):
If
Truereturn the precision unit which is lower (e.g.nanosecond->millisecond). IfFalse, return the precision unit which is higher.
Returns
- A
precisionstring which is lower or higher than the given precision unit.
Examples
>>> get_next_precision_unit('nanosecond')
'microsecond'
>>> get_next_precision_unit('ms')
'second'
>>> get_next_precision_unit('hour', decrease=False)
'minute'
1338def round_time( 1339 dt: Optional[datetime] = None, 1340 date_delta: Optional[timedelta] = None, 1341 to: 'str' = 'down' 1342) -> datetime: 1343 """ 1344 Round a datetime object to a multiple of a timedelta. 1345 1346 Parameters 1347 ---------- 1348 dt: Optional[datetime], default None 1349 If `None`, grab the current UTC datetime. 1350 1351 date_delta: Optional[timedelta], default None 1352 If `None`, use a delta of 1 minute. 1353 1354 to: 'str', default 'down' 1355 Available options are `'up'`, `'down'`, and `'closest'`. 1356 1357 Returns 1358 ------- 1359 A rounded `datetime` object. 1360 1361 Examples 1362 -------- 1363 >>> round_time(datetime(2022, 1, 1, 12, 15, 57, 200)) 1364 datetime.datetime(2022, 1, 1, 12, 15) 1365 >>> round_time(datetime(2022, 1, 1, 12, 15, 57, 200), to='up') 1366 datetime.datetime(2022, 1, 1, 12, 16) 1367 >>> round_time(datetime(2022, 1, 1, 12, 15, 57, 200), timedelta(hours=1)) 1368 datetime.datetime(2022, 1, 1, 12, 0) 1369 >>> round_time( 1370 ... datetime(2022, 1, 1, 12, 15, 57, 200), 1371 ... timedelta(hours=1), 1372 ... to = 'closest' 1373 ... ) 1374 datetime.datetime(2022, 1, 1, 12, 0) 1375 >>> round_time( 1376 ... datetime(2022, 1, 1, 12, 45, 57, 200), 1377 ... datetime.timedelta(hours=1), 1378 ... to = 'closest' 1379 ... ) 1380 datetime.datetime(2022, 1, 1, 13, 0) 1381 1382 """ 1383 from decimal import Decimal, ROUND_HALF_UP, ROUND_DOWN, ROUND_UP 1384 if date_delta is None: 1385 date_delta = timedelta(minutes=1) 1386 1387 if dt is None: 1388 dt = datetime.now(timezone.utc).replace(tzinfo=None) 1389 1390 def get_total_microseconds(td: timedelta) -> int: 1391 return (td.days * 86400 + td.seconds) * 1_000_000 + td.microseconds 1392 1393 round_to_microseconds = get_total_microseconds(date_delta) 1394 if round_to_microseconds == 0: 1395 return dt 1396 1397 dt_delta_from_min = dt.replace(tzinfo=None) - datetime.min 1398 dt_total_microseconds = get_total_microseconds(dt_delta_from_min) 1399 1400 dt_dec = Decimal(dt_total_microseconds) 1401 round_to_dec = Decimal(round_to_microseconds) 1402 1403 div = dt_dec / round_to_dec 1404 if to == 'down': 1405 num_intervals = div.to_integral_value(rounding=ROUND_DOWN) 1406 elif to == 'up': 1407 num_intervals = div.to_integral_value(rounding=ROUND_UP) 1408 else: 1409 num_intervals = div.to_integral_value(rounding=ROUND_HALF_UP) 1410 1411 rounded_dt_total_microseconds = num_intervals * round_to_dec 1412 adjustment_microseconds = int(rounded_dt_total_microseconds) - dt_total_microseconds 1413 1414 return dt + timedelta(microseconds=adjustment_microseconds)
Round a datetime object to a multiple of a timedelta.
Parameters
- dt (Optional[datetime], default None):
If
None, grab the current UTC datetime. - date_delta (Optional[timedelta], default None):
If
None, use a delta of 1 minute. - to ('str', default 'down'):
Available options are
'up','down', and'closest'.
Returns
- A rounded
datetimeobject.
Examples
>>> round_time(datetime(2022, 1, 1, 12, 15, 57, 200))
datetime.datetime(2022, 1, 1, 12, 15)
>>> round_time(datetime(2022, 1, 1, 12, 15, 57, 200), to='up')
datetime.datetime(2022, 1, 1, 12, 16)
>>> round_time(datetime(2022, 1, 1, 12, 15, 57, 200), timedelta(hours=1))
datetime.datetime(2022, 1, 1, 12, 0)
>>> round_time(
... datetime(2022, 1, 1, 12, 15, 57, 200),
... timedelta(hours=1),
... to = 'closest'
... )
datetime.datetime(2022, 1, 1, 12, 0)
>>> round_time(
... datetime(2022, 1, 1, 12, 45, 57, 200),
... datetime.timedelta(hours=1),
... to = 'closest'
... )
datetime.datetime(2022, 1, 1, 13, 0)