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)
MRSM_ALIAS_DTYPES: Dict[str, str] = {'decimal': 'numeric', 'Decimal': 'numeric', 'number': 'numeric', 'jsonl': 'json', 'JSON': 'json', 'binary': 'bytes', 'blob': 'bytes', 'varbinary': 'bytes', 'bytea': 'bytes', 'guid': 'uuid', 'UUID': 'uuid', 'geom': 'geometry', 'geog': 'geography', 'boolean': 'bool', 'day': 'date'}
MRSM_PD_DTYPES: Dict[Optional[str], str] = {'json': 'object', 'numeric': 'object', 'geometry': 'object', 'geography': 'object', 'uuid': 'object', 'date': 'date32[day][pyarrow]', 'datetime': 'datetime64[us, UTC]', 'bool': 'bool[pyarrow]', 'int': 'int64[pyarrow]', 'int8': 'int8[pyarrow]', 'int16': 'int16[pyarrow]', 'int32': 'int32[pyarrow]', 'int64': 'int64[pyarrow]', 'str': 'string', 'bytes': 'binary[pyarrow]', None: 'object'}
MRSM_PRECISION_UNITS_SCALARS: Dict[str, Union[int, float]] = {'nanosecond': 1000000000, 'microsecond': 1000000, 'millisecond': 1000, 'second': 1, 'minute': 0.016666666666666666, 'hour': 0.0002777777777777778, 'day': 1.1574074074074073e-05}
MRSM_PRECISION_UNITS_ALIASES: Dict[str, str] = {'ns': 'nanosecond', 'us': 'microsecond', 'ms': 'millisecond', 's': 'second', 'sec': 'second', 'm': 'minute', 'min': 'minute', 'h': 'hour', 'hr': 'hour', 'd': 'day', 'D': 'day'}
MRSM_PRECISION_UNITS_ABBREVIATIONS: Dict[str, str] = {'nanosecond': 'ns', 'microsecond': 'us', 'millisecond': 'ms', 'second': 's', 'minute': 'min', 'hour': 'hr', 'day': 'D'}
def to_pandas_dtype(dtype: str) -> str:
 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.

def are_dtypes_equal( ldtype: Union[str, Dict[str, str]], rdtype: Union[str, Dict[str, str]]) -> bool:
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 bool indicating whether the two dtypes are to be considered equivalent.
def is_dtype_numeric(dtype: str) -> bool:
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.
def attempt_cast_to_numeric( value: Any, quantize: bool = False, precision: Optional[int] = None, scale: Optional[int] = None) -> Any:
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 quantize is True, use this precision.
  • scale (Optional[int], default None): If quantize is True, use this scale.
Returns
  • A Decimal if possible, or value.
def attempt_cast_to_uuid(value: Any) -> Any:
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).

def attempt_cast_to_bytes(value: Any) -> Any:
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.

def attempt_cast_to_geometry(value: Any) -> Any:
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.

def geometry_is_wkt(value: Union[str, bytes]) -> Optional[bool]:
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 (True if value is WKT and False if it should be treated as WKB).
  • Return None if value should be parsed as neither.
def geometry_is_gpkg(value: bytes) -> bool:
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.

def gpkg_wkb_to_wkb(gpkg_wkb_bytes: bytes) -> Tuple[bytes, int, bytes]:
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.
def value_is_null(value: Any) -> bool:
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.

def none_if_null(value: Any) -> Any:
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.

def quantize_decimal(x: decimal.Decimal, precision: int, scale: int) -> decimal.Decimal:
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 Decimal to be quantized.
  • precision (int): The total number of significant digits.
  • scale (int): The number of significant digits after the decimal point.
Returns
  • A Decimal quantized to the specified scale and precision.
def serialize_decimal( x: Any, quantize: bool = False, precision: Optional[int] = None, scale: Optional[int] = None) -> Any:
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.
def coerce_timezone(dt: Any, strip_utc: bool = False) -> Any:
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.

def to_datetime( dt_val: Any, as_pydatetime: bool = False, coerce_utc: bool = True, precision_unit: Optional[str] = None) -> Any:
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.
def serialize_bytes(data: bytes) -> str:
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.

def serialize_geometry( geom: Any, geometry_format: str = 'wkb_hex', srid: Optional[int] = None) -> Union[str, Dict[str, Any], bytes, NoneType]:
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, and gpkg_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.
def deserialize_geometry(geom_wkb: Union[str, bytes]):
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.

def project_geometry(geom, srid: int, to_srid: int = 4326):
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).

def deserialize_bytes_string(data: Optional[str], force_hex: bool = False) -> Optional[bytes]:
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. If data does not begin with the prefix '\x', set force_hex to True. This will still strip the leading '\x' prefix if present.
Returns
  • The original bytes used to produce the encoded string data.
def deserialize_base64(data: str) -> bytes:
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.

def encode_bytes_for_bytea(data: bytes, with_prefix: bool = True) -> Optional[str]:
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.

def serialize_datetime(dt: datetime.datetime) -> Optional[str]:
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"}'
def serialize_date(d: datetime.date) -> Optional[str]:
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.

def json_serialize_value(x: Any, default_to_str: bool = True) -> Optional[str]:
 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 of x if x is not a designated type. Otherwise return x.
Returns
  • A serialized version of x, or x.
def get_geometry_type_srid( dtype: str = 'geometry', default_type: str = 'geometry', default_srid: Union[int, str] = 0) -> Tuple[str, Union[int, str, NoneType]]:
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 geometry syntax (e.g. geometry[MultiLineString, 4326]). You may specify a supported shapely geometry type and an SRID in the dtype modifier:

    • Point
    • LineString
    • LinearRing
    • Polygon
    • MultiPoint
    • MultiLineString
    • MultiPolygon
    • GeometryCollection
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')
def datetime_to_int( dt: Union[datetime.datetime, int], precision_unit: str = 'microsecond') -> int:
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
def get_current_timestamp( precision_unit: str = 'microsecond', precision_interval: int = 1, round_to: str = 'down', as_pandas: bool = False, as_int: bool = False, unit: str = 'microsecond', interval: int = 1, _now: Union[datetime.datetime, int, NoneType] = None) -> 'Union[datetime, pd.Timestamp, int]':
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_interval units. For example, precision='minute' and precision_interval=15 will round to 15-minute intervals. Note: precision_interval must be 1 when precision='nanosecond'.
  • round_to (str, default 'down'): The direction to which to round the timestamp. Available options are down, up, and closest.
  • as_pandas (bool, default False): If True, return a Pandas Timestamp. This is always true if unit is nanosecond.
  • as_int (bool, default False): If True, return the timestamp to an integer. Overrides as_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')
def is_dtype_special(type_: str) -> bool:
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.

def get_next_precision_unit(precision_unit: str, decrease: bool = True) -> str:
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 True return the precision unit which is lower (e.g. nanosecond -> millisecond). If False, return the precision unit which is higher.
Returns
  • A precision string 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'
def round_time( dt: Optional[datetime.datetime] = None, date_delta: Optional[datetime.timedelta] = None, to: str = 'down') -> datetime.datetime:
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 datetime object.
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)