Skip to content

Commit f871ed4

Browse files
committed
Preserve SAV missing values and file metadata
1 parent 27c0431 commit f871ed4

3 files changed

Lines changed: 23 additions & 3 deletions

File tree

‎src/openstatspec/spss/sav.py‎

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ def _variables(meta: Any, names: list[str]) -> list[dict[str, Any]]:
1616
labels = dict(getattr(meta, "column_names_to_labels", {}) or {})
1717
formats = dict(getattr(meta, "original_variable_types", {}) or {})
1818
measures = dict(getattr(meta, "variable_measure", {}) or {})
19+
alignments = dict(getattr(meta, "variable_alignment", {}) or {})
1920
displays = dict(getattr(meta, "variable_display_width", {}) or {})
2021
value_labels = dict(getattr(meta, "variable_value_labels", {}) or {})
2122
missing_ranges = dict(getattr(meta, "missing_ranges", {}) or {})
@@ -28,7 +29,8 @@ def _variables(meta: Any, names: list[str]) -> list[dict[str, Any]]:
2829
"physical_name": physical_name(source_name, used), "storage_kind": kind,
2930
"string_width": widths.get(source_name) if kind == "string" else None,
3031
"label": labels.get(source_name, "") or "", "format": formats.get(source_name),
31-
"measure": measures.get(source_name), "display_width": displays.get(source_name),
32+
"measure": measures.get(source_name), "alignment": alignments.get(source_name),
33+
"display_width": displays.get(source_name),
3234
"value_labels": json.dumps(value_labels.get(source_name, {}), sort_keys=True),
3335
"missing_ranges": json.dumps(missing_ranges.get(source_name, []), default=str),
3436
})
@@ -69,6 +71,8 @@ def import_sav_dataset(*, source: str | Path, database_url: str, dataset_id: str
6971
database_url=database_url, dataset_id=dataset_id, source_name=source_path.name,
7072
source_format=source_path.suffix[1:].upper(), rows=_rows(frame, variables),
7173
variables=variables, file_label=getattr(meta, "file_label", "") or "",
74+
source_encoding=getattr(meta, "file_encoding", None),
75+
documents=json.dumps(list(getattr(meta, "notes", []) or [])),
7276
)
7377
return {**result, "loss_report": LossReport().events}
7478

