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

Cast a supported Meerschaum dtype to a Pandas dtype.

def are_dtypes_equal(ldtype: str | Dict[str, str], rdtype: str | Dict[str, str]) -> bool:
102def are_dtypes_equal(
103    ldtype: Union[str, Dict[str, str]],
104    rdtype: Union[str, Dict[str, str]],
105) -> bool:
106    """
107    Determine whether two dtype strings may be considered
108    equivalent to avoid unnecessary conversions.
109
110    Parameters
111    ----------
112    ldtype: Union[str, Dict[str, str]]
113        The left dtype to compare.
114        May also provide a dtypes dictionary.
115
116    rdtype: Union[str, Dict[str, str]]
117        The right dtype to compare.
118        May also provide a dtypes dictionary.
119
120    Returns
121    -------
122    A `bool` indicating whether the two dtypes are to be considered equivalent.
123    """
124    _rust = _core.are_dtypes_equal
125    return _rust(ldtype, rdtype=rdtype)

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:
128def is_dtype_numeric(dtype: str) -> bool:
129    """
130    Determine whether a given `dtype` string
131    should be considered compatible with the Meerschaum dtype `numeric`.
132
133    Parameters
134    ----------
135    dtype: str
136        The pandas-like dtype string.
137
138    Returns
139    -------
140    A bool indicating the dtype is compatible with `numeric`.
141    """
142    _rust = _core.is_dtype_numeric
143    return _rust(dtype)

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: int | None = None, scale: int | None = None) -> Any:
146def attempt_cast_to_numeric(
147    value: Any,
148    quantize: bool = False,
149    precision: Optional[int] = None,
150    scale: Optional[int] = None,
151)-> Any:
152    """
153    Given a value, attempt to coerce it into a numeric (Decimal).
154
155    Parameters
156    ----------
157    value: Any
158        The value to be cast to a Decimal.
159
160    quantize: bool, default False
161        If `True`, quantize the decimal to the specified precision and scale.
162
163    precision: Optional[int], default None
164        If `quantize` is `True`, use this precision.
165
166    scale: Optional[int], default None
167        If `quantize` is `True`, use this scale.
168
169    Returns
170    -------
171    A `Decimal` if possible, or `value`.
172    """
173    if isinstance(value, Decimal):
174        if quantize and precision and scale:
175            return quantize_decimal(value, precision, scale)
176        return value
177    try:
178        if value_is_null(value):
179            return Decimal('NaN')
180
181        dec = Decimal(str(value))
182        if not quantize or not precision or not scale:
183            return dec
184        return quantize_decimal(dec, precision, scale)
185    except Exception:
186        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:
189def attempt_cast_to_uuid(value: Any) -> Any:
190    """
191    Given a value, attempt to coerce it into a UUID (`uuid4`).
192    """
193    if isinstance(value, uuid.UUID):
194        return value
195    try:
196        return (
197            uuid.UUID(str(value))
198            if not value_is_null(value)
199            else None
200        )
201    except Exception:
202        return value

Given a value, attempt to coerce it into a UUID (uuid4).

def attempt_cast_to_bytes(value: Any) -> Any:
205def attempt_cast_to_bytes(value: Any) -> Any:
206    """
207    Given a value, attempt to coerce it into a bytestring.
208    """
209    if isinstance(value, bytes):
210        return value
211    try:
212        return (
213            deserialize_bytes_string(str(value))
214            if not value_is_null(value)
215            else None
216        )
217    except Exception:
218        return value

Given a value, attempt to coerce it into a bytestring.

