11"""Reusable pyspssio fixtures and semantic comparators for SAV/ZSAV round trips."""
22
3- import math
43from pathlib import Path
54from typing import Any
65
109
1110
1211def write_supported_semantics_fixture (destination : str | Path ) -> dict [str , Any ]:
13- """Write the metadata pyspssio demonstrably exposes and restores."""
12+ """Write a real SAV/ZSAV fixture for every pyspssio-supported core feature.
13+
14+ SPSS time-like values are deliberately stored as numeric values. Their
15+ interpretation belongs to the accompanying SPSS format, never to a SQL
16+ temporal coercion. The fixture also covers every legal user-missing
17+ shape: three discrete values, a range alone, and a range plus one value.
18+ ``pyspssio`` exposes SPSS LOWEST/HIGHEST again as negative/positive
19+ infinity, which is the representation the strict SQL catalog preserves.
20+ """
1421 destination = Path (destination )
1522 long_text = "Õ🙂漢字" * 90
1623 frame = pd .DataFrame ({
24+ "discrete_missing" : [1.0 , 2.0 , 3.0 , 4.0 ],
25+ "range_only" : [- 1.0 , 0.0 , 1.0 , 2.0 ],
1726 "lowest_range" : [- 2.0 , - 1.0 , 0.0 , 7.0 ],
1827 "highest_range" : [- 99.0 , 0.0 , 1.0 , 42.0 ],
1928 "code" : [3.0 , 1.0 , 2.0 , 1.0 ],
2029 "status" : ["NA" , "DK" , "ok" , "" ],
2130 "comment" : [long_text , "" , "näide" , long_text ],
2231 "interview_date" : [23123.0 , 23124.0 , 23125.0 , 23126.0 ],
32+ "interview_time" : [3661.25 , 0.0 , 86399.5 , 12.0 ],
33+ "interview_datetime" : [23123.5 , 23124.0 , 23125.75 , 23126.125 ],
34+ "interview_dtime" : [90061.5 , 0.0 , 172800.25 , 12.0 ],
35+ "formatted_comma" : [12345.67 , - 12345.67 , 0.0 , 1.25 ],
36+ "formatted_dot" : [12345.67 , - 12345.67 , 0.0 , 1.25 ],
37+ "formatted_pct" : [12.5 , 0.0 , 100.0 , - 2.5 ],
2338 "resp_a" : [1.0 , 0.0 , 1.0 , 0.0 ],
2439 "resp_b" : [0.0 , 1.0 , 1.0 , 0.0 ],
2540 })
2641 metadata = {
2742 "var_types" : {"status" : 8 , "comment" : 1024 },
2843 "var_formats" : {
44+ "discrete_missing" : "F8.0" , "range_only" : "F8.0" ,
2945 "lowest_range" : "F12.1" , "highest_range" : "F12.1" , "code" : "F8.0" ,
3046 "status" : "A8" , "comment" : "A1024" , "interview_date" : "DATE11" ,
47+ "interview_time" : "TIME8" , "interview_datetime" : "DATETIME20" ,
48+ "interview_dtime" : "DTIME10" , "formatted_comma" : "COMMA12.2" ,
49+ "formatted_dot" : "DOT12.2" , "formatted_pct" : "PCT8.1" ,
3150 },
3251 "var_labels" : {
52+ "discrete_missing" : "Three numeric user-missing codes" ,
53+ "range_only" : "Range-only numeric user missing" ,
3354 "lowest_range" : "LOWEST-style missing range" , "highest_range" : "HIGHEST-style missing range" ,
3455 "code" : "Ordered numeric code" , "status" : "String missing code" ,
3556 "comment" : "Long UTF-8 comment" , "interview_date" : "SPSS numeric date" ,
57+ "interview_time" : "SPSS numeric time" , "interview_datetime" : "SPSS numeric datetime" ,
58+ "interview_dtime" : "SPSS numeric duration" , "formatted_comma" : "SPSS comma format" ,
59+ "formatted_dot" : "SPSS dot format" , "formatted_pct" : "SPSS percent format" ,
3660 },
3761 "var_value_labels" : {
62+ "discrete_missing" : {3.0 : "third" , 1.0 : "first" , 2.0 : "second" },
3863 "code" : {3.0 : "third" , 1.0 : "first" , 2.0 : "second" },
3964 "status" : {"DK" : "don't know" , "NA" : "not answered" , "ok" : "valid" },
4065 },
4166 "var_missing_values" : {
67+ "discrete_missing" : {"values" : [1.0 , 2.0 , 3.0 ]},
68+ "range_only" : {"lo" : - 1.0 , "hi" : 1.0 },
4269 "lowest_range" : {"lo" : - sys_float_max (), "hi" : - 1.0 , "values" : [7.0 ]},
4370 "highest_range" : {"lo" : 1.0 , "hi" : sys_float_max (), "values" : [- 99.0 ]},
4471 "status" : {"values" : ["NA" , "DK" ]},
4572 },
4673 "var_measure_levels" : {
47- "lowest_range" : "scale" , "highest_range" : "scale" , "code" : "scale" ,
74+ "discrete_missing" : "scale" , "range_only" : "scale" , "lowest_range" : "scale" ,
75+ "highest_range" : "scale" , "code" : "scale" ,
4876 "status" : "nominal" , "comment" : "nominal" , "interview_date" : "scale" ,
4977 },
5078 "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 },
79+ "var_column_widths" : {
80+ "discrete_missing" : 8 , "range_only" : 8 , "lowest_range" : 12 , "highest_range" : 12 ,
81+ "code" : 8 , "status" : 12 , "comment" : 48 , "interview_date" : 11 ,
82+ "interview_time" : 8 , "interview_datetime" : 20 , "interview_dtime" : 10 ,
83+ "formatted_comma" : 12 , "formatted_dot" : 12 , "formatted_pct" : 8 ,
84+ },
5285 "var_roles" : {"code" : "target" , "status" : "input" },
5386 "var_attributes" : {"code" : {"Origin" : "fixture" }},
5487 "mrsets" : {"$responses" : {"label" : "Responses" , "counted_value" : 1 , "variable_list" : ["resp_a" , "resp_b" ]}},
@@ -74,6 +107,14 @@ def _canonical_missing(metadata: dict[str, Any]) -> dict[str, tuple[tuple[Any, A
74107 return result
75108
76109
110+ def _ordered_value_labels (metadata : dict [str , Any ]) -> dict [str , tuple [tuple [Any , str ], ...]]:
111+ """Retain the label order exposed by the SPSS engine, not merely its mapping."""
112+ return {
113+ name : tuple ((value , str (label )) for value , label in labels .items ())
114+ for name , labels in (metadata .get ("var_value_labels" ) or {}).items ()
115+ }
116+
117+
77118def compare_sav_semantics (source : str | Path , exported : str | Path ) -> dict [str , Any ]:
78119 source_frame , source_metadata = pyspssio .read_sav (str (source ), convert_datetimes = False , include_user_missing = True )
79120 exported_frame , exported_metadata = pyspssio .read_sav (str (exported ), convert_datetimes = False , include_user_missing = True )
@@ -93,4 +134,6 @@ def compare_sav_semantics(source: str | Path, exported: str | Path) -> dict[str,
93134 failures .append (attribute )
94135 if _canonical_missing (source_metadata ) != _canonical_missing (exported_metadata ):
95136 failures .append ("var_missing_values" )
96- return {"equivalent" : not failures , "differences" : failures }
137+ if _ordered_value_labels (source_metadata ) != _ordered_value_labels (exported_metadata ):
138+ failures .append ("var_value_label_order" )
139+ return {"equivalent" : not failures , "differences" : failures }
0 commit comments