Skip to content

Commit 57d1c7d

Browse files
eendebakptclaude
andcommitted
gh-NNNN: Wire Python/_fmt and Python/_wuffs into the build
Adds the C-ABI shims and build rules for the two vendored libraries: Python/_fmt/fmt_dtoa.cc fmt-backed _Py_fmt_dtoa / _Py_fmt_dtoa_free (matches _Py_dg_dtoa's calling convention) Python/_wuffs/wuffs_strtod.c wuffs-backed _Py_wuffs_strtod (matches _Py_dg_strtod's calling convention) Both are declared in pycore_dtoa.h alongside the legacy _Py_dg_* entry points; no caller uses them yet — pystrtod.c still routes through dtoa.c. The later commits swap the call sites and then delete dtoa.c. Build details: * fmt_dtoa.cc compiles with -std=c++17 -fno-exceptions -fno-rtti plus FMT_HEADER_ONLY=1 and FMT_OPTIMIZE_SIZE=2. Header-only mode keeps us from needing fmt's compiled format.cc for Dragonbox tables and locale helpers; OPTIMIZE_SIZE=2 stubs out fmt's std::locale lookup so libpython doesn't pull in <locale>. * wuffs_strtod.c compiles as C with WUFFS_CONFIG__MODULE__BASE__CORE/ FLOATCONV/INTCONV defines, so only the floatconv sub-module of the 3.3 MB amalgamated wuffs source actually gets compiled. * LIBS gains -lstdc++ because fmt's allocator references std::bad_alloc/std::runtime_error vtables. This is the price of using a C++ library in libpython; fmt avoids the locale and iostream slices so the runtime dep stays small. The new .o files land in LIBRARY_OBJS alongside Python/dtoa.o — both are live in this commit so libpython has everything it needs whichever path the next commits take. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 41eaea3 commit 57d1c7d

4 files changed

Lines changed: 326 additions & 1 deletion

File tree

Include/internal/pycore_dtoa.h

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,16 @@ extern void _Py_dg_freedtoa(char *s);
3333
extern PyStatus _PyDtoa_Init(PyInterpreterState *interp);
3434
extern void _PyDtoa_Fini(PyInterpreterState *interp);
3535

36+
// Replacements for the two David Gay entry points above. _Py_fmt_dtoa is
37+
// backed by Python/_fmt/ (a vendored trim of fmtlib); _Py_wuffs_strtod is
38+
// backed by Python/_wuffs/ (a vendored trim of Wuffs). Calling conventions
39+
// match _Py_dg_dtoa / _Py_dg_strtod respectively so pystrtod.c can swap them
40+
// in without touching call-site logic.
41+
extern char* _Py_fmt_dtoa(double d, int mode, int ndigits,
42+
int *decpt, int *sign, char **rve);
43+
extern void _Py_fmt_dtoa_free(char *s);
44+
extern double _Py_wuffs_strtod(const char *nptr, char **endptr);
45+
3646

3747
#ifdef __cplusplus
3848
}

Makefile.pre.in

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -294,7 +294,13 @@ PY_ENABLE_SHARED= @PY_ENABLE_SHARED@
294294
STATIC_LIBPYTHON= @STATIC_LIBPYTHON@
295295

296296

297-
LIBS= @LIBS@
297+
LIBS= @LIBS@ -lstdc++
298+
# -lstdc++ is required because Python/_fmt/fmt_dtoa.cc (the float-to-string
299+
# shim backed by fmtlib) references std::bad_alloc / std::runtime_error
300+
# vtables. This is the price of using a C++ library in libpython; fmt takes
301+
# care to avoid the locale and iostream slices (FMT_OPTIMIZE_SIZE=2) so the
302+
# runtime dep is small. Long-term this should move into configure.ac as a
303+
# conditional on "does libpython have a C++ TU".
298304
LIBM= @LIBM@
299305
LIBC= @LIBC@
300306
SYSLIBS= $(LIBM) $(LIBC)
@@ -508,6 +514,8 @@ PYTHON_OBJS= \
508514
Python/pystrtod.o \
509515
Python/pystrhex.o \
510516
Python/dtoa.o \
517+
Python/_fmt/fmt_dtoa.o \
518+
Python/_wuffs/wuffs_strtod.o \
511519
Python/fileutils.o \
512520
Python/suggestions.o \
513521
Python/perf_trampoline.o \
@@ -3230,6 +3238,25 @@ regen-jit:
32303238
Python/dtoa.o: Python/dtoa.c
32313239
$(CC) -c $(PY_CORE_CFLAGS) $(CFLAGS_ALIASING) -o $@ $<
32323240

