parrot-codec 0.1.0__tar.gz
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.
- parrot_codec-0.1.0/Cargo.lock +227 -0
- parrot_codec-0.1.0/Cargo.toml +22 -0
- parrot_codec-0.1.0/PKG-INFO +7 -0
- parrot_codec-0.1.0/pyproject.toml +18 -0
- parrot_codec-0.1.0/src/lib.rs +300 -0
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
# This file is automatically @generated by Cargo.
|
|
2
|
+
# It is not intended for manual editing.
|
|
3
|
+
version = 4
|
|
4
|
+
|
|
5
|
+
[[package]]
|
|
6
|
+
name = "equivalent"
|
|
7
|
+
version = "1.0.2"
|
|
8
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
9
|
+
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
|
|
10
|
+
|
|
11
|
+
[[package]]
|
|
12
|
+
name = "hashbrown"
|
|
13
|
+
version = "0.17.1"
|
|
14
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
15
|
+
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
|
|
16
|
+
|
|
17
|
+
[[package]]
|
|
18
|
+
name = "heck"
|
|
19
|
+
version = "0.5.0"
|
|
20
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
21
|
+
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
|
22
|
+
|
|
23
|
+
[[package]]
|
|
24
|
+
name = "indexmap"
|
|
25
|
+
version = "2.14.0"
|
|
26
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
27
|
+
checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
|
|
28
|
+
dependencies = [
|
|
29
|
+
"equivalent",
|
|
30
|
+
"hashbrown",
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
[[package]]
|
|
34
|
+
name = "itoa"
|
|
35
|
+
version = "1.0.18"
|
|
36
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
37
|
+
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
|
38
|
+
|
|
39
|
+
[[package]]
|
|
40
|
+
name = "libc"
|
|
41
|
+
version = "0.2.189"
|
|
42
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
43
|
+
checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
|
|
44
|
+
|
|
45
|
+
[[package]]
|
|
46
|
+
name = "memchr"
|
|
47
|
+
version = "2.8.3"
|
|
48
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
49
|
+
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
|
50
|
+
|
|
51
|
+
[[package]]
|
|
52
|
+
name = "once_cell"
|
|
53
|
+
version = "1.21.4"
|
|
54
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
55
|
+
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
|
56
|
+
|
|
57
|
+
[[package]]
|
|
58
|
+
name = "parrot_codec"
|
|
59
|
+
version = "0.1.0"
|
|
60
|
+
dependencies = [
|
|
61
|
+
"pyo3",
|
|
62
|
+
"serde_json",
|
|
63
|
+
]
|
|
64
|
+
|
|
65
|
+
[[package]]
|
|
66
|
+
name = "portable-atomic"
|
|
67
|
+
version = "1.14.0"
|
|
68
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
69
|
+
checksum = "3d20d5497ef88037a52ff98267d066e7f11fcc5e99bbfbd58a42336193aacec3"
|
|
70
|
+
|
|
71
|
+
[[package]]
|
|
72
|
+
name = "proc-macro2"
|
|
73
|
+
version = "1.0.107"
|
|
74
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
75
|
+
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
|
|
76
|
+
dependencies = [
|
|
77
|
+
"unicode-ident",
|
|
78
|
+
]
|
|
79
|
+
|
|
80
|
+
[[package]]
|
|
81
|
+
name = "pyo3"
|
|
82
|
+
version = "0.29.0"
|
|
83
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
84
|
+
checksum = "cd274650b21d4bfc26a0a47587962c1edb425f69287324355cd040c3ea66071c"
|
|
85
|
+
dependencies = [
|
|
86
|
+
"libc",
|
|
87
|
+
"once_cell",
|
|
88
|
+
"portable-atomic",
|
|
89
|
+
"pyo3-build-config",
|
|
90
|
+
"pyo3-ffi",
|
|
91
|
+
"pyo3-macros",
|
|
92
|
+
]
|
|
93
|
+
|
|
94
|
+
[[package]]
|
|
95
|
+
name = "pyo3-build-config"
|
|
96
|
+
version = "0.29.0"
|
|
97
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
98
|
+
checksum = "c5e2a7d2f0d013342f295c048ad19237add5154a55b1c5a254c0ec93d4109078"
|
|
99
|
+
dependencies = [
|
|
100
|
+
"target-lexicon",
|
|
101
|
+
]
|
|
102
|
+
|
|
103
|
+
[[package]]
|
|
104
|
+
name = "pyo3-ffi"
|
|
105
|
+
version = "0.29.0"
|
|
106
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
107
|
+
checksum = "ca85c467da1bbc8d866eea5deff9cf29ea5f7785054a17da36e65bda9c05845b"
|
|
108
|
+
dependencies = [
|
|
109
|
+
"libc",
|
|
110
|
+
"pyo3-build-config",
|
|
111
|
+
]
|
|
112
|
+
|
|
113
|
+
[[package]]
|
|
114
|
+
name = "pyo3-macros"
|
|
115
|
+
version = "0.29.0"
|
|
116
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
117
|
+
checksum = "9ac53762fd065daa3194dd09337a38bd793a188100fd1a9304c4ab312d901771"
|
|
118
|
+
dependencies = [
|
|
119
|
+
"proc-macro2",
|
|
120
|
+
"pyo3-macros-backend",
|
|
121
|
+
"quote",
|
|
122
|
+
"syn 2.0.119",
|
|
123
|
+
]
|
|
124
|
+
|
|
125
|
+
[[package]]
|
|
126
|
+
name = "pyo3-macros-backend"
|
|
127
|
+
version = "0.29.0"
|
|
128
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
129
|
+
checksum = "4ca3a1557399783172dc5bf39cfca835157732532cba56b71d2292161e53b362"
|
|
130
|
+
dependencies = [
|
|
131
|
+
"heck",
|
|
132
|
+
"proc-macro2",
|
|
133
|
+
"quote",
|
|
134
|
+
"syn 2.0.119",
|
|
135
|
+
]
|
|
136
|
+
|
|
137
|
+
[[package]]
|
|
138
|
+
name = "quote"
|
|
139
|
+
version = "1.0.47"
|
|
140
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
141
|
+
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
|
|
142
|
+
dependencies = [
|
|
143
|
+
"proc-macro2",
|
|
144
|
+
]
|
|
145
|
+
|
|
146
|
+
[[package]]
|
|
147
|
+
name = "serde"
|
|
148
|
+
version = "1.0.229"
|
|
149
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
150
|
+
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
|
|
151
|
+
dependencies = [
|
|
152
|
+
"serde_core",
|
|
153
|
+
]
|
|
154
|
+
|
|
155
|
+
[[package]]
|
|
156
|
+
name = "serde_core"
|
|
157
|
+
version = "1.0.229"
|
|
158
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
159
|
+
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
|
|
160
|
+
dependencies = [
|
|
161
|
+
"serde_derive",
|
|
162
|
+
]
|
|
163
|
+
|
|
164
|
+
[[package]]
|
|
165
|
+
name = "serde_derive"
|
|
166
|
+
version = "1.0.229"
|
|
167
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
168
|
+
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
|
|
169
|
+
dependencies = [
|
|
170
|
+
"proc-macro2",
|
|
171
|
+
"quote",
|
|
172
|
+
"syn 3.0.3",
|
|
173
|
+
]
|
|
174
|
+
|
|
175
|
+
[[package]]
|
|
176
|
+
name = "serde_json"
|
|
177
|
+
version = "1.0.151"
|
|
178
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
179
|
+
checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
|
|
180
|
+
dependencies = [
|
|
181
|
+
"indexmap",
|
|
182
|
+
"itoa",
|
|
183
|
+
"memchr",
|
|
184
|
+
"serde",
|
|
185
|
+
"serde_core",
|
|
186
|
+
"zmij",
|
|
187
|
+
]
|
|
188
|
+
|
|
189
|
+
[[package]]
|
|
190
|
+
name = "syn"
|
|
191
|
+
version = "2.0.119"
|
|
192
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
193
|
+
checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
|
|
194
|
+
dependencies = [
|
|
195
|
+
"proc-macro2",
|
|
196
|
+
"quote",
|
|
197
|
+
"unicode-ident",
|
|
198
|
+
]
|
|
199
|
+
|
|
200
|
+
[[package]]
|
|
201
|
+
name = "syn"
|
|
202
|
+
version = "3.0.3"
|
|
203
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
204
|
+
checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3"
|
|
205
|
+
dependencies = [
|
|
206
|
+
"proc-macro2",
|
|
207
|
+
"quote",
|
|
208
|
+
"unicode-ident",
|
|
209
|
+
]
|
|
210
|
+
|
|
211
|
+
[[package]]
|
|
212
|
+
name = "target-lexicon"
|
|
213
|
+
version = "0.13.5"
|
|
214
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
215
|
+
checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca"
|
|
216
|
+
|
|
217
|
+
[[package]]
|
|
218
|
+
name = "unicode-ident"
|
|
219
|
+
version = "1.0.24"
|
|
220
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
221
|
+
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
|
222
|
+
|
|
223
|
+
[[package]]
|
|
224
|
+
name = "zmij"
|
|
225
|
+
version = "1.0.23"
|
|
226
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
227
|
+
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
[package]
|
|
2
|
+
name = "parrot_codec"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
edition = "2021"
|
|
5
|
+
description = "Optional Rust columnar-transform extension for parrot.tools.compression (FEAT-380)"
|
|
6
|
+
|
|
7
|
+
[lib]
|
|
8
|
+
name = "parrot_codec"
|
|
9
|
+
crate-type = ["cdylib"]
|
|
10
|
+
|
|
11
|
+
[dependencies]
|
|
12
|
+
pyo3 = { version = "0.29", features = ["extension-module"] }
|
|
13
|
+
# `preserve_order` keeps object-key insertion order matching the JSON text
|
|
14
|
+
# order (the Python side's dict order) — required for the "first-seen
|
|
15
|
+
# order across rows" determinism guarantee (G4); without it serde_json's
|
|
16
|
+
# default Map is a sorted BTreeMap, which would silently reorder columns.
|
|
17
|
+
serde_json = { version = "1.0", features = ["preserve_order"] }
|
|
18
|
+
|
|
19
|
+
[profile.release]
|
|
20
|
+
opt-level = 3
|
|
21
|
+
lto = true
|
|
22
|
+
codegen-units = 1
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: parrot_codec
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Classifier: Programming Language :: Rust
|
|
5
|
+
Classifier: Programming Language :: Python :: Implementation :: CPython
|
|
6
|
+
Summary: Optional Rust columnar-transform extension for parrot.tools.compression (FEAT-380)
|
|
7
|
+
Requires-Python: >=3.8
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["maturin>=1.0,<2.0"]
|
|
3
|
+
build-backend = "maturin"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "parrot_codec"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Optional Rust columnar-transform extension for parrot.tools.compression (FEAT-380)"
|
|
9
|
+
requires-python = ">=3.8"
|
|
10
|
+
classifiers = [
|
|
11
|
+
"Programming Language :: Rust",
|
|
12
|
+
"Programming Language :: Python :: Implementation :: CPython",
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
[tool.maturin]
|
|
16
|
+
module-name = "parrot_codec"
|
|
17
|
+
bindings = "pyo3"
|
|
18
|
+
features = ["pyo3/extension-module"]
|
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
//! File: codec-rs/src/lib.rs
|
|
2
|
+
//!
|
|
3
|
+
//! Optional Rust acceleration for the `columnar` compression codec
|
|
4
|
+
//! (FEAT-380, TASK-1955). A single FFI crossing: JSON bytes/str in ->
|
|
5
|
+
//! parse -> transform -> JSON bytes out, with the transform itself run
|
|
6
|
+
//! under `py.allow_threads()` so the GIL is released for the duration.
|
|
7
|
+
//!
|
|
8
|
+
//! This module implements ONLY the core columnarization algorithm — the
|
|
9
|
+
//! exact counterpart of `ColumnarCodec._columnarize()` in
|
|
10
|
+
//! `parrot/tools/compression/codecs/columnar.py` (the Python
|
|
11
|
+
//! implementation is the executable specification this must match
|
|
12
|
+
//! byte-for-byte). All of the *outer* concerns (min_rows / heterogeneity /
|
|
13
|
+
//! nested-value guards, QueryResult unwrapping, size measurement,
|
|
14
|
+
//! `CompressionOutcome` construction) stay in Python — by the time this
|
|
15
|
+
//! function is called, the Python side has already confirmed the input is
|
|
16
|
+
//! eligible for columnarization.
|
|
17
|
+
use std::panic::{catch_unwind, AssertUnwindSafe};
|
|
18
|
+
|
|
19
|
+
use pyo3::exceptions::PyRuntimeError;
|
|
20
|
+
use pyo3::prelude::*;
|
|
21
|
+
use serde_json::{Map, Value};
|
|
22
|
+
|
|
23
|
+
/// Python-`==`-compatible equality for `serde_json::Value`.
|
|
24
|
+
///
|
|
25
|
+
/// `serde_json::Value`'s derived `PartialEq` treats `Number(1)` (parsed
|
|
26
|
+
/// from a JSON integer literal) and `Number(1.0)` (parsed from a JSON
|
|
27
|
+
/// float literal) as UNEQUAL, whereas Python's `==` treats `1 == 1.0` as
|
|
28
|
+
/// `True`. Left unfixed, a numerically-constant column mixing int/float
|
|
29
|
+
/// literals across rows (e.g. `1` in row 0, `1.0` in row 1) would factor
|
|
30
|
+
/// out as a constant in the Python reference implementation but NOT here
|
|
31
|
+
/// — a byte-for-byte parity break (code-review fix, TASK-1955's parity
|
|
32
|
+
/// contract). Compare as `f64` whenever both sides parse as numbers;
|
|
33
|
+
/// fall back to `==` otherwise.
|
|
34
|
+
fn values_equal_like_python(a: &Value, b: &Value) -> bool {
|
|
35
|
+
match (a, b) {
|
|
36
|
+
(Value::Number(x), Value::Number(y)) => match (x.as_f64(), y.as_f64()) {
|
|
37
|
+
(Some(fx), Some(fy)) => fx == fy,
|
|
38
|
+
_ => x == y,
|
|
39
|
+
},
|
|
40
|
+
_ => a == b,
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/// Split row-oriented JSON (`[{...}, {...}, ...]`) into the columnar
|
|
45
|
+
/// `{"columns": [...], "rows": [[...], ...], "constants": {...}}` form.
|
|
46
|
+
///
|
|
47
|
+
/// Column order is derived from the data (first-seen order across rows,
|
|
48
|
+
/// in each row's own key order) — never sorted, matching the Python
|
|
49
|
+
/// reference implementation's determinism guarantee (G4).
|
|
50
|
+
///
|
|
51
|
+
/// `min_rows` is accepted for parity with the Python codec's signature,
|
|
52
|
+
/// but the min-rows guard is evaluated by the PYTHON caller before this
|
|
53
|
+
/// function is ever invoked; it is not re-checked here (harmless no-op
|
|
54
|
+
/// when the input already satisfies it).
|
|
55
|
+
fn columnarize_json(payload: &[u8], _min_rows: usize) -> Result<Vec<u8>, String> {
|
|
56
|
+
let rows: Vec<Map<String, Value>> = serde_json::from_slice::<Vec<Value>>(payload)
|
|
57
|
+
.map_err(|e| format!("invalid row-oriented JSON: {e}"))?
|
|
58
|
+
.into_iter()
|
|
59
|
+
.map(|v| match v {
|
|
60
|
+
Value::Object(m) => Ok(m),
|
|
61
|
+
other => Err(format!("expected a JSON object row, got: {other}")),
|
|
62
|
+
})
|
|
63
|
+
.collect::<Result<Vec<_>, String>>()?;
|
|
64
|
+
|
|
65
|
+
// -- column extraction: first-seen order across rows ------------------
|
|
66
|
+
let mut columns: Vec<String> = Vec::new();
|
|
67
|
+
{
|
|
68
|
+
let mut seen = std::collections::HashSet::new();
|
|
69
|
+
for row in &rows {
|
|
70
|
+
for key in row.keys() {
|
|
71
|
+
if seen.insert(key.clone()) {
|
|
72
|
+
columns.push(key.clone());
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// -- null-column elision: a column that is null/absent in EVERY row --
|
|
79
|
+
let null_cols: Vec<String> = columns
|
|
80
|
+
.iter()
|
|
81
|
+
.filter(|c| {
|
|
82
|
+
rows.iter()
|
|
83
|
+
.all(|row| matches!(row.get(*c), None | Some(Value::Null)))
|
|
84
|
+
})
|
|
85
|
+
.cloned()
|
|
86
|
+
.collect();
|
|
87
|
+
columns.retain(|c| !null_cols.contains(c));
|
|
88
|
+
|
|
89
|
+
// -- constant-column factoring: only when there is more than one row --
|
|
90
|
+
let mut constants: Map<String, Value> = Map::new();
|
|
91
|
+
if rows.len() > 1 {
|
|
92
|
+
let mut factored: Vec<String> = Vec::new();
|
|
93
|
+
for col in columns.iter() {
|
|
94
|
+
let first = rows[0].get(col).cloned().unwrap_or(Value::Null);
|
|
95
|
+
let all_equal = rows.iter().all(|row| {
|
|
96
|
+
values_equal_like_python(&row.get(col).cloned().unwrap_or(Value::Null), &first)
|
|
97
|
+
});
|
|
98
|
+
if all_equal {
|
|
99
|
+
constants.insert(col.clone(), first);
|
|
100
|
+
factored.push(col.clone());
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
columns.retain(|c| !factored.contains(c));
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// -- build positional rows, aligned to the final `columns` list -------
|
|
107
|
+
let out_rows: Vec<Vec<Value>> = rows
|
|
108
|
+
.iter()
|
|
109
|
+
.map(|row| {
|
|
110
|
+
columns
|
|
111
|
+
.iter()
|
|
112
|
+
.map(|c| row.get(c).cloned().unwrap_or(Value::Null))
|
|
113
|
+
.collect()
|
|
114
|
+
})
|
|
115
|
+
.collect();
|
|
116
|
+
|
|
117
|
+
let mut result = Map::new();
|
|
118
|
+
result.insert(
|
|
119
|
+
"columns".to_string(),
|
|
120
|
+
Value::Array(columns.into_iter().map(Value::String).collect()),
|
|
121
|
+
);
|
|
122
|
+
result.insert(
|
|
123
|
+
"rows".to_string(),
|
|
124
|
+
Value::Array(out_rows.into_iter().map(Value::Array).collect()),
|
|
125
|
+
);
|
|
126
|
+
result.insert("constants".to_string(), Value::Object(constants));
|
|
127
|
+
|
|
128
|
+
serde_json::to_vec(&Value::Object(result)).map_err(|e| format!("serialization error: {e}"))
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/// Columnarize a row-oriented JSON payload (bytes or str), GIL released
|
|
132
|
+
/// for the duration of the transform.
|
|
133
|
+
///
|
|
134
|
+
/// Args:
|
|
135
|
+
/// payload: UTF-8 JSON bytes/str encoding a `list[dict]` of rows.
|
|
136
|
+
/// min_rows: Accepted for parity with the Python codec's signature;
|
|
137
|
+
/// not re-checked here (the Python caller already applied the
|
|
138
|
+
/// guard before calling this function).
|
|
139
|
+
///
|
|
140
|
+
/// Returns:
|
|
141
|
+
/// UTF-8 JSON bytes encoding `{"columns", "rows", "constants"}`.
|
|
142
|
+
///
|
|
143
|
+
/// Panic safety: `columnarize_json` is run inside `catch_unwind` so an
|
|
144
|
+
/// unexpected panic (e.g. an unforeseen indexing bug) surfaces as an
|
|
145
|
+
/// ordinary `PyRuntimeError` instead of PyO3's `PanicException`.
|
|
146
|
+
/// `PanicException` derives from `BaseException`, not `Exception` — it
|
|
147
|
+
/// would silently bypass `columnar.py`'s `except Exception:` guard and
|
|
148
|
+
/// break the "a broken codec must never break a tool call" contract
|
|
149
|
+
/// (code-review fix). This does NOT protect against a stack overflow,
|
|
150
|
+
/// which aborts the process unconditionally in Rust and cannot be caught
|
|
151
|
+
/// by any mechanism — a residual, documented limitation.
|
|
152
|
+
#[pyfunction]
|
|
153
|
+
#[pyo3(signature = (payload, min_rows=20))]
|
|
154
|
+
fn columnarize(py: Python<'_>, payload: &[u8], min_rows: usize) -> PyResult<Vec<u8>> {
|
|
155
|
+
let owned = payload.to_vec();
|
|
156
|
+
// pyo3 0.29 renamed `Python::allow_threads` -> `Python::detach`
|
|
157
|
+
// (verified against this crate's pinned pyo3 version, `Cargo.toml`);
|
|
158
|
+
// functionally identical GIL-release mechanism (spec's own "Pattern
|
|
159
|
+
// to Follow" used the older `allow_threads` name).
|
|
160
|
+
let outcome = py.detach(move || {
|
|
161
|
+
catch_unwind(AssertUnwindSafe(|| columnarize_json(&owned, min_rows)))
|
|
162
|
+
});
|
|
163
|
+
let result = match outcome {
|
|
164
|
+
Ok(inner) => inner,
|
|
165
|
+
Err(panic_payload) => {
|
|
166
|
+
let msg = panic_payload
|
|
167
|
+
.downcast_ref::<&str>()
|
|
168
|
+
.map(|s| s.to_string())
|
|
169
|
+
.or_else(|| panic_payload.downcast_ref::<String>().cloned())
|
|
170
|
+
.unwrap_or_else(|| "unknown panic in columnarize_json".to_string());
|
|
171
|
+
Err(format!("internal panic during columnarization: {msg}"))
|
|
172
|
+
}
|
|
173
|
+
};
|
|
174
|
+
result.map_err(PyRuntimeError::new_err)
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
#[pymodule]
|
|
178
|
+
fn parrot_codec(m: &Bound<'_, PyModule>) -> PyResult<()> {
|
|
179
|
+
m.add_function(wrap_pyfunction!(columnarize, m)?)?;
|
|
180
|
+
Ok(())
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
#[cfg(test)]
|
|
184
|
+
mod tests {
|
|
185
|
+
use super::*;
|
|
186
|
+
|
|
187
|
+
fn run(input: &str) -> Value {
|
|
188
|
+
let out = columnarize_json(input.as_bytes(), 20).expect("transform failed");
|
|
189
|
+
serde_json::from_slice(&out).expect("invalid output JSON")
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
#[test]
|
|
193
|
+
fn column_order_is_first_seen_not_sorted() {
|
|
194
|
+
let out = run(r#"[{"z":1,"a":2},{"z":3,"a":4}]"#);
|
|
195
|
+
assert_eq!(out["columns"], serde_json::json!(["z", "a"]));
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
#[test]
|
|
199
|
+
fn null_columns_are_elided() {
|
|
200
|
+
let out = run(r#"[{"a":1,"b":null},{"a":2,"b":null}]"#);
|
|
201
|
+
assert_eq!(out["columns"], serde_json::json!(["a"]));
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
#[test]
|
|
205
|
+
fn missing_key_counts_as_null_for_elision() {
|
|
206
|
+
// "b" is entirely absent from every row -> same as always-null.
|
|
207
|
+
let out = run(r#"[{"a":1},{"a":2}]"#);
|
|
208
|
+
assert_eq!(out["columns"], serde_json::json!(["a"]));
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
#[test]
|
|
212
|
+
fn constant_columns_are_factored_when_more_than_one_row() {
|
|
213
|
+
let out = run(r#"[{"a":1,"region":"south"},{"a":2,"region":"south"}]"#);
|
|
214
|
+
assert_eq!(out["columns"], serde_json::json!(["a"]));
|
|
215
|
+
assert_eq!(out["constants"], serde_json::json!({"region": "south"}));
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
#[test]
|
|
219
|
+
fn single_row_never_factors_constants() {
|
|
220
|
+
let out = run(r#"[{"a":1,"b":2}]"#);
|
|
221
|
+
assert_eq!(out["constants"], serde_json::json!({}));
|
|
222
|
+
assert_eq!(out["columns"], serde_json::json!(["a", "b"]));
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
#[test]
|
|
226
|
+
fn rows_are_positionally_aligned_with_null_padding_for_missing_keys() {
|
|
227
|
+
let out = run(r#"[{"a":1,"c":3},{"a":2}]"#);
|
|
228
|
+
// "c" appears in row 0 only, and is not all-null (present+non-null
|
|
229
|
+
// in row 0) so it stays a column; row 1 is missing it -> null.
|
|
230
|
+
assert_eq!(out["columns"], serde_json::json!(["a", "c"]));
|
|
231
|
+
assert_eq!(out["rows"], serde_json::json!([[1, 3], [2, Value::Null]]));
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
#[test]
|
|
235
|
+
fn determinism_same_input_same_output() {
|
|
236
|
+
let input = r#"[{"a":1,"b":null,"region":"south"},{"a":2,"b":null,"region":"south"}]"#;
|
|
237
|
+
let first = run(input);
|
|
238
|
+
for _ in 0..25 {
|
|
239
|
+
assert_eq!(run(input), first);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
#[test]
|
|
244
|
+
fn rejects_non_array_input() {
|
|
245
|
+
let err = columnarize_json(br#"{"not": "an array"}"#, 20);
|
|
246
|
+
assert!(err.is_err());
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
#[test]
|
|
250
|
+
fn rejects_non_object_rows() {
|
|
251
|
+
let err = columnarize_json(br#"[1, 2, 3]"#, 20);
|
|
252
|
+
assert!(err.is_err());
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
#[test]
|
|
256
|
+
fn values_equal_like_python_matches_python_semantics() {
|
|
257
|
+
assert!(values_equal_like_python(
|
|
258
|
+
&serde_json::json!(1),
|
|
259
|
+
&serde_json::json!(1.0)
|
|
260
|
+
));
|
|
261
|
+
assert!(!values_equal_like_python(
|
|
262
|
+
&serde_json::json!(1),
|
|
263
|
+
&serde_json::json!(2)
|
|
264
|
+
));
|
|
265
|
+
assert!(values_equal_like_python(
|
|
266
|
+
&serde_json::json!("a"),
|
|
267
|
+
&serde_json::json!("a")
|
|
268
|
+
));
|
|
269
|
+
assert!(!values_equal_like_python(
|
|
270
|
+
&serde_json::json!("a"),
|
|
271
|
+
&serde_json::json!("b")
|
|
272
|
+
));
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
#[test]
|
|
276
|
+
fn int_and_float_constants_are_treated_as_equal_like_python() {
|
|
277
|
+
// Mirrors Python's `1 == 1.0` (`True`) — a column holding `1` in
|
|
278
|
+
// one row and `1.0` in another must still factor out as a
|
|
279
|
+
// constant, matching `ColumnarCodec._columnarize()`'s Python `==`
|
|
280
|
+
// semantics (code-review fix: previously `Number(1) !=
|
|
281
|
+
// Number(1.0)` under serde_json's derived `PartialEq` broke this
|
|
282
|
+
// parity contract).
|
|
283
|
+
let out = run(r#"[{"a":1,"n":1},{"a":2,"n":1.0}]"#);
|
|
284
|
+
assert_eq!(out["columns"], serde_json::json!(["a"]));
|
|
285
|
+
assert_eq!(out["constants"]["n"], serde_json::json!(1));
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
#[test]
|
|
289
|
+
fn catch_unwind_around_transform_captures_panics() {
|
|
290
|
+
// Not a test of the `columnarize()` pyfunction itself (that needs
|
|
291
|
+
// a `Python` GIL token) — verifies the SAME `catch_unwind` +
|
|
292
|
+
// `AssertUnwindSafe` pattern used inside it correctly converts a
|
|
293
|
+
// panic into an `Err` instead of unwinding past the FFI boundary
|
|
294
|
+
// (code-review fix).
|
|
295
|
+
let outcome = catch_unwind(AssertUnwindSafe(|| -> Result<(), String> {
|
|
296
|
+
panic!("synthetic panic for test coverage");
|
|
297
|
+
}));
|
|
298
|
+
assert!(outcome.is_err());
|
|
299
|
+
}
|
|
300
|
+
}
|