Repository navigation
fix(vars): compare array vars element-wise with Python semantics #7656
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,51 @@ | ||
| /** Compare two arrays element-wise with Python list semantics. | ||
| * | ||
| * Elements are compared pairwise in order; the first unequal pair decides | ||
| * the result. If every shared element is equal, the shorter array sorts | ||
| * before the longer one. Nested arrays are compared recursively. | ||
| * | ||
| * Booleans compare as numbers (0 or 1), matching Python where bool is a | ||
| * subclass of int, so [true] and [1] compare equal. | ||
| * | ||
| * Returns NaN when an element pair contains NaN (Python makes every | ||
| * ordering comparison against float("nan") false) or when elements are | ||
| * incomparable (mismatched or unsupported types, where Python raises | ||
| * TypeError). Either way every ordering result is false instead of a | ||
| * silent string comparison. | ||
| */ | ||
| export default function compareArrays(lhs, rhs) { | ||
| const sharedLength = Math.min(lhs.length, rhs.length); | ||
| for (let i = 0; i < sharedLength; i++) { | ||
| const left = lhs[i]; | ||
| const right = rhs[i]; | ||
| if (left === right) { | ||
| continue; | ||
| } | ||
| if (Array.isArray(left) && Array.isArray(right)) { | ||
| const nested = compareArrays(left, right); | ||
| if (nested !== 0) { | ||
| return nested; | ||
| } | ||
| continue; | ||
| } | ||
| const leftIsNumeric = typeof left === "number" || typeof left === "boolean"; | ||
| const rightIsNumeric = | ||
| typeof right === "number" || typeof right === "boolean"; | ||
| if (leftIsNumeric && rightIsNumeric) { | ||
| const leftNumber = Number(left); | ||
| const rightNumber = Number(right); | ||
| if (Number.isNaN(leftNumber) || Number.isNaN(rightNumber)) { | ||
| return Number.NaN; | ||
| } | ||
| if (leftNumber === rightNumber) { | ||
| continue; | ||
| } | ||
| return leftNumber < rightNumber ? -1 : 1; | ||
| } | ||
| if (typeof left === "string" && typeof right === "string") { | ||
| return left < right ? -1 : 1; | ||
| } | ||
| return Number.NaN; | ||
| } | ||
| return Math.sign(lhs.length - rhs.length); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -1810,6 +1810,13 @@ def array_reverse_operation( | |
| ) | ||
|
|
||
|
|
||
| _ARRAY_COMPARE_IMPORT: ImportDict = { | ||
| "$/utils/helpers/array.js": [ | ||
| ImportVar(tag="compareArrays", is_default=True, install=False) | ||
| ], | ||
| } | ||
|
|
||
|
|
||
| @var_operation | ||
| def array_lt_operation(lhs: ArrayVar | list | tuple, rhs: ArrayVar | list | tuple): | ||
| """Check if an array is less than another array. | ||
|
|
@@ -1821,7 +1828,11 @@ def array_lt_operation(lhs: ArrayVar | list | tuple, rhs: ArrayVar | list | tupl | |
| Returns: | ||
| The array less than operation. | ||
| """ | ||
| return var_operation_return(js_expression=f"{lhs!s} < {rhs!s}", var_type=bool) | ||
| return var_operation_return( | ||
| js_expression=f"(compareArrays({lhs!s}, {rhs!s}) < 0)", | ||
| var_type=bool, | ||
| var_data=VarData(imports=_ARRAY_COMPARE_IMPORT), | ||
|
Comment on lines
+1831
to
+1834
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This user-facing array ordering change has no bugfix fragment under Context Used: CLAUDE.md (source) Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time! |
||
| ) | ||
|
|
||
|
|
||
| @var_operation | ||
|
|
@@ -1835,7 +1846,11 @@ def array_gt_operation(lhs: ArrayVar | list | tuple, rhs: ArrayVar | list | tupl | |
| Returns: | ||
| The array greater than operation. | ||
| """ | ||
| return var_operation_return(js_expression=f"{lhs!s} > {rhs!s}", var_type=bool) | ||
| return var_operation_return( | ||
| js_expression=f"(compareArrays({lhs!s}, {rhs!s}) > 0)", | ||
| var_type=bool, | ||
| var_data=VarData(imports=_ARRAY_COMPARE_IMPORT), | ||
| ) | ||
|
|
||
|
|
||
| @var_operation | ||
|
|
@@ -1849,7 +1864,11 @@ def array_le_operation(lhs: ArrayVar | list | tuple, rhs: ArrayVar | list | tupl | |
| Returns: | ||
| The array less than or equal operation. | ||
| """ | ||
| return var_operation_return(js_expression=f"{lhs!s} <= {rhs!s}", var_type=bool) | ||
| return var_operation_return( | ||
| js_expression=f"(compareArrays({lhs!s}, {rhs!s}) <= 0)", | ||
| var_type=bool, | ||
| var_data=VarData(imports=_ARRAY_COMPARE_IMPORT), | ||
| ) | ||
|
|
||
|
|
||
| @var_operation | ||
|
|
@@ -1863,7 +1882,11 @@ def array_ge_operation(lhs: ArrayVar | list | tuple, rhs: ArrayVar | list | tupl | |
| Returns: | ||
| The array greater than or equal operation. | ||
| """ | ||
| return var_operation_return(js_expression=f"{lhs!s} >= {rhs!s}", var_type=bool) | ||
| return var_operation_return( | ||
| js_expression=f"(compareArrays({lhs!s}, {rhs!s}) >= 0)", | ||
| var_type=bool, | ||
| var_data=VarData(imports=_ARRAY_COMPARE_IMPORT), | ||
| ) | ||
|
|
||
|
|
||
| @var_operation | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -413,6 +413,34 @@ def test_datetime_comparison_preserves_microseconds(): | |
| ) | ||
|
|
||
|
|
||
| @pytest.mark.parametrize( | ||
| ("operation", "operator"), | ||
| [ | ||
| (op.lt, "<"), | ||
| (op.le, "<="), | ||
| (op.gt, ">"), | ||
| (op.ge, ">="), | ||
| ], | ||
| ) | ||
| def test_array_comparison_uses_compare_arrays(operation, operator): | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The new array comparison tests cover Context Used: CLAUDE.md (source) Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Neither Context Used: CLAUDE.md (source) Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time! |
||
| lhs = cast(ArrayVar[list[int]], v([1, 10, 0])) | ||
|
|
||
| assert str(operation(lhs, [1, 9, 0])) == ( | ||
| f"(compareArrays([1, 10, 0], [1, 9, 0]) {operator} 0)" | ||
| ) | ||
|
|
||
|
|
||
| def test_array_comparison_imports_compare_arrays(): | ||
| lhs = cast(ArrayVar[list[int]], v([1, 10, 0])) | ||
| comparison = cast(Var, lhs < [1, 9, 0]) | ||
| var_data = comparison._get_all_var_data() | ||
|
|
||
| assert var_data is not None | ||
| assert dict(var_data.imports)["$/utils/helpers/array.js"] == ( | ||
| ImportVar(tag="compareArrays", is_default=True, install=False), | ||
| ) | ||
|
|
||
|
|
||
| def test_date_comparison_uses_compare_datetime(): | ||
| lhs = cast(DateTimeVar, v(date(2024, 1, 1))) | ||
| rhs = date(2024, 1, 2) | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,74 @@ | ||
| import assert from "node:assert/strict"; | ||
| import { readFile } from "node:fs/promises"; | ||
| import test from "node:test"; | ||
| import vm from "node:vm"; | ||
|
|
||
| const source = await readFile( | ||
| new URL( | ||
| "../../../packages/reflex-base/src/reflex_base/.templates/web/utils/helpers/array.js", | ||
| import.meta.url, | ||
| ), | ||
| "utf8", | ||
| ); | ||
|
|
||
| /** Evaluate the actual frontend helper module and return its default export. */ | ||
| async function loadCompareArrays() { | ||
| const module = new vm.SourceTextModule(source, { | ||
| identifier: "array.js", | ||
| context: vm.createContext({}), | ||
| }); | ||
| await module.link(() => { | ||
| throw new Error("array.js must not have imports"); | ||
| }); | ||
| await module.evaluate(); | ||
| return module.namespace.default; | ||
| } | ||
|
|
||
| const compareArrays = await loadCompareArrays(); | ||
|
|
||
| test("numeric arrays compare element-wise like Python lists", () => { | ||
| assert.equal(compareArrays([10, 20], [5, 6]), 1); | ||
| assert.equal(compareArrays([2], [10]), -1); | ||
| assert.equal(compareArrays([1, 9], [1, 10]), -1); | ||
| assert.equal(compareArrays([1, 10, 0], [1, 9, 0]), 1); | ||
| }); | ||
|
|
||
| test("equal arrays compare equal and prefix sorts shorter first", () => { | ||
| assert.equal(compareArrays([1, 2], [1, 2]), 0); | ||
| assert.equal(compareArrays([1, 2], [1, 2, 3]), -1); | ||
| assert.equal(compareArrays([1, 2, 3], [1, 2]), 1); | ||
| assert.equal(compareArrays([], []), 0); | ||
| assert.equal(compareArrays([], [1]), -1); | ||
| }); | ||
|
|
||
| test("strings and booleans keep Python ordering", () => { | ||
| assert.equal(compareArrays(["a", "c"], ["a", "b"]), 1); | ||
| assert.equal(compareArrays([true], [false]), 1); | ||
| assert.equal(compareArrays([1, "a"], [1, "b"]), -1); | ||
| }); | ||
|
|
||
| test("nested arrays compare recursively", () => { | ||
| assert.equal(compareArrays([[1, 2], [3]], [[1, 2], [2]]), 1); | ||
| assert.equal(compareArrays([[1, 2]], [[1, 2]]), 0); | ||
| assert.equal(compareArrays([[1]], [[1, 2]]), -1); | ||
| }); | ||
|
|
||
| test("incomparable elements give NaN so ordering is false", () => { | ||
| assert.ok(Number.isNaN(compareArrays([1, "a"], [1, 2]))); | ||
| assert.ok(Number.isNaN(compareArrays([null], [1]))); | ||
| assert.ok(Number.isNaN(compareArrays([[1], 2], [[1], [2]]))); | ||
| }); | ||
|
|
||
| test("NaN elements make every ordering comparison false like Python", () => { | ||
| assert.ok(Number.isNaN(compareArrays([NaN], [1]))); | ||
| assert.ok(Number.isNaN(compareArrays([NaN], [NaN]))); | ||
| assert.ok(Number.isNaN(compareArrays([1, NaN], [1, 2]))); | ||
| }); | ||
|
|
||
| test("booleans compare numerically like Python bool subclassing int", () => { | ||
| assert.equal(compareArrays([true], [1]), 0); | ||
| assert.equal(compareArrays([true], [2]), -1); | ||
| assert.equal(compareArrays([false], [1]), -1); | ||
| assert.equal(compareArrays([0.5], [true]), -1); | ||
| assert.equal(compareArrays([1], [true]), 0); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,33 @@ | ||
| """Regression tests for the array.js comparison helper.""" | ||
|
|
||
| import shutil | ||
| import subprocess | ||
| from pathlib import Path | ||
|
|
||
| import pytest | ||
|
|
||
| ARRAY_HELPERS_TEMPLATE = ( | ||
| Path(__file__).parents[3] | ||
| / "packages/reflex-base/src/reflex_base/.templates/web/utils/helpers/array.js" | ||
| ) | ||
|
|
||
|
|
||
| def test_compare_arrays_matches_python_list_semantics() -> None: | ||
| """Execute the helper behavior tests against the real template module.""" | ||
| assert ARRAY_HELPERS_TEMPLATE.is_file() | ||
| node = shutil.which("node") | ||
| if node is None: | ||
| pytest.skip("Node.js is required for the frontend runtime tests") | ||
| return | ||
| result = subprocess.run( | ||
| [ | ||
| node, | ||
| "--experimental-vm-modules", | ||
| str(Path(__file__).with_name("array_helpers.test.mjs")), | ||
| ], | ||
| capture_output=True, | ||
| text=True, | ||
| timeout=30, | ||
| check=False, | ||
| ) | ||
| assert result.returncode == 0, result.stdout + result.stderr |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
P2: This numeric path does not preserve Python’s arbitrary-precision integer ordering: values above
Number.MAX_SAFE_INTEGERhave already collapsed to the same JavaScriptNumberbeforecompareArrayscompares them. Preserve integer precision in the frontend representation or explicitly reject/document this unsupported range so the helper does not claim Python list semantics for it.Prompt for AI agents