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.
@@ -0,0 +1,6 @@
1
+ """Public API for the OpenStatSpec Python reference implementation."""
2
+
3
+ from .api import capabilities, capability_matrix, export_sav, import_sav, inspect, validate
4
+ from .core import CapabilityDeclaration, LossReport, UnsupportedOperationError
5
+
6
+ __all__ = ["CapabilityDeclaration", "LossReport", "UnsupportedOperationError", "capabilities", "capability_matrix", "export_sav", "import_sav", "inspect", "validate"]
openstatspec/api.py ADDED
@@ -0,0 +1,95 @@
1
+ """Stable, database-connected public API."""
2
+
3
+ from collections.abc import Mapping
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+ from .core.results import result
8
+ from .spss import export_dataset, import_dataset, inspect_source
9
+ from .spss.sav import engine_identity
10
+ from .sql import declared_profiles, validate_dataset
11
+ from .sql.capabilities import (
12
+ SPECIFICATION_COMMIT, SPECIFICATION_RELEASE, active_connection, catalog_binding,
13
+ )
14
+
15
+
16
+
17
+ def capability_matrix(database_url: str | None = None) -> Mapping[str, Any]:
18
+ """Return the pyspssio-backed SAV/ZSAV fidelity boundary.
19
+
20
+ supported means that the adapter has a tested faithful path.
21
+ unobservable means that pyspssio's public reader API cannot expose the
22
+ source semantic. fail-closed-on-export means an imported/catalogued value
23
+ blocks export unless the documented audited loss route exists; a plain
24
+ fail-closed feature has no faithful writer route at all.
25
+ """
26
+ declaration = {
27
+ "specification": "OpenStatSpec",
28
+ "specification_status": "release_candidate",
29
+ "specification_release": SPECIFICATION_RELEASE,
30
+ "specification_commit": SPECIFICATION_COMMIT,
31
+ "profile": "SPSS SAV/ZSAV 1.0",
32
+ "directions": ["import", "export", "semantic_round_trip"],
33
+ "required_capabilities": [
34
+ "sav_read", "sav_write", "zsav_read", "zsav_write",
35
+ "file_label", "documents", "source_encoding", "attributes",
36
+ "variable_dictionary", "value_labels", "missing_rules",
37
+ "lowest_highest_missing", "long_utf8_strings", "weight_variable",
38
+ "variable_sets", "multiple_response_sets",
39
+ "multiple_response_string_counted_value",
40
+ ],
41
+ "engine": engine_identity(),
42
+ "spss": {
43
+ "values": "supported", "variable_labels": "supported",
44
+ "value_labels": "supported",
45
+ "print_format": "supported",
46
+ "write_format": "supported",
47
+ "file_label": "supported",
48
+ "documents": "supported",
49
+ "source_encoding": {
50
+ "utf8": "supported",
51
+ "legacy_code_pages": "requires-explicit-legacy-locale",
52
+ },
53
+ "measurement_level": "supported", "user_missing_rules": "supported",
54
+ "multiple_response_sets": "supported",
55
+ "variable_alignment": "supported",
56
+ "variable_sets": "supported",
57
+ "compatible_variable_names": "supported",
58
+ "custom_attributes": {
59
+ "scalar_values": "supported",
60
+ "ordered_value_arrays": "supported",
61
+ },
62
+ "variable_role": "supported",
63
+ },
64
+ "resource_behavior": {
65
+ "streaming_import": False, "streaming_export": False,
66
+ "buffering": "fully_buffered", "maximum_cases": None,
67
+ "maximum_source_file_bytes": None,
68
+ "limit_basis": "runtime memory and active SQL connection limits",
69
+ },
70
+ "active_connection": active_connection(database_url) if database_url else None,
71
+ "catalog_binding": catalog_binding(database_url) if database_url else None,
72
+ "sql_profiles": declared_profiles(database_url),
73
+ }
74
+ return declaration
75
+
76
+ def capabilities(database_url: str | None = None) -> Mapping[str, Any]:
77
+ return capability_matrix(database_url)
78
+
79
+
80
+ def inspect(source: str | Path, /, **options: Any) -> Mapping[str, Any]:
81
+ return result(inspect_source(source, **options))
82
+
83
+
84
+ def import_sav(source: str | Path, /, *, database_url: Any, dataset_id: str, **options: Any) -> Mapping[str, Any]:
85
+ """Import one source file into one dedicated wide SQL table."""
86
+ return result(import_dataset(source, database_url=database_url, dataset_id=dataset_id, **options))
87
+
88
+
89
+ def export_sav(*, database_url: Any, dataset_id: str, destination: str | Path, **options: Any) -> Mapping[str, Any]:
90
+ """Export one database-resident conforming dataset to SAV/ZSAV."""
91
+ return result(export_dataset(database_url=database_url, dataset_id=dataset_id, destination=destination, **options))
92
+
93
+
94
+ def validate(*, database_url: Any, dataset_id: str, **options: Any) -> Mapping[str, Any]:
95
+ return result(validate_dataset(database_url=database_url, dataset_id=dataset_id, **options))
openstatspec/cli.py ADDED
@@ -0,0 +1,42 @@
1
+ """Command-line entry points for the database-connected workflow."""
2
+ import json
3
+
4
+ import argparse
5
+ from collections.abc import Sequence
6
+
7
+ from .api import capability_matrix, export_sav, import_sav, inspect, validate
8
+
9
+
10
+ def main(argv: Sequence[str] | None = None) -> int:
11
+ parser = argparse.ArgumentParser(prog="openstatspec")
12
+ commands = parser.add_subparsers(dest="command", required=True)
13
+ capability_parser = commands.add_parser("capabilities", help="show supported and lossy feature matrix")
14
+ capability_parser.add_argument("--database-url", help="include active connection limits")
15
+ importer = commands.add_parser("import", help="import one SAV/ZSAV file")
16
+ importer.add_argument("source")
17
+ importer.add_argument("--database-url", required=True)
18
+ importer.add_argument("--dataset-id", required=True)
19
+ inspector = commands.add_parser("inspect", help="inspect one SAV/ZSAV dictionary")
20
+ inspector.add_argument("source")
21
+ validator = commands.add_parser("validate", help="validate one imported dataset")
22
+ validator.add_argument("--database-url", required=True)
23
+ validator.add_argument("--dataset-id", required=True)
24
+ exporter = commands.add_parser("export", help="export one dataset to SAV/ZSAV")
25
+ exporter.add_argument("--database-url", required=True)
26
+ exporter.add_argument("--dataset-id", required=True)
27
+ exporter.add_argument("--output", required=True)
28
+ exporter.add_argument("--allow-loss", action="append", default=[])
29
+ exporter.add_argument("--legacy-locale", help="OS locale for a non-UTF-8 source encoding")
30
+ args = parser.parse_args(argv)
31
+ if args.command == "capabilities":
32
+ result = capability_matrix(database_url=args.database_url)
33
+ elif args.command == "import":
34
+ result = import_sav(args.source, database_url=args.database_url, dataset_id=args.dataset_id)
35
+ elif args.command == "export":
36
+ result = export_sav(database_url=args.database_url, dataset_id=args.dataset_id, destination=args.output, allow_loss=args.allow_loss, legacy_locale=args.legacy_locale)
37
+ elif args.command == "inspect":
38
+ result = inspect(args.source)
39
+ else:
40
+ result = validate(database_url=args.database_url, dataset_id=args.dataset_id)
41
+ print(json.dumps(result.as_dict() if hasattr(result, "as_dict") else result, default=str, sort_keys=True))
42
+ return 0
@@ -0,0 +1,33 @@
1
+ from .results import Diagnostic, OperationResult
2
+ """Pure OpenStatSpec concepts; no file or database adapter code."""
3
+
4
+ from dataclasses import dataclass, field
5
+ from typing import Any
6
+
7
+
8
+ class UnsupportedOperationError(NotImplementedError):
9
+ """Raised when faithful support for a requested operation is unavailable."""
10
+
11
+
12
+ @dataclass(frozen=True)
13
+ class LossReport:
14
+ """Machine-readable fidelity outcome for a completed future operation."""
15
+
16
+ events: tuple[dict[str, Any], ...] = ()
17
+
18
+
19
+ @dataclass(frozen=True)
20
+ class CapabilityDeclaration:
21
+ specification: str | None = None
22
+ formats: dict[str, Any] = field(default_factory=dict)
23
+ database_profiles: dict[str, Any] = field(default_factory=dict)
24
+
25
+ @classmethod
26
+ def empty(cls) -> "CapabilityDeclaration":
27
+ return cls()
28
+
29
+ def as_dict(self) -> dict[str, Any]:
30
+ return {
31
+ "specification": self.specification, "formats": self.formats, "database_profiles": self.database_profiles,
32
+ "operations": {"inspect": True, "import_sav": True, "export_sav": True, "validate": True},
33
+ }
@@ -0,0 +1,47 @@
1
+ """Typed public results while retaining mapping compatibility."""
2
+
3
+ from collections.abc import Iterator, Mapping
4
+ from dataclasses import dataclass, field
5
+ from typing import Any
6
+
7
+
8
+ @dataclass(frozen=True)
9
+ class Diagnostic:
10
+ code: str
11
+ detail: str
12
+ details: Mapping[str, Any] = field(default_factory=dict)
13
+
14
+ def as_dict(self) -> dict[str, Any]:
15
+ result: dict[str, Any] = {"code": self.code, "detail": self.detail}
16
+ if self.details:
17
+ result["details"] = dict(self.details)
18
+ return result
19
+
20
+
21
+ @dataclass(frozen=True)
22
+ class OperationResult(Mapping[str, Any]):
23
+ values: Mapping[str, Any] = field(default_factory=dict)
24
+ diagnostics: tuple[Diagnostic, ...] = ()
25
+
26
+ def as_dict(self) -> dict[str, Any]:
27
+ result = dict(self.values)
28
+ result["loss_report"] = [item.as_dict() for item in self.diagnostics]
29
+ return result
30
+
31
+ def __getitem__(self, key: str) -> Any:
32
+ return self.as_dict()[key]
33
+
34
+ def __iter__(self) -> Iterator[str]:
35
+ return iter(self.as_dict())
36
+
37
+ def __len__(self) -> int:
38
+ return len(self.as_dict())
39
+
40
+
41
+ def result(values: Mapping[str, Any]) -> OperationResult:
42
+ payload = dict(values)
43
+ diagnostics = tuple(
44
+ Diagnostic(code=item["code"], detail=item["detail"], details=item.get("details", {}))
45
+ for item in payload.pop("loss_report", ())
46
+ )
47
+ return OperationResult(payload, diagnostics)
@@ -0,0 +1,22 @@
1
+ """SAV/ZSAV adapter boundary; never a data-model transformation layer."""
2
+
3
+ from pathlib import Path
4
+ from typing import Any
5
+
6
+ from .sav import export_sav_dataset, import_sav_dataset, inspect_sav
7
+
8
+
9
+ def inspect_source(source: str | Path, **options: Any) -> dict[str, Any]:
10
+ return inspect_sav(source)
11
+
12
+
13
+ def import_dataset(source: str | Path, *, database_url: Any, dataset_id: str, **options: Any) -> dict[str, Any]:
14
+ return import_sav_dataset(source=source, database_url=str(database_url), dataset_id=dataset_id)
15
+
16
+
17
+ def export_dataset(*, database_url: Any, dataset_id: str, destination: str | Path, **options: Any) -> dict[str, Any]:
18
+ return export_sav_dataset(
19
+ database_url=str(database_url), dataset_id=dataset_id, destination=destination,
20
+ allow_loss=tuple(options.get("allow_loss", ())),
21
+ legacy_locale=options.get("legacy_locale"),
22
+ )
@@ -0,0 +1,180 @@
1
+ """Small, contained bridge to pyspssio's IBM I/O dictionary functions.
2
+
3
+ The convenient read_metadata/write_sav helpers deliberately flatten some
4
+ dictionary information. OpenStatSpec needs separate print/write formats and
5
+ ordered repeated custom attributes. Keeping ctypes calls here makes that
6
+ boundary explicit and testable.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from ctypes import POINTER, c_char_p, c_int
12
+ from typing import Any, Iterable, Mapping
13
+
14
+ from pyspssio.header import spss_formats_simple, varformat_to_tuple, warn_or_raise
15
+
16
+ FormatTuple = tuple[int, int, int]
17
+ AttributePair = tuple[str, str]
18
+
19
+
20
+ def format_tuples(header: Any) -> tuple[dict[str, FormatTuple], dict[str, FormatTuple]]:
21
+ """Return raw SPSS print and write format tuples by variable."""
22
+ return (
23
+ {name: _get_format(header, name, "spssGetVarPrintFormat") for name in header.var_names},
24
+ {name: _get_format(header, name, "spssGetVarWriteFormat") for name in header.var_names},
25
+ )
26
+
27
+
28
+ def _get_format(header: Any, name: str, function_name: str) -> FormatTuple:
29
+ function = getattr(header.spssio, function_name)
30
+ function.argtypes = [c_int, c_char_p, POINTER(c_int), POINTER(c_int), POINTER(c_int)]
31
+ kind, decimals, width = c_int(), c_int(), c_int()
32
+ retcode = function(header.fh, name.encode(header.encoding), kind, decimals, width)
33
+ warn_or_raise(retcode, function, name)
34
+ return kind.value, width.value, decimals.value
35
+
36
+
37
+ def format_string(value: FormatTuple) -> str:
38
+ kind, width, decimals = value
39
+ return f"{spss_formats_simple[kind]}{width}" + (f".{decimals}" if decimals else "")
40
+
41
+
42
+ def format_tuple(value: Any) -> FormatTuple:
43
+ if isinstance(value, (list, tuple)) and len(value) == 3:
44
+ return int(value[0]), int(value[1]), int(value[2])
45
+ return tuple(int(part) for part in varformat_to_tuple(value)) # type: ignore[return-value]
46
+
47
+
48
+ def set_format_tuples(
49
+ header: Any, *, name: str, print_format: FormatTuple, write_format: FormatTuple,
50
+ ) -> None:
51
+ """Set print and write formats independently before dictionary commit."""
52
+ for function_name, value in (
53
+ ("spssSetVarPrintFormat", print_format),
54
+ ("spssSetVarWriteFormat", write_format),
55
+ ):
56
+ function = getattr(header.spssio, function_name)
57
+ function.argtypes = [c_int, c_char_p, c_int, c_int, c_int]
58
+ kind, width, decimals = value
59
+ retcode = function(
60
+ header.fh, name.encode(header.encoding), c_int(kind), c_int(decimals), c_int(width),
61
+ )
62
+ warn_or_raise(retcode, function, name, value)
63
+
64
+
65
+ def file_attribute_pairs(header: Any) -> list[AttributePair]:
66
+ return _get_attributes(header, function_name="spssGetFileAttributes")
67
+
68
+
69
+ def variable_attribute_pairs(header: Any, name: str) -> list[AttributePair]:
70
+ return _get_attributes(header, function_name="spssGetVarAttributes", name=name)
71
+
72
+
73
+ def _get_attributes(header: Any, *, function_name: str, name: str | None = None) -> list[AttributePair]:
74
+ """Read ordered attribute pairs without the public dict(zip()) loss."""
75
+ function = getattr(header.spssio, function_name)
76
+
77
+ def call(size: int) -> tuple[Any, Any, Any, int]:
78
+ names = POINTER(c_char_p * size)()
79
+ texts = POINTER(c_char_p * size)()
80
+ count = c_int()
81
+ argtypes = [c_int]
82
+ arguments: list[Any] = [header.fh]
83
+ if name is not None:
84
+ argtypes.append(c_char_p)
85
+ arguments.append(name.encode(header.encoding))
86
+ argtypes.extend([
87
+ POINTER(POINTER(c_char_p * size)), POINTER(POINTER(c_char_p * size)), POINTER(c_int),
88
+ ])
89
+ arguments.extend([names, texts, count])
90
+ function.argtypes = argtypes
91
+ retcode = function(*arguments)
92
+ warn_or_raise(retcode, function, *([name] if name is not None else []))
93
+ return names, texts, count, count.value
94
+
95
+ names, texts, count, size = call(0)
96
+ _free_attributes(header, names, texts, count)
97
+ if not size:
98
+ return []
99
+ names, texts, count, size = call(size)
100
+ try:
101
+ return [
102
+ (names[0][index].decode(header.encoding), texts[0][index].decode(header.encoding))
103
+ for index in range(size)
104
+ ]
105
+ finally:
106
+ _free_attributes(header, names, texts, count)
107
+
108
+
109
+ def _free_attributes(header: Any, names: Any, texts: Any, count: Any) -> None:
110
+ function = header.spssio.spssFreeAttributes
111
+ retcode = function(names, texts, count)
112
+ warn_or_raise(retcode, function)
113
+
114
+
115
+ def attribute_values(pairs: Iterable[AttributePair]) -> dict[str, Any]:
116
+ """Decode SPSS ``Name[1]`` array members to ordered catalog arrays."""
117
+ import re
118
+
119
+ scalar: dict[str, Any] = {}
120
+ arrays: dict[str, list[tuple[int, str]]] = {}
121
+ for name, value in pairs:
122
+ match = re.fullmatch(r"(.+)\[(\d+)\]", name)
123
+ if match and int(match.group(2)) > 0:
124
+ arrays.setdefault(match.group(1), []).append((int(match.group(2)), value))
125
+ else:
126
+ scalar[name] = value
127
+ for name, members in arrays.items():
128
+ members.sort(key=lambda item: item[0])
129
+ # A real SPSS attribute array begins at one. Preserve a malformed or
130
+ # non-contiguous spelling as literal attributes instead of guessing.
131
+ if [index for index, _ in members] == list(range(1, len(members) + 1)):
132
+ scalar.pop(name, None)
133
+ scalar[name] = [value for _, value in members]
134
+ else:
135
+ scalar.update({f"{name}[{index}]": value for index, value in members})
136
+ return scalar
137
+
138
+
139
+ def attribute_pairs(values: Mapping[str, Any]) -> list[AttributePair]:
140
+ """Encode catalog arrays as IBM SPSS ``Name[1]`` attribute members."""
141
+ pairs: list[AttributePair] = []
142
+ for name, value in values.items():
143
+ if isinstance(value, (list, tuple)):
144
+ pairs.extend(
145
+ (f"{name}[{index}]", "" if item is None else str(item))
146
+ for index, item in enumerate(value, start=1)
147
+ )
148
+ else:
149
+ pairs.append((str(name), "" if value is None else str(value)))
150
+ return pairs
151
+
152
+
153
+ def set_file_attribute_pairs(header: Any, pairs: Iterable[AttributePair]) -> None:
154
+ _set_attributes(header, function_name="spssSetFileAttributes", pairs=pairs)
155
+
156
+
157
+ def set_variable_attribute_pairs(header: Any, name: str, pairs: Iterable[AttributePair]) -> None:
158
+ _set_attributes(header, function_name="spssSetVarAttributes", pairs=pairs, name=name)
159
+
160
+
161
+ def _set_attributes(
162
+ header: Any, *, function_name: str, pairs: Iterable[AttributePair], name: str | None = None,
163
+ ) -> None:
164
+ values = list(pairs)
165
+ if not values:
166
+ return
167
+ size = len(values)
168
+ names = (c_char_p * size)(*(key.encode(header.encoding) for key, _ in values))
169
+ texts = (c_char_p * size)(*(value.encode(header.encoding) for _, value in values))
170
+ function = getattr(header.spssio, function_name)
171
+ argtypes = [c_int]
172
+ arguments: list[Any] = [header.fh]
173
+ if name is not None:
174
+ argtypes.append(c_char_p)
175
+ arguments.append(name.encode(header.encoding))
176
+ argtypes.extend([POINTER(c_char_p * size), POINTER(c_char_p * size), c_int])
177
+ arguments.extend([names, texts, c_int(size)])
178
+ function.argtypes = argtypes
179
+ retcode = function(*arguments)
180
+ warn_or_raise(retcode, function, *([name] if name is not None else []))