mainsequence 4.2.38__tar.gz → 4.2.40__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 (128) hide show
  1. {mainsequence-4.2.38/mainsequence.egg-info → mainsequence-4.2.40}/PKG-INFO +1 -1
  2. {mainsequence-4.2.38 → mainsequence-4.2.40}/agent_scaffold/skills/data_publishing/data_nodes/SKILL.md +6 -0
  3. {mainsequence-4.2.38 → mainsequence-4.2.40}/agent_scaffold/skills/data_publishing/meta_tables/SKILL.md +6 -2
  4. {mainsequence-4.2.38 → mainsequence-4.2.40}/mainsequence/meta_tables/migrations.py +28 -0
  5. {mainsequence-4.2.38 → mainsequence-4.2.40}/mainsequence/meta_tables/sqlalchemy_contracts.py +122 -10
  6. {mainsequence-4.2.38 → mainsequence-4.2.40/mainsequence.egg-info}/PKG-INFO +1 -1
  7. {mainsequence-4.2.38 → mainsequence-4.2.40}/pyproject.toml +1 -1
  8. {mainsequence-4.2.38 → mainsequence-4.2.40}/tests/test_meta_table_migrations.py +67 -1
  9. {mainsequence-4.2.38 → mainsequence-4.2.40}/tests/test_meta_tables_sqlalchemy_contracts.py +145 -5
  10. {mainsequence-4.2.38 → mainsequence-4.2.40}/LICENSE +0 -0
  11. {mainsequence-4.2.38 → mainsequence-4.2.40}/README.md +0 -0
  12. {mainsequence-4.2.38 → mainsequence-4.2.40}/agent_scaffold/AGENTS.md +0 -0
  13. {mainsequence-4.2.38 → mainsequence-4.2.40}/agent_scaffold/skills/a2a_communication/SKILL.md +0 -0
  14. {mainsequence-4.2.38 → mainsequence-4.2.40}/agent_scaffold/skills/application_surfaces/api_surfaces/SKILL.md +0 -0
  15. {mainsequence-4.2.38 → mainsequence-4.2.40}/agent_scaffold/skills/command_center/adapter_from_api/SKILL.md +0 -0
  16. {mainsequence-4.2.38 → mainsequence-4.2.40}/agent_scaffold/skills/command_center/api_mock_prototyping/SKILL.md +0 -0
  17. {mainsequence-4.2.38 → mainsequence-4.2.40}/agent_scaffold/skills/command_center/app_components/SKILL.md +0 -0
  18. {mainsequence-4.2.38 → mainsequence-4.2.40}/agent_scaffold/skills/command_center/connections/SKILL.md +0 -0
  19. {mainsequence-4.2.38 → mainsequence-4.2.40}/agent_scaffold/skills/command_center/workspace_analysis/SKILL.md +0 -0
  20. {mainsequence-4.2.38 → mainsequence-4.2.40}/agent_scaffold/skills/command_center/workspace_builder/SKILL.md +0 -0
  21. {mainsequence-4.2.38 → mainsequence-4.2.40}/agent_scaffold/skills/command_center/workspace_design/SKILL.md +0 -0
  22. {mainsequence-4.2.38 → mainsequence-4.2.40}/agent_scaffold/skills/dashboards/streamlit/SKILL.md +0 -0
  23. {mainsequence-4.2.38 → mainsequence-4.2.40}/agent_scaffold/skills/data_access/exploration/SKILL.md +0 -0
  24. {mainsequence-4.2.38 → mainsequence-4.2.40}/agent_scaffold/skills/maintenance/bug_auditor/SKILL.md +0 -0
  25. {mainsequence-4.2.38 → mainsequence-4.2.40}/agent_scaffold/skills/ms-markets/SKILL.md +0 -0
  26. {mainsequence-4.2.38 → mainsequence-4.2.40}/agent_scaffold/skills/platform_operations/access_control_and_sharing/SKILL.md +0 -0
  27. {mainsequence-4.2.38 → mainsequence-4.2.40}/agent_scaffold/skills/platform_operations/orchestration_and_releases/SKILL.md +0 -0
  28. {mainsequence-4.2.38 → mainsequence-4.2.40}/agent_scaffold/skills/project_builder/SKILL.md +0 -0
  29. {mainsequence-4.2.38 → mainsequence-4.2.40}/agent_scaffold/skills/project_to_agent/SKILL.md +0 -0
  30. {mainsequence-4.2.38 → mainsequence-4.2.40}/mainsequence/__init__.py +0 -0
  31. {mainsequence-4.2.38 → mainsequence-4.2.40}/mainsequence/__main__.py +0 -0
  32. {mainsequence-4.2.38 → mainsequence-4.2.40}/mainsequence/bootstrap.py +0 -0
  33. {mainsequence-4.2.38 → mainsequence-4.2.40}/mainsequence/cli/__init__.py +0 -0
  34. {mainsequence-4.2.38 → mainsequence-4.2.40}/mainsequence/cli/api.py +0 -0
  35. {mainsequence-4.2.38 → mainsequence-4.2.40}/mainsequence/cli/browser_auth.py +0 -0
  36. {mainsequence-4.2.38 → mainsequence-4.2.40}/mainsequence/cli/cli.py +0 -0
  37. {mainsequence-4.2.38 → mainsequence-4.2.40}/mainsequence/cli/config.py +0 -0
  38. {mainsequence-4.2.38 → mainsequence-4.2.40}/mainsequence/cli/docker_utils.py +0 -0
  39. {mainsequence-4.2.38 → mainsequence-4.2.40}/mainsequence/cli/doctor.py +0 -0
  40. {mainsequence-4.2.38 → mainsequence-4.2.40}/mainsequence/cli/local_ops.py +0 -0
  41. {mainsequence-4.2.38 → mainsequence-4.2.40}/mainsequence/cli/migrations.py +0 -0
  42. {mainsequence-4.2.38 → mainsequence-4.2.40}/mainsequence/cli/model_filters.py +0 -0
  43. {mainsequence-4.2.38 → mainsequence-4.2.40}/mainsequence/cli/project_status.py +0 -0
  44. {mainsequence-4.2.38 → mainsequence-4.2.40}/mainsequence/cli/pydantic_cli.py +0 -0
  45. {mainsequence-4.2.38 → mainsequence-4.2.40}/mainsequence/cli/sdk_utils.py +0 -0
  46. {mainsequence-4.2.38 → mainsequence-4.2.40}/mainsequence/cli/ssh_utils.py +0 -0
  47. {mainsequence-4.2.38 → mainsequence-4.2.40}/mainsequence/cli/ui.py +0 -0
  48. {mainsequence-4.2.38 → mainsequence-4.2.40}/mainsequence/client/__init__.py +0 -0
  49. {mainsequence-4.2.38 → mainsequence-4.2.40}/mainsequence/client/agent_runtime_models.py +0 -0
  50. {mainsequence-4.2.38 → mainsequence-4.2.40}/mainsequence/client/base.py +0 -0
  51. {mainsequence-4.2.38 → mainsequence-4.2.40}/mainsequence/client/client.py +0 -0
  52. {mainsequence-4.2.38 → mainsequence-4.2.40}/mainsequence/client/command_center/__init__.py +0 -0
  53. {mainsequence-4.2.38 → mainsequence-4.2.40}/mainsequence/client/command_center/app_component.py +0 -0
  54. {mainsequence-4.2.38 → mainsequence-4.2.40}/mainsequence/client/command_center/connections.py +0 -0
  55. {mainsequence-4.2.38 → mainsequence-4.2.40}/mainsequence/client/command_center/data_models.py +0 -0
  56. {mainsequence-4.2.38 → mainsequence-4.2.40}/mainsequence/client/command_center/workspace.py +0 -0
  57. {mainsequence-4.2.38 → mainsequence-4.2.40}/mainsequence/client/command_center/workspace_snapshot.py +0 -0
  58. {mainsequence-4.2.38 → mainsequence-4.2.40}/mainsequence/client/compute_validation.py +0 -0
  59. {mainsequence-4.2.38 → mainsequence-4.2.40}/mainsequence/client/data_sources_interfaces/__init__.py +0 -0
  60. {mainsequence-4.2.38 → mainsequence-4.2.40}/mainsequence/client/data_sources_interfaces/duckdb.py +0 -0
  61. {mainsequence-4.2.38 → mainsequence-4.2.40}/mainsequence/client/data_sources_interfaces/local_paths.py +0 -0
  62. {mainsequence-4.2.38 → mainsequence-4.2.40}/mainsequence/client/data_sources_interfaces/sqlite.py +0 -0
  63. {mainsequence-4.2.38 → mainsequence-4.2.40}/mainsequence/client/dtype_codec.py +0 -0
  64. {mainsequence-4.2.38 → mainsequence-4.2.40}/mainsequence/client/exceptions.py +0 -0
  65. {mainsequence-4.2.38 → mainsequence-4.2.40}/mainsequence/client/fastapi/__init__.py +0 -0
  66. {mainsequence-4.2.38 → mainsequence-4.2.40}/mainsequence/client/fastapi/auth.py +0 -0
  67. {mainsequence-4.2.38 → mainsequence-4.2.40}/mainsequence/client/metatables/__init__.py +0 -0
  68. {mainsequence-4.2.38 → mainsequence-4.2.40}/mainsequence/client/metatables/core.py +0 -0
  69. {mainsequence-4.2.38 → mainsequence-4.2.40}/mainsequence/client/models_foundry.py +0 -0
  70. {mainsequence-4.2.38 → mainsequence-4.2.40}/mainsequence/client/models_helpers.py +0 -0
  71. {mainsequence-4.2.38 → mainsequence-4.2.40}/mainsequence/client/models_user.py +0 -0
  72. {mainsequence-4.2.38 → mainsequence-4.2.40}/mainsequence/client/utils.py +0 -0
  73. {mainsequence-4.2.38 → mainsequence-4.2.40}/mainsequence/defaults.py +0 -0
  74. {mainsequence-4.2.38 → mainsequence-4.2.40}/mainsequence/instrumentation/__init__.py +0 -0
  75. {mainsequence-4.2.38 → mainsequence-4.2.40}/mainsequence/instrumentation/utils.py +0 -0
  76. {mainsequence-4.2.38 → mainsequence-4.2.40}/mainsequence/logconf.py +0 -0
  77. {mainsequence-4.2.38 → mainsequence-4.2.40}/mainsequence/meta_tables/__init__.py +0 -0
  78. {mainsequence-4.2.38 → mainsequence-4.2.40}/mainsequence/meta_tables/__main__.py +0 -0
  79. {mainsequence-4.2.38 → mainsequence-4.2.40}/mainsequence/meta_tables/compiled_sql/__init__.py +0 -0
  80. {mainsequence-4.2.38 → mainsequence-4.2.40}/mainsequence/meta_tables/compiled_sql/v1.py +0 -0
  81. {mainsequence-4.2.38 → mainsequence-4.2.40}/mainsequence/meta_tables/data_nodes/__init__.py +0 -0
  82. {mainsequence-4.2.38 → mainsequence-4.2.40}/mainsequence/meta_tables/data_nodes/build_operations.py +0 -0
  83. {mainsequence-4.2.38 → mainsequence-4.2.40}/mainsequence/meta_tables/data_nodes/data_nodes.py +0 -0
  84. {mainsequence-4.2.38 → mainsequence-4.2.40}/mainsequence/meta_tables/data_nodes/models.py +0 -0
  85. {mainsequence-4.2.38 → mainsequence-4.2.40}/mainsequence/meta_tables/data_nodes/namespacing.py +0 -0
  86. {mainsequence-4.2.38 → mainsequence-4.2.40}/mainsequence/meta_tables/data_nodes/persist_managers.py +0 -0
  87. {mainsequence-4.2.38 → mainsequence-4.2.40}/mainsequence/meta_tables/data_nodes/run_operations.py +0 -0
  88. {mainsequence-4.2.38 → mainsequence-4.2.40}/mainsequence/meta_tables/data_nodes/utils.py +0 -0
  89. {mainsequence-4.2.38 → mainsequence-4.2.40}/mainsequence/meta_tables/future_registry.py +0 -0
  90. {mainsequence-4.2.38 → mainsequence-4.2.40}/mainsequence/meta_tables/hashing.py +0 -0
  91. {mainsequence-4.2.38 → mainsequence-4.2.40}/mainsequence/meta_tables/pydantic_metadata.py +0 -0
  92. {mainsequence-4.2.38 → mainsequence-4.2.40}/mainsequence/meta_tables/schema_names.py +0 -0
  93. {mainsequence-4.2.38 → mainsequence-4.2.40}/mainsequence/runtime_flags.py +0 -0
  94. {mainsequence-4.2.38 → mainsequence-4.2.40}/mainsequence.egg-info/SOURCES.txt +0 -0
  95. {mainsequence-4.2.38 → mainsequence-4.2.40}/mainsequence.egg-info/dependency_links.txt +0 -0
  96. {mainsequence-4.2.38 → mainsequence-4.2.40}/mainsequence.egg-info/entry_points.txt +0 -0
  97. {mainsequence-4.2.38 → mainsequence-4.2.40}/mainsequence.egg-info/requires.txt +0 -0
  98. {mainsequence-4.2.38 → mainsequence-4.2.40}/mainsequence.egg-info/top_level.txt +0 -0
  99. {mainsequence-4.2.38 → mainsequence-4.2.40}/setup.cfg +0 -0
  100. {mainsequence-4.2.38 → mainsequence-4.2.40}/tests/test_auth_precedence.py +0 -0
  101. {mainsequence-4.2.38 → mainsequence-4.2.40}/tests/test_build_operations_hashing.py +0 -0
  102. {mainsequence-4.2.38 → mainsequence-4.2.40}/tests/test_cli.py +0 -0
  103. {mainsequence-4.2.38 → mainsequence-4.2.40}/tests/test_cli_browser_auth.py +0 -0
  104. {mainsequence-4.2.38 → mainsequence-4.2.40}/tests/test_cli_migrations.py +0 -0
  105. {mainsequence-4.2.38 → mainsequence-4.2.40}/tests/test_client.py +0 -0
  106. {mainsequence-4.2.38 → mainsequence-4.2.40}/tests/test_command_center_app_component_models.py +0 -0
  107. {mainsequence-4.2.38 → mainsequence-4.2.40}/tests/test_command_center_data_models.py +0 -0
  108. {mainsequence-4.2.38 → mainsequence-4.2.40}/tests/test_command_center_models.py +0 -0
  109. {mainsequence-4.2.38 → mainsequence-4.2.40}/tests/test_data_access_mixin_dimension_audit.py +0 -0
  110. {mainsequence-4.2.38 → mainsequence-4.2.40}/tests/test_data_node_storage_dimension_queries.py +0 -0
  111. {mainsequence-4.2.38 → mainsequence-4.2.40}/tests/test_data_node_update_flow.py +0 -0
  112. {mainsequence-4.2.38 → mainsequence-4.2.40}/tests/test_dependency_extras.py +0 -0
  113. {mainsequence-4.2.38 → mainsequence-4.2.40}/tests/test_duckdb_interface_dimensions.py +0 -0
  114. {mainsequence-4.2.38 → mainsequence-4.2.40}/tests/test_filter_normalization.py +0 -0
  115. {mainsequence-4.2.38 → mainsequence-4.2.40}/tests/test_logconf.py +0 -0
  116. {mainsequence-4.2.38 → mainsequence-4.2.40}/tests/test_meta_tables_client_models.py +0 -0
  117. {mainsequence-4.2.38 → mainsequence-4.2.40}/tests/test_models_user_request_bound_auth.py +0 -0
  118. {mainsequence-4.2.38 → mainsequence-4.2.40}/tests/test_pod_project_resolution.py +0 -0
  119. {mainsequence-4.2.38 → mainsequence-4.2.40}/tests/test_project_batch_jobs_from_file.py +0 -0
  120. {mainsequence-4.2.38 → mainsequence-4.2.40}/tests/test_run_configuration.py +0 -0
  121. {mainsequence-4.2.38 → mainsequence-4.2.40}/tests/test_schema_names.py +0 -0
  122. {mainsequence-4.2.38 → mainsequence-4.2.40}/tests/test_secret_client_model.py +0 -0
  123. {mainsequence-4.2.38 → mainsequence-4.2.40}/tests/test_source_table_configuration.py +0 -0
  124. {mainsequence-4.2.38 → mainsequence-4.2.40}/tests/test_sqlite_interface_dimensions.py +0 -0
  125. {mainsequence-4.2.38 → mainsequence-4.2.40}/tests/test_update_runner_uid_runtime.py +0 -0
  126. {mainsequence-4.2.38 → mainsequence-4.2.40}/tests/test_update_statistics.py +0 -0
  127. {mainsequence-4.2.38 → mainsequence-4.2.40}/tests/test_update_uid_guards.py +0 -0
  128. {mainsequence-4.2.38 → mainsequence-4.2.40}/tests/test_workspace_snapshot.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mainsequence