3241+
# Vendored {fmt} float-to-string path — see Python/_fmt/README.vendor.
3242+
# Compiled as C++17 with exceptions and RTTI disabled so libpython doesn't
3243+
# acquire a libstdc++ runtime dependency beyond what operator new pulls in.
3244+
# PY_CORE_CFLAGS carries a handful of C-only flags (-std=c11,
3245+
# -Werror=implicit-function-declaration, -Wstrict-prototypes) that C++
3246+
# compilers warn about; strip them for this TU.
3247+
_FMT_CXX_CFLAGS = $(filter-out -std=c11 -std=c99 -Werror=implicit-function-declaration -Wstrict-prototypes,$(PY_CORE_CFLAGS))
3248+
Python/_fmt/fmt_dtoa.o: Python/_fmt/fmt_dtoa.cc Python/_fmt/format.h \
3249+
Python/_fmt/format-inl.h Python/_fmt/base.h
3250+
$(CXX) -c $(_FMT_CXX_CFLAGS) -std=c++17 -fno-exceptions -fno-rtti \
3251+
-IPython/_fmt -o $@ $<
3252+
3253+
# Vendored Wuffs string-to-float path — see Python/_wuffs/README.vendor.
3254+
# wuffs-v0.4.c is 3.3 MB of amalgamated C; the WUFFS_CONFIG__MODULE__BASE__*
3255+
# defines restrict compilation to just the floatconv sub-module.
3256+
Python/_wuffs/wuffs_strtod.o: Python/_wuffs/wuffs_strtod.c \
3257+
Python/_wuffs/wuffs-v0.4.c
3258+
$(CC) -c $(PY_CORE_CFLAGS) -IPython/_wuffs -o $@ $<
3259+
32333260
Python/ceval.o: Python/ceval.c
32343261
$(CC) -c $(PY_CORE_CFLAGS) $(CFLAGS_CEVAL) -o $@ $<
32353262

