Skip to content

Commit 26768c0

Browse files
committed
Use pyspssio as the sole SPSS adapter
1 parent 4e1dd3e commit 26768c0

9 files changed

Lines changed: 551 additions & 637 deletions

File tree

‎src/openstatspec/api.py‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,9 @@ def capability_matrix() -> Mapping[str, Any]:
1919
"print_format": "supported", "write_format": "unobservable",
2020
"source_encoding": "preserved only when UTF-8",
2121
"measurement_level": "supported", "user_missing_rules": "supported",
22-
"documents": "supported", "multiple_response_sets": "lossy",
23-
"variable_alignment": "lossy", "variable_sets": "unobservable",
24-
"custom_attributes": "unobservable", "variable_role": "unobservable",
22+
"documents": "unobservable", "multiple_response_sets": "supported",
23+
"variable_alignment": "supported", "variable_sets": "fail-closed",
24+
"custom_attributes": "supported", "variable_role": "supported",
2525
},
2626
"sql_profiles": declared_profiles(),
2727
}

‎src/openstatspec/core/results.py‎

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,13 @@
99
class Diagnostic:
1010
code: str
1111
detail: str
12+
details: Mapping[str, Any] = field(default_factory=dict)
1213

13-
def as_dict(self) -> dict[str, str]:
14-
return {"code": self.code, "detail": self.detail}
14+
def as_dict(self) -> dict[str, Any]:
15+
result: dict[str, Any] = {"code": self.code, "detail": self.detail}
16+
if self.details:
17+
result["details"] = dict(self.details)
18+
return result
1519

1620

1721
@dataclass(frozen=True)
@@ -36,5 +40,8 @@ def __len__(self) -> int:
3640

3741
def result(values: Mapping[str, Any]) -> OperationResult:
3842
payload = dict(values)
39-
diagnostics = tuple(Diagnostic(**item) for item in payload.pop("loss_report", ()))
43+
diagnostics = tuple(
44+
Diagnostic(code=item["code"], detail=item["detail"], details=item.get("details", {}))
45+
for item in payload.pop("loss_report", ())
46+
)
4047
return OperationResult(payload, diagnostics)

‎src/openstatspec/spss/sav.py‎

Lines changed: 303 additions & 132 deletions
Large diffs are not rendered by default.

‎src/openstatspec/sql/wide.py‎

Lines changed: 43 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
from collections.abc import Iterable, Mapping
1010
from typing import Any
1111

12-
from sqlalchemy import delete, BigInteger, Boolean, Column, Float, Integer, MetaData, String, Table, Text, create_engine, insert, select, update
12+
from sqlalchemy import delete, BigInteger, Boolean, Column, Float, Integer, MetaData, String, Table, Text, create_engine, insert, inspect, select, text, update
1313
from sqlalchemy.dialects import mysql, postgresql, sqlite
1414
from .profiles import preflight, validate_connection_url
1515

@@ -48,6 +48,8 @@ def catalog(metadata: MetaData) -> tuple[Table, Table, Table, Table]:
4848
Column("case_count", BigInteger, nullable=False),
4949
Column("file_label", Text, nullable=False, default=""),
5050
Column("documents", Text, nullable=False, default="[]"),
51+
Column("file_attributes", Text, nullable=False, default="{}"),
52+
Column("case_weight_variable", String(255)),
5153
Column("multiple_response_sets", Text, nullable=False, default="{}"),
5254
)
5355
variables = Table(
@@ -62,8 +64,11 @@ def catalog(metadata: MetaData) -> tuple[Table, Table, Table, Table]:
6264
Column("label", Text, nullable=False, default=""),
6365
Column("format", String(64)),
6466
Column("measure", String(32)),
67+
Column("role", String(32)),
6568
Column("alignment", String(32)),
6669
Column("display_width", Integer),
70+
Column("attributes", Text, nullable=False, default="{}"),
71+
Column("compat_name", String(255)),
6772
Column("value_labels", Text, nullable=False, default="{}"),
6873
Column("missing_ranges", Text, nullable=False, default="[]"),
6974
)
@@ -96,6 +101,38 @@ def catalog(metadata: MetaData) -> tuple[Table, Table, Table, Table]:
96101
def _now() -> str:
97102
return datetime.now(UTC).isoformat()
98103