3
- Version: 4.2.38
3
+ Version: 4.2.40
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
@@ -191,6 +191,12 @@ MetaTable migration provider and run `mainsequence migrations upgrade --provider
191
191
  ... head`. Do not call `PricesTable.register()` directly and do not rely on
192
192
  DataNode construction to register storage tables.
193
193
 
194
+ `__index_names__` is the full DataNode storage grain. `PlatformTimeIndexMetaData`
195
+ automatically adds a SQLAlchemy unique index over that tuple before Alembic
196
+ autogenerate runs. Do not manually repeat the full grain unique index in
197
+ `__table_args__`; add ordinary SQLAlchemy `Index(...)` entries only for
198
+ additional lookup/performance paths.
199
+
194
200
  `PlatformTimeIndexMetaData.register()` remains SDK plumbing for the migration
195
201
  workflow. Do not manually attach an existing UID, reconstruct a generic
196
202
  `MetaTable`, or use manual bind helpers as an authoring step.
@@ -137,7 +137,11 @@ first version, use Alembic. Keep the SDK model as a normal
137
137
  `PlatformManagedMetaTable` or `PlatformTimeIndexMetaData` catalog contract, and
138
138
  apply physical schema changes through the Alembic migration workflow.
139
139
 
140
- Schema must come from SQLAlchemy table metadata, usually `__table_args__ = {"schema": "public"}` or the tuple form ending in `{"schema": ...}`. Do not add a separate MetaTable-specific schema attribute.
140
+ Default-schema tables must leave SQLAlchemy `Table.schema` unset; do not write
141
+ `__table_args__ = {"schema": "public"}` for the default PostgreSQL schema. Set
142
+ schema metadata only for non-default schemas, using `__table_args__ = {"schema":
143
+ "custom_schema"}` or the tuple form ending in `{"schema": ...}`. Do not add a
144
+ separate MetaTable-specific schema attribute.
141
145
 
