openstatspec 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- openstatspec/__init__.py +6 -0
- openstatspec/api.py +95 -0
- openstatspec/cli.py +42 -0
- openstatspec/core/__init__.py +33 -0
- openstatspec/core/results.py +47 -0
- openstatspec/spss/__init__.py +22 -0
- openstatspec/spss/dictionary.py +180 -0
- openstatspec/spss/raw_dictionary.py +393 -0
- openstatspec/spss/sav.py +569 -0
- openstatspec/sql/__init__.py +14 -0
- openstatspec/sql/capabilities.py +260 -0
- openstatspec/sql/normative.py +513 -0
- openstatspec/sql/profiles.py +217 -0
- openstatspec/sql/wide.py +1087 -0
- openstatspec-0.1.0.dist-info/METADATA +90 -0
- openstatspec-0.1.0.dist-info/RECORD +18 -0
- openstatspec-0.1.0.dist-info/WHEEL +4 -0
- openstatspec-0.1.0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
"""Small, fail-closed helpers for selected raw SPSS dictionary records.
|
|
2
|
+
|
|
3
|
+
This is not a second SAV engine: pyspssio still owns values and the normal
|
|
4
|
+
dictionary. IBM I/O exposes copying document records but not their text.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Iterable
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class RawDictionaryError(ValueError):
|
|
14
|
+
"""Raw dictionary data cannot be handled without risking silent loss."""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
_HEADER_SIZE = 176
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class _Record:
|
|
22
|
+
record_type: int
|
|
23
|
+
start: int
|
|
24
|
+
end: int
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def read_document_lines(path: str | Path, *, encoding: str) -> list[str]:
|
|
28
|
+
"""Read standard 80-byte SAV document lines without touching case data."""
|
|
29
|
+
data = Path(path).read_bytes()
|
|
30
|
+
byte_order, records = _records(data)
|
|
31
|
+
documents = [record for record in records if record.record_type == 6]
|
|
32
|
+
if len(documents) > 1:
|
|
33
|
+
raise RawDictionaryError("SAV dictionary contains more than one document record.")
|
|
34
|
+
if not documents:
|
|
35
|
+
return []
|
|
36
|
+
document = documents[0]
|
|
37
|
+
count = _int(data, document.start + 4, byte_order)
|
|
38
|
+
start = document.start + 8
|
|
39
|
+
return [
|
|
40
|
+
data[offset : offset + 80].decode(encoding).rstrip(" ")
|
|
41
|
+
for offset in range(start, start + (count * 80), 80)
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def write_document_lines(path: str | Path, lines: Iterable[str], *, encoding: str) -> None:
|
|
46
|
+
"""Replace the type-6 record and retain all non-document bytes."""
|
|
47
|
+
target = Path(path)
|
|
48
|
+
data = target.read_bytes()
|
|
49
|
+
byte_order, records = _records(data)
|
|
50
|
+
documents = [record for record in records if record.record_type == 6]
|
|
51
|
+
if len(documents) > 1:
|
|
52
|
+
raise RawDictionaryError("SAV dictionary contains more than one document record.")
|
|
53
|
+
replacement = _document_record(lines, encoding=encoding, byte_order=byte_order)
|
|
54
|
+
terminator = next(record for record in records if record.record_type == 999)
|
|
55
|
+
if documents:
|
|
56
|
+
current = documents[0]
|
|
57
|
+
updated = data[: current.start] + replacement + data[current.end :]
|
|
58
|
+
else:
|
|
59
|
+
extension = next((record for record in records if record.record_type == 7), None)
|
|
60
|
+
insertion = extension.start if extension else terminator.start
|
|
61
|
+
updated = data[:insertion] + replacement + data[insertion:]
|
|
62
|
+
target.write_bytes(_shift_zsav_offsets(
|
|
63
|
+
updated,
|
|
64
|
+
original_data_start=terminator.end,
|
|
65
|
+
delta=len(updated) - len(data),
|
|
66
|
+
byte_order=byte_order,
|
|
67
|
+
))
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def write_compatible_names(
|
|
71
|
+
path: str | Path, names: dict[str, str], *, encoding: str,
|
|
72
|
+
) -> None:
|
|
73
|
+
"""Set exact legacy short names without changing long source variable names.
|
|
74
|
+
|
|
75
|
+
IBM I/O has no compatible-name setter. The standard long-name extension
|
|
76
|
+
and the fixed 8-byte variable records contain the complete mapping, so this
|
|
77
|
+
narrowly rewrites only those two dictionary locations. Every requested
|
|
78
|
+
source name must have a long-name record; otherwise the operation fails
|
|
79
|
+
instead of silently deriving another name.
|
|
80
|
+
"""
|
|
81
|
+
if not names:
|
|
82
|
+
return
|
|
83
|
+
target = Path(path)
|
|
84
|
+
data = target.read_bytes()
|
|
85
|
+
byte_order, records = _records(data)
|
|
86
|
+
terminator = next(record for record in records if record.record_type == 999)
|
|
87
|
+
long_names = next(
|
|
88
|
+
(
|
|
89
|
+
record for record in records
|
|
90
|
+
if record.record_type == 7 and _int(data, record.start + 4, byte_order) == 13
|
|
91
|
+
),
|
|
92
|
+
None,
|
|
93
|
+
)
|
|
94
|
+
if long_names is None:
|
|
95
|
+
raise RawDictionaryError("SAV dictionary has no long-variable-name record.")
|
|
96
|
+
payload_start = long_names.start + 16
|
|
97
|
+
payload = data[payload_start : long_names.end]
|
|
98
|
+
pairs = _long_name_pairs(payload, encoding)
|
|
99
|
+
replacements: dict[str, str] = {}
|
|
100
|
+
updated_pairs: list[tuple[str, str]] = []
|
|
101
|
+
unresolved = set(names)
|
|
102
|
+
for short_name, long_name in pairs:
|
|
103
|
+
replacement = names.get(long_name)
|
|
104
|
+
if replacement is None:
|
|
105
|
+
updated_pairs.append((short_name, long_name))
|
|
106
|
+
continue
|
|
107
|
+
replacements[short_name] = _validated_compatible_name(replacement)
|
|
108
|
+
updated_pairs.append((replacements[short_name], long_name))
|
|
109
|
+
unresolved.remove(long_name)
|
|
110
|
+
if unresolved:
|
|
111
|
+
missing = ", ".join(sorted(unresolved))
|
|
112
|
+
raise RawDictionaryError(
|
|
113
|
+
"Compatible-name update requires a long-name record for: " + missing
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
mutable = bytearray(data)
|
|
117
|
+
for record in records:
|
|
118
|
+
if record.record_type != 2:
|
|
119
|
+
continue
|
|
120
|
+
short_name = bytes(mutable[record.start + 24 : record.start + 32]).decode(encoding).rstrip(" ")
|
|
121
|
+
replacement = replacements.get(short_name)
|
|
122
|
+
if replacement is not None:
|
|
123
|
+
mutable[record.start + 24 : record.start + 32] = replacement.encode("ascii").ljust(8, b" ")
|
|
124
|
+
|
|
125
|
+
new_payload = b" ".join(
|
|
126
|
+
short_name.encode("ascii") + b"=" + long_name.encode(encoding)
|
|
127
|
+
for short_name, long_name in updated_pairs
|
|
128
|
+
)
|
|
129
|
+
header = bytearray(mutable[long_names.start : long_names.start + 16])
|
|
130
|
+
header[12:16] = _pack(len(new_payload), byte_order)
|
|
131
|
+
updated = (
|
|
132
|
+
bytes(mutable[: long_names.start]) + bytes(header) + new_payload
|
|
133
|
+
+ bytes(mutable[long_names.end :])
|
|
134
|
+
)
|
|
135
|
+
target.write_bytes(_shift_zsav_offsets(
|
|
136
|
+
updated,
|
|
137
|
+
original_data_start=terminator.end,
|
|
138
|
+
delta=len(updated) - len(data),
|
|
139
|
+
byte_order=byte_order,
|
|
140
|
+
))
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def write_extended_mrset_labels(
|
|
144
|
+
path: str | Path, labels: dict[str, str], *, encoding: str,
|
|
145
|
+
) -> None:
|
|
146
|
+
"""Restore explicit labels that IBM I/O drops from extended MR sets.
|
|
147
|
+
|
|
148
|
+
When an extended dichotomy set uses the first variable label, the IBM
|
|
149
|
+
writer can emit a zero-length set label even if the source dictionary
|
|
150
|
+
carried both pieces of metadata. Rewrite only subtype-19 label fields;
|
|
151
|
+
all flags, counted values, member names, and case data remain untouched.
|
|
152
|
+
"""
|
|
153
|
+
if not labels:
|
|
154
|
+
return
|
|
155
|
+
target = Path(path)
|
|
156
|
+
data = target.read_bytes()
|
|
157
|
+
byte_order, records = _records(data)
|
|
158
|
+
terminator = next(record for record in records if record.record_type == 999)
|
|
159
|
+
extensions = [
|
|
160
|
+
record for record in records
|
|
161
|
+
if record.record_type == 7 and _int(data, record.start + 4, byte_order) == 19
|
|
162
|
+
]
|
|
163
|
+
if len(extensions) != 1:
|
|
164
|
+
raise RawDictionaryError("Expected exactly one extended multiple-response record.")
|
|
165
|
+
record = extensions[0]
|
|
166
|
+
payload = data[record.start + 16 : record.end]
|
|
167
|
+
unresolved = set(labels)
|
|
168
|
+
output: list[bytes] = []
|
|
169
|
+
for line in payload.rstrip(b"\x00\n").splitlines():
|
|
170
|
+
raw_name, raw_definition = line.split(b"=", 1)
|
|
171
|
+
name = raw_name.decode(encoding)
|
|
172
|
+
label = labels.get(name)
|
|
173
|
+
if label is None:
|
|
174
|
+
output.append(line)
|
|
175
|
+
continue
|
|
176
|
+
fields = raw_definition.split(b" ", 5)
|
|
177
|
+
if len(fields) != 6 or fields[0] != b"E":
|
|
178
|
+
raise RawDictionaryError(
|
|
179
|
+
f"Multiple-response set {name!r} is not an extended subtype-19 definition."
|
|
180
|
+
)
|
|
181
|
+
try:
|
|
182
|
+
old_length = int(fields[4].decode("ascii"))
|
|
183
|
+
except (UnicodeDecodeError, ValueError) as error:
|
|
184
|
+
raise RawDictionaryError("Invalid extended multiple-response label length.") from error
|
|
185
|
+
remainder = fields[5][old_length:]
|
|
186
|
+
if remainder.startswith(b" "):
|
|
187
|
+
remainder = remainder[1:]
|
|
188
|
+
encoded = label.encode(encoding)
|
|
189
|
+
definition = b" ".join([
|
|
190
|
+
fields[0], fields[1], fields[2], fields[3],
|
|
191
|
+
str(len(encoded)).encode("ascii"), encoded,
|
|
192
|
+
])
|
|
193
|
+
if remainder:
|
|
194
|
+
definition += b" " + remainder
|
|
195
|
+
output.append(raw_name + b"=" + definition)
|
|
196
|
+
unresolved.remove(name)
|
|
197
|
+
if unresolved:
|
|
198
|
+
raise RawDictionaryError(
|
|
199
|
+
"Extended multiple-response labels not found: " + ", ".join(sorted(unresolved))
|
|
200
|
+
)
|
|
201
|
+
new_payload = b"\n".join(output) + b"\n"
|
|
202
|
+
header = bytearray(data[record.start : record.start + 16])
|
|
203
|
+
header[8:12] = _pack(1, byte_order)
|
|
204
|
+
header[12:16] = _pack(len(new_payload), byte_order)
|
|
205
|
+
updated = data[: record.start] + bytes(header) + new_payload + data[record.end :]
|
|
206
|
+
target.write_bytes(_shift_zsav_offsets(
|
|
207
|
+
updated,
|
|
208
|
+
original_data_start=terminator.end,
|
|
209
|
+
delta=len(updated) - len(data),
|
|
210
|
+
byte_order=byte_order,
|
|
211
|
+
))
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _long_name_pairs(payload: bytes, encoding: str) -> list[tuple[str, str]]:
|
|
215
|
+
pairs: list[tuple[str, str]] = []
|
|
216
|
+
for raw_pair in payload.split(b" "):
|
|
217
|
+
try:
|
|
218
|
+
raw_short, raw_long = raw_pair.split(b"=", maxsplit=1)
|
|
219
|
+
short_name = raw_short.decode("ascii")
|
|
220
|
+
long_name = raw_long.decode(encoding)
|
|
221
|
+
except (UnicodeDecodeError, ValueError) as error:
|
|
222
|
+
raise RawDictionaryError("Invalid SAV long-variable-name record.") from error
|
|
223
|
+
pairs.append((short_name, long_name))
|
|
224
|
+
return pairs
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _validated_compatible_name(value: str) -> str:
|
|
228
|
+
encoded = str(value).encode("ascii")
|
|
229
|
+
if not 1 <= len(encoded) <= 8:
|
|
230
|
+
raise RawDictionaryError("SPSS compatible variable names must contain one to eight ASCII bytes.")
|
|
231
|
+
first = encoded[:1]
|
|
232
|
+
rest = encoded[1:]
|
|
233
|
+
if not (first.isalpha() or first == b"@"):
|
|
234
|
+
raise RawDictionaryError("SPSS compatible variable names must start with an ASCII letter or @.")
|
|
235
|
+
if any(character not in b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_.$#"
|
|
236
|
+
for character in rest):
|
|
237
|
+
raise RawDictionaryError("SPSS compatible variable name contains an unsupported character.")
|
|
238
|
+
return encoded.decode("ascii")
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _shift_zsav_offsets(
|
|
242
|
+
data: bytes, *, original_data_start: int, delta: int, byte_order: str,
|
|
243
|
+
) -> bytes:
|
|
244
|
+
"""Shift all absolute ZSAV data offsets after a dictionary-size rewrite."""
|
|
245
|
+
if not delta or data[:4] != b"$FL3":
|
|
246
|
+
return data
|
|
247
|
+
adjusted = bytearray(data)
|
|
248
|
+
data_start = original_data_start + delta
|
|
249
|
+
_need(adjusted, data_start, 24)
|
|
250
|
+
original_zheader = _int64(adjusted, data_start, byte_order)
|
|
251
|
+
original_ztrailer = _int64(adjusted, data_start + 8, byte_order)
|
|
252
|
+
trailer_length = _int64(adjusted, data_start + 16, byte_order)
|
|
253
|
+
if original_zheader != original_data_start:
|
|
254
|
+
raise RawDictionaryError("Unexpected ZSAV data-header offset.")
|
|
255
|
+
_set_int64(adjusted, data_start, original_zheader + delta, byte_order)
|
|
256
|
+
_set_int64(adjusted, data_start + 8, original_ztrailer + delta, byte_order)
|
|
257
|
+
trailer_start = original_ztrailer + delta
|
|
258
|
+
if trailer_length < 24:
|
|
259
|
+
raise RawDictionaryError("Invalid ZSAV data-trailer length.")
|
|
260
|
+
_need(adjusted, trailer_start, 24)
|
|
261
|
+
block_count = _int(adjusted, trailer_start + 20, byte_order)
|
|
262
|
+
if block_count < 0 or trailer_length != 24 + (block_count * 24):
|
|
263
|
+
raise RawDictionaryError("Invalid ZSAV data-trailer block metadata.")
|
|
264
|
+
_need(adjusted, trailer_start + 24, block_count * 24)
|
|
265
|
+
for index in range(block_count):
|
|
266
|
+
descriptor = trailer_start + 24 + (index * 24)
|
|
267
|
+
_set_int64(adjusted, descriptor, _int64(adjusted, descriptor, byte_order) + delta, byte_order)
|
|
268
|
+
_set_int64(
|
|
269
|
+
adjusted, descriptor + 8,
|
|
270
|
+
_int64(adjusted, descriptor + 8, byte_order) + delta,
|
|
271
|
+
byte_order,
|
|
272
|
+
)
|
|
273
|
+
return bytes(adjusted)
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _document_record(lines: Iterable[str], *, encoding: str, byte_order: str) -> bytes:
|
|
277
|
+
encoded_lines: list[bytes] = []
|
|
278
|
+
for line in lines:
|
|
279
|
+
encoded = str(line).encode(encoding)
|
|
280
|
+
if len(encoded) > 80:
|
|
281
|
+
raise RawDictionaryError("Each SPSS document line must be at most 80 encoded bytes.")
|
|
282
|
+
encoded_lines.append(encoded.ljust(80, b" "))
|
|
283
|
+
if not encoded_lines:
|
|
284
|
+
return b""
|
|
285
|
+
return _pack(6, byte_order) + _pack(len(encoded_lines), byte_order) + b"".join(encoded_lines)
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def _records(data: bytes) -> tuple[str, list[_Record]]:
|
|
289
|
+
if len(data) < _HEADER_SIZE or data[:4] not in {b"$FL2", b"$FL3"}:
|
|
290
|
+
raise RawDictionaryError("Not a supported SAV or ZSAV system file.")
|
|
291
|
+
byte_order = _byte_order(data)
|
|
292
|
+
offset = _HEADER_SIZE
|
|
293
|
+
records: list[_Record] = []
|
|
294
|
+
while True:
|
|
295
|
+
_need(data, offset, 4)
|
|
296
|
+
record_type = _int(data, offset, byte_order)
|
|
297
|
+
if record_type == 2:
|
|
298
|
+
end = _variable_end(data, offset, byte_order)
|
|
299
|
+
elif record_type == 3:
|
|
300
|
+
end = _value_label_end(data, offset, byte_order)
|
|
301
|
+
elif record_type == 4:
|
|
302
|
+
_need(data, offset, 8)
|
|
303
|
+
end = offset + 8 + (_int(data, offset + 4, byte_order) * 4)
|
|
304
|
+
elif record_type == 6:
|
|
305
|
+
_need(data, offset, 8)
|
|
306
|
+
count = _int(data, offset + 4, byte_order)
|
|
307
|
+
if count < 0:
|
|
308
|
+
raise RawDictionaryError("Negative SAV document line count.")
|
|
309
|
+
end = offset + 8 + (count * 80)
|
|
310
|
+
elif record_type == 7:
|
|
311
|
+
_need(data, offset, 16)
|
|
312
|
+
size = _int(data, offset + 8, byte_order)
|
|
313
|
+
count = _int(data, offset + 12, byte_order)
|
|
314
|
+
if size < 0 or count < 0:
|
|
315
|
+
raise RawDictionaryError("Negative SAV extension-record dimensions.")
|
|
316
|
+
end = offset + 16 + (size * count)
|
|
317
|
+
elif record_type == 999:
|
|
318
|
+
_need(data, offset, 8)
|
|
319
|
+
records.append(_Record(record_type, offset, offset + 8))
|
|
320
|
+
return byte_order, records
|
|
321
|
+
else:
|
|
322
|
+
raise RawDictionaryError(
|
|
323
|
+
f"Unsupported SAV dictionary record type {record_type}; refusing raw rewrite."
|
|
324
|
+
)
|
|
325
|
+
_need(data, offset, end - offset)
|
|
326
|
+
records.append(_Record(record_type, offset, end))
|
|
327
|
+
offset = end
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def _variable_end(data: bytes, offset: int, byte_order: str) -> int:
|
|
331
|
+
_need(data, offset, 32)
|
|
332
|
+
has_label = _int(data, offset + 8, byte_order)
|
|
333
|
+
missing_count = _int(data, offset + 12, byte_order)
|
|
334
|
+
if has_label not in {0, 1} or missing_count < -3 or missing_count > 3:
|
|
335
|
+
raise RawDictionaryError("Invalid SAV variable dictionary record.")
|
|
336
|
+
end = offset + 32
|
|
337
|
+
if has_label:
|
|
338
|
+
_need(data, end, 4)
|
|
339
|
+
label_size = _int(data, end, byte_order)
|
|
340
|
+
if label_size < 0:
|
|
341
|
+
raise RawDictionaryError("Negative SAV variable-label size.")
|
|
342
|
+
end += 4 + _round4(label_size)
|
|
343
|
+
return end + (abs(missing_count) * 8)
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def _value_label_end(data: bytes, offset: int, byte_order: str) -> int:
|
|
347
|
+
_need(data, offset, 8)
|
|
348
|
+
count = _int(data, offset + 4, byte_order)
|
|
349
|
+
if count < 0:
|
|
350
|
+
raise RawDictionaryError("Negative SAV value-label count.")
|
|
351
|
+
end = offset + 8
|
|
352
|
+
for _ in range(count):
|
|
353
|
+
_need(data, end, 9)
|
|
354
|
+
end += 8 + _round8(1 + data[end + 8])
|
|
355
|
+
return end
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def _byte_order(data: bytes) -> str:
|
|
359
|
+
layout = data[64:68]
|
|
360
|
+
if int.from_bytes(layout, "little", signed=True) in {2, 3}:
|
|
361
|
+
return "little"
|
|
362
|
+
if int.from_bytes(layout, "big", signed=True) in {2, 3}:
|
|
363
|
+
return "big"
|
|
364
|
+
raise RawDictionaryError("Cannot determine SAV integer byte order.")
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def _pack(value: int, byte_order: str) -> bytes:
|
|
368
|
+
return int(value).to_bytes(4, byte_order, signed=True)
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def _int(data: bytes, offset: int, byte_order: str) -> int:
|
|
372
|
+
return int.from_bytes(data[offset : offset + 4], byte_order, signed=True)
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def _int64(data: bytes, offset: int, byte_order: str) -> int:
|
|
376
|
+
return int.from_bytes(data[offset : offset + 8], byte_order, signed=True)
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def _set_int64(data: bytearray, offset: int, value: int, byte_order: str) -> None:
|
|
380
|
+
data[offset : offset + 8] = int(value).to_bytes(8, byte_order, signed=True)
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def _round4(value: int) -> int:
|
|
384
|
+
return (value + 3) & ~3
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def _round8(value: int) -> int:
|
|
388
|
+
return (value + 7) & ~7
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def _need(data: bytes, offset: int, size: int) -> None:
|
|
392
|
+
if offset < 0 or size < 0 or offset + size > len(data):
|
|
393
|
+
raise RawDictionaryError("Truncated SAV dictionary record.")
|