def attempt_cast_to_geometry(value: Any) -> Any:
221def attempt_cast_to_geometry(value: Any) -> Any:
222    """
223    Given a value, attempt to coerce it into a `shapely` (`geometry`) object.
224    """
225    typ = str(type(value))
226    if 'pandas' in typ and 'Series' in typ:
227        if 'GeoSeries' in typ:
228            return value
229
230        gpd = mrsm.attempt_import('geopandas', lazy=False)
231        if len(value) == 0:
232            return gpd.GeoSeries([])
233
234        ix = value.first_valid_index()
235        if ix is None:
236            try:
237                return gpd.GeoSeries(value)
238            except Exception:
239                traceback.print_exc()
240                return gpd.GeoSeries(attempt_cast_to_geometry(val) for val in value)
241
242        sample_val = value[ix]
243        sample_typ = str(type(sample_val))
244        if 'shapely' in sample_typ:
245            try:
246                return gpd.GeoSeries(value)
247            except Exception:
248                traceback.print_exc()
249                return gpd.GeoSeries(attempt_cast_to_geometry(val) for val in value)
250
251        sample_is_gpkg = geometry_is_gpkg(sample_val)
252        if sample_is_gpkg:
253            try:
254                value = value.apply(lambda x: gpkg_wkb_to_wkb(x)[0])
255            except Exception:
256                traceback.print_exc()
257                return gpd.GeoSeries(attempt_cast_to_geometry(val) for val in value)
258
259        sample_is_wkt = geometry_is_wkt(sample_val) if not sample_is_gpkg else False
260        try:
261            return (
262                gpd.GeoSeries.from_wkt(value)
263                if sample_is_wkt
264                else gpd.GeoSeries.from_wkb(value)
265            )
266        except Exception:
267            traceback.print_exc()
268            return gpd.GeoSeries(attempt_cast_to_geometry(val) for val in value)
269
270    if 'shapely' in typ:
271        return value
272
273    shapely, shapely_wkt, shapely_wkb = mrsm.attempt_import(
274        'shapely',
275        'shapely.wkt',
276        'shapely.wkb',
277        lazy=False,
278    )
279
280    if isinstance(value, (dict, list)):
281        try:
282            return shapely.from_geojson(json.dumps(value))
283        except Exception:
284            return value
285
286    value_is_gpkg = geometry_is_gpkg(value)
287    if value_is_gpkg:
288        try:
289            wkb_data, _, _ = gpkg_wkb_to_wkb(value)
290            return shapely_wkb.loads(wkb_data)
291        except Exception:
292            return value
293
294    value_is_wkt = geometry_is_wkt(value)
295    if value_is_wkt is None:
296        return value
297
298    try:
299        return (
300            shapely_wkt.loads(value)
301            if value_is_wkt
302            else shapely_wkb.loads(value)
303        )
304    except Exception:
305        pass
306
307    return value

Given a value, attempt to coerce it into a shapely (geometry) object.