142
146
  Always declare `__metatable_description__` on the model. The description must
143
147
  explain the table's business intention, row grain, and expected use, not only
@@ -246,7 +250,7 @@ Use this pattern:
246
250
  ```python
247
251
  account_uid: Mapped[uuid.UUID] = mapped_column(
248
252
  Uuid,
249
- ForeignKey("public.sdk_examples__account.uid", ondelete="RESTRICT"),
253
+ ForeignKey("sdk_examples__account.uid", ondelete="RESTRICT"),
250
254
  nullable=False,
251
255
  )
252
256
  ```
@@ -27,9 +27,14 @@ from mainsequence.meta_tables.hashing import build_meta_table_storage_hash
27
27
  from mainsequence.meta_tables.sqlalchemy_contracts import (
28
28
  PlatformManagedMetaTable,
29
29
  PlatformTimeIndexMetaData,
30
+ _ensure_time_index_unique_grain_index,
31
+ _normalize_table_default_schema,
30
32
  _resolve_model_data_source_uid,
31
33
  _resolve_table,
34
+ _resolve_time_index_name,
35
+ _resolve_time_index_names,
32
36
  _table_name,
37
+ _validate_time_index_contract,
33
38
  platform_managed_migration_registration_context,
34
39
  )
35
40
 
@@ -268,6 +273,8 @@ class AlembicMetaTableMigration:
268
273
  self.after_register_metatables
