Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/reflex-base/news/6757.bugfix.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
`NumberVar` rounding now matches Python's round-half-even behavior and supports negative `ndigits` values in compiled frontend expressions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
/**
* Round a number using Python's round-half-even semantics.
* Decompose the IEEE 754 value into a rational so ties are compared exactly.
*
* @param {number} value The value to round.
* @param {number} ndigits The decimal precision.
* @returns {number} The rounded value.
*/
export default function pyRound(value, ndigits) {
if (!Number.isFinite(value) || ndigits > 323) return value;
if (ndigits < -308) return value * 0;
Comment on lines +9 to +11

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 No guard for fractional ndigits values

BigInt() throws a RangeError for non-integer floats (e.g. BigInt(2.5)RangeError). Because shift = binaryExponent + ndigits (line 24) uses ndigits directly, any fractional ndigits — such as a Reflex state variable typed as float with a non-integer runtime value like 2.5 — would cause BigInt(shift) to throw and crash the frontend render. Adding if (!Number.isInteger(ndigits)) return value; after the existing early-return guards would make the function robust against this class of misuse.


const view = new DataView(new ArrayBuffer(8));
view.setFloat64(0, Math.abs(value));
const bits = view.getBigUint64(0);
const exponent = Number((bits >> 52n) & 0x7ffn);
const fraction = bits & 0xfffffffffffffn;
const significand = exponent === 0 ? fraction : fraction | 0x10000000000000n;
const binaryExponent = exponent === 0 ? -1074 : exponent - 1075;
const fivePower = 5n ** BigInt(Math.abs(ndigits));

let numerator = significand * (ndigits >= 0 ? fivePower : 1n);
let denominator = ndigits < 0 ? fivePower : 1n;
const shift = binaryExponent + ndigits;
if (shift >= 0) {
numerator <<= BigInt(shift);
} else {
denominator <<= BigInt(-shift);
}

let rounded = numerator / denominator;
const doubledRemainder = (numerator % denominator) * 2n;
if (
doubledRemainder > denominator ||
(doubledRemainder === denominator && rounded % 2n === 1n)
) {
rounded += 1n;
}

const sign = value < 0 || Object.is(value, -0) ? "-" : "";
const result = Number(`${sign}${rounded}e${-ndigits}`);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Guard overflow when materializing rounded values

When rounding very large finite values with negative precisions near -308, this conversion silently overflows the exact BigInt result to Infinity. For example, pyRound(Number.MAX_VALUE, -308) returns Infinity, while Python's round(1.7976931348623157e308, -308) raises OverflowError instead of producing a non-finite value. This means compiled frontend expressions can introduce Infinity for finite inputs in cases where the helper is intended to mirror Python semantics; please guard the conversion and handle overflow consistently.

Useful? React with 👍 / 👎.

return result === 0 && sign ? -0 : result;
}
15 changes: 13 additions & 2 deletions packages/reflex-base/src/reflex_base/vars/number.py
Original file line number Diff line number Diff line change
Expand Up @@ -656,6 +656,13 @@ def number_exponent_operation(lhs: NumberVar, rhs: NumberVar):
return f"({lhs} ** {rhs})"


_PY_ROUND_IMPORT: ImportDict = {
"$/utils/helpers/round.js": [
ImportVar(tag="pyRound", is_default=True, install=False)
],
}


@var_operation
def number_round_operation(value: NumberVar, ndigits: NumberVar | int):
"""Round the number.
Expand All @@ -670,9 +677,13 @@ def number_round_operation(value: NumberVar, ndigits: NumberVar | int):
if (isinstance(ndigits, LiteralNumberVar) and ndigits._var_value == 0) or (
isinstance(ndigits, int) and ndigits == 0
):
return var_operation_return(js_expression=f"Math.round({value})", var_type=int)
var_type = int
else:
var_type = float
return var_operation_return(
js_expression=f"(+{value}.toFixed({ndigits}))", var_type=float
js_expression=f"pyRound({value}, {ndigits})",
var_type=var_type,
var_data=VarData(imports=_PY_ROUND_IMPORT),
)


Expand Down
56 changes: 54 additions & 2 deletions tests/units/test_var.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@
import json
import math
import re
import subprocess
import typing
from collections.abc import Mapping, Sequence
from pathlib import Path
from typing import cast

import pytest
Expand Down Expand Up @@ -54,9 +56,14 @@

pytest.importorskip("pydantic")


from pydantic import BaseModel as Base

ROUND_HELPER = (
Path(__file__).parents[2]
/ "packages/reflex-base/src/reflex_base/.templates/web/utils/helpers/round.js"
)


test_vars = [
Var(_js_expr="prop1", _var_type=int),
Var(_js_expr="key", _var_type=str),
Expand Down Expand Up @@ -1038,7 +1045,7 @@ def test_all_number_operations():

assert (
str(even_more_complicated_number)
== "!(isTrue(pyOr(Math.abs(Math.floor(((Math.floor(((-((-5.4 + 1)) * 2) / 3) / 2) % 3) ** 2))), () => (pyAnd(2, () => (Math.round(((Math.floor(((-((-5.4 + 1)) * 2) / 3) / 2) % 3) ** 2))))))))"
== "!(isTrue(pyOr(Math.abs(Math.floor(((Math.floor(((-((-5.4 + 1)) * 2) / 3) / 2) % 3) ** 2))), () => (pyAnd(2, () => (pyRound(((Math.floor(((-((-5.4 + 1)) * 2) / 3) / 2) % 3) ** 2), 0)))))))"
)

assert str(LiteralNumberVar.create(5) > False) == "(5 > 0)"
Expand All @@ -1049,6 +1056,51 @@ def test_all_number_operations():
)


@pytest.mark.parametrize(
("value", "ndigits", "expected"),
[
(2.5, 0, "2"),
(3.5, 0, "4"),
(-2.5, 0, "-2"),
(-3.5, 0, "-4"),
(3.6, 0, "4"),
(250, -2, "200"),
(350, -2, "400"),
(-250, -2, "-200"),
(-350, -2, "-400"),
(2.675, 2, "2.67"),
(5e-324, 323, "0"),
(1e20, 0, "100000000000000000000"),
(1.2345, 324, "1.2345"),
(-1.2345, -309, "-0"),
(float("inf"), 2, "Infinity"),
],
)
def test_number_round_python_semantics(value, ndigits, expected):
rounded = round(LiteralNumberVar.create(value), ndigits)
var_data = rounded._get_all_var_data()
assert var_data is not None
assert any(
imported.tag == "pyRound" and imported.is_default
for imported in dict(var_data.imports)["$/utils/helpers/round.js"]
)

result = subprocess.run(
[
"node",
"--input-type=module",
"--eval",
f"{ROUND_HELPER.read_text(encoding='utf-8')}\nconsole.log({rounded!s})",
],
check=True,
Comment on lines +1075 to +1095

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 test_number_round_python_semantics requires Node.js in PATH

The parametrized test spawns node --input-type=module --eval ... via subprocess.run(check=True, ...). Any environment where node is not on PATH (minimal CI containers, pip install smoke tests, etc.) will cause all 15 parametrized cases to fail with a FileNotFoundError or CalledProcessError, not a proper pytest.skip. Consider guarding with pytest.importorskip or a skipif decorator based on shutil.which("node") so the suite degrades gracefully instead of erroring out.

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!

capture_output=True,
text=True,
)

assert result.stdout.strip() == expected
assert rounded._var_type is (int if ndigits == 0 else float)


@pytest.mark.parametrize(
("var", "expected"),
[
Expand Down
Loading