104+
def _migrate_catalog_columns(connection: Any, datasets: Table, variables: Table) -> None:
105+
"""Add pyspssio metadata columns to catalogs created by earlier adapters.
106+
107+
This additive migration is intentionally small and portable: no existing
108+
source data or dictionary row is rewritten, while a later import can store
109+
the newly observable metadata alongside it.
110+
"""
111+
inspector = inspect(connection)
112+
additions = {
113+
datasets.name: {
114+
"file_attributes": "TEXT NOT NULL DEFAULT '{}'",
115+
"case_weight_variable": "VARCHAR(255)",
116+
},
117+
variables.name: {
118+
"role": "VARCHAR(32)",
119+
"attributes": "TEXT NOT NULL DEFAULT '{}'",
120+
"compat_name": "VARCHAR(255)",
121+
},
122+
}
123+
preparer = connection.dialect.identifier_preparer
124+
for table_name, columns in additions.items():
125+
if not inspector.has_table(table_name):
126+
continue
127+
existing = {column["name"] for column in inspector.get_columns(table_name)}
128+
for name, declaration in columns.items():
129+
if name not in existing:
130+
connection.execute(text(
131+
f"ALTER TABLE {preparer.quote(table_name)} ADD COLUMN "
132+
f"{preparer.quote(name)} {declaration}"
133+
))
134+
135+
99136