269
274
  ):
270
275
  raise TypeError("after_register_metatables must be callable when provided.")
276
+ _normalize_provider_default_schemas(self.metatable_models)
277
+ _ensure_provider_time_index_grain_indexes(self.metatable_models)
271
278
 
272
279
  @property
273
280
  def alembic_version_table(self) -> str:
@@ -956,6 +963,27 @@ def _metadata_table_names(target_metadata: Any) -> list[str]:
956
963
  return list(dict.fromkeys(names))
957
964
 
958
965
 
966
+ def _normalize_provider_default_schemas(models: Sequence[type[Any]]) -> None:
967
+ for model in models:
968
+ if _is_platform_managed_metatable_model(model):
969
+ _normalize_table_default_schema(_resolve_table(model))
970
+
971
+
972
+ def _ensure_provider_time_index_grain_indexes(models: Sequence[type[Any]]) -> None:
973
+ for model in models:
974
+ if not _is_platform_time_index_metatable_model(model):
975
+ continue
976
+ table = _resolve_table(model)
977
+ time_index_name = _resolve_time_index_name(model)
978
+ index_names = _resolve_time_index_names(model, time_index_name=time_index_name)
979
+ _validate_time_index_contract(
980
+ columns=list(table.columns),
981
+ time_index_name=time_index_name,
982
+ index_names=index_names,
983
+ )
984
+ _ensure_time_index_unique_grain_index(table=table, index_names=index_names)
985
+
986
+
959
987
  def _normalize_down_revision(value: Any) -> str | None:
960
988
  if value in (None, ""):
961
989
  return None
@@ -11,6 +11,7 @@ from uuid import UUID
11
11
  from sqlalchemy import Table as _SQLAlchemyTable
12
12
  from sqlalchemy import inspect as _sqlalchemy_inspect
13
13
  from sqlalchemy.orm import declared_attr as _sqlalchemy_declared_attr
14
+ from sqlalchemy.schema import BLANK_SCHEMA as _SQLALCHEMY_BLANK_SCHEMA
14
15
 
