meerschaum 2.9.5__py3-none-any.whl → 3.0.0rc1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (153) hide show
  1. meerschaum/__init__.py +5 -2
  2. meerschaum/_internal/__init__.py +1 -0
  3. meerschaum/_internal/arguments/_parse_arguments.py +4 -4
  4. meerschaum/_internal/arguments/_parser.py +17 -1
  5. meerschaum/_internal/entry.py +6 -6
  6. meerschaum/_internal/shell/Shell.py +1 -1
  7. meerschaum/_internal/static.py +372 -0
  8. meerschaum/actions/api.py +12 -2
  9. meerschaum/actions/bootstrap.py +7 -7
  10. meerschaum/actions/edit.py +142 -18
  11. meerschaum/actions/register.py +137 -6
  12. meerschaum/actions/show.py +117 -29
  13. meerschaum/actions/stop.py +4 -1
  14. meerschaum/actions/sync.py +1 -1
  15. meerschaum/actions/tag.py +9 -8
  16. meerschaum/api/__init__.py +9 -2
  17. meerschaum/api/_events.py +39 -2
  18. meerschaum/api/_oauth2.py +118 -8
  19. meerschaum/api/_tokens.py +102 -0
  20. meerschaum/api/dash/__init__.py +0 -1
  21. meerschaum/api/dash/callbacks/custom.py +2 -2
  22. meerschaum/api/dash/callbacks/dashboard.py +102 -18
  23. meerschaum/api/dash/callbacks/plugins.py +0 -1
  24. meerschaum/api/dash/callbacks/register.py +1 -1
  25. meerschaum/api/dash/callbacks/settings/__init__.py +1 -0
  26. meerschaum/api/dash/callbacks/settings/password_reset.py +2 -2
  27. meerschaum/api/dash/callbacks/settings/tokens.py +388 -0
  28. meerschaum/api/dash/components.py +30 -8
  29. meerschaum/api/dash/keys.py +19 -93
  30. meerschaum/api/dash/pages/dashboard.py +1 -20
  31. meerschaum/api/dash/pages/settings/__init__.py +1 -0
  32. meerschaum/api/dash/pages/settings/password_reset.py +1 -1
  33. meerschaum/api/dash/pages/settings/tokens.py +55 -0
  34. meerschaum/api/dash/pipes.py +94 -59
  35. meerschaum/api/dash/sessions.py +12 -0
  36. meerschaum/api/dash/tokens.py +606 -0
  37. meerschaum/api/dash/websockets.py +1 -1
  38. meerschaum/api/dash/webterm.py +4 -0
  39. meerschaum/api/models/__init__.py +23 -3
  40. meerschaum/api/models/_actions.py +22 -0
  41. meerschaum/api/models/_pipes.py +85 -7
  42. meerschaum/api/models/_tokens.py +81 -0
  43. meerschaum/api/resources/templates/termpage.html +12 -0
  44. meerschaum/api/routes/__init__.py +1 -0
  45. meerschaum/api/routes/_actions.py +3 -4
  46. meerschaum/api/routes/_connectors.py +3 -7
  47. meerschaum/api/routes/_jobs.py +14 -35
  48. meerschaum/api/routes/_login.py +49 -12
  49. meerschaum/api/routes/_misc.py +5 -10
  50. meerschaum/api/routes/_pipes.py +134 -111
  51. meerschaum/api/routes/_plugins.py +38 -28
  52. meerschaum/api/routes/_tokens.py +236 -0
  53. meerschaum/api/routes/_users.py +47 -35
  54. meerschaum/api/routes/_version.py +3 -3
  55. meerschaum/config/__init__.py +43 -20
  56. meerschaum/config/_default.py +32 -5
  57. meerschaum/config/_edit.py +28 -24
  58. meerschaum/config/_environment.py +1 -1
  59. meerschaum/config/_patch.py +6 -6
  60. meerschaum/config/_paths.py +5 -1
  61. meerschaum/config/_read_config.py +65 -34
  62. meerschaum/config/_sync.py +6 -3
  63. meerschaum/config/_version.py +1 -1
  64. meerschaum/config/stack/__init__.py +24 -5
  65. meerschaum/config/static.py +18 -0
  66. meerschaum/connectors/_Connector.py +10 -4
  67. meerschaum/connectors/__init__.py +4 -20
  68. meerschaum/connectors/api/_APIConnector.py +34 -6
  69. meerschaum/connectors/api/_actions.py +2 -2
  70. meerschaum/connectors/api/_jobs.py +1 -1
  71. meerschaum/connectors/api/_login.py +33 -7
  72. meerschaum/connectors/api/_misc.py +2 -2
  73. meerschaum/connectors/api/_pipes.py +15 -14
  74. meerschaum/connectors/api/_plugins.py +2 -2
  75. meerschaum/connectors/api/_request.py +1 -1
  76. meerschaum/connectors/api/_tokens.py +146 -0
  77. meerschaum/connectors/api/_users.py +70 -58
  78. meerschaum/connectors/instance/_InstanceConnector.py +83 -0
  79. meerschaum/connectors/instance/__init__.py +10 -0
  80. meerschaum/connectors/instance/_pipes.py +442 -0
  81. meerschaum/connectors/instance/_plugins.py +151 -0
  82. meerschaum/connectors/instance/_tokens.py +296 -0
  83. meerschaum/connectors/instance/_users.py +181 -0
  84. meerschaum/connectors/parse.py +4 -1
  85. meerschaum/connectors/sql/_SQLConnector.py +8 -5
  86. meerschaum/connectors/sql/_cli.py +12 -11
  87. meerschaum/connectors/sql/_create_engine.py +6 -154
  88. meerschaum/connectors/sql/_fetch.py +2 -18
  89. meerschaum/connectors/sql/_pipes.py +42 -31
  90. meerschaum/connectors/sql/_plugins.py +29 -0
  91. meerschaum/connectors/sql/_sql.py +8 -1
  92. meerschaum/connectors/sql/_users.py +29 -2
  93. meerschaum/connectors/sql/tables/__init__.py +1 -1
  94. meerschaum/connectors/valkey/_ValkeyConnector.py +2 -4
  95. meerschaum/connectors/valkey/_pipes.py +9 -10
  96. meerschaum/connectors/valkey/_plugins.py +2 -26
  97. meerschaum/core/Pipe/__init__.py +31 -14
  98. meerschaum/core/Pipe/_attributes.py +156 -58
  99. meerschaum/core/Pipe/_bootstrap.py +54 -24
  100. meerschaum/core/Pipe/_data.py +41 -1
  101. meerschaum/core/Pipe/_dtypes.py +29 -14
  102. meerschaum/core/Pipe/_edit.py +12 -4
  103. meerschaum/core/Pipe/_show.py +5 -5
  104. meerschaum/core/Pipe/_sync.py +48 -53
  105. meerschaum/core/Pipe/_verify.py +1 -1
  106. meerschaum/{plugins → core/Plugin}/_Plugin.py +9 -11
  107. meerschaum/core/Plugin/__init__.py +1 -1
  108. meerschaum/core/Token/_Token.py +221 -0
  109. meerschaum/core/Token/__init__.py +12 -0
  110. meerschaum/core/User/_User.py +34 -8
  111. meerschaum/core/User/__init__.py +9 -1
  112. meerschaum/core/__init__.py +1 -0
  113. meerschaum/jobs/_Job.py +3 -2
  114. meerschaum/jobs/__init__.py +3 -2
  115. meerschaum/jobs/systemd.py +1 -1
  116. meerschaum/models/__init__.py +35 -0
  117. meerschaum/models/pipes.py +247 -0
  118. meerschaum/models/tokens.py +38 -0
  119. meerschaum/models/users.py +26 -0
  120. meerschaum/plugins/__init__.py +22 -7
  121. meerschaum/plugins/bootstrap.py +2 -1
  122. meerschaum/utils/_get_pipes.py +68 -27
  123. meerschaum/utils/daemon/Daemon.py +2 -1
  124. meerschaum/utils/daemon/__init__.py +30 -2
  125. meerschaum/utils/dataframe.py +95 -14
  126. meerschaum/utils/dtypes/__init__.py +91 -18
  127. meerschaum/utils/dtypes/sql.py +44 -0
  128. meerschaum/utils/formatting/__init__.py +1 -1
  129. meerschaum/utils/formatting/_pipes.py +5 -4
  130. meerschaum/utils/formatting/_shell.py +11 -9
  131. meerschaum/utils/misc.py +237 -80
  132. meerschaum/utils/packages/__init__.py +3 -6
  133. meerschaum/utils/packages/_packages.py +34 -32
  134. meerschaum/utils/pipes.py +181 -0
  135. meerschaum/utils/process.py +1 -1
  136. meerschaum/utils/prompt.py +3 -1
  137. meerschaum/utils/schedule.py +1 -0
  138. meerschaum/utils/sql.py +114 -37
  139. meerschaum/utils/typing.py +1 -4
  140. meerschaum/utils/venv/_Venv.py +2 -2
  141. meerschaum/utils/venv/__init__.py +5 -7
  142. {meerschaum-2.9.5.dist-info → meerschaum-3.0.0rc1.dist-info}/METADATA +88 -80
  143. meerschaum-3.0.0rc1.dist-info/RECORD +282 -0
  144. {meerschaum-2.9.5.dist-info → meerschaum-3.0.0rc1.dist-info}/WHEEL +1 -1
  145. meerschaum/api/models/_interfaces.py +0 -15
  146. meerschaum/api/models/_locations.py +0 -15
  147. meerschaum/api/models/_metrics.py +0 -15
  148. meerschaum/config/static/__init__.py +0 -186
  149. meerschaum-2.9.5.dist-info/RECORD +0 -263
  150. {meerschaum-2.9.5.dist-info → meerschaum-3.0.0rc1.dist-info}/entry_points.txt +0 -0
  151. {meerschaum-2.9.5.dist-info → meerschaum-3.0.0rc1.dist-info}/licenses/LICENSE +0 -0
  152. {meerschaum-2.9.5.dist-info → meerschaum-3.0.0rc1.dist-info}/top_level.txt +0 -0
  153. {meerschaum-2.9.5.dist-info → meerschaum-3.0.0rc1.dist-info}/zip-safe +0 -0
