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,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)