Skip to content

Commit 0f1819f

Browse files
committed
Canonicalize-SPSS-reference-rewrites
1 parent d008b59 commit 0f1819f

3 files changed

Lines changed: 61 additions & 35 deletions

File tree

‎src/openstatspec/spss/raw_dictionary.py‎

Lines changed: 45 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,7 @@ def write_compatible_names(
176176
encoding=encoding,
177177
compatible_replacements=replacements,
178178
known_compatible_names=[name for _record, name in primary_variables],
179+
known_long_names=[long_name for _short_name, long_name in pairs],
179180
)
180181
record_replacements.update(reference_replacements)
181182

@@ -419,16 +420,49 @@ def _assert_dictionary_semantics(
419420
raise RawDictionaryError("Multiple-response Reader readback changed semantics.")
420421

421422

423+
def _rewrite_reference_member(
424+
raw_member: bytes,
425+
*,
426+
subtype: int,
427+
encoding: str,
428+
known_compatible: dict[str, str],
429+
known_long: dict[str, str],
430+
encoded_replacements: dict[str, bytes],
431+
) -> bytes:
432+
try:
433+
compatible = raw_member.decode("ascii")
434+
_validated_compatible_name(compatible)
435+
except (UnicodeDecodeError, RawDictionaryError):
436+
compatible = None
437+
if compatible is not None and compatible.casefold() in known_compatible:
438+
return encoded_replacements.get(compatible.casefold(), raw_member)
439+
try:
440+
long_name = raw_member.decode(encoding)
441+
except UnicodeDecodeError as error:
442+
raise RawDictionaryError(
443+
f"Invalid subtype-{subtype} variable member token."
444+
) from error
445+
if long_name.casefold() in known_long:
446+
return raw_member
447+
raise RawDictionaryError(
448+
f"Subtype-{subtype} references unknown variable {long_name!r}."
449+
)
450+
451+
422452
def _rewrite_reference_records(
423453
data: bytes, *, byte_order: str, records: list[_Record], encoding: str,
424454
compatible_replacements: dict[str, str], known_compatible_names: Iterable[str],
455+
known_long_names: Iterable[str],
425456
) -> tuple[dict[int, bytes], dict[int, tuple[bytes, ...]]]:
426457
known_names = list(known_compatible_names)
427458
_require_unique_names(known_names, "reference-compatible variable")
428459
known = {name.casefold(): name for name in known_names}
429460
encoded_replacements = {
430461
key: value.encode("ascii") for key, value in compatible_replacements.items()
431462
}
463+
long_names = list(known_long_names)
464+
_require_unique_names(long_names, "reference long variable")
465+
known_long = {name.casefold(): name for name in long_names}
432466
replacements: dict[int, bytes] = {}
433467
expected: dict[int, list[bytes]] = {5: [], 7: [], 19: []}
434468
seen_variable_sets: dict[str, str] = {}
@@ -463,19 +497,14 @@ def _rewrite_reference_records(
463497
seen[set_key] = set_name
464498
members: list[bytes] = []
465499
for raw_member in parsed.members:
466-
try:
467-
member = raw_member.decode("ascii")
468-
_validated_compatible_name(member)
469-
except (UnicodeDecodeError, RawDictionaryError) as error:
470-
raise RawDictionaryError(
471-
f"Invalid subtype-{subtype} compatible member token."
472-
) from error
473-
member_key = member.casefold()
474-
if member_key not in known:
475-
raise RawDictionaryError(
476-
f"Subtype-{subtype} references unknown compatible variable {member!r}."
477-
)
478-
members.append(encoded_replacements.get(member_key, raw_member))
500+
members.append(_rewrite_reference_member(
501+
raw_member,
502+
subtype=subtype,
503+
encoding=encoding,
504+
known_compatible=known,
505+
known_long=known_long,
506+
encoded_replacements=encoded_replacements,
507+
))
479508
updated_lines.append(_ReferenceLine(
480509
set_name=parsed.set_name,
481510
segments=parsed.segments,
@@ -648,7 +677,7 @@ def _very_long_string_entries(
648677
return _very_long_string_pairs(payload), True
649678

650679
def _very_long_string_pairs(payload: bytes) -> list[tuple[str, bytes]]:
651-
if not payload or not payload.endswith(b"\x00 "):
680+
if not payload or not payload.endswith(b" "):
652681
raise RawDictionaryError("Invalid SAV very-long-string record terminator.")
653682
raw_pairs = payload.split(b" ")
654683
if raw_pairs[-1] != b"":
@@ -722,6 +751,7 @@ def _assert_compatible_name_rewrite(
722751
encoding=encoding,
723752
compatible_replacements={},
724753
known_compatible_names=expected_primary_names,
754+
known_long_names=[long_name for _short_name, long_name in pairs],
725755
)
726756
if observed_reference_payloads != expected_reference_payloads:
727757
raise RawDictionaryError(
@@ -844,7 +874,7 @@ def _document_record(lines: Iterable[str], *, encoding: str, byte_order: str) ->
844874

845875

846876
def _records(data: bytes) -> tuple[str, list[_Record]]:
847-
if len(data) < _HEADER_SIZE or data[:4] not in {b"$FL2", b"$FL3"}:
877+
if len(data) < _HEADER_SIZE or bytes(data[:4]) not in {b"$FL2", b"$FL3"}:
848878
raise RawDictionaryError("Not a supported SAV or ZSAV system file.")
849879
byte_order = _byte_order(data)
850880
offset = _HEADER_SIZE

‎src/openstatspec/sql/profiles.py‎

Lines changed: 0 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -266,18 +266,6 @@ def preflight(
266266
row_ordinal=row_ordinal, source_name=variable["source_name"],
267267
encoded_bytes=encoded_bytes, maximum=profile.max_text_value_bytes,
268268
)
269-
declared_width = variable.get("string_width")
270-
if declared_width is not None and encoded_bytes > declared_width:
271-
raise _exceeded(
272-
"text_value_declared_width",
273-
f"row {row_ordinal} value for {variable['source_name']!r} is "
274-
f"{encoded_bytes} UTF-8 bytes; its declared SPSS width is "
275-
f"{declared_width}.",
276-
row_ordinal=row_ordinal,
277-
source_name=variable["source_name"],
278-
encoded_bytes=encoded_bytes,
279-
string_width=declared_width,
280-
)
281269
row_bytes += 20 if profile.name in MYSQL_WIRE_PROFILES else encoded_bytes
282270
if profile.max_row_bytes is not None and row_bytes > profile.max_row_bytes:
283271
raise _exceeded(

‎tests/test_document_round_trip.py‎

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
from pathlib import Path
2+
import re
23
import shutil
34
import sqlite3
45
import subprocess
@@ -48,9 +49,12 @@ def _replace_expected_member_tokens(
4849
updated = []
4950
for payload in payloads:
5051
for old_name, new_name in replacements.items():
51-
old = old_name.encode("ascii")
52-
assert payload.count(old) <= 1
53-
payload = payload.replace(old, new_name.encode("ascii"))
52+
pattern = rb"(?i)(?<!\S)" + re.escape(old_name.encode("ascii")) + rb"(?!\S)"
53+
matches = list(re.finditer(pattern, payload))
54+
assert len(matches) <= 1
55+
if matches:
56+
match = matches[0]
57+
payload = payload[:match.start()] + new_name.encode("ascii") + payload[match.end():]
5458
updated.append(payload)
5559
return tuple(updated)
5660

@@ -89,8 +93,12 @@ def _replace_reference_payload_bytes(
8993
assert len(matches) == 1
9094
record = matches[0]
9195
payload = bytes(data[record.start + 16 : record.end])
92-
assert payload.count(old) == 1
93-
data[record.start + 16 : record.end] = payload.replace(old, new, 1)
96+
matches = list(re.finditer(re.escape(old), payload, flags=re.IGNORECASE))
97+
assert len(matches) == 1
98+
match = matches[0]
99+
data[record.start + 16 : record.end] = (
100+
payload[:match.start()] + new + payload[match.end():]
101+
)
94102
path.write_bytes(data)
95103

96104

@@ -500,7 +508,7 @@ def test_unknown_subtype_19_member_fails_without_source_change(
500508
)
501509
original = source.read_bytes()
502510

503-
with pytest.raises(RawDictionaryError, match="references unknown compatible variable"):
511+
with pytest.raises(RawDictionaryError, match="references unknown variable"):
504512
write_compatible_names(
505513
source, {source_names[1]: "MEMBER2"}, encoding="UTF-8",
506514
)
@@ -526,7 +534,7 @@ def test_combined_zsav_reference_rewrite_preserves_offsets_and_semantics(
526534
for name in text_names:
527535
writer._add_var(name, 8) # pylint: disable=protected-access
528536
writer._add_var(vls_name, 300) # pylint: disable=protected-access
529-
writer.var_sets = {"All analysis variables": all_names}
537+
writer.var_sets = {"Analysis": all_names}
530538
writer.mrsets = {
531539
"$categories": {"label": "Categories", "variable_list": numeric_names[:2]},
532540
"$dichotomies": {
@@ -696,7 +704,7 @@ def test_legacy_source_unicode_export_uses_actual_output_encoding_for_raw_rewrit
696704
source = tmp_path / "legacy-source.sav"
697705
destination = tmp_path / "unicode-output.sav"
698706
database = f"sqlite:///{tmp_path / 'legacy-to-unicode.sqlite'}"
699-
source_names = ["küsimuse_pikk_nimi", "teine_pikk_nimi"]
707+
source_names = ["kusimuse_pikk_nimi", "teine_pikk_nimi"]
700708
mr_label = "Mitmikvalik – küsimus"
701709
frame = pd.DataFrame({source_names[0]: [1.0, 0.0], source_names[1]: [0.0, 1.0]})
702710
pyspssio.write_sav(

0 commit comments

Comments
 (0)