Skip to content

type_methods: read the locale for the 'n' presentation type - #1147

Merged
youknowone merged 1 commit into
mainfrom
str
Aug 10, 2026
Merged

type_methods: read the locale for the 'n' presentation type#1147
youknowone merged 1 commit into
mainfrom
str

Conversation

@youknowone

@youknowone youknowone commented Aug 10, 2026

Copy link
Copy Markdown
Owner

format(x, 'n') never consulted the locale. With LC_ALL=en_US.UTF-8:

format(123456789, 'n')     -> '123456789'     # CPython: '123,456,789'
format(1234.5, 'n')        -> '1234.5'        # CPython: '1,234.5'
'{:,d}'.format(123456789)  -> '123,456,789'   # the insertion machinery works
locale.localeconv()        -> grouping [3, 0], thousands_sep ','   # the data is there

This is the failure behind test_format.test_locale, which #1138 moved into the
gated set when it refreshed 91 baseline entries — the gate has been red on main
since (see #1138 (comment)).

Why it could not work

The integer path inserted a separator only inside
if let Some(separator) = p.grouping, which 'n' never enters — it carries no
, or _ — and the group size came from a per-radix constant with no channel
for a locale's grouping vector. The float path delegated render, group and pad
end-to-end to the shared engine, which treats 'n' as 'g'.

The port

Upstream splits this across three routines and this ports all three:

new upstream
get_locale newformat.py:642-658 _get_locale
group_digits :738-778 _group_digits
fill_digits :727-736 _fill_digits
numeric_formatting rlocale.py:173-178 (new module/_locale/rlocale.rs)

_get_locale branches on the presentation code — 'n' takes the current
locale, an explicit ,/_ takes that separator at a fixed group size (four for
the power-of-two radices, three otherwise), anything else carries a stop
sentinel — and one insertion routine serves all of them. So
separate_integer_digits (RustPython-shaped) goes away and the ,/_ specs
travel the same route as 'n', which is why upstream has one routine and not
two.

The zero fill of a 0= spec belongs inside the grouping, not after it.
format(1234, '012n') is '0,000,001,234' — thirteen characters from a width of
twelve, because the padding digits are separated like any other. That is what
_calc_num_width's n_min_width (:695-704) carries, and both call sites now
compute it: width - (sign + prefix) for integers,
width - (sign + decimal point + remainder) for floats.

The float path grows the split _format_float performs (:1004-1044): render
unpadded, take the sign off, separate the integer digit run alone, re-emit the
decimal point as the locale's, leave the fraction and any exponent in the
remainder — so format(1e300, 'n') keeps its exponent ungrouped. Zero padding is
rejected for complex specs, so the per-lane complex_component_spec split
reaches _group_digits only at n_min_width 0 and needs nothing further.

numeric_formatting sits beside the _locale module port so it shares that
module's raw localeconv() walk: the grouping format(x, 'n') separates by and
the grouping locale.localeconv() reports come out of one read. The walk keeps a
CHAR_MAX element, which rustpython_host_env::locale's reader drops —
dropping it collapses "stop" onto "repeat the last group". Off unix, without
host_env, and under sandbox the C locale's values stand in; upstream declares
localeconv sandboxsafe=True and reads the host locale even there, but pyre's
sandbox build replaces _locale's host entry points with raising stubs and
format() must not acquire a raising path.

Verification

  • Differential against CPython 3.14.6 — byte-identical. 108 format cases in
    two locales (en_US.UTF-8 and C), covering the regression paths as well:
    ,/_, every radix, # prefixes, floats, complex, and the ValueError
    messages.
  • CPython gate suite: PASS 200, FAIL 0, no regressions — the same 200 gated
    modules CI runs, i.e. the check that is currently red.
  • cargo test -p pyre-interpreter --features dynasm: 511 passed, 0 failed.
  • Six new unit tests pin _group_digits against values produced by running the
    vendored upstream source: the zero-fill widths, the repeat-the-last-group rule,
    the stop sentinel, and a multi-byte separator surviving the buffer reverse.
    test_format.test_locale asserts only that the separator appears, so it covers
    none of those.

Full local check.py across all three backends was still running when this was
opened; CI covers the same ground.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added locale-aware numeric formatting for integers and floating-point values.
    • Number grouping now follows locale settings, including custom group sizes, repeated patterns, multibyte separators, and zero-padding.
    • Locale-specific decimal and thousands separators are preserved in supported environments.
  • Bug Fixes

    • Improved handling of grouping boundaries, signs, fractions, exponents, and trailing grouping markers.
    • Formatting now correctly handles special locale grouping values without truncating them.

`format(x, 'n')` never consulted the locale.  The integer path inserted a
separator only inside `if let Some(separator) = p.grouping`, which `'n'` never
enters — it carries no `,` or `_` — and the group size came from a per-radix
constant with no channel for a locale's grouping vector.  The float path
delegated render, group and pad end-to-end to the shared engine, which treats
`'n'` as `'g'`.  Under `LC_ALL=en_US.UTF-8`, `format(123456789, 'n')` was
`'123456789'` and `format(1234.5, 'n')` was `'1234.5'`.

Port the three routines upstream splits this across.  `_get_locale`
(`newformat.py:642-658`) branches on the presentation code: `'n'` takes the
current locale, an explicit `,`/`_` takes that separator at a fixed group size
— four for the power-of-two radices, three otherwise — and anything else
carries a stop sentinel.  `_group_digits` (`:738-778`) and `_fill_digits`
(`:727-736`) perform the insertion for all of those, so `separate_integer_digits`
goes away and the `,`/`_` specs travel the same route as `'n'`.

The zero fill of a `0=` spec belongs inside the grouping rather than after it:
`format(1234, '012n')` is `'0,000,001,234'`, thirteen characters from a width
of twelve, because the padding digits are separated like any other.  That is
what `_calc_num_width`'s `n_min_width` (`:695-704`) carries, and both call
sites compute it — `width - (sign + prefix)` for integers,
`width - (sign + decimal point + remainder)` for floats, matching
`extra_length`.

The float path grows the split `_format_float` performs (`:1004-1044`): render
unpadded, take the sign off, separate the integer digit run alone, re-emit the
decimal point as the locale's, and leave the fraction and any exponent in the
remainder, so `format(1e300, 'n')` keeps its exponent ungrouped.  Zero padding
is rejected for complex specs, so the per-lane `complex_component_spec` split
reaches `_group_digits` only at `n_min_width` 0 and needs nothing further.

`numeric_formatting` (`rlocale.py:173-178`) is new, placed beside the `_locale`
module port so it shares that module's raw `localeconv()` walk: the grouping
`format(x, 'n')` separates by and the grouping `locale.localeconv()` reports
come out of one read.  The walk keeps a `CHAR_MAX` element, which
`rustpython_host_env::locale`'s reader drops — dropping it collapses "stop"
onto "repeat the last group".  Off unix, without `host_env`, and under sandbox
the C locale's values stand in; upstream declares `localeconv`
`sandboxsafe=True` and reads the host locale even there, but pyre's sandbox
build replaces `_locale`'s host entry points with raising stubs and `format()`
must not acquire a raising path.

Six unit tests pin `_group_digits` against values taken from the vendored
source: the zero-fill widths, the repeat-the-last-group rule, the stop
sentinel, and a multi-byte separator surviving the buffer reverse.  None are
covered by `test_format.test_locale`, which asserts only that the separator
appears — the test that made this visible when #1138 promoted its module into
the gated set.

Assisted-by: Claude
@coderabbitai

coderabbitai Bot commented Aug 10, 2026

Copy link
Copy Markdown

Review Change Stack

Walkthrough

The change adds locale numeric metadata access and shared grouping logic. Integer formatting now uses locale grouping and separator data. Float formatting with presentation type n groups integer digits and restores locale decimal separators.

Changes

Locale-aware numeric formatting

Layer / File(s) Summary
Locale data bridge
pyre/pyre-interpreter/src/module/_locale/...
The _locale module exposes rlocale. The new helpers read decimal points, thousands separators, and grouping from libc::localeconv(). C-string conversion preserves CHAR_MAX bytes.
Shared integer grouping
pyre/pyre-interpreter/src/type_methods.rs
Integer formatting resolves locale grouping and applies shared grouping logic for repeated group sizes, sentinels, multibyte separators, signs, prefixes, and zero-padding.
Locale-aware float presentation
pyre/pyre-interpreter/src/type_methods.rs
Float presentation type n groups only integer digits, restores the locale decimal separator, and accounts for fractions, exponents, signs, and padding.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Formatter as Integer or float formatter
  participant Locale as get_locale
  participant RLocale as numeric_formatting()
  participant Host as libc::localeconv()
  Formatter->>Locale: request locale grouping metadata
  Locale->>RLocale: read numeric formatting
  RLocale->>Host: call localeconv()
  Host-->>RLocale: return locale pointers
  RLocale-->>Locale: return separators and grouping
  Locale-->>Formatter: provide grouping metadata
  Formatter->>Formatter: group digits and apply padding
Loading

Poem

A rabbit sorts digits in rows,
With commas and decimals where each locale goes.
CHAR_MAX stays in the line,
Signs and prefixes align,
And grouped floats finish with a shine.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly identifies the primary change: locale-aware handling for the 'n' numeric presentation type.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch str

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions

Copy link
Copy Markdown

🤖 Codex parity review

Static analysis of this diff vs the local RPython/PyPy sources (commit b489a84).
Updated: 2026-08-10T21:10:17.900Z

Files in the reviewed diff
pyre/pyre-interpreter/src/module/_locale/interp_locale.rs
pyre/pyre-interpreter/src/module/_locale/mod.rs
pyre/pyre-interpreter/src/module/_locale/rlocale.rs
pyre/pyre-interpreter/src/type_methods.rs

1. Regressions to PyPy parity introduced by this patch

None.

2. Other mismatches introduced by this patch

None.

3. Pre-existing mismatches (already present before this patch)

None.

4. Structural adaptations

  • pyre/pyre-interpreter/src/type_methods.rs:2340 ↔ pypy/objspace/std/newformat.py:642: Rust converts locale byte strings with String::from_utf8_lossy, whereas PyPy retains RPython byte strings. A non-UTF-8 locale decimal/thousands separator therefore becomes U+FFFD in pyre rather than PyPy’s byte-preserving behavior. This is a Rust UTF-8-string representation adaptation.

  • pyre/pyre-interpreter/src/module/_locale/rlocale.rs:41 ↔ rpython/rlib/rlocale.py:173: pyre returns fixed C-locale values when Unix host access is unavailable or in the sandbox; RPython always calls its sandbox-safe localeconv() external. This preserves pyre’s sandbox boundary and avoids making format(..., 'n') acquire a host-dependent or raising path.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

https://github.com/youknowone/pyre/blob/b489a84e865ccfe7d9acf7af481e97ba95f8422b/pyre-interpreter/src/type_methods.rs#L2348-L2349
P2 Badge Decode locale bytes using the active locale encoding

When LC_NUMERIC uses a legacy non-UTF-8 encoding and its decimal point or thousands separator contains a high byte (for example an NBSP encoded as 0xA0), from_utf8_lossy replaces that byte with U+FFFD, so format(value, 'n') emits replacement characters instead of the locale's separator. Decode these values using the active locale codeset rather than lossy UTF-8; the current conversion is also a deliberate divergence from the required upstream structural parity.

AGENTS.md reference: AGENTS.md:L231-L233

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
pyre/pyre-interpreter/src/module/_locale/interp_locale.rs (1)

206-221: 🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

Read all locale fields from one localeconv() snapshot.

rustpython_host_env::locale::localeconv_data() preserves separator bytes correctly. If setlocale() changes the locale between its localeconv() call and the later libc::localeconv() call, the result combines fields from different locales. Reuse one snapshot for all fields.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@pyre/pyre-interpreter/src/module/_locale/interp_locale.rs` around lines 206 -
221, Update the locale-data construction around the grouping_of closure to
obtain one rustpython_host_env::locale::localeconv_data() snapshot and derive
every locale field from it, including grouping bytes, decimal/thousands
separators, and monetary fields. Remove the later direct libc::localeconv() read
so all returned values come from the same snapshot.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@pyre/pyre-interpreter/src/type_methods.rs`:
- Around line 2420-2451: Update group_digits and the corresponding vendored
_group_digits implementation to stop immediately when loc_grouping is empty, or
normalize it to the existing 0xFF terminator representation before the loop.
Preserve normal grouping behavior and add a regression test covering a non-empty
loc_thousands with empty loc_grouping, verifying termination without repeated
separators or unbounded output.

---

Outside diff comments:
In `@pyre/pyre-interpreter/src/module/_locale/interp_locale.rs`:
- Around line 206-221: Update the locale-data construction around the
grouping_of closure to obtain one rustpython_host_env::locale::localeconv_data()
snapshot and derive every locale field from it, including grouping bytes,
decimal/thousands separators, and monetary fields. Remove the later direct
libc::localeconv() read so all returned values come from the same snapshot.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: c7bb6b0f-1443-4f36-a9d1-f554e2194763

📥 Commits

Reviewing files that changed from the base of the PR and between 863e645 and b489a84.

📒 Files selected for processing (4)
  • pyre/pyre-interpreter/src/module/_locale/interp_locale.rs
  • pyre/pyre-interpreter/src/module/_locale/mod.rs
  • pyre/pyre-interpreter/src/module/_locale/rlocale.rs
  • pyre/pyre-interpreter/src/type_methods.rs

Comment on lines +2420 to +2451
loop {
let group = if grouping_state >= loc_grouping.len() {
previous
} else {
let group = i32::from(loc_grouping[grouping_state]);
if group == 0xFF {
break;
}
grouping_state += 1;
previous = group;
group
};
let final_grouping = group.min(left.max(min_width.max(1)));
let n_zeros = (final_grouping - left).max(0);
let n_chars = left.min(final_grouping).max(0);
fill_digits(
&mut buf,
digits,
left,
n_chars,
n_zeros,
need_separator.then_some(loc_thousands),
);
need_separator = true;
left -= n_chars;
min_width -= final_grouping;
if left <= 0 && min_width <= 0 {
done = true;
break;
}
min_width -= n_ts;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Show the upstream _group_digits loop and the rlocale numeric_formatting source.
fd -t f 'newformat.py' --exec-batch rg -n -C6 'def _group_digits|def _get_locale|def _fill_digits|previous'
fd -t f 'rlocale.py' --exec-batch rg -n -C6 'def numeric_formatting|grouping'

Repository: youknowone/pyre

Length of output: 9214


🏁 Script executed:

#!/bin/bash
set -e
printf '%s\n' '--- Rust implementation and callers ---'
sed -n '2325,2465p' pyre/pyre-interpreter/src/type_methods.rs
printf '%s\n' '--- locale and grouping conversion ---'
sed -n '2500,2605p' pyre/pyre-interpreter/src/type_methods.rs
rg -n -C5 'numeric_formatting|loc_grouping|group_digits|format_rbigint|thousands_sep' pyre/pyre-interpreter/src pypy rpython
printf '%s\n' '--- upstream loop remainder ---'
sed -n '740,785p' pypy/objspace/std/newformat.py
printf '%s\n' '--- behavioral probe ---'
python3 - <<'PY'
def ported(grouping, thousands, left=4, min_width=0, limit=20):
    state = 0
    previous = 0
    separators = 0
    for iteration in range(limit):
        if state >= len(grouping):
            group = previous
        else:
            group = grouping[state]
            if group == 0xFF:
                return ("break", iteration, separators)
            state += 1
            previous = group
        final_grouping = min(group, max(left, max(min_width, 1)))
        n_chars = max(0, min(left, final_grouping))
        left -= n_chars
        min_width -= final_grouping
        if left <= 0 and min_width <= 0:
            return ("done", iteration + 1, separators)
        min_width -= len(thousands)
        separators += 1 if iteration else 0
    return ("nonterminating-within-limit", limit, separators, left, min_width)

for grouping, thousands in [([], ","), ([], ""), ([3], ","), ([0xFF], ",")]:
    print(grouping, repr(thousands), ported(grouping, thousands))
PY

Repository: youknowone/pyre

Length of output: 50371


🏁 Script executed:

#!/bin/bash
set -e
printf '%s\n' '--- compact upstream loop ---'
sed -n '747,785p' pypy/objspace/std/newformat.py
printf '%s\n' '--- host locale observations ---'
locale -a 2>/dev/null | head -50 || true
python3 - <<'PY'
import locale
print("localeconv:", locale.localeconv())
PY
printf '%s\n' '--- existing locale/format tests ---'
rg -n -C4 "format\(.*['\"]n|LC_NUMERIC|thousands_sep|grouping" \
  pypy/objspace/std/test pyre/pyre-interpreter/src 2>/dev/null | head -250
printf '%s\n' '--- exact loop model ---'
python3 - <<'PY'
def model(grouping, separator, left=4, min_width=0, limit=8):
    previous = 0
    state = 0
    for i in range(limit):
        if state >= len(grouping):
            group = previous
        else:
            group = grouping[state]
            if group == 0xFF:
                return "break"
            state += 1
            previous = group
        final_grouping = min(group, max(left, max(min_width, 1)))
        left -= max(0, min(left, final_grouping))
        min_width -= final_grouping
        if left <= 0 and min_width <= 0:
            return ("done", i + 1)
        min_width -= len(separator)
    return ("no termination", left, min_width)
for grouping, separator in [([], ","), ([], ""), ([3], ","), ([0xFF], ",")]:
    print(repr(grouping), repr(separator), model(grouping, separator))
PY

Repository: youknowone/pyre

Length of output: 22603


Prevent non-termination for an empty locale grouping.

When get_locale('n', ...) returns a non-empty loc_thousands and an empty loc_grouping, group_digits repeatedly uses previous == 0. It never reduces left, appends another separator on each iteration, and grows memory without bound. The vendored _group_digits has the same defect, so preserve the stop behavior for this edge case by normalizing an empty grouping to vec![0xFF] or exiting before the loop. Add a regression test for this locale shape.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@pyre/pyre-interpreter/src/type_methods.rs` around lines 2420 - 2451, Update
group_digits and the corresponding vendored _group_digits implementation to stop
immediately when loc_grouping is empty, or normalize it to the existing 0xFF
terminator representation before the loop. Preserve normal grouping behavior and
add a regression test covering a non-empty loc_thousands with empty
loc_grouping, verifying termination without repeated separators or unbounded
output.

@youknowone
youknowone merged commit f705ef0 into main Aug 10, 2026
17 checks passed
@youknowone
youknowone deleted the str branch August 10, 2026 23:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant