mainsequence 4.1.2__tar.gz → 4.1.3__tar.gz

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 (122) hide show
  1. {mainsequence-4.1.2 → mainsequence-4.1.3}/PKG-INFO +1 -1
  2. {mainsequence-4.1.2 → mainsequence-4.1.3}/mainsequence/client/dtype_codec.py +20 -19
  3. {mainsequence-4.1.2 → mainsequence-4.1.3}/mainsequence/client/models_metatables.py +75 -42
  4. {mainsequence-4.1.2 → mainsequence-4.1.3}/mainsequence/client/utils.py +13 -4
  5. {mainsequence-4.1.2 → mainsequence-4.1.3}/mainsequence/meta_tables/data_nodes/build_operations.py +6 -0
  6. {mainsequence-4.1.2 → mainsequence-4.1.3}/mainsequence/meta_tables/data_nodes/persist_managers.py +11 -0
  7. {mainsequence-4.1.2 → mainsequence-4.1.3}/mainsequence.egg-info/PKG-INFO +1 -1
  8. {mainsequence-4.1.2 → mainsequence-4.1.3}/pyproject.toml +1 -1
  9. {mainsequence-4.1.2 → mainsequence-4.1.3}/tests/test_build_operations_hashing.py +34 -0
  10. {mainsequence-4.1.2 → mainsequence-4.1.3}/tests/test_data_node_update_flow.py +133 -0
  11. {mainsequence-4.1.2 → mainsequence-4.1.3}/tests/test_run_configuration.py +64 -0
  12. {mainsequence-4.1.2 → mainsequence-4.1.3}/LICENSE +0 -0
  13. {mainsequence-4.1.2 → mainsequence-4.1.3}/README.md +0 -0
  14. {mainsequence-4.1.2 → mainsequence-4.1.3}/agent_scaffold/AGENTS.md +0 -0
  15. {mainsequence-4.1.2 → mainsequence-4.1.3}/agent_scaffold/skills/a2a_communication/SKILL.md +0 -0
  16. {mainsequence-4.1.2 → mainsequence-4.1.3}/agent_scaffold/skills/application_surfaces/api_surfaces/SKILL.md +0 -0
  17. {mainsequence-4.1.2 → mainsequence-4.1.3}/agent_scaffold/skills/command_center/adapter_from_api/SKILL.md +0 -0
  18. {mainsequence-4.1.2 → mainsequence-4.1.3}/agent_scaffold/skills/command_center/api_mock_prototyping/SKILL.md +0 -0
  19. {mainsequence-4.1.2 → mainsequence-4.1.3}/agent_scaffold/skills/command_center/app_components/SKILL.md +0 -0
  20. {mainsequence-4.1.2 → mainsequence-4.1.3}/agent_scaffold/skills/command_center/connections/SKILL.md +0 -0
  21. {mainsequence-4.1.2 → mainsequence-4.1.3}/agent_scaffold/skills/command_center/workspace_analysis/SKILL.md +0 -0
  22. {mainsequence-4.1.2 → mainsequence-4.1.3}/agent_scaffold/skills/command_center/workspace_builder/SKILL.md +0 -0
  23. {mainsequence-4.1.2 → mainsequence-4.1.3}/agent_scaffold/skills/command_center/workspace_design/SKILL.md +0 -0
  24. {mainsequence-4.1.2 → mainsequence-4.1.3}/agent_scaffold/skills/dashboards/streamlit/SKILL.md +0 -0
  25. {mainsequence-4.1.2 → mainsequence-4.1.3}/agent_scaffold/skills/data_access/exploration/SKILL.md +0 -0
  26. {mainsequence-4.1.2 → mainsequence-4.1.3}/agent_scaffold/skills/data_publishing/data_nodes/SKILL.md +0 -0
  27. {mainsequence-4.1.2 → mainsequence-4.1.3}/agent_scaffold/skills/data_publishing/meta_tables/SKILL.md +0 -0
  28. {mainsequence-4.1.2 → mainsequence-4.1.3}/agent_scaffold/skills/maintenance/bug_auditor/SKILL.md +0 -0
  29. {mainsequence-4.1.2 → mainsequence-4.1.3}/agent_scaffold/skills/ms-markets/SKILL.md +0 -0
  30. {mainsequence-4.1.2 → mainsequence-4.1.3}/agent_scaffold/skills/platform_operations/access_control_and_sharing/SKILL.md +0 -0
  31. {mainsequence-4.1.2 → mainsequence-4.1.3}/agent_scaffold/skills/platform_operations/orchestration_and_releases/SKILL.md +0 -0
  32. {mainsequence-4.1.2 → mainsequence-4.1.3}/agent_scaffold/skills/project_builder/SKILL.md +0 -0
  33. {mainsequence-4.1.2 → mainsequence-4.1.3}/agent_scaffold/skills/project_to_agent/SKILL.md +0 -0
  34. {mainsequence-4.1.2 → mainsequence-4.1.3}/mainsequence/__init__.py +0 -0
  35. {mainsequence-4.1.2 → mainsequence-4.1.3}/mainsequence/__main__.py +0 -0
  36. {mainsequence-4.1.2 → mainsequence-4.1.3}/mainsequence/bootstrap.py +0 -0
  37. {mainsequence-4.1.2 → mainsequence-4.1.3}/mainsequence/cli/__init__.py +0 -0
  38. {mainsequence-4.1.2 → mainsequence-4.1.3}/mainsequence/cli/api.py +0 -0
  39. {mainsequence-4.1.2 → mainsequence-4.1.3}/mainsequence/cli/browser_auth.py +0 -0
  40. {mainsequence-4.1.2 → mainsequence-4.1.3}/mainsequence/cli/cli.py +0 -0
  41. {mainsequence-4.1.2 → mainsequence-4.1.3}/mainsequence/cli/config.py +0 -0
  42. {mainsequence-4.1.2 → mainsequence-4.1.3}/mainsequence/cli/docker_utils.py +0 -0
  43. {mainsequence-4.1.2 → mainsequence-4.1.3}/mainsequence/cli/doctor.py +0 -0
  44. {mainsequence-4.1.2 → mainsequence-4.1.3}/mainsequence/cli/local_ops.py +0 -0
  45. {mainsequence-4.1.2 → mainsequence-4.1.3}/mainsequence/cli/model_filters.py +0 -0
  46. {mainsequence-4.1.2 → mainsequence-4.1.3}/mainsequence/cli/project_status.py +0 -0
  47. {mainsequence-4.1.2 → mainsequence-4.1.3}/mainsequence/cli/pydantic_cli.py +0 -0
  48. {mainsequence-4.1.2 → mainsequence-4.1.3}/mainsequence/cli/sdk_utils.py +0 -0
  49. {mainsequence-4.1.2 → mainsequence-4.1.3}/mainsequence/cli/ssh_utils.py +0 -0
  50. {mainsequence-4.1.2 → mainsequence-4.1.3}/mainsequence/cli/ui.py +0 -0
  51. {mainsequence-4.1.2 → mainsequence-4.1.3}/mainsequence/client/__init__.py +0 -0
  52. {mainsequence-4.1.2 → mainsequence-4.1.3}/mainsequence/client/agent_runtime_models.py +0 -0
  53. {mainsequence-4.1.2 → mainsequence-4.1.3}/mainsequence/client/base.py +0 -0
  54. {mainsequence-4.1.2 → mainsequence-4.1.3}/mainsequence/client/client.py +0 -0
  55. {mainsequence-4.1.2 → mainsequence-4.1.3}/mainsequence/client/command_center/__init__.py +0 -0
  56. {mainsequence-4.1.2 → mainsequence-4.1.3}/mainsequence/client/command_center/app_component.py +0 -0
  57. {mainsequence-4.1.2 → mainsequence-4.1.3}/mainsequence/client/command_center/connections.py +0 -0
  58. {mainsequence-4.1.2 → mainsequence-4.1.3}/mainsequence/client/command_center/data_models.py +0 -0
  59. {mainsequence-4.1.2 → mainsequence-4.1.3}/mainsequence/client/command_center/workspace.py +0 -0
  60. {mainsequence-4.1.2 → mainsequence-4.1.3}/mainsequence/client/command_center/workspace_snapshot.py +0 -0
  61. {mainsequence-4.1.2 → mainsequence-4.1.3}/mainsequence/client/data_sources_interfaces/__init__.py +0 -0
  62. {mainsequence-4.1.2 → mainsequence-4.1.3}/mainsequence/client/data_sources_interfaces/duckdb.py +0 -0
  63. {mainsequence-4.1.2 → mainsequence-4.1.3}/mainsequence/client/data_sources_interfaces/sqlite.py +0 -0
  64. {mainsequence-4.1.2 → mainsequence-4.1.3}/mainsequence/client/exceptions.py +0 -0
  65. {mainsequence-4.1.2 → mainsequence-4.1.3}/mainsequence/client/fastapi/__init__.py +0 -0
  66. {mainsequence-4.1.2 → mainsequence-4.1.3}/mainsequence/client/fastapi/auth.py +0 -0
  67. {mainsequence-4.1.2 → mainsequence-4.1.3}/mainsequence/client/models_foundry.py +0 -0
  68. {mainsequence-4.1.2 → mainsequence-4.1.3}/mainsequence/client/models_helpers.py +0 -0
  69. {mainsequence-4.1.2 → mainsequence-4.1.3}/mainsequence/client/models_user.py +0 -0
  70. {mainsequence-4.1.2 → mainsequence-4.1.3}/mainsequence/compute_validation.py +0 -0
  71. {mainsequence-4.1.2 → mainsequence-4.1.3}/mainsequence/defaults.py +0 -0
  72. {mainsequence-4.1.2 → mainsequence-4.1.3}/mainsequence/instrumentation/__init__.py +0 -0
  73. {mainsequence-4.1.2 → mainsequence-4.1.3}/mainsequence/instrumentation/utils.py +0 -0
  74. {mainsequence-4.1.2 → mainsequence-4.1.3}/mainsequence/logconf.py +0 -0
  75. {mainsequence-4.1.2 → mainsequence-4.1.3}/mainsequence/meta_tables/__init__.py +0 -0
  76. {mainsequence-4.1.2 → mainsequence-4.1.3}/mainsequence/meta_tables/__main__.py +0 -0
  77. {mainsequence-4.1.2 → mainsequence-4.1.3}/mainsequence/meta_tables/compiled_sql.py +0 -0
  78. {mainsequence-4.1.2 → mainsequence-4.1.3}/mainsequence/meta_tables/config.py +0 -0
  79. {mainsequence-4.1.2 → mainsequence-4.1.3}/mainsequence/meta_tables/configuration_models.py +0 -0
  80. {mainsequence-4.1.2 → mainsequence-4.1.3}/mainsequence/meta_tables/data_nodes/__init__.py +0 -0
  81. {mainsequence-4.1.2 → mainsequence-4.1.3}/mainsequence/meta_tables/data_nodes/data_nodes.py +0 -0
  82. {mainsequence-4.1.2 → mainsequence-4.1.3}/mainsequence/meta_tables/data_nodes/models.py +0 -0
  83. {mainsequence-4.1.2 → mainsequence-4.1.3}/mainsequence/meta_tables/data_nodes/namespacing.py +0 -0
  84. {mainsequence-4.1.2 → mainsequence-4.1.3}/mainsequence/meta_tables/data_nodes/run_operations.py +0 -0
  85. {mainsequence-4.1.2 → mainsequence-4.1.3}/mainsequence/meta_tables/data_nodes/utils.py +0 -0
  86. {mainsequence-4.1.2 → mainsequence-4.1.3}/mainsequence/meta_tables/future_registry.py +0 -0
  87. {mainsequence-4.1.2 → mainsequence-4.1.3}/mainsequence/meta_tables/hashing.py +0 -0
  88. {mainsequence-4.1.2 → mainsequence-4.1.3}/mainsequence/meta_tables/pydantic_metadata.py +0 -0
  89. {mainsequence-4.1.2 → mainsequence-4.1.3}/mainsequence/meta_tables/sqlalchemy_contracts.py +0 -0
  90. {mainsequence-4.1.2 → mainsequence-4.1.3}/mainsequence/meta_tables/utils.py +0 -0
  91. {mainsequence-4.1.2 → mainsequence-4.1.3}/mainsequence/runtime_flags.py +0 -0
  92. {mainsequence-4.1.2 → mainsequence-4.1.3}/mainsequence.egg-info/SOURCES.txt +0 -0
  93. {mainsequence-4.1.2 → mainsequence-4.1.3}/mainsequence.egg-info/dependency_links.txt +0 -0
  94. {mainsequence-4.1.2 → mainsequence-4.1.3}/mainsequence.egg-info/entry_points.txt +0 -0
  95. {mainsequence-4.1.2 → mainsequence-4.1.3}/mainsequence.egg-info/requires.txt +0 -0
  96. {mainsequence-4.1.2 → mainsequence-4.1.3}/mainsequence.egg-info/top_level.txt +0 -0
  97. {mainsequence-4.1.2 → mainsequence-4.1.3}/setup.cfg +0 -0
  98. {mainsequence-4.1.2 → mainsequence-4.1.3}/tests/test_auth_precedence.py +0 -0
  99. {mainsequence-4.1.2 → mainsequence-4.1.3}/tests/test_cli.py +0 -0
  100. {mainsequence-4.1.2 → mainsequence-4.1.3}/tests/test_cli_browser_auth.py +0 -0
  101. {mainsequence-4.1.2 → mainsequence-4.1.3}/tests/test_client.py +0 -0
  102. {mainsequence-4.1.2 → mainsequence-4.1.3}/tests/test_command_center_app_component_models.py +0 -0
  103. {mainsequence-4.1.2 → mainsequence-4.1.3}/tests/test_command_center_data_models.py +0 -0
  104. {mainsequence-4.1.2 → mainsequence-4.1.3}/tests/test_command_center_models.py +0 -0
  105. {mainsequence-4.1.2 → mainsequence-4.1.3}/tests/test_data_access_mixin_dimension_audit.py +0 -0
  106. {mainsequence-4.1.2 → mainsequence-4.1.3}/tests/test_data_node_storage_dimension_queries.py +0 -0
  107. {mainsequence-4.1.2 → mainsequence-4.1.3}/tests/test_dependency_extras.py +0 -0
  108. {mainsequence-4.1.2 → mainsequence-4.1.3}/tests/test_duckdb_interface_dimensions.py +0 -0
  109. {mainsequence-4.1.2 → mainsequence-4.1.3}/tests/test_filter_normalization.py +0 -0
  110. {mainsequence-4.1.2 → mainsequence-4.1.3}/tests/test_logconf.py +0 -0
  111. {mainsequence-4.1.2 → mainsequence-4.1.3}/tests/test_meta_tables_client_models.py +0 -0
  112. {mainsequence-4.1.2 → mainsequence-4.1.3}/tests/test_meta_tables_sqlalchemy_contracts.py +0 -0
  113. {mainsequence-4.1.2 → mainsequence-4.1.3}/tests/test_models_user_request_bound_auth.py +0 -0
  114. {mainsequence-4.1.2 → mainsequence-4.1.3}/tests/test_pod_project_resolution.py +0 -0
  115. {mainsequence-4.1.2 → mainsequence-4.1.3}/tests/test_project_batch_jobs_from_file.py +0 -0
  116. {mainsequence-4.1.2 → mainsequence-4.1.3}/tests/test_secret_client_model.py +0 -0
  117. {mainsequence-4.1.2 → mainsequence-4.1.3}/tests/test_source_table_configuration.py +0 -0
  118. {mainsequence-4.1.2 → mainsequence-4.1.3}/tests/test_sqlite_interface_dimensions.py +0 -0
  119. {mainsequence-4.1.2 → mainsequence-4.1.3}/tests/test_update_runner_uid_runtime.py +0 -0
  120. {mainsequence-4.1.2 → mainsequence-4.1.3}/tests/test_update_statistics.py +0 -0
  121. {mainsequence-4.1.2 → mainsequence-4.1.3}/tests/test_update_uid_guards.py +0 -0
  122. {mainsequence-4.1.2 → mainsequence-4.1.3}/tests/test_workspace_snapshot.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mainsequence