@@ -87,6 +91,10 @@ def export_sav_dataset(*, database_url: str, dataset_id: str, destination: str |
8791
formats = {item["source_name"]: item["format"] for item in variables if item["format"]}
8892
measures = {item["source_name"]: item["measure"] for item in variables if item["measure"]}
8993
displays = {item["source_name"]: item["display_width"] for item in variables if item["display_width"]}
94+
missing_ranges = {
95+
item["source_name"]: json.loads(item["missing_ranges"])
96+
for item in variables if item["missing_ranges"] != "[]"
97+
}
9098
value_labels = {
9199
item["source_name"]: {(float(key) if item["storage_kind"] == "numeric" else key): value for key, value in json.loads(item["value_labels"]).items()}
92100
for item in variables if item["value_labels"] != "{}"
@@ -95,10 +103,11 @@ def export_sav_dataset(*, database_url: str, dataset_id: str, destination: str |
95103
frame, destination_path, file_label=dataset["file_label"], column_labels=labels,
96104
variable_format=formats, variable_measure=measures,
97105
variable_display_width=displays, variable_value_labels=value_labels,
106+
missing_ranges=missing_ranges, note=json.loads(dataset["documents"]),
98107
)
99108
return {
100109
"dataset_id": dataset_id, "destination": str(destination_path),
101-
"loss_report": ({"code": "missing-ranges-not-exported", "detail": "The initial writer does not preserve user-missing ranges."},),
110+
"loss_report": LossReport().events,
102111
}
103112

104113

‎src/openstatspec/sql/wide.py‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,10 @@ def catalog(metadata: MetaData) -> tuple[Table, Table]:
1616
Column("data_table", String(255), nullable=False, unique=True),
1717
Column("source_format", String(16), nullable=False),
1818
Column("source_name", Text, nullable=False),
19+
Column("source_encoding", String(128)),
1920
Column("case_count", BigInteger, nullable=False),
2021
Column("file_label", Text, nullable=False, default=""),
22+
Column("documents", Text, nullable=False, default="[]"),
2123
)
2224
variables = Table(
2325
"variable_catalog", metadata,
@@ -30,6 +32,7 @@ def catalog(metadata: MetaData) -> tuple[Table, Table]:
3032
Column("label", Text, nullable=False, default=""),
3133
Column("format", String(64)),
3234
Column("measure", String(32)),
35+
Column("alignment", String(32)),
3336
Column("display_width", Integer),
3437
Column("value_labels", Text, nullable=False, default="{}"),
3538
Column("missing_ranges", Text, nullable=False, default="[]"),
@@ -56,6 +59,7 @@ def data_table_name(dataset_id: str) -> str:
5659
def create_wide_dataset(
5760
*, database_url: str, dataset_id: str, source_name: str, source_format: str,
5861
rows: Iterable[Mapping[str, Any]], variables: list[dict[str, Any]], file_label: str = "",
62+
source_encoding: str | None = None, documents: str = "[]",
5963
) -> dict[str, Any]:
6064
engine = create_engine(database_url)
6165
metadata = MetaData()
@@ -74,7 +78,8 @@ def create_wide_dataset(
7478
materialized = [{"__case_ordinal": ordinal, **row} for ordinal, row in enumerate(rows, start=1)]
7579
connection.execute(insert(datasets).values(
7680
dataset_id=dataset_id, data_table=data_table.name, source_format=source_format,
77-
source_name=source_name, case_count=len(materialized), file_label=file_label,
81+
source_name=source_name, source_encoding=source_encoding, case_count=len(materialized),
82+
file_label=file_label, documents=documents,
7883
))
7984
connection.execute(insert(variable_catalog), [dict(dataset_id=dataset_id, **item) for item in variables])
8085
if materialized:

‎tests/test_sav_sqlite.py‎

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

34
import openstatspec
@@ -18,6 +19,8 @@ def test_sav_round_trip_uses_one_wide_table_and_catalog(tmp_path) -> None:
1819
variable_value_labels={"age": {34.0: "thirty-four"}},
1920
variable_measure={"age": "scale", "name": "nominal"},
2021
variable_format={"age": "F8.0", "name": "A12"},
22+
missing_ranges={"age": [34.0]},
23+
note=["Fixture note"],
2124
)
2225

2326
imported = openstatspec.import_sav(source, database_url=database, dataset_id="tiny")
@@ -31,11 +34,14 @@ def test_sav_round_trip_uses_one_wide_table_and_catalog(tmp_path) -> None:
3134
assert connection.execute("select __case_ordinal, age, name from data_tiny order by __case_ordinal").fetchall() == [
3235
(1, 34.0, "Ada"), (2, None, "")
3336
]
37+
assert connection.execute("select source_encoding, documents from dataset_catalog").fetchone() == ("UTF-8", json.dumps(["Fixture note"]))
3438

3539
openstatspec.export_sav(database_url=database, dataset_id="tiny", destination=exported)
3640
frame, meta = pyreadstat.read_sav(exported, user_missing=True)
3741
assert frame["age"].iloc[0] == 34.0
3842
assert pd.isna(frame["age"].iloc[1])
3943
assert frame["name"].tolist() == ["Ada", ""]
4044
assert meta.column_labels == ["Age", "Name"]
45+
assert meta.missing_ranges == {"age": [{"lo": 34.0, "hi": 34.0}]}
46+
assert meta.notes == ["Fixture note"]
4147

0 commit comments

Comments
 (0)