sqlspec 0.26.0__py3-none-any.whl → 0.27.0__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.

Potentially problematic release.


This version of sqlspec might be problematic. Click here for more details.

Files changed (197) hide show
  1. sqlspec/__init__.py +7 -15
  2. sqlspec/_serialization.py +55 -25
  3. sqlspec/_typing.py +62 -52
  4. sqlspec/adapters/adbc/_types.py +1 -1
  5. sqlspec/adapters/adbc/adk/__init__.py +5 -0
  6. sqlspec/adapters/adbc/adk/store.py +870 -0
  7. sqlspec/adapters/adbc/config.py +62 -12
  8. sqlspec/adapters/adbc/data_dictionary.py +52 -2
  9. sqlspec/adapters/adbc/driver.py +144 -45
  10. sqlspec/adapters/adbc/litestar/__init__.py +5 -0
  11. sqlspec/adapters/adbc/litestar/store.py +504 -0
  12. sqlspec/adapters/adbc/type_converter.py +44 -50
  13. sqlspec/adapters/aiosqlite/_types.py +1 -1
  14. sqlspec/adapters/aiosqlite/adk/__init__.py +5 -0
  15. sqlspec/adapters/aiosqlite/adk/store.py +527 -0
  16. sqlspec/adapters/aiosqlite/config.py +86 -16
  17. sqlspec/adapters/aiosqlite/data_dictionary.py +34 -2
  18. sqlspec/adapters/aiosqlite/driver.py +127 -38
  19. sqlspec/adapters/aiosqlite/litestar/__init__.py +5 -0
  20. sqlspec/adapters/aiosqlite/litestar/store.py +281 -0
  21. sqlspec/adapters/aiosqlite/pool.py +7 -7
  22. sqlspec/adapters/asyncmy/__init__.py +7 -1
  23. sqlspec/adapters/asyncmy/_types.py +1 -1
  24. sqlspec/adapters/asyncmy/adk/__init__.py +5 -0
  25. sqlspec/adapters/asyncmy/adk/store.py +493 -0
  26. sqlspec/adapters/asyncmy/config.py +59 -17
  27. sqlspec/adapters/asyncmy/data_dictionary.py +41 -2
  28. sqlspec/adapters/asyncmy/driver.py +293 -62
  29. sqlspec/adapters/asyncmy/litestar/__init__.py +5 -0
  30. sqlspec/adapters/asyncmy/litestar/store.py +296 -0
  31. sqlspec/adapters/asyncpg/__init__.py +2 -1
  32. sqlspec/adapters/asyncpg/_type_handlers.py +71 -0
  33. sqlspec/adapters/asyncpg/_types.py +11 -7
  34. sqlspec/adapters/asyncpg/adk/__init__.py +5 -0
  35. sqlspec/adapters/asyncpg/adk/store.py +450 -0
  36. sqlspec/adapters/asyncpg/config.py +57 -36
  37. sqlspec/adapters/asyncpg/data_dictionary.py +41 -2
  38. sqlspec/adapters/asyncpg/driver.py +153 -23
  39. sqlspec/adapters/asyncpg/litestar/__init__.py +5 -0
  40. sqlspec/adapters/asyncpg/litestar/store.py +253 -0
  41. sqlspec/adapters/bigquery/_types.py +1 -1
  42. sqlspec/adapters/bigquery/adk/__init__.py +5 -0
  43. sqlspec/adapters/bigquery/adk/store.py +576 -0
  44. sqlspec/adapters/bigquery/config.py +25 -11
  45. sqlspec/adapters/bigquery/data_dictionary.py +42 -2
  46. sqlspec/adapters/bigquery/driver.py +352 -144
  47. sqlspec/adapters/bigquery/litestar/__init__.py +5 -0
  48. sqlspec/adapters/bigquery/litestar/store.py +327 -0
  49. sqlspec/adapters/bigquery/type_converter.py +55 -23
  50. sqlspec/adapters/duckdb/_types.py +2 -2
  51. sqlspec/adapters/duckdb/adk/__init__.py +14 -0
  52. sqlspec/adapters/duckdb/adk/store.py +553 -0
  53. sqlspec/adapters/duckdb/config.py +79 -21
  54. sqlspec/adapters/duckdb/data_dictionary.py +41 -2
  55. sqlspec/adapters/duckdb/driver.py +138 -43
  56. sqlspec/adapters/duckdb/litestar/__init__.py +5 -0
  57. sqlspec/adapters/duckdb/litestar/store.py +332 -0
  58. sqlspec/adapters/duckdb/pool.py +5 -5
  59. sqlspec/adapters/duckdb/type_converter.py +51 -21
  60. sqlspec/adapters/oracledb/_numpy_handlers.py +133 -0
  61. sqlspec/adapters/oracledb/_types.py +20 -2
  62. sqlspec/adapters/oracledb/adk/__init__.py +5 -0
  63. sqlspec/adapters/oracledb/adk/store.py +1745 -0
  64. sqlspec/adapters/oracledb/config.py +120 -36
  65. sqlspec/adapters/oracledb/data_dictionary.py +87 -20
  66. sqlspec/adapters/oracledb/driver.py +292 -84
  67. sqlspec/adapters/oracledb/litestar/__init__.py +5 -0
  68. sqlspec/adapters/oracledb/litestar/store.py +767 -0
  69. sqlspec/adapters/oracledb/migrations.py +316 -25
  70. sqlspec/adapters/oracledb/type_converter.py +91 -16
  71. sqlspec/adapters/psqlpy/_type_handlers.py +44 -0
  72. sqlspec/adapters/psqlpy/_types.py +2 -1
  73. sqlspec/adapters/psqlpy/adk/__init__.py +5 -0
  74. sqlspec/adapters/psqlpy/adk/store.py +482 -0
  75. sqlspec/adapters/psqlpy/config.py +45 -19
  76. sqlspec/adapters/psqlpy/data_dictionary.py +41 -2
  77. sqlspec/adapters/psqlpy/driver.py +101 -31
  78. sqlspec/adapters/psqlpy/litestar/__init__.py +5 -0
  79. sqlspec/adapters/psqlpy/litestar/store.py +272 -0
  80. sqlspec/adapters/psqlpy/type_converter.py +40 -11
  81. sqlspec/adapters/psycopg/_type_handlers.py +80 -0
  82. sqlspec/adapters/psycopg/_types.py +2 -1
  83. sqlspec/adapters/psycopg/adk/__init__.py +5 -0
  84. sqlspec/adapters/psycopg/adk/store.py +944 -0
  85. sqlspec/adapters/psycopg/config.py +65 -37
  86. sqlspec/adapters/psycopg/data_dictionary.py +77 -3
  87. sqlspec/adapters/psycopg/driver.py +200 -78
  88. sqlspec/adapters/psycopg/litestar/__init__.py +5 -0
  89. sqlspec/adapters/psycopg/litestar/store.py +554 -0
  90. sqlspec/adapters/sqlite/__init__.py +2 -1
  91. sqlspec/adapters/sqlite/_type_handlers.py +86 -0
  92. sqlspec/adapters/sqlite/_types.py +1 -1
  93. sqlspec/adapters/sqlite/adk/__init__.py +5 -0
  94. sqlspec/adapters/sqlite/adk/store.py +572 -0
  95. sqlspec/adapters/sqlite/config.py +85 -16
  96. sqlspec/adapters/sqlite/data_dictionary.py +34 -2
  97. sqlspec/adapters/sqlite/driver.py +120 -52
  98. sqlspec/adapters/sqlite/litestar/__init__.py +5 -0
  99. sqlspec/adapters/sqlite/litestar/store.py +318 -0
  100. sqlspec/adapters/sqlite/pool.py +5 -5
  101. sqlspec/base.py +45 -26
  102. sqlspec/builder/__init__.py +73 -4
  103. sqlspec/builder/_base.py +91 -58
  104. sqlspec/builder/_column.py +5 -5
  105. sqlspec/builder/_ddl.py +98 -89
  106. sqlspec/builder/_delete.py +5 -4
  107. sqlspec/builder/_dml.py +388 -0
  108. sqlspec/{_sql.py → builder/_factory.py} +41 -44
  109. sqlspec/builder/_insert.py +5 -82
  110. sqlspec/builder/{mixins/_join_operations.py → _join.py} +145 -143
  111. sqlspec/builder/_merge.py +446 -11
  112. sqlspec/builder/_parsing_utils.py +9 -11
  113. sqlspec/builder/_select.py +1313 -25
  114. sqlspec/builder/_update.py +11 -42
  115. sqlspec/cli.py +76 -69
  116. sqlspec/config.py +231 -60
  117. sqlspec/core/__init__.py +5 -4
  118. sqlspec/core/cache.py +18 -18
  119. sqlspec/core/compiler.py +6 -8
  120. sqlspec/core/filters.py +37 -37
  121. sqlspec/core/hashing.py +9 -9
  122. sqlspec/core/parameters.py +76 -45
  123. sqlspec/core/result.py +102 -46
  124. sqlspec/core/splitter.py +16 -17
  125. sqlspec/core/statement.py +32 -31
  126. sqlspec/core/type_conversion.py +3 -2
  127. sqlspec/driver/__init__.py +1 -3
  128. sqlspec/driver/_async.py +95 -161
  129. sqlspec/driver/_common.py +133 -80
  130. sqlspec/driver/_sync.py +95 -162
  131. sqlspec/driver/mixins/_result_tools.py +20 -236
  132. sqlspec/driver/mixins/_sql_translator.py +4 -4
  133. sqlspec/exceptions.py +70 -7
  134. sqlspec/extensions/adk/__init__.py +53 -0
  135. sqlspec/extensions/adk/_types.py +51 -0
  136. sqlspec/extensions/adk/converters.py +172 -0
  137. sqlspec/extensions/adk/migrations/0001_create_adk_tables.py +144 -0
  138. sqlspec/extensions/adk/migrations/__init__.py +0 -0
  139. sqlspec/extensions/adk/service.py +181 -0
  140. sqlspec/extensions/adk/store.py +536 -0
  141. sqlspec/extensions/aiosql/adapter.py +73 -53
  142. sqlspec/extensions/litestar/__init__.py +21 -4
  143. sqlspec/extensions/litestar/cli.py +54 -10
  144. sqlspec/extensions/litestar/config.py +59 -266
  145. sqlspec/extensions/litestar/handlers.py +46 -17
  146. sqlspec/extensions/litestar/migrations/0001_create_session_table.py +137 -0
  147. sqlspec/extensions/litestar/migrations/__init__.py +3 -0
  148. sqlspec/extensions/litestar/plugin.py +324 -223
  149. sqlspec/extensions/litestar/providers.py +25 -25
  150. sqlspec/extensions/litestar/store.py +265 -0
  151. sqlspec/loader.py +30 -49
  152. sqlspec/migrations/base.py +200 -76
  153. sqlspec/migrations/commands.py +591 -62
  154. sqlspec/migrations/context.py +6 -9
  155. sqlspec/migrations/fix.py +199 -0
  156. sqlspec/migrations/loaders.py +47 -19
  157. sqlspec/migrations/runner.py +241 -75
  158. sqlspec/migrations/tracker.py +237 -21
  159. sqlspec/migrations/utils.py +51 -3
  160. sqlspec/migrations/validation.py +177 -0
  161. sqlspec/protocols.py +66 -36
  162. sqlspec/storage/_utils.py +98 -0
  163. sqlspec/storage/backends/fsspec.py +134 -106
  164. sqlspec/storage/backends/local.py +78 -51
  165. sqlspec/storage/backends/obstore.py +278 -162
  166. sqlspec/storage/registry.py +75 -39
  167. sqlspec/typing.py +14 -84
  168. sqlspec/utils/config_resolver.py +6 -6
  169. sqlspec/utils/correlation.py +4 -5
  170. sqlspec/utils/data_transformation.py +3 -2
  171. sqlspec/utils/deprecation.py +9 -8
  172. sqlspec/utils/fixtures.py +4 -4
  173. sqlspec/utils/logging.py +46 -6
  174. sqlspec/utils/module_loader.py +2 -2
  175. sqlspec/utils/schema.py +288 -0
  176. sqlspec/utils/serializers.py +3 -3
  177. sqlspec/utils/sync_tools.py +21 -17
  178. sqlspec/utils/text.py +1 -2
  179. sqlspec/utils/type_guards.py +111 -20
  180. sqlspec/utils/version.py +433 -0
  181. {sqlspec-0.26.0.dist-info → sqlspec-0.27.0.dist-info}/METADATA +40 -21
  182. sqlspec-0.27.0.dist-info/RECORD +207 -0
  183. sqlspec/builder/mixins/__init__.py +0 -55
  184. sqlspec/builder/mixins/_cte_and_set_ops.py +0 -253
  185. sqlspec/builder/mixins/_delete_operations.py +0 -50
  186. sqlspec/builder/mixins/_insert_operations.py +0 -282
  187. sqlspec/builder/mixins/_merge_operations.py +0 -698
  188. sqlspec/builder/mixins/_order_limit_operations.py +0 -145
  189. sqlspec/builder/mixins/_pivot_operations.py +0 -157
  190. sqlspec/builder/mixins/_select_operations.py +0 -930
  191. sqlspec/builder/mixins/_update_operations.py +0 -199
  192. sqlspec/builder/mixins/_where_clause.py +0 -1298
  193. sqlspec-0.26.0.dist-info/RECORD +0 -157
  194. sqlspec-0.26.0.dist-info/licenses/NOTICE +0 -29
  195. {sqlspec-0.26.0.dist-info → sqlspec-0.27.0.dist-info}/WHEEL +0 -0
  196. {sqlspec-0.26.0.dist-info → sqlspec-0.27.0.dist-info}/entry_points.txt +0 -0
  197. {sqlspec-0.26.0.dist-info → sqlspec-0.27.0.dist-info}/licenses/LICENSE +0 -0
