Skip to content

Commit 14e2a06

Browse files
committed
Persist fidelity events across exports
1 parent 84327a6 commit 14e2a06

4 files changed

Lines changed: 102 additions & 11 deletions

File tree

‎src/openstatspec/spss/sav.py‎

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
import pyreadstat
1111

1212
from ..core import LossReport, UnsupportedOperationError
13-
from ..sql.wide import create_wide_dataset, physical_name, read_wide_dataset
13+
from ..sql.wide import create_wide_dataset, physical_name, read_fidelity_events, read_wide_dataset
1414

1515

1616
def _variables(meta: Any, names: list[str]) -> list[dict[str, Any]]:
@@ -76,6 +76,7 @@ def import_sav_dataset(*, source: str | Path, database_url: str, dataset_id: str
7676
_require_source(source_path)
7777
frame, meta = pyreadstat.read_sav(source_path, user_missing=True, disable_datetime_conversion=True)
7878
variables = _variables(meta, list(frame.columns))
79+
import_loss_report = _import_loss_report(meta)
7980
result = create_wide_dataset(
8081
database_url=database_url, dataset_id=dataset_id, source_name=source_path.name,
8182
source_format=source_path.suffix[1:].upper(), rows=_rows(frame, variables),
@@ -88,8 +89,9 @@ def import_sav_dataset(*, source: str | Path, database_url: str, dataset_id: str
8889
imported_at=datetime.now(UTC).isoformat(),
8990
documents=json.dumps(list(getattr(meta, "notes", []) or [])),
9091
multiple_response_sets=json.dumps(dict(getattr(meta, "mr_sets", {}) or {}), default=str),
92+
fidelity_events=import_loss_report,
9193
)
92-
return {**result, "loss_report": _import_loss_report(meta)}
94+
return {**result, "loss_report": import_loss_report}
9395

9496

9597
def export_sav_dataset(*, database_url: str, dataset_id: str, destination: str | Path, allow_loss: tuple[str, ...] = ()) -> dict[str, Any]:
@@ -114,7 +116,10 @@ def export_sav_dataset(*, database_url: str, dataset_id: str, destination: str |
114116
item["source_name"]: {(float(key) if item["storage_kind"] == "numeric" else key): value for key, value in json.loads(item["value_labels"]).items()}
115117
for item in variables if item["value_labels"] != "{}"
116118
}
117-
loss_report = _export_loss_report(dataset, variables)
119+
loss_report = _merge_loss_reports(
120+
read_fidelity_events(database_url=database_url, dataset_id=dataset_id),
121+
_export_loss_report(dataset, variables),
122+
)
118123
rejected = [event["code"] for event in loss_report if event["code"] not in allow_loss]
119124
if rejected:
120125
raise UnsupportedOperationError("Export requires explicit allow_loss for: " + ", ".join(rejected))
@@ -160,6 +165,16 @@ def _export_loss_report(dataset: dict[str, Any], variables: list[dict[str, Any]]
160165
events.append({"code": "variable-alignment-not-exported", "detail": "The SAV writer has no variable-alignment output capability."})
161166
return tuple(events)
162167

168+
169+
def _merge_loss_reports(*reports: tuple[dict[str, str], ...]) -> tuple[dict[str, str], ...]:
170+
"""Keep one deterministic diagnostic per loss code across import and export."""
171+
events: dict[str, dict[str, str]] = {}
172+
for report in reports:
173+
for event in report:
174+
events.setdefault(event["code"], event)
175+
return tuple(events[code] for code in sorted(events))
176+
177+
163178
def _require_source(source_path: Path) -> None:
164179
if source_path.suffix.lower() not in {".sav", ".zsav"}:
165180
raise UnsupportedOperationError("Only unencrypted SAV and ZSAV sources are supported.")

‎src/openstatspec/sql/wide.py‎

Lines changed: 34 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
_IDENTIFIER = re.compile(r"[^a-zA-Z0-9_]+")
1212

1313

14-
def catalog(metadata: MetaData) -> tuple[Table, Table]:
14+
def catalog(metadata: MetaData) -> tuple[Table, Table, Table]:
1515
datasets = Table(
1616
"dataset_catalog", metadata,
1717
Column("dataset_id", String(255), primary_key=True),
@@ -46,8 +46,13 @@ def catalog(metadata: MetaData) -> tuple[Table, Table]:
4646
Column("value_labels", Text, nullable=False, default="{}"),
4747
Column("missing_ranges", Text, nullable=False, default="[]"),
4848
)
49-
return datasets, variables
50-
49+
fidelity_events = Table(
50+
"fidelity_event_catalog", metadata,
51+
Column("dataset_id", String(255), primary_key=True),
52+
Column("code", String(128), primary_key=True),
53+
Column("detail", Text, nullable=False),
54+
)
55+
return datasets, variables, fidelity_events
5156

5257

5358
def multiple_response_set_catalog(metadata: MetaData) -> Table:
@@ -103,12 +108,13 @@ def create_wide_dataset(
103108
source_created_at: str | None = None, source_modified_at: str | None = None,
104109
imported_at: str = "",
105110
multiple_response_sets: str = "{}",
111+
fidelity_events: Iterable[Mapping[str, str]] = (),
106112
) -> dict[str, Any]:
107113
profile = validate_connection_url(database_url)
108114
preflight(profile, len(variables))
109115
engine = create_engine(database_url)
110116
metadata = MetaData()
111-
datasets, variable_catalog = catalog(metadata)
117+
datasets, variable_catalog, fidelity_event_catalog = catalog(metadata)
112118
multiple_response_catalog = multiple_response_set_catalog(metadata)
113119
data_table = Table(
114120
data_table_name(dataset_id), metadata,
@@ -117,7 +123,7 @@ def create_wide_dataset(
117123
nullable=item["storage_kind"] == "numeric") for item in variables),
118124
)
119125
with engine.begin() as connection:
120-
metadata.create_all(connection, tables=[datasets, variable_catalog, multiple_response_catalog])
126+
metadata.create_all(connection, tables=[datasets, variable_catalog, multiple_response_catalog, fidelity_event_catalog])
121127
if connection.execute(select(datasets.c.dataset_id).where(datasets.c.dataset_id == dataset_id)).first():
122128
raise ValueError(f"Dataset {dataset_id!r} already exists; imports never overwrite a dataset.")
123129
if connection.execute(select(datasets.c.dataset_id).where(datasets.c.data_table == data_table.name)).first():
@@ -138,12 +144,19 @@ def create_wide_dataset(
138144
mrset_rows = multiple_response_set_rows(dataset_id, multiple_response_sets)
139145
if mrset_rows:
140146
connection.execute(insert(multiple_response_catalog), mrset_rows)
147+
event_rows = [
148+
{"dataset_id": dataset_id, "code": event["code"], "detail": event["detail"]}
149+
for event in fidelity_events
150+
]
151+
if event_rows:
152+
connection.execute(insert(fidelity_event_catalog), event_rows)
141153
if materialized:
142154
try:
143155
connection.execute(insert(data_table), materialized)
144156
except Exception:
145157
data_table.drop(connection, checkfirst=True)
146158
connection.execute(delete(multiple_response_catalog).where(multiple_response_catalog.c.dataset_id == dataset_id))
159+
connection.execute(delete(fidelity_event_catalog).where(fidelity_event_catalog.c.dataset_id == dataset_id))
147160
connection.execute(delete(variable_catalog).where(variable_catalog.c.dataset_id == dataset_id))
148161
connection.execute(delete(datasets).where(datasets.c.dataset_id == dataset_id))
149162
connection.commit()
@@ -154,7 +167,7 @@ def create_wide_dataset(
154167
def read_wide_dataset(*, database_url: str, dataset_id: str) -> tuple[dict[str, Any], list[dict[str, Any]], list[dict[str, Any]]]:
155168
engine = create_engine(database_url)
156169
metadata = MetaData()
157-
datasets, variable_catalog = catalog(metadata)
170+
datasets, variable_catalog, _ = catalog(metadata)
158171
with engine.connect() as connection:
159172
dataset = connection.execute(select(datasets).where(datasets.c.dataset_id == dataset_id)).mappings().one()
160173
data_table = Table(dataset["data_table"], MetaData(), autoload_with=connection)
@@ -165,6 +178,21 @@ def read_wide_dataset(*, database_url: str, dataset_id: str) -> tuple[dict[str,
165178
return dict(dataset), [dict(item) for item in variables], [dict(item) for item in rows]
166179

167180

181+
def read_fidelity_events(*, database_url: str, dataset_id: str) -> tuple[dict[str, str], ...]:
182+
"""Read import-time fidelity diagnostics for a catalogued dataset."""
183+
engine = create_engine(database_url)
184+
metadata = MetaData()
185+
_, _, fidelity_event_catalog = catalog(metadata)
186+
with engine.connect() as connection:
187+
fidelity_event_catalog.create(connection, checkfirst=True)
188+
events = connection.execute(
189+
select(fidelity_event_catalog)
190+
.where(fidelity_event_catalog.c.dataset_id == dataset_id)
191+
.order_by(fidelity_event_catalog.c.code)
192+
).mappings().all()
193+
return tuple({"code": item["code"], "detail": item["detail"]} for item in events)
194+
195+
168196
def validate_wide_dataset(*, database_url: str, dataset_id: str) -> dict[str, Any]:
169197
dataset, variables, rows = read_wide_dataset(database_url=database_url, dataset_id=dataset_id)
170198
profile = validate_connection_url(database_url)

‎tests/test_loss_reports.py‎

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,4 +27,52 @@ def test_export_reports_unwritable_spss_dictionary_features(tmp_path) -> None:
2727
assert connection.execute("select set_name, member_ordinal, variable_name from multiple_response_set_catalog").fetchall() == [("set1", 1, None)]
2828
assert {event["code"] for event in result["loss_report"]} == {
2929
"multiple-response-sets-not-exported", "variable-alignment-not-exported", "unobservable-source-dictionary-features"
30-
}
30+
}
31+
32+
33+
def test_persisted_import_fidelity_events_require_consent_after_reopen(tmp_path) -> None:
34+
import pandas as pd
35+
import pyreadstat
36+
import pytest
37+
38+
from openstatspec.core import UnsupportedOperationError
39+
40+
source = tmp_path / "source.sav"
41+
database_path = tmp_path / "persisted.sqlite"
42+
database = f"sqlite:///{database_path}"
43+
blocked = tmp_path / "blocked.sav"
44+
approved = tmp_path / "approved.sav"
45+
pyreadstat.write_sav(pd.DataFrame({"answer": [1.0]}), source)
46+
47+
imported = __import__("openstatspec").import_sav(
48+
source, database_url=database, dataset_id="persisted"
49+
)
50+
assert {event["code"] for event in imported["loss_report"]} == {
51+
"unobservable-source-dictionary-features"
52+
}
53+
54+
import sqlite3
55+
connection = sqlite3.connect(database_path)
56+
assert connection.execute(
57+
"select dataset_id, code from fidelity_event_catalog"
58+
).fetchall() == [("persisted", "unobservable-source-dictionary-features")]
59+
connection.close()
60+
61+
with pytest.raises(
62+
UnsupportedOperationError, match="unobservable-source-dictionary-features"
63+
):
64+
__import__("openstatspec").export_sav(
65+
database_url=database, dataset_id="persisted", destination=blocked
66+
)
67+
assert not blocked.exists()
68+
69+
exported = __import__("openstatspec").export_sav(
70+
database_url=database,
71+
dataset_id="persisted",
72+
destination=approved,
73+
allow_loss=["unobservable-source-dictionary-features"],
74+
)
75+
assert approved.exists()
76+
assert {event["code"] for event in exported["loss_report"]} == {
77+
"unobservable-source-dictionary-features"
78+
}

‎tests/test_sav_sqlite.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ def test_sav_round_trip_uses_one_wide_table_and_catalog(tmp_path) -> None:
3333

3434
connection = sqlite3.connect(database_path)
3535
table_names = [row[0] for row in connection.execute("select name from sqlite_master where type = \"table\" order by name")]
36-
assert table_names == ["data_tiny", "dataset_catalog", "multiple_response_set_catalog", "variable_catalog"]
36+
assert table_names == ["data_tiny", "dataset_catalog", "fidelity_event_catalog", "multiple_response_set_catalog", "variable_catalog"]
3737
assert connection.execute("select __case_ordinal, age, name from data_tiny order by __case_ordinal").fetchall() == [
3838
(1, 34.0, "Ada"), (2, None, "")
3939
]

0 commit comments

Comments
 (0)