openstatspec 0.1.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.
@@ -0,0 +1,217 @@
1
+ """Dialect capability declarations for the strict wide-table contract.
2
+
3
+ A declaration is deliberately not a claim that a live target has been tested.
4
+ Importers use this information for preflight checks before creating a dataset.
5
+ """
6
+
7
+ from collections.abc import Iterable, Mapping
8
+ from dataclasses import dataclass
9
+ from typing import Any
10
+ from urllib.parse import urlparse
11
+
12
+ from ..core import UnsupportedOperationError
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class SqlProfile:
17
+ name: str
18
+ url_schemes: tuple[str, ...]
19
+ max_physical_variables: int
20
+ identifier_limit: int
21
+ binary64_numeric: bool
22
+ lossless_text: bool
23
+ max_text_value_bytes: int
24
+ max_row_bytes: int
25
+ tested_reference: bool = False
26
+ driver_packages: tuple[str, ...] = ()
27
+
28
+ def as_dict(self) -> dict[str, object]:
29
+ return {
30
+ "max_physical_variables": self.max_physical_variables,
31
+ "identifier_limit": self.identifier_limit,
32
+ "binary64_numeric": self.binary64_numeric,
33
+ "lossless_text": self.lossless_text,
34
+ "max_text_value_bytes": self.max_text_value_bytes,
35
+ "max_row_bytes": self.max_row_bytes,
36
+ "tested_reference": self.tested_reference,
37
+ "driver_packages": list(self.driver_packages),
38
+ }
39
+
40
+
41
+ SQLITE = SqlProfile(
42
+ "sqlite", ("sqlite",), 1_999, 255, True, True,
43
+ 1_000_000_000, 1_000_000_000, True,
44
+ )
45
+ POSTGRESQL = SqlProfile(
46
+ "postgresql", ("postgresql", "postgres"), 1_599, 63, True, True,
47
+ 1_073_741_823, 1_073_741_823, True, ("psycopg",),
48
+ )
49
+ # SQLAlchemy's generic Text column compiles to MySQL TEXT, not MEDIUMTEXT.
50
+ # The contract therefore declares TEXT's 64 KiB payload limit rather than
51
+ # promising an unimplemented wider physical type.
52
+ MYSQL = SqlProfile(
53
+ "mysql", ("mysql", "mariadb"), 1_016, 64, True, True,
54
+ 4_294_967_295, 65_535, True, ("PyMySQL",),
55
+ )
56
+ PROFILES = (SQLITE, POSTGRESQL, MYSQL)
57
+
58
+
59
+ def profile_for_url(database_url: str) -> SqlProfile:
60
+ scheme = urlparse(database_url).scheme.split("+", 1)[0].lower()
61
+ for profile in PROFILES:
62
+ if scheme in profile.url_schemes:
63
+ return profile
64
+ raise UnsupportedOperationError(
65
+ f"No OpenStatSpec SQL profile is declared for database URL scheme {scheme!r}."
66
+ )
67
+
68
+
69
+
70
+ def validate_connection_url(database_url: str) -> SqlProfile:
71
+ profile = profile_for_url(database_url)
72
+ scheme = urlparse(database_url).scheme.lower()
73
+ if profile is POSTGRESQL and scheme != "postgresql+psycopg":
74
+ raise UnsupportedOperationError("PostgreSQL requires an explicit postgresql+psycopg URL.")
75
+ if profile is MYSQL and scheme not in {"mysql+pymysql", "mariadb+mariadbconnector"}:
76
+ raise UnsupportedOperationError("MySQL/MariaDB requires an explicit mysql+pymysql or mariadb+mariadbconnector URL.")
77
+ return profile
78
+
79
+ class TargetCapabilityExceededError(UnsupportedOperationError):
80
+ """A strict wide-table capability check failed before source state existed."""
81
+
82
+ def __init__(self, message: str, *, details: Mapping[str, Any]) -> None:
83
+ super().__init__(message)
84
+ self.details = dict(details)
85
+
86
+
87
+ def _exceeded(reason: str, message: str, **details: Any) -> TargetCapabilityExceededError:
88
+ return TargetCapabilityExceededError(
89
+ "Target capability exceeded: " + message,
90
+ details={"reason": reason, **details},
91
+ )
92
+
93
+
94
+ def preflight(
95
+ profile: SqlProfile,
96
+ variables_or_count: int | Iterable[Mapping[str, Any]],
97
+ *,
98
+ rows: Iterable[Mapping[str, Any]] | None = None,
99
+ ) -> None:
100
+ """Validate strict target capabilities before any source dataset is created."""
101
+ variables = None if isinstance(variables_or_count, int) else list(variables_or_count)
102
+ variable_count = variables_or_count if isinstance(variables_or_count, int) else len(variables)
103
+ if variable_count > profile.max_physical_variables:
104
+ raise _exceeded(
105
+ "physical_variable_limit",
106
+ f"{profile.name} supports at most {profile.max_physical_variables} "
107
+ "source variables in one strict wide table.",
108
+ variable_count=variable_count, maximum=profile.max_physical_variables,
109
+ )
110
+ if variables is None:
111
+ return
112
+
113
+ used = {"__case_ordinal"}
114
+ source_names: set[str] = set()
115
+ for expected_ordinal, variable in enumerate(variables, start=1):
116
+ source_name = variable.get("source_name")
117
+ if not isinstance(source_name, str) or not source_name or source_name in source_names:
118
+ raise _exceeded(
119
+ "source_identifier_collision", "source variable names must be non-empty and unique.",
120
+ source_name=source_name,
121
+ )
122
+ source_names.add(source_name)
123
+ expected_name = _physical_name(source_name, used)
124
+ actual_name = variable.get("physical_name")
125
+ if variable.get("ordinal") != expected_ordinal or actual_name != expected_name:
126
+ raise _exceeded(
127
+ "physical_identifier_mapping_invalid",
128
+ f"{source_name!r} must map deterministically to {expected_name!r} in source order.",
129
+ source_name=source_name, expected_physical_name=expected_name,
130
+ actual_physical_name=actual_name,
131
+ )
132
+ identifier_bytes = len(expected_name.encode("utf-8"))
133
+ if identifier_bytes > profile.identifier_limit:
134
+ raise _exceeded(
135
+ "identifier_limit",
136
+ f"identifier {expected_name!r} is {identifier_bytes} bytes; "
137
+ f"{profile.name} permits {profile.identifier_limit}.",
138
+ identifier=expected_name, identifier_bytes=identifier_bytes,
139
+ maximum=profile.identifier_limit,
140
+ )
141
+ if variable.get("storage_kind") == "string":
142
+ declared_width = variable.get("string_width")
143
+ if declared_width is not None and (
144
+ isinstance(declared_width, bool)
145
+ or not isinstance(declared_width, int)
146
+ or declared_width < 0
147
+ ):
148
+ raise _exceeded(
149
+ "invalid_declared_string_width",
150
+ f"{source_name!r} has an invalid declared string width.",
151
+ source_name=source_name, string_width=declared_width,
152
+ )
153
+ if declared_width is not None and declared_width > profile.max_text_value_bytes:
154
+ raise _exceeded(
155
+ "declared_string_width_limit",
156
+ f"{source_name!r} declares {declared_width} UTF-8 bytes; "
157
+ f"{profile.name} permits {profile.max_text_value_bytes}.",
158
+ source_name=source_name, string_width=declared_width,
159
+ maximum=profile.max_text_value_bytes,
160
+ )
161
+ declared_row_bytes = 8 + sum(_row_storage_bytes(profile, variable) for variable in variables)
162
+ if declared_row_bytes > profile.max_row_bytes:
163
+ raise _exceeded(
164
+ "declared_row_size_limit",
165
+ f"the declared SQL row requires {declared_row_bytes} bytes; "
166
+ f"{profile.name} permits {profile.max_row_bytes}.",
167
+ declared_row_bytes=declared_row_bytes, maximum=profile.max_row_bytes,
168
+ )
169
+ for row_ordinal, row in enumerate(rows or (), start=1):
170
+ row_bytes = 8
171
+ for variable in variables:
172
+ if variable.get("storage_kind") != "string":
173
+ row_bytes += 8
174
+ continue
175
+ value = row.get(str(variable["physical_name"]), "")
176
+ encoded_bytes = len(str(value).encode("utf-8"))
177
+ if encoded_bytes > profile.max_text_value_bytes:
178
+ raise _exceeded(
179
+ "text_value_limit",
180
+ f"row {row_ordinal} value for {variable['source_name']!r} is "
181
+ f"{encoded_bytes} UTF-8 bytes; {profile.name} permits "
182
+ f"{profile.max_text_value_bytes}.",
183
+ row_ordinal=row_ordinal, source_name=variable["source_name"],
184
+ encoded_bytes=encoded_bytes, maximum=profile.max_text_value_bytes,
185
+ )
186
+ row_bytes += 20 if profile.name in {"mysql", "mariadb"} else encoded_bytes
187
+ if row_bytes > profile.max_row_bytes:
188
+ raise _exceeded(
189
+ "row_size_limit",
190
+ f"row {row_ordinal} requires {row_bytes} bytes; "
191
+ f"{profile.name} permits {profile.max_row_bytes}.",
192
+ row_ordinal=row_ordinal, row_bytes=row_bytes,
193
+ maximum=profile.max_row_bytes,
194
+ )
195
+
196
+
197
+ def _row_storage_bytes(profile: SqlProfile, variable: Mapping[str, Any]) -> int:
198
+ if variable.get("storage_kind") == "numeric":
199
+ return 8
200
+ if profile.name in {"mysql", "mariadb"}:
201
+ return 20
202
+ return int(variable.get("string_width") or 0)
203
+
204
+
205
+
206
+ def _physical_name(source_name: str, used: set[str]) -> str:
207
+ """The profile-independent deterministic OpenStatSpec SQL-name mapping."""
208
+ import re
209
+
210
+ stem = re.sub(r"[^a-zA-Z0-9_]+", "_", source_name).strip("_").lower() or "variable"
211
+ stem = stem[:54]
212
+ candidate, suffix = stem, 2
213
+ while candidate.lower() in used or candidate.startswith("__"):
214
+ candidate = f"{stem[:50]}_{suffix}"
215
+ suffix += 1
216
+ used.add(candidate.lower())
217
+ return candidate