Skip to content

Commit cc49cf0

Browse files
committed
Fail closed on SPSS compatible names
1 parent 0072fcc commit cc49cf0

4 files changed

Lines changed: 112 additions & 9 deletions

File tree

src/openstatspec/spss/sav.py

Lines changed: 43 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,8 @@ def inspect_sav(source: str | Path) -> dict[str, Any]:
147147
_require_source(source_path)
148148
metadata, loss_report = _dictionary(source_path)
149149
names = list(metadata.get("var_names") or [])
150+
variables = _variables(metadata, names)
151+
loss_report = _merge_loss_reports(tuple(loss_report.values()), _compat_name_loss_report(variables))
150152
return {
151153
"source_format": source_path.suffix[1:].upper(),
152154
"source_name": source_path.name,
@@ -157,9 +159,9 @@ def inspect_sav(source: str | Path) -> dict[str, Any]:
157159
"file_attributes": dict(metadata.get("file_attributes") or {}),
158160
"case_weight_variable": metadata.get("case_weight_var") or None,
159161
"multiple_response_sets": dict(metadata.get("mrsets") or {}),
160-
"loss_report": tuple(loss_report.values()),
162+
"loss_report": loss_report,
161163
"variable_count": len(names),
162-
"variables": _variables(metadata, names),
164+
"variables": variables,
163165
}
164166

165167

