#[cfg(feature = "python")]
use pyo3::exceptions::{PyRuntimeError, PyValueError};
#[cfg(feature = "python")]
use pyo3::prelude::*;
#[cfg(feature = "python")]
use pyo3::sync::PyOnceLock;
#[cfg(feature = "python")]
use pyo3::types::{PyBytes, PyDict, PyList, PyModule, PyString};
#[cfg(feature = "python")]
use std::mem;
#[cfg(feature = "python")]
use std::sync::Mutex;
#[cfg(feature = "python")]
use indexmap::{IndexMap, IndexSet};
#[cfg(feature = "python")]
use crate::{JSONTools, JsonOutput};
#[cfg(feature = "python")]
pyo3::create_exception!(
json_tools_rs,
JsonToolsError,
pyo3::exceptions::PyException,
"Python exception for JSON Tools operations"
);
#[cfg(feature = "python")]
#[inline]
fn lock_config(mutex: &Mutex<JSONTools>) -> PyResult<std::sync::MutexGuard<'_, JSONTools>> {
mutex
.lock()
.map_err(|e| PyRuntimeError::new_err(format!("internal config lock poisoned: {e}")))
}
#[cfg(feature = "python")]
struct JsonCallables {
dumps: Py<PyAny>,
dumps_kwargs: Py<PyDict>,
stdlib_dumps: Py<PyAny>,
loads: Py<PyAny>,
stdlib_loads: Py<PyAny>,
}
#[cfg(feature = "python")]
static JSON_CALLABLES: PyOnceLock<JsonCallables> = PyOnceLock::new();
#[cfg(feature = "python")]
fn json_callables(py: Python<'_>) -> PyResult<&'static JsonCallables> {
JSON_CALLABLES.get_or_try_init(py, || {
let json_mod = py.import("json")?;
let stdlib_dumps = json_mod.getattr("dumps")?.unbind();
let stdlib_loads = json_mod.getattr("loads")?.unbind();
let orjson = py.import("orjson")?;
let kwargs = PyDict::new(py);
kwargs.set_item("option", orjson.getattr("OPT_NON_STR_KEYS")?)?;
Ok(JsonCallables {
dumps: orjson.getattr("dumps")?.unbind(),
dumps_kwargs: kwargs.unbind(),
stdlib_dumps,
loads: orjson.getattr("loads")?.unbind(),
stdlib_loads,
})
})
}
#[cfg(feature = "python")]
#[inline]
fn py_dumps(py: Python<'_>, obj: &Bound<'_, PyAny>) -> PyResult<String> {
let callables = json_callables(py)?;
let result = match callables
.dumps
.bind(py)
.call((obj,), Some(callables.dumps_kwargs.bind(py)))
{
Ok(out) => out,
Err(_) => callables.stdlib_dumps.bind(py).call1((obj,))?,
};
if let Ok(bytes) = result.cast::<PyBytes>() {
std::str::from_utf8(bytes.as_bytes())
.map(str::to_owned)
.map_err(|e| {
PyValueError::new_err(format!("JSON serializer produced non-UTF-8 output: {e}"))
})
} else {
result.extract::<String>()
}
}
#[cfg(feature = "python")]
#[inline]
fn may_contain_big_int(s: &[u8]) -> bool {
const RUN: usize = 19;
let n = s.len();
let mut i = 0;
while i < n {
if s[i].is_ascii_digit() {
let mut start = i;
while start > 0 && s[start - 1].is_ascii_digit() {
start -= 1;
}
let mut end = i + 1;
while end < n && s[end].is_ascii_digit() {
end += 1;
}
if end - start >= RUN {
return true;
}
i = end + RUN;
} else {
i += RUN;
}
}
false
}
#[cfg(feature = "python")]
#[inline]
fn py_loads<'py>(py: Python<'py>, json_str: &str) -> PyResult<Bound<'py, PyAny>> {
let callables = json_callables(py)?;
if !may_contain_big_int(json_str.as_bytes()) {
if let Ok(obj) = callables.loads.bind(py).call1((json_str,)) {
return Ok(obj);
}
}
callables.stdlib_loads.bind(py).call1((json_str,))
}
#[cfg(feature = "python")]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum DataFrameType {
Pandas,
Polars,
PyArrow,
PySpark,
Generic,
}
#[cfg(feature = "python")]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum SeriesType {
Pandas,
Polars,
PyArrow,
PySpark,
Generic,
}
#[cfg(feature = "python")]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum DataStructureType {
DataFrame(DataFrameType),
Series(SeriesType),
}
#[cfg(feature = "python")]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum NormaliseTarget {
Pandas,
Polars,
PyArrow,
PySpark,
}
#[cfg(feature = "python")]
impl NormaliseTarget {
fn parse(s: &str) -> PyResult<Self> {
match s {
"pandas" => Ok(Self::Pandas),
"polars" => Ok(Self::Polars),
"pyarrow" => Ok(Self::PyArrow),
"pyspark" => Ok(Self::PySpark),
other => Err(JsonToolsError::new_err(format!(
"Unknown normalise target {other:?}; expected one of \"pandas\", \"polars\", \"pyarrow\", \"pyspark\""
))),
}
}
fn name(self) -> &'static str {
match self {
Self::Pandas => "pandas",
Self::Polars => "polars",
Self::PyArrow => "pyarrow",
Self::PySpark => "pyspark",
}
}
fn from_dataframe_type(t: DataFrameType) -> Option<Self> {
match t {
DataFrameType::Pandas => Some(Self::Pandas),
DataFrameType::Polars => Some(Self::Polars),
DataFrameType::PyArrow => Some(Self::PyArrow),
DataFrameType::PySpark => Some(Self::PySpark),
DataFrameType::Generic => None,
}
}
fn from_series_type(t: SeriesType) -> Option<Self> {
match t {
SeriesType::Pandas => Some(Self::Pandas),
SeriesType::Polars => Some(Self::Polars),
SeriesType::PyArrow => Some(Self::PyArrow),
SeriesType::PySpark => Some(Self::PySpark),
SeriesType::Generic => None,
}
}
}
#[cfg(feature = "python")]
#[pyclass(name = "JsonOutput", module = "json_tools_rs", frozen, from_py_object)]
#[derive(Debug, Clone)]
pub struct PyJsonOutput {
inner: JsonOutput,
}
#[cfg(feature = "python")]
#[pymethods]
impl PyJsonOutput {
#[getter]
fn is_single(&self) -> bool {
matches!(self.inner, JsonOutput::Single(_))
}
#[getter]
fn is_multiple(&self) -> bool {
matches!(self.inner, JsonOutput::Multiple(_))
}
fn get_single(&self) -> PyResult<String> {
match &self.inner {
JsonOutput::Single(result) => Ok(result.clone()),
JsonOutput::Multiple(_) => Err(PyValueError::new_err(
"Result contains multiple JSON strings, use get_multiple() instead",
)),
}
}
fn get_multiple(&self) -> PyResult<Vec<String>> {
match &self.inner {
JsonOutput::Single(_) => Err(PyValueError::new_err(
"Result contains single JSON string, use get_single() instead",
)),
JsonOutput::Multiple(results) => Ok(results.clone()),
}
}
fn to_python(&self, py: Python) -> PyResult<Py<PyAny>> {
match &self.inner {
JsonOutput::Single(result) => Ok(result.into_pyobject(py)?.into_any().unbind()),
JsonOutput::Multiple(results) => Ok(results.into_pyobject(py)?.into_any().unbind()),
}
}
fn __repr__(&self) -> String {
match &self.inner {
JsonOutput::Single(result) => format!("JsonOutput.Single('{}')", result),
JsonOutput::Multiple(results) => format!("JsonOutput.Multiple({:?})", results),
}
}
fn __str__(&self) -> String {
match &self.inner {
JsonOutput::Single(result) => result.clone(),
JsonOutput::Multiple(results) => format!("{:?}", results),
}
}
}
#[cfg(feature = "python")]
impl From<JsonOutput> for PyJsonOutput {
fn from(output: JsonOutput) -> Self {
PyJsonOutput { inner: output }
}
}
#[cfg(feature = "python")]
impl PyJsonOutput {
pub fn from_rust_output(output: JsonOutput) -> Self {
PyJsonOutput { inner: output }
}
}
#[cfg(feature = "python")]
#[pyclass(name = "JSONTools", module = "json_tools_rs")]
pub struct PyJSONTools {
inner: Mutex<JSONTools>,
}
#[cfg(feature = "python")]
impl Default for PyJSONTools {
fn default() -> Self {
Self::new()
}
}
#[cfg(feature = "python")]
fn detect_data_structure(obj: &Bound<'_, PyAny>) -> PyResult<Option<DataStructureType>> {
if let Some(df_type) = detect_dataframe_type(obj)? {
return Ok(Some(DataStructureType::DataFrame(df_type)));
}
if let Some(series_type) = detect_series_type(obj)? {
return Ok(Some(DataStructureType::Series(series_type)));
}
Ok(None)
}
#[cfg(feature = "python")]
fn detect_dataframe_type(obj: &Bound<'_, PyAny>) -> PyResult<Option<DataFrameType>> {
let module = obj
.getattr("__class__")?
.getattr("__module__")?
.extract::<String>()
.unwrap_or_default();
let class_name = obj
.getattr("__class__")?
.getattr("__name__")?
.extract::<String>()
.unwrap_or_default();
if module.starts_with("pyarrow") && class_name == "Table" {
return Ok(Some(DataFrameType::PyArrow));
}
let has_to_dict = obj.hasattr("to_dict")?;
let has_columns = obj.hasattr("columns")?;
if !has_to_dict && !has_columns {
return Ok(None); }
if class_name != "DataFrame" {
return Ok(None);
}
if module.starts_with("pandas") {
Ok(Some(DataFrameType::Pandas))
} else if module.starts_with("polars") {
Ok(Some(DataFrameType::Polars))
} else if module.contains("pyspark.sql") {
Ok(Some(DataFrameType::PySpark))
} else if has_to_dict || obj.hasattr("to_json")? {
Ok(Some(DataFrameType::Generic))
} else {
Ok(None)
}
}
#[cfg(feature = "python")]
fn detect_series_type(obj: &Bound<'_, PyAny>) -> PyResult<Option<SeriesType>> {
let module = obj
.getattr("__class__")?
.getattr("__module__")?
.extract::<String>()
.unwrap_or_default();
let class_name = obj
.getattr("__class__")?
.getattr("__name__")?
.extract::<String>()
.unwrap_or_default();
if module.starts_with("pyarrow")
&& (class_name.contains("Array") || obj.hasattr("to_pylist")?)
{
return Ok(Some(SeriesType::PyArrow));
}
let has_to_list = obj.hasattr("to_list")? || obj.hasattr("tolist")?;
let has_dtype = obj.hasattr("dtype")?;
if !has_to_list && !has_dtype {
return Ok(None); }
if class_name != "Series" {
return Ok(None);
}
if module.starts_with("pandas") {
Ok(Some(SeriesType::Pandas))
} else if module.starts_with("polars") {
Ok(Some(SeriesType::Polars))
} else if module.contains("pyspark") {
Ok(Some(SeriesType::PySpark))
} else if has_to_list {
Ok(Some(SeriesType::Generic))
} else {
Ok(None)
}
}
#[cfg(feature = "python")]
fn split_ndjson(text: &str) -> Vec<String> {
text.lines()
.filter(|line| !line.trim().is_empty())
.map(str::to_string)
.collect()
}
#[cfg(feature = "python")]
fn dataframe_to_json_strings(
df: &Bound<'_, PyAny>,
df_type: DataFrameType,
) -> PyResult<Vec<String>> {
let py = df.py();
match df_type {
DataFrameType::Pandas => {
let kwargs = pyo3::types::PyDict::new(py);
kwargs.set_item("orient", "records")?;
kwargs.set_item("lines", true)?;
let text: String = df.call_method("to_json", (), Some(&kwargs))?.extract()?;
Ok(split_ndjson(&text))
}
DataFrameType::Polars => {
let text: String = df.call_method0("write_ndjson")?.extract()?;
Ok(split_ndjson(&text))
}
DataFrameType::PyArrow => {
let records = df.call_method0("to_pylist")?;
let list = records.cast::<pyo3::types::PyList>()?;
pylist_to_json_strings(py, list)
}
DataFrameType::PySpark => {
let pandas_df = df.call_method0("toPandas")?;
dataframe_to_json_strings(&pandas_df, DataFrameType::Pandas)
}
DataFrameType::Generic => {
if df.hasattr("to_json")? {
let kwargs = pyo3::types::PyDict::new(py);
kwargs.set_item("orient", "records")?;
kwargs.set_item("lines", true)?;
if let Ok(result) = df.call_method("to_json", (), Some(&kwargs)) {
if let Ok(text) = result.extract::<String>() {
return Ok(split_ndjson(&text));
}
}
}
if df.hasattr("to_dict")? {
let kwargs = pyo3::types::PyDict::new(py);
kwargs.set_item("orient", "records")?;
let records = df.call_method("to_dict", (), Some(&kwargs))?;
let list = records.cast::<pyo3::types::PyList>()?;
pylist_to_json_strings(py, list)
} else {
Err(JsonToolsError::new_err(
"Generic DataFrame must have to_dict() method",
))
}
}
}
}
#[cfg(feature = "python")]
const JSON_COLUMN_DETECTION_SAMPLE_SIZE: usize = 20;
#[cfg(feature = "python")]
fn detect_json_string_columns(rows: &[String]) -> Vec<String> {
let sample_size = rows.len().min(JSON_COLUMN_DETECTION_SAMPLE_SIZE);
let mut seen: IndexMap<String, usize> = IndexMap::new();
let mut parsed_ok: IndexMap<String, usize> = IndexMap::new();
for row in &rows[..sample_size] {
let Ok(fields) =
serde_json::from_str::<IndexMap<String, &serde_json::value::RawValue>>(row)
else {
continue;
};
for (key, raw) in &fields {
let text = raw.get();
if !text.starts_with('"') {
continue;
}
let Ok(inner) = serde_json::from_str::<String>(text) else {
continue;
};
*seen.entry(key.clone()).or_insert(0) += 1;
let is_object_or_array = serde_json::from_str::<&serde_json::value::RawValue>(&inner)
.is_ok()
&& matches!(inner.as_bytes().first(), Some(b'{') | Some(b'['));
if is_object_or_array {
*parsed_ok.entry(key.clone()).or_insert(0) += 1;
}
}
}
seen.into_iter()
.filter(|(key, count)| parsed_ok.get(key).copied().unwrap_or(0) == *count)
.map(|(key, _)| key)
.collect()
}
#[cfg(feature = "python")]
fn splice_row(
row: &str,
target_keys: &[String],
failure_counts: &mut IndexMap<String, usize>,
) -> Option<String> {
let fields: IndexMap<String, &serde_json::value::RawValue> = serde_json::from_str(row).ok()?;
let mut substitutions: IndexMap<&str, String> = IndexMap::new();
let mut changed = false;
for key in target_keys {
let Some(raw) = fields.get(key.as_str()) else {
continue;
};
let text = raw.get();
if !text.starts_with('"') {
continue; }
let Ok(inner) = serde_json::from_str::<String>(text) else {
continue;
};
match serde_json::from_str::<&serde_json::value::RawValue>(&inner) {
Ok(_) if matches!(inner.as_bytes().first(), Some(b'{') | Some(b'[')) => {
substitutions.insert(key.as_str(), inner);
changed = true;
}
_ => {
*failure_counts.entry(key.clone()).or_insert(0) += 1;
}
}
}
if !changed {
return None;
}
let mut out = String::with_capacity(row.len() + 64);
out.push('{');
for (i, (key, raw)) in fields.iter().enumerate() {
if i > 0 {
out.push(',');
}
out.push_str(&serde_json::to_string(key).ok()?);
out.push(':');
match substitutions.get(key.as_str()) {
Some(inner) => out.push_str(inner),
None => out.push_str(raw.get()),
}
}
out.push('}');
Some(out)
}
#[cfg(feature = "python")]
fn expand_json_string_columns(py: Python<'_>, rows: Vec<String>) -> PyResult<Vec<String>> {
let target_keys = detect_json_string_columns(&rows);
if target_keys.is_empty() {
return Ok(rows);
}
let mut failure_counts: IndexMap<String, usize> = IndexMap::new();
let mut out = Vec::with_capacity(rows.len());
for row in rows {
let spliced = splice_row(&row, &target_keys, &mut failure_counts);
out.push(spliced.unwrap_or(row));
}
if !failure_counts.is_empty() {
let warnings = py.import("warnings")?;
for (key, count) in &failure_counts {
warnings.call_method1(
"warn",
(format!(
"JSON-string column expansion: {count} row(s) in column \"{key}\" looked \
like JSON in a sample but failed to parse and were left as their \
original string value",
),),
)?;
}
}
Ok(out)
}
#[cfg(feature = "python")]
fn pylist_to_json_strings(
py: Python<'_>,
list: &Bound<'_, pyo3::types::PyList>,
) -> PyResult<Vec<String>> {
let mut strings = Vec::with_capacity(list.len());
for item in list.iter() {
let json_str = py_dumps(py, &item)
.map_err(|e| JsonToolsError::new_err(format!("Failed to convert record: {}", e)))?;
strings.push(json_str);
}
Ok(strings)
}
#[cfg(feature = "python")]
fn series_to_list<'py>(
series: &Bound<'py, PyAny>,
series_type: SeriesType,
) -> PyResult<Bound<'py, pyo3::types::PyList>> {
match series_type {
SeriesType::Pandas => {
if series.hasattr("to_list")? {
let list = series.call_method0("to_list")?;
Ok(list.cast::<pyo3::types::PyList>()?.clone())
} else {
let list = series.call_method0("tolist")?;
Ok(list.cast::<pyo3::types::PyList>()?.clone())
}
}
SeriesType::Polars => {
let list = series.call_method0("to_list")?;
Ok(list.cast::<pyo3::types::PyList>()?.clone())
}
SeriesType::PyArrow => {
let list = series.call_method0("to_pylist")?;
Ok(list.cast::<pyo3::types::PyList>()?.clone())
}
SeriesType::PySpark => {
let pandas_series = series.call_method0("toPandas")?;
let list = pandas_series.call_method0("tolist")?;
Ok(list.cast::<pyo3::types::PyList>()?.clone())
}
SeriesType::Generic => {
if series.hasattr("to_list")? {
let list = series.call_method0("to_list")?;
Ok(list.cast::<pyo3::types::PyList>()?.clone())
} else if series.hasattr("tolist")? {
let list = series.call_method0("tolist")?;
Ok(list.cast::<pyo3::types::PyList>()?.clone())
} else {
Err(JsonToolsError::new_err(
"Generic Series must have to_list() or tolist() method",
))
}
}
}
}
#[cfg(feature = "python")]
macro_rules! py_builder_method {
($slf:expr, $tools:ident, $body:expr) => {{
let mut guard = lock_config(&$slf.inner)?;
let $tools = mem::take(&mut *guard);
*guard = $body;
drop(guard);
Ok($slf)
}};
}
#[cfg(feature = "python")]
#[pymethods]
impl PyJSONTools {
#[new]
#[pyo3(text_signature = "()")]
pub fn new() -> Self {
Self {
inner: Mutex::new(JSONTools::new()),
}
}
#[pyo3(text_signature = "($self)")]
#[inline]
pub fn flatten(slf: PyRef<'_, Self>) -> PyResult<PyRef<'_, Self>> {
py_builder_method!(slf, tools, tools.flatten())
}
#[pyo3(text_signature = "($self)")]
#[inline]
pub fn unflatten(slf: PyRef<'_, Self>) -> PyResult<PyRef<'_, Self>> {
py_builder_method!(slf, tools, tools.unflatten())
}
#[pyo3(text_signature = "($self)")]
#[inline]
pub fn normal(slf: PyRef<'_, Self>) -> PyResult<PyRef<'_, Self>> {
py_builder_method!(slf, tools, tools.normal())
}
#[pyo3(text_signature = "($self, separator)")]
#[inline]
pub fn separator(slf: PyRef<'_, Self>, separator: String) -> PyResult<PyRef<'_, Self>> {
if separator.is_empty() {
return Err(PyValueError::new_err("Separator cannot be empty"));
}
py_builder_method!(slf, tools, tools.separator(separator))
}
#[pyo3(text_signature = "($self, value)")]
#[inline]
pub fn lowercase_keys(slf: PyRef<'_, Self>, value: bool) -> PyResult<PyRef<'_, Self>> {
py_builder_method!(slf, tools, tools.lowercase_keys(value))
}
#[pyo3(text_signature = "($self, value)")]
#[inline]
pub fn remove_empty_strings(slf: PyRef<'_, Self>, value: bool) -> PyResult<PyRef<'_, Self>> {
py_builder_method!(slf, tools, tools.remove_empty_strings(value))
}
#[pyo3(text_signature = "($self, value)")]
#[inline]
pub fn remove_nulls(slf: PyRef<'_, Self>, value: bool) -> PyResult<PyRef<'_, Self>> {
py_builder_method!(slf, tools, tools.remove_nulls(value))
}
#[pyo3(text_signature = "($self, value)")]
#[inline]
pub fn remove_empty_objects(slf: PyRef<'_, Self>, value: bool) -> PyResult<PyRef<'_, Self>> {
py_builder_method!(slf, tools, tools.remove_empty_objects(value))
}
#[pyo3(text_signature = "($self, value)")]
#[inline]
pub fn remove_empty_arrays(slf: PyRef<'_, Self>, value: bool) -> PyResult<PyRef<'_, Self>> {
py_builder_method!(slf, tools, tools.remove_empty_arrays(value))
}
#[pyo3(text_signature = "($self, find, replace)")]
#[inline]
pub fn key_replacement(
slf: PyRef<'_, Self>,
find: String,
replace: String,
) -> PyResult<PyRef<'_, Self>> {
py_builder_method!(slf, tools, tools.key_replacement(find, replace))
}
#[pyo3(text_signature = "($self, find, replace)")]
#[inline]
pub fn value_replacement(
slf: PyRef<'_, Self>,
find: String,
replace: String,
) -> PyResult<PyRef<'_, Self>> {
py_builder_method!(slf, tools, tools.value_replacement(find, replace))
}
#[pyo3(text_signature = "($self, pattern)")]
#[inline]
pub fn exclude_key(slf: PyRef<'_, Self>, pattern: String) -> PyResult<PyRef<'_, Self>> {
py_builder_method!(slf, tools, tools.exclude_key(pattern))
}
#[pyo3(text_signature = "($self, pattern)")]
#[inline]
pub fn exclude_value(slf: PyRef<'_, Self>, pattern: String) -> PyResult<PyRef<'_, Self>> {
py_builder_method!(slf, tools, tools.exclude_value(pattern))
}
#[pyo3(text_signature = "($self, value)")]
#[inline]
pub fn handle_key_collision(slf: PyRef<'_, Self>, value: bool) -> PyResult<PyRef<'_, Self>> {
py_builder_method!(slf, tools, tools.handle_key_collision(value))
}
#[pyo3(text_signature = "($self, enable)")]
#[inline]
pub fn auto_convert_types(slf: PyRef<'_, Self>, enable: bool) -> PyResult<PyRef<'_, Self>> {
py_builder_method!(slf, tools, tools.auto_convert_types(enable))
}
#[pyo3(signature = (enable, normalize_to_utc=None, assume_utc_for_naive=None))]
#[pyo3(text_signature = "($self, enable, normalize_to_utc=None, assume_utc_for_naive=None)")]
pub fn convert_dates(
slf: PyRef<'_, Self>,
enable: bool,
normalize_to_utc: Option<bool>,
assume_utc_for_naive: Option<bool>,
) -> PyResult<PyRef<'_, Self>> {
let mut guard = lock_config(&slf.inner)?;
let tools = mem::take(&mut *guard);
let mut cfg = tools.date_conversion().clone();
cfg.enabled = enable;
if let Some(v) = normalize_to_utc {
cfg.normalize_to_utc = v;
}
if let Some(v) = assume_utc_for_naive {
cfg.assume_utc_for_naive = v;
}
*guard = tools.convert_dates_config(cfg);
drop(guard);
Ok(slf)
}
#[pyo3(signature = (enable, extra_tokens=None))]
#[pyo3(text_signature = "($self, enable, extra_tokens=None)")]
pub fn convert_nulls(
slf: PyRef<'_, Self>,
enable: bool,
extra_tokens: Option<Vec<String>>,
) -> PyResult<PyRef<'_, Self>> {
let mut guard = lock_config(&slf.inner)?;
let tools = mem::take(&mut *guard);
let mut cfg = tools.null_conversion().clone();
cfg.enabled = enable;
if let Some(tokens) = extra_tokens {
cfg.extra_tokens = tokens.into();
}
*guard = tools.convert_nulls_config(cfg);
drop(guard);
Ok(slf)
}
#[pyo3(signature = (enable, extra_true_tokens=None, extra_false_tokens=None))]
#[pyo3(text_signature = "($self, enable, extra_true_tokens=None, extra_false_tokens=None)")]
pub fn convert_booleans(
slf: PyRef<'_, Self>,
enable: bool,
extra_true_tokens: Option<Vec<String>>,
extra_false_tokens: Option<Vec<String>>,
) -> PyResult<PyRef<'_, Self>> {
let mut guard = lock_config(&slf.inner)?;
let tools = mem::take(&mut *guard);
let mut cfg = tools.boolean_conversion().clone();
cfg.enabled = enable;
if let Some(tokens) = extra_true_tokens {
cfg.extra_true_tokens = tokens.into();
}
if let Some(tokens) = extra_false_tokens {
cfg.extra_false_tokens = tokens.into();
}
*guard = tools.convert_booleans_config(cfg);
drop(guard);
Ok(slf)
}
#[pyo3(signature = (
enable,
currency=None,
percent=None,
basis_points=None,
suffixes=None,
fractions=None,
radix=None
))]
#[pyo3(
text_signature = "($self, enable, currency=None, percent=None, basis_points=None, suffixes=None, fractions=None, radix=None)"
)]
#[allow(clippy::too_many_arguments)]
pub fn convert_numbers(
slf: PyRef<'_, Self>,
enable: bool,
currency: Option<bool>,
percent: Option<bool>,
basis_points: Option<bool>,
suffixes: Option<bool>,
fractions: Option<bool>,
radix: Option<bool>,
) -> PyResult<PyRef<'_, Self>> {
let mut guard = lock_config(&slf.inner)?;
let tools = mem::take(&mut *guard);
let mut cfg = tools.number_conversion().clone();
cfg.enabled = enable;
if let Some(v) = currency {
cfg.currency = v;
}
if let Some(v) = percent {
cfg.percent = v;
}
if let Some(v) = basis_points {
cfg.basis_points = v;
}
if let Some(v) = suffixes {
cfg.suffixes = v;
}
if let Some(v) = fractions {
cfg.fractions = v;
}
if let Some(v) = radix {
cfg.radix = v;
}
*guard = tools.convert_numbers_config(cfg);
drop(guard);
Ok(slf)
}
#[pyo3(text_signature = "($self, threshold)")]
#[inline]
pub fn parallel_threshold(slf: PyRef<'_, Self>, threshold: usize) -> PyResult<PyRef<'_, Self>> {
py_builder_method!(slf, tools, tools.parallel_threshold(threshold))
}
#[pyo3(text_signature = "($self, num_threads)")]
#[inline]
pub fn num_threads(
slf: PyRef<'_, Self>,
num_threads: Option<usize>,
) -> PyResult<PyRef<'_, Self>> {
py_builder_method!(slf, tools, tools.num_threads(num_threads))
}
#[pyo3(text_signature = "($self, threshold)")]
#[inline]
pub fn nested_parallel_threshold(
slf: PyRef<'_, Self>,
threshold: usize,
) -> PyResult<PyRef<'_, Self>> {
py_builder_method!(slf, tools, tools.nested_parallel_threshold(threshold))
}
#[pyo3(text_signature = "($self, max)")]
#[inline]
pub fn max_array_index(slf: PyRef<'_, Self>, max: usize) -> PyResult<PyRef<'_, Self>> {
py_builder_method!(slf, tools, tools.max_array_index(max))
}
#[pyo3(signature = (json_input, normalise=false, target=None))]
#[pyo3(text_signature = "($self, json_input, normalise=False, target=None)")]
pub fn execute(
&self,
json_input: &Bound<'_, PyAny>,
normalise: bool,
target: Option<String>,
) -> PyResult<Py<PyAny>> {
let py = json_input.py();
if normalise {
return self.execute_normalise(json_input, target);
}
if target.is_some() {
return Err(JsonToolsError::new_err(
"target is only valid when normalise=True",
));
}
let is_exact_common_type = json_input.is_exact_instance_of::<PyString>()
|| json_input.is_exact_instance_of::<PyDict>()
|| json_input.is_exact_instance_of::<PyList>();
if !is_exact_common_type {
match detect_data_structure(json_input)? {
Some(DataStructureType::DataFrame(df_type)) => {
return self.execute_dataframe(json_input, df_type);
}
Some(DataStructureType::Series(series_type)) => {
return self.execute_series(json_input, series_type);
}
None => {
}
}
}
if let Ok(json_str) = json_input.extract::<String>() {
let result = py
.detach(|| {
let mut guard = lock_config(&self.inner)?;
let tools = mem::take(&mut *guard);
let result = tools.execute(json_str.as_str());
*guard = tools;
result
})
.map_err(|e| {
JsonToolsError::new_err(format!("Failed to process JSON string: {}", e))
})?;
match result {
JsonOutput::Single(processed) => {
Ok(processed.into_pyobject(py)?.into_any().unbind())
}
JsonOutput::Multiple(_) => Err(PyValueError::new_err(
"Unexpected multiple results for single JSON input",
)),
}
} else if json_input.is_instance_of::<pyo3::types::PyDict>() {
let json_str = py_dumps(py, json_input).map_err(|e| {
JsonToolsError::new_err(format!("Failed to convert Python dict: {}", e))
})?;
let result = py
.detach(|| {
let mut guard = lock_config(&self.inner)?;
let tools = mem::take(&mut *guard);
let result = tools.execute(json_str.as_str());
*guard = tools;
result
})
.map_err(|e| {
JsonToolsError::new_err(format!("Failed to process Python dict: {}", e))
})?;
match result {
JsonOutput::Single(processed) => {
let python_dict = py_loads(py, &processed).map_err(|e| {
JsonToolsError::new_err(format!("Failed to convert to Python: {}", e))
})?;
Ok(python_dict.unbind())
}
JsonOutput::Multiple(_) => Err(PyValueError::new_err(
"Unexpected multiple results for single dict input",
)),
}
} else if json_input.is_instance_of::<pyo3::types::PyList>() {
let list = json_input.cast::<pyo3::types::PyList>()?;
if list.is_empty() {
return Ok(Vec::<String>::new().into_pyobject(py)?.into_any().unbind());
}
let mut json_strings: Vec<String> = Vec::with_capacity(list.len());
let mut is_str_flags: Vec<bool> = Vec::with_capacity(list.len());
let mut has_other_types = false;
for item in list.iter() {
if let Ok(json_str) = item.extract::<String>() {
json_strings.push(json_str);
is_str_flags.push(true);
} else if item.is_instance_of::<pyo3::types::PyDict>() {
let json_str = py_dumps(py, &item).map_err(|e| {
JsonToolsError::new_err(format!("Failed to convert dict in list: {}", e))
})?;
json_strings.push(json_str);
is_str_flags.push(false);
} else {
has_other_types = true;
break;
}
}
if has_other_types {
return Err(PyValueError::new_err(
"List items must be either JSON strings or Python dictionaries",
));
}
let result = py
.detach(|| {
let mut guard = lock_config(&self.inner)?;
let tools = mem::take(&mut *guard);
let result = tools.execute(json_strings);
*guard = tools;
result
})
.map_err(|e| {
JsonToolsError::new_err(format!("Failed to process JSON list: {}", e))
})?;
match result {
JsonOutput::Single(_) => Err(PyValueError::new_err(
"Unexpected single result for multiple input",
)),
JsonOutput::Multiple(processed_list) => {
let all_strings = is_str_flags.iter().all(|&b| b);
let all_dicts = is_str_flags.iter().all(|&b| !b);
if all_strings {
Ok(processed_list.into_pyobject(py)?.into_any().unbind())
} else if all_dicts {
let mut dict_results: Vec<Py<PyAny>> =
Vec::with_capacity(processed_list.len());
for processed_json in processed_list {
let python_dict = py_loads(py, &processed_json).map_err(|e| {
JsonToolsError::new_err(format!(
"Failed to convert to Python dict: {}",
e
))
})?;
dict_results.push(python_dict.unbind());
}
Ok(dict_results.into_pyobject(py)?.into_any().unbind())
} else {
let mut mixed_results: Vec<Py<PyAny>> =
Vec::with_capacity(processed_list.len());
for (processed_json, is_str) in processed_list.into_iter().zip(is_str_flags)
{
if is_str {
mixed_results
.push(processed_json.into_pyobject(py)?.into_any().unbind());
} else {
let python_dict = py_loads(py, &processed_json).map_err(|e| {
JsonToolsError::new_err(format!(
"Failed to convert to Python dict: {}",
e
))
})?;
mixed_results.push(python_dict.unbind());
}
}
Ok(mixed_results.into_pyobject(py)?.into_any().unbind())
}
}
}
} else {
Err(PyValueError::new_err(
"json_input must be a JSON string, Python dict, list of JSON strings, or list of Python dicts",
))
}
}
#[pyo3(text_signature = "($self, json_input)")]
pub fn execute_to_output(&self, json_input: &Bound<'_, PyAny>) -> PyResult<PyJsonOutput> {
let py = json_input.py();
if let Ok(json_str) = json_input.extract::<String>() {
let result = py
.detach(|| {
let mut guard = lock_config(&self.inner)?;
let tools = mem::take(&mut *guard);
let result = tools.execute(json_str.as_str());
*guard = tools;
result
})
.map_err(|e| {
JsonToolsError::new_err(format!("Failed to process JSON string: {}", e))
})?;
return Ok(PyJsonOutput::from_rust_output(result));
}
if json_input.is_instance_of::<pyo3::types::PyDict>() {
let json_str = py_dumps(py, json_input).map_err(|e| {
JsonToolsError::new_err(format!("Failed to convert Python dict: {}", e))
})?;
let result = py
.detach(|| {
let mut guard = lock_config(&self.inner)?;
let tools = mem::take(&mut *guard);
let result = tools.execute(json_str.as_str());
*guard = tools;
result
})
.map_err(|e| {
JsonToolsError::new_err(format!("Failed to process Python dict: {}", e))
})?;
return Ok(PyJsonOutput::from_rust_output(result));
}
if json_input.is_instance_of::<pyo3::types::PyList>() {
let list = json_input.cast::<pyo3::types::PyList>()?;
if list.is_empty() {
return Ok(PyJsonOutput::from_rust_output(JsonOutput::Multiple(vec![])));
}
let mut json_strings: Vec<String> = Vec::with_capacity(list.len());
for item in list.iter() {
if let Ok(json_str) = item.extract::<String>() {
json_strings.push(json_str);
} else if item.is_instance_of::<pyo3::types::PyDict>() {
let json_str = py_dumps(py, &item).map_err(|e| {
JsonToolsError::new_err(format!("Failed to convert dict in list: {}", e))
})?;
json_strings.push(json_str);
} else {
return Err(PyValueError::new_err(
"List items must be either JSON strings or Python dictionaries",
));
}
}
let result = py
.detach(|| {
let mut guard = lock_config(&self.inner)?;
let tools = mem::take(&mut *guard);
let result = tools.execute(json_strings);
*guard = tools;
result
})
.map_err(|e| {
JsonToolsError::new_err(format!("Failed to process JSON list: {}", e))
})?;
return Ok(PyJsonOutput::from_rust_output(result));
}
Err(PyValueError::new_err(
"json_input must be a JSON string, Python dict, DataFrame, Series, list of JSON strings, or list of Python dicts",
))
}
#[pyo3(text_signature = "($self)")]
pub fn to_config_json(&self) -> PyResult<String> {
let guard = lock_config(&self.inner)?;
Ok(guard.to_config_json())
}
#[staticmethod]
#[pyo3(text_signature = "(config_json)")]
pub fn from_config_json(config_json: &str) -> PyResult<Self> {
let tools = crate::config_json::build_tools(config_json)
.map_err(|e| JsonToolsError::new_err(format!("Invalid configuration: {e}")))?;
Ok(Self {
inner: Mutex::new(tools),
})
}
pub fn __reduce__<'py>(slf: &Bound<'py, Self>) -> PyResult<(Bound<'py, PyAny>, (String,))> {
let config_json = slf.borrow().to_config_json()?;
let ctor = slf.get_type().getattr("from_config_json")?;
Ok((ctor, (config_json,)))
}
}
#[cfg(feature = "python")]
fn reconstruct_dataframe(
py: Python,
df_type: DataFrameType,
processed_dicts: Vec<Py<PyAny>>,
) -> PyResult<Py<PyAny>> {
match df_type {
DataFrameType::Pandas => reconstruct_pandas_df(py, processed_dicts),
DataFrameType::Polars => reconstruct_polars_df(py, processed_dicts),
DataFrameType::PyArrow => reconstruct_pyarrow_table(py, processed_dicts),
DataFrameType::PySpark => {
Ok(processed_dicts.into_pyobject(py)?.into_any().unbind())
}
DataFrameType::Generic => {
Ok(processed_dicts.into_pyobject(py)?.into_any().unbind())
}
}
}
#[cfg(feature = "python")]
fn reconstruct_pandas_df(py: Python, records: Vec<Py<PyAny>>) -> PyResult<Py<PyAny>> {
let py_list = records.into_pyobject(py)?.into_any().unbind();
match py.import("pandas") {
Ok(pandas) => match pandas.call_method1("DataFrame", (py_list.clone_ref(py),)) {
Ok(df) => Ok(df.unbind()),
Err(_) => Ok(py_list),
},
Err(_) => Ok(py_list),
}
}
#[cfg(feature = "python")]
fn reconstruct_polars_df(py: Python, records: Vec<Py<PyAny>>) -> PyResult<Py<PyAny>> {
let py_list = records.into_pyobject(py)?.into_any().unbind();
match py.import("polars") {
Ok(polars) => match polars.call_method1("DataFrame", (py_list.clone_ref(py),)) {
Ok(df) => Ok(df.unbind()),
Err(_) => Ok(py_list),
},
Err(_) => Ok(py_list),
}
}
#[cfg(feature = "python")]
fn reconstruct_pyarrow_table(py: Python, records: Vec<Py<PyAny>>) -> PyResult<Py<PyAny>> {
let py_list = records.into_pyobject(py)?.into_any().unbind();
match py.import("pyarrow") {
Ok(pyarrow) => {
let table_class = pyarrow.getattr("Table")?;
match table_class.call_method1("from_pylist", (py_list.clone_ref(py),)) {
Ok(table) => Ok(table.unbind()),
Err(_) => Ok(py_list),
}
}
Err(_) => Ok(py_list),
}
}
#[cfg(feature = "python")]
fn reconstruct_series(
py: Python,
series_type: SeriesType,
processed_items: Vec<Py<PyAny>>,
) -> PyResult<Py<PyAny>> {
match series_type {
SeriesType::Pandas => reconstruct_pandas_series(py, processed_items),
SeriesType::Polars => reconstruct_polars_series(py, processed_items),
SeriesType::PyArrow => reconstruct_pyarrow_array(py, processed_items),
SeriesType::PySpark => {
Ok(processed_items.into_pyobject(py)?.into_any().unbind())
}
SeriesType::Generic => {
Ok(processed_items.into_pyobject(py)?.into_any().unbind())
}
}
}
#[cfg(feature = "python")]
fn reconstruct_pandas_series(py: Python, items: Vec<Py<PyAny>>) -> PyResult<Py<PyAny>> {
let py_list = items.into_pyobject(py)?.into_any().unbind();
match py.import("pandas") {
Ok(pandas) => match pandas.call_method1("Series", (py_list.clone_ref(py),)) {
Ok(series) => Ok(series.unbind()),
Err(_) => Ok(py_list),
},
Err(_) => Ok(py_list),
}
}
#[cfg(feature = "python")]
fn reconstruct_polars_series(py: Python, items: Vec<Py<PyAny>>) -> PyResult<Py<PyAny>> {
let py_list = items.into_pyobject(py)?.into_any().unbind();
match py.import("polars") {
Ok(polars) => match polars.call_method1("Series", (py_list.clone_ref(py),)) {
Ok(series) => Ok(series.unbind()),
Err(_) => Ok(py_list),
},
Err(_) => Ok(py_list),
}
}
#[cfg(feature = "python")]
fn reconstruct_pyarrow_array(py: Python, items: Vec<Py<PyAny>>) -> PyResult<Py<PyAny>> {
let py_list = items.into_pyobject(py)?.into_any().unbind();
match py.import("pyarrow") {
Ok(pyarrow) => match pyarrow.call_method1("array", (py_list.clone_ref(py),)) {
Ok(array) => Ok(array.unbind()),
Err(_) => Ok(py_list),
},
Err(_) => Ok(py_list),
}
}
#[cfg(feature = "python")]
fn mixed_pylist_to_json_strings(py: Python<'_>, list: &Bound<'_, PyList>) -> PyResult<Vec<String>> {
let mut json_strings = Vec::with_capacity(list.len());
for item in list.iter() {
if let Ok(json_str) = item.extract::<String>() {
json_strings.push(json_str);
} else if item.is_instance_of::<PyDict>() {
let json_str = py_dumps(py, &item)
.map_err(|e| JsonToolsError::new_err(format!("Failed to convert item: {e}")))?;
json_strings.push(json_str);
} else {
return Err(PyValueError::new_err(
"List/Series items must be either JSON strings or Python dictionaries",
));
}
}
Ok(json_strings)
}
#[cfg(feature = "python")]
fn extract_normalise_json_strings(
json_input: &Bound<'_, PyAny>,
) -> PyResult<(Vec<String>, Option<DataStructureType>)> {
let py = json_input.py();
if let Some(structure) = detect_data_structure(json_input)? {
let json_strings = match structure {
DataStructureType::DataFrame(df_type) => {
let rows = dataframe_to_json_strings(json_input, df_type)?;
expand_json_string_columns(py, rows)?
}
DataStructureType::Series(series_type) => {
let list = series_to_list(json_input, series_type)?;
mixed_pylist_to_json_strings(py, &list)?
}
};
return Ok((json_strings, Some(structure)));
}
if let Ok(json_str) = json_input.extract::<String>() {
return Ok((vec![json_str], None));
}
if json_input.is_instance_of::<PyDict>() {
let json_str = py_dumps(py, json_input)
.map_err(|e| JsonToolsError::new_err(format!("Failed to convert Python dict: {e}")))?;
return Ok((vec![json_str], None));
}
if json_input.is_instance_of::<PyList>() {
let list = json_input.cast::<PyList>()?;
let json_strings = mixed_pylist_to_json_strings(py, list)?;
return Ok((json_strings, None));
}
Err(PyValueError::new_err(
"json_input must be a JSON string, Python dict, list of JSON strings/dicts, DataFrame, or Series",
))
}
#[cfg(feature = "python")]
static PANDAS_MODULE: PyOnceLock<Py<PyModule>> = PyOnceLock::new();
#[cfg(feature = "python")]
static POLARS_MODULE: PyOnceLock<Py<PyModule>> = PyOnceLock::new();
#[cfg(feature = "python")]
static PYARROW_MODULE: PyOnceLock<Py<PyModule>> = PyOnceLock::new();
#[cfg(feature = "python")]
static PYSPARK_MODULE: PyOnceLock<Py<PyModule>> = PyOnceLock::new();
#[cfg(feature = "python")]
static PYSPARK_SQL_MODULE: PyOnceLock<Py<PyModule>> = PyOnceLock::new();
#[cfg(feature = "python")]
static PYSPARK_TYPES_MODULE: PyOnceLock<Py<PyModule>> = PyOnceLock::new();
#[cfg(feature = "python")]
fn cached_import<'py>(
py: Python<'py>,
cell: &'static PyOnceLock<Py<PyModule>>,
name: &str,
) -> PyResult<Bound<'py, PyModule>> {
let module = cell.get_or_try_init(py, || py.import(name).map(|m| m.unbind()))?;
Ok(module.bind(py).clone())
}
#[cfg(feature = "python")]
fn resolve_normalise_target(
py: Python<'_>,
detected: Option<DataStructureType>,
requested: Option<NormaliseTarget>,
) -> PyResult<NormaliseTarget> {
if let Some(target) = requested {
return Ok(target);
}
let from_input = match detected {
Some(DataStructureType::DataFrame(t)) => NormaliseTarget::from_dataframe_type(t),
Some(DataStructureType::Series(t)) => NormaliseTarget::from_series_type(t),
None => None,
};
if let Some(target) = from_input {
return Ok(target);
}
for candidate in [
NormaliseTarget::Pandas,
NormaliseTarget::Polars,
NormaliseTarget::PyArrow,
] {
let cell = match candidate {
NormaliseTarget::Pandas => &PANDAS_MODULE,
NormaliseTarget::Polars => &POLARS_MODULE,
NormaliseTarget::PyArrow => &PYARROW_MODULE,
NormaliseTarget::PySpark => unreachable!("PySpark excluded from this candidate list"),
};
if cached_import(py, cell, candidate.name()).is_ok() {
return Ok(candidate);
}
}
Err(JsonToolsError::new_err(
"normalise=True could not auto-detect a target DataFrame library: none of pandas, \
polars, pyarrow are installed. Pass target=\"pandas\"|\"polars\"|\"pyarrow\"|\"pyspark\" \
explicitly, or install one of these libraries.",
))
}
#[cfg(feature = "python")]
fn require_importable<'py>(
py: Python<'py>,
target: NormaliseTarget,
) -> PyResult<Bound<'py, PyModule>> {
let cell = match target {
NormaliseTarget::Pandas => &PANDAS_MODULE,
NormaliseTarget::Polars => &POLARS_MODULE,
NormaliseTarget::PyArrow => &PYARROW_MODULE,
NormaliseTarget::PySpark => &PYSPARK_MODULE,
};
cached_import(py, cell, target.name()).map_err(|_| {
JsonToolsError::new_err(format!(
"normalise(target=\"{}\") requires the '{}' package to be installed",
target.name(),
target.name()
))
})
}
#[cfg(feature = "python")]
fn require_active_spark_session(py: Python<'_>) -> PyResult<Bound<'_, PyAny>> {
let pyspark_sql = cached_import(py, &PYSPARK_SQL_MODULE, "pyspark.sql").map_err(|_| {
JsonToolsError::new_err(
"normalise(target=\"pyspark\") requires the 'pyspark' package to be installed",
)
})?;
let session_cls = pyspark_sql.getattr("SparkSession")?;
let active = session_cls.call_method0("getActiveSession")?;
if active.is_none() {
return Err(JsonToolsError::new_err(
"normalise(target=\"pyspark\") requires an active SparkSession \
(SparkSession.getActiveSession() returned None); create one first, \
e.g. SparkSession.builder.getOrCreate()",
));
}
Ok(active)
}
#[cfg(feature = "python")]
struct NormaliseColumns<'py> {
columns: IndexMap<String, Vec<Bound<'py, PyAny>>>,
}
#[cfg(feature = "python")]
fn classify_scalar_kind(v: &Bound<'_, PyAny>) -> (bool, bool, bool) {
if v.is_instance_of::<pyo3::types::PyBool>() {
(true, false, false)
} else if v.is_instance_of::<pyo3::types::PyInt>() || v.is_instance_of::<pyo3::types::PyFloat>()
{
(false, true, false)
} else if v.is_instance_of::<PyString>() {
(false, false, true)
} else {
(false, false, false)
}
}
#[cfg(feature = "python")]
fn union_and_columnarize<'py>(
py: Python<'py>,
processed: Vec<String>,
) -> PyResult<NormaliseColumns<'py>> {
let n_rows = processed.len();
let mut rows: Vec<Bound<'py, PyDict>> = Vec::with_capacity(n_rows);
let mut key_order: IndexSet<String> = IndexSet::new();
for (idx, json_str) in processed.iter().enumerate() {
let value = py_loads(py, json_str).map_err(|e| {
JsonToolsError::new_err(format!("Failed to parse flattened row {idx}: {e}"))
})?;
let dict = value
.cast::<PyDict>()
.map_err(|_| {
JsonToolsError::new_err(format!(
"normalise=True requires every flattened row to be a JSON object; \
row {idx} produced a different type instead"
))
})?
.clone();
for key in dict.keys() {
key_order.insert(key.extract::<String>()?);
}
rows.push(dict);
}
let n_keys = key_order.len();
let mut column_slots: Vec<Vec<Option<Bound<'py, PyAny>>>> =
(0..n_keys).map(|_| vec![None; n_rows]).collect();
let mut any_list_per_col: Vec<bool> = vec![false; n_keys];
for (row_idx, row) in rows.iter().enumerate() {
for (key, value) in row.iter() {
let key_str: &str = key.extract()?;
let Some(col_idx) = key_order.get_index_of(key_str) else {
continue; };
if value.is_instance_of::<PyList>() {
any_list_per_col[col_idx] = true;
}
column_slots[col_idx][row_idx] = Some(value);
}
}
let mut columns: IndexMap<String, Vec<Bound<'py, PyAny>>> = IndexMap::with_capacity(n_keys);
for (col_idx, key) in key_order.iter().enumerate() {
let mut col: Vec<Bound<'py, PyAny>> = column_slots[col_idx]
.drain(..)
.map(|maybe_v| maybe_v.unwrap_or_else(|| py.None().into_bound(py)))
.collect();
let any_list = any_list_per_col[col_idx];
if any_list {
for cell in col.iter_mut() {
if !cell.is_none() && !cell.is_instance_of::<PyList>() {
*cell = PyList::new(py, [cell.clone()])?.into_any();
}
}
let (has_bool, has_numeric, has_str) = col
.iter()
.filter_map(|cell| cell.cast::<PyList>().ok())
.flat_map(|list| list.iter())
.map(|item| classify_scalar_kind(&item))
.fold((false, false, false), |acc, k| {
(acc.0 || k.0, acc.1 || k.1, acc.2 || k.2)
});
if [has_bool, has_numeric, has_str]
.iter()
.filter(|present| **present)
.count()
> 1
{
for cell in col.iter_mut() {
let Ok(list) = cell.cast::<PyList>() else {
continue;
};
let stringified = list
.iter()
.map(|item| {
if item.is_none() {
Ok(item)
} else {
item.str().map(|s| s.into_any())
}
})
.collect::<PyResult<Vec<_>>>()?;
*cell = PyList::new(py, stringified)?.into_any();
}
}
} else {
let (has_bool, has_numeric, has_str) = col
.iter()
.map(classify_scalar_kind)
.fold((false, false, false), |acc, k| {
(acc.0 || k.0, acc.1 || k.1, acc.2 || k.2)
});
let kinds_present = [has_bool, has_numeric, has_str]
.iter()
.filter(|present| **present)
.count();
if kinds_present > 1 {
for cell in col.iter_mut() {
if !cell.is_none() {
*cell = cell.str()?.into_any();
}
}
}
}
columns.insert(key.clone(), col);
}
Ok(NormaliseColumns { columns })
}
#[cfg(feature = "python")]
fn reconstruct_pandas_normalise(
py: Python<'_>,
cols: &NormaliseColumns<'_>,
) -> PyResult<Py<PyAny>> {
let pandas = cached_import(py, &PANDAS_MODULE, "pandas")?;
let data = PyDict::new(py);
for (key, values) in &cols.columns {
data.set_item(key, PyList::new(py, values.iter().cloned())?)?;
}
let df = pandas.call_method1("DataFrame", (data,))?;
Ok(df.unbind())
}
#[cfg(feature = "python")]
fn reconstruct_polars_normalise(
py: Python<'_>,
cols: &NormaliseColumns<'_>,
) -> PyResult<Py<PyAny>> {
let polars = cached_import(py, &POLARS_MODULE, "polars")?;
let data = PyDict::new(py);
for (key, values) in &cols.columns {
data.set_item(key, PyList::new(py, values.iter().cloned())?)?;
}
let df = polars.call_method1("DataFrame", (data,))?;
Ok(df.unbind())
}
#[cfg(feature = "python")]
fn reconstruct_pyarrow_normalise(
py: Python<'_>,
cols: &NormaliseColumns<'_>,
) -> PyResult<Py<PyAny>> {
let pyarrow = cached_import(py, &PYARROW_MODULE, "pyarrow")?;
let data = PyDict::new(py);
for (key, values) in &cols.columns {
data.set_item(key, PyList::new(py, values.iter().cloned())?)?;
}
let table = pyarrow
.getattr("Table")?
.call_method1("from_pydict", (data,))?;
Ok(table.unbind())
}
#[cfg(feature = "python")]
fn infer_spark_type<'py>(
types_mod: &Bound<'py, PyModule>,
values: &[Bound<'py, PyAny>],
) -> PyResult<Bound<'py, PyAny>> {
let mut saw_bool = false;
let mut saw_int = false;
let mut saw_float = false;
let mut saw_list = false;
let mut saw_other = false;
for v in values {
if v.is_none() {
continue;
}
if v.is_instance_of::<pyo3::types::PyBool>() {
saw_bool = true;
} else if v.is_instance_of::<pyo3::types::PyInt>() {
saw_int = true;
} else if v.is_instance_of::<pyo3::types::PyFloat>() {
saw_float = true;
} else if v.is_instance_of::<PyList>() {
saw_list = true;
} else if !v.is_instance_of::<PyString>() {
saw_other = true;
}
}
if saw_bool {
return types_mod.getattr("BooleanType")?.call0();
}
if saw_list {
let mut elem_bool = false;
let mut elem_int = false;
let mut elem_float = false;
for v in values {
let Ok(list) = v.cast::<PyList>() else {
continue;
};
for item in list.iter() {
if item.is_none() {
continue;
}
if item.is_instance_of::<pyo3::types::PyBool>() {
elem_bool = true;
} else if item.is_instance_of::<pyo3::types::PyInt>() {
elem_int = true;
} else if item.is_instance_of::<pyo3::types::PyFloat>() {
elem_float = true;
}
}
}
let element_type = if elem_bool {
types_mod.getattr("BooleanType")?.call0()?
} else if elem_float {
types_mod.getattr("DoubleType")?.call0()?
} else if elem_int {
types_mod.getattr("LongType")?.call0()?
} else {
types_mod.getattr("StringType")?.call0()?
};
return types_mod.getattr("ArrayType")?.call1((element_type,));
}
if saw_float {
return types_mod.getattr("DoubleType")?.call0();
}
if saw_int {
return types_mod.getattr("LongType")?.call0();
}
let _ = saw_other;
types_mod.getattr("StringType")?.call0()
}
#[cfg(feature = "python")]
fn reconstruct_pyspark_normalise(
py: Python<'_>,
spark: &Bound<'_, PyAny>,
cols: &NormaliseColumns<'_>,
) -> PyResult<Py<PyAny>> {
let types_mod = cached_import(py, &PYSPARK_TYPES_MODULE, "pyspark.sql.types")?;
if cols.columns.is_empty() {
let empty_schema = types_mod.getattr("StructType")?.call0()?;
let empty_rows: Vec<Py<PyAny>> = Vec::new();
let df = spark.call_method1("createDataFrame", (empty_rows, empty_schema))?;
return Ok(df.unbind());
}
cached_import(py, &PANDAS_MODULE, "pandas").map_err(|_| {
JsonToolsError::new_err(
"Reconstructing a PySpark DataFrame (via execute() or \
normalise(target=\"pyspark\")) requires pandas to be installed -- it's \
used internally to build the DataFrame handed to Spark's \
Arrow-optimized createDataFrame() bridge",
)
})?;
let struct_field_cls = types_mod.getattr("StructField")?;
let mut fields: Vec<Bound<'_, PyAny>> = Vec::with_capacity(cols.columns.len());
for (key, values) in &cols.columns {
let field_type = infer_spark_type(&types_mod, values)?;
fields.push(struct_field_cls.call1((key, field_type, true))?);
}
let schema = types_mod.getattr("StructType")?.call1((fields,))?;
let pandas_df = reconstruct_pandas_normalise(py, cols)?;
let df = spark.call_method1("createDataFrame", (pandas_df, schema))?;
Ok(df.unbind())
}
#[cfg(feature = "python")]
impl PyJSONTools {
fn execute_dataframe(
&self,
df: &Bound<'_, PyAny>,
df_type: DataFrameType,
) -> PyResult<Py<PyAny>> {
let py = df.py();
let mut json_strings = dataframe_to_json_strings(df, df_type)?;
if lock_config(&self.inner)?.is_flatten_mode() {
json_strings = expand_json_string_columns(py, json_strings)?;
}
let result = py
.detach(|| {
let mut guard = lock_config(&self.inner)?;
let tools = mem::take(&mut *guard);
let result = tools.execute(json_strings); *guard = tools;
result
})
.map_err(|e| JsonToolsError::new_err(format!("Failed to process DataFrame: {}", e)))?;
match result {
JsonOutput::Multiple(processed_list) => match df_type {
DataFrameType::PySpark => {
let spark = require_active_spark_session(py)?;
let columns = union_and_columnarize(py, processed_list)?;
reconstruct_pyspark_normalise(py, &spark, &columns)
}
_ => {
let mut processed_dicts: Vec<Py<PyAny>> =
Vec::with_capacity(processed_list.len());
for json_str in processed_list {
let py_dict = py_loads(py, &json_str).map_err(|e| {
JsonToolsError::new_err(format!("Failed to convert to Python: {}", e))
})?;
processed_dicts.push(py_dict.unbind());
}
reconstruct_dataframe(py, df_type, processed_dicts)
}
},
JsonOutput::Single(_) => Err(PyValueError::new_err(
"Unexpected single result for DataFrame input",
)),
}
}
fn execute_series(
&self,
series: &Bound<'_, PyAny>,
series_type: SeriesType,
) -> PyResult<Py<PyAny>> {
let py = series.py();
let list = series_to_list(series, series_type)?;
let mut json_strings: Vec<String> = Vec::with_capacity(list.len());
let mut is_str_flags: Vec<bool> = Vec::with_capacity(list.len());
let mut has_other_types = false;
for item in list.iter() {
if let Ok(json_str) = item.extract::<String>() {
json_strings.push(json_str);
is_str_flags.push(true);
} else if item.is_instance_of::<pyo3::types::PyDict>() {
let json_str = py_dumps(py, &item).map_err(|e| {
JsonToolsError::new_err(format!("Failed to convert dict in series: {}", e))
})?;
json_strings.push(json_str);
is_str_flags.push(false);
} else {
has_other_types = true;
break;
}
}
if has_other_types {
return Err(PyValueError::new_err(
"Series items must be either JSON strings or Python dictionaries",
));
}
let result = py
.detach(|| {
let mut guard = lock_config(&self.inner)?;
let tools = mem::take(&mut *guard);
let result = tools.execute(json_strings);
*guard = tools;
result
})
.map_err(|e| JsonToolsError::new_err(format!("Failed to process Series: {}", e)))?;
match result {
JsonOutput::Multiple(processed_list) => {
let all_strings = is_str_flags.iter().all(|&b| b);
let all_dicts = is_str_flags.iter().all(|&b| !b);
let processed_items: Vec<Py<PyAny>> = if all_strings {
processed_list
.into_iter()
.map(|s| {
s.into_pyobject(py)
.map(|o| o.into_any().unbind())
.map_err(|e| {
JsonToolsError::new_err(format!(
"Failed to convert string to Python object: {}",
e
))
})
})
.collect::<PyResult<Vec<_>>>()?
} else if all_dicts {
let mut dict_results = Vec::with_capacity(processed_list.len());
for processed_json in processed_list {
let python_dict = py_loads(py, &processed_json).map_err(|e| {
JsonToolsError::new_err(format!(
"Failed to convert to Python dict: {}",
e
))
})?;
dict_results.push(python_dict.unbind());
}
dict_results
} else {
let mut mixed_results = Vec::with_capacity(processed_list.len());
for (processed_json, is_str) in processed_list.into_iter().zip(is_str_flags) {
if is_str {
mixed_results
.push(processed_json.into_pyobject(py)?.into_any().unbind());
} else {
let python_dict = py_loads(py, &processed_json).map_err(|e| {
JsonToolsError::new_err(format!(
"Failed to convert to Python: {}",
e
))
})?;
mixed_results.push(python_dict.unbind());
}
}
mixed_results
};
reconstruct_series(py, series_type, processed_items)
}
JsonOutput::Single(_) => Err(PyValueError::new_err(
"Unexpected single result for Series input",
)),
}
}
fn execute_normalise(
&self,
json_input: &Bound<'_, PyAny>,
target: Option<String>,
) -> PyResult<Py<PyAny>> {
let py = json_input.py();
if !lock_config(&self.inner)?.is_flatten_mode() {
return Err(JsonToolsError::new_err(
"normalise=True requires .flatten() mode -- unflattened/nested JSON \
can't produce clean scalar columns for a wide DataFrame",
));
}
let requested_target = target.as_deref().map(NormaliseTarget::parse).transpose()?;
let (json_strings, detected) = extract_normalise_json_strings(json_input)?;
let resolved_target = resolve_normalise_target(py, detected, requested_target)?;
let spark_session = if resolved_target == NormaliseTarget::PySpark {
Some(require_active_spark_session(py)?)
} else {
require_importable(py, resolved_target)?;
None
};
let result = py
.detach(|| {
let mut guard = lock_config(&self.inner)?;
let tools = mem::take(&mut *guard);
let result = tools.execute(json_strings);
*guard = tools;
result
})
.map_err(|e| JsonToolsError::new_err(format!("Failed to process JSON: {}", e)))?;
let processed_list = match result {
JsonOutput::Multiple(processed_list) => processed_list,
JsonOutput::Single(single) => vec![single],
};
let columns = union_and_columnarize(py, processed_list)?;
match resolved_target {
NormaliseTarget::Pandas => reconstruct_pandas_normalise(py, &columns),
NormaliseTarget::Polars => reconstruct_polars_normalise(py, &columns),
NormaliseTarget::PyArrow => reconstruct_pyarrow_normalise(py, &columns),
NormaliseTarget::PySpark => {
let spark = spark_session.expect("resolved to PySpark target above");
reconstruct_pyspark_normalise(py, &spark, &columns)
}
}
}
}
#[cfg(feature = "python")]
#[pymodule]
fn json_tools_rs(m: &Bound<'_, PyModule>) -> PyResult<()> {
m.add_class::<PyJSONTools>()?;
m.add_class::<PyJsonOutput>()?;
m.add("JsonToolsError", m.py().get_type::<JsonToolsError>())?;
m.add("__version__", env!("CARGO_PKG_VERSION"))?;
m.add("__author__", "JSON Tools RS Contributors")?;
m.add(
"__description__",
"Python bindings for JSON Tools RS - Unified JSON manipulation library with advanced collision handling and filtering",
)?;
Ok(())
}
#[cfg(all(test, feature = "python"))]
mod marshal_tests {
use super::may_contain_big_int;
fn naive(s: &[u8]) -> bool {
let mut run = 0usize;
for &b in s {
if b.is_ascii_digit() {
run += 1;
if run >= 19 {
return true;
}
} else {
run = 0;
}
}
false
}
#[test]
fn big_int_guard_edges() {
assert!(!may_contain_big_int(b""));
assert!(!may_contain_big_int(b"{}"));
assert!(!may_contain_big_int(b"123456789012345678"));
assert!(may_contain_big_int(b"1234567890123456789"));
assert!(may_contain_big_int(b"{\"big\": 1234567890123456789}"));
assert!(may_contain_big_int(
b"xxxxxxxxxxxxxxxxxxxxx1234567890123456789"
));
assert!(may_contain_big_int(b"9223372036854775807"));
assert!(!may_contain_big_int(b"123456789.123456789e12"));
assert!(may_contain_big_int(b"{\"id\": \"12345678901234567890\"}"));
}
#[test]
fn big_int_guard_matches_naive_scan() {
let mut state = 0x9e3779b97f4a7c15u64;
let mut next = move || {
state ^= state << 13;
state ^= state >> 7;
state ^= state << 17;
state
};
for len in [0usize, 1, 17, 18, 19, 20, 37, 38, 57, 200, 1000] {
for _ in 0..500 {
let bytes: Vec<u8> = (0..len)
.map(|_| match next() % 4 {
0 => b'a',
1 => b',',
_ => b'0' + (next() % 10) as u8,
})
.collect();
assert_eq!(
may_contain_big_int(&bytes),
naive(&bytes),
"mismatch on {:?}",
String::from_utf8_lossy(&bytes)
);
}
}
}
}