Skip to content

Commit 55a5bad

Browse files
committed
Report Python SAV encoding fidelity loss
1 parent 238c343 commit 55a5bad

3 files changed

Lines changed: 63 additions & 3 deletions

File tree

‎src/openstatspec/api.py‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,9 @@ def capability_matrix() -> Mapping[str, Any]:
1515
return {
1616
"spss": {
1717
"values": "supported", "variable_labels": "supported",
18-
"value_labels": "supported", "formats": "supported",
18+
"value_labels": "supported",
19+
"print_format": "supported", "write_format": "unobservable",
20+
"source_encoding": "preserved only when UTF-8",
1921
"measurement_level": "supported", "user_missing_rules": "supported",
2022
"documents": "supported", "multiple_response_sets": "lossy",
2123
"variable_alignment": "lossy", "variable_sets": "unobservable",

‎src/openstatspec/spss/sav.py‎

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@
1313
from ..sql.wide import create_wide_dataset, physical_name, read_fidelity_events, read_wide_dataset, record_export_operation
1414

1515

16+
_UTF8_ENCODINGS = {"UTF-8", "UTF8"}
17+
18+
1619
def _variables(meta: Any, names: list[str]) -> list[dict[str, Any]]:
1720
used = {"__case_ordinal"}
1821
labels = dict(getattr(meta, "column_names_to_labels", {}) or {})
@@ -167,21 +170,30 @@ def _sha256(path: Path) -> str:
167170
return digest.hexdigest()
168171

169172
def _import_loss_report(meta: Any) -> tuple[dict[str, str], ...]:
170-
events = [{"code": "unobservable-source-dictionary-features", "detail": "The reader does not expose SPSS variable sets or custom file/variable attributes, so their presence cannot be established or preserved."}]
173+
events = [{"code": "unobservable-source-dictionary-features", "detail": "The reader does not expose SPSS variable sets, custom file/variable attributes, variable roles, or separate write formats, so their presence cannot be established or preserved."}]
174+
if _is_non_utf8_encoding(getattr(meta, "file_encoding", None)):
175+
events.append({"code": "source-encoding-not-preserved", "detail": "The SAV writer has no output-encoding option and writes UTF-8, so a non-UTF-8 source encoding cannot be restored."})
171176
if getattr(meta, "mr_sets", {}) or {}:
172177
events.append({"code": "multiple-response-sets-not-exportable", "detail": "Multiple-response sets are catalogued but pyreadstat cannot write them back to SAV."})
173178
return tuple(events)
174179

175180

176181
def _export_loss_report(dataset: dict[str, Any], variables: list[dict[str, Any]]) -> tuple[dict[str, str], ...]:
177-
events = [{"code": "unobservable-source-dictionary-features", "detail": "Variable sets and custom file/variable attributes are not observable through the reader and therefore cannot be restored by export."}]
182+
events = [{"code": "unobservable-source-dictionary-features", "detail": "Variable sets, custom file/variable attributes, variable roles, and separate SPSS write formats are not observable through the reader and therefore cannot be restored by export."}]
183+
if _is_non_utf8_encoding(dataset.get("source_encoding")):
184+
events.append({"code": "source-encoding-not-preserved", "detail": "The SAV writer has no output-encoding option and writes UTF-8, so a non-UTF-8 source encoding cannot be restored."})
178185
if dataset["multiple_response_sets"] != "{}":
179186
events.append({"code": "multiple-response-sets-not-exported", "detail": "The SAV writer has no multiple-response-set output capability."})
180187
if any(item.get("alignment") not in (None, "unknown") for item in variables):
181188
events.append({"code": "variable-alignment-not-exported", "detail": "The SAV writer has no variable-alignment output capability."})
182189
return tuple(events)
183190

184191

192+
def _is_non_utf8_encoding(encoding: Any) -> bool:
193+
"""Whether a known source encoding differs from the writer's UTF-8 output."""
194+
return encoding is not None and str(encoding).strip().replace("_", "-").upper() not in _UTF8_ENCODINGS
195+
196+
185197
def _merge_loss_reports(*reports: tuple[dict[str, str], ...]) -> tuple[dict[str, str], ...]:
186198
"""Keep one deterministic diagnostic per loss code across import and export."""
187199
events: dict[str, dict[str, str]] = {}

‎tests/test_loss_reports.py‎

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,3 +108,49 @@ def test_loss_allowed_export_persists_accepted_diagnostics(tmp_path) -> None:
108108
).fetchone()
109109
assert (direction, severity, code) == ("export", "warning", "unobservable-source-dictionary-features")
110110
assert '"accepted_by_user": true' in details
111+
112+
113+
def test_non_utf8_source_encoding_is_explicit_export_loss(tmp_path) -> None:
114+
"""The writer has no output-encoding argument, so it must fail closed."""
115+
import pyreadstat
116+
import pytest
117+
118+
from openstatspec.api import capability_matrix
119+
from openstatspec.core import UnsupportedOperationError
120+
121+
database_path = tmp_path / "legacy-encoding.sqlite"
122+
database = f"sqlite:///{database_path}"
123+
destination = tmp_path / "legacy-encoding.sav"
124+
create_wide_dataset(
125+
database_url=database, dataset_id="legacy-encoding", source_name="legacy.sav",
126+
source_format="SAV", source_encoding="WINDOWS-1252",
127+
rows=[{"name": "Muller"}], variables=[{
128+
"ordinal": 1, "source_name": "name", "physical_name": "name",
129+
"storage_kind": "string", "string_width": 8, "label": "",
130+
"format": "A8", "measure": "nominal", "alignment": None,
131+
"display_width": 8, "value_labels": "{}", "missing_ranges": "[]",
132+
}],
133+
)
134+
135+
with pytest.raises(UnsupportedOperationError, match="source-encoding-not-preserved"):
136+
export_sav(
137+
database_url=database, dataset_id="legacy-encoding", destination=destination,
138+
allow_loss=["unobservable-source-dictionary-features"],
139+
)
140+
assert not destination.exists()
141+
142+
exported = export_sav(
143+
database_url=database, dataset_id="legacy-encoding", destination=destination,
144+
allow_loss=[
145+
"unobservable-source-dictionary-features",
146+
"source-encoding-not-preserved",
147+
],
148+
)
149+
assert {event["code"] for event in exported["loss_report"]} == {
150+
"source-encoding-not-preserved",
151+
"unobservable-source-dictionary-features",
152+
}
153+
_, metadata = pyreadstat.read_sav(destination, metadataonly=True)
154+
assert metadata.file_encoding == "UTF-8"
155+
assert capability_matrix()["spss"]["print_format"] == "supported"
156+
assert capability_matrix()["spss"]["write_format"] == "unobservable"

0 commit comments

Comments
 (0)