def geometry_is_wkt(value: str | bytes) -> bool | None:
310def geometry_is_wkt(value: Union[str, bytes]) -> Union[bool, None]:
311    """
312    Determine whether an input value should be treated as WKT or WKB geometry data.
313
314    Parameters
315    ----------
316    value: Union[str, bytes]
317        The input data to be parsed into geometry data.
318
319    Returns
320    -------
321    A `bool` (`True` if `value` is WKT and `False` if it should be treated as WKB).
322    Return `None` if `value` should be parsed as neither.
323    """
324    import re
325    if not isinstance(value, (str, bytes)):
326        return None
327
328    if isinstance(value, bytes):
329        return False
330    
331    wkt_pattern = r'^\s*(POINT|LINESTRING|POLYGON|MULTIPOINT|MULTILINESTRING|MULTIPOLYGON|GEOMETRYCOLLECTION)\s*\(.*\)\s*$'
332    if re.match(wkt_pattern, value, re.IGNORECASE):
333        return True
334    
335    if all(c in '0123456789ABCDEFabcdef' for c in value) and len(value) % 2 == 0:
336        return False
337    
338    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:
341def geometry_is_gpkg(value: bytes) -> bool:
342    """
343    Return whether the input `value` is formatted as GeoPackage WKB.
344    """
345    if not isinstance(value, bytes) or len(value) < 2:
346        return False
347
348    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]:
350def gpkg_wkb_to_wkb(gpkg_wkb_bytes: bytes) -> Tuple[bytes, int, bytes]:
351    """
352    Converts GeoPackage WKB to standard WKB by removing the header.
353    
354    Parameters
355    ----------
356    gpkg_wkb_bytes: bytes
357        The GeoPackage WKB byte string.
358        
359    Returns
360    -------
361    A tuple containing the standard WKB bytes, SRID, and flags.
362    """
363    magic_number = gpkg_wkb_bytes[0:2]
364    if magic_number != b'GP':
365        raise ValueError("Invalid GeoPackage WKB header: missing magic number.")
366    
367    try:
368        header = gpkg_wkb_bytes[0:8]
369        header_vals = struct.unpack('<ccBBi', header)
370        flags = header_vals[-2]
371        srid = header_vals[-1]
372    except struct.error:
373        header = gpkg_wkb_bytes[0:6]
374        header_vals = struct.unpack('<ccBBh', header)
375        flags = header_vals[-2]
376        srid = header_vals[-1]
377
378    envelope_type = (flags >> 1) & 0x07
379    envelope_sizes = {
380        0: 0,
381        1: 32,
382        2: 48,
383        3: 48,
384        4: 64,
385    }
386    header_length = 8 + envelope_sizes.get(envelope_type, 0)
387    standard_wkb_bytes = gpkg_wkb_bytes[header_length:]
388    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:
391def value_is_null(value: Any) -> bool:
392    """
393    Determine if a value is a null-like string.
394    """
395    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:
398def none_if_null(value: Any) -> Any:
399    """
400    Return `None` if a value is a null-like string.
401    """
402    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:
405def quantize_decimal(x: Decimal, precision: int, scale: int) -> Decimal:
406    """
407    Quantize a given `Decimal` to a known scale and precision.
408
409    Parameters
410    ----------
411    x: Decimal
412        The `Decimal` to be quantized.
413
414    precision: int
415        The total number of significant digits.
416
417    scale: int
418        The number of significant digits after the decimal point.
419
420    Returns
421    -------
422    A `Decimal` quantized to the specified scale and precision.
423    """
424    precision_decimal = Decimal(('1' * (precision - scale)) + '.' + ('1' * scale))
425    try:
426        return x.quantize(precision_decimal, context=Context(prec=precision), rounding=ROUND_HALF_UP)
427    except InvalidOperation:
428        pass
429
430    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: int | None = None, scale: int | None = None) -> Any:
433def serialize_decimal(
434    x: Any,
435    quantize: bool = False,
436    precision: Optional[int] = None,
437    scale: Optional[int] = None,
438) -> Any:
439    """
440    Return a quantized string of an input decimal.
441
442    Parameters
443    ----------
444    x: Any
445        The potential decimal to be serialized.
446
447    quantize: bool, default False
448        If `True`, quantize the incoming Decimal to the specified scale and precision
449        before serialization.
450
451    precision: Optional[int], default None
452        The precision of the decimal to be quantized.
453
454    scale: Optional[int], default None
455        The scale of the decimal to be quantized.
456
457    Returns
458    -------
459    A string of the input decimal or the input if not a Decimal.
460    """
461    if not isinstance(x, Decimal):
462        return x
463
464    if value_is_null(x):
465        return None
466
467    if quantize and scale and precision:
468        x = quantize_decimal(x, precision, scale)
469
470    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:
473def coerce_timezone(
474    dt: Any,
475    strip_utc: bool = False,
476) -> Any:
477    """
478    Given a `datetime`, pandas `Timestamp` or `Series` of `Timestamp`,
479    return a UTC timestamp (strip timezone if `strip_utc` is `True`.
480    """
481    if dt is None:
482        return None
483
484    if isinstance(dt, int):
485        return dt
486
487    if isinstance(dt, str):
488        dateutil_parser = mrsm.attempt_import('dateutil.parser')
489        try:
490            dt = dateutil_parser.parse(dt)
491        except Exception:
492            return dt
493
494    dt_is_series = hasattr(dt, 'dtype') and hasattr(dt, '__module__')
495    if dt_is_series:
496        pandas = mrsm.attempt_import('pandas', lazy=False)
497        dt_timezone = getattr(getattr(dt, 'dt', None), 'tz', None)
498
499        if dt_timezone is not None:
500            utc_dt = dt if str(dt_timezone).lower() == 'utc' else dt.dt.tz_convert(timezone.utc)
501            return utc_dt.dt.tz_localize(None) if strip_utc else utc_dt
502
503        if (
504            pandas.api.types.is_datetime64_any_dtype(dt)
505            and dt_timezone is None
506            and strip_utc
507        ):
508            return dt
509
510        dt_series = to_datetime(dt, coerce_utc=False)
511        if dt_series.dt.tz is None:
512            dt_series = dt_series.dt.tz_localize(timezone.utc)
513        if strip_utc:
514            try:
515                if dt_series.dt.tz is not None:
516                    dt_series = dt_series.dt.tz_localize(None)
517            except Exception:
518                pass
519
520        return dt_series
521
522    if dt.tzinfo is None:
523        if strip_utc:
524            return dt
525        return dt.replace(tzinfo=timezone.utc)
526
527    utc_dt = dt.astimezone(timezone.utc)
528    if strip_utc:
529        return utc_dt.replace(tzinfo=None)
530    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: str | None = None) -> Any:
533def to_datetime(
534    dt_val: Any,
535    as_pydatetime: bool = False,
536    coerce_utc: bool = True,
537    precision_unit: Optional[str] = None,
538) -> Any:
539    """
540    Wrap `pd.to_datetime()` and add support for out-of-bounds values.
541
542    Parameters
543    ----------
544    dt_val: Any
545        The value to coerce to Pandas Timestamps.
546
547    as_pydatetime: bool, default False
548        If `True`, return a Python datetime object.
549
550    coerce_utc: bool, default True
551        If `True`, ensure the value has UTC tzinfo.
552
553    precision_unit: Optional[str], default None
554        If provided, enforce the provided precision unit.
555    """
556    pandas, dateutil_parser = mrsm.attempt_import('pandas', 'dateutil.parser', lazy=False)
557    dt_is_series = hasattr(dt_val, 'dtype') and hasattr(dt_val, '__module__')
558    enforce_precision = precision_unit is not None
559    precision_unit = precision_unit or 'microsecond'
560    true_precision_unit = MRSM_PRECISION_UNITS_ALIASES.get(precision_unit, precision_unit)
561    precision_abbreviation = MRSM_PRECISION_UNITS_ABBREVIATIONS.get(true_precision_unit, None)
562    if not precision_abbreviation:
563        raise ValueError(f"Invalid precision '{precision_unit}'.")
564
565    def parse(x: Any) -> Any:
566        try:
567            return dateutil_parser.parse(x)
568        except Exception:
569            return x
570
571    def check_dtype(dtype_to_check: str, with_utc: bool = True) -> bool:
572        dtype_check_against = (
573            f"datetime64[{precision_abbreviation}, UTC]"
574            if with_utc
575            else f"datetime64[{precision_abbreviation}]"
576        )
577        return (
578            dtype_to_check == dtype_check_against
579            if enforce_precision
580            else (
581                dtype_to_check.startswith('datetime64[')
582                and (
583                    ('utc' in dtype_to_check.lower())
584                    if with_utc
585                    else ('utc' not in dtype_to_check.lower())
586                )
587            )
588        )
589
590    if isinstance(dt_val, pandas.Timestamp):
591        dt_val_to_return = dt_val if not as_pydatetime else dt_val.to_pydatetime()
592        return (
593            coerce_timezone(dt_val_to_return)
594            if coerce_utc
595            else dt_val_to_return
596        )
597
598    if dt_is_series:
599        changed_tz = False
600        original_tz = None
601        dtype = str(getattr(dt_val, 'dtype', 'object'))
602        if (
603            are_dtypes_equal(dtype, 'datetime')
604            and 'utc' not in dtype.lower()
605            and hasattr(dt_val, 'dt')
606        ):
607            original_tz = dt_val.dt.tz
608            dt_val = dt_val.dt.tz_localize(timezone.utc)
609            changed_tz = True
610            dtype = str(getattr(dt_val, 'dtype', 'object'))
611        try:
612            new_dt_series = (
613                dt_val
614                if check_dtype(dtype, with_utc=True)
615                else dt_val.astype(f"datetime64[{precision_abbreviation}, UTC]")
616            )
617        except pandas.errors.OutOfBoundsDatetime:
618            try:
619                next_precision = get_next_precision_unit(true_precision_unit)
620                next_precision_abbrevation = MRSM_PRECISION_UNITS_ABBREVIATIONS[next_precision]
621                new_dt_series = dt_val.astype(f"datetime64[{next_precision_abbrevation}, UTC]")
622            except Exception:
623                new_dt_series = None
624        except ValueError:
625            new_dt_series = None
626        except TypeError:
627            try:
628                new_dt_series = (
629                    new_dt_series
630                    if check_dtype(str(getattr(new_dt_series, 'dtype', None)), with_utc=False)
631                    else dt_val.astype(f"datetime64[{precision_abbreviation}]")
632                )
633            except Exception:
634                new_dt_series = None
635
636        if new_dt_series is None:
637            new_dt_series = dt_val.apply(lambda x: parse(str(x)))
638
639        if coerce_utc:
640            return coerce_timezone(new_dt_series)
641
642        if changed_tz:
643            new_dt_series = new_dt_series.dt.tz_localize(original_tz)
644        return new_dt_series
645
646    try:
647        new_dt_val = pandas.to_datetime(dt_val, utc=True, format='ISO8601')
648        if new_dt_val.unit != precision_abbreviation:
649            new_dt_val = new_dt_val.as_unit(precision_abbreviation)
650        if as_pydatetime:
651            return new_dt_val.to_pydatetime()
652        return new_dt_val
653    except (pandas.errors.OutOfBoundsDatetime, ValueError):
654        pass
655
656    new_dt_val = parse(dt_val)
657    if not coerce_utc:
658        return new_dt_val
659    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:
662def serialize_bytes(data: bytes) -> str:
663    """
664    Return the given bytes as a base64-encoded string.
665    """
666    import base64
667    if not isinstance(data, bytes) and value_is_null(data):
668        return data
669    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: int | None = None) -> str | Dict[str, Any] | bytes | None:
672def serialize_geometry(
673    geom: Any,
674    geometry_format: str = 'wkb_hex',
675    srid: Optional[int] = None,
676) -> Union[str, Dict[str, Any], bytes, None]:
677    """
678    Serialize geometry data as WKB, WKB (hex), GPKG-WKB, WKT, or GeoJSON. 
679
680    Parameters
681    ----------
682    geom: Any
683        The potential geometry data to be serialized.
684
685    geometry_format: str, default 'wkb_hex'
686        The serialization format for geometry data.
687        Accepted formats are `wkb`, `wkb_hex`, `wkt`, `geojson`, and `gpkg_wkb`.
688
689    srid: Optional[int], default None
690        If provided, use this as the source CRS when serializing to GeoJSON.
691
692    Returns
693    -------
694    A string containing the geometry data, or bytes, or a dictionary, or None.
695    """
696    if value_is_null(geom):
697        return None
698
699    shapely, shapely_ops, pyproj, np = mrsm.attempt_import(
700        'shapely', 'shapely.ops', 'pyproj', 'numpy',
701        lazy=False,
702    )
703    if geometry_format == 'geojson':
704        if srid:
705            transformer = pyproj.Transformer.from_crs(f"EPSG:{srid}", "EPSG:4326", always_xy=True)
706            geom = shapely_ops.transform(transformer.transform, geom)
707        geojson_str = shapely.to_geojson(geom)
708        return json.loads(geojson_str)
709
710    if not hasattr(geom, 'wkb_hex'):
711        return str(geom)
712
713    byte_order = 1 if np.little_endian else 0
714
715    if geometry_format.startswith("wkb"):
716        return shapely.to_wkb(geom, hex=(geometry_format=="wkb_hex"), include_srid=True)
717
718    if geometry_format == 'gpkg_wkb':
719        wkb_data = shapely.to_wkb(geom, hex=False, byte_order=byte_order)
720        flags = (
721            ((byte_order & 0x01) | (0x20))
722            if geom.is_empty
723            else (byte_order & 0x01)
724        )
725        srid_val = srid or -1
726        header = struct.pack(
727            '<ccBBi',
728            b'G', b'P',
729            0,
730            flags,
731            srid_val
732        )
733        return header + wkb_data
734
735    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: str | bytes):
738def deserialize_geometry(geom_wkb: Union[str, bytes]):
739    """
740    Deserialize a WKB string into a shapely geometry object.
741    """
742    shapely = mrsm.attempt_import('shapely', lazy=False)
743    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):
746def project_geometry(geom, srid: int, to_srid: int = 4326):
747    """
748    Project a shapely geometry object to a new CRS (SRID).
749    """
750    pyproj, shapely_ops = mrsm.attempt_import('pyproj', 'shapely.ops', lazy=False)
751    transformer = pyproj.Transformer.from_crs(f"EPSG:{srid}", f"EPSG:{to_srid}", always_xy=True)
752    return shapely_ops.transform(transformer.transform, geom)

