@marimo-team/islands 0.23.3-dev9 → 0.23.4-dev0
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.
- package/dist/{chat-ui-BLFhPclV.js → chat-ui-DEd_Ndal.js} +82 -82
- package/dist/{html-to-image-XYwXqg2E.js → html-to-image-DBosi5GK.js} +2240 -2214
- package/dist/main.js +2627 -2746
- package/dist/{process-output-BDVjDpbu.js → process-output-k-4WHpxz.js} +1 -1
- package/dist/{reveal-component-CrnLosc4.js → reveal-component-CFuofbBD.js} +827 -561
- package/dist/{slide-Dl7Rf496.js → slide-form-DgMI37ES.js} +1729 -894
- package/dist/style.css +1 -1
- package/package.json +1 -1
- package/src/components/editor/file-tree/renderers.tsx +1 -1
- package/src/components/editor/output/JsonOutput.tsx +187 -4
- package/src/components/editor/output/__tests__/JsonOutput-mimetype.test.tsx +80 -0
- package/src/components/editor/output/__tests__/json-output.test.ts +185 -2
- package/src/components/editor/renderers/slides-layout/__tests__/compute-slide-cells.test.ts +150 -0
- package/src/components/editor/renderers/slides-layout/__tests__/plugin.test.ts +298 -0
- package/src/components/editor/renderers/slides-layout/compute-slide-cells.ts +50 -0
- package/src/components/editor/renderers/slides-layout/plugin.tsx +54 -9
- package/src/components/editor/renderers/slides-layout/slides-layout.tsx +30 -12
- package/src/components/editor/renderers/slides-layout/types.ts +31 -3
- package/src/components/editor/renderers/types.ts +2 -0
- package/src/components/slides/__tests__/compose-slides.test.ts +433 -0
- package/src/components/slides/compose-slides.ts +337 -0
- package/src/components/slides/minimap.tsx +133 -12
- package/src/components/slides/reveal-component.tsx +337 -74
- package/src/components/slides/reveal-slides.css +33 -1
- package/src/components/slides/slide-form.tsx +347 -0
- package/src/components/ui/radio-group.tsx +5 -3
- package/src/core/cells/types.ts +2 -0
- package/src/core/islands/__tests__/bridge.test.ts +116 -5
- package/src/core/islands/bridge.ts +5 -1
- package/src/core/layout/layout.ts +6 -2
- package/src/core/static/__tests__/export-context.test.ts +122 -0
- package/src/core/static/__tests__/static-state.test.ts +80 -0
- package/src/core/static/export-context.ts +84 -0
- package/src/core/static/static-state.ts +44 -6
- package/src/plugins/core/RenderHTML.tsx +23 -2
- package/src/plugins/core/__test__/RenderHTML.test.ts +86 -1
- package/src/plugins/core/__test__/trusted-url.test.ts +130 -18
- package/src/plugins/core/sanitize.ts +11 -5
- package/src/plugins/core/trusted-url.ts +32 -10
- package/src/plugins/impl/anywidget/__tests__/widget-binding.test.ts +29 -1
- package/src/plugins/impl/mpl-interactive/__tests__/MplInteractivePlugin.test.tsx +34 -0
- package/src/plugins/impl/panel/__tests__/PanelPlugin.test.ts +35 -2
package/package.json
CHANGED
|
@@ -88,7 +88,7 @@ export const CsvViewer: React.FC<{ contents: string }> = ({ contents }) => {
|
|
|
88
88
|
setPaginationState={setPagination}
|
|
89
89
|
wrapperClassName="h-full justify-between pb-1 px-1"
|
|
90
90
|
pagination={true}
|
|
91
|
-
className="rounded-none border-b flex overflow-hidden"
|
|
91
|
+
className="rounded-none border-b flex flex-col overflow-hidden"
|
|
92
92
|
rowSelection={Objects.EMPTY}
|
|
93
93
|
/>
|
|
94
94
|
);
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
floatType,
|
|
9
9
|
intType,
|
|
10
10
|
JsonViewer,
|
|
11
|
+
type JsonViewerKeyRenderer,
|
|
11
12
|
nullType,
|
|
12
13
|
objectType,
|
|
13
14
|
stringType,
|
|
@@ -17,6 +18,7 @@ import { memo, useState } from "react";
|
|
|
17
18
|
import type { OutputMessage } from "@/core/kernel/messages";
|
|
18
19
|
import { cn } from "@/utils/cn";
|
|
19
20
|
import { copyToClipboard } from "@/utils/copy";
|
|
21
|
+
import { jsonParseWithSpecialChar } from "@/utils/json/json-parser";
|
|
20
22
|
import { isUrl } from "@/utils/urls";
|
|
21
23
|
import { useTheme } from "../../../theme/useTheme";
|
|
22
24
|
import { logNever } from "../../../utils/assertNever";
|
|
@@ -130,6 +132,9 @@ export const JsonOutput: React.FC<Props> = memo(
|
|
|
130
132
|
collapseStringsAfterLength={COLLAPSED_TEXT_LENGTH}
|
|
131
133
|
// leave the default valueTypes as it was - 'python', only 'json' is changed
|
|
132
134
|
valueTypes={valueTypesMap[valueTypes]}
|
|
135
|
+
// Render dict keys that carry Python type info (e.g. `int`, `tuple`).
|
|
136
|
+
// See `_key_formatter` in marimo/_output/formatters/structures.py.
|
|
137
|
+
keyRenderer={valueTypes === "python" ? keyRenderer : undefined}
|
|
133
138
|
// Don't group arrays, it will make the tree view look like there are nested arrays
|
|
134
139
|
groupArraysAfterLength={Number.MAX_SAFE_INTEGER}
|
|
135
140
|
// Built-in clipboard shifts content on hover
|
|
@@ -207,7 +212,10 @@ const LEAF_RENDERERS: Record<string, LeafRenderer> = {
|
|
|
207
212
|
),
|
|
208
213
|
"text/plain+float:": (value) => <span>{value}</span>,
|
|
209
214
|
"text/plain+bigint:": (value) => <span>{value}</span>,
|
|
210
|
-
"text/plain+set:": (value) => <span>
|
|
215
|
+
"text/plain+set:": (value) => <span>{formatSetPayload(value)}</span>,
|
|
216
|
+
"text/plain+frozenset:": (value) => (
|
|
217
|
+
<span>{formatFrozensetPayload(value)}</span>
|
|
218
|
+
),
|
|
211
219
|
"text/plain+tuple:": (value) => <span>{value}</span>,
|
|
212
220
|
"text/plain:": (value) => <CollapsibleTextOutput text={value} />,
|
|
213
221
|
"application/json:": (value) => (
|
|
@@ -375,6 +383,128 @@ function renderLeaf(leaf: string, render: LeafRenderer): React.ReactNode {
|
|
|
375
383
|
return <span>{leaf}</span>;
|
|
376
384
|
}
|
|
377
385
|
|
|
386
|
+
// Prefix marking keys that carry encoded type information from Python.
|
|
387
|
+
// See `_key_formatter` in marimo/_output/formatters/structures.py.
|
|
388
|
+
const KEY_ENCODED_PREFIX = "text/plain+";
|
|
389
|
+
|
|
390
|
+
// Format elements for a Python collection literal. Non-finite floats
|
|
391
|
+
// (NaN / Infinity / -Infinity) parse as JS `number` via
|
|
392
|
+
// `jsonParseWithSpecialChar`; `JSON.stringify` on those returns `null`,
|
|
393
|
+
// so render them as the same `float(...)` literals we use for scalar
|
|
394
|
+
// float keys (see `decodeKeyForCopy`).
|
|
395
|
+
function formatCollectionItems(items: unknown[]): string {
|
|
396
|
+
return items
|
|
397
|
+
.map((x) => {
|
|
398
|
+
if (typeof x === "number" && !Number.isFinite(x)) {
|
|
399
|
+
if (Number.isNaN(x)) {
|
|
400
|
+
return "float('nan')";
|
|
401
|
+
}
|
|
402
|
+
return x > 0 ? "float('inf')" : "-float('inf')";
|
|
403
|
+
}
|
|
404
|
+
return JSON.stringify(x);
|
|
405
|
+
})
|
|
406
|
+
.join(", ");
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// Format a JSON-list payload as a Python tuple literal. 1-element tuples
|
|
410
|
+
// need a trailing comma — `(1)` is just `1` in Python, `(1,)` is the tuple.
|
|
411
|
+
// Uses `jsonParseWithSpecialChar` so bare `NaN`/`Infinity`/`-Infinity`
|
|
412
|
+
// emitted by Python's json.dumps round-trip cleanly.
|
|
413
|
+
function formatTuplePayload(jsonList: string): string {
|
|
414
|
+
const items = jsonParseWithSpecialChar<unknown[]>(jsonList);
|
|
415
|
+
// `jsonParseWithSpecialChar` returns `{}` when both parse passes fail;
|
|
416
|
+
// fall back to the raw payload so a malformed wire form doesn't crash
|
|
417
|
+
// rendering/copy. Matches the defensive pattern in `formatSetPayload`.
|
|
418
|
+
if (!Array.isArray(items)) {
|
|
419
|
+
return jsonList;
|
|
420
|
+
}
|
|
421
|
+
if (items.length === 0) {
|
|
422
|
+
return "()";
|
|
423
|
+
}
|
|
424
|
+
const inner = formatCollectionItems(items);
|
|
425
|
+
if (items.length === 1) {
|
|
426
|
+
return `(${inner},)`;
|
|
427
|
+
}
|
|
428
|
+
return `(${inner})`;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
// Format a JSON-list payload as a Python frozenset literal. Empty → `frozenset()`
|
|
432
|
+
// rather than `frozenset({})` (which reads like a dict).
|
|
433
|
+
function formatFrozensetPayload(jsonList: string): string {
|
|
434
|
+
const items = jsonParseWithSpecialChar<unknown[]>(jsonList);
|
|
435
|
+
if (!Array.isArray(items)) {
|
|
436
|
+
return jsonList;
|
|
437
|
+
}
|
|
438
|
+
if (items.length === 0) {
|
|
439
|
+
return "frozenset()";
|
|
440
|
+
}
|
|
441
|
+
const inner = formatCollectionItems(items);
|
|
442
|
+
return `frozenset({${inner}})`;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
// Format a JSON-list payload as a Python set literal. Empty → `set()`
|
|
446
|
+
// (not `{}`, which is a dict literal in Python).
|
|
447
|
+
function formatSetPayload(jsonList: string): string {
|
|
448
|
+
const items = jsonParseWithSpecialChar<unknown[]>(jsonList);
|
|
449
|
+
if (!Array.isArray(items)) {
|
|
450
|
+
// Back-compat: older wire form was `text/plain+set:{1, 2, 3}` (Python
|
|
451
|
+
// set-literal string, not JSON). Pass it through as-is rather than crash.
|
|
452
|
+
return jsonList;
|
|
453
|
+
}
|
|
454
|
+
if (items.length === 0) {
|
|
455
|
+
return "set()";
|
|
456
|
+
}
|
|
457
|
+
const inner = formatCollectionItems(items);
|
|
458
|
+
return `{${inner}}`;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
// Renderers for decoded non-string keys. Visual affordances match Python:
|
|
462
|
+
// unquoted primitives, parens for tuple, `frozenset({...})` for frozenset,
|
|
463
|
+
// and the `text/plain+str:` escape re-quotes the original string.
|
|
464
|
+
const KEY_DECODERS: Record<string, (data: string) => React.ReactNode> = {
|
|
465
|
+
"text/plain+int:": (v) => <span>{v}</span>,
|
|
466
|
+
"text/plain+float:": (v) => <span>{v}</span>,
|
|
467
|
+
"text/plain+bool:": (v) => <span>{v === "True" ? "True" : "False"}</span>,
|
|
468
|
+
"text/plain+none:": () => <span>None</span>,
|
|
469
|
+
"text/plain+tuple:": (v) => <span>{formatTuplePayload(v)}</span>,
|
|
470
|
+
"text/plain+frozenset:": (v) => <span>{formatFrozensetPayload(v)}</span>,
|
|
471
|
+
"text/plain+str:": (v) => <span>"{v}"</span>,
|
|
472
|
+
};
|
|
473
|
+
|
|
474
|
+
function isEncodedKey(key: unknown): key is string {
|
|
475
|
+
return typeof key === "string" && key.startsWith(KEY_ENCODED_PREFIX);
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
// `@textea/json-viewer` drops quotes from integer-like string keys, which
|
|
479
|
+
// makes the string `"2"` visually identical to the decoded int `2`. Match
|
|
480
|
+
// the same keys the viewer strips and render them with explicit quotes.
|
|
481
|
+
const INT_LIKE_STRING = /^-?\d+$/;
|
|
482
|
+
|
|
483
|
+
const keyRenderer: JsonViewerKeyRenderer = Object.assign(
|
|
484
|
+
({ path }: DataItemProps) => {
|
|
485
|
+
const key = path[path.length - 1];
|
|
486
|
+
if (typeof key !== "string") {
|
|
487
|
+
return <span>{String(key)}</span>;
|
|
488
|
+
}
|
|
489
|
+
if (isEncodedKey(key)) {
|
|
490
|
+
const [data, mimeType] = leafDataAndMimeType(key);
|
|
491
|
+
const render = KEY_DECODERS[`${mimeType}:`];
|
|
492
|
+
return render ? render(data) : <span>{key}</span>;
|
|
493
|
+
}
|
|
494
|
+
// Plain integer-like string — quote it so it's distinct from a decoded int.
|
|
495
|
+
return <span>"{key}"</span>;
|
|
496
|
+
},
|
|
497
|
+
{
|
|
498
|
+
when: ({ path }: DataItemProps) => {
|
|
499
|
+
const key = path[path.length - 1];
|
|
500
|
+
return (
|
|
501
|
+
isEncodedKey(key) ||
|
|
502
|
+
(typeof key === "string" && INT_LIKE_STRING.test(key))
|
|
503
|
+
);
|
|
504
|
+
},
|
|
505
|
+
},
|
|
506
|
+
);
|
|
507
|
+
|
|
378
508
|
const MIME_PREFIXES = Object.keys(LEAF_RENDERERS);
|
|
379
509
|
const REPLACE_PREFIX = "<marimo-replace>";
|
|
380
510
|
const REPLACE_SUFFIX = "</marimo-replace>";
|
|
@@ -413,8 +543,10 @@ function pythonJsonReplacer(_key: string, value: unknown): unknown {
|
|
|
413
543
|
return `${REPLACE_PREFIX}(${leafData(value).slice(1, -1)})${REPLACE_SUFFIX}`;
|
|
414
544
|
}
|
|
415
545
|
if (value.startsWith("text/plain+set:")) {
|
|
416
|
-
|
|
417
|
-
|
|
546
|
+
return `${REPLACE_PREFIX}${formatSetPayload(leafData(value))}${REPLACE_SUFFIX}`;
|
|
547
|
+
}
|
|
548
|
+
if (value.startsWith("text/plain+frozenset:")) {
|
|
549
|
+
return `${REPLACE_PREFIX}${formatFrozensetPayload(leafData(value))}${REPLACE_SUFFIX}`;
|
|
418
550
|
}
|
|
419
551
|
|
|
420
552
|
if (MIME_PREFIXES.some((prefix) => value.startsWith(prefix))) {
|
|
@@ -428,10 +560,61 @@ function pythonJsonReplacer(_key: string, value: unknown): unknown {
|
|
|
428
560
|
return value;
|
|
429
561
|
}
|
|
430
562
|
|
|
563
|
+
// Rewrite an encoded key string into the Python literal that should appear
|
|
564
|
+
// unquoted in the copy output. Wrapping in REPLACE_PREFIX/SUFFIX makes the
|
|
565
|
+
// final regex pass strip the surrounding JSON quotes.
|
|
566
|
+
function decodeKeyForCopy(key: string): string {
|
|
567
|
+
const [data, mimeType] = leafDataAndMimeType(key);
|
|
568
|
+
const wrap = (s: string) => `${REPLACE_PREFIX}${s}${REPLACE_SUFFIX}`;
|
|
569
|
+
switch (`${mimeType}:`) {
|
|
570
|
+
case "text/plain+int:":
|
|
571
|
+
return wrap(data);
|
|
572
|
+
case "text/plain+float:":
|
|
573
|
+
if (data === "nan") {
|
|
574
|
+
return wrap("float('nan')");
|
|
575
|
+
}
|
|
576
|
+
if (data === "inf") {
|
|
577
|
+
return wrap("float('inf')");
|
|
578
|
+
}
|
|
579
|
+
if (data === "-inf") {
|
|
580
|
+
return wrap("-float('inf')");
|
|
581
|
+
}
|
|
582
|
+
return wrap(data);
|
|
583
|
+
case "text/plain+bool:":
|
|
584
|
+
return wrap(data === "True" ? "True" : "False");
|
|
585
|
+
case "text/plain+none:":
|
|
586
|
+
return wrap("None");
|
|
587
|
+
case "text/plain+tuple:":
|
|
588
|
+
return wrap(formatTuplePayload(data));
|
|
589
|
+
case "text/plain+frozenset:":
|
|
590
|
+
return wrap(formatFrozensetPayload(data));
|
|
591
|
+
case "text/plain+str:":
|
|
592
|
+
// `data` is the original Python string; it stays quoted.
|
|
593
|
+
return data;
|
|
594
|
+
default:
|
|
595
|
+
return key;
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
function rewriteEncodedKeys(value: unknown): unknown {
|
|
600
|
+
if (Array.isArray(value)) {
|
|
601
|
+
return value.map(rewriteEncodedKeys);
|
|
602
|
+
}
|
|
603
|
+
if (typeof value === "object" && value !== null) {
|
|
604
|
+
const out: Record<string, unknown> = {};
|
|
605
|
+
for (const [k, v] of Object.entries(value)) {
|
|
606
|
+
const newKey = isEncodedKey(k) ? decodeKeyForCopy(k) : k;
|
|
607
|
+
out[newKey] = rewriteEncodedKeys(v);
|
|
608
|
+
}
|
|
609
|
+
return out;
|
|
610
|
+
}
|
|
611
|
+
return value;
|
|
612
|
+
}
|
|
613
|
+
|
|
431
614
|
export function getCopyValue(value: unknown): string {
|
|
432
615
|
// Because this results in valid json, it adds quotes around None and True/False.
|
|
433
616
|
// but we want to make this look like Python, so we remove the quotes.
|
|
434
|
-
return JSON.stringify(value, pythonJsonReplacer, 2)
|
|
617
|
+
return JSON.stringify(rewriteEncodedKeys(value), pythonJsonReplacer, 2)
|
|
435
618
|
.replaceAll(`"${REPLACE_PREFIX}`, "")
|
|
436
619
|
.replaceAll(`${REPLACE_SUFFIX}"`, "");
|
|
437
620
|
}
|
|
@@ -39,4 +39,84 @@ describe("JsonOutput with enhanced mimetype handling", () => {
|
|
|
39
39
|
expect(container).toBeInTheDocument();
|
|
40
40
|
expect(container.querySelector(".marimo-json-output")).toBeInTheDocument();
|
|
41
41
|
});
|
|
42
|
+
|
|
43
|
+
it("renders encoded non-string keys with Python-style affordances", () => {
|
|
44
|
+
// Server-side `_key_formatter` encodes non-string dict keys with
|
|
45
|
+
// mimetype prefixes; the frontend `keyRenderer` must decode them
|
|
46
|
+
// so users see the original Python types (unquoted ints, parens for
|
|
47
|
+
// tuples, etc.) instead of the raw encoded strings.
|
|
48
|
+
const data = {
|
|
49
|
+
"text/plain+int:2": "int_val",
|
|
50
|
+
"text/plain+float:2.5": "float_val",
|
|
51
|
+
"text/plain+bool:True": "bool_val",
|
|
52
|
+
"text/plain+none:": "none_val",
|
|
53
|
+
"text/plain+tuple:[1, 2]": "tuple_val",
|
|
54
|
+
"text/plain+frozenset:[3, 4]": "fs_val",
|
|
55
|
+
"text/plain+str:text/plain+int:2": "escaped_str_val",
|
|
56
|
+
plain: "unchanged",
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const { container } = render(<JsonOutput data={data} format="tree" />);
|
|
60
|
+
const text = container.textContent ?? "";
|
|
61
|
+
|
|
62
|
+
// `text/plain+str:` is the escape prefix — must never survive in output.
|
|
63
|
+
expect(text).not.toContain("text/plain+str:");
|
|
64
|
+
// Other encoded prefixes must not leak as-is. (They can still appear
|
|
65
|
+
// inside the unescaped original string key `"text/plain+int:2"`,
|
|
66
|
+
// which is intentional — but not for types *other* than int.)
|
|
67
|
+
expect(text).not.toContain("text/plain+bool:True");
|
|
68
|
+
expect(text).not.toContain("text/plain+tuple:[");
|
|
69
|
+
expect(text).not.toContain("text/plain+frozenset:[");
|
|
70
|
+
expect(text).not.toContain("text/plain+none:");
|
|
71
|
+
|
|
72
|
+
// Decoded visual forms are present with Python-style affordances.
|
|
73
|
+
expect(text).toContain('None:"none_val"');
|
|
74
|
+
expect(text).toContain('True:"bool_val"');
|
|
75
|
+
expect(text).toContain('2:"int_val"');
|
|
76
|
+
expect(text).toContain('2.5:"float_val"');
|
|
77
|
+
expect(text).toContain('(1, 2):"tuple_val"');
|
|
78
|
+
expect(text).toContain('frozenset({3, 4}):"fs_val"');
|
|
79
|
+
// Escaped str key renders as the original literal string (quoted).
|
|
80
|
+
expect(text).toContain('"text/plain+int:2":"escaped_str_val"');
|
|
81
|
+
// Plain string key unchanged.
|
|
82
|
+
expect(text).toContain('"plain":"unchanged"');
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("renders 1-tuple and empty-frozenset keys with correct Python syntax", () => {
|
|
86
|
+
// Regressions caught in review: `(1)` is not a tuple (needs `(1,)`),
|
|
87
|
+
// and `frozenset({})` reads like constructing from an empty dict
|
|
88
|
+
// (should be `frozenset()`). Locks in the tree-view rendering so these
|
|
89
|
+
// don't slip back.
|
|
90
|
+
const data = {
|
|
91
|
+
"text/plain+tuple:[42]": "one_tuple",
|
|
92
|
+
"text/plain+tuple:[]": "empty_tuple",
|
|
93
|
+
"text/plain+frozenset:[]": "empty_fs",
|
|
94
|
+
"text/plain+frozenset:[1]": "one_fs",
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
const { container } = render(<JsonOutput data={data} format="tree" />);
|
|
98
|
+
const text = container.textContent ?? "";
|
|
99
|
+
|
|
100
|
+
expect(text).toContain('(42,):"one_tuple"'); // trailing comma
|
|
101
|
+
expect(text).toContain('():"empty_tuple"');
|
|
102
|
+
expect(text).toContain('frozenset():"empty_fs"'); // not `frozenset({})`
|
|
103
|
+
expect(text).toContain('frozenset({1}):"one_fs"');
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("quotes integer-like string keys to distinguish them from int keys", () => {
|
|
107
|
+
// Without this, `"2"` and the decoded int `2` look identical — the
|
|
108
|
+
// textea viewer drops quotes from integer-like string keys by default.
|
|
109
|
+
const data = {
|
|
110
|
+
"2": "string_two",
|
|
111
|
+
"text/plain+int:2": "int_two",
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
const { container } = render(<JsonOutput data={data} format="tree" />);
|
|
115
|
+
const text = container.textContent ?? "";
|
|
116
|
+
|
|
117
|
+
expect(text).toContain('"2":"string_two"'); // quoted
|
|
118
|
+
expect(text).toContain('2:"int_two"'); // unquoted
|
|
119
|
+
// Non-integer string keys still render without our intervention.
|
|
120
|
+
expect(text).not.toContain("text/plain+"); // prefix stripped from int key
|
|
121
|
+
});
|
|
42
122
|
});
|
|
@@ -174,7 +174,21 @@ describe("getCopyValue", () => {
|
|
|
174
174
|
it("should handle sets", () => {
|
|
175
175
|
const value = "text/plain+set:[1,2,3]";
|
|
176
176
|
const result = getCopyValue(value);
|
|
177
|
-
expect(result).toMatchInlineSnapshot(`"{1,2,3}"`);
|
|
177
|
+
expect(result).toMatchInlineSnapshot(`"{1, 2, 3}"`);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it("should handle empty set", () => {
|
|
181
|
+
// Empty set literal in Python is `set()`, not `{}` (which is a dict).
|
|
182
|
+
expect(getCopyValue("text/plain+set:[]")).toMatchInlineSnapshot(`"set()"`);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it("should handle frozenset values", () => {
|
|
186
|
+
expect(getCopyValue("text/plain+frozenset:[1,2]")).toMatchInlineSnapshot(
|
|
187
|
+
`"frozenset({1, 2})"`,
|
|
188
|
+
);
|
|
189
|
+
expect(getCopyValue("text/plain+frozenset:[]")).toMatchInlineSnapshot(
|
|
190
|
+
`"frozenset()"`,
|
|
191
|
+
);
|
|
178
192
|
});
|
|
179
193
|
|
|
180
194
|
it("should handle sets in mixed types", () => {
|
|
@@ -188,7 +202,7 @@ describe("getCopyValue", () => {
|
|
|
188
202
|
`
|
|
189
203
|
"{
|
|
190
204
|
"key1": 42,
|
|
191
|
-
"key2": {1,2,3},
|
|
205
|
+
"key2": {1, 2, 3},
|
|
192
206
|
"key3": True
|
|
193
207
|
}"
|
|
194
208
|
`,
|
|
@@ -311,6 +325,175 @@ describe("determineMaxDisplayLength", () => {
|
|
|
311
325
|
});
|
|
312
326
|
});
|
|
313
327
|
|
|
328
|
+
describe("getCopyValue with encoded non-string keys", () => {
|
|
329
|
+
// Keys are encoded by _key_formatter in
|
|
330
|
+
// marimo/_output/formatters/structures.py. Frontend must round-trip them
|
|
331
|
+
// to Python literals in the copy output.
|
|
332
|
+
|
|
333
|
+
it("decodes int keys unquoted", () => {
|
|
334
|
+
// JS reorders integer-like string keys to the front of object iteration
|
|
335
|
+
// (spec-mandated), so `"2"` appears before `"text/plain+int:2"` here.
|
|
336
|
+
// This is pre-existing and unrelated to the encoding — both entries
|
|
337
|
+
// survive, which is the regression this guards.
|
|
338
|
+
const value = { "text/plain+int:2": "no", "2": "oh" };
|
|
339
|
+
expect(getCopyValue(value)).toMatchInlineSnapshot(`
|
|
340
|
+
"{
|
|
341
|
+
"2": "oh",
|
|
342
|
+
2: "no"
|
|
343
|
+
}"
|
|
344
|
+
`);
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
it("decodes large int keys unquoted (no BigInt precision concern)", () => {
|
|
348
|
+
const value = { "text/plain+int:18446744073709551616": "v" };
|
|
349
|
+
expect(getCopyValue(value)).toMatchInlineSnapshot(`
|
|
350
|
+
"{
|
|
351
|
+
18446744073709551616: "v"
|
|
352
|
+
}"
|
|
353
|
+
`);
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
it("decodes float, bool, None, tuple, frozenset keys", () => {
|
|
357
|
+
const value = {
|
|
358
|
+
"text/plain+float:2.5": "f",
|
|
359
|
+
"text/plain+bool:True": "t",
|
|
360
|
+
"text/plain+bool:False": "b",
|
|
361
|
+
"text/plain+none:": "n",
|
|
362
|
+
"text/plain+tuple:[1, 2]": "tup",
|
|
363
|
+
"text/plain+frozenset:[3, 4]": "fs",
|
|
364
|
+
};
|
|
365
|
+
expect(getCopyValue(value)).toMatchInlineSnapshot(`
|
|
366
|
+
"{
|
|
367
|
+
2.5: "f",
|
|
368
|
+
True: "t",
|
|
369
|
+
False: "b",
|
|
370
|
+
None: "n",
|
|
371
|
+
(1, 2): "tup",
|
|
372
|
+
frozenset({3, 4}): "fs"
|
|
373
|
+
}"
|
|
374
|
+
`);
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
it("emits 1-element tuple keys with a trailing comma (Python syntax)", () => {
|
|
378
|
+
// `(1)` is just `1` in Python — a 1-tuple needs `(1,)`.
|
|
379
|
+
const value = {
|
|
380
|
+
"text/plain+tuple:[1]": "one",
|
|
381
|
+
"text/plain+tuple:[]": "empty",
|
|
382
|
+
};
|
|
383
|
+
expect(getCopyValue(value)).toMatchInlineSnapshot(`
|
|
384
|
+
"{
|
|
385
|
+
(1,): "one",
|
|
386
|
+
(): "empty"
|
|
387
|
+
}"
|
|
388
|
+
`);
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
it("emits empty frozenset keys as `frozenset()` not `frozenset({})`", () => {
|
|
392
|
+
// `frozenset({})` reads like it's constructing from an empty dict.
|
|
393
|
+
const value = {
|
|
394
|
+
"text/plain+frozenset:[]": "empty",
|
|
395
|
+
"text/plain+frozenset:[1]": "single",
|
|
396
|
+
};
|
|
397
|
+
expect(getCopyValue(value)).toMatchInlineSnapshot(`
|
|
398
|
+
"{
|
|
399
|
+
frozenset(): "empty",
|
|
400
|
+
frozenset({1}): "single"
|
|
401
|
+
}"
|
|
402
|
+
`);
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
it("decodes NaN/Inf float keys to valid Python literals", () => {
|
|
406
|
+
const value = {
|
|
407
|
+
"text/plain+float:nan": "n",
|
|
408
|
+
"text/plain+float:inf": "p",
|
|
409
|
+
"text/plain+float:-inf": "m",
|
|
410
|
+
};
|
|
411
|
+
expect(getCopyValue(value)).toMatchInlineSnapshot(`
|
|
412
|
+
"{
|
|
413
|
+
float('nan'): "n",
|
|
414
|
+
float('inf'): "p",
|
|
415
|
+
-float('inf'): "m"
|
|
416
|
+
}"
|
|
417
|
+
`);
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
it("parses tuple/frozenset payloads containing bare NaN/Infinity", () => {
|
|
421
|
+
// Python's json.dumps emits bare `NaN`/`Infinity` inside the embedded
|
|
422
|
+
// tuple/frozenset payload strings (JSON spec violation, but ECMA-262-
|
|
423
|
+
// friendly via the fallback in jsonParseWithSpecialChar). The outer
|
|
424
|
+
// JSON stays strict because those tokens live inside a JSON string
|
|
425
|
+
// key/value. Regression for tuple-key payloads that previously broke
|
|
426
|
+
// the frontend's `JSON.parse` and threw.
|
|
427
|
+
const value = {
|
|
428
|
+
"text/plain+tuple:[NaN]": "tn",
|
|
429
|
+
"text/plain+tuple:[Infinity, -Infinity]": "ti",
|
|
430
|
+
k: "text/plain+frozenset:[Infinity, 1]",
|
|
431
|
+
};
|
|
432
|
+
expect(getCopyValue(value)).toMatchInlineSnapshot(`
|
|
433
|
+
"{
|
|
434
|
+
(float('nan'),): "tn",
|
|
435
|
+
(float('inf'), -float('inf')): "ti",
|
|
436
|
+
"k": frozenset({float('inf'), 1})
|
|
437
|
+
}"
|
|
438
|
+
`);
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
it("falls back to the raw payload for malformed tuple/frozenset", () => {
|
|
442
|
+
// `jsonParseWithSpecialChar` returns `{}` on parse failure rather
|
|
443
|
+
// than throwing; without an `Array.isArray` guard, the formatters
|
|
444
|
+
// would crash on `.length`/`.map`. Pass the raw payload through so
|
|
445
|
+
// a malformed wire form doesn't break the whole render.
|
|
446
|
+
const value = {
|
|
447
|
+
"text/plain+tuple:not a json list": "t",
|
|
448
|
+
k: "text/plain+frozenset:also broken",
|
|
449
|
+
};
|
|
450
|
+
expect(getCopyValue(value)).toMatchInlineSnapshot(`
|
|
451
|
+
"{
|
|
452
|
+
not a json list: "t",
|
|
453
|
+
"k": also broken
|
|
454
|
+
}"
|
|
455
|
+
`);
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
it("unescapes string keys that looked encoded", () => {
|
|
459
|
+
const value = {
|
|
460
|
+
"text/plain+str:text/plain+int:2": "hello",
|
|
461
|
+
};
|
|
462
|
+
expect(getCopyValue(value)).toMatchInlineSnapshot(`
|
|
463
|
+
"{
|
|
464
|
+
"text/plain+int:2": "hello"
|
|
465
|
+
}"
|
|
466
|
+
`);
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
it("decodes keys at every nesting level", () => {
|
|
470
|
+
const value = {
|
|
471
|
+
outer: {
|
|
472
|
+
"text/plain+int:1": "inner",
|
|
473
|
+
"text/plain+tuple:[2, 3]": "tup",
|
|
474
|
+
},
|
|
475
|
+
};
|
|
476
|
+
expect(getCopyValue(value)).toMatchInlineSnapshot(`
|
|
477
|
+
"{
|
|
478
|
+
"outer": {
|
|
479
|
+
1: "inner",
|
|
480
|
+
(2, 3): "tup"
|
|
481
|
+
}
|
|
482
|
+
}"
|
|
483
|
+
`);
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
it("leaves plain string keys untouched", () => {
|
|
487
|
+
const value = { foo: 1, bar: 2 };
|
|
488
|
+
expect(getCopyValue(value)).toMatchInlineSnapshot(`
|
|
489
|
+
"{
|
|
490
|
+
"foo": 1,
|
|
491
|
+
"bar": 2
|
|
492
|
+
}"
|
|
493
|
+
`);
|
|
494
|
+
});
|
|
495
|
+
});
|
|
496
|
+
|
|
314
497
|
describe("getCopyValue with application/ mimetypes", () => {
|
|
315
498
|
it("should strip application/ mimetype prefix from leaf data", () => {
|
|
316
499
|
expect(getCopyValue("application/json:{data}")).toBe('"{data}"');
|