Python/_fmt/fmt_dtoa.cc

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
// Bridge between {fmt}'s float formatting and the _Py_dg_dtoa calling
2+
// convention used throughout Python/pystrtod.c and Objects/floatobject.c.
3+
//
4+
// Exposes a single extern "C" entry point, _Py_fmt_dtoa, with the exact shape
5+
// of _Py_dg_dtoa (the David Gay dtoa that used to live in Python/dtoa.c) so
6+
// existing call sites can swap with a one-line change.
7+
//
8+
// mode 0 -> shortest round-trip decimal (uses fmt::detail::dragonbox)
9+
// mode 2 -> max(1, ndigits) significant digits (uses format_float with
10+
// presentation_type::exp and precision = N - 1)
11+
// mode 3 -> ndigits digits past the decimal point, ndigits may be negative
12+
// (uses format_float with presentation_type::fixed)
13+
//
14+
// Trailing zeros are stripped so the digit string + decpt shape matches
15+
// _Py_dg_dtoa byte-for-byte. The "rounded-to-zero-at-target-precision" case
16+
// for mode 3 is normalised from fmt's "one '0' digit" output to cpython's
17+
// empty-digit + decpt = -ndigits convention.
18+
//
19+
// Memory model: the returned char* is allocated with PyMem_Malloc and must be
20+
// freed via _Py_fmt_dtoa_free (mirrors _Py_dg_freedtoa).
21+
22+
// Build fmt in header-only mode so we don't need a separate compiled
23+
// format.cc TU for the Dragonbox tables and the `write_fixed` locale hooks.
24+
// FMT_OPTIMIZE_SIZE >= 2 disables fmt's std::locale-based locale lookup,
25+
// which cpython's dtoa never uses anyway and which would otherwise drag
26+
// libstdc++'s <locale> into libpython.
27+
#define FMT_HEADER_ONLY 1
28+
#define FMT_OPTIMIZE_SIZE 2
29+
30+
#include "format.h"
31+
#include "format-inl.h" // brings in out-of-line dragonbox tables; FMT_FUNC
32+
// resolves to `inline` in header-only mode.
33+
34+
#include <Python.h>
35+
36+
#include <cmath>
37+
#include <cstdint>
38+
#include <cstdio>
39+
#include <cstring>
40+
41+
extern "C" {
42+
43+
void _Py_fmt_dtoa_free(char *s) {
44+
PyMem_Free(s);
45+
}
46+
47+
static char *alloc_copy(const char *src, size_t n) {
48+
char *out = static_cast<char *>(PyMem_Malloc(n + 1));
49+
if (!out) return nullptr;
50+
std::memcpy(out, src, n);
51+
out[n] = '\0';
52+
return out;
53+
}
54+
55+
char *_Py_fmt_dtoa(double d, int mode, int ndigits,
56+
int *decpt, int *sign, char **rve) {
57+
// Sign. _Py_dg_dtoa distinguishes -0.0 from +0.0 via *sign.
58+
*sign = std::signbit(d) ? 1 : 0;
59+
double abs_d = *sign ? -d : d;
60+
61+
// Infinity / NaN / zero: cpython conventions, no fmt involvement.
62+
if (std::isinf(abs_d)) {
63+
*decpt = 9999;
64+
char *out = alloc_copy("Infinity", 8);
65+
if (!out) return nullptr;
66+
if (rve) *rve = out + 8;
67+
return out;
68+
}
69+
if (std::isnan(abs_d)) {
70+
*decpt = 9999;
71+
char *out = alloc_copy("NaN", 3);
72+
if (!out) return nullptr;
73+
if (rve) *rve = out + 3;
74+
return out;
75+
}
76+
if (abs_d == 0.0) {
77+
*decpt = 1;
78+
char *out = alloc_copy("0", 1);
79+
if (!out) return nullptr;
80+
if (rve) *rve = out + 1;
81+
return out;
82+
}
83+
84+
// Mode 0 (shortest) uses Dragonbox directly.
85+
if (mode == 0) {
86+
auto r = fmt::detail::dragonbox::to_decimal(abs_d);
87+
char tmp[32];
88+
int n = std::snprintf(tmp, sizeof(tmp), "%llu",
89+
(unsigned long long)r.significand);
90+
int exp = r.exponent;
91+
while (n > 1 && tmp[n - 1] == '0') { --n; ++exp; }
92+
*decpt = exp + n;
93+
char *out = alloc_copy(tmp, (size_t)n);
94+
if (!out) return nullptr;
95+
if (rve) *rve = out + n;
96+
return out;
97+
}
98+
99+
// Modes 2 and 3: fmt::detail::format_float writes digits (no dot, no
100+
// sign, no exponent) into a buffer and returns the decimal exponent.
101+
fmt::format_specs specs;
102+
int precision;
103+
bool fixed;
104+
if (mode == 2) {
105+
specs.set_type(fmt::presentation_type::exp);
106+
// max(1, ndigits) significant digits, fmt wants precision = N - 1.
107+
precision = (ndigits < 1 ? 1 : ndigits) - 1;
108+
fixed = false;
109+
} else {
110+
// Treat any unrecognised mode as 3 (matches _Py_dg_dtoa's fallthrough).
111+
specs.set_type(fmt::presentation_type::fixed);
112+
precision = ndigits;
113+
fixed = true;
114+
}
115+
116+
fmt::memory_buffer buf;
117+
int exp = fmt::detail::format_float(abs_d, precision, specs,
118+
/*binary32=*/false, buf);
119+
const char *bd = buf.data();
120+
int n = static_cast<int>(buf.size());
121+
122+
// Normalise fmt's rounded-to-zero output to cpython's "no_digits" shape:
123+
// fmt may emit a single '0' (e.g. round(5, -1) == 0); cpython returns an
124+
// empty digit string with decpt = -ndigits.
125+
if (fixed && n == 1 && bd[0] == '0') {
126+
char *out = static_cast<char *>(PyMem_Malloc(1));
127+
if (!out) return nullptr;
128+
out[0] = '\0';
129+
*decpt = -ndigits;
130+
if (rve) *rve = out;
131+
return out;
132+
}
133+
// Similarly handle the fixed case where fmt emitted 0 digits (precision
134+
// was so negative the whole value rounded away).
135+
if (fixed && n == 0) {
136+
char *out = static_cast<char *>(PyMem_Malloc(1));
137+
if (!out) return nullptr;
138+
out[0] = '\0';
139+
*decpt = -ndigits;
140+
if (rve) *rve = out;
141+
return out;
142+
}
143+
144+
// Strip trailing zeros (cpython mode-2/3 invariant).
145+
int nz = n;
146+
while (nz > 1 && bd[nz - 1] == '0') --nz;
147+
*decpt = exp + n; // decpt uses pre-strip length
148+
char *out = alloc_copy(bd, (size_t)nz);
149+
if (!out) return nullptr;
150+
if (rve) *rve = out + nz;
151+
return out;
152+
}
153+
154+
} // extern "C"

