Skip to content

Commit d1437bd

Browse files
authored
Merge pull request #312 from bbopen/feat/0.10-pandas-json-domain
feat(codec): pandas Arrow fidelity gates + JSON loss preflight
2 parents b344f54 + d5bb4fd commit d1437bd

7 files changed

Lines changed: 475 additions & 31 deletions

File tree

examples/living-app/living_app/app.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -364,4 +364,11 @@ def top_users_by_spend(path: str, top_n: int = 10) -> pd.DataFrame:
364364
cols = [c for c in cols if c in df.columns]
365365
if "spend_usd_last_7d" not in df.columns:
366366
return df[cols].head(top_n)
367-
return df[cols].sort_values("spend_usd_last_7d", ascending=False).head(top_n)
367+
# The JSON codec requires a default RangeIndex; sort_values().head() keeps
368+
# the original row labels, so drop them.
369+
return (
370+
df[cols]
371+
.sort_values("spend_usd_last_7d", ascending=False)
372+
.head(top_n)
373+
.reset_index(drop=True)
374+
)

runtime/tywrap_bridge_core.py

Lines changed: 113 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -521,11 +521,42 @@ def serialize_dataframe(obj, *, force_json_markers):
521521

522522

523523
def serialize_dataframe_json(obj):
524-
"""JSON fallback for DataFrame: records orientation."""
524+
"""JSON fallback for DataFrame values that JavaScript can represent safely."""
525+
import pandas as pd # type: ignore
526+
527+
_validate_pandas_json_index(obj.index, pd, 'DataFrame')
528+
json_columns = [_pandas_json_object_key(column) for column in obj.columns]
529+
supported_json_columns = [column for column in json_columns if column is not None]
530+
if not obj.columns.is_unique or len(set(supported_json_columns)) != len(
531+
supported_json_columns
532+
):
533+
raise RuntimeError(
534+
'JSON pandas.DataFrame encoding requires column labels to remain unique after '
535+
'JSON object-key coercion; rename columns or make them distinct before applying '
536+
'.columns.astype(str)'
537+
)
538+
for column, dtype in obj.dtypes.items():
539+
if isinstance(dtype, pd.CategoricalDtype):
540+
raise RuntimeError(
541+
f'JSON pandas.DataFrame encoding does not support categorical dtype in '
542+
f'column {column!r}; use Arrow encoding or convert explicitly '
543+
"(e.g. .astype(str))"
544+
)
525545
try:
526-
data = obj.to_dict(orient='records')
546+
data = (
547+
[{} for _ in range(len(obj.index))]
548+
if len(obj.columns) == 0
549+
else obj.to_dict(orient='records')
550+
)
527551
except Exception as exc:
528552
raise RuntimeError('JSON fallback failed for pandas.DataFrame') from exc
553+
for row_number, row in enumerate(data):
554+
for column, value in row.items():
555+
row[column] = _normalize_pandas_json_scalar(
556+
value,
557+
f'DataFrame cell at row {row_number}, column {column!r}',
558+
pd,
559+
)
529560
return {
530561
'__tywrap__': 'dataframe',
531562
'codecVersion': CODEC_VERSION,
@@ -567,14 +598,23 @@ def serialize_series(obj, *, force_json_markers):
567598

568599

569600
def serialize_series_json(obj):
570-
"""JSON fallback for Series (potentially lossy dtype/NA representation)."""
601+
"""JSON fallback for Series values that JavaScript can represent safely."""
602+
import pandas as pd # type: ignore
603+
604+
_validate_pandas_json_index(obj.index, pd, 'Series')
605+
if isinstance(obj.dtype, pd.CategoricalDtype):
606+
raise RuntimeError(
607+
'JSON pandas.Series encoding does not support categorical dtype; use Arrow '
608+
"encoding or convert explicitly (e.g. .astype(str))"
609+
)
571610
try:
572611
data = obj.to_list() # type: ignore
573-
except Exception:
574-
try:
575-
data = obj.to_dict() # type: ignore
576-
except Exception as exc:
577-
raise RuntimeError('JSON fallback failed for pandas.Series') from exc
612+
except Exception as exc:
613+
raise RuntimeError('JSON fallback failed for pandas.Series') from exc
614+
data = [
615+
_normalize_pandas_json_scalar(value, f'Series value at position {position}', pd)
616+
for position, value in enumerate(data)
617+
]
578618
return {
579619
'__tywrap__': 'series',
580620
'codecVersion': CODEC_VERSION,
@@ -584,6 +624,71 @@ def serialize_series_json(obj):
584624
}
585625

586626

627+
def _validate_pandas_json_index(index, pd, container_name):
628+
"""Reject index metadata that records/list-oriented JSON would discard."""
629+
if isinstance(index, pd.MultiIndex):
630+
raise RuntimeError(
631+
f'JSON pandas.{container_name} encoding does not support MultiIndex; use Arrow '
632+
'encoding or flatten the index explicitly with .reset_index()'
633+
)
634+
if not (
635+
isinstance(index, pd.RangeIndex)
636+
and index.start == 0
637+
and index.step == 1
638+
and index.stop == len(index)
639+
and index.name is None
640+
):
641+
raise RuntimeError(
642+
f'JSON pandas.{container_name} encoding requires an unnamed RangeIndex starting '
643+
'at 0 with step 1; use Arrow encoding or normalize explicitly with '
644+
'.reset_index(drop=True)'
645+
)
646+
647+
648+
def _pandas_json_object_key(value):
649+
"""Return json.dumps' object-key spelling, or None for unsupported keys."""
650+
try:
651+
encoded = json.dumps({value: None})
652+
except (TypeError, ValueError):
653+
return None
654+
return next(iter(json.loads(encoded)))
655+
656+
657+
def _normalize_pandas_json_scalar(value, location, pd):
658+
"""Normalize pandas nulls and reject values outside the plain JSON domain."""
659+
np = sys.modules.get('numpy')
660+
if np is not None and isinstance(value, np.generic):
661+
value = value.item()
662+
if value is None or value is pd.NA or value is pd.NaT:
663+
return None
664+
if type(value) is bool:
665+
return value
666+
if type(value) is int:
667+
js_safe_integer_max = 2**53 - 1
668+
if value < -js_safe_integer_max or value > js_safe_integer_max:
669+
raise RuntimeError(
670+
f'JSON pandas encoding cannot safely represent {location} integer values '
671+
'outside the JavaScript safe integer range; use Arrow encoding or '
672+
"cast/encode explicitly (e.g. .astype('float64') or str)"
673+
)
674+
return value
675+
if type(value) is float:
676+
if not math.isfinite(value):
677+
raise RuntimeError(
678+
f'JSON pandas encoding cannot represent non-finite {location} float values '
679+
'(NaN or Infinity); use .fillna(...) for intentional missing values or '
680+
'Arrow encoding'
681+
)
682+
return value
683+
if type(value) is str:
684+
return value
685+
raise RuntimeError(
686+
f'JSON pandas encoding does not support {location} value of type '
687+
f'{type(value).__name__}; use Arrow encoding or convert explicitly '
688+
'(e.g. .astype(str))'
689+
)
690+
691+
587692
def serialize_sparse_matrix(obj):
588693
"""
589694
Serialize scipy sparse matrices into structured JSON envelopes (json-only;

src/runtime/pyodide-bootstrap-core.generated.ts

Lines changed: 1 addition & 1 deletion
Large diffs are not rendered by default.

test/menagerie/fixtures/library_torture.py

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -148,10 +148,30 @@ def pandas_nullable_int64() -> object:
148148
return pd.DataFrame({"value": pd.Series([1, pd.NA], dtype="Int64")})
149149

150150

151+
def pandas_named_series() -> object:
152+
import pandas as pd
153+
154+
return pd.Series([1, pd.NA], dtype="Int64", name="original_name")
155+
156+
151157
def pandas_categorical() -> object:
152158
import pandas as pd
153159

154-
return pd.DataFrame({"value": pd.Categorical(["a", "b", "a"])})
160+
return pd.DataFrame(
161+
{
162+
"value": pd.Categorical(
163+
["a", "b", "a"],
164+
categories=["unused", "b", "a"],
165+
ordered=True,
166+
)
167+
}
168+
)
169+
170+
171+
def pandas_nullable_boolean() -> object:
172+
import pandas as pd
173+
174+
return pd.DataFrame({"value": pd.Series([True, pd.NA, False], dtype="boolean")})
155175

156176

157177
def pandas_pyarrow_string() -> object:

test/menagerie/manifest.ts

Lines changed: 86 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,16 @@ export type CatalogueExpectation =
2727
kind: 'table-rows';
2828
value: readonly object[];
2929
pandasMetadataIncludes?: readonly string[];
30+
pandasMetadataAbsent?: boolean;
31+
pandasIndexColumns?: readonly unknown[];
32+
fields?: readonly {
33+
name: string;
34+
type: string;
35+
nullCount?: number;
36+
validity?: readonly boolean[];
37+
dictionaryValues?: readonly unknown[];
38+
dictionaryOrdered?: boolean;
39+
}[];
3040
};
3141

3242
export interface CatalogueRow {
@@ -422,17 +432,59 @@ export const RUNTIME_CATALOGUE: readonly CatalogueRow[] = [
422432
kind: 'table-rows',
423433
value: [{ value: 1n }, { value: null }],
424434
pandasMetadataIncludes: ['"numpy_type": "Int64"'],
435+
pandasIndexColumns: [{ kind: 'range', name: null, start: 0, stop: 2, step: 1 }],
436+
fields: [{ name: 'value', type: 'Int64', nullCount: 1, validity: [true, false] }],
425437
},
426438
}),
427439
libraryRow({
428440
id: 'pandas-nullable-int64-json',
429441
call: 'pandas_nullable_int64()',
430442
codec: 'json',
431443
requires: ['pandas'],
432-
status: 'KNOWN_LIE',
433-
currentBehavior: 'JSON preserves values but drops the nullable integer dtype.',
444+
status: 'EXPECTED_OK',
445+
currentBehavior:
446+
'Values-only JSON preserves nullable integer values and normalizes pd.NA to null.',
434447
expected: equal([{ value: 1 }, { value: null }]),
435448
}),
449+
libraryRow({
450+
id: 'pandas-series-arrow-current-truth',
451+
call: 'pandas_named_series()',
452+
codec: 'arrow',
453+
requires: ['pandas', 'pyarrow'],
454+
status: 'KNOWN_LIE',
455+
currentBehavior:
456+
'Series Arrow output is table-like: values survive, while its name and RangeIndex metadata do not.',
457+
expected: {
458+
kind: 'table-rows',
459+
value: [{ value: 1n }, { value: null }],
460+
pandasMetadataAbsent: true,
461+
fields: [{ name: 'value', type: 'Int64', nullCount: 1, validity: [true, false] }],
462+
},
463+
expectedFix: 'Preserve the Series name and RangeIndex metadata through Arrow decoding.',
464+
}),
465+
libraryRow({
466+
id: 'pandas-nullable-boolean-arrow',
467+
call: 'pandas_nullable_boolean()',
468+
codec: 'arrow',
469+
requires: ['pandas', 'pyarrow'],
470+
status: 'EXPECTED_OK',
471+
currentBehavior: 'Arrow preserves nullable boolean values and their null bitmap.',
472+
expected: {
473+
kind: 'table-rows',
474+
value: [{ value: true }, { value: null }, { value: false }],
475+
pandasMetadataIncludes: ['"numpy_type": "boolean"'],
476+
fields: [{ name: 'value', type: 'Bool', nullCount: 1, validity: [true, false, true] }],
477+
},
478+
}),
479+
libraryRow({
480+
id: 'pandas-nullable-boolean-json',
481+
call: 'pandas_nullable_boolean()',
482+
codec: 'json',
483+
requires: ['pandas'],
484+
status: 'EXPECTED_OK',
485+
currentBehavior: 'Values-only JSON preserves booleans and normalizes pd.NA to null.',
486+
expected: equal([{ value: true }, { value: null }, { value: false }]),
487+
}),
436488
libraryRow({
437489
id: 'pandas-categorical-arrow',
438490
call: 'pandas_categorical()',
@@ -443,17 +495,28 @@ export const RUNTIME_CATALOGUE: readonly CatalogueRow[] = [
443495
expected: {
444496
kind: 'table-rows',
445497
value: [{ value: 'a' }, { value: 'b' }, { value: 'a' }],
446-
pandasMetadataIncludes: ['"pandas_type": "categorical"'],
498+
pandasMetadataIncludes: [
499+
'"pandas_type": "categorical"',
500+
'"num_categories": 3, "ordered": true',
501+
],
502+
fields: [
503+
{
504+
name: 'value',
505+
type: 'Dictionary<Int8, LargeUtf8>',
506+
dictionaryValues: ['unused', 'b', 'a'],
507+
dictionaryOrdered: true,
508+
},
509+
],
447510
},
448511
}),
449512
libraryRow({
450513
id: 'pandas-categorical-json',
451514
call: 'pandas_categorical()',
452515
codec: 'json',
453516
requires: ['pandas'],
454-
status: 'KNOWN_LIE',
455-
currentBehavior: 'JSON preserves values but drops categorical dtype information.',
456-
expected: equal([{ value: 'a' }, { value: 'b' }, { value: 'a' }]),
517+
status: 'LOUD_FAIL',
518+
currentBehavior: 'JSON rejects categorical dtype instead of dropping its vocabulary and order.',
519+
expected: error(/categorical dtype.*Arrow.*astype\(str\)/i),
457520
}),
458521
libraryRow({
459522
id: 'pandas-pyarrow-string-arrow',
@@ -467,6 +530,7 @@ export const RUNTIME_CATALOGUE: readonly CatalogueRow[] = [
467530
kind: 'table-rows',
468531
value: [{ value: 'a' }, { value: null }],
469532
pandasMetadataIncludes: ['"numpy_type": "string"'],
533+
fields: [{ name: 'value', type: 'LargeUtf8', nullCount: 1, validity: [true, false] }],
470534
},
471535
}),
472536
libraryRow({
@@ -475,8 +539,8 @@ export const RUNTIME_CATALOGUE: readonly CatalogueRow[] = [
475539
codec: 'json',
476540
requires: ['pandas', 'pyarrow'],
477541
featureProbe: 'import pandas as pd; pd.Series(["a"], dtype="string[pyarrow]"); print("1")',
478-
status: 'KNOWN_LIE',
479-
currentBehavior: 'JSON preserves values but drops the Arrow-backed string dtype.',
542+
status: 'EXPECTED_OK',
543+
currentBehavior: 'Values-only JSON preserves strings and normalizes pd.NA to null.',
480544
expected: equal([{ value: 'a' }, { value: null }]),
481545
}),
482546
libraryRow({
@@ -490,16 +554,18 @@ export const RUNTIME_CATALOGUE: readonly CatalogueRow[] = [
490554
kind: 'table-rows',
491555
value: [{ when: 1704164645000 }],
492556
pandasMetadataIncludes: ['"timezone": "UTC"'],
557+
fields: [{ name: 'when', type: 'Timestamp<MICROSECOND, UTC>' }],
493558
},
494559
}),
495560
libraryRow({
496561
id: 'pandas-timezone-json',
497562
call: 'pandas_timezone_aware()',
498563
codec: 'json',
499564
requires: ['pandas'],
500-
status: 'KNOWN_LIE',
501-
currentBehavior: 'JSON delivers an ISO string but loses timestamp dtype and timezone schema.',
502-
expected: equal([{ when: '2024-01-02T03:04:05+00:00' }]),
565+
status: 'LOUD_FAIL',
566+
currentBehavior:
567+
'JSON rejects timezone-aware Timestamp cells instead of dropping their dtype schema.',
568+
expected: error(/value of type Timestamp.*Arrow.*astype\(str\)/i),
503569
}),
504570
libraryRow({
505571
id: 'pandas-multiindex-arrow',
@@ -519,9 +585,9 @@ export const RUNTIME_CATALOGUE: readonly CatalogueRow[] = [
519585
call: 'pandas_multiindex()',
520586
codec: 'json',
521587
requires: ['pandas'],
522-
status: 'KNOWN_LIE',
523-
currentBehavior: 'Records-oriented JSON drops the MultiIndex.',
524-
expected: equal([{ value: 3 }]),
588+
status: 'LOUD_FAIL',
589+
currentBehavior: 'JSON rejects MultiIndex instead of silently dropping its levels.',
590+
expected: error(/MultiIndex.*Arrow.*reset_index\(\)/i),
525591
}),
526592
libraryRow({
527593
id: 'pandas-duplicate-labels-arrow',
@@ -537,9 +603,10 @@ export const RUNTIME_CATALOGUE: readonly CatalogueRow[] = [
537603
call: 'pandas_duplicate_labels()',
538604
codec: 'json',
539605
requires: ['pandas'],
540-
status: 'KNOWN_LIE',
541-
currentBehavior: 'Records-oriented JSON silently retains only the last duplicate label.',
542-
expected: equal([{ value: 2 }]),
606+
status: 'LOUD_FAIL',
607+
currentBehavior:
608+
'JSON rejects duplicate column labels instead of silently retaining the last value.',
609+
expected: error(/column labels.*unique after JSON object-key coercion.*astype\(str\)/i),
543610
}),
544611
libraryRow({
545612
id: 'pandas-empty-frame',
@@ -559,8 +626,8 @@ export const RUNTIME_CATALOGUE: readonly CatalogueRow[] = [
559626
call: 'pandas_empty_frame()',
560627
codec: 'json',
561628
requires: ['pandas'],
562-
status: 'KNOWN_LIE',
563-
currentBehavior: 'Records-oriented JSON loses the empty DataFrame schema.',
629+
status: 'EXPECTED_OK',
630+
currentBehavior: 'The documented values-only JSON case permits an empty DataFrame.',
564631
expected: equal([]),
565632
}),
566633
libraryRow({

0 commit comments

Comments
 (0)