15
16
  from mainsequence.client.dtype_codec import (
16
17
  is_temporal_token,
@@ -28,12 +29,14 @@ from mainsequence.client.metatables import (
28
29
  )
29
30
 
30
31
  from .hashing import build_meta_table_configured_storage_hash
32
+ from .schema_names import schema_index_name
31
33
 
32
34
  DEFAULT_PLATFORM_MANAGED_PROVISIONING = {
33
35
  "create_table": True,
34
36
  "if_not_exists": True,
35
37
  }
36
38
  SERVER_GENERATED_UUID_DEFAULT = "gen_random_uuid()"
39
+ DEFAULT_POSTGRES_SCHEMA = "public"
37
40
  _PLATFORM_MANAGED_MIGRATION_REGISTRATION_CONTEXT: contextvars.ContextVar[bool] = (
38
41
  contextvars.ContextVar(
39
42
  "mainsequence_platform_managed_migration_registration_context",
@@ -152,8 +155,14 @@ class PlatformManagedMetaTable:
152
155
 
153
156
  name, metadata, *table_items = args
154
157
  kwargs = dict(kwargs)
155
- schema = str(kwargs.get("schema") or _resolve_class_schema(cls, metadata=metadata))
156
- if not kwargs.get("schema"):
158
+ schema = _normalize_sqlalchemy_schema(
159
+ kwargs.get("schema")
160
+ if "schema" in kwargs
161
+ else _resolve_class_schema(cls, metadata=metadata)
162
+ )
163
+ if schema is None:
164
+ kwargs["schema"] = _SQLALCHEMY_BLANK_SCHEMA
165
+ else:
157
166
  kwargs["schema"] = schema
158
167
 
159
168
  from sqlalchemy import Table
@@ -307,8 +316,14 @@ class PlatformTimeIndexMetaData(PlatformManagedMetaTable):
307
316
 
308
317
  name, metadata, *table_items = args
309
318
  kwargs = dict(kwargs)
310
- schema = str(kwargs.get("schema") or _resolve_class_schema(cls, metadata=metadata))
311
- if not kwargs.get("schema"):
319
+ schema = _normalize_sqlalchemy_schema(
320
+ kwargs.get("schema")
321
+ if "schema" in kwargs
322
+ else _resolve_class_schema(cls, metadata=metadata)
323
+ )
324
+ if schema is None:
325
+ kwargs["schema"] = _SQLALCHEMY_BLANK_SCHEMA
326
+ else:
312
327
  kwargs["schema"] = schema
313
328
 
314
329
  columns = [item for item in table_items if _looks_like_column(item)]
@@ -322,7 +337,9 @@ class PlatformTimeIndexMetaData(PlatformManagedMetaTable):
322
337
 
323
338
  from sqlalchemy import Table
324
339
 
325
- return Table(str(name), metadata, *table_items, **kwargs)
340
+ table = Table(str(name), metadata, *table_items, **kwargs)
341
+ _ensure_time_index_unique_grain_index(table=table, index_names=index_names)
342
+ return table
326
343
 
327
344
  @classmethod
328
345
  def build_registration_request(
@@ -413,7 +430,7 @@ def table_contract_from_sqlalchemy_model(
413
430
  include_physical_table_name: bool = True,
414
431
  ) -> MetaTableContract:
415
432
  table = _resolve_table(model_or_table)
416
- _resolve_schema(table, schema=schema)
433
+ resolved_schema = _resolve_schema(table, schema=schema)
417
434
  module, qualname = _resolve_model_path(
418
435
  model_or_table,
419
436
  table_model_module=table_model_module,
@@ -430,6 +447,7 @@ def table_contract_from_sqlalchemy_model(
430
447
  }
431
448
  },
432
449
  physical=MetaTablePhysicalContract(
450
+ schema_=resolved_schema,
433
451
  table_name=_table_name(table) if include_physical_table_name else None,
434
452
  ),
435
453
  columns=[
@@ -560,7 +578,10 @@ def time_indexed_registration_request_from_sqlalchemy_model(
560
578
  ),
561
579
  },
562
580
  },
563
- "physical": {"table_name": _table_name(table)},
581
+ "physical": {
582
+ "schema": resolved_schema,
583
+ "table_name": _table_name(table),
584
+ },
564
585
  "columns": column_contracts,
565
586
  },
566
587
  )
@@ -741,7 +762,7 @@ def _table_name(table: Any) -> str:
741
762
  def _resolve_schema(table: Any, *, schema: str | None = None) -> str:
742
763
  resolved_schema = schema or getattr(table, "schema", None)
743
764
  if not resolved_schema:
744
- raise ValueError("MetaTable SQLAlchemy contracts require a SQLAlchemy table schema.")
765
+ return DEFAULT_POSTGRES_SCHEMA
745
766
  return str(resolved_schema)
746
767
 
747
768
 
@@ -752,7 +773,7 @@ def _resolve_class_namespace(cls: type[Any]) -> str:
752
773
  return str(resolved_namespace)
753
774
 
754
775
 
755
- def _resolve_class_schema(cls: type[Any], *, metadata: Any | None = None) -> str:
776
+ def _resolve_class_schema(cls: type[Any], *, metadata: Any | None = None) -> str | None:
756
777
  table_args = getattr(cls, "__table_args__", None)
757
778
  if isinstance(table_args, Mapping):
758
779
  schema = table_args.get("schema")
@@ -762,7 +783,29 @@ def _resolve_class_schema(cls: type[Any], *, metadata: Any | None = None) -> str
762
783
  schema = None
763
784
  if schema is None and metadata is not None:
764
785
  schema = getattr(metadata, "schema", None)
765
- return str(schema or "public")
786
+ return _normalize_sqlalchemy_schema(schema)
787
+
788
+
789
+ def _normalize_sqlalchemy_schema(schema: Any | None) -> str | None:
790
+ if schema is None:
791
+ return None
792
+ schema_name = str(schema).strip()
793
+ if schema_name in ("", DEFAULT_POSTGRES_SCHEMA):
794
+ return None
795
+ return schema_name
796
+
797
+
798
+ def _normalize_table_default_schema(table: Any) -> None:
799
+ if getattr(table, "schema", None) != DEFAULT_POSTGRES_SCHEMA:
800
+ return
801
+
802
+ metadata = getattr(table, "metadata", None)
803
+ if metadata is not None:
804
+ metadata._remove_table(table.name, table.schema)
805
+ table.schema = None
806
+ table.fullname = _table_name(table)
807
+ if metadata is not None:
808
+ metadata._add_table(table.name, table.schema, table)
766
809
 
767
810
 
768
811
  def _resolve_data_source_uid(
@@ -1391,6 +1434,75 @@ def _validate_time_index_contract(
1391
1434
  )
1392
1435
 
1393
1436
 
1437
+ def _ensure_time_index_unique_grain_index(
1438
+ *,
1439
+ table: Any,
1440
+ index_names: Sequence[str],
1441
+ ) -> None:
1442
+ grain_names = [str(name) for name in index_names]
1443
+ if _has_unique_grain_enforcement(table=table, index_names=grain_names):
1444
+ return
1445
+
1446
+ from sqlalchemy import Index
1447
+
1448
+ Index(
1449
+ schema_index_name(_table_name(table), grain_names, unique=True),
1450
+ *(table.c[name] for name in grain_names),
1451
+ unique=True,
1452
+ )
1453
+
1454
+
1455
+ def _has_unique_grain_enforcement(
1456
+ *,
1457
+ table: Any,
1458
+ index_names: Sequence[str],
1459
+ ) -> bool:
1460
+ for index in _iter_indexes(table):
1461
+ if bool(getattr(index, "unique", False)) and _grain_columns_match(
1462
+ _index_column_names(index),
1463
+ index_names,
1464
+ ):
1465
+ return True
1466
+
1467
+ for constraint in _iter_unique_constraints(table):
1468
+ if _grain_columns_match(_constraint_column_names(constraint), index_names):
1469
+ return True
1470
+
1471
+ return False
1472
+
1473
+
1474
+ def _grain_columns_match(existing_names: Sequence[str], index_names: Sequence[str]) -> bool:
1475
+ existing = [str(name) for name in existing_names]
1476
+ expected = [str(name) for name in index_names]
1477
+ if len(existing) != len(expected):
1478
+ return False
1479
+ return set(existing) == set(expected)
1480
+
1481
+
1482
+ def _index_column_names(index: Any) -> list[str]:
1483
+ return [_storage_item_name(item) for item in _iter_index_items(index)]
1484
+
1485
+
1486
+ def _iter_unique_constraints(table: Any) -> list[Any]:
1487
+ from sqlalchemy import UniqueConstraint
1488
+
1489
+ constraints = getattr(table, "constraints", None)
1490
+ if constraints is None:
1491
+ return []
1492
+ return [
1493
+ constraint
1494
+ for constraint in constraints
1495
+ if isinstance(constraint, UniqueConstraint)
1496
+ ]
1497
+
1498
+
1499
+ def _constraint_column_names(constraint: Any) -> list[str]:
1500
+ columns = getattr(constraint, "columns", None)
1501
+ if columns is None:
1502
+ return []
1503
+ return [_storage_item_name(column) for column in columns]
1504
+
1505
+
1394
1506
  def _storage_identity_from_parts(
1395
1507
  *,
1396
1508
  columns: Sequence[Any],
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mainsequence
3
- Version: 4.2.38
3
+ Version: 4.2.40
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.2.38"
7
+ version = "4.2.40"
8
8
  description = "Main Sequence SDK "
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -16,7 +16,11 @@ from mainsequence.client.metatables import (
16
16
  MetaTable,
17
17
  TimeIndexMetaData,
18
18
  )
19
- from mainsequence.meta_tables import PlatformManagedMetaTable, PlatformTimeIndexMetaData
19
+ from mainsequence.meta_tables import (
20
+ PlatformManagedMetaTable,
21
+ PlatformTimeIndexMetaData,
22
+ schema_index_name,
23
+ )
20
24
  from mainsequence.meta_tables.migrations import (
21
25
  DEFAULT_ALEMBIC_VERSION_COLUMN_NAME,
22
26
  DEFAULT_ALEMBIC_VERSION_IDENTIFIER,
@@ -1005,6 +1009,68 @@ def test_prepare_for_alembic_routes_time_indexed_models_to_dynamic_table_bulk_cr
1005
1009
  assert "schema_management" not in row
1006
1010
 
1007
1011
 
1012
+ def test_provider_adds_time_index_grain_index_when_table_cls_is_overridden():
1013
+ class Base(DeclarativeBase):
1014
+ metadata = MetaData(schema="public")
1015
+
1016
+ class CustomTimeIndexMixin(PlatformTimeIndexMetaData):
1017
+ __abstract__ = True
1018
+
1019
+ @classmethod
1020
+ def __table_cls__(cls, *args, **kwargs):
1021
+ name, metadata, *table_items = args
1022
+ return Table(str(name), metadata, *table_items, **kwargs)
1023
+
1024
+ class ProjectAlembicVersion(AlembicVersionMetaTable):
1025
+ __metatable_uid__ = "registry-meta-table-uid"
1026
+ __metatable_data_source_uid__ = "data-source-uid"
1027
+
1028
+ class Prices(CustomTimeIndexMixin, Base):
1029
+ __tablename__ = "example_assets__prices"
1030
+ __metatable_data_source_uid__ = "data-source-uid"
1031
+ __metatable_namespace__ = "example.assets"
1032
+ __metatable_identifier__ = "Prices"
1033
+ __time_index_name__ = "time_index"
1034
+ __index_names__ = ["time_index", "asset_identifier"]
1035
+
1036
+ time_index: Mapped[datetime.datetime] = mapped_column(
1037
+ DateTime(timezone=True),
1038
+ nullable=False,
1039
+ )
1040
+ asset_identifier: Mapped[str] = mapped_column(String(255), nullable=False)
1041
+ close: Mapped[int] = mapped_column(Integer, nullable=True)
1042
+
1043
+ assert not Prices.__table__.indexes
1044
+ assert Prices.__table__.schema == "public"
1045
+ assert "public.example_assets__prices" in Base.metadata.tables
1046
+
1047
+ AlembicMetaTableMigration(
1048
+ package="sample",
1049
+ migration_namespace="markets",
1050
+ script_location="sample:migrations",
1051
+ target_metadata=Base.metadata,
1052
+ alembic_registry=ProjectAlembicVersion,
1053
+ metatable_models=[Prices],
1054
+ )
1055
+
1056
+ assert Prices.__table__.schema is None
1057
+ assert "example_assets__prices" in Base.metadata.tables
1058
+ assert "public.example_assets__prices" not in Base.metadata.tables
1059
+
1060
+ grain_indexes = [
1061
+ index
1062
+ for index in Prices.__table__.indexes
1063
+ if index.unique
1064
+ and [column.name for column in index.columns] == ["time_index", "asset_identifier"]
1065
+ ]
1066
+ assert len(grain_indexes) == 1
1067
+ assert grain_indexes[0].name == schema_index_name(
1068
+ "example_assets__prices",
1069
+ ["time_index", "asset_identifier"],
1070
+ unique=True,
1071
+ )
1072
+
1073
+
1008
1074
  def test_prepare_for_alembic_reserves_existing_table_name_with_provider_identity(monkeypatch):
1009
1075
  class Base(DeclarativeBase):
1010
1076
  metadata = MetaData()
@@ -22,6 +22,8 @@ from mainsequence.meta_tables import (
22
22
  external_registered_registration_request_from_sqlalchemy_model,
23
23
  platform_managed_migration_registration_context,
24
24
  platform_managed_registration_request_from_sqlalchemy_model,
25
+ schema_index_name,
26
+ sqlalchemy_naming_convention,
25
27
  table_contract_from_sqlalchemy_model,
26
28
  time_indexed_registration_request_from_sqlalchemy_model,
27
29
  )
@@ -708,6 +710,63 @@ def test_platform_managed_schema_resolves_from_sqlalchemy_table_args_only():
708
710
  assert sqlalchemy_contracts._resolve_class_schema(Account) == "table_args_schema"
709
711
 
710
712
 
713
+ def test_platform_managed_default_public_schema_stays_sqlalchemy_default():
714
+ pytest.importorskip("sqlalchemy")
715
+
716
+ from sqlalchemy import MetaData
717
+ from sqlalchemy import Uuid as SQLAlchemyUuid
718
+ from sqlalchemy.orm import DeclarativeBase, mapped_column
719
+
720
+ class Base(DeclarativeBase):
721
+ metadata = MetaData()
722
+
723
+ class Account(PlatformManagedMetaTable, Base):
724
+ __tablename__ = "example_assets__account"
725
+ __table_args__ = {"schema": "public"}
726
+ __metatable_namespace__ = "example.assets"
727
+ __metatable_identifier__ = "Account"
728
+
729
+ uid: Mapped[uuid.UUID] = mapped_column(SQLAlchemyUuid, primary_key=True)
730
+
731
+ assert Account.__table__.schema is None
732
+ assert sqlalchemy_contracts._resolve_schema(Account.__table__) == "public"
733
+
734
+ request = Account.build_registration_request(
735
+ data_source_uid="dddddddd-dddd-4ddd-8ddd-dddddddddddd",
736
+ )
737
+
738
+ assert request.table_contract.physical.schema_ == "public"
739
+
740
+
741
+ def test_platform_managed_default_public_metadata_schema_stays_sqlalchemy_default():
742
+ pytest.importorskip("sqlalchemy")
743
+
744
+ from sqlalchemy import MetaData
745
+ from sqlalchemy import Uuid as SQLAlchemyUuid
746
+ from sqlalchemy.orm import DeclarativeBase, mapped_column
747
+
748
+ class Base(DeclarativeBase):
749
+ metadata = MetaData(schema="public")
750
+
751
+ class Account(PlatformManagedMetaTable, Base):
752
+ __tablename__ = "example_assets__account"
753
+ __metatable_namespace__ = "example.assets"
754
+ __metatable_identifier__ = "Account"
755
+
756
+ uid: Mapped[uuid.UUID] = mapped_column(SQLAlchemyUuid, primary_key=True)
757
+
758
+ assert Account.__table__.schema is None
759
+ assert Account.__table__.fullname == "example_assets__account"
760
+ assert "example_assets__account" in Base.metadata.tables
761
+ assert "public.example_assets__account" not in Base.metadata.tables
762
+
763
+ request = Account.build_registration_request(
764
+ data_source_uid="dddddddd-dddd-4ddd-8ddd-dddddddddddd",
765
+ )
766
+
767
+ assert request.table_contract.physical.schema_ == "public"
768
+
769
+
711
770
  def test_time_index_optional_table_info_resolvers_allow_pending_declarative_class():
712
771
  class PendingTimeIndexTable:
713
772
  __time_index_name__ = "time_index"
@@ -1535,6 +1594,85 @@ def test_platform_managed_metatable_preserves_authored_tablename_with_sqlalchemy
1535
1594
  assert not hasattr(request.table_contract, "foreign_keys")
1536
1595
 
1537
1596
 
1597
+ def test_time_index_metadata_generates_unique_grain_index_with_schema_name():
1598
+ pytest.importorskip("sqlalchemy")
1599
+
1600
+ from sqlalchemy import DateTime, Float, MetaData, String
1601
+ from sqlalchemy.orm import DeclarativeBase, mapped_column
1602
+
1603
+ class Base(DeclarativeBase):
1604
+ metadata = MetaData(naming_convention=sqlalchemy_naming_convention())
1605
+
1606
+ class Prices(PlatformTimeIndexMetaData, Base):
1607
+ __tablename__ = "ms_markets__prices__mainsequence_examples"
1608
+ __metatable_namespace__ = "mainsequence.examples"
1609
+ __time_index_name__ = "time_index"
1610
+ __index_names__ = ["time_index", "asset_identifier"]
1611
+
1612
+ time_index: Mapped[datetime.datetime] = mapped_column(
1613
+ DateTime(timezone=True),
1614
+ nullable=False,
1615
+ )
1616
+ asset_identifier: Mapped[str] = mapped_column(String(255), nullable=False)
1617
+ close: Mapped[float] = mapped_column(Float, nullable=True)
1618
+
1619
+ grain_indexes = [
1620
+ index
1621
+ for index in Prices.__table__.indexes
1622
+ if index.unique
1623
+ and [column.name for column in index.columns] == ["time_index", "asset_identifier"]
1624
+ ]
1625
+
1626
+ assert len(grain_indexes) == 1
1627
+ assert grain_indexes[0].name == schema_index_name(
1628
+ "ms_markets__prices__mainsequence_examples",
1629
+ ["time_index", "asset_identifier"],
1630
+ unique=True,
1631
+ )
1632
+
1633
+
1634
+ def test_time_index_metadata_reuses_existing_unique_grain_constraint():
1635
+ pytest.importorskip("sqlalchemy")
1636
+
1637
+ from sqlalchemy import DateTime, Float, MetaData, String, UniqueConstraint
1638
+ from sqlalchemy.orm import DeclarativeBase, mapped_column
1639
+
1640
+ class Base(DeclarativeBase):
1641
+ metadata = MetaData(naming_convention=sqlalchemy_naming_convention())
1642
+
1643
+ class Prices(PlatformTimeIndexMetaData, Base):
1644
+ __tablename__ = "ms_markets__prices__mainsequence_examples"
1645
+ __table_args__ = (
1646
+ UniqueConstraint(
1647
+ "asset_identifier",
1648
+ "time_index",
1649
+ name="uix_custom_asset_time",
1650
+ ),
1651
+ )
1652
+ __metatable_namespace__ = "mainsequence.examples"
1653
+ __time_index_name__ = "time_index"
1654
+ __index_names__ = ["time_index", "asset_identifier"]
1655
+
1656
+ time_index: Mapped[datetime.datetime] = mapped_column(
1657
+ DateTime(timezone=True),
1658
+ nullable=False,
1659
+ )
1660
+ asset_identifier: Mapped[str] = mapped_column(String(255), nullable=False)
1661
+ close: Mapped[float] = mapped_column(Float, nullable=True)
1662
+
1663
+ assert not Prices.__table__.indexes
1664
+ unique_constraints = [
1665
+ constraint
1666
+ for constraint in Prices.__table__.constraints
1667
+ if isinstance(constraint, UniqueConstraint)
1668
+ ]
1669
+ assert len(unique_constraints) == 1
1670
+ assert [column.name for column in unique_constraints[0].columns] == [
1671
+ "asset_identifier",
1672
+ "time_index",
1673
+ ]
1674
+
1675
+
1538
1676
  def test_platform_managed_register_preserves_authored_sqlalchemy_table_name(
1539
1677
  monkeypatch,
1540
1678
  ):
@@ -1582,14 +1720,15 @@ def test_platform_managed_register_preserves_authored_sqlalchemy_table_name(
1582
1720
  assert Account.get_storage_hash() == storage_hash
1583
1721
  assert Account.get_physical_table_name() == "example_assets__account"
1584
1722
  assert Account.__table__.name == "example_assets__account"
1585
- assert Account.__table__.fullname == "public.example_assets__account"
1586
- assert Base.metadata.tables["public.example_assets__account"] is Account.__table__
1723
+ assert Account.__table__.schema is None
1724
+ assert Account.__table__.fullname == "example_assets__account"
1725
+ assert Base.metadata.tables["example_assets__account"] is Account.__table__
1587
1726
  assert f"public.{physical_table_name}" not in Base.metadata.tables
1588
1727
 
1589
1728
  compiled_sql = str(
1590
1729
  select(Account.__table__).compile(dialect=postgresql.dialect(paramstyle="pyformat"))
1591
1730
  )
1592
- assert "FROM public.example_assets__account" in compiled_sql
1731
+ assert "FROM example_assets__account" in compiled_sql
1593
1732
  assert physical_table_name not in compiled_sql
1594
1733
  assert storage_hash not in compiled_sql
1595
1734
 
@@ -1621,7 +1760,8 @@ def test_bound_parent_table_foreign_key_stays_sqlalchemy_only():
1621
1760
  )
1622
1761
  )
1623
1762
 
1624
- assert Account.__table__.fullname == "public.example_assets__account"
1763
+ assert Account.__table__.schema is None
1764
+ assert Account.__table__.fullname == "example_assets__account"
1625
1765
 
1626
1766
  class Asset(PlatformManagedMetaTable, Base):
1627
1767
  __tablename__ = "example_assets__asset"
@@ -1631,7 +1771,7 @@ def test_bound_parent_table_foreign_key_stays_sqlalchemy_only():
1631
1771
  uid: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True)
1632
1772
  account_uid: Mapped[uuid.UUID] = mapped_column(
1633
1773
  Uuid,
1634
- ForeignKey("public.example_assets__account.uid", ondelete="RESTRICT"),
1774
+ ForeignKey("example_assets__account.uid", ondelete="RESTRICT"),
1635
1775
  nullable=False,
1636
1776
  )
1637
1777
  symbol: Mapped[str] = mapped_column(String(64), nullable=False)
File without changes
File without changes
File without changes