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
93import math
10- import sys
114from pathlib import Path
125from typing import Any
136
147import pandas as pd
15- import pyreadstat
8+ import pyspssio
169from pandas .testing import assert_frame_equal
1710
1811
1912def 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-
11977def 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 }
0 commit comments