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,260 @@
|
|
|
1
|
+
"""Machine-readable SQL and specification capability declarations."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from dataclasses import replace
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from sqlalchemy import MetaData, create_engine, text
|
|
11
|
+
from sqlalchemy.engine import make_url
|
|
12
|
+
|
|
13
|
+
from .normative import catalog
|
|
14
|
+
from .profiles import MYSQL, POSTGRESQL, SQLITE, SqlProfile
|
|
15
|
+
from .profiles import profile_for_url
|
|
16
|
+
from ..core import UnsupportedOperationError
|
|
17
|
+
|
|
18
|
+
SPECIFICATION_COMMIT = "6b9d1fc38f2f083c0ac5cf1c64874a6d07b95045"
|
|
19
|
+
SPECIFICATION_RELEASE: str | None = None
|
|
20
|
+
|
|
21
|
+
SERVER_POLICIES = {
|
|
22
|
+
"sqlite": {
|
|
23
|
+
"claimed": ["SQLite >=3.24.0 <4.0.0"],
|
|
24
|
+
"ci": ["active GitHub runner SQLite version"],
|
|
25
|
+
},
|
|
26
|
+
"mysql": {
|
|
27
|
+
"claimed": ["MySQL 8.4.x", "MySQL 9.7.x"],
|
|
28
|
+
"ci": ["MySQL 8.4.x", "MySQL 9.7.x"],
|
|
29
|
+
},
|
|
30
|
+
"mariadb": {
|
|
31
|
+
"claimed": ["MariaDB 11.4.x", "MariaDB 11.8.x", "MariaDB 12.3.x"],
|
|
32
|
+
"ci": ["MariaDB 11.4.x", "MariaDB 11.8.x", "MariaDB 12.3.x"],
|
|
33
|
+
},
|
|
34
|
+
"postgresql": {
|
|
35
|
+
"claimed": ["PostgreSQL 17.x", "PostgreSQL 18.x"],
|
|
36
|
+
"ci": ["PostgreSQL 17.x", "PostgreSQL 18.x"],
|
|
37
|
+
},
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def profile_declarations(database_url: str | None = None) -> dict[str, dict[str, Any]]:
|
|
42
|
+
"""Declare every profile and, optionally, one active connection."""
|
|
43
|
+
active = active_connection(database_url) if database_url else None
|
|
44
|
+
return {
|
|
45
|
+
"sqlite": _profile("sqlite", SQLITE, active),
|
|
46
|
+
"mysql": _profile("mysql", MYSQL, active),
|
|
47
|
+
"mariadb": _profile("mariadb", MYSQL, active),
|
|
48
|
+
"postgresql": _profile("postgresql", POSTGRESQL, active),
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def active_connection(database_url: str) -> dict[str, Any]:
|
|
53
|
+
engine = create_engine(database_url)
|
|
54
|
+
configured = make_url(database_url)
|
|
55
|
+
with engine.connect() as connection:
|
|
56
|
+
dialect = connection.dialect.name
|
|
57
|
+
if dialect == "sqlite":
|
|
58
|
+
profile_name = "sqlite"
|
|
59
|
+
raw_version = str(connection.execute(text("select sqlite_version()")).scalar_one())
|
|
60
|
+
identity_source = "select sqlite_version()"
|
|
61
|
+
compile_options = {
|
|
62
|
+
name: int(value)
|
|
63
|
+
for option in connection.exec_driver_sql("pragma compile_options").scalars()
|
|
64
|
+
if "=" in str(option)
|
|
65
|
+
for name, value in [str(option).split("=", 1)]
|
|
66
|
+
if value.isdigit()
|
|
67
|
+
}
|
|
68
|
+
observed = {"compile_options": compile_options}
|
|
69
|
+
elif dialect == "postgresql":
|
|
70
|
+
profile_name = "postgresql"
|
|
71
|
+
raw_version = str(connection.execute(text("show server_version")).scalar_one())
|
|
72
|
+
identity_source = "SHOW server_version"
|
|
73
|
+
observed = {}
|
|
74
|
+
elif dialect in {"mysql", "mariadb"}:
|
|
75
|
+
raw_version = str(connection.execute(text("select @@version")).scalar_one())
|
|
76
|
+
comment = str(connection.execute(text("select @@version_comment")).scalar_one())
|
|
77
|
+
profile_name = "mariadb" if "mariadb" in (raw_version + " " + comment).lower() else "mysql"
|
|
78
|
+
identity_source = "SELECT @@version, @@version_comment"
|
|
79
|
+
observed = {
|
|
80
|
+
"max_allowed_packet": int(
|
|
81
|
+
connection.execute(text("select @@max_allowed_packet")).scalar_one()
|
|
82
|
+
)
|
|
83
|
+
}
|
|
84
|
+
else: # pragma: no cover - validate_connection_url rejects this first
|
|
85
|
+
raise ValueError(f"Unsupported active SQL dialect {dialect!r}.")
|
|
86
|
+
return {
|
|
87
|
+
"profile": profile_name,
|
|
88
|
+
"server_version": _normalized_version(raw_version),
|
|
89
|
+
"raw_server_version": raw_version,
|
|
90
|
+
"identity_source": identity_source,
|
|
91
|
+
"claimed_supported": server_version_supported(profile_name, raw_version),
|
|
92
|
+
"matched_claim": _matched_claim(profile_name, raw_version),
|
|
93
|
+
"catalog_binding": catalog_binding(database_url),
|
|
94
|
+
"observed": observed,
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def effective_profile(database_url: str) -> tuple[SqlProfile, dict[str, Any]]:
|
|
99
|
+
"""Resolve and enforce the profile used by import preflight."""
|
|
100
|
+
configured = profile_for_url(database_url)
|
|
101
|
+
active = active_connection(database_url)
|
|
102
|
+
if configured is not MYSQL and active["profile"] != configured.name:
|
|
103
|
+
raise UnsupportedOperationError("The active SQL server does not match the configured profile.")
|
|
104
|
+
if configured is MYSQL and active["profile"] not in {"mysql", "mariadb"}:
|
|
105
|
+
raise UnsupportedOperationError("The active SQL server is not MySQL or MariaDB.")
|
|
106
|
+
if not active["claimed_supported"]:
|
|
107
|
+
raise UnsupportedOperationError(
|
|
108
|
+
f"Active {active['profile']} server version {active['server_version']} is not claimed supported."
|
|
109
|
+
)
|
|
110
|
+
declaration = _profile(active["profile"], configured, active)
|
|
111
|
+
limits = declaration["effective_limits"]
|
|
112
|
+
assert limits is not None
|
|
113
|
+
return replace(
|
|
114
|
+
configured,
|
|
115
|
+
name=active["profile"],
|
|
116
|
+
max_physical_variables=int(limits["maximum_source_variables"]),
|
|
117
|
+
max_text_value_bytes=int(limits["maximum_value_bytes"]),
|
|
118
|
+
max_row_bytes=int(limits["maximum_row_bytes"]),
|
|
119
|
+
), active
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def catalog_binding(database_url: str) -> dict[str, Any]:
|
|
123
|
+
url = make_url(database_url)
|
|
124
|
+
database = url.database or ""
|
|
125
|
+
if url.get_backend_name() == "sqlite":
|
|
126
|
+
namespace = str(Path(database).resolve()) if database and database != ":memory:" else database
|
|
127
|
+
mode = "dedicated_database_file"
|
|
128
|
+
else:
|
|
129
|
+
namespace = database
|
|
130
|
+
mode = "dedicated_database"
|
|
131
|
+
logical = catalog(MetaData())
|
|
132
|
+
return {
|
|
133
|
+
"mode": mode,
|
|
134
|
+
"namespace": namespace,
|
|
135
|
+
"identity_marker": "catalog_identity",
|
|
136
|
+
"contract_id": "openstatspec-strict-wide-table-v1",
|
|
137
|
+
"schema_version": 1,
|
|
138
|
+
"required_isolation": "exclusive OpenStatSpec catalog connection",
|
|
139
|
+
"logical_to_physical_relations": {
|
|
140
|
+
table.name: table.name for table in logical.all()
|
|
141
|
+
},
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _profile(
|
|
146
|
+
name: str, profile: SqlProfile, active: dict[str, Any] | None,
|
|
147
|
+
) -> dict[str, Any]:
|
|
148
|
+
identifier = {
|
|
149
|
+
"value": profile.identifier_limit,
|
|
150
|
+
"unit": "characters" if name in {"mysql", "mariadb"} else "bytes",
|
|
151
|
+
"source": (
|
|
152
|
+
"MySQL/MariaDB native identifier limit" if name in {"mysql", "mariadb"}
|
|
153
|
+
else "PostgreSQL NAMEDATALEN minus one native byte limit" if name == "postgresql"
|
|
154
|
+
else "OpenStatSpec profile boundary; SQLite has no fixed native identifier limit"
|
|
155
|
+
),
|
|
156
|
+
"repertoire": "generated ASCII [a-z0-9_] identifiers",
|
|
157
|
+
}
|
|
158
|
+
theoretical = {
|
|
159
|
+
"maximum_physical_columns": profile.max_physical_variables + 1,
|
|
160
|
+
"maximum_source_variables": profile.max_physical_variables,
|
|
161
|
+
"identifier_limit": identifier,
|
|
162
|
+
"maximum_value_bytes": profile.max_text_value_bytes,
|
|
163
|
+
"maximum_row_bytes": profile.max_row_bytes,
|
|
164
|
+
}
|
|
165
|
+
effective = None
|
|
166
|
+
status = "not_connected"
|
|
167
|
+
if active and active["profile"] == name:
|
|
168
|
+
effective = dict(theoretical)
|
|
169
|
+
sources = {
|
|
170
|
+
"maximum_source_variables": "profile theoretical engine ceiling",
|
|
171
|
+
"maximum_physical_columns": "profile theoretical engine ceiling",
|
|
172
|
+
"identifier_limit": identifier["source"],
|
|
173
|
+
"maximum_value_bytes": "profile theoretical engine ceiling",
|
|
174
|
+
"maximum_row_bytes": "profile theoretical engine ceiling",
|
|
175
|
+
}
|
|
176
|
+
observed = active["observed"]
|
|
177
|
+
if name == "sqlite":
|
|
178
|
+
options = observed["compile_options"]
|
|
179
|
+
if "MAX_COLUMN" in options:
|
|
180
|
+
effective["maximum_physical_columns"] = min(
|
|
181
|
+
theoretical["maximum_physical_columns"], options["MAX_COLUMN"],
|
|
182
|
+
)
|
|
183
|
+
effective["maximum_source_variables"] = max(
|
|
184
|
+
0, effective["maximum_physical_columns"] - 1,
|
|
185
|
+
)
|
|
186
|
+
sources["maximum_physical_columns"] = "active PRAGMA compile_options MAX_COLUMN"
|
|
187
|
+
sources["maximum_source_variables"] = "active MAX_COLUMN minus technical ordinal"
|
|
188
|
+
if "MAX_LENGTH" in options:
|
|
189
|
+
effective["maximum_value_bytes"] = min(
|
|
190
|
+
theoretical["maximum_value_bytes"], options["MAX_LENGTH"],
|
|
191
|
+
)
|
|
192
|
+
effective["maximum_row_bytes"] = min(
|
|
193
|
+
theoretical["maximum_row_bytes"], options["MAX_LENGTH"],
|
|
194
|
+
)
|
|
195
|
+
sources["maximum_value_bytes"] = "active PRAGMA compile_options MAX_LENGTH"
|
|
196
|
+
sources["maximum_row_bytes"] = "active PRAGMA compile_options MAX_LENGTH"
|
|
197
|
+
status = "active_connection_mixed"
|
|
198
|
+
elif name in {"mysql", "mariadb"}:
|
|
199
|
+
packet = int(observed["max_allowed_packet"])
|
|
200
|
+
payload = max(0, (packet - 131_072) // 2)
|
|
201
|
+
effective["maximum_value_bytes"] = min(
|
|
202
|
+
theoretical["maximum_value_bytes"], payload,
|
|
203
|
+
)
|
|
204
|
+
effective["maximum_statement_bytes"] = payload
|
|
205
|
+
sources["maximum_value_bytes"] = "active @@max_allowed_packet worst-case payload"
|
|
206
|
+
sources["maximum_statement_bytes"] = "active @@max_allowed_packet worst-case payload"
|
|
207
|
+
status = "active_connection_mixed"
|
|
208
|
+
else:
|
|
209
|
+
status = "profile_theoretical_fallback"
|
|
210
|
+
effective["sources"] = sources
|
|
211
|
+
policy = SERVER_POLICIES[name]
|
|
212
|
+
return {
|
|
213
|
+
"driver": "psycopg" if name == "postgresql" else "PyMySQL" if name in {"mysql", "mariadb"} else "sqlite3",
|
|
214
|
+
"claimed_server_versions": policy["claimed"],
|
|
215
|
+
"ci_tested_server_versions": policy["ci"],
|
|
216
|
+
"theoretical_limits": theoretical,
|
|
217
|
+
"effective_limits": effective,
|
|
218
|
+
"effective_limits_status": status,
|
|
219
|
+
"numeric_type": "DOUBLE PRECISION" if name == "postgresql" else "DOUBLE" if name in {"mysql", "mariadb"} else "REAL",
|
|
220
|
+
"text_type": "LONGTEXT" if name in {"mysql", "mariadb"} else "TEXT",
|
|
221
|
+
"ddl_atomic": name not in {"mysql", "mariadb"},
|
|
222
|
+
"failure_cleanup": "compensating_cleanup" if name in {"mysql", "mariadb"} else "transaction_rollback",
|
|
223
|
+
"physical_table_mapping": "dataset.physical_table_schema + dataset.physical_table_name",
|
|
224
|
+
"identifier_policy": "deterministic ASCII mapping; source name remains authoritative",
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def server_version_supported(profile: str, raw_version: str) -> bool:
|
|
229
|
+
version = _version_tuple(raw_version)
|
|
230
|
+
if profile == "sqlite":
|
|
231
|
+
return (3, 24) <= version[:2] < (4, 0)
|
|
232
|
+
allowed = {
|
|
233
|
+
"postgresql": {(17,), (18,)},
|
|
234
|
+
"mysql": {(8, 4), (9, 7)},
|
|
235
|
+
"mariadb": {(11, 4), (11, 8), (12, 3)},
|
|
236
|
+
}[profile]
|
|
237
|
+
width = len(next(iter(allowed)))
|
|
238
|
+
return version[:width] in allowed
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _matched_claim(profile: str, raw_version: str) -> str | None:
|
|
242
|
+
if not server_version_supported(profile, raw_version):
|
|
243
|
+
return None
|
|
244
|
+
version = _version_tuple(raw_version)
|
|
245
|
+
for claim in SERVER_POLICIES[profile]["claimed"]:
|
|
246
|
+
numbers = _version_tuple(claim)
|
|
247
|
+
if profile == "sqlite" or version[:2] == numbers[:2] or version[:1] == numbers[:1]:
|
|
248
|
+
return claim
|
|
249
|
+
return SERVER_POLICIES[profile]["claimed"][0]
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _version_tuple(raw_version: str) -> tuple[int, ...]:
|
|
253
|
+
match = re.search(r"(\d+)(?:\.(\d+))?(?:\.(\d+))?", raw_version)
|
|
254
|
+
if not match:
|
|
255
|
+
return ()
|
|
256
|
+
return tuple(int(part) for part in match.groups(default="0"))
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _normalized_version(raw_version: str) -> str:
|
|
260
|
+
return ".".join(str(part) for part in _version_tuple(raw_version))
|