@@ -175,6 +177,7 @@ def import_sav_dataset(*, source: str | Path, database_url: str, dataset_id: str
175177
# frame read's values where a future pyspssio version exposes more there.
176178
metadata = {**dictionary, **metadata}
177179
variables = _variables(metadata, list(frame.columns))
180+
loss_report = _merge_loss_reports(tuple(loss_report.values()), _compat_name_loss_report(variables))
178181
result = create_wide_dataset(
179182
database_url=database_url,
180183
dataset_id=dataset_id,
@@ -196,9 +199,9 @@ def import_sav_dataset(*, source: str | Path, database_url: str, dataset_id: str
196199
{"spss.variable_sets": metadata["_var_sets"]}
197200
if metadata.get("_var_sets") else {}
198201
),
199-
fidelity_events=tuple(loss_report.values()),
202+
fidelity_events=loss_report,
200203
)
201-
return {**result, "loss_report": tuple(loss_report.values())}
204+
return {**result, "loss_report": loss_report}
202205

203206

204207
def export_sav_dataset(
@@ -215,8 +218,8 @@ def export_sav_dataset(
215218
multiple_response_sets=dataset.get("multiple_response_sets"),
216219
)
217220
loss_report = _merge_loss_reports(
221+
_export_loss_report(dataset, variables),
218222
read_fidelity_events(database_url=database_url, dataset_id=dataset_id),
219-
_export_loss_report(dataset),
220223
)
221224
rejected = [event["code"] for event in loss_report if event["code"] not in allow_loss]
222225
if rejected:
@@ -365,14 +368,48 @@ def _engine_loss_report(metadata: dict[str, Any]) -> dict[str, dict[str, Any]]:
365368
return events
366369

367370

368-
def _export_loss_report(dataset: dict[str, Any]) -> tuple[dict[str, Any], ...]:
371+
def _compat_name_loss_report(variables: list[dict[str, Any]]) -> tuple[dict[str, Any], ...]:
372+
"""Report legacy SPSS compatible names the writer cannot set explicitly.
373+
374+
A compatible name is meaningful only when it differs from the long source
375+
variable name. ``pyspssio`` exposes these names while reading, but its
376+
public writer metadata has no ``var_compat_names`` input. It may derive a
377+
name today, but that is not a preservation contract: export must therefore
378+
require an explicit, auditable acceptance rather than silently rederive or
379+
rename the value.
380+
"""
381+
events: list[dict[str, Any]] = []
382+
for variable in variables:
383+
source_name = str(variable["source_name"])
384+
compat_name = variable.get("compat_name")
385+
if compat_name is None or str(compat_name).casefold() == source_name.casefold():
386+
continue
387+
events.append({
388+
"code": "compatible-variable-name-not-exportable",
389+
"detail": (
390+
"pyspssio exposes the source compatible variable name but its public "
391+
"writer API cannot set or guarantee preservation of that name."
392+
),
393+
"details": {
394+
"source_name": source_name,
395+
"compatible_name": str(compat_name),
396+
"physical_name": str(variable["physical_name"]),
397+
},
398+
})
399+
return tuple(events)
400+
401+
402+
def _export_loss_report(
403+
dataset: dict[str, Any], variables: list[dict[str, Any]],
404+
) -> tuple[dict[str, Any], ...]:
369405
events: list[dict[str, Any]] = []
370406
if _is_non_utf8_encoding(dataset.get("source_encoding")):
371407
events.append({
372408
"code": "source-encoding-not-preserved",
373409
"detail": "The pyspssio writer has no source-encoding preservation contract for this legacy code page.",
374410
"details": {"source_encoding": dataset.get("source_encoding")},
375411
})
412+
events.extend(_compat_name_loss_report(variables))
376413
return tuple(events)
377414

378415

tests/test_loss_reports.py

Lines changed: 64 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import json
12
import sqlite3
23

34
import pandas as pd
@@ -68,4 +69,66 @@ def test_non_utf8_source_encoding_is_explicit_export_loss(tmp_path) -> None:
6869
exported = openstatspec.export_sav(database_url=database, dataset_id="legacy-encoding", destination=destination, allow_loss=["source-encoding-not-preserved"])
6970
assert {diagnostic.code for diagnostic in exported.diagnostics} == {"source-encoding-not-preserved"}
7071
metadata = pyspssio.read_metadata(str(destination))
71-
assert metadata["encoding"] == "UTF-8"
72+
assert metadata["encoding"] == "UTF-8"
73+
74+
@pytest.mark.parametrize("suffix", [".sav", ".zsav"])
75+
def test_compatible_variable_name_requires_explicit_audited_export_loss(tmp_path, suffix: str) -> None:
76+
"""A long SPSS name exposes a legacy compatible name the writer cannot set."""
77+
source = tmp_path / f"compat-source{suffix}"
78+
database_path = tmp_path / f"compat-{suffix[1:]}.sqlite"
79+
database = f"sqlite:///{database_path}"
80+
blocked = tmp_path / f"compat-blocked{suffix}"
81+
approved = tmp_path / f"compat-approved{suffix}"
82+
source_name = "long_variable_name"
83+
84+
pyspssio.write_sav(str(source), pd.DataFrame({source_name: [1.0]}))
85+
assert pyspssio.read_metadata(str(source))["var_compat_names"][source_name] != source_name
86+
87+
imported = openstatspec.import_sav(source, database_url=database, dataset_id=f"compat-{suffix[1:]}")
88+
diagnostic = next(
89+
item for item in imported.diagnostics
90+
if item.code == "compatible-variable-name-not-exportable"
91+
)
92+
assert diagnostic.details == {
93+
"source_name": source_name,
94+
"compatible_name": "LONG_VAR",
95+
"physical_name": source_name,
96+
}
97+
98+
# Export must also assess the current normalized SQL catalog. Clear the
99+
# import event and alter the catalog value to prove that no persisted
100+
# diagnostic can mask an unguarded re-export or silent renaming path.
101+
connection = sqlite3.connect(database_path)
102+
connection.execute(
103+
"delete from fidelity_event_catalog where dataset_id = ? and code = ?",
104+
(f"compat-{suffix[1:]}", "compatible-variable-name-not-exportable"),
105+
)
106+
connection.execute(
107+
"update variable_catalog set compat_name = ? where dataset_id = ? and source_name = ?",
108+
("CUSTOM_NAME", f"compat-{suffix[1:]}", source_name),
109+
)
110+
connection.commit()
111+
expected_catalog_detail = {
112+
"source_name": source_name,
113+
"compatible_name": "CUSTOM_NAME",
114+
"physical_name": source_name,
115+
}
116+
117+
with pytest.raises(UnsupportedOperationError, match="compatible-variable-name-not-exportable"):
118+
openstatspec.export_sav(
119+
database_url=database, dataset_id=f"compat-{suffix[1:]}", destination=blocked,
120+
allow_loss=_REQUIRED_ENGINE_LOSS,
121+
)
122+
assert not blocked.exists()
123+
124+
result = openstatspec.export_sav(
125+
database_url=database, dataset_id=f"compat-{suffix[1:]}", destination=approved,
126+
allow_loss=[*_REQUIRED_ENGINE_LOSS, "compatible-variable-name-not-exportable"],
127+
)
128+
accepted = next(item for item in result.diagnostics if item.code == "compatible-variable-name-not-exportable")
129+
assert accepted.details == expected_catalog_detail
130+
persisted = connection.execute(
131+
"select details from fidelity_event_catalog where operation_id = ? and code = ?",
132+
(result["operation_id"], "compatible-variable-name-not-exportable"),
133+
).fetchone()[0]
134+
assert json.loads(persisted) == {**expected_catalog_detail, "accepted_by_user": True}

tests/test_sav_sqlite.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@
1616
"separate-write-format-unobservable",
1717
]
1818

19+
_COMPAT_NAME_LOSS = [*_REQUIRED_ENGINE_LOSS, "compatible-variable-name-not-exportable"]
20+
1921

2022
def test_pyspssio_round_trip_uses_one_wide_table_and_catalog(tmp_path) -> None:
2123
source = tmp_path / "tiny.sav"
@@ -85,7 +87,7 @@ def test_supported_pyspssio_metadata_round_trips_through_sqlite_for_sav_and_zsav
8587
assert openstatspec.validate(database_url=f"sqlite:///{database_path}", dataset_id=f"supported-{suffix[1:]}")["valid"] is True
8688
connection = sqlite3.connect(database_path)
8789
assert connection.execute(f"select comment from data_supported_{suffix[1:]} order by __case_ordinal").fetchone() == (expected["long_text"],)
88-
openstatspec.export_sav(database_url=f"sqlite:///{database_path}", dataset_id=f"supported-{suffix[1:]}", destination=destination, allow_loss=_REQUIRED_ENGINE_LOSS)
90+
openstatspec.export_sav(database_url=f"sqlite:///{database_path}", dataset_id=f"supported-{suffix[1:]}", destination=destination, allow_loss=_COMPAT_NAME_LOSS)
8991
assert compare_sav_semantics(source, destination) == {"equivalent": True, "differences": []}
9092

9193

tests/test_sql_services.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313

1414
pytestmark = pytest.mark.services
1515
_REQUIRED_ENGINE_LOSS = ["file-label-and-documents-unobservable", "separate-write-format-unobservable"]
16+
_COMPAT_NAME_LOSS = [*_REQUIRED_ENGINE_LOSS, "compatible-variable-name-not-exportable"]
1617

1718

1819
@pytest.fixture
@@ -72,5 +73,5 @@ def test_live_profile_preserves_supported_sav_semantics(environment_name, datase
7273
imported = openstatspec.import_sav(source, database_url=database_url, dataset_id=f"{dataset_id}_{suffix[1:]}")
7374
assert imported["case_count"] == 4
7475
assert openstatspec.validate(database_url=database_url, dataset_id=f"{dataset_id}_{suffix[1:]}")["valid"] is True
75-
openstatspec.export_sav(database_url=database_url, dataset_id=f"{dataset_id}_{suffix[1:]}", destination=destination, allow_loss=_REQUIRED_ENGINE_LOSS)
76+
openstatspec.export_sav(database_url=database_url, dataset_id=f"{dataset_id}_{suffix[1:]}", destination=destination, allow_loss=_COMPAT_NAME_LOSS)
7677
assert compare_sav_semantics(source, destination) == {"equivalent": True, "differences": []}

0 commit comments

Comments
 (0)