@@ -7,7 +7,9 @@ understand type narrowing, replacing defensive hasattr() and duck typing pattern
7
7
  from collections.abc import Sequence
8
8
  from collections.abc import Set as AbstractSet
9
9
  from functools import lru_cache
10
- from typing import TYPE_CHECKING, Any, Optional, Union, cast
10
+ from typing import TYPE_CHECKING, Any, cast
11
+
12
+ from typing_extensions import is_typeddict
11
13
 
12
14
  from sqlspec.typing import (
13
15
  ATTRS_INSTALLED,
@@ -24,9 +26,9 @@ from sqlspec.typing import (
24
26
 
25
27
  if TYPE_CHECKING:
26
28
  from dataclasses import Field
29
+ from typing import TypeGuard
27
30
 
28
31
  from sqlglot import exp
29
- from typing_extensions import TypeGuard
30
32
 
31
33
  from sqlspec._typing import AttrsInstanceStub, BaseModelStub, DTODataStub, StructStub
32
34
  from sqlspec.builder import Select
@@ -100,6 +102,7 @@ __all__ = (
100
102
  "is_indexable_row",
101
103
  "is_iterable_parameters",
102
104
  "is_limit_offset_filter",
105
+ "is_local_path",
103
106
  "is_msgspec_struct",
104
107
  "is_msgspec_struct_with_field",
105
108
  "is_msgspec_struct_without_field",
@@ -117,8 +120,10 @@ __all__ = (
117
120
  "is_select_builder",
118
121
  "is_statement_filter",
119
122
  "is_string_literal",
123
+ "is_typed_dict",
120
124
  "is_typed_parameter",
121
125
  "schema_dump",
126
+ "supports_arrow_native",
122
127
  "supports_limit",
123
128
  "supports_offset",
124
129
  "supports_order_by",
@@ -126,6 +131,18 @@ __all__ = (
126
131
  )
127
132
 
128
133
 
134
+ def is_typed_dict(obj: Any) -> "TypeGuard[type]":
135
+ """Check if an object is a TypedDict class.
136
+
137
+ Args:
138
+ obj: The object to check
139
+
140
+ Returns:
141
+ True if the object is a TypedDict class, False otherwise
142
+ """
143
+ return is_typeddict(obj)
144
+
145
+
129
146
  def is_statement_filter(obj: Any) -> "TypeGuard[StatementFilter]":
130
147
  """Check if an object implements the StatementFilter protocol.
131
148
 
@@ -434,7 +451,7 @@ def is_msgspec_struct_without_field(obj: Any, field_name: str) -> "TypeGuard[Str
434
451
 
435
452
 
436
453
  @lru_cache(maxsize=500)
437
- def _detect_rename_pattern(field_name: str, encode_name: str) -> "Optional[str]":
454
+ def _detect_rename_pattern(field_name: str, encode_name: str) -> "str | None":
438
455
  """Detect the rename pattern by comparing field name transformations.
439
456
 
440
457
  Args:
@@ -458,7 +475,7 @@ def _detect_rename_pattern(field_name: str, encode_name: str) -> "Optional[str]"
458
475
  return None
459
476
 
460
477
 
461
- def get_msgspec_rename_config(schema_type: type) -> "Optional[str]":
478
+ def get_msgspec_rename_config(schema_type: type) -> "str | None":
462
479
  """Extract msgspec rename configuration from a struct type.
463
480
 
464
481
  Analyzes field name transformations to detect the rename pattern used by msgspec.
@@ -611,7 +628,7 @@ def is_schema(obj: Any) -> "TypeGuard[SupportedSchemaModel]":
611
628
  )
612
629
 
613
630
 
614
- def is_schema_or_dict(obj: Any) -> "TypeGuard[Union[SupportedSchemaModel, dict[str, Any]]]":
631
+ def is_schema_or_dict(obj: Any) -> "TypeGuard[SupportedSchemaModel | dict[str, Any]]":
615
632
  """Check if a value is a msgspec Struct, Pydantic model, or dict.
616
633
 
617
634
  Args:
@@ -649,7 +666,7 @@ def is_schema_without_field(obj: Any, field_name: str) -> "TypeGuard[SupportedSc
649
666
  return not is_schema_with_field(obj, field_name)
650
667
 
651
668
 
652
- def is_schema_or_dict_with_field(obj: Any, field_name: str) -> "TypeGuard[Union[SupportedSchemaModel, dict[str, Any]]]":
669
+ def is_schema_or_dict_with_field(obj: Any, field_name: str) -> "TypeGuard[SupportedSchemaModel | dict[str, Any]]":
653
670
  """Check if a value is a msgspec Struct, Pydantic model, or dict with a specific field.
654
671
 
655
672
  Args:
@@ -662,9 +679,7 @@ def is_schema_or_dict_with_field(obj: Any, field_name: str) -> "TypeGuard[Union[
662
679
  return is_schema_with_field(obj, field_name) or is_dict_with_field(obj, field_name)
663
680
 
664
681
 
665
- def is_schema_or_dict_without_field(
666
- obj: Any, field_name: str
667
- ) -> "TypeGuard[Union[SupportedSchemaModel, dict[str, Any]]]":
682
+ def is_schema_or_dict_without_field(obj: Any, field_name: str) -> "TypeGuard[SupportedSchemaModel | dict[str, Any]]":
668
683
  """Check if a value is a msgspec Struct, Pydantic model, or dict without a specific field.
669
684
 
670
685
  Args:
@@ -721,8 +736,8 @@ def extract_dataclass_fields(
721
736
  obj: "DataclassProtocol",
722
737
  exclude_none: bool = False,
723
738
  exclude_empty: bool = False,
724
- include: "Optional[AbstractSet[str]]" = None,
725
- exclude: "Optional[AbstractSet[str]]" = None,
739
+ include: "AbstractSet[str] | None" = None,
740
+ exclude: "AbstractSet[str] | None" = None,
726
741
  ) -> "tuple[Field[Any], ...]":
727
742
  """Extract dataclass fields.
728
743
 
@@ -767,8 +782,8 @@ def extract_dataclass_items(
767
782
  obj: "DataclassProtocol",
768
783
  exclude_none: bool = False,
769
784
  exclude_empty: bool = False,
770
- include: "Optional[AbstractSet[str]]" = None,
771
- exclude: "Optional[AbstractSet[str]]" = None,
785
+ include: "AbstractSet[str] | None" = None,
786
+ exclude: "AbstractSet[str] | None" = None,
772
787
  ) -> "tuple[tuple[str, Any], ...]":
773
788
  """Extract name-value pairs from a dataclass instance.
774
789
 
@@ -791,7 +806,7 @@ def dataclass_to_dict(
791
806
  exclude_none: bool = False,
792
807
  exclude_empty: bool = False,
793
808
  convert_nested: bool = True,
794
- exclude: "Optional[AbstractSet[str]]" = None,
809
+ exclude: "AbstractSet[str] | None" = None,
795
810
  ) -> "dict[str, Any]":
796
811
  """Convert a dataclass instance to a dictionary.
797
812
 
@@ -978,7 +993,7 @@ def has_attr(obj: Any, attr: str) -> bool:
978
993
  return True
979
994
 
980
995
 
981
- def get_node_this(node: "exp.Expression", default: Optional[Any] = None) -> Any:
996
+ def get_node_this(node: "exp.Expression", default: Any | None = None) -> Any:
982
997
  """Safely get the 'this' attribute from a SQLGlot node.
983
998
 
984
999
  Args:
@@ -1010,7 +1025,7 @@ def has_this_attribute(node: "exp.Expression") -> bool:
1010
1025
  return True
1011
1026
 
1012
1027
 
1013
- def get_node_expressions(node: "exp.Expression", default: Optional[Any] = None) -> Any:
1028
+ def get_node_expressions(node: "exp.Expression", default: Any | None = None) -> Any:
1014
1029
  """Safely get the 'expressions' attribute from a SQLGlot node.
1015
1030
 
1016
1031
  Args:
@@ -1042,7 +1057,7 @@ def has_expressions_attribute(node: "exp.Expression") -> bool:
1042
1057
  return True
1043
1058
 
1044
1059
 
1045
- def get_literal_parent(literal: "exp.Expression", default: Optional[Any] = None) -> Any:
1060
+ def get_literal_parent(literal: "exp.Expression", default: Any | None = None) -> Any:
1046
1061
  """Safely get the 'parent' attribute from a SQLGlot literal.
1047
1062
 
1048
1063
  Args:
@@ -1113,7 +1128,7 @@ def is_number_literal(literal: "exp.Literal") -> bool:
1113
1128
  return False
1114
1129
 
1115
1130
 
1116
- def get_initial_expression(context: Any) -> "Optional[exp.Expression]":
1131
+ def get_initial_expression(context: Any) -> "exp.Expression | None":
1117
1132
  """Safely get initial_expression from context.
1118
1133
 
1119
1134
  Args:
@@ -1128,7 +1143,7 @@ def get_initial_expression(context: Any) -> "Optional[exp.Expression]":
1128
1143
  return None
1129
1144
 
1130
1145
 
1131
- def expression_has_limit(expr: "Optional[exp.Expression]") -> bool:
1146
+ def expression_has_limit(expr: "exp.Expression | None") -> bool:
1132
1147
  """Check if an expression has a limit clause.
1133
1148
 
1134
1149
  Args:
@@ -1160,7 +1175,7 @@ def get_value_attribute(obj: Any) -> Any:
1160
1175
  return obj
1161
1176
 
1162
1177
 
1163
- def get_param_style_and_name(param: Any) -> "tuple[Optional[str], Optional[str]]":
1178
+ def get_param_style_and_name(param: Any) -> "tuple[str | None, str | None]":
1164
1179
  """Safely get style and name attributes from a parameter.
1165
1180
 
1166
1181
  Args:
@@ -1242,3 +1257,79 @@ def has_expression_and_parameters(obj: Any) -> bool:
1242
1257
  True if the object has both attributes, False otherwise
1243
1258
  """
1244
1259
  return hasattr(obj, "expression") and hasattr(obj, "parameters")
1260
+
1261
+
1262
+ WINDOWS_DRIVE_PATTERN_LENGTH = 3
1263
+
1264
+
1265
+ def is_local_path(uri: str) -> bool:
1266
+ r"""Check if URI represents a local filesystem path.
1267
+
1268
+ Detects local paths including:
1269
+ - file:// URIs
1270
+ - Absolute paths (Unix: /, Windows: C:\\)
1271
+ - Relative paths (., .., ~)
1272
+
1273
+ Args:
1274
+ uri: URI or path string to check.
1275
+
1276
+ Returns:
1277
+ True if uri is a local path, False for remote URIs.
1278
+
1279
+ Examples:
1280
+ >>> is_local_path("file:///data/file.txt")
1281
+ True
1282
+ >>> is_local_path("/absolute/path")
1283
+ True
1284
+ >>> is_local_path("s3://bucket/key")
1285
+ False
1286
+ """
1287
+ if not uri:
1288
+ return False
1289
+
1290
+ if "://" in uri and not uri.startswith("file://"):
1291
+ return False
1292
+
1293
+ if uri.startswith("file://"):
1294
+ return True
1295
+
1296
+ if uri.startswith("/"):
1297
+ return True
1298
+
1299
+ if uri.startswith((".", "~")):
1300
+ return True
1301
+
1302
+ if len(uri) >= WINDOWS_DRIVE_PATTERN_LENGTH and uri[1:3] == ":\\":
1303
+ return True
1304
+
1305
+ return "/" in uri or "\\" in uri
1306
+
1307
+
1308
+ def supports_arrow_native(backend: Any) -> bool:
1309
+ """Check if storage backend supports native Arrow operations.
1310
+
1311
+ Some storage backends (like certain obstore stores) have native
1312
+ Arrow read/write support, which is faster than going through bytes.
1313
+
1314
+ Args:
1315
+ backend: Storage backend instance to check.
1316
+
1317
+ Returns:
1318
+ True if backend has native read_arrow/write_arrow methods.
1319
+
1320
+ Examples:
1321
+ >>> from sqlspec.storage.backends.obstore import ObStoreBackend
1322
+ >>> backend = ObStoreBackend("file:///tmp")
1323
+ >>> supports_arrow_native(backend)
1324
+ False
1325
+ """
1326
+ from sqlspec.protocols import ObjectStoreProtocol
1327
+
1328
+ if not isinstance(backend, ObjectStoreProtocol):
1329
+ return False
1330
+
1331
+ try:
1332
+ store = backend.store # type: ignore[attr-defined]
1333
+ return callable(getattr(store, "read_arrow", None))
1334
+ except AttributeError:
1335
+ return False
@@ -0,0 +1,433 @@
1
+ """Migration version parsing and comparison utilities.
2
+
3
+ Provides structured parsing of migration versions supporting both legacy sequential
4
+ (0001) and timestamp-based (20251011120000) formats with type-safe comparison.
5
+ """
6
+
7
+ import logging
8
+ import re
9
+ from dataclasses import dataclass
10
+ from datetime import datetime, timezone
11
+ from enum import Enum
12
+ from typing import Any
13
+
14
+ __all__ = (
15
+ "MigrationVersion",
16
+ "VersionType",
17
+ "convert_to_sequential_version",
18
+ "generate_conversion_map",
19
+ "generate_timestamp_version",
20
+ "get_next_sequential_number",
21
+ "is_sequential_version",
22
+ "is_timestamp_version",
23
+ "parse_version",
24
+ )
25
+
26
+ logger = logging.getLogger(__name__)
27
+
28
+ # Regex patterns for version detection
29
+ SEQUENTIAL_PATTERN = re.compile(r"^(?!\d{14}$)(\d+)$")
30
+ TIMESTAMP_PATTERN = re.compile(r"^(\d{14})$")
31
+ EXTENSION_PATTERN = re.compile(r"^ext_(\w+)_(.+)$")
32
+
33
+
34
+ class VersionType(Enum):
35
+ """Migration version format type."""
36
+
37
+ SEQUENTIAL = "sequential"
38
+ TIMESTAMP = "timestamp"
39
+
40
+
41
+ @dataclass(frozen=True)
42
+ class MigrationVersion:
43
+ """Parsed migration version with structured comparison support.
44
+
45
+ Attributes:
46
+ raw: Original version string (e.g., "0001", "20251011120000", "ext_litestar_0001").
47
+ type: Version format type (sequential or timestamp).
48
+ sequence: Numeric value for sequential versions (e.g., 1, 2, 42).
49
+ timestamp: Parsed datetime for timestamp versions (UTC).
50
+ extension: Extension name for extension-prefixed versions (e.g., "litestar").
51
+ """
52
+
53
+ raw: str
54
+ type: VersionType
55
+ sequence: "int | None"
56
+ timestamp: "datetime | None"
57
+ extension: "str | None"
58
+
59
+ def __lt__(self, other: "MigrationVersion") -> bool:
60
+ """Compare versions supporting mixed formats.
61
+
62
+ Comparison Rules:
63
+ 1. Extension migrations sort by extension name first, then version
64
+ 2. Sequential < Timestamp (legacy migrations first)
65
+ 3. Sequential vs Sequential: numeric comparison
66
+ 4. Timestamp vs Timestamp: chronological comparison
67
+
68
+ Args:
69
+ other: Version to compare against.
70
+
71
+ Returns:
72
+ True if this version sorts before other.
73
+
74
+ Raises:
75
+ TypeError: If comparing against non-MigrationVersion.
76
+ """
77
+ if not isinstance(other, MigrationVersion):
78
+ return NotImplemented
79
+
80
+ if self.extension != other.extension:
81
+ if self.extension is None:
82
+ return True
83
+ if other.extension is None:
84
+ return False
85
+ return self.extension < other.extension
86
+
87
+ if self.type == other.type:
88
+ if self.type == VersionType.SEQUENTIAL:
89
+ return (self.sequence or 0) < (other.sequence or 0)
90
+ return (self.timestamp or datetime.min.replace(tzinfo=timezone.utc)) < (
91
+ other.timestamp or datetime.min.replace(tzinfo=timezone.utc)
92
+ )
93
+
94
+ return self.type == VersionType.SEQUENTIAL
95
+
96
+ def __le__(self, other: "MigrationVersion") -> bool:
97
+ """Check if version is less than or equal to another.
98
+
99
+ Args:
100
+ other: Version to compare against.
101
+
102
+ Returns:
103
+ True if this version is less than or equal to other.
104
+ """
105
+ return self == other or self < other
106
+
107
+ def __eq__(self, other: object) -> bool:
108
+ """Check version equality.
109
+
110
+ Args:
111
+ other: Version to compare against.
112
+
113
+ Returns:
114
+ True if versions are equal.
115
+ """
116
+ if not isinstance(other, MigrationVersion):
117
+ return NotImplemented
118
+ return self.raw == other.raw
119
+
120
+ def __hash__(self) -> int:
121
+ """Hash version for use in sets and dicts.
122
+
123
+ Returns:
124
+ Hash value based on raw version string.
125
+ """
126
+ return hash(self.raw)
127
+
128
+ def __repr__(self) -> str:
129
+ """Get string representation for debugging.
130
+
131
+ Returns:
132
+ String representation with type and value.
133
+ """
134
+ if self.extension:
135
+ return f"MigrationVersion(ext={self.extension}, {self.type.value}={self.raw})"
136
+ return f"MigrationVersion({self.type.value}={self.raw})"
137
+
138
+
139
+ def is_sequential_version(version_str: str) -> bool:
140
+ """Check if version string is sequential format.
141
+
142
+ Sequential format: Any sequence of digits (0001, 42, 9999, 10000+).
143
+
144
+ Args:
145
+ version_str: Version string to check.
146
+
147
+ Returns:
148
+ True if sequential format.
149
+
150
+ Examples:
151
+ >>> is_sequential_version("0001")
152
+ True
153
+ >>> is_sequential_version("42")
154
+ True
155
+ >>> is_sequential_version("10000")
156
+ True
157
+ >>> is_sequential_version("20251011120000")
158
+ False
159
+ """
160
+ return bool(SEQUENTIAL_PATTERN.match(version_str))
161
+
162
+
163
+ def is_timestamp_version(version_str: str) -> bool:
164
+ """Check if version string is timestamp format.
165
+
166
+ Timestamp format: 14-digit YYYYMMDDHHmmss (20251011120000).
167
+
168
+ Args:
169
+ version_str: Version string to check.
170
+
171
+ Returns:
172
+ True if timestamp format.
173
+
174
+ Examples:
175
+ >>> is_timestamp_version("20251011120000")
176
+ True
177
+ >>> is_timestamp_version("0001")
178
+ False
179
+ """
180
+ if not TIMESTAMP_PATTERN.match(version_str):
181
+ return False
182
+
183
+ try:
184
+ datetime.strptime(version_str, "%Y%m%d%H%M%S").replace(tzinfo=timezone.utc)
185
+ except ValueError:
186
+ return False
187
+ else:
188
+ return True
189
+
190
+
191
+ def parse_version(version_str: str) -> MigrationVersion:
192
+ """Parse version string into structured format.
193
+
194
+ Supports:
195
+ - Sequential: "0001", "42", "9999"
196
+ - Timestamp: "20251011120000"
197
+ - Extension: "ext_litestar_0001", "ext_litestar_20251011120000"
198
+
199
+ Args:
200
+ version_str: Version string to parse.
201
+
202
+ Returns:
203
+ Parsed migration version.
204
+
205
+ Raises:
206
+ ValueError: If version format is invalid.
207
+
208
+ Examples:
209
+ >>> v = parse_version("0001")
210
+ >>> v.type == VersionType.SEQUENTIAL
211
+ True
212
+ >>> v.sequence
213
+ 1
214
+
215
+ >>> v = parse_version("20251011120000")
216
+ >>> v.type == VersionType.TIMESTAMP
217
+ True
218
+
219
+ >>> v = parse_version("ext_litestar_0001")
220
+ >>> v.extension
221
+ 'litestar'
222
+ """
223
+ extension_match = EXTENSION_PATTERN.match(version_str)
224
+ if extension_match:
225
+ extension_name = extension_match.group(1)
226
+ base_version = extension_match.group(2)
227
+ parsed = parse_version(base_version)
228
+
229
+ return MigrationVersion(
230
+ raw=version_str,
231
+ type=parsed.type,
232
+ sequence=parsed.sequence,
233
+ timestamp=parsed.timestamp,
234
+ extension=extension_name,
235
+ )
236
+
237
+ if is_timestamp_version(version_str):
238
+ dt = datetime.strptime(version_str, "%Y%m%d%H%M%S").replace(tzinfo=timezone.utc)
239
+ return MigrationVersion(
240
+ raw=version_str, type=VersionType.TIMESTAMP, sequence=None, timestamp=dt, extension=None
241
+ )
242
+
243
+ if is_sequential_version(version_str):
244
+ return MigrationVersion(
245
+ raw=version_str, type=VersionType.SEQUENTIAL, sequence=int(version_str), timestamp=None, extension=None
246
+ )
247
+
248
+ msg = f"Invalid migration version format: {version_str}. Expected sequential (0001) or timestamp (YYYYMMDDHHmmss)."
249
+ raise ValueError(msg)
250
+
251
+
252
+ def generate_timestamp_version() -> str:
253
+ """Generate new timestamp version in UTC.
254
+
255
+ Format: YYYYMMDDHHmmss (14 digits).
256
+
257
+ Returns:
258
+ Timestamp version string.
259
+
260
+ Examples:
261
+ >>> version = generate_timestamp_version()
262
+ >>> len(version)
263
+ 14
264
+ >>> is_timestamp_version(version)
265
+ True
266
+ """
267
+ return datetime.now(tz=timezone.utc).strftime("%Y%m%d%H%M%S")
268
+
269
+
270
+ def get_next_sequential_number(migrations: "list[MigrationVersion]", extension: "str | None" = None) -> int:
271
+ """Find highest sequential number and return next available.
272
+
273
+ Scans migrations for sequential versions and returns the next number in sequence.
274
+ When extension is specified, only that extension's migrations are considered.
275
+ When extension is None, only core (non-extension) migrations are considered.
276
+
277
+ Args:
278
+ migrations: List of parsed migration versions.
279
+ extension: Optional extension name to filter by (e.g., "litestar", "adk").
280
+ None means core migrations only.
281
+
282
+ Returns:
283
+ Next available sequential number (1 if no sequential migrations exist).
284
+
285
+ Examples:
286
+ >>> v1 = parse_version("0001")
287
+ >>> v2 = parse_version("0002")
288
+ >>> get_next_sequential_number([v1, v2])
289
+ 3
290
+
291
+ >>> get_next_sequential_number([])
292
+ 1
293
+
294
+ >>> ext = parse_version("ext_litestar_0001")
295
+ >>> core = parse_version("0001")
296
+ >>> get_next_sequential_number([ext, core])
297
+ 2
298
+
299
+ >>> ext1 = parse_version("ext_litestar_0001")
300
+ >>> get_next_sequential_number([ext1], extension="litestar")
301
+ 2
302
+ """
303
+ sequential = [
304
+ m.sequence for m in migrations if m.type == VersionType.SEQUENTIAL and m.extension == extension and m.sequence
305
+ ]
306
+
307
+ if not sequential:
308
+ return 1
309
+
310
+ return max(sequential) + 1
311
+
312
+
313
+ def convert_to_sequential_version(timestamp_version: MigrationVersion, sequence_number: int) -> str:
314
+ """Convert timestamp MigrationVersion to sequential string format.
315
+
316
+ Preserves extension prefixes during conversion. Format uses zero-padded
317
+ 4-digit numbers (0001, 0002, etc.).
318
+
319
+ Args:
320
+ timestamp_version: Parsed timestamp version to convert.
321
+ sequence_number: Sequential number to assign.
322
+
323
+ Returns:
324
+ Sequential version string with extension prefix if applicable.
325
+
326
+ Raises:
327
+ ValueError: If input is not a timestamp version.
328
+
329
+ Examples:
330
+ >>> v = parse_version("20251011120000")
331
+ >>> convert_to_sequential_version(v, 3)
332
+ '0003'
333
+
334
+ >>> v = parse_version("ext_litestar_20251011120000")
335
+ >>> convert_to_sequential_version(v, 1)
336
+ 'ext_litestar_0001'
337
+
338
+ >>> v = parse_version("0001")
339
+ >>> convert_to_sequential_version(v, 2)
340
+ Traceback (most recent call last):
341
+ ...
342
+ ValueError: Can only convert timestamp versions to sequential
343
+ """
344
+ if timestamp_version.type != VersionType.TIMESTAMP:
345
+ msg = "Can only convert timestamp versions to sequential"
346
+ raise ValueError(msg)
347
+
348
+ seq_str = str(sequence_number).zfill(4)
349
+
350
+ if timestamp_version.extension:
351
+ return f"ext_{timestamp_version.extension}_{seq_str}"
352
+
353
+ return seq_str
354
+
355
+
356
+ def generate_conversion_map(migrations: "list[tuple[str, Any]]") -> "dict[str, str]":
357
+ """Generate mapping from timestamp versions to sequential versions.
358
+
359
+ Separates timestamp migrations from sequential, sorts timestamps chronologically,
360
+ and assigns sequential numbers starting after the highest existing sequential
361
+ number. Extension migrations maintain separate numbering within their namespace.
362
+
363
+ Args:
364
+ migrations: List of tuples (version_string, migration_path).
365
+
366
+ Returns:
367
+ Dictionary mapping old timestamp versions to new sequential versions.
368
+
369
+ Examples:
370
+ >>> migrations = [
371
+ ... ("0001", Path("0001_init.sql")),
372
+ ... ("0002", Path("0002_users.sql")),
373
+ ... ("20251011120000", Path("20251011120000_products.sql")),
374
+ ... ("20251012130000", Path("20251012130000_orders.sql")),
375
+ ... ]
376
+ >>> result = generate_conversion_map(migrations)
377
+ >>> result
378
+ {'20251011120000': '0003', '20251012130000': '0004'}
379
+
380
+ >>> migrations = [
381
+ ... ("20251011120000", Path("20251011120000_first.sql")),
382
+ ... ("20251010090000", Path("20251010090000_earlier.sql")),
383
+ ... ]
384
+ >>> result = generate_conversion_map(migrations)
385
+ >>> result
386
+ {'20251010090000': '0001', '20251011120000': '0002'}
387
+
388
+ >>> migrations = []
389
+ >>> generate_conversion_map(migrations)
390
+ {}
391
+ """
392
+ if not migrations:
393
+ return {}
394
+
395
+ def _try_parse_version(version_str: str) -> "MigrationVersion | None":
396
+ """Parse version string, returning None for invalid versions."""
397
+ try:
398
+ return parse_version(version_str)
399
+ except ValueError:
400
+ logger.warning("Skipping invalid migration version: %s", version_str)
401
+ return None
402
+
403
+ parsed_versions = [v for version_str, _path in migrations if (v := _try_parse_version(version_str)) is not None]
404
+
405
+ timestamp_migrations = sorted([v for v in parsed_versions if v.type == VersionType.TIMESTAMP])
406
+
407
+ if not timestamp_migrations:
408
+ return {}
409
+
410
+ core_timestamps = [m for m in timestamp_migrations if m.extension is None]
411
+ ext_timestamps_by_name: dict[str, list[MigrationVersion]] = {}
412
+ for m in timestamp_migrations:
413
+ if m.extension:
414
+ ext_timestamps_by_name.setdefault(m.extension, []).append(m)
415
+
416
+ conversion_map: dict[str, str] = {}
417
+
418
+ if core_timestamps:
419
+ next_seq = get_next_sequential_number(parsed_versions)
420
+ for timestamp_version in core_timestamps:
421
+ sequential_version = convert_to_sequential_version(timestamp_version, next_seq)
422
+ conversion_map[timestamp_version.raw] = sequential_version
423
+ next_seq += 1
424
+
425
+ for ext_name, ext_migrations in ext_timestamps_by_name.items():
426
+ ext_parsed = [v for v in parsed_versions if v.extension == ext_name]
427
+ next_seq = get_next_sequential_number(ext_parsed, extension=ext_name)
428
+ for timestamp_version in ext_migrations:
429
+ sequential_version = convert_to_sequential_version(timestamp_version, next_seq)
430
+ conversion_map[timestamp_version.raw] = sequential_version
431
+ next_seq += 1
432
+
433
+ return conversion_map