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.
- openstatspec/__init__.py +6 -0
- openstatspec/api.py +95 -0
- openstatspec/cli.py +42 -0
- openstatspec/core/__init__.py +33 -0
- openstatspec/core/results.py +47 -0
- openstatspec/spss/__init__.py +22 -0
- openstatspec/spss/dictionary.py +180 -0
- openstatspec/spss/raw_dictionary.py +393 -0
- openstatspec/spss/sav.py +569 -0
- openstatspec/sql/__init__.py +14 -0
- openstatspec/sql/capabilities.py +260 -0
- openstatspec/sql/normative.py +513 -0
- openstatspec/sql/profiles.py +217 -0
- openstatspec/sql/wide.py +1087 -0
- openstatspec-0.1.0.dist-info/METADATA +90 -0
- openstatspec-0.1.0.dist-info/RECORD +18 -0
- openstatspec-0.1.0.dist-info/WHEEL +4 -0
- openstatspec-0.1.0.dist-info/entry_points.txt +2 -0
|
@@ -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
|