Python/_wuffs/wuffs_strtod.c

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
// Bridge between Wuffs's wuffs_base__parse_number_f64 and the _Py_dg_strtod
2+
// calling convention used by Python/pystrtod.c.
3+
//
4+
// _Py_dg_strtod's contract (from the David Gay dtoa that used to live in
5+
// Python/dtoa.c):
6+
//
7+
// double _Py_dg_strtod(const char *nptr, char **endptr);
8+
//
9+
// * Skips leading whitespace (like the C standard strtod).
10+
// * Consumes an optional sign, a decimal mantissa (with optional '.'),
11+
// and an optional 'e'/'E' exponent.
12+
// * Does NOT accept infinities or NaNs — the caller in pystrtod.c falls
13+
// back to _Py_parse_inf_or_nan when we don't consume anything.
14+
// * On success *endptr points past the last consumed character.
15+
// * On parse failure *endptr == nptr, returns 0.
16+
// * On overflow returns +/-HUGE_VAL and sets errno = ERANGE.
17+
// * On underflow returns the nearest representable value (possibly 0) and
18+
// sets errno = ERANGE.
19+
//
20+
// Wuffs is stricter: wuffs_base__parse_number_f64 requires the whole slice
21+
// to be consumed, returns an in-band status, and does not touch errno. So
22+
// the shim has to:
23+
//
24+
// (1) scan the ASCII tail itself to find where the numeric syntax ends,
25+
// (2) hand wuffs just that slice,
26+
// (3) translate wuffs's result (including inf-on-overflow) back into the
27+
// strtod errno discipline.
28+
29+
// Compile only the base/floatconv path of Wuffs into this TU. With these
30+
// defines set (before the include), the preprocessor prunes every other
31+
// module (image codecs, JSON, compression, ...), cutting a ~3.3 MB source
32+
// down to ~80-100 KB of object code. `WUFFS_IMPLEMENTATION` activates
33+
// function definitions alongside declarations.
34+
#define WUFFS_IMPLEMENTATION
35+
#define WUFFS_CONFIG__STATIC_FUNCTIONS
36+
#define WUFFS_CONFIG__MODULES
37+
#define WUFFS_CONFIG__MODULE__BASE
38+
#define WUFFS_CONFIG__MODULE__BASE__CORE
39+
#define WUFFS_CONFIG__MODULE__BASE__FLOATCONV
40+
#define WUFFS_CONFIG__MODULE__BASE__INTCONV
41+
#include "wuffs-v0.4.c"
42+
43+
#include <Python.h>
44+
45+
#include <ctype.h>
46+
#include <errno.h>
47+
#include <math.h>
48+
#include <stddef.h>
49+
#include <string.h>
50+
51+
// Scan forward from `p` and return the first character that isn't part of a
52+
// valid strtod-style numeric literal (after the sign we've already stepped
53+
// past). Returns `p` itself when no digits were found — the caller uses that
54+
// to signal "parse failure, don't consume".
55+
static const char *
56+
scan_number_end(const char *p)
57+
{
58+
const char *start = p;
59+
int have_int = 0, have_frac = 0;
60+
while (isdigit((unsigned char)*p)) { ++p; have_int = 1; }
61+
if (*p == '.') {
62+
++p;
63+
while (isdigit((unsigned char)*p)) { ++p; have_frac = 1; }
64+
}
65+
if (!have_int && !have_frac) return start; // no digits at all
66+
if (*p == 'e' || *p == 'E') {
67+
const char *exp_at = p;
68+
++p;
69+
if (*p == '+' || *p == '-') ++p;
70+
int have_exp_digits = 0;
71+
while (isdigit((unsigned char)*p)) { ++p; have_exp_digits = 1; }
72+
if (!have_exp_digits) p = exp_at; // malformed exponent; back out
73+
}
74+
return p;
75+
}
76+
77+
double
78+
_Py_wuffs_strtod(const char *nptr, char **endptr)
79+
{
80+
const char *p = nptr;
81+
82+
// Leading whitespace (strtod semantics).
83+
while (isspace((unsigned char)*p)) ++p;
84+
85+
const char *sign_start = p;
86+
if (*p == '+' || *p == '-') ++p;
87+
88+
const char *digits_start = p;
89+
const char *digits_end = scan_number_end(p);
90+
91+
if (digits_end == digits_start) {
92+
// No numeric content. Caller (pystrtod.c) will then try
93+
// _Py_parse_inf_or_nan.
94+
if (endptr) *endptr = (char *)nptr;
95+
return 0.0;
96+
}
97+
98+
// Hand wuffs the [sign_start, digits_end) slice. We include the sign so
99+
// wuffs handles +/- consistently with strtod. Wuffs rejects leading
100+
// zeros by default (e.g. "00.7"), so opt in to ALLOW_MULTIPLE_LEADING_ZEROES.
101+
// REJECT_INF_AND_NAN mirrors _Py_dg_strtod — pystrtod.c's
102+
// _Py_parse_inf_or_nan handles those separately.
103+
wuffs_base__slice_u8 slice = wuffs_base__make_slice_u8(
104+
(uint8_t *)sign_start, (size_t)(digits_end - sign_start));
105+
uint32_t options =
106+
WUFFS_BASE__PARSE_NUMBER_XXX__ALLOW_MULTIPLE_LEADING_ZEROES
107+
| WUFFS_BASE__PARSE_NUMBER_FXX__REJECT_INF_AND_NAN;
108+
109+
wuffs_base__result_f64 r = wuffs_base__parse_number_f64(slice, options);
110+
if (r.status.repr != NULL) {
111+
if (endptr) *endptr = (char *)nptr;
112+
return 0.0;
113+
}
114+
115+
if (endptr) *endptr = (char *)digits_end;
116+
117+
// Overflow: wuffs returns +/-inf silently; strtod convention is
118+
// HUGE_VAL + errno=ERANGE.
119+
if (isinf(r.value)) {
120+
errno = ERANGE;
121+
}
122+
// Underflow: parsed value is zero but the numeric substring had at least
123+
// one non-zero digit.
124+
else if (r.value == 0.0) {
125+
for (const char *q = digits_start; q < digits_end; ++q) {
126+
if (*q >= '1' && *q <= '9') {
127+
errno = ERANGE;
128+
break;
129+
}
130+
}
131+
}
132+
133+
return r.value;
134+
}

0 commit comments

Comments
 (0)