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
openstatspec/__init__.py
ADDED
|
@@ -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 []))
|