Skip to content

Commit 0916d5d

Browse files
committed
Support verified legacy SPSS locales
1 parent d4cc506 commit 0916d5d

9 files changed

Lines changed: 106 additions & 21 deletions

File tree

‎THIRD_PARTY_NOTICES.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ SPSS engine is a separate third-party distribution:
55

66
| Component | Version | Applicable terms |
77
| --- | --- | --- |
8-
| pyspssio | TonisOrmisson/pyspssio pinned commit 446af0c | MIT for the Python wrapper, except for its IBM I/O Module files |
8+
| pyspssio | TonisOrmisson/pyspssio pinned commit 6a0f9fa | MIT for the Python wrapper, except for its IBM I/O Module files |
99
| IBM I/O Modules for IBM SPSS Statistics Data Files | bundled by the pinned pyspssio fork | IBM International License Agreement for Non-Warranted Programs and the accompanying License Information / REDIST files |
1010

1111
pyspssio ships the IBM licence, redistribution list, and third-party notices

‎docs/sav-profile.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ of this boundary. It records the pinned source commit and installed engine versi
4242
| Print and write formats independently | Supported as raw IBM I/O tuples | The adapter stores both tuples separately and writes them without collapsing either value. |
4343
| Variable sets | Supported through raw IBM I/O | The adapter stores source sets in the extension catalog and writes them through the raw dictionary setter. Invalid target definitions fail before data are written. |
4444
| Legacy compatible variable names | Supported through the strict dictionary bridge | The adapter stores the short-name mapping and rewrites both the fixed variable record and long-name extension before export. |
45-
| Source encoding | UTF-8 fidelity only | UTF-8 is supported. A legacy code page is retained in metadata, but export requires `source-encoding-not-preserved` because the writer has no legacy-code-page preservation contract. |
45+
| Source encoding | UTF-8 by default; legacy code page with an explicit OS locale | The caller supplies legacy_locale and the writer verifies that the emitted encoding matches the stored source encoding. Without it, export fails before output creation. |
4646
| Custom attributes with one value | Supported | File and variable scalar attributes round trip through the normalized attribute catalog. |
4747
| Custom-attribute value arrays | Supported through raw IBM I/O | The adapter represents an array as IBM SPSS `Name[1]`, `Name[2]`, … members and reconstructs the ordered array in the normalized catalog. |
4848

‎pyproject.toml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ readme = "README.md"
1010
requires-python = ">=3.11"
1111
license = {text = "Apache-2.0"}
1212
authors = [{name = "OpenStatSpec"}]
13-
dependencies = ["SQLAlchemy>=2.0", "pandas>=2.2", "pyspssio @ git+https://github.com/TonisOrmisson/pyspssio.git@446af0c"]
13+
dependencies = ["SQLAlchemy>=2.0", "pandas>=2.2", "pyspssio @ git+https://github.com/TonisOrmisson/pyspssio.git@6a0f9fa"]
1414

1515
[project.optional-dependencies]
1616
dev = ["pytest>=8"]

‎src/openstatspec/api.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ def capability_matrix() -> Mapping[str, Any]:
3232
"documents": "supported",
3333
"source_encoding": {
3434
"utf8": "supported",
35-
"legacy_code_pages": "fail-closed-on-export",
35+
"legacy_code_pages": "requires-explicit-legacy-locale",
3636
},
3737
"measurement_level": "supported", "user_missing_rules": "supported",
3838
"multiple_response_sets": "supported",

‎src/openstatspec/cli.py‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,13 +25,14 @@ def main(argv: Sequence[str] | None = None) -> int:
2525
exporter.add_argument("--dataset-id", required=True)
2626
exporter.add_argument("--output", required=True)
2727
exporter.add_argument("--allow-loss", action="append", default=[])
28+
exporter.add_argument("--legacy-locale", help="OS locale for a non-UTF-8 source encoding")
2829
args = parser.parse_args(argv)
2930
if args.command == "capabilities":
3031
result = capability_matrix()
3132
elif args.command == "import":
3233
result = import_sav(args.source, database_url=args.database_url, dataset_id=args.dataset_id)
3334
elif args.command == "export":
34-
result = export_sav(database_url=args.database_url, dataset_id=args.dataset_id, destination=args.output, allow_loss=args.allow_loss)
35+
result = export_sav(database_url=args.database_url, dataset_id=args.dataset_id, destination=args.output, allow_loss=args.allow_loss, legacy_locale=args.legacy_locale)
3536
elif args.command == "inspect":
3637
result = inspect(args.source)
3738
else:

‎src/openstatspec/spss/__init__.py‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,4 +15,8 @@ def import_dataset(source: str | Path, *, database_url: Any, dataset_id: str, **
1515

1616

1717
def export_dataset(*, database_url: Any, dataset_id: str, destination: str | Path, **options: Any) -> dict[str, Any]:
18-
return export_sav_dataset(database_url=str(database_url), dataset_id=dataset_id, destination=destination, allow_loss=tuple(options.get("allow_loss", ())) )
18+
return export_sav_dataset(
19+
database_url=str(database_url), dataset_id=dataset_id, destination=destination,
20+
allow_loss=tuple(options.get("allow_loss", ())),
21+
legacy_locale=options.get("legacy_locale"),
22+
)

‎src/openstatspec/spss/sav.py‎

Lines changed: 47 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ def engine_identity() -> dict[str, str]:
5252
return {
5353
"package": "pyspssio",
5454
"distribution": "TonisOrmisson/pyspssio",
55-
"pinned_commit": "446af0c",
55+
"pinned_commit": "6a0f9fa",
5656
"installed_version": str(pyspssio.__version__),
5757
}
5858

@@ -255,7 +255,7 @@ def import_sav_dataset(*, source: str | Path, database_url: str, dataset_id: str
255255

256256
def export_sav_dataset(
257257
*, database_url: str, dataset_id: str, destination: str | Path,
258-
allow_loss: tuple[str, ...] = (),
258+
allow_loss: tuple[str, ...] = (), legacy_locale: str | None = None,
259259
) -> dict[str, Any]:
260260
destination_path = Path(destination)
261261
if destination_path.suffix.lower() not in {".sav", ".zsav"}:
@@ -266,9 +266,15 @@ def export_sav_dataset(
266266
case_weight_variable=dataset.get("case_weight_variable"),
267267
multiple_response_sets=dataset.get("multiple_response_sets"),
268268
)
269+
persisted_events = read_fidelity_events(database_url=database_url, dataset_id=dataset_id)
270+
if legacy_locale is not None:
271+
persisted_events = tuple(
272+
event for event in persisted_events
273+
if event["code"] != "source-encoding-not-preserved"
274+
)
269275
loss_report = _merge_loss_reports(
270-
_export_loss_report(dataset, variables),
271-
read_fidelity_events(database_url=database_url, dataset_id=dataset_id),
276+
_export_loss_report(dataset, variables, legacy_locale=legacy_locale),
277+
persisted_events,
272278
)
273279
rejected = [event["code"] for event in loss_report if event["code"] not in allow_loss]
274280
if rejected:
@@ -287,7 +293,9 @@ def export_sav_dataset(
287293
columns=[variable["source_name"] for variable in variables],
288294
)
289295
try:
290-
_write_with_dictionary_bridge(destination_path, frame, dataset, variables)
296+
_write_with_dictionary_bridge(
297+
destination_path, frame, dataset, variables, legacy_locale=legacy_locale,
298+
)
291299
except Exception:
292300
destination_path.unlink(missing_ok=True)
293301
raise
@@ -298,7 +306,7 @@ def export_sav_dataset(
298306
allowed_fidelity_events=tuple(
299307
event for event in loss_report if event["code"] in allow_loss
300308
),
301-
operation_details={"engine": engine_identity()},
309+
operation_details={"engine": engine_identity(), "legacy_locale": legacy_locale},
302310
)
303311
return {
304312
"dataset_id": dataset_id,
@@ -310,6 +318,7 @@ def export_sav_dataset(
310318

311319
def _write_with_dictionary_bridge(
312320
destination: Path, frame: pd.DataFrame, dataset: dict[str, Any], variables: list[dict[str, Any]],
321+
*, legacy_locale: str | None = None,
313322
) -> None:
314323
"""Write a dictionary through pyspssio and preserve its document record.
315324
@@ -320,17 +329,27 @@ def _write_with_dictionary_bridge(
320329
"""
321330
metadata = _writer_metadata(dataset, variables)
322331
documents = _json_load(dataset.get("documents"), [])
332+
source_encoding = str(dataset.get("source_encoding") or "UTF-8")
333+
legacy_output = _is_non_utf8_encoding(source_encoding) and legacy_locale is not None
323334
with ExitStack() as stack:
324335
document_source = None
325336
if documents:
326337
temporary_directory = Path(stack.enter_context(TemporaryDirectory()))
327338
temporary_source = temporary_directory / "documents.sav"
328339
pyspssio.write_sav(
329340
str(temporary_source), pd.DataFrame({"document_source": [0.0]}),
341+
unicode=not legacy_output, locale=legacy_locale,
330342
)
331-
write_document_lines(temporary_source, documents, encoding="UTF-8")
343+
temporary_encoding = str(pyspssio.read_metadata(str(temporary_source)).get("encoding") or "UTF-8")
344+
_require_matching_legacy_encoding(source_encoding, temporary_encoding, legacy_output)
345+
write_document_lines(temporary_source, documents, encoding=temporary_encoding)
332346
document_source = stack.enter_context(pyspssio.Reader(str(temporary_source), mode="r"))
333-
writer = stack.enter_context(pyspssio.Writer(str(destination), mode="w"))
347+
writer = stack.enter_context(
348+
pyspssio.Writer(
349+
str(destination), mode="w", unicode=not legacy_output, locale=legacy_locale,
350+
)
351+
)
352+
_require_matching_legacy_encoding(source_encoding, str(writer.file_encoding), legacy_output)
334353
writer.compression = 2 if destination.suffix.lower() == ".zsav" else 1
335354
writer.file_label = str(dataset.get("file_label") or "")
336355
for variable in variables:
@@ -461,17 +480,34 @@ def _engine_loss_report(metadata: dict[str, Any]) -> dict[str, dict[str, Any]]:
461480

462481

463482
def _export_loss_report(
464-
dataset: dict[str, Any], variables: list[dict[str, Any]],
483+
dataset: dict[str, Any], variables: list[dict[str, Any]], *, legacy_locale: str | None,
465484
) -> tuple[dict[str, Any], ...]:
466485
events: list[dict[str, Any]] = []
467-
if _is_non_utf8_encoding(dataset.get("source_encoding")):
486+
if _is_non_utf8_encoding(dataset.get("source_encoding")) and legacy_locale is None:
468487
events.append({
469488
"code": "source-encoding-not-preserved",
470-
"detail": "The pyspssio writer has no source-encoding preservation contract for this legacy code page.",
489+
"detail": "Export requires an explicit legacy_locale for this non-UTF-8 source encoding.",
471490
"details": {"source_encoding": dataset.get("source_encoding")},
472491
})
473492
return tuple(events)
474493

494+
495+
def _require_matching_legacy_encoding(
496+
source_encoding: str, emitted_encoding: str, legacy_output: bool,
497+
) -> None:
498+
if legacy_output and _normalize_encoding(source_encoding) != _normalize_encoding(emitted_encoding):
499+
raise UnsupportedOperationError(
500+
"Configured legacy locale emitted "
501+
+ emitted_encoding
502+
+ " instead of required source encoding "
503+
+ source_encoding
504+
+ "."
505+
)
506+
507+
508+
def _normalize_encoding(value: str) -> str:
509+
return str(value).strip().replace("_", "-").replace("WINDOWS-", "CP").upper()
510+
475511
def _merge_loss_reports(*reports: tuple[dict[str, Any], ...]) -> tuple[dict[str, Any], ...]:
476512
events: dict[str, dict[str, Any]] = {}
477513
for report in reports:

‎tests/test_cli.py‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ def test_cli_import_inspect_validate_and_export_emit_json(tmp_path, capsys) -> N
1717
assert openstatspec.cli.main(["inspect", str(source)]) == 0
1818
inspected = json.loads(capsys.readouterr().out)
1919
assert inspected["source_format"] == "SAV"
20-
assert inspected["engine"]["pinned_commit"] == "446af0c"
20+
assert inspected["engine"]["pinned_commit"] == "6a0f9fa"
2121
assert inspected["source_sha256"]
2222
assert inspected["loss_report"] == []
2323
assert imported["case_count"] == 1
@@ -37,7 +37,7 @@ def test_cli_import_inspect_validate_and_export_emit_json(tmp_path, capsys) -> N
3737

3838
def test_capability_matrix_is_public_and_cli_matches_engine_boundary(capsys) -> None:
3939
matrix = openstatspec.capability_matrix()
40-
assert matrix["engine"]["pinned_commit"] == "446af0c"
40+
assert matrix["engine"]["pinned_commit"] == "6a0f9fa"
4141

4242
assert matrix["spss"] == {
4343
"values": "supported",
@@ -49,7 +49,7 @@ def test_capability_matrix_is_public_and_cli_matches_engine_boundary(capsys) ->
4949
"documents": "supported",
5050
"source_encoding": {
5151
"utf8": "supported",
52-
"legacy_code_pages": "fail-closed-on-export",
52+
"legacy_code_pages": "requires-explicit-legacy-locale",
5353
},
5454
"measurement_level": "supported",
5555
"user_missing_rules": "supported",

‎tests/test_loss_reports.py‎

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from openstatspec.core import UnsupportedOperationError
1010
from openstatspec.sql.wide import create_wide_dataset
1111
from openstatspec.spss.raw_dictionary import write_compatible_names
12+
from openstatspec.spss import sav as sav_module
1213

1314

1415
_REQUIRED_ENGINE_LOSS = []
@@ -27,7 +28,7 @@ def test_persisted_import_fidelity_events_require_consent_after_reopen(tmp_path)
2728
connection = sqlite3.connect(database_path)
2829
assert {row[0] for row in connection.execute("select code from fidelity_event_catalog")} == set(_REQUIRED_ENGINE_LOSS)
2930
import_details = json.loads(connection.execute("select details from operation_catalog order by created_at limit 1").fetchone()[0])
30-
assert import_details["engine"]["pinned_commit"] == "446af0c"
31+
assert import_details["engine"]["pinned_commit"] == "6a0f9fa"
3132

3233
openstatspec.export_sav(database_url=database, dataset_id="persisted", destination=blocked)
3334
assert blocked.exists()
@@ -75,6 +76,49 @@ def test_non_utf8_source_encoding_is_explicit_export_loss(tmp_path) -> None:
7576
metadata = pyspssio.read_metadata(str(destination))
7677
assert metadata["encoding"] == "UTF-8"
7778

79+
80+
def test_explicit_legacy_locale_selects_the_single_engine_route(tmp_path, monkeypatch) -> None:
81+
database_path = tmp_path / "legacy-locale.sqlite"
82+
database = f"sqlite:///{database_path}"
83+
destination = tmp_path / "legacy-locale.sav"
84+
create_wide_dataset(
85+
database_url=database, dataset_id="legacy-locale", source_name="legacy.sav",
86+
source_format="SAV", source_encoding="WINDOWS-1252", rows=[{"name": "Muller"}],
87+
variables=[{
88+
"ordinal": 1, "source_name": "name", "physical_name": "name", "storage_kind": "string",
89+
"string_width": 8, "label": "", "format": "A8", "measure": "nominal", "role": None,
90+
"alignment": None, "display_width": 8, "attributes": "{}", "compat_name": None,
91+
"value_labels": "{}", "missing_ranges": "[]",
92+
}],
93+
fidelity_events=[{
94+
"code": "source-encoding-not-preserved",
95+
"detail": "Legacy encoding needs a locale.",
96+
"details": {"source_encoding": "WINDOWS-1252"},
97+
}],
98+
)
99+
observed = {}
100+
101+
def writer(destination_path, frame, dataset, variables, *, legacy_locale=None):
102+
observed["locale"] = legacy_locale
103+
observed["encoding"] = dataset["source_encoding"]
104+
observed["values"] = frame["name"].tolist()
105+
106+
monkeypatch.setattr(sav_module, "_write_with_dictionary_bridge", writer)
107+
result = openstatspec.export_sav(
108+
database_url=database, dataset_id="legacy-locale", destination=destination,
109+
legacy_locale="en_US.cp1252",
110+
)
111+
assert result.diagnostics == ()
112+
assert observed == {
113+
"locale": "en_US.cp1252", "encoding": "WINDOWS-1252", "values": ["Muller"],
114+
}
115+
116+
117+
def test_legacy_locale_must_emit_the_exact_source_encoding() -> None:
118+
sav_module._require_matching_legacy_encoding("WINDOWS-1252", "CP1252", True)
119+
with pytest.raises(UnsupportedOperationError, match="instead of required"):
120+
sav_module._require_matching_legacy_encoding("WINDOWS-1252", "UTF-8", True)
121+
78122
@pytest.mark.parametrize("suffix", [".sav", ".zsav"])
79123
def test_compatible_variable_name_round_trips_from_current_sql_catalog(tmp_path, suffix: str) -> None:
80124
source = tmp_path / f"compat-source{suffix}"

0 commit comments

Comments
 (0)