@@ -1003,6 +1003,7 @@ def enforce_dtypes(
1003
1003
  attempt_cast_to_bytes,
1004
1004
  attempt_cast_to_geometry,
1005
1005
  coerce_timezone as _coerce_timezone,
1006
+ get_geometry_type_srid,
1006
1007
  )
1007
1008
  from meerschaum.utils.dtypes.sql import get_numeric_precision_scale
1008
1009
  pandas = mrsm.attempt_import('pandas')
@@ -1028,11 +1029,11 @@ def enforce_dtypes(
1028
1029
  for col, typ in dtypes.items()
1029
1030
  if typ.startswith('numeric')
1030
1031
  ]
1031
- geometry_cols = [
1032
- col
1032
+ geometry_cols_types_srids = {
1033
+ col: get_geometry_type_srid(typ, default_srid=0)
1033
1034
  for col, typ in dtypes.items()
1034
1035
  if typ.startswith('geometry') or typ.startswith('geography')
1035
- ]
1036
+ }
1036
1037
  uuid_cols = [
1037
1038
  col
1038
1039
  for col, typ in dtypes.items()
@@ -1120,14 +1121,24 @@ def enforce_dtypes(
1120
1121
  dprint(f"Checking for datetime conversion: {datetime_cols}")
1121
1122
  for col in datetime_cols:
1122
1123
  if col in df.columns:
1124
+ if debug:
1125
+ dprint(
1126
+ f"Data type for column '{col}' before timezone coersion: "
1127
+ f"{str(df[col].dtype)}"
1128
+ )
1123
1129
  df[col] = _coerce_timezone(df[col], strip_utc=strip_timezone)
1130
+ if debug:
1131
+ dprint(
1132
+ f"Data type for column '{col}' after timezone coersion: "
1133
+ f"{str(df[col].dtype)}"
1134
+ )
1124
1135
 
1125
- if geometry_cols:
1136
+ if geometry_cols_types_srids:
1126
1137
  geopandas = mrsm.attempt_import('geopandas')
1127
1138
  if debug:
1128
- dprint(f"Checking for geometry: {geometry_cols}")
1139
+ dprint(f"Checking for geometry: {list(geometry_cols_types_srids)}")
1129
1140
  parsed_geom_cols = []
1130
- for col in geometry_cols:
1141
+ for col in geometry_cols_types_srids:
1131
1142
  try:
1132
1143
  df[col] = df[col].apply(attempt_cast_to_geometry)
1133
1144
  parsed_geom_cols.append(col)
@@ -1139,10 +1150,19 @@ def enforce_dtypes(
1139
1150
  if debug:
1140
1151
  dprint(f"Converting to GeoDataFrame (geometry column: '{parsed_geom_cols[0]}')...")
1141
1152
  try:
1142
- df = geopandas.GeoDataFrame(df, geometry=parsed_geom_cols[0])
1143
- df.rename_geometry(parsed_geom_cols[0], inplace=True)
1153
+ _, default_srid = geometry_cols_types_srids[parsed_geom_cols[0]]
1154
+ df = geopandas.GeoDataFrame(df, geometry=parsed_geom_cols[0], crs=default_srid)
1155
+ for col, (_, srid) in geometry_cols_types_srids.items():
1156
+ if srid:
1157
+ if debug:
1158
+ dprint(f"Setting '{col}' to SRID '{srid}'...")
1159
+ _ = df[col].set_crs(srid)
1160
+ if parsed_geom_cols[0] not in df.columns:
1161
+ df.rename_geometry(parsed_geom_cols[0], inplace=True)
1144
1162
  except (ValueError, TypeError):
1145
- pass
1163
+ if debug:
1164
+ import traceback
1165
+ dprint(f"Failed to cast to GeoDataFrame:\n{traceback.format_exc()}")
1146
1166
 
1147
1167
  df_dtypes = {c: str(t) for c, t in df.dtypes.items()}
1148
1168
  if are_dtypes_equal(df_dtypes, pipe_pandas_dtypes):
@@ -1386,7 +1406,7 @@ def chunksize_to_npartitions(chunksize: Optional[int]) -> int:
1386
1406
 
1387
1407
  def df_from_literal(
1388
1408
  pipe: Optional[mrsm.Pipe] = None,
1389
- literal: str = None,
1409
+ literal: Optional[str] = None,
1390
1410
  debug: bool = False
1391
1411
  ) -> 'pd.DataFrame':
1392
1412
  """
@@ -1408,8 +1428,8 @@ def df_from_literal(
1408
1428
 
1409
1429
  if pipe is None or literal is None:
1410
1430
  error("Please provide a Pipe and a literal value")
1411
- ### this will raise an error if the columns are undefined
1412
- dt_name, val_name = pipe.get_columns('datetime', 'value')
1431
+ dt_col = pipe.columns.get('datetime', 'ts')
1432
+ val_col = pipe.get_val_column(debug=debug)
1413
1433
 
1414
1434
  val = literal
1415
1435
  if isinstance(literal, str):
@@ -1429,7 +1449,7 @@ def df_from_literal(
1429
1449
  now = datetime.now(timezone.utc).replace(tzinfo=None)
1430
1450
 
1431
1451
  pd = import_pandas()
1432
- return pd.DataFrame({dt_name: [now], val_name: [val]})
1452
+ return pd.DataFrame({dt_col: [now], val_col: [val]})
1433
1453
 
1434
1454
 
1435
1455
  def get_first_valid_dask_partition(ddf: 'dask.dataframe.DataFrame') -> Union['pd.DataFrame', None]:
@@ -1533,9 +1553,14 @@ def query_df(
1533
1553
  NA = pandas.NA
1534
1554
 
1535
1555
  if params:
1556
+ proto_in_ex_params = get_in_ex_params(params)
1557
+ for key, (proto_in_vals, proto_ex_vals) in proto_in_ex_params.items():
1558
+ if proto_ex_vals:
1559
+ coerce_types = True
1560
+ break
1536
1561
  params = params.copy()
1537
1562
  for key, val in {k: v for k, v in params.items()}.items():
1538
- if isinstance(val, (list, tuple)):
1563
+ if isinstance(val, (list, tuple, set)) or hasattr(val, 'astype'):
1539
1564
  if None in val:
1540
1565
  val = [item for item in val if item is not None] + [NA]
1541
1566
  params[key] = val
@@ -1742,6 +1767,10 @@ def to_json(
1742
1767
  bytes_cols = get_bytes_cols(df)
1743
1768
  numeric_cols = get_numeric_cols(df)
1744
1769
  geometry_cols = get_geometry_cols(df)
1770
+ geometry_cols_srids = {
1771
+ col: int((getattr(df[col].crs, 'srs', '') or '').split(':', maxsplit=1)[-1] or '0')
1772
+ for col in geometry_cols
1773
+ } if 'geodataframe' in str(type(df)).lower() else {}
1745
1774
  if safe_copy and bool(uuid_cols or bytes_cols or geometry_cols or numeric_cols):
1746
1775
  df = df.copy()
1747
1776
  for col in uuid_cols:
@@ -1753,10 +1782,12 @@ def to_json(
1753
1782
  with warnings.catch_warnings():
1754
1783
  warnings.simplefilter("ignore")
1755
1784
  for col in geometry_cols:
1785
+ srid = geometry_cols_srids.get(col, None) or None
1756
1786
  df[col] = df[col].apply(
1757
1787
  functools.partial(
1758
1788
  serialize_geometry,
1759
1789
  geometry_format=geometry_format,
1790
+ srid=srid,
1760
1791
  )
1761
1792
  )
1762
1793
  return df.infer_objects(copy=False).fillna(pd.NA).to_json(
@@ -1766,3 +1797,53 @@ def to_json(
1766
1797
  orient=orient,
1767
1798
  **kwargs
1768
1799
  )
1800
+
1801
+
1802
+ def to_simple_lines(df: 'pd.DataFrame') -> str:
1803
+ """
1804
+ Serialize a Pandas Dataframe as lines of simple dictionaries.
1805
+
1806
+ Parameters
1807
+ ----------
1808
+ df: pd.DataFrame
1809
+ The dataframe to serialize into simple lines text.
1810
+
1811
+ Returns
1812
+ -------
1813
+ A string of simple line dictionaries joined by newlines.
1814
+ """
1815
+ from meerschaum.utils.misc import to_simple_dict
1816
+ if df is None or len(df) == 0:
1817
+ return ''
1818
+
1819
+ docs = df.to_dict(orient='records')
1820
+ return '\n'.join(to_simple_dict(doc) for doc in docs)
1821
+
1822
+
1823
+ def parse_simple_lines(data: str) -> 'pd.DataFrame':
1824
+ """
1825
+ Parse simple lines text into a DataFrame.
1826
+
1827
+ Parameters
1828
+ ----------
1829
+ data: str
1830
+ The simple lines text to parse into a DataFrame.
1831
+
1832
+ Returns
1833
+ -------
1834
+ A dataframe containing the rows serialized in `data`.
1835
+ """
1836
+ from meerschaum.utils.misc import string_to_dict
1837
+ from meerschaum.utils.packages import import_pandas
1838
+ pd = import_pandas()
1839
+ lines = data.splitlines()
1840
+ try:
1841
+ docs = [string_to_dict(line) for line in lines]
1842
+ df = pd.DataFrame(docs)
1843
+ except Exception:
1844
+ df = None
1845
+
1846
+ if df is None:
1847
+ raise ValueError("Cannot parse simple lines into a dataframe.")
1848
+
1849
+ return df
@@ -309,7 +309,7 @@ def attempt_cast_to_geometry(value: Any) -> Any:
309
309
  if isinstance(value, (dict, list)):
310
310
  try:
311
311
  return shapely.from_geojson(json.dumps(value))
312
- except Exception as e:
312
+ except Exception:
313
313
  return value
314
314
 
315
315
  value_is_wkt = geometry_is_wkt(value)
@@ -361,7 +361,7 @@ def value_is_null(value: Any) -> bool:
361
361
  """
362
362
  Determine if a value is a null-like string.
363
363
  """
364
- return str(value).lower() in ('none', 'nan', 'na', 'nat', '', '<na>')
364
+ return str(value).lower() in ('none', 'nan', 'na', 'nat', 'natz', '', '<na>')
365
365
 
366
366
 
367
367
  def none_if_null(value: Any) -> Any:
@@ -455,7 +455,10 @@ def coerce_timezone(
455
455
 
456
456
  if isinstance(dt, str):
457
457
  dateutil_parser = mrsm.attempt_import('dateutil.parser')
458
- dt = dateutil_parser.parse(dt)
458
+ try:
459
+ dt = dateutil_parser.parse(dt)
460
+ except Exception:
461
+ return dt
459
462
 
460
463
  dt_is_series = hasattr(dt, 'dtype') and hasattr(dt, '__module__')
461
464
 
@@ -472,6 +475,8 @@ def coerce_timezone(
472
475
  return dt
473
476
 
474
477
  dt_series = to_datetime(dt, coerce_utc=False)
478
+ if dt_series.dt.tz is None:
479
+ dt_series = dt_series.dt.tz_localize(timezone.utc)
475
480
  if strip_utc:
476
481
  try:
477
482
  if dt_series.dt.tz is not None:
@@ -502,25 +507,69 @@ def to_datetime(dt_val: Any, as_pydatetime: bool = False, coerce_utc: bool = Tru
502
507
  dt_is_series = hasattr(dt_val, 'dtype') and hasattr(dt_val, '__module__')
503
508
  pd = pandas if dd is None else dd
504
509
 
505
- try:
506
- new_dt_val = pd.to_datetime(dt_val, utc=True, format='ISO8601')
507
- if as_pydatetime:
508
- return new_dt_val.to_pydatetime()
509
- return new_dt_val
510
- except (pd.errors.OutOfBoundsDatetime, ValueError):
511
- pass
512
-
513
510
  def parse(x: Any) -> Any:
514
511
  try:
515
512
  return dateutil_parser.parse(x)
516
513
  except Exception:
517
514
  return x
518
515
 
516
+ if isinstance(dt_val, pd.Timestamp):
517
+ dt_val_to_return = dt_val if not as_pydatetime else dt_val.to_pydatetime()
518
+ return (
519
+ coerce_timezone(dt_val_to_return)
520
+ if coerce_utc
521
+ else dt_val_to_return
522
+ )
523
+
519
524
  if dt_is_series:
520
- new_series = dt_val.apply(parse)
525
+ changed_tz = False
526
+ original_tz = None
527
+ dtype = str(getattr(dt_val, 'dtype', 'object'))
528
+ if are_dtypes_equal(dtype, 'datetime') and 'utc' not in dtype.lower():
529
+ original_tz = dt_val.dt.tz
530
+ dt_val = dt_val.dt.tz_localize(timezone.utc)
531
+ changed_tz = True
532
+ dtype = str(getattr(dt_val, 'dtype', 'object'))
533
+ try:
534
+ new_dt_series = (
535
+ dt_val
536
+ if dtype == 'datetime64[ns, UTC]'
537
+ else dt_val.astype("datetime64[ns, UTC]")
538
+ )
539
+ except pd.errors.OutOfBoundsDatetime:
540
+ try:
541
+ new_dt_series = dt_val.astype("datetime64[ms, UTC]")
542
+ except Exception:
543
+ new_dt_series = None
544
+ except ValueError:
545
+ new_dt_series = None
546
+ except TypeError:
547
+ try:
548
+ new_dt_series = (
549
+ new_dt_series
550
+ if str(getattr(new_dt_series, 'dtype', None)) == 'datetime64[ns]'
551
+ else dt_val.astype("datetime64[ns]")
552
+ )
553
+ except Exception:
554
+ new_dt_series = None
555
+
556
+ if new_dt_series is None:
557
+ new_dt_series = dt_val.apply(lambda x: parse(str(x)))
558
+
521
559
  if coerce_utc:
522
- return coerce_timezone(new_series)
523
- return new_series
560
+ return coerce_timezone(new_dt_series)
561
+
562
+ if changed_tz:
563
+ new_dt_series = new_dt_series.dt.tz_localize(original_tz)
564
+ return new_dt_series
565
+
566
+ try:
567
+ new_dt_val = pd.to_datetime(dt_val, utc=True, format='ISO8601')
568
+ if as_pydatetime:
569
+ return new_dt_val.to_pydatetime()
570
+ return new_dt_val
571
+ except (pd.errors.OutOfBoundsDatetime, ValueError):
572
+ pass
524
573
 
525
574
  new_dt_val = parse(dt_val)
526
575
  if not coerce_utc:
@@ -541,6 +590,7 @@ def serialize_bytes(data: bytes) -> str:
541
590
  def serialize_geometry(
542
591
  geom: Any,
543
592
  geometry_format: str = 'wkb_hex',
593
+ srid: Optional[int] = None,
544
594
  ) -> Union[str, Dict[str, Any], None]:
545
595
  """
546
596
  Serialize geometry data as a hex-encoded well-known-binary string.
@@ -555,19 +605,30 @@ def serialize_geometry(
555
605
  Accepted formats are `wkb_hex` (well-known binary hex string),
556
606
  `wkt` (well-known text), and `geojson`.
557
607
 
608
+ srid: Optional[int], default None
609
+ If provided, use this as the source CRS when serializing to GeoJSON.
610
+
558
611
  Returns
559
612
  -------
560
613
  A string containing the geometry data.
561
614
  """
562
615
  if value_is_null(geom):
563
616
  return None
564
- shapely = mrsm.attempt_import('shapely', lazy=False)
617
+ shapely, shapely_ops, pyproj = mrsm.attempt_import(
618
+ 'shapely', 'shapely.ops', 'pyproj',
619
+ lazy=False,
620
+ )
565
621
  if geometry_format == 'geojson':
622
+ if srid:
623
+ transformer = pyproj.Transformer.from_crs(f"EPSG:{srid}", "EPSG:4326", always_xy=True)
624
+ geom = shapely_ops.transform(transformer.transform, geom)
566
625
  geojson_str = shapely.to_geojson(geom)
567
626
  return json.loads(geojson_str)
568
627
 
569
628
  if hasattr(geom, 'wkb_hex'):
570
- return geom.wkb_hex if geometry_format == 'wkb_hex' else geom.wkt
629
+ if geometry_format == "wkb_hex":
630
+ return shapely.to_wkb(geom, hex=True, include_srid=True)
631
+ return shapely.to_wkt(geom)
571
632
 
572
633
  return str(geom)
573
634
 
@@ -576,10 +637,19 @@ def deserialize_geometry(geom_wkb: Union[str, bytes]):
576
637
  """
577
638
  Deserialize a WKB string into a shapely geometry object.
578
639
  """
579
- shapely = mrsm.attempt_import(lazy=False)
640
+ shapely = mrsm.attempt_import('shapely', lazy=False)
580
641
  return shapely.wkb.loads(geom_wkb)
581
642
 
582
643
 
644
+ def project_geometry(geom, srid: int, to_srid: int = 4326):
645
+ """
646
+ Project a shapely geometry object to a new CRS (SRID).
647
+ """
648
+ pyproj, shapely_ops = mrsm.attempt_import('pyproj', 'shapely.ops', lazy=False)
649
+ transformer = pyproj.Transformer.from_crs(f"EPSG:{srid}", f"EPSG:{to_srid}", always_xy=True)
650
+ return shapely_ops.transform(transformer.transform, geom)
651
+
652
+
583
653
  def deserialize_bytes_string(data: Optional[str], force_hex: bool = False) -> Union[bytes, None]:
584
654
  """
585
655
  Given a serialized ASCII string of bytes data, return the original bytes.
@@ -652,7 +722,7 @@ def serialize_datetime(dt: datetime) -> Union[str, None]:
652
722
  return dt.isoformat() + tz_suffix
653
723
 
654
724
 
655
- def json_serialize_value(x: Any, default_to_str: bool = True) -> str:
725
+ def json_serialize_value(x: Any, default_to_str: bool = True) -> Union[str, None]:
656
726
  """
657
727
  Serialize the given value to a JSON value. Accounts for datetimes, bytes, decimals, etc.
658
728
 
@@ -687,6 +757,9 @@ def json_serialize_value(x: Any, default_to_str: bool = True) -> str:
687
757
  if value_is_null(x):
688
758
  return None
689
759
 
760
+ if isinstance(x, (dict, list, tuple)):
761
+ return json.dumps(x, default=json_serialize_value, separators=(',', ':'))
762
+
690
763
  return str(x) if default_to_str else x
691
764
 
692
765