Skip to content

Commit 2bb07c7

Browse files
committed
Preflight SPSS weight and MR catalog references
1 parent 43522c3 commit 2bb07c7

4 files changed

Lines changed: 364 additions & 2 deletions

File tree

‎src/openstatspec/spss/sav.py‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
read_fidelity_events,
2525
read_wide_dataset,
2626
record_export_operation,
27+
validate_spss_catalog,
2728
)
2829

2930
_UTF8_ENCODINGS = {"UTF-8", "UTF8"}
@@ -206,6 +207,11 @@ def export_sav_dataset(
206207
if destination_path.suffix.lower() not in {".sav", ".zsav"}:
207208
raise UnsupportedOperationError("Export destinations must use the .sav or .zsav extension.")
208209
dataset, variables, rows = read_wide_dataset(database_url=database_url, dataset_id=dataset_id)
210+
validate_spss_catalog(
211+
variables,
212+
case_weight_variable=dataset.get("case_weight_variable"),
213+
multiple_response_sets=dataset.get("multiple_response_sets"),
214+
)
209215
loss_report = _merge_loss_reports(
210216
read_fidelity_events(database_url=database_url, dataset_id=dataset_id),
211217
_export_loss_report(dataset),

‎src/openstatspec/sql/wide.py‎

Lines changed: 208 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,25 @@
1111

1212
from sqlalchemy import delete, BigInteger, Boolean, Column, Float, Integer, MetaData, String, Table, Text, create_engine, insert, inspect, select, text, update
1313
from sqlalchemy.dialects import mysql, postgresql, sqlite
14+
from ..core import UnsupportedOperationError
1415
from .profiles import preflight, validate_connection_url
1516

1617
_IDENTIFIER = re.compile(r"[^a-zA-Z0-9_]+")
1718

1819

20+
class CatalogPreflightError(UnsupportedOperationError):
21+
"""A semantic SPSS catalog rule failed before an import or writer runs."""
22+
23+
def __init__(self, code: str, detail: str, *, details: Mapping[str, Any]) -> None:
24+
super().__init__(f"OpenStatSpec catalog preflight failed [{code}]: {detail}")
25+
self.code = code
26+
self.details = {"reason": code, **details}
27+
28+
29+
def _catalog_error(code: str, detail: str, **details: Any) -> CatalogPreflightError:
30+
return CatalogPreflightError(code, detail, details=details)
31+
32+
1933
def binary64_type() -> Float:
2034
"""Return the required IEEE-754 binary64 SQL type for every profile.
2135
@@ -185,7 +199,7 @@ def _record_failed_preflight(
185199
))
186200
connection.execute(insert(fidelity_event_catalog), _event_rows(
187201
operation_id=operation_id, dataset_id=None, direction="import", fidelity_events=({
188-
"code": "target-capability-exceeded", "detail": str(error), "severity": "error",
202+
"code": getattr(error, "code", "target-capability-exceeded"), "detail": str(error), "severity": "error",
189203
"details": {"variable_count": variable_count, "profile": profile_name,
190204
**getattr(error, "details", {})},
191205
},),
@@ -350,6 +364,189 @@ def _mr_counted_value(definition: Mapping[str, Any]) -> tuple[str | None, float
350364
return "text", None, str(value)
351365

352366

367+
def _multiple_response_definitions(definitions: str | Mapping[str, Any] | None) -> Mapping[str, Any]:
368+
if definitions is None or definitions == "":
369+
return {}
370+
if isinstance(definitions, str):
371+
try:
372+
definitions = json.loads(definitions)
373+
except json.JSONDecodeError as error:
374+
raise _catalog_error(
375+
"multiple-response-definition-invalid",
376+
"Multiple-response set catalog is not valid JSON.",
377+
error=str(error),
378+
) from error
379+
if not isinstance(definitions, Mapping):
380+
raise _catalog_error(
381+
"multiple-response-definition-invalid",
382+
"Multiple-response set catalog must be an object keyed by set name.",
383+
actual_type=type(definitions).__name__,
384+
)
385+
return definitions
386+
387+
388+
def _mr_is_dichotomy(definition: Mapping[str, Any]) -> bool:
389+
value = definition.get("is_dichotomy")
390+
if value is not None:
391+
return bool(value)
392+
return definition.get("set_type", definition.get("type")) in {"MD", "D", "E"} or (
393+
definition.get("counted_value", definition.get("countedvalue")) is not None
394+
)
395+
396+
397+
def validate_spss_catalog(
398+
variables: Iterable[Mapping[str, Any]], *,
399+
case_weight_variable: str | None = None,
400+
multiple_response_sets: str | Mapping[str, Any] | None = None,
401+
) -> None:
402+
"""Validate SPSS dictionary cross-references before persisting or writing."""
403+
variables_by_name: dict[str, Mapping[str, Any]] = {}
404+
for variable in variables:
405+
source_name = variable.get("source_name")
406+
if isinstance(source_name, str):
407+
variables_by_name[source_name] = variable
408+
409+
if case_weight_variable is not None:
410+
if not isinstance(case_weight_variable, str) or not case_weight_variable:
411+
raise _catalog_error(
412+
"case-weight-variable-invalid",
413+
"The case-weight reference must be a non-empty source variable name.",
414+
case_weight_variable=case_weight_variable,
415+
)
416+
weight = variables_by_name.get(case_weight_variable)
417+
if weight is None:
418+
raise _catalog_error(
419+
"case-weight-variable-not-found",
420+
"The case-weight reference does not name a source variable.",
421+
case_weight_variable=case_weight_variable,
422+
)
423+
if weight.get("storage_kind") != "numeric":
424+
raise _catalog_error(
425+
"case-weight-variable-not-numeric",
426+
"The case-weight variable must use SPSS numeric storage.",
427+
case_weight_variable=case_weight_variable,
428+
storage_kind=weight.get("storage_kind"),
429+
)
430+
if str(weight.get("measure") or "").lower() != "scale":
431+
raise _catalog_error(
432+
"case-weight-variable-not-scale",
433+
"The case-weight variable must have SPSS scale measurement level.",
434+
case_weight_variable=case_weight_variable,
435+
measurement_level=weight.get("measure"),
436+
)
437+
438+
for set_name, definition in _multiple_response_definitions(multiple_response_sets).items():
439+
if not isinstance(set_name, str) or not set_name:
440+
raise _catalog_error(
441+
"multiple-response-set-name-invalid",
442+
"A multiple-response set needs a non-empty string name.",
443+
set_name=set_name,
444+
)
445+
if not isinstance(definition, Mapping):
446+
raise _catalog_error(
447+
"multiple-response-definition-invalid",
448+
"A multiple-response set definition must be an object.",
449+
set_name=set_name,
450+
actual_type=type(definition).__name__,
451+
)
452+
members = definition.get("variable_list", definition.get("variables", []))
453+
if isinstance(members, str):
454+
members = members.split()
455+
if not isinstance(members, (list, tuple)) or not members:
456+
raise _catalog_error(
457+
"multiple-response-members-invalid",
458+
"A multiple-response set needs at least one ordered member variable.",
459+
set_name=set_name,
460+
)
461+
if any(not isinstance(member, str) or not member for member in members):
462+
raise _catalog_error(
463+
"multiple-response-member-invalid",
464+
"Every multiple-response member must be a non-empty source variable name.",
465+
set_name=set_name,
466+
)
467+
if len(set(members)) != len(members):
468+
raise _catalog_error(
469+
"multiple-response-member-duplicate",
470+
"A multiple-response set cannot list one source variable twice.",
471+
set_name=set_name,
472+
)
473+
member_variables: list[Mapping[str, Any]] = []
474+
for member in members:
475+
variable = variables_by_name.get(member)
476+
if variable is None:
477+
raise _catalog_error(
478+
"multiple-response-member-not-found",
479+
"A multiple-response member does not name a source variable.",
480+
set_name=set_name,
481+
member=member,
482+
)
483+
member_variables.append(variable)
484+
storage_kinds = {str(variable.get("storage_kind")) for variable in member_variables}
485+
if len(storage_kinds) != 1 or storage_kinds == {"None"}:
486+
raise _catalog_error(
487+
"multiple-response-member-type-mismatch",
488+
"All multiple-response members must have one shared SPSS storage kind.",
489+
set_name=set_name,
490+
members=list(members),
491+
storage_kinds=sorted(storage_kinds),
492+
)
493+
is_dichotomy = _mr_is_dichotomy(definition)
494+
counted_value = definition.get("counted_value", definition.get("countedvalue"))
495+
if not is_dichotomy:
496+
if counted_value is not None:
497+
raise _catalog_error(
498+
"multiple-response-counted-value-not-permitted",
499+
"An MC multiple-response set cannot define a dichotomy counted value.",
500+
set_name=set_name,
501+
counted_value=counted_value,
502+
)
503+
continue
504+
if counted_value is None:
505+
raise _catalog_error(
506+
"multiple-response-counted-value-missing",
507+
"An MD multiple-response set must define its counted value.",
508+
set_name=set_name,
509+
)
510+
storage_kind = storage_kinds.pop()
511+
if storage_kind == "numeric":
512+
if isinstance(counted_value, bool) or not isinstance(counted_value, (int, float)) or not math.isfinite(float(counted_value)):
513+
raise _catalog_error(
514+
"multiple-response-counted-value-type-mismatch",
515+
"An MD set of numeric variables requires a finite numeric counted value.",
516+
set_name=set_name,
517+
counted_value=counted_value,
518+
storage_kind=storage_kind,
519+
)
520+
elif storage_kind == "string":
521+
if not isinstance(counted_value, str):
522+
raise _catalog_error(
523+
"multiple-response-counted-value-type-mismatch",
524+
"An MD set of string variables requires a string counted value.",
525+
set_name=set_name,
526+
counted_value=counted_value,
527+
storage_kind=storage_kind,
528+
)
529+
encoded_length = len(counted_value.encode("utf-8"))
530+
for member, variable in zip(members, member_variables):
531+
width = variable.get("string_width")
532+
if isinstance(width, int) and encoded_length > width:
533+
raise _catalog_error(
534+
"multiple-response-counted-value-too-wide",
535+
"The MD counted value exceeds a member's declared SPSS string width.",
536+
set_name=set_name,
537+
member=member,
538+
counted_value=counted_value,
539+
counted_value_bytes=encoded_length,
540+
string_width=width,
541+
)
542+
else:
543+
raise _catalog_error(
544+
"multiple-response-member-storage-invalid",
545+
"A multiple-response member has unsupported SPSS storage kind.",
546+
set_name=set_name,
547+
storage_kind=storage_kind,
548+
)
549+
353550
def multiple_response_set_rows(dataset_id: str, definitions: str) -> list[dict[str, Any]]:
354551
sets = json.loads(definitions or "{}")
355552
rows: list[dict[str, Any]] = []
@@ -462,6 +659,11 @@ def create_wide_dataset(
462659
operation_id = str(uuid4())
463660
try:
464661
preflight(profile, variables)
662+
validate_spss_catalog(
663+
variables,
664+
case_weight_variable=case_weight_variable,
665+
multiple_response_sets=multiple_response_sets,
666+
)
465667
except Exception as error:
466668
_record_failed_preflight(
467669
engine=engine, metadata=metadata, datasets=datasets,
@@ -672,6 +874,11 @@ def validate_wide_dataset(*, database_url: str, dataset_id: str) -> dict[str, An
672874
dataset, variables, rows = read_wide_dataset(database_url=database_url, dataset_id=dataset_id)
673875
profile = validate_connection_url(database_url)
674876
preflight(profile, variables)
877+
validate_spss_catalog(
878+
variables,
879+
case_weight_variable=dataset.get("case_weight_variable"),
880+
multiple_response_sets=dataset.get("multiple_response_sets"),
881+
)
675882
if not variables:
676883
raise ValueError("A conforming dataset needs at least one source variable.")
677884
expected_columns = {"__case_ordinal", *(item["physical_name"] for item in variables)}

‎tests/conformance.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ def write_supported_semantics_fixture(destination: str | Path) -> dict[str, Any]
4444
"status": {"values": ["NA", "DK"]},
4545
},
4646
"var_measure_levels": {
47-
"lowest_range": "scale", "highest_range": "scale", "code": "ordinal",
47+
"lowest_range": "scale", "highest_range": "scale", "code": "scale",
4848
"status": "nominal", "comment": "nominal", "interview_date": "scale",
4949
},
5050
"var_alignments": {column: "left" for column in frame.columns},

0 commit comments

Comments
 (0)