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
openstatspec/spss/sav.py
ADDED
|
@@ -0,0 +1,569 @@
|
|
|
1
|
+
"""Strict SAV/ZSAV adapter backed exclusively by pyspssio.
|
|
2
|
+
|
|
3
|
+
The adapter keeps SPSS data in one physical wide table and preserves every
|
|
4
|
+
metadata feature exposed by pyspssio plus standard type-6 document records. A source feature that neither path can
|
|
5
|
+
observe or write is a durable fidelity event and blocks export unless the
|
|
6
|
+
caller explicitly accepts that exact loss.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import hashlib
|
|
10
|
+
import json
|
|
11
|
+
import math
|
|
12
|
+
from contextlib import ExitStack
|
|
13
|
+
from datetime import UTC, datetime
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from tempfile import TemporaryDirectory
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
import pandas as pd
|
|
19
|
+
import pyspssio
|
|
20
|
+
|
|
21
|
+
from .raw_dictionary import (
|
|
22
|
+
RawDictionaryError,
|
|
23
|
+
read_document_lines,
|
|
24
|
+
write_compatible_names,
|
|
25
|
+
write_document_lines,
|
|
26
|
+
write_extended_mrset_labels,
|
|
27
|
+
)
|
|
28
|
+
from .dictionary import (
|
|
29
|
+
attribute_pairs,
|
|
30
|
+
attribute_values,
|
|
31
|
+
file_attribute_pairs,
|
|
32
|
+
format_string,
|
|
33
|
+
format_tuple,
|
|
34
|
+
format_tuples,
|
|
35
|
+
set_file_attribute_pairs,
|
|
36
|
+
set_format_tuples,
|
|
37
|
+
set_variable_attribute_pairs,
|
|
38
|
+
variable_attribute_pairs,
|
|
39
|
+
)
|
|
40
|
+
from ..core import UnsupportedOperationError
|
|
41
|
+
from ..sql.wide import (
|
|
42
|
+
create_wide_dataset,
|
|
43
|
+
physical_name,
|
|
44
|
+
read_fidelity_events,
|
|
45
|
+
read_wide_dataset,
|
|
46
|
+
record_export_operation,
|
|
47
|
+
validate_spss_catalog,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
_UTF8_ENCODINGS = {"UTF-8", "UTF8"}
|
|
51
|
+
def engine_identity() -> dict[str, str]:
|
|
52
|
+
"""Return the exact pinned SPSS engine identity for audit records."""
|
|
53
|
+
return {
|
|
54
|
+
"package": "openstatspec-pyspssio",
|
|
55
|
+
"module": "pyspssio",
|
|
56
|
+
"repository": "TonisOrmisson/pyspssio",
|
|
57
|
+
"pinned_commit": "e069adf33c70bcd9e8e6ee495106479463a84fa2",
|
|
58
|
+
"installed_version": str(pyspssio.__version__),
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _dictionary(source_path: Path) -> tuple[dict[str, Any], dict[str, Any]]:
|
|
63
|
+
"""Read public pyspssio metadata plus the exposed variable-set property."""
|
|
64
|
+
metadata = dict(pyspssio.read_metadata(str(source_path)))
|
|
65
|
+
try:
|
|
66
|
+
with pyspssio.Reader(str(source_path), mode="r") as reader:
|
|
67
|
+
metadata["_var_sets"] = dict(reader.var_sets or {})
|
|
68
|
+
print_formats, write_formats = format_tuples(reader)
|
|
69
|
+
metadata["_print_format_tuples"] = print_formats
|
|
70
|
+
metadata["_write_format_tuples"] = write_formats
|
|
71
|
+
metadata["file_attributes"] = attribute_values(file_attribute_pairs(reader))
|
|
72
|
+
metadata["var_attributes"] = {
|
|
73
|
+
name: attribute_values(variable_attribute_pairs(reader, name))
|
|
74
|
+
for name in reader.var_names
|
|
75
|
+
if variable_attribute_pairs(reader, name)
|
|
76
|
+
}
|
|
77
|
+
try:
|
|
78
|
+
metadata["_documents"] = read_document_lines(
|
|
79
|
+
source_path, encoding=str(metadata.get("encoding") or "UTF-8"),
|
|
80
|
+
)
|
|
81
|
+
except RawDictionaryError as error:
|
|
82
|
+
metadata["_documents"] = None
|
|
83
|
+
metadata["_documents_error"] = str(error)
|
|
84
|
+
except Exception as error: # The source stays importable, but not silently faithful.
|
|
85
|
+
metadata["_var_sets"] = None
|
|
86
|
+
metadata["_var_sets_error"] = str(error)
|
|
87
|
+
return metadata, _engine_loss_report(metadata)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _variables(metadata: dict[str, Any], names: list[str]) -> list[dict[str, Any]]:
|
|
91
|
+
used = {"__case_ordinal"}
|
|
92
|
+
types = dict(metadata.get("var_types") or {})
|
|
93
|
+
labels = dict(metadata.get("var_labels") or {})
|
|
94
|
+
formats = dict(metadata.get("var_formats") or {})
|
|
95
|
+
print_formats = dict(metadata.get("_print_format_tuples") or {})
|
|
96
|
+
write_formats = dict(metadata.get("_write_format_tuples") or {})
|
|
97
|
+
measures = dict(metadata.get("var_measure_levels") or {})
|
|
98
|
+
roles = dict(metadata.get("var_roles") or {})
|
|
99
|
+
alignments = dict(metadata.get("var_alignments") or {})
|
|
100
|
+
displays = dict(metadata.get("var_column_widths") or {})
|
|
101
|
+
value_labels = dict(metadata.get("var_value_labels") or {})
|
|
102
|
+
missing = dict(metadata.get("var_missing_values") or {})
|
|
103
|
+
attributes = dict(metadata.get("var_attributes") or {})
|
|
104
|
+
compat_names = dict(metadata.get("var_compat_names") or {})
|
|
105
|
+
variables: list[dict[str, Any]] = []
|
|
106
|
+
for ordinal, source_name in enumerate(names, start=1):
|
|
107
|
+
width = int(types.get(source_name) or 0)
|
|
108
|
+
kind = "string" if width else "numeric"
|
|
109
|
+
variables.append({
|
|
110
|
+
"ordinal": ordinal,
|
|
111
|
+
"source_name": source_name,
|
|
112
|
+
"physical_name": physical_name(source_name, used),
|
|
113
|
+
"storage_kind": kind,
|
|
114
|
+
"readstat_storage_type": f"pyspssio:{kind}",
|
|
115
|
+
"string_width": width if kind == "string" else None,
|
|
116
|
+
"label": str(labels.get(source_name) or ""),
|
|
117
|
+
"format": format_string(print_formats[source_name]) if source_name in print_formats else formats.get(source_name),
|
|
118
|
+
"print_format": _json(list(print_formats[source_name])) if source_name in print_formats else None,
|
|
119
|
+
"write_format": _json(list(write_formats[source_name])) if source_name in write_formats else None,
|
|
120
|
+
"measure": measures.get(source_name),
|
|
121
|
+
"role": roles.get(source_name),
|
|
122
|
+
"alignment": alignments.get(source_name),
|
|
123
|
+
"display_width": displays.get(source_name),
|
|
124
|
+
"attributes": _json_ordered(attributes.get(source_name, {})),
|
|
125
|
+
"compat_name": compat_names.get(source_name),
|
|
126
|
+
"value_labels": _json(value_labels.get(source_name, {})),
|
|
127
|
+
"missing_ranges": _json(_missing_ranges(missing.get(source_name))),
|
|
128
|
+
})
|
|
129
|
+
return variables
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _missing_ranges(rule: Any) -> list[Any]:
|
|
133
|
+
"""Map pyspssio's {values, lo, hi} form to the catalog's ordered rules."""
|
|
134
|
+
if not rule:
|
|
135
|
+
return []
|
|
136
|
+
if not isinstance(rule, dict):
|
|
137
|
+
return list(rule) if isinstance(rule, (list, tuple)) else [rule]
|
|
138
|
+
result: list[Any] = []
|
|
139
|
+
if "lo" in rule or "hi" in rule:
|
|
140
|
+
result.append({"lo": rule.get("lo"), "hi": rule.get("hi")})
|
|
141
|
+
result.extend(rule.get("values") or [])
|
|
142
|
+
return result
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _pyspssio_missing_rules(encoded: str) -> dict[str, Any] | None:
|
|
146
|
+
rules = json.loads(encoded or "[]")
|
|
147
|
+
if not rules:
|
|
148
|
+
return None
|
|
149
|
+
discrete: list[Any] = []
|
|
150
|
+
range_rule: dict[str, Any] | None = None
|
|
151
|
+
for rule in rules:
|
|
152
|
+
if isinstance(rule, dict):
|
|
153
|
+
lower, upper = rule.get("lo"), rule.get("hi")
|
|
154
|
+
if lower == upper:
|
|
155
|
+
discrete.append(lower)
|
|
156
|
+
else:
|
|
157
|
+
if range_rule is not None:
|
|
158
|
+
raise UnsupportedOperationError(
|
|
159
|
+
"SPSS permits at most one user-missing range per variable; catalog contains more."
|
|
160
|
+
)
|
|
161
|
+
range_rule = {"lo": lower, "hi": upper}
|
|
162
|
+
else:
|
|
163
|
+
discrete.append(rule)
|
|
164
|
+
if range_rule is not None:
|
|
165
|
+
if len(discrete) > 1:
|
|
166
|
+
raise UnsupportedOperationError(
|
|
167
|
+
"SPSS permits one discrete user-missing value alongside a range; catalog contains more."
|
|
168
|
+
)
|
|
169
|
+
if discrete:
|
|
170
|
+
range_rule["values"] = discrete
|
|
171
|
+
return range_rule
|
|
172
|
+
if len(discrete) > 3:
|
|
173
|
+
raise UnsupportedOperationError(
|
|
174
|
+
"SPSS permits at most three discrete user-missing values per variable."
|
|
175
|
+
)
|
|
176
|
+
return {"values": discrete}
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _rows(frame: pd.DataFrame, variables: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
180
|
+
records: list[dict[str, Any]] = []
|
|
181
|
+
for input_row in frame.to_dict(orient="records"):
|
|
182
|
+
output: dict[str, Any] = {}
|
|
183
|
+
for item in variables:
|
|
184
|
+
value = input_row[item["source_name"]]
|
|
185
|
+
if item["storage_kind"] == "numeric" and pd.isna(value):
|
|
186
|
+
value = None
|
|
187
|
+
elif item["storage_kind"] == "string" and (value is None or pd.isna(value)):
|
|
188
|
+
value = ""
|
|
189
|
+
output[item["physical_name"]] = value
|
|
190
|
+
records.append(output)
|
|
191
|
+
return records
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def inspect_sav(source: str | Path) -> dict[str, Any]:
|
|
195
|
+
source_path = Path(source)
|
|
196
|
+
_require_source(source_path)
|
|
197
|
+
metadata, loss_report = _dictionary(source_path)
|
|
198
|
+
names = list(metadata.get("var_names") or [])
|
|
199
|
+
variables = _variables(metadata, names)
|
|
200
|
+
loss_report = _merge_loss_reports(tuple(loss_report.values()))
|
|
201
|
+
return {
|
|
202
|
+
"source_format": source_path.suffix[1:].upper(),
|
|
203
|
+
"engine": engine_identity(),
|
|
204
|
+
"source_name": source_path.name,
|
|
205
|
+
"source_sha256": _sha256(source_path),
|
|
206
|
+
"source_encoding": metadata.get("encoding"),
|
|
207
|
+
"file_label": str(metadata.get("file_label") or ""),
|
|
208
|
+
"documents": list(metadata.get("_documents") or []),
|
|
209
|
+
"file_attributes": dict(metadata.get("file_attributes") or {}),
|
|
210
|
+
"case_weight_variable": metadata.get("case_weight_var") or None,
|
|
211
|
+
"multiple_response_sets": dict(metadata.get("mrsets") or {}),
|
|
212
|
+
"loss_report": loss_report,
|
|
213
|
+
"variable_count": len(names),
|
|
214
|
+
"variables": variables,
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def import_sav_dataset(*, source: str | Path, database_url: str, dataset_id: str) -> dict[str, Any]:
|
|
219
|
+
source_path = Path(source)
|
|
220
|
+
_require_source(source_path)
|
|
221
|
+
frame, metadata = pyspssio.read_sav(
|
|
222
|
+
str(source_path), convert_datetimes=False, include_user_missing=True,
|
|
223
|
+
)
|
|
224
|
+
metadata = dict(metadata)
|
|
225
|
+
dictionary, loss_report = _dictionary(source_path)
|
|
226
|
+
# read_sav and read_metadata expose the same header fields; retain the
|
|
227
|
+
# frame read's values where a future pyspssio version exposes more there.
|
|
228
|
+
metadata = {**metadata, **dictionary}
|
|
229
|
+
variables = _variables(metadata, list(frame.columns))
|
|
230
|
+
loss_report = _merge_loss_reports(tuple(loss_report.values()))
|
|
231
|
+
result = create_wide_dataset(
|
|
232
|
+
database_url=database_url,
|
|
233
|
+
dataset_id=dataset_id,
|
|
234
|
+
source_name=source_path.name,
|
|
235
|
+
source_format=source_path.suffix[1:].upper(),
|
|
236
|
+
rows=_rows(frame, variables),
|
|
237
|
+
variables=variables,
|
|
238
|
+
file_label=str(metadata.get("file_label") or ""),
|
|
239
|
+
source_encoding=metadata.get("encoding"),
|
|
240
|
+
source_sha256=_sha256(source_path),
|
|
241
|
+
imported_at=datetime.now(UTC).isoformat(),
|
|
242
|
+
documents=_json_ordered(metadata.get("_documents") or []),
|
|
243
|
+
file_attributes=_json_ordered(metadata.get("file_attributes") or {}),
|
|
244
|
+
file_attribute_values=dict(metadata.get("file_attributes") or {}),
|
|
245
|
+
variable_attribute_values=dict(metadata.get("var_attributes") or {}),
|
|
246
|
+
case_weight_variable=metadata.get("case_weight_var") or None,
|
|
247
|
+
multiple_response_sets=_json(metadata.get("mrsets") or {}),
|
|
248
|
+
source_extensions=(
|
|
249
|
+
{"spss.variable_sets": metadata["_var_sets"]}
|
|
250
|
+
if metadata.get("_var_sets") else {}
|
|
251
|
+
),
|
|
252
|
+
fidelity_events=loss_report,
|
|
253
|
+
operation_details={"engine": engine_identity()},
|
|
254
|
+
)
|
|
255
|
+
return {**result, "loss_report": loss_report}
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def export_sav_dataset(
|
|
259
|
+
*, database_url: str, dataset_id: str, destination: str | Path,
|
|
260
|
+
allow_loss: tuple[str, ...] = (), legacy_locale: str | None = None,
|
|
261
|
+
) -> dict[str, Any]:
|
|
262
|
+
destination_path = Path(destination)
|
|
263
|
+
if destination_path.suffix.lower() not in {".sav", ".zsav"}:
|
|
264
|
+
raise UnsupportedOperationError("Export destinations must use the .sav or .zsav extension.")
|
|
265
|
+
dataset, variables, rows = read_wide_dataset(database_url=database_url, dataset_id=dataset_id)
|
|
266
|
+
validate_spss_catalog(
|
|
267
|
+
variables,
|
|
268
|
+
case_weight_variable=dataset.get("case_weight_variable"),
|
|
269
|
+
multiple_response_sets=dataset.get("multiple_response_sets"),
|
|
270
|
+
)
|
|
271
|
+
persisted_events = read_fidelity_events(database_url=database_url, dataset_id=dataset_id)
|
|
272
|
+
if legacy_locale is not None:
|
|
273
|
+
persisted_events = tuple(
|
|
274
|
+
event for event in persisted_events
|
|
275
|
+
if event["code"] != "source-encoding-not-preserved"
|
|
276
|
+
)
|
|
277
|
+
loss_report = _merge_loss_reports(
|
|
278
|
+
_export_loss_report(dataset, variables, legacy_locale=legacy_locale),
|
|
279
|
+
persisted_events,
|
|
280
|
+
)
|
|
281
|
+
rejected = [event["code"] for event in loss_report if event["code"] not in allow_loss]
|
|
282
|
+
if rejected:
|
|
283
|
+
raise UnsupportedOperationError("Export requires explicit allow_loss for: " + ", ".join(rejected))
|
|
284
|
+
|
|
285
|
+
frame = pd.DataFrame(
|
|
286
|
+
[
|
|
287
|
+
{
|
|
288
|
+
variable["source_name"]: (
|
|
289
|
+
math.nan if row[variable["physical_name"]] is None else float(row[variable["physical_name"]])
|
|
290
|
+
) if variable["storage_kind"] == "numeric" else row[variable["physical_name"]]
|
|
291
|
+
for variable in variables
|
|
292
|
+
}
|
|
293
|
+
for row in rows
|
|
294
|
+
],
|
|
295
|
+
columns=[variable["source_name"] for variable in variables],
|
|
296
|
+
)
|
|
297
|
+
try:
|
|
298
|
+
_write_with_dictionary_bridge(
|
|
299
|
+
destination_path, frame, dataset, variables, legacy_locale=legacy_locale,
|
|
300
|
+
)
|
|
301
|
+
except Exception:
|
|
302
|
+
destination_path.unlink(missing_ok=True)
|
|
303
|
+
raise
|
|
304
|
+
operation_id = record_export_operation(
|
|
305
|
+
database_url=database_url,
|
|
306
|
+
dataset_id=dataset_id,
|
|
307
|
+
destination=str(destination_path),
|
|
308
|
+
allowed_fidelity_events=tuple(
|
|
309
|
+
event for event in loss_report if event["code"] in allow_loss
|
|
310
|
+
),
|
|
311
|
+
operation_details={"engine": engine_identity(), "legacy_locale": legacy_locale},
|
|
312
|
+
)
|
|
313
|
+
return {
|
|
314
|
+
"dataset_id": dataset_id,
|
|
315
|
+
"destination": str(destination_path),
|
|
316
|
+
"operation_id": operation_id,
|
|
317
|
+
"loss_report": loss_report,
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def _write_with_dictionary_bridge(
|
|
322
|
+
destination: Path, frame: pd.DataFrame, dataset: dict[str, Any], variables: list[dict[str, Any]],
|
|
323
|
+
*, legacy_locale: str | None = None,
|
|
324
|
+
) -> None:
|
|
325
|
+
"""Write a dictionary through pyspssio and preserve its document record.
|
|
326
|
+
|
|
327
|
+
IBM I/O can copy document records but cannot expose their lines. The
|
|
328
|
+
strict helper reads and writes a temporary UTF-8 SAV source; the selected
|
|
329
|
+
pyspssio engine then copies it into the real SAV or ZSAV writer, which
|
|
330
|
+
keeps any ZSAV internal dictionary offsets valid.
|
|
331
|
+
"""
|
|
332
|
+
metadata = _writer_metadata(dataset, variables)
|
|
333
|
+
documents = _json_load(dataset.get("documents"), [])
|
|
334
|
+
source_encoding = str(dataset.get("source_encoding") or "UTF-8")
|
|
335
|
+
legacy_output = _is_non_utf8_encoding(source_encoding) and legacy_locale is not None
|
|
336
|
+
with ExitStack() as stack:
|
|
337
|
+
document_source = None
|
|
338
|
+
if documents:
|
|
339
|
+
temporary_directory = Path(stack.enter_context(TemporaryDirectory()))
|
|
340
|
+
temporary_source = temporary_directory / "documents.sav"
|
|
341
|
+
pyspssio.write_sav(
|
|
342
|
+
str(temporary_source), pd.DataFrame({"document_source": [0.0]}),
|
|
343
|
+
unicode=not legacy_output, locale=legacy_locale,
|
|
344
|
+
)
|
|
345
|
+
temporary_encoding = str(pyspssio.read_metadata(str(temporary_source)).get("encoding") or "UTF-8")
|
|
346
|
+
_require_matching_legacy_encoding(source_encoding, temporary_encoding, legacy_output)
|
|
347
|
+
write_document_lines(temporary_source, documents, encoding=temporary_encoding)
|
|
348
|
+
document_source = stack.enter_context(
|
|
349
|
+
pyspssio.Reader(
|
|
350
|
+
str(temporary_source), mode="r",
|
|
351
|
+
unicode=not legacy_output, locale=legacy_locale,
|
|
352
|
+
)
|
|
353
|
+
)
|
|
354
|
+
writer = stack.enter_context(
|
|
355
|
+
pyspssio.Writer(
|
|
356
|
+
str(destination), mode="w", unicode=not legacy_output, locale=legacy_locale,
|
|
357
|
+
)
|
|
358
|
+
)
|
|
359
|
+
writer.compression = 2 if destination.suffix.lower() == ".zsav" else 1
|
|
360
|
+
writer.file_label = str(dataset.get("file_label") or "")
|
|
361
|
+
for variable in variables:
|
|
362
|
+
writer._add_var( # pylint: disable=protected-access
|
|
363
|
+
variable["source_name"],
|
|
364
|
+
int(variable["string_width"] or 0) if variable["storage_kind"] == "string" else 0,
|
|
365
|
+
)
|
|
366
|
+
for variable in variables:
|
|
367
|
+
set_format_tuples(
|
|
368
|
+
writer, name=variable["source_name"],
|
|
369
|
+
print_format=_catalog_format(variable, "print_format"),
|
|
370
|
+
write_format=_catalog_format(variable, "write_format"),
|
|
371
|
+
)
|
|
372
|
+
for name in (
|
|
373
|
+
"var_labels", "var_measure_levels", "var_roles", "var_alignments",
|
|
374
|
+
"var_column_widths", "var_value_labels", "var_missing_values", "mrsets",
|
|
375
|
+
):
|
|
376
|
+
value = metadata.get(name)
|
|
377
|
+
if value:
|
|
378
|
+
setattr(writer, name, value)
|
|
379
|
+
if dataset.get("case_weight_variable"):
|
|
380
|
+
writer.case_weight_var = dataset["case_weight_variable"]
|
|
381
|
+
set_file_attribute_pairs(
|
|
382
|
+
writer, attribute_pairs(_json_load(dataset.get("file_attributes"), {})),
|
|
383
|
+
)
|
|
384
|
+
for variable in variables:
|
|
385
|
+
values = _json_load(variable.get("attributes"), {})
|
|
386
|
+
if values:
|
|
387
|
+
set_variable_attribute_pairs(
|
|
388
|
+
writer, variable["source_name"], attribute_pairs(values),
|
|
389
|
+
)
|
|
390
|
+
variable_sets = (dataset.get("source_extensions") or {}).get("spss.variable_sets")
|
|
391
|
+
if variable_sets:
|
|
392
|
+
writer.var_sets = variable_sets
|
|
393
|
+
if document_source is not None:
|
|
394
|
+
writer.copy_documents_from(document_source)
|
|
395
|
+
writer.commit_header()
|
|
396
|
+
_require_matching_legacy_encoding(source_encoding, str(writer.file_encoding), legacy_output)
|
|
397
|
+
writer.write_data(frame)
|
|
398
|
+
write_compatible_names(
|
|
399
|
+
destination,
|
|
400
|
+
{
|
|
401
|
+
str(variable["source_name"]): str(variable["compat_name"])
|
|
402
|
+
for variable in variables
|
|
403
|
+
if variable.get("compat_name")
|
|
404
|
+
and str(variable["compat_name"]).casefold() != str(variable["source_name"]).casefold()
|
|
405
|
+
},
|
|
406
|
+
encoding="UTF-8",
|
|
407
|
+
)
|
|
408
|
+
extended_labels = {
|
|
409
|
+
str(name): str(definition["label"])
|
|
410
|
+
for name, definition in _json_load(dataset.get("multiple_response_sets"), {}).items()
|
|
411
|
+
if isinstance(definition, dict)
|
|
412
|
+
and definition.get("use_category_labels")
|
|
413
|
+
and definition.get("use_first_var_label")
|
|
414
|
+
and definition.get("label")
|
|
415
|
+
}
|
|
416
|
+
write_extended_mrset_labels(
|
|
417
|
+
destination, extended_labels, encoding=source_encoding,
|
|
418
|
+
)
|
|
419
|
+
|
|
420
|
+
def _catalog_format(variable: dict[str, Any], key: str) -> tuple[int, int, int]:
|
|
421
|
+
encoded = variable.get(key)
|
|
422
|
+
if encoded:
|
|
423
|
+
return format_tuple(_json_load(encoded, encoded))
|
|
424
|
+
legacy = variable.get("format")
|
|
425
|
+
if legacy:
|
|
426
|
+
return format_tuple(legacy)
|
|
427
|
+
if variable["storage_kind"] == "string":
|
|
428
|
+
return 1, int(variable["string_width"] or 1), 0
|
|
429
|
+
return 5, 8, 2
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def _writer_metadata(dataset: dict[str, Any], variables: list[dict[str, Any]]) -> dict[str, Any]:
|
|
433
|
+
metadata: dict[str, Any] = {
|
|
434
|
+
"case_weight_var": dataset.get("case_weight_variable") or None,
|
|
435
|
+
"mrsets": _json_load(dataset.get("multiple_response_sets"), {}),
|
|
436
|
+
"var_types": {
|
|
437
|
+
item["source_name"]: int(item["string_width"] or 1)
|
|
438
|
+
for item in variables if item["storage_kind"] == "string"
|
|
439
|
+
},
|
|
440
|
+
"var_labels": {
|
|
441
|
+
item["source_name"]: item["label"] for item in variables if item.get("label")
|
|
442
|
+
},
|
|
443
|
+
"var_measure_levels": {
|
|
444
|
+
item["source_name"]: item["measure"] for item in variables if item.get("measure")
|
|
445
|
+
},
|
|
446
|
+
"var_roles": {
|
|
447
|
+
item["source_name"]: item["role"] for item in variables if item.get("role")
|
|
448
|
+
},
|
|
449
|
+
"var_alignments": {
|
|
450
|
+
item["source_name"]: item["alignment"] for item in variables if item.get("alignment")
|
|
451
|
+
},
|
|
452
|
+
"var_column_widths": {
|
|
453
|
+
item["source_name"]: int(item["display_width"])
|
|
454
|
+
for item in variables if item.get("display_width") is not None
|
|
455
|
+
},
|
|
456
|
+
"var_value_labels": {
|
|
457
|
+
item["source_name"]: _typed_value_labels(item)
|
|
458
|
+
for item in variables if _json_load(item.get("value_labels"), {})
|
|
459
|
+
},
|
|
460
|
+
"var_missing_values": {
|
|
461
|
+
item["source_name"]: missing
|
|
462
|
+
for item in variables
|
|
463
|
+
if (missing := _pyspssio_missing_rules(item.get("missing_ranges") or "[]"))
|
|
464
|
+
},
|
|
465
|
+
}
|
|
466
|
+
return {key: value for key, value in metadata.items() if value not in ({}, None)}
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
def _typed_value_labels(variable: dict[str, Any]) -> dict[Any, str]:
|
|
470
|
+
raw = _json_load(variable.get("value_labels"), {})
|
|
471
|
+
if variable["storage_kind"] == "numeric":
|
|
472
|
+
return {float(value): str(label) for value, label in raw.items()}
|
|
473
|
+
return {str(value): str(label) for value, label in raw.items()}
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
def _engine_loss_report(metadata: dict[str, Any]) -> dict[str, dict[str, Any]]:
|
|
477
|
+
events: dict[str, dict[str, Any]] = {}
|
|
478
|
+
if metadata.get("_documents") is None:
|
|
479
|
+
events["documents-unreadable"] = {
|
|
480
|
+
"code": "documents-unreadable",
|
|
481
|
+
"detail": "The strict raw SAV dictionary reader could not inspect document text.",
|
|
482
|
+
"details": {"engine_error": metadata.get("_documents_error", "unknown")},
|
|
483
|
+
}
|
|
484
|
+
variable_sets = metadata.get("_var_sets")
|
|
485
|
+
if variable_sets is None:
|
|
486
|
+
events["variable-sets-unobservable"] = {
|
|
487
|
+
"code": "variable-sets-unobservable",
|
|
488
|
+
"detail": "pyspssio could not inspect source variable sets; they cannot be preserved silently.",
|
|
489
|
+
"details": {"engine_error": metadata.get("_var_sets_error", "unknown")},
|
|
490
|
+
}
|
|
491
|
+
if _is_non_utf8_encoding(metadata.get("encoding")):
|
|
492
|
+
events["source-encoding-not-preserved"] = {
|
|
493
|
+
"code": "source-encoding-not-preserved",
|
|
494
|
+
"detail": "The pyspssio writer has no source-encoding preservation contract for this legacy code page.",
|
|
495
|
+
"details": {"source_encoding": metadata.get("encoding")},
|
|
496
|
+
}
|
|
497
|
+
return events
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
def _export_loss_report(
|
|
501
|
+
dataset: dict[str, Any], variables: list[dict[str, Any]], *, legacy_locale: str | None,
|
|
502
|
+
) -> tuple[dict[str, Any], ...]:
|
|
503
|
+
events: list[dict[str, Any]] = []
|
|
504
|
+
if _is_non_utf8_encoding(dataset.get("source_encoding")) and legacy_locale is None:
|
|
505
|
+
events.append({
|
|
506
|
+
"code": "source-encoding-not-preserved",
|
|
507
|
+
"detail": "Export requires an explicit legacy_locale for this non-UTF-8 source encoding.",
|
|
508
|
+
"details": {"source_encoding": dataset.get("source_encoding")},
|
|
509
|
+
})
|
|
510
|
+
return tuple(events)
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
def _require_matching_legacy_encoding(
|
|
514
|
+
source_encoding: str, emitted_encoding: str, legacy_output: bool,
|
|
515
|
+
) -> None:
|
|
516
|
+
if legacy_output and _normalize_encoding(source_encoding) != _normalize_encoding(emitted_encoding):
|
|
517
|
+
raise UnsupportedOperationError(
|
|
518
|
+
"Configured legacy locale emitted "
|
|
519
|
+
+ emitted_encoding
|
|
520
|
+
+ " instead of required source encoding "
|
|
521
|
+
+ source_encoding
|
|
522
|
+
+ "."
|
|
523
|
+
)
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
def _normalize_encoding(value: str) -> str:
|
|
527
|
+
return str(value).strip().replace("_", "-").replace("WINDOWS-", "CP").upper()
|
|
528
|
+
|
|
529
|
+
def _merge_loss_reports(*reports: tuple[dict[str, Any], ...]) -> tuple[dict[str, Any], ...]:
|
|
530
|
+
events: dict[str, dict[str, Any]] = {}
|
|
531
|
+
for report in reports:
|
|
532
|
+
for event in report:
|
|
533
|
+
events.setdefault(event["code"], event)
|
|
534
|
+
return tuple(events[code] for code in sorted(events))
|
|
535
|
+
|
|
536
|
+
|
|
537
|
+
def _json(value: Any) -> str:
|
|
538
|
+
return json.dumps(value, ensure_ascii=False, default=str, sort_keys=True)
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
def _json_ordered(value: Any) -> str:
|
|
542
|
+
"""Legacy JSON copy for migration only; retain source insertion order."""
|
|
543
|
+
return json.dumps(value, ensure_ascii=False, default=str)
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
def _json_load(value: Any, default: Any) -> Any:
|
|
547
|
+
if not value:
|
|
548
|
+
return default
|
|
549
|
+
try:
|
|
550
|
+
return json.loads(value)
|
|
551
|
+
except (TypeError, json.JSONDecodeError):
|
|
552
|
+
return default
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
def _is_non_utf8_encoding(encoding: Any) -> bool:
|
|
556
|
+
return encoding is not None and str(encoding).strip().replace("_", "-").upper() not in _UTF8_ENCODINGS
|
|
557
|
+
|
|
558
|
+
|
|
559
|
+
def _sha256(path: Path) -> str:
|
|
560
|
+
digest = hashlib.sha256()
|
|
561
|
+
with path.open("rb") as source:
|
|
562
|
+
for chunk in iter(lambda: source.read(1024 * 1024), b""):
|
|
563
|
+
digest.update(chunk)
|
|
564
|
+
return digest.hexdigest()
|
|
565
|
+
|
|
566
|
+
|
|
567
|
+
def _require_source(source_path: Path) -> None:
|
|
568
|
+
if source_path.suffix.lower() not in {".sav", ".zsav"}:
|
|
569
|
+
raise UnsupportedOperationError("Only unencrypted SAV and ZSAV sources are supported.")
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""SQL connection and strict wide-table/catalog operations."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from .capabilities import profile_declarations
|
|
6
|
+
from .wide import validate_wide_dataset
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def declared_profiles(database_url: str | None = None) -> dict[str, dict[str, object]]:
|
|
10
|
+
return profile_declarations(database_url)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def validate_dataset(*, database_url: Any, dataset_id: str, **options: Any) -> dict[str, Any]:
|
|
14
|
+
return validate_wide_dataset(database_url=str(database_url), dataset_id=dataset_id)
|