3
- Version: 4.1.2
3
+ Version: 4.1.3
4
4
  Summary: Main Sequence SDK
5
5
  Author-email: Main Sequence GmbH <dev@main-sequence.io>
6
6
  License: MainSequence GmbH SDK License Agreement
@@ -163,9 +163,7 @@ def record_definitions_to_column_dtypes_map(
163
163
  )
164
164
 
165
165
  if duplicate_columns:
166
- raise ValueError(
167
- f"Duplicate DataNode record column names: {sorted(duplicate_columns)}"
168
- )
166
+ raise ValueError(f"Duplicate DataNode record column names: {sorted(duplicate_columns)}")
169
167
 
170
168
  return column_dtypes_map
171
169
 
@@ -247,10 +245,14 @@ def token_to_pandas_series(
247
245
  is_time_index: bool = False,
248
246
  nullable: bool = True,
249
247
  ) -> pd.Series:
250
- normalized = TIMESTAMP_TZ if is_time_index else normalize_dtype_token(
251
- token,
252
- remote=False,
253
- allow_naive_datetime=True,
248
+ normalized = (
249
+ TIMESTAMP_TZ
250
+ if is_time_index
251
+ else normalize_dtype_token(
252
+ token,
253
+ remote=False,
254
+ allow_naive_datetime=True,
255
+ )
254
256
  )
255
257
  if normalized == TIMESTAMP_TZ:
256
258
  return pd.to_datetime(series, errors="coerce", utc=True)
@@ -319,17 +321,15 @@ def _serialize_timestamp_tz_value(value: Any) -> str | None:
319
321
  raise ValueError(
320
322
  f"Remote datetime value {value!r} must include an explicit timezone offset or Z."
321
323
  )
322
- if isinstance(value, (pd.Timestamp, datetime.datetime)) and not _timestamp_is_timezone_aware(value):
323
- raise ValueError(
324
- f"Remote datetime value {value!r} must be timezone-aware."
325
- )
324
+ if isinstance(value, (pd.Timestamp, datetime.datetime)) and not _timestamp_is_timezone_aware(
325
+ value
326
+ ):
327
+ raise ValueError(f"Remote datetime value {value!r} must be timezone-aware.")
326
328
  timestamp = pd.to_datetime(value, errors="raise", utc=True)
327
329
  if not isinstance(timestamp, pd.Timestamp):
328
330
  timestamp = pd.Timestamp(timestamp).tz_convert("UTC")
329
331
  if timestamp.tzinfo is None:
330
- raise ValueError(
331
- f"Remote datetime value {value!r} must be timezone-aware."
332
- )
332
+ raise ValueError(f"Remote datetime value {value!r} must be timezone-aware.")
333
333
  timestamp = timestamp.tz_convert("UTC")
334
334
  return timestamp.isoformat().replace("+00:00", "Z")
335
335
 
@@ -375,11 +375,8 @@ def prepare_dataframe_for_remote_write(
375
375
  if column_name not in prepared.columns:
376
376
  continue
377
377
  normalized = normalize_dtype_token(token, remote=True)
378
- if normalized not in {DATE, TIMESTAMP_TZ}:
379
- continue
380
378
  prepared[column_name] = [
381
- serialize_remote_value(value, normalized)
382
- for value in prepared[column_name].tolist()
379
+ serialize_remote_value(value, normalized) for value in prepared[column_name].tolist()
383
380
  ]
384
381
  return prepared
385
382
 
@@ -459,7 +456,11 @@ def sqlalchemy_type_to_token(column_type: Any, *, remote: bool = True) -> str:
459
456
  "by the remote TS Manager contract."
460
457
  )
461
458
  return LOCAL_DATETIME_NAIVE
462
- if timezone is True or "with time zone" in normalized_backend or "timestamptz" in normalized_backend:
459
+ if (
460
+ timezone is True
461
+ or "with time zone" in normalized_backend
462
+ or "timestamptz" in normalized_backend
463
+ ):
463
464
  return TIMESTAMP_TZ
464
465
  if remote:
465
466
  raise ValueError(
@@ -1244,9 +1244,6 @@ class MetaTable(BasePydanticModel, LabelableObjectMixin, ShareableObjectMixin, B
1244
1244
  )
1245
1245
 
1246
1246
 
1247
- LOGICAL_COLUMN_DTYPES_ATTR = "mainsequence_column_dtypes_map"
1248
-
1249
-
1250
1247
  def _warn_legacy_compat(message: str, *, stacklevel: int = 3) -> None:
1251
1248
  warnings.warn(
1252
1249
  f"Deprecated TDAG compatibility path: {message}",
@@ -2143,7 +2140,6 @@ class DataNodeUpdate(TableUpdateNode, BaseObjectOrm):
2143
2140
  remote_dtypes: bool = True,
2144
2141
  allow_naive_datetime: bool = False,
2145
2142
  ):
2146
- logical_column_dtypes_map = data_frame.attrs.get(LOGICAL_COLUMN_DTYPES_ATTR)
2147
2143
  record_column_dtypes_map = record_definitions_to_column_dtypes_map(
2148
2144
  records,
2149
2145
  remote=remote_dtypes,
@@ -2178,40 +2174,6 @@ class DataNodeUpdate(TableUpdateNode, BaseObjectOrm):
2178
2174
  "DataNode records declare columns not present in the DataFrame: "
2179
2175
  f"{missing_record_columns}"
2180
2176
  )
2181
- if logical_column_dtypes_map is not None:
2182
- logical_column_dtypes_map = normalize_column_dtypes_map(
2183
- logical_column_dtypes_map,
2184
- remote=remote_dtypes,
2185
- allow_naive_datetime=allow_naive_datetime,
2186
- )
2187
- missing_logical_columns = [
2188
- column_name
2189
- for column_name in logical_column_dtypes_map
2190
- if column_name not in data_frame.columns
2191
- ]
2192
- if missing_logical_columns:
2193
- raise ValueError(
2194
- "Logical column dtype contract contains columns not present "
2195
- f"in the DataFrame: {missing_logical_columns}"
2196
- )
2197
- conflicting_declared_dtypes = {
2198
- column_name: {
2199
- "record_dtype": record_column_dtypes_map[column_name],
2200
- "logical_dtype": logical_column_dtypes_map[column_name],
2201
- }
2202
- for column_name in logical_column_dtypes_map
2203
- if (
2204
- column_name in record_column_dtypes_map
2205
- and logical_column_dtypes_map[column_name]
2206
- != record_column_dtypes_map[column_name]
2207
- )
2208
- }
2209
- if conflicting_declared_dtypes:
2210
- raise ValueError(
2211
- "Logical column dtype contract conflicts with DataNode records: "
2212
- f"{conflicting_declared_dtypes}"
2213
- )
2214
- column_dtypes_map.update(logical_column_dtypes_map)
2215
2177
  column_dtypes_map.update(record_column_dtypes_map)
2216
2178
 
2217
2179
  data_frame = data_frame.replace({np.nan: None})
@@ -3970,10 +3932,70 @@ class UpdateStatistics(BaseUpdateStatistics):
3970
3932
  def _assign_nested_coordinate(root: dict[Any, Any], keys: list[Any], value: Any) -> None:
3971
3933
  if not keys:
3972
3934
  return
3935
+ normalized_keys = [_normalize_update_stat_key(key) for key in keys]
3973
3936
  sub = root
3974
- for key in keys[:-1]:
3975
- sub = sub.setdefault(key, {})
3976
- sub[keys[-1]] = value
3937
+ for key in normalized_keys[:-1]:
3938
+ existing = sub.setdefault(key, {})
3939
+ if not isinstance(existing, dict):
3940
+ raise ValueError("Update statistics coordinate keys collide after JSON normalization.")
3941
+ sub = existing
3942
+ final_key = normalized_keys[-1]
3943
+ if final_key in sub:
3944
+ raise ValueError("Update statistics coordinate keys collide after JSON normalization.")
3945
+ sub[final_key] = value
3946
+
3947
+
3948
+ def _normalize_update_stat_key(key: Any) -> str:
3949
+ if isinstance(key, str):
3950
+ return key
3951
+ if isinstance(key, np.generic):
3952
+ key = key.item()
3953
+ if isinstance(key, datetime.datetime):
3954
+ value = serialize_to_json({"_": key})["_"]
3955
+ return str(value)
3956
+ if isinstance(key, datetime.date):
3957
+ return key.isoformat()
3958
+ if key is None:
3959
+ return "null"
3960
+ if isinstance(key, bool):
3961
+ return "true" if key else "false"
3962
+ if isinstance(key, int | float):
3963
+ try:
3964
+ return json.dumps(key, allow_nan=False)
3965
+ except (TypeError, ValueError):
3966
+ return str(key)
3967
+
3968
+ normalized = serialize_to_json({key: None})
3969
+ normalized_key = next(iter(normalized.keys()))
3970
+ if normalized_key is None:
3971
+ return "null"
3972
+ if isinstance(normalized_key, bool):
3973
+ return "true" if normalized_key else "false"
3974
+ if isinstance(normalized_key, int | float):
3975
+ try:
3976
+ return json.dumps(normalized_key, allow_nan=False)
3977
+ except (TypeError, ValueError):
3978
+ return str(normalized_key)
3979
+ return str(normalized_key)
3980
+
3981
+
3982
+ def _normalize_update_stat_mapping_keys(value: Any) -> Any:
3983
+ if isinstance(value, Mapping):
3984
+ normalized: dict[str, Any] = {}
3985
+ for key, item in value.items():
3986
+ normalized_key = _normalize_update_stat_key(key)
3987
+ if normalized_key in normalized:
3988
+ raise ValueError(
3989
+ "Update statistics coordinate keys collide after JSON normalization: "
3990
+ f"{normalized_key!r}."
3991
+ )
3992
+ normalized[normalized_key] = _normalize_update_stat_mapping_keys(item)
3993
+ return normalized
3994
+ if isinstance(value, list):
3995
+ return [_normalize_update_stat_mapping_keys(item) for item in value]
3996
+ if isinstance(value, tuple):
3997
+ return [_normalize_update_stat_mapping_keys(item) for item in value]
3998
+ return value
3977
3999
 
3978
4000
 
3979
4001
  def get_index_progress_chunk_stats(chunk_df, time_index_name, index_names):
@@ -4081,6 +4103,11 @@ class LastUpdateMultiIndexStatsPayload(BaseModel):
4081
4103
  index_progress: dict[str, Any] = Field(default_factory=dict)
4082
4104
  index_min: dict[str, Any] = Field(default_factory=dict)
4083
4105
 
4106
+ @model_validator(mode="before")
4107
+ @classmethod
4108
+ def _normalize_mapping_keys(cls, value: Any) -> Any:
4109
+ return _normalize_update_stat_mapping_keys(value)
4110
+
4084
4111
  def to_payload(self) -> dict[str, Any]:
4085
4112
  return {
4086
4113
  "_GLOBAL_": self.global_stats,
@@ -4099,6 +4126,11 @@ class LastUpdateIndexTimePayload(BaseModel):
4099
4126
  multi_index_stats: LastUpdateMultiIndexStatsPayload | None = None
4100
4127
  multi_index_column_stats: dict[str, Any] | None = Field(default_factory=dict)
4101
4128
 
4129
+ @model_validator(mode="before")
4130
+ @classmethod
4131
+ def _normalize_mapping_keys(cls, value: Any) -> Any:
4132
+ return _normalize_update_stat_mapping_keys(value)
4133
+
4102
4134
  @model_validator(mode="after")
4103
4135
  def _validate_shape(self):
4104
4136
  top_level_progress_keys = [
@@ -4164,7 +4196,8 @@ def build_last_update_index_time_payload(
4164
4196
  }
4165
4197
  )
4166
4198
 
4167
- return LastUpdateIndexTimePayload.model_validate(raw_payload).to_nested_payload()
4199
+ normalized_payload = _normalize_update_stat_mapping_keys(raw_payload)
4200
+ return LastUpdateIndexTimePayload.model_validate(normalized_payload).to_nested_payload()
4168
4201
 
4169
4202
 
4170
4203
  class HistoricalUpdateRecord:
@@ -9,11 +9,11 @@ import socket
9
9
  import subprocess
10
10
  import threading
11
11
  import time
12
- import uuid
13
12
  from dataclasses import dataclass, field
14
13
  from decimal import Decimal
15
14
  from enum import Enum
16
15
  from typing import TypedDict
16
+ from uuid import UUID, getnode
17
17
 
18
18
  import psutil
19
19
  import pytz
@@ -761,6 +761,9 @@ def serialize_to_json(kwargs):
761
761
  if isinstance(v, Decimal):
762
762
  return str(v)
763
763
 
764
+ if isinstance(v, UUID):
765
+ return str(v)
766
+
764
767
  if isinstance(v, datetime.datetime):
765
768
  dt = v
766
769
  if dt.tzinfo is None:
@@ -776,13 +779,19 @@ def serialize_to_json(kwargs):
776
779
  return v.model_dump()
777
780
 
778
781
  if isinstance(v, dict):
779
- return {k: to_jsonable(x) for k, x in v.items()}
782
+ return {to_json_key(k): to_jsonable(x) for k, x in v.items()}
780
783
  if isinstance(v, (list, tuple)):
781
784
  return [to_jsonable(x) for x in v]
782
785
 
783
786
  return v
784
787
 
785
- return {k: to_jsonable(v) for k, v in kwargs.items()}
788
+ def to_json_key(value):
789
+ key = to_jsonable(value)
790
+ if key is None or isinstance(key, str | int | float | bool):
791
+ return key
792
+ return str(key)
793
+
794
+ return {to_json_key(k): to_jsonable(v) for k, v in kwargs.items()}
786
795
 
787
796
 
788
797
  def _linux_machine_id() -> str | None:
@@ -839,7 +848,7 @@ def bios_uuid() -> str:
839
848
  return mid
840
849
 
841
850
  # Tier 4 – MAC address (uuid.getnode). Always available.
842
- return f"{uuid.getnode():012x}"
851
+ return f"{getnode():012x}"
843
852
 
844
853
 
845
854
  def _install_retry_adapters_in_place(
@@ -13,6 +13,7 @@ from enum import Enum
13
13
  from functools import singledispatch
14
14
  from types import SimpleNamespace
15
15
  from typing import TYPE_CHECKING, Any
16
+ from uuid import UUID
16
17
 
17
18
  from pydantic import BaseModel
18
19
 
@@ -111,6 +112,11 @@ def _(value: datetime.datetime) -> str:
111
112
  return value.isoformat()
112
113
 
113
114
 
115
+ @serialize_argument.register(UUID)
116
+ def _(value: UUID) -> str:
117
+ return str(value)
118
+
119
+
114
120
  @serialize_argument.register(BaseModel)
115
121
  def _(value: BaseModel) -> dict[str, Any]:
116
122
  """Serialization logic for any Pydantic BaseModel."""
@@ -438,11 +438,22 @@ class BasePersistManager:
438
438
  data=temp_df,
439
439
  data_source=self.data_source,
440
440
  overwrite=overwrite,
441
+ source_table_schema=self._source_table_schema(),
441
442
  )
442
443
 
443
444
  persisted = True
444
445
  return persisted
445
446
 
447
+ def _source_table_schema(self) -> dict[str, Any]:
448
+ time_index_name, index_names, column_dtypes_map = (
449
+ self.storage_metadata._require_time_indexed_table_contract()
450
+ )
451
+ return {
452
+ "time_index_name": time_index_name,
453
+ "index_names": list(index_names),
454
+ "column_dtypes_map": dict(column_dtypes_map),
455
+ }
456
+
446
457
  def get_update_statistics_for_table(self) -> UpdateStatistics:
447
458
  return self.storage_metadata.get_data_updates()
448
459
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mainsequence
3
- Version: 4.1.2
3
+ Version: 4.1.3
4
4
  Summary: Main Sequence SDK
5
5
  Author-email: Main Sequence GmbH <dev@main-sequence.io>
6
6
  License: MainSequence GmbH SDK License Agreement
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "mainsequence"
7
- version = "4.1.2"
7
+ version = "4.1.3"
8
8
  description = "Main Sequence SDK "
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -2,6 +2,7 @@ from __future__ import annotations
2
2
 
3
3
  import datetime
4
4
  import os
5
+ import uuid
5
6
 
6
7
  import pytest
7
8
  from pydantic import BaseModel, Field
@@ -24,6 +25,10 @@ def _hashes(payload):
24
25
  return build_operations.hash_signature({"config": serialized_payload})
25
26
 
26
27
 
28
+ class UUIDNodeConfig(BaseModel):
29
+ account_uid: uuid.UUID
30
+
31
+
27
32
  def test_create_config_crops_hash_prefix_to_postgres_identifier_limit(monkeypatch):
28
33
  monkeypatch.setattr(build_operations, "POD_PROJECT", None, raising=False)
29
34
 
@@ -211,6 +216,35 @@ def test_platform_time_index_metadata_config_hashes_by_bound_metadata_uid(monkey
211
216
  )
212
217
 
213
218
 
219
+ def test_uuid_config_values_serialize_hash_and_rebuild(monkeypatch):
220
+ monkeypatch.setattr(build_operations, "POD_PROJECT", None, raising=False)
221
+
222
+ account_uid = uuid.UUID("00000000-0000-4000-8000-000000000001")
223
+ other_account_uid = uuid.UUID("00000000-0000-4000-8000-000000000002")
224
+
225
+ serialized = build_operations.serialize_argument(UUIDNodeConfig(account_uid=account_uid))
226
+ assert serialized["serialized_model"]["account_uid"] == str(account_uid)
227
+
228
+ hashes_a = _hashes(UUIDNodeConfig(account_uid=account_uid))
229
+ hashes_b = _hashes(UUIDNodeConfig(account_uid=account_uid))
230
+ hashes_c = _hashes(UUIDNodeConfig(account_uid=other_account_uid))
231
+
232
+ assert hashes_a == hashes_b
233
+ assert hashes_a != hashes_c
234
+
235
+ config = build_operations.create_config(
236
+ ts_class_name="UUIDConfigNode",
237
+ kwargs={"config": UUIDNodeConfig(account_uid=account_uid)},
238
+ )
239
+ rebuilt = build_operations.DeserializerManager().rebuild_serialized_config(
240
+ config.local_initial_configuration,
241
+ time_serie_class_name="UUIDConfigNode",
242
+ )
243
+
244
+ assert rebuilt["config"].account_uid == account_uid
245
+ assert isinstance(rebuilt["config"].account_uid, uuid.UUID)
246
+
247
+
214
248
  def test_plain_dict_with_pydantic_model_import_path_key_is_not_treated_as_wrapper(monkeypatch):
215
249
  monkeypatch.setattr(build_operations, "POD_PROJECT", None, raising=False)
216
250
 
@@ -3,6 +3,7 @@ import datetime
3
3
  import gzip
4
4
  import json
5
5
  from types import SimpleNamespace
6
+ from uuid import UUID
6
7
 
7
8
  import pandas as pd
8
9
  import pytest
@@ -127,6 +128,72 @@ def test_post_data_frame_in_chunks_serializes_remote_temporal_payload_columns(mo
127
128
  ]
128
129
 
129
130
 
131
+ def test_post_data_frame_in_chunks_serializes_remote_uuid_payload_columns(monkeypatch):
132
+ captured = {}
133
+
134
+ class FakeResponse:
135
+ status_code = 200
136
+ text = ""
137
+ content = b'{"ok": true}'
138
+
139
+ @staticmethod
140
+ def json():
141
+ return {"ok": True}
142
+
143
+ def _fake_make_request(*, s, loaders, payload, r_type, url, time_out=None):
144
+ captured["payload"] = payload
145
+ captured["r_type"] = r_type
146
+ captured["url"] = url
147
+ return FakeResponse()
148
+
149
+ monkeypatch.setattr(models_metatables, "make_request", _fake_make_request)
150
+ monkeypatch.setattr(
151
+ models_metatables.DataNodeUpdate,
152
+ "build_session",
153
+ classmethod(lambda cls: object()),
154
+ )
155
+
156
+ account_uid = UUID("00000000-0000-4000-8000-000000000001")
157
+ frame = pd.DataFrame(
158
+ {
159
+ "time_index": [pd.Timestamp("2026-05-29T13:40:00Z")],
160
+ "account_uid": [account_uid],
161
+ "unique_identifier": ["AAPL"],
162
+ "quantity": [12.0],
163
+ }
164
+ )
165
+
166
+ models_metatables.DataNodeUpdate.post_data_frame_in_chunks(
167
+ serialized_data_frame=frame,
168
+ data_node_update=_minimal_update(),
169
+ index_names=["time_index", "account_uid", "unique_identifier"],
170
+ time_index_name="time_index",
171
+ column_dtypes_map={
172
+ "time_index": "timestamp with time zone",
173
+ "account_uid": "uuid",
174
+ "unique_identifier": "string",
175
+ "quantity": "float64",
176
+ },
177
+ )
178
+
179
+ decoded = _decode_compressed_payload(captured["payload"])
180
+ assert decoded == [
181
+ {
182
+ "time_index": "2026-05-29T13:40:00Z",
183
+ "account_uid": str(account_uid),
184
+ "unique_identifier": "AAPL",
185
+ "quantity": 12.0,
186
+ }
187
+ ]
188
+ json.dumps(captured["payload"]["json"], allow_nan=False)
189
+ assert captured["payload"]["json"]["chunk_stats"]["index_progress"] == {
190
+ str(account_uid): {"AAPL": "2026-05-29T13:40:00Z"}
191
+ }
192
+ assert captured["payload"]["json"]["chunk_stats"]["index_min"] == {
193
+ str(account_uid): {"AAPL": "2026-05-29T13:40:00Z"}
194
+ }
195
+
196
+
130
197
  def test_set_start_of_execution_prefers_canonical_update_stats(monkeypatch):
131
198
  class FakeResponse:
132
199
  status_code = 201
@@ -243,6 +310,50 @@ def test_last_update_payload_model_rejects_unknown_keys_generically():
243
310
  )
244
311
 
245
312
 
313
+ def test_last_update_payload_builder_normalizes_nested_coordinate_keys():
314
+ account_uid = UUID("00000000-0000-4000-8000-000000000001")
315
+ raw_payload = {
316
+ "global_index_progress": {
317
+ "max": _dt(3),
318
+ "min": _dt(0),
319
+ },
320
+ "index_progress": {account_uid: {101: _dt(2)}},
321
+ "index_min": {account_uid: {101: _dt(0)}},
322
+ "multi_index_column_stats": {
323
+ "quantity": {
324
+ account_uid: {
325
+ 101: {
326
+ "min": _dt(0),
327
+ "max": _dt(2),
328
+ }
329
+ }
330
+ }
331
+ },
332
+ }
333
+
334
+ payload = models_metatables.LastUpdateIndexTimePayload.model_validate(
335
+ raw_payload
336
+ ).to_nested_payload()
337
+ builder_payload = models_metatables.build_last_update_index_time_payload(
338
+ **raw_payload,
339
+ )
340
+
341
+ account_key = str(account_uid)
342
+ assert payload["multi_index_stats"]["index_progress"] == {account_key: {"101": _dt(2)}}
343
+ assert payload["multi_index_stats"]["index_min"] == {account_key: {"101": _dt(0)}}
344
+ assert payload["multi_index_column_stats"] == {
345
+ "quantity": {
346
+ account_key: {
347
+ "101": {
348
+ "min": _dt(0),
349
+ "max": _dt(2),
350
+ }
351
+ }
352
+ }
353
+ }
354
+ assert builder_payload == payload
355
+
356
+
246
357
  def test_set_last_update_index_time_from_update_stats_sends_canonical_payload(monkeypatch):
247
358
  captured = {}
248
359
 
@@ -335,6 +446,28 @@ def test_get_index_progress_chunk_stats_for_three_index_frame():
335
446
  assert grouped_dates is not None
336
447
 
337
448
 
449
+ def test_get_index_progress_chunk_stats_normalizes_uuid_coordinate_keys():
450
+ account_uid = UUID("00000000-0000-4000-8000-000000000001")
451
+ df = pd.DataFrame(
452
+ {
453
+ "time_index": [_dt(0), _dt(2)],
454
+ "account_uid": [account_uid, account_uid],
455
+ "unique_identifier": ["asset-1", "asset-1"],
456
+ "value": [1, 2],
457
+ }
458
+ )
459
+
460
+ stats, grouped_dates = models_metatables.get_index_progress_chunk_stats(
461
+ df,
462
+ time_index_name="time_index",
463
+ index_names=["time_index", "account_uid", "unique_identifier"],
464
+ )
465
+
466
+ assert stats["index_progress"] == {str(account_uid): {"asset-1": _dt(2)}}
467
+ assert stats["index_min"] == {str(account_uid): {"asset-1": _dt(0)}}
468
+ assert grouped_dates is not None
469
+
470
+
338
471
  def test_set_last_update_index_time_rejects_legacy_per_asset_backend_payload(monkeypatch):
339
472
  def _unexpected_make_request(**_kwargs):
340
473
  raise AssertionError("legacy backend payload should fail before make_request")
@@ -214,6 +214,70 @@ def test_persist_manager_build_update_details_uses_update_details_resource():
214
214
  assert patched == [("data-node-update-44", {})]
215
215
 
216
216
 
217
+ def test_persist_manager_passes_storage_contract_schema_to_update():
218
+ captured = {}
219
+
220
+ class UpdateResource:
221
+ build_configuration = {}
222
+
223
+ def upsert_data_into_table(self, **kwargs):
224
+ captured.update(kwargs)
225
+ return self
226
+
227
+ storage_metadata = TimeIndexMetaData.model_construct(
228
+ uid="data-node-storage-44",
229
+ data_source_uid="data-source-uid",
230
+ data_source=SimpleNamespace(
231
+ related_resource=SimpleNamespace(class_type="postgres"),
232
+ ),
233
+ time_indexed_profile=models_metatables.TimeIndexedProfile(
234
+ related_table_uid="data-node-storage-44",
235
+ time_index_name="time_index",
236
+ index_names=["time_index", "account_uid", "unique_identifier"],
237
+ column_dtypes_map={
238
+ "time_index": "timestamp with time zone",
239
+ "account_uid": "uuid",
240
+ "unique_identifier": "string",
241
+ "quantity": "float64",
242
+ },
243
+ storage_layout={
244
+ "time_index": "time_index",
245
+ "identity_dimensions": ["account_uid", "unique_identifier"],
246
+ },
247
+ physical_index_plan={
248
+ "uniqueness": {
249
+ "columns": ["time_index", "account_uid", "unique_identifier"],
250
+ },
251
+ },
252
+ ),
253
+ )
254
+ storage_table = _platform_storage_model(storage_metadata)
255
+ manager = BasePersistManager(
256
+ update_hash="account-holdings-update-hash",
257
+ storage_table=storage_table,
258
+ data_node_update=UpdateResource(),
259
+ )
260
+ df = pd.DataFrame(
261
+ {"quantity": [12.0]},
262
+ index=pd.MultiIndex.from_tuples(
263
+ [("2026-05-30T12:00:00Z", "account-a", "AAPL")],
264
+ names=["time_index", "account_uid", "unique_identifier"],
265
+ ),
266
+ )
267
+
268
+ assert manager.persist_updated_data(df) is True
269
+ assert captured["source_table_schema"] == {
270
+ "time_index_name": "time_index",
271
+ "index_names": ["time_index", "account_uid", "unique_identifier"],
272
+ "column_dtypes_map": {
273
+ "time_index": "timestamp with time zone",
274
+ "account_uid": "uuid",
275
+ "unique_identifier": "string",
276
+ "quantity": "float64",
277
+ },
278
+ }
279
+
280
+
217
281
  def test_data_node_storage_accepts_namespace():
218
282
  storage = TimeIndexMetaData(
219
283
  uid="data-node-storage-12",
File without changes
File without changes
File without changes