solverpilot 0.1__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.
- solverpilot/__init__.py +250 -0
- solverpilot/_immutability.py +27 -0
- solverpilot/_version.py +3 -0
- solverpilot/applications/__init__.py +3 -0
- solverpilot/applications/routing/__init__.py +19 -0
- solverpilot/applications/routing/compile_milp.py +321 -0
- solverpilot/applications/routing/diagnostics.py +78 -0
- solverpilot/applications/routing/errors.py +14 -0
- solverpilot/applications/routing/heuristics.py +167 -0
- solverpilot/applications/routing/model.py +259 -0
- solverpilot/applications/routing/reference.py +81 -0
- solverpilot/applications/routing/solution.py +89 -0
- solverpilot/applications/routing/timeline.py +79 -0
- solverpilot/applications/routing/validation.py +169 -0
- solverpilot/applications/tsp/__init__.py +41 -0
- solverpilot/applications/tsp/compile_cp.py +121 -0
- solverpilot/applications/tsp/compile_milp.py +139 -0
- solverpilot/applications/tsp/distance.py +60 -0
- solverpilot/applications/tsp/errors.py +17 -0
- solverpilot/applications/tsp/heuristics.py +112 -0
- solverpilot/applications/tsp/model.py +175 -0
- solverpilot/applications/tsp/reference.py +121 -0
- solverpilot/applications/tsp/solution.py +65 -0
- solverpilot/applications/tsp/validation.py +92 -0
- solverpilot/backends/__init__.py +51 -0
- solverpilot/backends/_casadi_highs_worker.py +92 -0
- solverpilot/backends/base.py +28 -0
- solverpilot/backends/bundled_capi.py +910 -0
- solverpilot/backends/casadi_conic.py +303 -0
- solverpilot/backends/health.py +240 -0
- solverpilot/backends/highspy_native.py +217 -0
- solverpilot/backends/nlopt_native.py +237 -0
- solverpilot/backends/osqp_native.py +297 -0
- solverpilot/backends/protocol_v2.py +119 -0
- solverpilot/backends/pyscipopt_native.py +200 -0
- solverpilot/backends/registry.py +38 -0
- solverpilot/backends/scipy_highs.py +138 -0
- solverpilot/backends/scipy_highs_lp.py +195 -0
- solverpilot/backends/scipy_slsqp_qp.py +133 -0
- solverpilot/backends/scipy_vendored_highs_dev.py +363 -0
- solverpilot/benchmark/__init__.py +23 -0
- solverpilot/benchmark/acquire.py +199 -0
- solverpilot/benchmark/baselines.py +70 -0
- solverpilot/benchmark/bundle.py +37 -0
- solverpilot/benchmark/campaign.py +150 -0
- solverpilot/benchmark/environment.py +115 -0
- solverpilot/benchmark/integrity.py +46 -0
- solverpilot/benchmark/model.py +146 -0
- solverpilot/benchmark/policy.py +182 -0
- solverpilot/benchmark/registry.py +38 -0
- solverpilot/benchmark/report.py +44 -0
- solverpilot/benchmark/runner.py +516 -0
- solverpilot/benchmark/slurm.py +55 -0
- solverpilot/benchmark/specs.py +75 -0
- solverpilot/benchmark/splits.py +181 -0
- solverpilot/benchmark/summary.py +384 -0
- solverpilot/benchmark/verify.py +142 -0
- solverpilot/benchmark/worker.py +71 -0
- solverpilot/bridges/__init__.py +19 -0
- solverpilot/bridges/engine.py +44 -0
- solverpilot/bridges/indicator.py +232 -0
- solverpilot/bridges/registry.py +78 -0
- solverpilot/bridges/transformation-tape.schema.json +53 -0
- solverpilot/bridges/types.py +149 -0
- solverpilot/bridges/validation.py +110 -0
- solverpilot/capabilities/__init__.py +32 -0
- solverpilot/capabilities/backend-capability-v2.schema.json +21 -0
- solverpilot/capabilities/conformance.py +151 -0
- solverpilot/capabilities/enums.py +27 -0
- solverpilot/capabilities/manifest.py +19 -0
- solverpilot/capabilities/ontology.py +73 -0
- solverpilot/capabilities/requirements.py +37 -0
- solverpilot/capabilities/resolve.py +30 -0
- solverpilot/capabilities/v2.py +711 -0
- solverpilot/cli/__init__.py +1 -0
- solverpilot/cli/backend_health.py +77 -0
- solverpilot/cli/benchmark.py +331 -0
- solverpilot/cli/benchmark_worker.py +33 -0
- solverpilot/cli/capabilities.py +65 -0
- solverpilot/conic/__init__.py +11 -0
- solverpilot/conic/backend.py +263 -0
- solverpilot/conic/compiler.py +310 -0
- solverpilot/conic/conformance.py +60 -0
- solverpilot/conic/conic-ir-v1.schema.json +24 -0
- solverpilot/conic/ir.py +299 -0
- solverpilot/conic/validation.py +149 -0
- solverpilot/cp/__init__.py +7 -0
- solverpilot/cp/conformance.py +32 -0
- solverpilot/cp/cp-ir-v1.schema.json +13 -0
- solverpilot/cp/ir.py +170 -0
- solverpilot/cp/model.py +143 -0
- solverpilot/cp/ortools_backend.py +423 -0
- solverpilot/cp/ortools_worker.py +75 -0
- solverpilot/cp/reference.py +51 -0
- solverpilot/cp/serialization.py +117 -0
- solverpilot/cp/validate.py +83 -0
- solverpilot/diagnose/__init__.py +33 -0
- solverpilot/diagnose/infeasibility.py +463 -0
- solverpilot/diagnose/model.py +87 -0
- solverpilot/evaluation/__init__.py +53 -0
- solverpilot/evaluation/metrics.py +150 -0
- solverpilot/evaluation/oracle.py +288 -0
- solverpilot/evaluation/proof.py +53 -0
- solverpilot/evaluation/references.py +73 -0
- solverpilot/evaluation/regret.py +281 -0
- solverpilot/exceptions.py +34 -0
- solverpilot/experimental/__init__.py +32 -0
- solverpilot/extensions/__init__.py +56 -0
- solverpilot/extensions/entrypoints.py +34 -0
- solverpilot/extensions/errors.py +35 -0
- solverpilot/extensions/manager.py +651 -0
- solverpilot/extensions/manifest.py +232 -0
- solverpilot/extensions/model.py +101 -0
- solverpilot/extensions/versioning.py +136 -0
- solverpilot/history/__init__.py +22 -0
- solverpilot/history/errors.py +15 -0
- solverpilot/history/model.py +129 -0
- solverpilot/history/sqlite.py +651 -0
- solverpilot/inspect/__init__.py +3 -0
- solverpilot/inspect/fingerprint.py +199 -0
- solverpilot/intelligence/__init__.py +36 -0
- solverpilot/intelligence/errors.py +16 -0
- solverpilot/intelligence/fingerprint.py +108 -0
- solverpilot/intelligence/leakage.py +69 -0
- solverpilot/intelligence/schema.py +314 -0
- solverpilot/intelligence/shift.py +208 -0
- solverpilot/io/__init__.py +41 -0
- solverpilot/io/binary64.py +49 -0
- solverpilot/io/csv.py +230 -0
- solverpilot/io/errors.py +25 -0
- solverpilot/io/hashing.py +111 -0
- solverpilot/io/json.py +90 -0
- solverpilot/io/mapping.py +195 -0
- solverpilot/io/model.py +407 -0
- solverpilot/io/numeric.py +108 -0
- solverpilot/io/provenance.py +114 -0
- solverpilot/io/result.py +45 -0
- solverpilot/io/source.py +104 -0
- solverpilot/io/strict_json.py +133 -0
- solverpilot/minlp/__init__.py +6 -0
- solverpilot/minlp/compiler.py +66 -0
- solverpilot/minlp/conformance.py +34 -0
- solverpilot/minlp/convexity.py +85 -0
- solverpilot/minlp/ir.py +35 -0
- solverpilot/minlp/minlp-ir-v1.schema.json +1 -0
- solverpilot/minlp/orchestrator.py +220 -0
- solverpilot/minlp/validation.py +28 -0
- solverpilot/model/__init__.py +27 -0
- solverpilot/model/compiler.py +1263 -0
- solverpilot/model/errors.py +22 -0
- solverpilot/model/expression.py +373 -0
- solverpilot/model/model.py +716 -0
- solverpilot/model/sets.py +60 -0
- solverpilot/model/types.py +36 -0
- solverpilot/nlp/__init__.py +7 -0
- solverpilot/nlp/ad.py +166 -0
- solverpilot/nlp/backend.py +113 -0
- solverpilot/nlp/compiler.py +116 -0
- solverpilot/nlp/conformance.py +58 -0
- solverpilot/nlp/ir.py +70 -0
- solverpilot/nlp/nlp-ir-v1.schema.json +1 -0
- solverpilot/nlp/validation.py +60 -0
- solverpilot/plan/__init__.py +33 -0
- solverpilot/plan/model.py +79 -0
- solverpilot/plan/planner.py +201 -0
- solverpilot/plan/production.py +627 -0
- solverpilot/plan/selective_lp.py +65 -0
- solverpilot/problem/__init__.py +16 -0
- solverpilot/problem/enums.py +20 -0
- solverpilot/problem/hashing.py +88 -0
- solverpilot/problem/linear.py +196 -0
- solverpilot/problem/mps.py +447 -0
- solverpilot/problem/quadratic.py +192 -0
- solverpilot/reporting/__init__.py +27 -0
- solverpilot/reporting/explain.py +327 -0
- solverpilot/reporting/model.py +264 -0
- solverpilot/reporting/render.py +55 -0
- solverpilot/runtime/__init__.py +19 -0
- solverpilot/runtime/auto.py +193 -0
- solverpilot/runtime/budgeting.py +43 -0
- solverpilot/runtime/executor.py +126 -0
- solverpilot/runtime/portfolio.py +197 -0
- solverpilot/runtime/result.py +72 -0
- solverpilot/session/__init__.py +29 -0
- solverpilot/session/conformance_v2.py +239 -0
- solverpilot/session/mutations.py +115 -0
- solverpilot/session/persistent-session-trace.schema.json +45 -0
- solverpilot/session/persistent.py +491 -0
- solverpilot/session/reuse.py +81 -0
- solverpilot/session/session.py +236 -0
- solverpilot/trace/__init__.py +3 -0
- solverpilot/trace/schema.py +47 -0
- solverpilot/validate/__init__.py +10 -0
- solverpilot/validate/core.py +179 -0
- solverpilot/validate/result.py +55 -0
- solverpilot-0.1.dist-info/METADATA +554 -0
- solverpilot-0.1.dist-info/RECORD +201 -0
- solverpilot-0.1.dist-info/WHEEL +5 -0
- solverpilot-0.1.dist-info/entry_points.txt +4 -0
- solverpilot-0.1.dist-info/licenses/LICENSE +202 -0
- solverpilot-0.1.dist-info/top_level.txt +1 -0
solverpilot/io/csv.py
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
"""Safe deterministic local CSV and explicitly declared CSV-bundle ingestion."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import csv
|
|
6
|
+
import io
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from .errors import (
|
|
11
|
+
DataIOConfigurationError,
|
|
12
|
+
DataIOMappingError,
|
|
13
|
+
DataIOParseError,
|
|
14
|
+
DataIOSourceError,
|
|
15
|
+
)
|
|
16
|
+
from .hashing import canonicalize_json_value, deep_thaw_json, sha256_bytes, sha256_json
|
|
17
|
+
from .mapping import apply_rules, set_path
|
|
18
|
+
from .model import CsvMapping, CsvTable, DataIssue, LoadPolicy
|
|
19
|
+
from .provenance import SourceFileProvenance, SourceProvenance
|
|
20
|
+
from .result import LoadedData
|
|
21
|
+
from .source import read_limited_bytes, safe_bundle_child, validate_local_path
|
|
22
|
+
|
|
23
|
+
_CSV_LOADER_VERSION = "1.0"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _parse_table(
|
|
27
|
+
raw_bytes: bytes,
|
|
28
|
+
*,
|
|
29
|
+
table: CsvTable,
|
|
30
|
+
encoding: str,
|
|
31
|
+
delimiter: str,
|
|
32
|
+
policy: LoadPolicy,
|
|
33
|
+
) -> tuple[list[tuple[int, dict[str, str]]], tuple[str, ...]]:
|
|
34
|
+
try:
|
|
35
|
+
text = raw_bytes.decode(encoding)
|
|
36
|
+
except LookupError as exc:
|
|
37
|
+
raise DataIOSourceError("Declared CSV encoding is not supported.") from exc
|
|
38
|
+
except UnicodeDecodeError as exc:
|
|
39
|
+
raise DataIOParseError("CSV source cannot be decoded with the declared encoding.") from exc
|
|
40
|
+
|
|
41
|
+
try:
|
|
42
|
+
reader = csv.reader(io.StringIO(text, newline=""), delimiter=delimiter, strict=True)
|
|
43
|
+
try:
|
|
44
|
+
raw_headers = next(reader)
|
|
45
|
+
except StopIteration as exc:
|
|
46
|
+
raise DataIOParseError("CSV source is missing a header row.") from exc
|
|
47
|
+
headers = tuple(str(header).strip() for header in raw_headers)
|
|
48
|
+
if any(not header for header in headers):
|
|
49
|
+
raise DataIOParseError("CSV header names must be non-empty.")
|
|
50
|
+
if len(headers) != len(set(headers)):
|
|
51
|
+
raise DataIOParseError("CSV header names must be unique.")
|
|
52
|
+
|
|
53
|
+
required_headers = set(table.required_headers)
|
|
54
|
+
required_headers.update(
|
|
55
|
+
rule.source_path
|
|
56
|
+
for rule in table.fields
|
|
57
|
+
if rule.source_path is not None and rule.required and not rule.has_constant
|
|
58
|
+
)
|
|
59
|
+
missing = sorted(str(item) for item in required_headers.difference(headers))
|
|
60
|
+
if missing:
|
|
61
|
+
raise DataIOParseError(f"CSV source is missing required headers: {', '.join(missing)}.")
|
|
62
|
+
|
|
63
|
+
rows: list[tuple[int, dict[str, str]]] = []
|
|
64
|
+
for raw_row in reader:
|
|
65
|
+
row_number = reader.line_num
|
|
66
|
+
if not raw_row:
|
|
67
|
+
raise DataIOParseError(f"CSV row {row_number} is blank; blank data rows are not allowed.")
|
|
68
|
+
if len(raw_row) != len(headers):
|
|
69
|
+
raise DataIOParseError(
|
|
70
|
+
f"CSV row {row_number} has a different field count than the header."
|
|
71
|
+
)
|
|
72
|
+
rows.append((row_number, dict(zip(headers, map(str, raw_row)))))
|
|
73
|
+
return rows, headers
|
|
74
|
+
except DataIOParseError:
|
|
75
|
+
raise
|
|
76
|
+
except (csv.Error, ValueError) as exc:
|
|
77
|
+
raise DataIOParseError("CSV source or delimiter configuration is invalid.") from exc
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _resolve_table_path(root: Path, table: CsvTable, policy: LoadPolicy) -> Path:
|
|
81
|
+
if root.is_file():
|
|
82
|
+
if table.file_name not in {None, root.name}:
|
|
83
|
+
raise DataIOConfigurationError(
|
|
84
|
+
"Single-file CSV mapping file_name must be omitted or match the source name."
|
|
85
|
+
)
|
|
86
|
+
return root
|
|
87
|
+
if table.file_name is None:
|
|
88
|
+
raise DataIOConfigurationError("Every CSV table in a directory bundle must declare file_name.")
|
|
89
|
+
return safe_bundle_child(root, table.file_name, reject_symlinks=policy.reject_symlinks)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def load_csv(
|
|
93
|
+
source: str | Path,
|
|
94
|
+
*,
|
|
95
|
+
mapping: CsvMapping,
|
|
96
|
+
policy: LoadPolicy | None = None,
|
|
97
|
+
) -> LoadedData:
|
|
98
|
+
"""Load one CSV file or an explicitly declared local CSV directory bundle."""
|
|
99
|
+
policy = LoadPolicy() if policy is None else policy
|
|
100
|
+
if not isinstance(mapping, CsvMapping):
|
|
101
|
+
raise DataIOConfigurationError("mapping must be CsvMapping.")
|
|
102
|
+
if not isinstance(policy, LoadPolicy):
|
|
103
|
+
raise DataIOConfigurationError("policy must be LoadPolicy.")
|
|
104
|
+
|
|
105
|
+
root = validate_local_path(
|
|
106
|
+
source,
|
|
107
|
+
expect_directory=None,
|
|
108
|
+
reject_symlinks=policy.reject_symlinks,
|
|
109
|
+
)
|
|
110
|
+
if root.is_dir() and any(table.file_name is None for table in mapping.tables):
|
|
111
|
+
raise DataIOConfigurationError("Every CSV table in a directory bundle must declare file_name.")
|
|
112
|
+
if root.is_file() and len(mapping.tables) != 1:
|
|
113
|
+
raise DataIOConfigurationError("A single CSV file source requires exactly one table mapping.")
|
|
114
|
+
|
|
115
|
+
normalized: Any = canonicalize_json_value(deep_thaw_json(mapping.constants))
|
|
116
|
+
if not isinstance(normalized, dict):
|
|
117
|
+
raise DataIOConfigurationError("CSV constants must form an object.")
|
|
118
|
+
|
|
119
|
+
source_files: list[SourceFileProvenance] = []
|
|
120
|
+
issues: list[DataIssue] = []
|
|
121
|
+
total_size = 0
|
|
122
|
+
total_records = 0
|
|
123
|
+
table_metadata: list[dict[str, Any]] = []
|
|
124
|
+
|
|
125
|
+
for table in mapping.tables:
|
|
126
|
+
table_path = _resolve_table_path(root, table, policy)
|
|
127
|
+
source_name = table_path.name if root.is_file() else str(table.file_name)
|
|
128
|
+
remaining = policy.max_source_bytes - total_size
|
|
129
|
+
if remaining <= 0:
|
|
130
|
+
raise DataIOSourceError("Combined CSV bundle size exceeds the configured maximum.")
|
|
131
|
+
raw_bytes = read_limited_bytes(table_path, max_bytes=remaining)
|
|
132
|
+
total_size += len(raw_bytes)
|
|
133
|
+
encoding = table.encoding or policy.encoding
|
|
134
|
+
delimiter = table.delimiter or policy.delimiter
|
|
135
|
+
rows, headers = _parse_table(
|
|
136
|
+
raw_bytes,
|
|
137
|
+
table=table,
|
|
138
|
+
encoding=encoding,
|
|
139
|
+
delimiter=delimiter,
|
|
140
|
+
policy=policy,
|
|
141
|
+
)
|
|
142
|
+
if len(rows) < table.min_rows:
|
|
143
|
+
raise DataIOParseError(
|
|
144
|
+
f"CSV table {source_name!r} has {len(rows)} data rows; minimum required is {table.min_rows}."
|
|
145
|
+
)
|
|
146
|
+
used_headers = {rule.source_path for rule in table.fields if rule.source_path is not None}
|
|
147
|
+
unused = tuple(header for header in headers if header not in used_headers)
|
|
148
|
+
if unused:
|
|
149
|
+
issues.append(
|
|
150
|
+
DataIssue(
|
|
151
|
+
severity="info",
|
|
152
|
+
code="unused_csv_headers",
|
|
153
|
+
message=f"CSV table {source_name!r} contains headers not used by the explicit mapping.",
|
|
154
|
+
path=source_name,
|
|
155
|
+
)
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
records: list[dict[str, Any]] = []
|
|
159
|
+
for row_number, row in rows:
|
|
160
|
+
try:
|
|
161
|
+
records.append(
|
|
162
|
+
apply_rules(
|
|
163
|
+
row,
|
|
164
|
+
table.fields,
|
|
165
|
+
strict_numbers=policy.strict_numbers,
|
|
166
|
+
max_json_depth=policy.max_json_depth,
|
|
167
|
+
row_number=row_number,
|
|
168
|
+
)
|
|
169
|
+
)
|
|
170
|
+
except DataIOMappingError as exc:
|
|
171
|
+
raise DataIOMappingError(f"CSV mapping failed in {source_name!r}: {exc}") from exc
|
|
172
|
+
|
|
173
|
+
if table.target_path:
|
|
174
|
+
set_path(normalized, table.target_path, records)
|
|
175
|
+
else:
|
|
176
|
+
if len(mapping.tables) != 1 or mapping.constants:
|
|
177
|
+
raise DataIOConfigurationError(
|
|
178
|
+
"An empty CSV target_path is only valid for one table with no top-level constants."
|
|
179
|
+
)
|
|
180
|
+
normalized = records
|
|
181
|
+
|
|
182
|
+
file_hash = sha256_bytes(raw_bytes)
|
|
183
|
+
source_files.append(
|
|
184
|
+
SourceFileProvenance(name=source_name, sha256=file_hash, size_bytes=len(raw_bytes))
|
|
185
|
+
)
|
|
186
|
+
total_records += len(records)
|
|
187
|
+
table_metadata.append(
|
|
188
|
+
{
|
|
189
|
+
"source_name": source_name,
|
|
190
|
+
"target_path": table.target_path,
|
|
191
|
+
"row_count": len(records),
|
|
192
|
+
"encoding": encoding,
|
|
193
|
+
"delimiter": delimiter,
|
|
194
|
+
}
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
evidence = tuple(sorted(source_files, key=lambda item: item.name.casefold()))
|
|
198
|
+
combined_source_hash = (
|
|
199
|
+
evidence[0].sha256
|
|
200
|
+
if root.is_file() and len(evidence) == 1
|
|
201
|
+
else sha256_json([item.to_dict() for item in evidence])
|
|
202
|
+
)
|
|
203
|
+
provenance = SourceProvenance(
|
|
204
|
+
loader="solverpilot.io.csv",
|
|
205
|
+
loader_version=_CSV_LOADER_VERSION,
|
|
206
|
+
source_type="csv_bundle" if root.is_dir() else "csv_file",
|
|
207
|
+
source_name=root.name,
|
|
208
|
+
source_sha256=combined_source_hash,
|
|
209
|
+
source_size_bytes=total_size,
|
|
210
|
+
mapping_sha256=sha256_json(mapping.to_dict()),
|
|
211
|
+
normalized_payload_sha256=sha256_json(normalized),
|
|
212
|
+
policy_sha256=sha256_json(policy.to_dict()),
|
|
213
|
+
record_count=total_records,
|
|
214
|
+
encoding=policy.encoding,
|
|
215
|
+
delimiter=policy.delimiter,
|
|
216
|
+
source_files=evidence,
|
|
217
|
+
metadata={
|
|
218
|
+
"mapping_version": mapping.version,
|
|
219
|
+
"reject_symlinks": policy.reject_symlinks,
|
|
220
|
+
"strict_numbers": policy.strict_numbers,
|
|
221
|
+
"bundle_name_policy": "normalized_posix_casefold_unique_v1",
|
|
222
|
+
"tables": table_metadata,
|
|
223
|
+
},
|
|
224
|
+
)
|
|
225
|
+
return LoadedData(
|
|
226
|
+
payload=normalized,
|
|
227
|
+
provenance=provenance,
|
|
228
|
+
issues=tuple(issues),
|
|
229
|
+
metadata={"mapping_kind": "csv", "table_count": len(mapping.tables)},
|
|
230
|
+
)
|
solverpilot/io/errors.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Fail-closed exceptions for local data ingestion."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from solverpilot.exceptions import SolverPilotError
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class DataIOError(SolverPilotError):
|
|
9
|
+
"""Base class for deterministic SolverPilot data-I/O failures."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class DataIOConfigurationError(DataIOError, ValueError):
|
|
13
|
+
"""Raised when an I/O or mapping configuration is invalid."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class DataIOSourceError(DataIOError):
|
|
17
|
+
"""Raised when a local source cannot be inspected or read safely."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class DataIOParseError(DataIOError, ValueError):
|
|
21
|
+
"""Raised when source bytes cannot be parsed under the declared policy."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class DataIOMappingError(DataIOError, ValueError):
|
|
25
|
+
"""Raised when explicit normalization or field mapping fails."""
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""Canonical JSON snapshots and SHA-256 helpers for data provenance."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import math
|
|
8
|
+
from collections.abc import Mapping, Sequence
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from types import MappingProxyType
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from .errors import DataIOConfigurationError
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _validate_text(value: str) -> str:
|
|
17
|
+
try:
|
|
18
|
+
value.encode("utf-8")
|
|
19
|
+
except UnicodeEncodeError as exc:
|
|
20
|
+
raise DataIOConfigurationError("JSON-compatible text contains an invalid Unicode surrogate.") from exc
|
|
21
|
+
return value
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def canonicalize_json_value(value: Any) -> Any:
|
|
25
|
+
"""Return a detached JSON-compatible value with deterministic mapping order."""
|
|
26
|
+
if value is None or isinstance(value, bool):
|
|
27
|
+
return value
|
|
28
|
+
if isinstance(value, str):
|
|
29
|
+
return _validate_text(value)
|
|
30
|
+
if isinstance(value, int):
|
|
31
|
+
return value
|
|
32
|
+
if isinstance(value, float):
|
|
33
|
+
if not math.isfinite(value):
|
|
34
|
+
raise DataIOConfigurationError("JSON-compatible numbers must be finite.")
|
|
35
|
+
return value
|
|
36
|
+
if isinstance(value, Mapping):
|
|
37
|
+
keys = tuple(value.keys())
|
|
38
|
+
if any(not isinstance(key, str) for key in keys):
|
|
39
|
+
raise DataIOConfigurationError("Canonical JSON mapping keys must be strings.")
|
|
40
|
+
return {
|
|
41
|
+
_validate_text(key): canonicalize_json_value(value[key])
|
|
42
|
+
for key in sorted(keys)
|
|
43
|
+
}
|
|
44
|
+
if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)):
|
|
45
|
+
return [canonicalize_json_value(item) for item in value]
|
|
46
|
+
raise DataIOConfigurationError(
|
|
47
|
+
f"Value of type {type(value).__name__} is not JSON-compatible for provenance."
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _freeze(value: Any) -> Any:
|
|
52
|
+
if isinstance(value, dict):
|
|
53
|
+
return MappingProxyType({key: _freeze(item) for key, item in value.items()})
|
|
54
|
+
if isinstance(value, list):
|
|
55
|
+
return tuple(_freeze(item) for item in value)
|
|
56
|
+
return value
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def deep_freeze_json(value: Any) -> Any:
|
|
60
|
+
"""Create a detached, recursively immutable JSON-compatible snapshot."""
|
|
61
|
+
try:
|
|
62
|
+
return _freeze(canonicalize_json_value(value))
|
|
63
|
+
except DataIOConfigurationError:
|
|
64
|
+
raise
|
|
65
|
+
except RecursionError as exc:
|
|
66
|
+
raise DataIOConfigurationError(
|
|
67
|
+
"JSON-compatible configuration exceeds supported nesting or contains a cycle."
|
|
68
|
+
) from exc
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def deep_thaw_json(value: Any) -> Any:
|
|
72
|
+
"""Return a detached mutable JSON-compatible copy of a frozen snapshot."""
|
|
73
|
+
if isinstance(value, Mapping):
|
|
74
|
+
return {str(key): deep_thaw_json(item) for key, item in value.items()}
|
|
75
|
+
if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)):
|
|
76
|
+
return [deep_thaw_json(item) for item in value]
|
|
77
|
+
return canonicalize_json_value(value)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def canonical_json_bytes(value: Any) -> bytes:
|
|
81
|
+
try:
|
|
82
|
+
normalized = canonicalize_json_value(value)
|
|
83
|
+
return json.dumps(
|
|
84
|
+
normalized,
|
|
85
|
+
ensure_ascii=False,
|
|
86
|
+
sort_keys=True,
|
|
87
|
+
separators=(",", ":"),
|
|
88
|
+
allow_nan=False,
|
|
89
|
+
).encode("utf-8")
|
|
90
|
+
except DataIOConfigurationError:
|
|
91
|
+
raise
|
|
92
|
+
except (TypeError, ValueError, UnicodeEncodeError, RecursionError) as exc:
|
|
93
|
+
raise DataIOConfigurationError("Value cannot be serialized as canonical JSON.") from exc
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def sha256_bytes(data: bytes) -> str:
|
|
97
|
+
return hashlib.sha256(data).hexdigest()
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def sha256_json(value: Any) -> str:
|
|
101
|
+
return sha256_bytes(canonical_json_bytes(value))
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def sha256_file(path: str | Path, *, chunk_size: int = 1024 * 1024) -> str:
|
|
105
|
+
if type(chunk_size) is not int or chunk_size <= 0:
|
|
106
|
+
raise ValueError("chunk_size must be a positive integer")
|
|
107
|
+
digest = hashlib.sha256()
|
|
108
|
+
with Path(path).open("rb") as handle:
|
|
109
|
+
for chunk in iter(lambda: handle.read(chunk_size), b""):
|
|
110
|
+
digest.update(chunk)
|
|
111
|
+
return digest.hexdigest()
|
solverpilot/io/json.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""Safe deterministic local JSON ingestion."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from .errors import DataIOParseError, DataIOSourceError
|
|
8
|
+
from .hashing import sha256_bytes, sha256_json
|
|
9
|
+
from .mapping import apply_object_mapping
|
|
10
|
+
from .model import LoadPolicy, ObjectMapping
|
|
11
|
+
from .provenance import SourceFileProvenance, SourceProvenance
|
|
12
|
+
from .result import LoadedData
|
|
13
|
+
from .source import read_limited_bytes, validate_local_path
|
|
14
|
+
from .strict_json import loads_strict_json
|
|
15
|
+
|
|
16
|
+
_JSON_LOADER_VERSION = "1.0"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def load_json(
|
|
20
|
+
source: str | Path,
|
|
21
|
+
*,
|
|
22
|
+
mapping: ObjectMapping | None = None,
|
|
23
|
+
policy: LoadPolicy | None = None,
|
|
24
|
+
) -> LoadedData:
|
|
25
|
+
"""Load one local JSON file with strict parsing, mapping, and provenance.
|
|
26
|
+
|
|
27
|
+
No problem type is inferred and no solver-specific object is materialized. The
|
|
28
|
+
returned normalized payload is immutable; use ``mutable_payload()`` when a
|
|
29
|
+
mutable detached copy is needed.
|
|
30
|
+
"""
|
|
31
|
+
mapping = ObjectMapping.identity_mapping() if mapping is None else mapping
|
|
32
|
+
policy = LoadPolicy() if policy is None else policy
|
|
33
|
+
if not isinstance(mapping, ObjectMapping):
|
|
34
|
+
from .errors import DataIOConfigurationError
|
|
35
|
+
|
|
36
|
+
raise DataIOConfigurationError("mapping must be ObjectMapping.")
|
|
37
|
+
if not isinstance(policy, LoadPolicy):
|
|
38
|
+
from .errors import DataIOConfigurationError
|
|
39
|
+
|
|
40
|
+
raise DataIOConfigurationError("policy must be LoadPolicy.")
|
|
41
|
+
|
|
42
|
+
path = validate_local_path(
|
|
43
|
+
source,
|
|
44
|
+
expect_directory=False,
|
|
45
|
+
reject_symlinks=policy.reject_symlinks,
|
|
46
|
+
)
|
|
47
|
+
raw_bytes = read_limited_bytes(path, max_bytes=policy.max_source_bytes)
|
|
48
|
+
try:
|
|
49
|
+
text = raw_bytes.decode(policy.encoding)
|
|
50
|
+
except LookupError as exc:
|
|
51
|
+
raise DataIOSourceError("Declared JSON encoding is not supported.") from exc
|
|
52
|
+
except UnicodeDecodeError as exc:
|
|
53
|
+
raise DataIOParseError("JSON source cannot be decoded with the declared encoding.") from exc
|
|
54
|
+
|
|
55
|
+
parsed = loads_strict_json(
|
|
56
|
+
text,
|
|
57
|
+
context="JSON source",
|
|
58
|
+
max_depth=policy.max_json_depth,
|
|
59
|
+
strict_numbers=policy.strict_numbers,
|
|
60
|
+
)
|
|
61
|
+
normalized = apply_object_mapping(
|
|
62
|
+
parsed,
|
|
63
|
+
mapping,
|
|
64
|
+
strict_numbers=policy.strict_numbers,
|
|
65
|
+
max_json_depth=policy.max_json_depth,
|
|
66
|
+
)
|
|
67
|
+
source_hash = sha256_bytes(raw_bytes)
|
|
68
|
+
provenance = SourceProvenance(
|
|
69
|
+
loader="solverpilot.io.json",
|
|
70
|
+
loader_version=_JSON_LOADER_VERSION,
|
|
71
|
+
source_type="json_file",
|
|
72
|
+
source_name=path.name,
|
|
73
|
+
source_sha256=source_hash,
|
|
74
|
+
source_size_bytes=len(raw_bytes),
|
|
75
|
+
mapping_sha256=sha256_json(mapping.to_dict()),
|
|
76
|
+
normalized_payload_sha256=sha256_json(normalized),
|
|
77
|
+
policy_sha256=sha256_json(policy.to_dict()),
|
|
78
|
+
record_count=len(parsed) if isinstance(parsed, list) else 1,
|
|
79
|
+
encoding=policy.encoding,
|
|
80
|
+
source_files=(
|
|
81
|
+
SourceFileProvenance(name=path.name, sha256=source_hash, size_bytes=len(raw_bytes)),
|
|
82
|
+
),
|
|
83
|
+
metadata={
|
|
84
|
+
"mapping_version": mapping.version,
|
|
85
|
+
"reject_symlinks": policy.reject_symlinks,
|
|
86
|
+
"strict_numbers": policy.strict_numbers,
|
|
87
|
+
"strict_json_policy": "solverpilot_strict_json_v1",
|
|
88
|
+
},
|
|
89
|
+
)
|
|
90
|
+
return LoadedData(payload=normalized, provenance=provenance, metadata={"mapping_kind": "object"})
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
"""Collision-safe explicit field mapping and scalar normalization."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from collections.abc import Mapping, Sequence
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from .errors import DataIOConfigurationError, DataIOMappingError
|
|
10
|
+
from .hashing import canonicalize_json_value, deep_thaw_json
|
|
11
|
+
from .model import MappingRule, ObjectMapping
|
|
12
|
+
from .numeric import parse_boolean, parse_integer, parse_number
|
|
13
|
+
from .strict_json import loads_strict_json
|
|
14
|
+
|
|
15
|
+
_MISSING = object()
|
|
16
|
+
_PATH_TOKEN_RE = re.compile(r"([^.[\]]+)|\[([0-9]+)\]")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _path_tokens(path: str) -> tuple[str | int, ...]:
|
|
20
|
+
if path == "":
|
|
21
|
+
return ()
|
|
22
|
+
tokens: list[str | int] = []
|
|
23
|
+
position = 0
|
|
24
|
+
while position < len(path):
|
|
25
|
+
match = _PATH_TOKEN_RE.match(path, position)
|
|
26
|
+
if match is None:
|
|
27
|
+
raise DataIOMappingError(f"Invalid source path syntax at position {position}.")
|
|
28
|
+
token = match.group(1)
|
|
29
|
+
tokens.append(token if token is not None else int(match.group(2)))
|
|
30
|
+
position = match.end()
|
|
31
|
+
if position == len(path):
|
|
32
|
+
break
|
|
33
|
+
if path[position] == "[":
|
|
34
|
+
continue
|
|
35
|
+
if path[position] != ".":
|
|
36
|
+
raise DataIOMappingError(f"Invalid source path syntax at position {position}.")
|
|
37
|
+
position += 1
|
|
38
|
+
if position == len(path) or path[position] in ".[":
|
|
39
|
+
raise DataIOMappingError(f"Invalid source path syntax at position {position}.")
|
|
40
|
+
return tuple(tokens)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def get_path(source: Any, path: str) -> Any:
|
|
44
|
+
current = source
|
|
45
|
+
for token in _path_tokens(path):
|
|
46
|
+
if isinstance(token, int):
|
|
47
|
+
if not isinstance(current, Sequence) or isinstance(current, (str, bytes, bytearray)):
|
|
48
|
+
return _MISSING
|
|
49
|
+
if token >= len(current):
|
|
50
|
+
return _MISSING
|
|
51
|
+
current = current[token]
|
|
52
|
+
else:
|
|
53
|
+
if not isinstance(current, Mapping) or token not in current:
|
|
54
|
+
return _MISSING
|
|
55
|
+
current = current[token]
|
|
56
|
+
return current
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def set_path(target: dict[str, Any], path: str, value: Any) -> None:
|
|
60
|
+
if not path:
|
|
61
|
+
raise DataIOMappingError("Target path cannot be empty for object field mapping.")
|
|
62
|
+
parts = path.split(".")
|
|
63
|
+
if any(not part or "[" in part or "]" in part for part in parts):
|
|
64
|
+
raise DataIOMappingError(f"Invalid target path syntax: {path!r}.")
|
|
65
|
+
current = target
|
|
66
|
+
for part in parts[:-1]:
|
|
67
|
+
if part not in current:
|
|
68
|
+
nested: dict[str, Any] = {}
|
|
69
|
+
current[part] = nested
|
|
70
|
+
current = nested
|
|
71
|
+
continue
|
|
72
|
+
existing = current[part]
|
|
73
|
+
if not isinstance(existing, dict):
|
|
74
|
+
raise DataIOMappingError(
|
|
75
|
+
f"Target path {path!r} collides with a non-object value at {part!r}."
|
|
76
|
+
)
|
|
77
|
+
current = existing
|
|
78
|
+
leaf = parts[-1]
|
|
79
|
+
if leaf in current:
|
|
80
|
+
raise DataIOMappingError(f"Target path {path!r} would overwrite an existing value.")
|
|
81
|
+
current[leaf] = value
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _canonical_mapped(value: Any, *, field_name: str) -> Any:
|
|
85
|
+
try:
|
|
86
|
+
return canonicalize_json_value(value)
|
|
87
|
+
except DataIOConfigurationError as exc:
|
|
88
|
+
raise DataIOMappingError(f"{field_name} is not a supported JSON-compatible value.") from exc
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def normalize_value(
|
|
92
|
+
value: Any,
|
|
93
|
+
rule: MappingRule,
|
|
94
|
+
*,
|
|
95
|
+
field_name: str,
|
|
96
|
+
strict_numbers: bool,
|
|
97
|
+
max_json_depth: int,
|
|
98
|
+
) -> Any:
|
|
99
|
+
is_null = value is None or (
|
|
100
|
+
isinstance(value, str) and value.strip().lower() in rule.null_values
|
|
101
|
+
)
|
|
102
|
+
if is_null:
|
|
103
|
+
if rule.has_default:
|
|
104
|
+
value = deep_thaw_json(rule.default)
|
|
105
|
+
elif rule.required:
|
|
106
|
+
raise DataIOMappingError(f"{field_name} is required and cannot be null.")
|
|
107
|
+
else:
|
|
108
|
+
return None
|
|
109
|
+
if rule.value_type == "any":
|
|
110
|
+
if isinstance(value, str) and rule.trim:
|
|
111
|
+
return value.strip()
|
|
112
|
+
return _canonical_mapped(value, field_name=field_name)
|
|
113
|
+
if rule.value_type == "string":
|
|
114
|
+
if not isinstance(value, str):
|
|
115
|
+
raise DataIOMappingError(f"{field_name} must be a string.")
|
|
116
|
+
return value.strip() if rule.trim else value
|
|
117
|
+
if rule.value_type == "integer":
|
|
118
|
+
return parse_integer(value, field_name=field_name)
|
|
119
|
+
if rule.value_type == "number":
|
|
120
|
+
return parse_number(value, field_name=field_name, strict_binary64=strict_numbers)
|
|
121
|
+
if rule.value_type == "boolean":
|
|
122
|
+
return parse_boolean(value, field_name=field_name)
|
|
123
|
+
if rule.value_type == "json":
|
|
124
|
+
if isinstance(value, str):
|
|
125
|
+
value = loads_strict_json(
|
|
126
|
+
value,
|
|
127
|
+
context=f"{field_name} JSON text",
|
|
128
|
+
max_depth=max_json_depth,
|
|
129
|
+
strict_numbers=strict_numbers,
|
|
130
|
+
)
|
|
131
|
+
return _canonical_mapped(value, field_name=field_name)
|
|
132
|
+
raise DataIOMappingError(f"Unsupported normalization type {rule.value_type!r}.")
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def apply_rules(
|
|
136
|
+
source: Any,
|
|
137
|
+
rules: Sequence[MappingRule],
|
|
138
|
+
*,
|
|
139
|
+
strict_numbers: bool,
|
|
140
|
+
max_json_depth: int,
|
|
141
|
+
row_number: int | None = None,
|
|
142
|
+
) -> dict[str, Any]:
|
|
143
|
+
target: dict[str, Any] = {}
|
|
144
|
+
for rule in rules:
|
|
145
|
+
if rule.has_constant:
|
|
146
|
+
raw_value = deep_thaw_json(rule.constant)
|
|
147
|
+
else:
|
|
148
|
+
if rule.source_path is None:
|
|
149
|
+
raise DataIOMappingError("Mapping rule without a constant requires source_path.")
|
|
150
|
+
raw_value = get_path(source, rule.source_path)
|
|
151
|
+
if raw_value is _MISSING:
|
|
152
|
+
if rule.has_default:
|
|
153
|
+
raw_value = deep_thaw_json(rule.default)
|
|
154
|
+
elif rule.required:
|
|
155
|
+
suffix = "" if row_number is None else f" at row {row_number}"
|
|
156
|
+
raise DataIOMappingError(
|
|
157
|
+
f"Required source field {rule.source_path!r} is missing{suffix}."
|
|
158
|
+
)
|
|
159
|
+
else:
|
|
160
|
+
raw_value = None
|
|
161
|
+
suffix = "" if row_number is None else f" at row {row_number}"
|
|
162
|
+
normalized = normalize_value(
|
|
163
|
+
raw_value,
|
|
164
|
+
rule,
|
|
165
|
+
field_name=f"Field {rule.source_path or rule.target_path!r}{suffix}",
|
|
166
|
+
strict_numbers=strict_numbers,
|
|
167
|
+
max_json_depth=max_json_depth,
|
|
168
|
+
)
|
|
169
|
+
set_path(target, rule.target_path, normalized)
|
|
170
|
+
return target
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def apply_object_mapping(
|
|
174
|
+
source: Any,
|
|
175
|
+
config: ObjectMapping,
|
|
176
|
+
*,
|
|
177
|
+
strict_numbers: bool,
|
|
178
|
+
max_json_depth: int,
|
|
179
|
+
) -> Any:
|
|
180
|
+
if config.identity:
|
|
181
|
+
return _canonical_mapped(source, field_name="Identity-mapped source")
|
|
182
|
+
target = _canonical_mapped(deep_thaw_json(config.constants), field_name="Object mapping constants")
|
|
183
|
+
if not isinstance(target, dict):
|
|
184
|
+
raise DataIOMappingError("Object mapping constants must form an object.")
|
|
185
|
+
mapped = apply_rules(
|
|
186
|
+
source,
|
|
187
|
+
config.rules,
|
|
188
|
+
strict_numbers=strict_numbers,
|
|
189
|
+
max_json_depth=max_json_depth,
|
|
190
|
+
)
|
|
191
|
+
for key, value in mapped.items():
|
|
192
|
+
if key in target:
|
|
193
|
+
raise DataIOMappingError(f"Object mapping would overwrite constant key {key!r}.")
|
|
194
|
+
target[key] = value
|
|
195
|
+
return target
|