100137
def _event_rows(
101138
*, operation_id: str, dataset_id: str | None, direction: str,
@@ -304,6 +341,7 @@ def create_wide_dataset(
304341
*, database_url: str, dataset_id: str, source_name: str, source_format: str,
305342
rows: Iterable[Mapping[str, Any]], variables: list[dict[str, Any]], file_label: str = "",
306343
source_encoding: str | None = None, documents: str = "[]",
344+
file_attributes: str = "{}", case_weight_variable: str | None = None,
307345
source_table_name: str | None = None,
308346
source_sha256: str = "",
309347
source_created_at: str | None = None, source_modified_at: str | None = None,
@@ -337,6 +375,7 @@ def create_wide_dataset(
337375
)
338376
with engine.begin() as connection:
339377
metadata.create_all(connection, tables=[datasets, variable_catalog, multiple_response_catalog, documents_catalog, value_labels_catalog, missing_rules_catalog, fidelity_event_catalog, operation_catalog])
378+
_migrate_catalog_columns(connection, datasets, variable_catalog)
340379
connection.execute(insert(operation_catalog).values(
341380
operation_id=operation_id, direction="import", status="running", dataset_id=dataset_id,
342381
source=source_name, created_at=_now(), details=json.dumps({"variable_count": len(variables)}, sort_keys=True),
@@ -355,6 +394,7 @@ def create_wide_dataset(
355394
source_created_at=source_created_at, source_modified_at=source_modified_at,
356395
imported_at=imported_at,
357396
file_label=file_label, documents=documents,
397+
file_attributes=file_attributes, case_weight_variable=case_weight_variable,
358398
multiple_response_sets=multiple_response_sets,
359399
))
360400
connection.execute(insert(variable_catalog), [dict(dataset_id=dataset_id, **item) for item in variables])
@@ -411,7 +451,8 @@ def read_wide_dataset(*, database_url: str, dataset_id: str) -> tuple[dict[str,
411451
datasets, variable_catalog, _, _ = catalog(metadata)
412452
documents_catalog, value_labels_catalog, missing_rules_catalog = normalized_metadata_tables(metadata)
413453
with engine.begin() as connection:
414-
metadata.create_all(connection, tables=[documents_catalog, value_labels_catalog, missing_rules_catalog])
454+
metadata.create_all(connection, tables=[datasets, variable_catalog, documents_catalog, value_labels_catalog, missing_rules_catalog])
455+
_migrate_catalog_columns(connection, datasets, variable_catalog)
415456
dataset = dict(connection.execute(select(datasets).where(datasets.c.dataset_id == dataset_id)).mappings().one())
416457
data_table = Table(dataset["data_table"], MetaData(), autoload_with=connection)
417458
variables = [dict(item) for item in connection.execute(

‎tests/conformance.py‎

Lines changed: 51 additions & 100 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,16 @@
1-
"""Reusable fixtures and semantic comparators for supported SAV/ZSAV round trips.
2-
3-
These helpers deliberately compare only metadata that the Python adapter and
4-
its current SAV writer expose. Attributes, variable sets, and
5-
multiple-response sets are not asserted here because they are explicitly
6-
reported as unsupported fidelity losses.
7-
"""
1+
"""Reusable pyspssio fixtures and semantic comparators for SAV/ZSAV round trips."""
82

93
import math
10-
import sys
114
from pathlib import Path
125
from typing import Any
136

147
import pandas as pd
15-
import pyreadstat
8+
import pyspssio
169
from pandas.testing import assert_frame_equal
1710

1811

1912
def write_supported_semantics_fixture(destination: str | Path) -> dict[str, Any]:
20-
"""Write a fixture spanning every currently supported dictionary semantic.
21-
22-
-sys.float_info.max and sys.float_info.max are how ReadStat represents
23-
SPSS LOWEST and HIGHEST missing-range endpoints on write. ReadStat exposes
24-
them as NaN and +inf when the SAV/ZSAV file is read back; the comparator
25-
canonicalizes those markers.
26-
"""
13+
"""Write the metadata pyspssio demonstrably exposes and restores."""
2714
destination = Path(destination)
2815
long_text = "Õ🙂漢字" * 90
2916
frame = pd.DataFrame({
@@ -33,92 +20,63 @@ def write_supported_semantics_fixture(destination: str | Path) -> dict[str, Any]
3320
"status": ["NA", "DK", "ok", ""],
3421
"comment": [long_text, "", "näide", long_text],
3522
"interview_date": [23123.0, 23124.0, 23125.0, 23126.0],
23+
"resp_a": [1.0, 0.0, 1.0, 0.0],
24+
"resp_b": [0.0, 1.0, 1.0, 0.0],
3625
})
37-
pyreadstat.write_sav(
38-
frame,
39-
destination,
40-
compress=destination.suffix.lower() == ".zsav",
41-
file_label="OpenStatSpec supported-semantics fixture",
42-
column_labels={
43-
"lowest_range": "LOWEST-style missing range",
44-
"highest_range": "HIGHEST-style missing range",
45-
"code": "Ordered numeric code",
46-
"status": "String missing code",
47-
"comment": "Long UTF-8 comment",
48-
"interview_date": "SPSS numeric date",
26+
metadata = {
27+
"var_types": {"status": 8, "comment": 1024},
28+
"var_formats": {
29+
"lowest_range": "F12.1", "highest_range": "F12.1", "code": "F8.0",
30+
"status": "A8", "comment": "A1024", "interview_date": "DATE11",
31+
},
32+
"var_labels": {
33+
"lowest_range": "LOWEST-style missing range", "highest_range": "HIGHEST-style missing range",
34+
"code": "Ordered numeric code", "status": "String missing code",
35+
"comment": "Long UTF-8 comment", "interview_date": "SPSS numeric date",
4936
},
50-
variable_value_labels={
51-
# Deliberately non-sorted insertion order is part of the fixture.
37+
"var_value_labels": {
5238
"code": {3.0: "third", 1.0: "first", 2.0: "second"},
5339
"status": {"DK": "don't know", "NA": "not answered", "ok": "valid"},
5440
},
55-
missing_ranges={
56-
"lowest_range": [{"lo": -sys.float_info.max, "hi": -1.0}, 7.0],
57-
"highest_range": [{"lo": 1.0, "hi": sys.float_info.max}, -99.0],
58-
"status": ["NA", "DK"],
59-
},
60-
variable_measure={
61-
"lowest_range": "scale",
62-
"highest_range": "scale",
63-
"code": "ordinal",
64-
"status": "nominal",
65-
"comment": "nominal",
66-
"interview_date": "scale",
67-
},
68-
variable_display_width={
69-
"lowest_range": 12,
70-
"highest_range": 12,
71-
"code": 8,
72-
"status": 12,
73-
"comment": 48,
74-
"interview_date": 11,
41+
"var_missing_values": {
42+
"lowest_range": {"lo": -sys_float_max(), "hi": -1.0, "values": [7.0]},
43+
"highest_range": {"lo": 1.0, "hi": sys_float_max(), "values": [-99.0]},
44+
"status": {"values": ["NA", "DK"]},
7545
},
76-
variable_format={
77-
"lowest_range": "F12.1",
78-
"highest_range": "F12.1",
79-
"code": "F8.0",
80-
"status": "A8",
81-
"comment": "A1024",
82-
"interview_date": "DATE11",
46+
"var_measure_levels": {
47+
"lowest_range": "scale", "highest_range": "scale", "code": "ordinal",
48+
"status": "nominal", "comment": "nominal", "interview_date": "scale",
8349
},
84-
note=["First ordered document", "Teine dokument: Õ🙂"],
85-
)
50+
"var_alignments": {column: "left" for column in frame.columns},
51+
"var_column_widths": {"lowest_range": 12, "highest_range": 12, "code": 8, "status": 12, "comment": 48, "interview_date": 11},
52+
"var_roles": {"code": "target", "status": "input"},
53+
"var_attributes": {"code": {"Origin": "fixture"}},
54+
"mrsets": {"$responses": {"label": "Responses", "counted_value": 1, "variable_list": ["resp_a", "resp_b"]}},
55+
"file_attributes": {"Fixture": "OpenStatSpec"},
56+
"case_weight_var": "code",
57+
}
58+
pyspssio.write_sav(str(destination), frame, metadata=metadata)
8659
return {"long_text": long_text}
8760

8861

89-
def _canonical_missing_endpoint(value: Any, *, endpoint: str) -> Any:
90-
"""Normalize ReadStat's public sentinels for SPSS LOWEST/HIGHEST."""
91-
if isinstance(value, float):
92-
if math.isnan(value):
93-
return "<LOWEST>" if endpoint == "lo" else "<SYSTEM-NAN>"
94-
if math.isinf(value):
95-
return "<HIGHEST>" if value > 0 else "<LOWEST>"
96-
return value
62+
def sys_float_max() -> float:
63+
return float.fromhex("0x1.fffffffffffffp+1023")
9764

9865

99-
def _canonical_missing_ranges(metadata: Any) -> dict[str, tuple[tuple[Any, Any], ...]]:
100-
result: dict[str, tuple[tuple[Any, Any], ...]] = {}
101-
for variable, rules in (getattr(metadata, "missing_ranges", {}) or {}).items():
102-
result[variable] = tuple(
103-
(
104-
_canonical_missing_endpoint(rule.get("lo"), endpoint="lo"),
105-
_canonical_missing_endpoint(rule.get("hi"), endpoint="hi"),
106-
)
107-
for rule in rules
108-
)
66+
def _canonical_missing(metadata: dict[str, Any]) -> dict[str, tuple[tuple[Any, Any, tuple[Any, ...]], ...]]:
67+
result: dict[str, tuple[tuple[Any, Any, tuple[Any, ...]], ...]] = {}
68+
for name, rule in (metadata.get("var_missing_values") or {}).items():
69+
rule = rule or {}
70+
if "lo" in rule or "hi" in rule:
71+
result[name] = ((rule.get("lo"), rule.get("hi"), tuple(rule.get("values") or ())),)
72+
else:
73+
result[name] = ((None, None, tuple(rule.get("values") or ())),)
10974
return result
11075

11176

112-
def _ordered_value_labels(metadata: Any) -> dict[str, tuple[tuple[Any, str], ...]]:
113-
return {
114-
variable: tuple((value, str(label)) for value, label in labels.items())
115-
for variable, labels in (getattr(metadata, "variable_value_labels", {}) or {}).items()
116-
}
117-
118-
11977
def compare_sav_semantics(source: str | Path, exported: str | Path) -> dict[str, Any]:
120-
source_frame, source_meta = pyreadstat.read_sav(source, user_missing=True, disable_datetime_conversion=True)
121-
exported_frame, exported_meta = pyreadstat.read_sav(exported, user_missing=True, disable_datetime_conversion=True)
78+
source_frame, source_metadata = pyspssio.read_sav(str(source), convert_datetimes=False, include_user_missing=True)
79+
exported_frame, exported_metadata = pyspssio.read_sav(str(exported), convert_datetimes=False, include_user_missing=True)
12280
failures: list[str] = []
12381
if list(source_frame.columns) != list(exported_frame.columns):
12482
failures.append("variable-order")
@@ -127,19 +85,12 @@ def compare_sav_semantics(source: str | Path, exported: str | Path) -> dict[str,
12785
except AssertionError:
12886
failures.append("values-or-case-order")
12987
for attribute in (
130-
"column_labels",
131-
"original_variable_types",
132-
"variable_measure",
133-
"variable_display_width",
134-
"variable_storage_width",
135-
"notes",
136-
"file_label",
137-
"file_encoding",
88+
"encoding", "case_weight_var", "file_attributes", "mrsets", "var_types", "var_formats",
89+
"var_labels", "var_alignments", "var_column_widths", "var_measure_levels", "var_roles",
90+
"var_value_labels", "var_attributes",
13891
):
139-
if getattr(source_meta, attribute, None) != getattr(exported_meta, attribute, None):
92+
if source_metadata.get(attribute) != exported_metadata.get(attribute):
14093
failures.append(attribute)
141-
if _canonical_missing_ranges(source_meta) != _canonical_missing_ranges(exported_meta):
142-
failures.append("missing_ranges")
143-
if _ordered_value_labels(source_meta) != _ordered_value_labels(exported_meta):
144-
failures.append("variable_value_labels")
145-
return {"equivalent": not failures, "differences": failures}
94+
if _canonical_missing(source_metadata) != _canonical_missing(exported_metadata):
95+
failures.append("var_missing_values")
96+
return {"equivalent": not failures, "differences": failures}

‎tests/test_cli.py‎

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,33 +2,35 @@
22

33
import openstatspec.cli
44
import pandas as pd
5-
import pyreadstat
5+
import pyspssio
66

77

88
def test_cli_import_inspect_validate_and_export_emit_json(tmp_path, capsys) -> None:
99
source = tmp_path / "fixture.sav"
1010
database = f"sqlite:///{tmp_path / 'dataset.sqlite'}"
1111
output = tmp_path / "output.zsav"
12-
pyreadstat.write_sav(pd.DataFrame({"answer": [1.0]}), source)
12+
pyspssio.write_sav(str(source), pd.DataFrame({"answer": [1.0]}))
1313

1414
assert openstatspec.cli.main(["import", str(source), "--database-url", database, "--dataset-id", "fixture"]) == 0
1515
imported = json.loads(capsys.readouterr().out)
16-
preview = openstatspec.cli.main(["inspect", str(source)])
17-
assert preview == 0
16+
assert openstatspec.cli.main(["inspect", str(source)]) == 0
1817
inspected = json.loads(capsys.readouterr().out)
1918
assert inspected["source_format"] == "SAV"
2019
assert inspected["source_sha256"]
21-
assert inspected["loss_report"][0]["code"] == "unobservable-source-dictionary-features"
20+
assert {event["code"] for event in inspected["loss_report"]} == {
21+
"file-label-and-documents-unobservable", "separate-write-format-unobservable",
22+
}
2223
assert imported["case_count"] == 1
24+
2325
from openstatspec.core.results import OperationResult
2426
assert isinstance(openstatspec.import_sav(source, database_url=f"sqlite:///{tmp_path / 'typed.sqlite'}", dataset_id="typed"), OperationResult)
2527

2628
assert openstatspec.cli.main(["validate", "--database-url", database, "--dataset-id", "fixture"]) == 0
2729
assert json.loads(capsys.readouterr().out)["valid"] is True
2830

29-
assert openstatspec.cli.main(["export", "--database-url", database, "--dataset-id", "fixture", "--output", str(output), "--allow-loss", "unobservable-source-dictionary-features"]) == 0
31+
assert openstatspec.cli.main([
32+
"export", "--database-url", database, "--dataset-id", "fixture", "--output", str(output),
33+
"--allow-loss", "file-label-and-documents-unobservable", "--allow-loss", "separate-write-format-unobservable",
34+
]) == 0
3035
assert json.loads(capsys.readouterr().out)["destination"] == str(output)
31-
assert pyreadstat.read_sav(output)[0]["answer"].tolist() == [1.0]
32-
33-
assert openstatspec.cli.main(["inspect", str(source)]) == 0
34-
assert json.loads(capsys.readouterr().out)["variable_count"] == 1
36+
assert pyspssio.read_sav(str(output), convert_datetimes=False)[0]["answer"].tolist() == [1.0]

0 commit comments

Comments
 (0)