Project a shapely geometry object to a new CRS (SRID).

def deserialize_bytes_string(data: str | None, force_hex: bool = False) -> bytes | None:
755def deserialize_bytes_string(data: Optional[str], force_hex: bool = False) -> Union[bytes, None]:
756    """
757    Given a serialized ASCII string of bytes data, return the original bytes.
758    The input data may either be base64- or hex-encoded.
759
760    Parameters
761    ----------
762    data: Optional[str]
763        The string to be deserialized into bytes.
764        May be base64- or hex-encoded (prefixed with `'\\x'`).
765
766    force_hex: bool = False
767        If `True`, treat the input string as hex-encoded.
768        If `data` does not begin with the prefix `'\\x'`, set `force_hex` to `True`.
769        This will still strip the leading `'\\x'` prefix if present.
770
771    Returns
772    -------
773    The original bytes used to produce the encoded string `data`.
774    """
775    if not isinstance(data, str) and value_is_null(data):
776        return data
777
778    import binascii
779    import base64
780
781    is_hex = force_hex or data.startswith('\\x')
782
783    if is_hex:
784        if data.startswith('\\x'):
785            data = data[2:]
786        return binascii.unhexlify(data)
787
788    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:
791def deserialize_base64(data: str) -> bytes:
792    """
793    Return the original bytestring from the given base64-encoded string.
794    """
795    import base64
796    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) -> str | None:
799def encode_bytes_for_bytea(data: bytes, with_prefix: bool = True) -> Union[str, None]:
800    """
801    Return the given bytes as a hex string for PostgreSQL's `BYTEA` type.
802    """
803    import binascii
804    if not isinstance(data, bytes) and value_is_null(data):
805        return data
806    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) -> str | None:
809def serialize_datetime(dt: datetime) -> Union[str, None]:
810    """
811    Serialize a datetime object into JSON (ISO format string).
812
813    Examples
814    --------
815    >>> import json
816    >>> from datetime import datetime
817    >>> json.dumps({'a': datetime(2022, 1, 1)}, default=json_serialize_datetime)
818    '{"a": "2022-01-01T00:00:00Z"}'
819
820    """
821    if not hasattr(dt, 'isoformat'):
822        return None
823
824    tz_suffix = 'Z' if getattr(dt, 'tzinfo', None) is None else ''
825    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) -> str | None:
828def serialize_date(d: date) -> Union[str, None]:
829    """
830    Serialize a date object into its ISO representation.
831    """
832    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) -> str | None:
835def json_serialize_value(x: Any, default_to_str: bool = True) -> Union[str, None]:
836    """
837    Serialize the given value to a JSON value. Accounts for datetimes, bytes, decimals, etc.
838
839    Parameters
840    ----------
841    x: Any
842        The value to serialize.
843
844    default_to_str: bool, default True
845        If `True`, return a string of `x` if x is not a designated type.
846        Otherwise return x.
847
848    Returns
849    -------
850    A serialized version of x, or x.
851    """
852    if isinstance(x, (mrsm.Pipe, mrsm.connectors.Connector)):
853        return x.meta
854
855    if hasattr(x, 'tzinfo'):
856        return serialize_datetime(x)
857
858    if hasattr(x, 'isoformat'):
859        return serialize_date(x)
860
861    if isinstance(x, bytes):
862        return serialize_bytes(x)
863
864    if isinstance(x, Decimal):
865        return serialize_decimal(x)
866
867    if 'shapely' in str(type(x)):
868        return serialize_geometry(x)
869
870    if value_is_null(x):
871        return None
872
873    if isinstance(x, (dict, list, tuple)):
874        return json.dumps(x, default=json_serialize_value, separators=(',', ':'))
875
876    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: int | str = 0) -> Tuple[str, int | str | None]:
879def get_geometry_type_srid(
880    dtype: str = 'geometry',
881    default_type: str = 'geometry',
882    default_srid: Union[int, str] = 0,
883) -> Tuple[str, Union[int, str, None]]:
884    """
885    Given the specified geometry `dtype`, return a tuple in the form (type, SRID).
886
887    Parameters
888    ----------
889    dtype: Optional[str], default None
890        Optionally provide a specific `geometry` syntax (e.g. `geometry[MultiLineString, 4326]`).
891        You may specify a supported `shapely` geometry type and an SRID in the dtype modifier:
892
893        - `Point`
894        - `LineString`
895        - `LinearRing`
896        - `Polygon`
897        - `MultiPoint`
898        - `MultiLineString`
899        - `MultiPolygon`
900        - `GeometryCollection`
901
902    Returns
903    -------
904    A tuple in the form (type, SRID).
905    Defaults to `(default_type, default_srid)`.
906
907    Examples
908    --------
909    >>> from meerschaum.utils.dtypes import get_geometry_type_srid
910    >>> get_geometry_type_srid()
911    ('geometry', 4326)
912    >>> get_geometry_type_srid('geometry[]')
913    ('geometry', 4326)
914    >>> get_geometry_type_srid('geometry[Point, 0]')
915    ('Point', 0)
916    >>> get_geometry_type_srid('geometry[0, Point]')
917    ('Point', 0)
918    >>> get_geometry_type_srid('geometry[0]')
919    ('geometry', 0)
920    >>> get_geometry_type_srid('geometry[MULTILINESTRING, 4326]')
921    ('MultiLineString', 4326)
922    >>> get_geometry_type_srid('geography')
923    ('geometry', 0)
924    >>> get_geometry_type_srid('geography[POINT]')
925    ('Point', 0)
926    >>> get_geometry_type_srid('geometry[POINT, ESRI:102003]')
927    ('Point', 'ESRI:102003')
928    """
929    _rust = _core.get_geometry_type_srid
930    return _rust(dtype, default_type=default_type, default_srid=default_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: datetime.datetime | int, precision_unit: str = 'microsecond') -> int:
933def datetime_to_int(
934    dt: 'Union[datetime, int]',
935    precision_unit: str = 'microsecond',
936) -> int:
937    """
938    Convert a timezone-aware (or naive UTC) `datetime` into an integer epoch value
939    at the given precision unit.
940
941    Parameters
942    ----------
943    dt: Union[datetime, int]
944        The datetime to convert. Naive datetimes are assumed to be UTC.
945        Integers are returned as-is.
946
947    precision_unit: str, default 'microsecond'
948        The precision of the epoch value (e.g. `'millisecond'`, `'second'`).
949        Aliases (`'ms'`, `'s'`, etc.) are accepted.
950
951    Returns
952    -------
953    An integer epoch value at the requested precision.
954
955    Examples
956    --------
957    >>> from datetime import datetime, timezone
958    >>> datetime_to_int(datetime(2026, 5, 30, tzinfo=timezone.utc), 'millisecond')
959    1779840000000
960    """
961    if isinstance(dt, int):
962        return dt
963
964    true_precision_unit = MRSM_PRECISION_UNITS_ALIASES.get(precision_unit, precision_unit)
965    if true_precision_unit not in MRSM_PRECISION_UNITS_SCALARS:
966        from meerschaum.utils.misc import items_str
967        raise ValueError(
968            f"Unknown precision unit '{precision_unit}'. "
969            "Accepted values are "
970            f"{items_str(list(MRSM_PRECISION_UNITS_SCALARS) + list(MRSM_PRECISION_UNITS_ALIASES))}."
971        )
972
973    dt = coerce_timezone(dt)
974    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: datetime.datetime | int | None = None) -> 'Union[datetime, pd.Timestamp, int]':
 977def get_current_timestamp(
 978    precision_unit: str = _STATIC_CONFIG['dtypes']['datetime']['default_precision_unit'],
 979    precision_interval: int = 1,
 980    round_to: str = 'down',
 981    as_pandas: bool = False,
 982    as_int: bool = False,
 983    unit: str = _STATIC_CONFIG['dtypes']['datetime']['default_precision_unit'],
 984    interval: int = 1,
 985    _now: Union[datetime, int, None] = None,
 986) -> 'Union[datetime, pd.Timestamp, int]':
 987    """
 988    Return the current UTC timestamp to nanosecond precision.
 989
 990    Parameters
 991    ----------
 992    precision_unit: str, default 'us'
 993        The precision of the timestamp to be returned.
 994        Valid values are the following:
 995            - `ns` / `nanosecond`
 996            - `us` / `microsecond`
 997            - `ms` / `millisecond`
 998            - `s` / `sec` / `second`
 999            - `m` / `min` / `minute`
1000            - `h` / `hr` / `hour`
1001            - `d` / `day`
1002
1003    precision_interval: int, default 1
1004        Round the timestamp to the `precision_interval` units.
1005        For example, `precision='minute'` and `precision_interval=15` will round to 15-minute intervals.
1006        Note: `precision_interval` must be 1 when `precision='nanosecond'`.
1007
1008    round_to: str, default 'down'
1009        The direction to which to round the timestamp.
1010        Available options are `down`, `up`, and `closest`.
1011
1012    as_pandas: bool, default False
1013        If `True`, return a Pandas Timestamp.
1014        This is always true if `unit` is `nanosecond`.
1015
1016    as_int: bool, default False
1017        If `True`, return the timestamp to an integer.
1018        Overrides `as_pandas`.
1019
1020    unit: str, default 'us'
1021        Alias for `precision_unit`.
1022
1023    interval: int, default 1
1024        Alias for `precision_interval`.
1025
1026    Returns
1027    -------
1028    A Pandas Timestamp, datetime object, or integer with precision to the provided unit.
1029
1030    Examples
1031    --------
1032    >>> get_current_timestamp('ns')
1033    Timestamp('2025-07-17 17:59:16.423644369+0000', tz='UTC')
1034    >>> get_current_timestamp('ms')
1035    Timestamp('2025-07-17 17:59:16.424000+0000', tz='UTC')
1036    """
1037    default_unit = _STATIC_CONFIG['dtypes']['datetime']['default_precision_unit']
1038    if unit != precision_unit and precision_unit == default_unit:
1039        precision_unit = unit
1040
1041    if interval != precision_interval and precision_interval == 1:
1042        precision_interval = interval
1043
1044    true_precision_unit = MRSM_PRECISION_UNITS_ALIASES.get(precision_unit, precision_unit)
1045    if true_precision_unit not in MRSM_PRECISION_UNITS_SCALARS:
1046        from meerschaum.utils.misc import items_str
1047        raise ValueError(
1048            f"Unknown precision unit '{precision_unit}'. "
1049            "Accepted values are "
1050            f"{items_str(list(MRSM_PRECISION_UNITS_SCALARS) + list(MRSM_PRECISION_UNITS_ALIASES))}."
1051        )
1052
1053    if not as_int:
1054        as_pandas = as_pandas or true_precision_unit == 'nanosecond'
1055    pd = mrsm.attempt_import('pandas', lazy=False) if as_pandas else None
1056
1057    if true_precision_unit == 'nanosecond':
1058        if precision_interval != 1:
1059            warn("`precision_interval` must be 1 for nanosecond precision.")
1060        now_ts = time.time_ns() if not isinstance(_now, int) else _now
1061        if as_int:
1062            return now_ts
1063        return pd.to_datetime(now_ts, unit='ns', utc=True)
1064
1065    now = datetime.now(timezone.utc) if not isinstance(_now, datetime) else _now
1066    delta = timedelta(**{true_precision_unit + 's': precision_interval})
1067    rounded_now = round_time(now, delta, to=round_to)
1068
1069    if as_int:
1070        return datetime_to_int(rounded_now, true_precision_unit)
1071
1072    ts_val = (
1073        pd.to_datetime(rounded_now, utc=True)
1074        if as_pandas
1075        else rounded_now
1076    )
1077
1078    if not as_pandas:
1079        return ts_val
1080
1081    as_unit_precisions = ('microsecond', 'millisecond', 'second')
1082    if true_precision_unit not in as_unit_precisions:
1083        return ts_val
1084
1085    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:
1088def is_dtype_special(type_: str) -> bool:
1089    """
1090    Return whether a dtype should be treated as a special Meerschaum dtype.
1091    This is not the same as a Meerschaum alias.
1092    """
1093    true_type = MRSM_ALIAS_DTYPES.get(type_, type_)
1094    if true_type in (
1095        'uuid',
1096        'json',
1097        'bytes',
1098        'numeric',
1099        'datetime',
1100        'geometry',
1101        'geography',
1102        'date',
1103        'bool',
1104    ):
1105        return True
1106
1107    if are_dtypes_equal(true_type, 'datetime'):
1108        return True
1109
1110    if are_dtypes_equal(true_type, 'date'):
1111        return True
1112
1113    if true_type.startswith('numeric'):
1114        return True
1115
1116    if true_type.startswith('bool'):
1117        return True
1118
1119    if true_type.startswith('geometry'):
1120        return True
1121
1122    if true_type.startswith('geography'):
1123        return True
1124
1125    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:
1128def get_next_precision_unit(precision_unit: str, decrease: bool = True) -> str:
1129    """
1130    Get the next precision string in order of value.
1131
1132    Parameters
1133    ----------
1134    precision_unit: str
1135        The precision string (`'nanosecond'`, `'ms'`, etc.).
1136
1137    decrease: bool, defaul True
1138        If `True` return the precision unit which is lower (e.g. `nanosecond` -> `millisecond`).
1139        If `False`, return the precision unit which is higher.
1140
1141    Returns
1142    -------
1143    A `precision` string which is lower or higher than the given precision unit.
1144
1145    Examples
1146    --------
1147    >>> get_next_precision_unit('nanosecond')
1148    'microsecond'
1149    >>> get_next_precision_unit('ms')
1150    'second'
1151    >>> get_next_precision_unit('hour', decrease=False)
1152    'minute'
1153    """
1154    true_precision_unit = MRSM_PRECISION_UNITS_ALIASES.get(precision_unit, precision_unit)
1155    precision_scalar = MRSM_PRECISION_UNITS_SCALARS.get(true_precision_unit, None)
1156    if not precision_scalar:
1157        raise ValueError(f"Invalid precision unit '{precision_unit}'.")
1158
1159    precisions = sorted(
1160        list(MRSM_PRECISION_UNITS_SCALARS),
1161        key=lambda p: MRSM_PRECISION_UNITS_SCALARS[p]
1162    )
1163
1164    precision_index = precisions.index(true_precision_unit)
1165    new_precision_index = precision_index + (-1 if decrease else 1)
1166    if new_precision_index < 0 or new_precision_index >= len(precisions):
1167        raise ValueError(f"No precision {'below' if decrease else 'above'} '{precision_unit}'.")
1168
1169    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: datetime.datetime | None = None, date_delta: datetime.timedelta | None = None, to: str = 'down') -> datetime.datetime:
1172def round_time(
1173    dt: Optional[datetime] = None,
1174    date_delta: Optional[timedelta] = None,
1175    to: 'str' = 'down'
1176) -> datetime:
1177    """
1178    Round a datetime object to a multiple of a timedelta.
1179
1180    Parameters
1181    ----------
1182    dt: Optional[datetime], default None
1183        If `None`, grab the current UTC datetime.
1184
1185    date_delta: Optional[timedelta], default None
1186        If `None`, use a delta of 1 minute.
1187
1188    to: 'str', default 'down'
1189        Available options are `'up'`, `'down'`, and `'closest'`.
1190
1191    Returns
1192    -------
1193    A rounded `datetime` object.
1194
1195    Examples
1196    --------
1197    >>> round_time(datetime(2022, 1, 1, 12, 15, 57, 200))
1198    datetime.datetime(2022, 1, 1, 12, 15)
1199    >>> round_time(datetime(2022, 1, 1, 12, 15, 57, 200), to='up')
1200    datetime.datetime(2022, 1, 1, 12, 16)
1201    >>> round_time(datetime(2022, 1, 1, 12, 15, 57, 200), timedelta(hours=1))
1202    datetime.datetime(2022, 1, 1, 12, 0)
1203    >>> round_time(
1204    ...   datetime(2022, 1, 1, 12, 15, 57, 200),
1205    ...   timedelta(hours=1),
1206    ...   to = 'closest'
1207    ... )
1208    datetime.datetime(2022, 1, 1, 12, 0)
1209    >>> round_time(
1210    ...   datetime(2022, 1, 1, 12, 45, 57, 200),
1211    ...   datetime.timedelta(hours=1),
1212    ...   to = 'closest'
1213    ... )
1214    datetime.datetime(2022, 1, 1, 13, 0)
1215
1216    """
1217    from decimal import Decimal, ROUND_HALF_UP, ROUND_DOWN, ROUND_UP
1218    if date_delta is None:
1219        date_delta = timedelta(minutes=1)
1220
1221    if dt is None:
1222        dt = datetime.now(timezone.utc).replace(tzinfo=None)
1223
1224    def get_total_microseconds(td: timedelta) -> int:
1225        return (td.days * 86400 + td.seconds) * 1_000_000 + td.microseconds
1226
1227    round_to_microseconds = get_total_microseconds(date_delta)
1228    if round_to_microseconds == 0:
1229        return dt
1230
1231    dt_delta_from_min = dt.replace(tzinfo=None) - datetime.min
1232    dt_total_microseconds = get_total_microseconds(dt_delta_from_min)
1233
1234    dt_dec = Decimal(dt_total_microseconds)
1235    round_to_dec = Decimal(round_to_microseconds)
1236
1237    div = dt_dec / round_to_dec
1238    if to == 'down':
1239        num_intervals = div.to_integral_value(rounding=ROUND_DOWN)
1240    elif to == 'up':
1241        num_intervals = div.to_integral_value(rounding=ROUND_UP)
1242    else:
1243        num_intervals = div.to_integral_value(rounding=ROUND_HALF_UP)
1244
1245    rounded_dt_total_microseconds = num_intervals * round_to_dec
1246    adjustment_microseconds = int(rounded_dt_total_microseconds) - dt_total_microseconds
1247
1248    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)