Repository: pydantic/jiter (language: python)

Issue:
Retain full float information
At the moment `python_parse` parses JSON floats to python floats (internally an f64 AFAIK), thereby potentially loosing some information. 

(I guess `JsonValue` arguably suffers from the same problem as it parses floats to rust's `f64`)

The idea would be to create a new `JsonFloat` Rust struct that implemented pyclass (so it's available as an option in Python), internally `JsonFloat` would just hold a string representing the original JSON float data, but have the following signature in python:

```py
class JsonFloat:
    def as_decimal(self) -> Decimal:
        """Construct a Decimal directly from the original string"""
        # what do we do about scientific notation?

    def as_float(self) -> float:
        """Use the current logic to parse the string as a float at call time"""

    def __str__(self) -> str:
        """Return the original string"""
```

`PythonParser` should be generic in `FloatParser` so there's no overhead if you want normal `float` as now.

In future we could do something similar for `JsonValue` if needed, but that doesn't have to happen at the same time.


Discussion / hints:

Fix patch:
diff --git a/crates/jiter-python/jiter.pyi b/crates/jiter-python/jiter.pyi
index d15033fe..c488de7b 100644
--- a/crates/jiter-python/jiter.pyi
+++ b/crates/jiter-python/jiter.pyi
@@ -1,3 +1,4 @@
+import decimal
 from typing import Any, Literal
 
 def from_json(
@@ -5,9 +6,10 @@ def from_json(
     /,
     *,
     allow_inf_nan: bool = True,
-    cache_strings: Literal[True, False, "all", "keys", "none"] = True,
+    cache_strings: Literal[True, False, "all", "keys", "none"] = "all",
     allow_partial: bool = False,
     catch_duplicate_keys: bool = False,
+    lossless_floats: bool = False,
 ) -> Any:
     """
     Parse input bytes into a JSON object.
@@ -22,6 +24,7 @@ def from_json(
             - False / 'none' - cache nothing
         allow_partial: if True, return parsed content when reaching EOF without closing objects and arrays
         catch_duplicate_keys: if True, raise an exception if objects contain the same key multiple times
+        lossless_floats: if True, preserve full detail on floats using `LosslessFloat`
 
     Returns:
         Python object built from the JSON input.
@@ -39,3 +42,26 @@ def cache_usage() -> int:
     Returns:
         Size of the string cache in bytes.
     """
+
+
+class LosslessFloat:
+    """
+    Represents a float from JSON, by holding the underlying bytes representing a float from JSON.
+    """
+    def __init__(self, json_float: bytes):
+        """Construct a LosslessFloat object from a JSON bytes slice"""
+
+    def as_decimal(self) -> decimal.Decimal:
+        """Construct a Python Decimal from the JSON bytes slice"""
+
+    def __float__(self) -> float:
+        """Construct a Python float from the JSON bytes slice"""
+
+    def __bytes__(self) -> bytes:
+        """Return the JSON bytes slice as bytes"""
+
+    def __str__(self):
+        """Return the JSON bytes slice as a string"""
+
+    def __repr__(self):
+        ...
diff --git a/crates/jiter-python/src/lib.rs b/crates/jiter-python/src/lib.rs
index a30082d1..06879009 100644
--- a/crates/jiter-python/src/lib.rs
+++ b/crates/jiter-python/src/lib.rs
@@ -2,8 +2,9 @@ use std::sync::OnceLock;
 
 use pyo3::prelude::*;
 
-use jiter::{map_json_error, python_parse, StringCacheMode};
+use jiter::{map_json_error, LosslessFloat, PythonParseBuilder, StringCacheMode};
 
+#[allow(clippy::fn_params_excessive_bools)]
 #[pyfunction(
     signature = (
         json_data,
@@ -12,7 +13,8 @@ use jiter::{map_json_error, python_parse, StringCacheMode};
         allow_inf_nan=true,
         cache_strings=StringCacheMode::All,
         allow_partial=false,
-        catch_duplicate_keys=false
+        catch_duplicate_keys=false,
+        lossless_floats=false,
     )
 )]
 pub fn from_json<'py>(
@@ -22,16 +24,18 @@ pub fn from_json<'py>(
     cache_strings: StringCacheMode,
     allow_partial: bool,
     catch_duplicate_keys: bool,
+    lossless_floats: bool,
 ) -> PyResult<Bound<'py, PyAny>> {
-    python_parse(
-        py,
-        json_data,
+    let parse_builder = PythonParseBuilder {
         allow_inf_nan,
-        cache_strings,
+        cache_mode: cache_strings,
         allow_partial,
         catch_duplicate_keys,
-    )
-    .map_err(|e| map_json_error(json_data, &e))
+        lossless_floats,
+    };
+    parse_builder
+        .python_parse(py, json_data)
+        .map_err(|e| map_json_error(json_data, &e))
 }
 
 pub fn get_jiter_version() -> &'static str {
@@ -65,5 +69,6 @@ fn jiter_python(_py: Python, m: &Bound<'_, PyModule>) -> PyResult<()> {
     m.add_function(wrap_pyfunction!(from_json, m)?)?;
     m.add_function(wrap_pyfunction!(cache_clear, m)?)?;
     m.add_function(wrap_pyfunction!(cache_usage, m)?)?;
+    m.add_class::<LosslessFloat>()?;
     Ok(())
 }
diff --git a/crates/jiter/Cargo.toml b/crates/jiter/Cargo.toml
index c5b69cdf..7278211a 100644
--- a/crates/jiter/Cargo.toml
+++ b/crates/jiter/Cargo.toml
@@ -16,7 +16,7 @@ num-bigint = "0.4.4"
 num-traits = "0.2.16"
 ahash = "0.8.0"
 smallvec = "1.11.0"
-pyo3 = { version = "0.21.0", default-features=false, features = ["num-bigint"], optional = true }
+pyo3 = { version = "0.21.0", optional = true }
 lexical-parse-float = { version = "0.8.5", features =  ["format"] }
 
 [features]
@@ -69,5 +69,6 @@ match_bool = "allow"
 doc_markdown = "allow"
 implicit_clone = "allow"
 iter_without_into_iter = "allow"
+return_self_not_must_use = "allow"
 inline_always = "allow"  # TODO remove?
 match_same_arms = "allow"  # TODO remove?
diff --git a/crates/jiter/benches/python.rs b/crates/jiter/benches/python.rs
index e7a8619f..9820b3b2 100644
--- a/crates/jiter/benches/python.rs
+++ b/crates/jiter/benches/python.rs
@@ -5,21 +5,18 @@ use std::io::Read;
 
 use pyo3::Python;
 
-use jiter::{cache_clear, python_parse, StringCacheMode};
+use jiter::{cache_clear, PythonParseBuilder, StringCacheMode};
 
 fn python_parse_numeric(bench: &mut Bencher) {
     Python::with_gil(|py| {
         cache_clear(py);
         bench.iter(|| {
-            python_parse(
-                py,
-                br#"  { "int": 1, "bigint": 123456789012345678901234567890, "float": 1.2}  "#,
-                false,
-                StringCacheMode::All,
-                false,
-                false,
-            )
-            .unwrap()
+            PythonParseBuilder::default()
+                .python_parse(
+                    py,
+                    br#"  { "int": 1, "bigint": 123456789012345678901234567890, "float": 1.2}  "#,
+                )
+                .unwrap()
         });
     })
 }
@@ -28,15 +25,9 @@ fn python_parse_other(bench: &mut Bencher) {
     Python::with_gil(|py| {
         cache_clear(py);
         bench.iter(|| {
-            python_parse(
-                py,
-                br#"["string", true, false, null]"#,
-                false,
-                StringCacheMode::All,
-                false,
-                false,
-            )
-            .unwrap()
+            PythonParseBuilder::default()
+                .python_parse(py, br#"["string", true, false, null]"#)
+                .unwrap()
         });
     })
 }
@@ -49,7 +40,14 @@ fn _python_parse_file(path: &str, bench: &mut Bencher, cache_mode: StringCacheMo
 
     Python::with_gil(|py| {
         cache_clear(py);
-        bench.iter(|| python_parse(py, json_data, false, cache_mode, false, false).unwrap());
+        bench.iter(|| {
+            PythonParseBuilder {
+                cache_mode,
+                ..Default::default()
+            }
+            .python_parse(py, json_data)
+            .unwrap()
+        });
     })
 }
 
diff --git a/crates/jiter/src/jiter.rs b/crates/jiter/src/jiter.rs
index 97a7ee4e..b0adc2bc 100644
--- a/crates/jiter/src/jiter.rs
+++ b/crates/jiter/src/jiter.rs
@@ -170,7 +170,7 @@ impl<'j> Jiter<'j> {
             .parser
             .consume_number::<NumberRange>(peek.into_inner(), self.allow_inf_nan)
         {
-            Ok(range) => Ok(&self.data[range]),
+            Ok(numbe_range) => Ok(&self.data[numbe_range.range]),
             Err(e) => Err(self.maybe_number_error(e, JsonType::Float, peek)),
         }
     }
diff --git a/crates/jiter/src/lib.rs b/crates/jiter/src/lib.rs
index 3e9e0fb9..4d1c27c9 100644
--- a/crates/jiter/src/lib.rs
+++ b/crates/jiter/src/lib.rs
@@ -6,6 +6,8 @@ mod lazy_index_map;
 mod number_decoder;
 mod parse;
 #[cfg(feature = "python")]
+mod py_lossless_float;
+#[cfg(feature = "python")]
 mod py_string_cache;
 #[cfg(feature = "python")]
 mod python;
@@ -21,7 +23,9 @@ pub use number_decoder::{NumberAny, NumberInt};
 pub use parse::Peek;
 pub use value::{JsonArray, JsonObject, JsonValue};
 
+#[cfg(feature = "python")]
+pub use py_lossless_float::LosslessFloat;
 #[cfg(feature = "python")]
 pub use py_string_cache::{cache_clear, cache_usage, cached_py_string, pystring_fast_new, StringCacheMode};
 #[cfg(feature = "python")]
-pub use python::{map_json_error, python_parse};
+pub use python::{map_json_error, PythonParseBuilder};
diff --git a/crates/jiter/src/number_decoder.rs b/crates/jiter/src/number_decoder.rs
index e6a026a0..33b0f5c0 100644
--- a/crates/jiter/src/number_decoder.rs
+++ b/crates/jiter/src/number_decoder.rs
@@ -23,10 +23,7 @@ impl From<NumberInt> for f64 {
     fn from(num: NumberInt) -> Self {
         match num {
             NumberInt::Int(int) => int as f64,
-            NumberInt::BigInt(big_int) => match big_int.to_f64() {
-                Some(f) => f,
-                None => f64::NAN,
-            },
+            NumberInt::BigInt(big_int) => big_int.to_f64().unwrap_or(f64::NAN),
         }
     }
 }
@@ -99,7 +96,7 @@ impl AbstractNumberDecoder for NumberFloat {
                     }
                 }
             } else if digit == &b'I' {
-                consume_inf(data, index, positive, allow_inf_nan)
+                consume_inf_f64(data, index, positive, allow_inf_nan)
             } else {
                 json_err!(InvalidNumber, index)
             }
@@ -116,6 +113,17 @@ pub enum NumberAny {
     Float(f64),
 }
 
+#[cfg(feature = "python")]
+impl pyo3::ToPyObject for NumberAny {
+    fn to_object(&self, py: pyo3::Python<'_>) -> pyo3::PyObject {
+        match self {
+            Self::Int(NumberInt::Int(int)) => int.to_object(py),
+            Self::Int(NumberInt::BigInt(big_int)) => big_int.to_object(py),
+            Self::Float(float) => float.to_object(py),
+        }
+    }
+}
+
 impl From<NumberAny> for f64 {
     fn from(num: NumberAny) -> Self {
         match num {
@@ -137,21 +145,16 @@ impl AbstractNumberDecoder for NumberAny {
                 NumberFloat::decode(data, start, first, allow_inf_nan).map(|(f, index)| (Self::Float(f), index))
             }
             IntParse::FloatInf(positive) => {
-                consume_inf(data, index, positive, allow_inf_nan).map(|(f, index)| (Self::Float(f), index))
+                consume_inf_f64(data, index, positive, allow_inf_nan).map(|(f, index)| (Self::Float(f), index))
             }
             IntParse::FloatNaN => consume_nan(data, index, allow_inf_nan).map(|(f, index)| (Self::Float(f), index)),
         }
     }
 }
 
-fn consume_inf(data: &[u8], index: usize, positive: bool, allow_inf_nan: bool) -> JsonResult<(f64, usize)> {
+fn consume_inf(data: &[u8], index: usize, positive: bool, allow_inf_nan: bool) -> JsonResult<usize> {
     if allow_inf_nan {
-        let end = crate::parse::consume_infinity(data, index)?;
-        if positive {
-            Ok((f64::INFINITY, end))
-        } else {
-            Ok((f64::NEG_INFINITY, end))
-        }
+        crate::parse::consume_infinity(data, index)
     } else if positive {
         json_err!(ExpectedSomeValue, index)
     } else {
@@ -159,6 +162,15 @@ fn consume_inf(data: &[u8], index: usize, positive: bool, allow_inf_nan: bool) -
     }
 }
 
+fn consume_inf_f64(data: &[u8], index: usize, positive: bool, allow_inf_nan: bool) -> JsonResult<(f64, usize)> {
+    let end = consume_inf(data, index, positive, allow_inf_nan)?;
+    if positive {
+        Ok((f64::INFINITY, end))
+    } else {
+        Ok((f64::NEG_INFINITY, end))
+    }
+}
+
 fn consume_nan(data: &[u8], index: usize, allow_inf_nan: bool) -> JsonResult<(f64, usize)> {
     if allow_inf_nan {
         let end = crate::parse::consume_nan(data, index)?;
@@ -348,10 +360,29 @@ pub(crate) static INT_CHAR_MAP: [bool; 256] = {
     ]
 };
 
-pub struct NumberRange;
+pub struct NumberRange {
+    pub range: Range<usize>,
+    pub is_int: bool,
+}
+
+impl NumberRange {
+    fn int(data: Range<usize>) -> Self {
+        Self {
+            range: data,
+            is_int: true,
+        }
+    }
+
+    fn float(data: Range<usize>) -> Self {
+        Self {
+            range: data,
+            is_int: false,
+        }
+    }
+}
 
 impl AbstractNumberDecoder for NumberRange {
-    type Output = Range<usize>;
+    type Output = Self;
 
     fn decode(data: &[u8], mut index: usize, first: u8, allow_inf_nan: bool) -> JsonResult<(Self::Output, usize)> {
         let start = index;
@@ -359,7 +390,7 @@ impl AbstractNumberDecoder for NumberRange {
         let positive = match first {
             b'N' => {
                 let (_, end) = consume_nan(data, index, allow_inf_nan)?;
-                return Ok((start..end, end));
+                return Ok((Self::float(start..end), end));
             }
             b'-' => false,
             _ => true,
@@ -377,20 +408,20 @@ impl AbstractNumberDecoder for NumberRange {
                     Some(b'.') => {
                         index += 1;
                         let end = consume_decimal(data, index)?;
-                        Ok((start..end, end))
+                        Ok((Self::float(start..end), end))
                     }
                     Some(b'e' | b'E') => {
                         index += 1;
                         let end = consume_exponential(data, index)?;
-                        Ok((start..end, end))
+                        Ok((Self::float(start..end), end))
                     }
                     Some(digit) if digit.is_ascii_digit() => json_err!(InvalidNumber, index),
-                    _ => return Ok((start..index, index)),
+                    _ => return Ok((Self::int(start..index), index)),
                 };
             }
             Some(b'I') => {
-                let (_, end) = consume_inf(data, index, positive, allow_inf_nan)?;
-                return Ok((start..end, end));
+                let end = consume_inf(data, index, positive, allow_inf_nan)?;
+                return Ok((Self::float(start..end), end));
             }
             Some(digit) if (b'1'..=b'9').contains(digit) => (),
             Some(_) => return json_err!(InvalidNumber, index),
@@ -406,14 +437,14 @@ impl AbstractNumberDecoder for NumberRange {
                 } else if matches!(digit, b'.') {
                     index += 1;
                     let end = consume_decimal(data, index)?;
-                    return Ok((start..end, end));
+                    return Ok((Self::float(start..end), end));
                 } else if matches!(digit, b'e' | b'E') {
                     index += 1;
                     let end = consume_exponential(data, index)?;
-                    return Ok((start..end, end));
+                    return Ok((Self::float(start..end), end));
                 }
             }
-            return Ok((start..index, index));
+            return Ok((Self::int(start..index), index));
         }
         loop {
             let (chunk, new_index) = IntChunk::parse_big(data, index);
@@ -425,18 +456,18 @@ impl AbstractNumberDecoder for NumberRange {
                 IntChunk::Ongoing(_) => {
                     index = new_index;
                 }
-                IntChunk::Done(_) => return Ok((start..new_index, new_index)),
+                IntChunk::Done(_) => return Ok((Self::int(start..new_index), new_index)),
                 IntChunk::Float => {
                     return match data.get(new_index) {
                         Some(b'.') => {
                             index = new_index + 1;
                             let end = consume_decimal(data, index)?;
-                            Ok((start..end, end))
+                            Ok((Self::float(start..end), end))
                         }
                         _ => {
                             index = new_index + 1;
                             let end = consume_exponential(data, index)?;
-                            Ok((start..end, end))
+                            Ok((Self::float(start..end), end))
                         }
                     }
                 }
diff --git a/crates/jiter/src/parse.rs b/crates/jiter/src/parse.rs
index 18729cdb..027bcaf9 100644
--- a/crates/jiter/src/parse.rs
+++ b/crates/jiter/src/parse.rs
@@ -1,4 +1,5 @@
 use std::fmt;
+use std::ops::Range;
 
 use crate::errors::{json_err, JsonResult, LinePosition};
 use crate::number_decoder::AbstractNumberDecoder;
@@ -67,9 +68,12 @@ impl<'j> Parser<'j> {
     pub fn new(data: &'j [u8]) -> Self {
         Self { data, index: 0 }
     }
-}
 
-impl<'j> Parser<'j> {
+    #[allow(dead_code)]
+    pub fn slice(&self, range: Range<usize>) -> Option<&[u8]> {
+        self.data.get(range)
+    }
+
     pub fn current_position(&self) -> LinePosition {
         LinePosition::find(self.data, self.index)
     }
diff --git a/crates/jiter/src/py_lossless_float.rs b/crates/jiter/src/py_lossless_float.rs
new file mode 100644
index 00000000..852c752d
--- /dev/null
+++ b/crates/jiter/src/py_lossless_float.rs
@@ -0,0 +1,71 @@
+use pyo3::exceptions::PyValueError;
+use pyo3::prelude::*;
+use pyo3::sync::GILOnceCell;
+use pyo3::types::PyType;
+
+use crate::Jiter;
+
+/// Represents a float from JSON, by holding the underlying bytes representing a float from JSON.
+#[derive(Debug, Clone)]
+#[pyclass(module = "jiter")]
+pub struct LosslessFloat(Vec<u8>);
+
+impl LosslessFloat {
+    pub fn new_unchecked(raw: Vec<u8>) -> Self {
+        Self(raw)
+    }
+}
+
+#[pymethods]
+impl LosslessFloat {
+    #[new]
+    fn new(raw: Vec<u8>) -> PyResult<Self> {
+        let s = Self(raw);
+        // check the string is valid by calling `as_float`
+        s.__float__()?;
+        Ok(s)
+    }
+
+    fn as_decimal<'py>(&self, py: Python<'py>) -> PyResult<Bound<'py, PyAny>> {
+        let decimal = get_decimal_type(py)?;
+        let float_str = self.__str__()?;
+        decimal.call1((float_str,))
+    }
+
+    fn __float__(&self) -> PyResult<f64> {
+        let bytes = &self.0;
+        let mut jiter = Jiter::new(bytes, true);
+        let f = jiter
+            .next_float()
+            .map_err(|e| PyValueError::new_err(e.description(&jiter)))?;
+        jiter
+            .finish()
+            .map_err(|e| PyValueError::new_err(e.description(&jiter)))?;
+        Ok(f)
+    }
+
+    fn __bytes__(&self) -> &[u8] {
+        &self.0
+    }
+
+    fn __str__(&self) -> PyResult<&str> {
+        std::str::from_utf8(&self.0).map_err(|_| PyValueError::new_err("Invalid UTF-8"))
+    }
+
+    fn __repr__(&self) -> PyResult<String> {
+        self.__str__().map(|s| format!("LosslessFloat({s})"))
+    }
+}
+
+static DECIMAL_TYPE: GILOnceCell<Py<PyType>> = GILOnceCell::new();
+
+pub fn get_decimal_type(py: Python) -> PyResult<&Bound<'_, PyType>> {
+    DECIMAL_TYPE
+        .get_or_try_init(py, || {
+            py.import_bound("decimal")?
+                .getattr("Decimal")?
+                .extract::<&PyType>()
+                .map(Into::into)
+        })
+        .map(|t| t.bind(py))
+}
diff --git a/crates/jiter/src/py_string_cache.rs b/crates/jiter/src/py_string_cache.rs
index 5f74efc9..9047ed4d 100644
--- a/crates/jiter/src/py_string_cache.rs
+++ b/crates/jiter/src/py_string_cache.rs
@@ -13,6 +13,12 @@ pub enum StringCacheMode {
     None,
 }
 
+impl Default for StringCacheMode {
+    fn default() -> Self {
+        Self::All
+    }
+}
+
 impl<'py> FromPyObject<'py> for StringCacheMode {
     fn extract_bound(ob: &Bound<'py, PyAny>) -> PyResult<StringCacheMode> {
         if let Ok(bool_mode) = ob.downcast::<PyBool>() {
diff --git a/crates/jiter/src/python.rs b/crates/jiter/src/python.rs
index e58c3512..e5d31613 100644
--- a/crates/jiter/src/python.rs
+++ b/crates/jiter/src/python.rs
@@ -5,57 +5,71 @@ use pyo3::exceptions::PyValueError;
 use pyo3::ffi;
 use pyo3::prelude::*;
 use pyo3::types::{PyDict, PyList, PyString};
+use pyo3::ToPyObject;
 
 use smallvec::SmallVec;
 
 use crate::errors::{json_err, json_error, JsonError, JsonResult, DEFAULT_RECURSION_LIMIT};
-use crate::number_decoder::{NumberAny, NumberInt};
+use crate::number_decoder::{AbstractNumberDecoder, NumberAny, NumberRange};
 use crate::parse::{Parser, Peek};
 use crate::py_string_cache::{StringCacheAll, StringCacheKeys, StringCacheMode, StringMaybeCache, StringNoCache};
 use crate::string_decoder::{StringDecoder, Tape};
-use crate::JsonErrorType;
+use crate::{JsonErrorType, LosslessFloat};
 
-/// Parse a JSON value from a byte slice and return a Python object.
-///
-/// # Arguments
-/// - `py`: [Python](https://docs.rs/pyo3/latest/pyo3/marker/struct.Python.html) marker token.
-/// - `json_data`: The JSON data to parse.
-/// - `allow_inf_nan`: Whether to allow `(-)Infinity` and `NaN` values.
-/// - `cache_strings`: Whether to cache strings to avoid constructing new Python objects,
-/// - `allow_partial`: Whether to allow partial JSON data.
-/// - `catch_duplicate_keys`: Whether to catch duplicate keys in objects.
-/// this should have a significant improvement on performance but increases memory slightly.
-///
-/// # Returns
-///
-/// A [PyObject](https://docs.rs/pyo3/latest/pyo3/type.PyObject.html) representing the parsed JSON value.
-pub fn python_parse<'py>(
-    py: Python<'py>,
-    json_data: &[u8],
-    allow_inf_nan: bool,
-    cache_mode: StringCacheMode,
-    allow_partial: bool,
-    catch_duplicate_keys: bool,
-) -> JsonResult<Bound<'py, PyAny>> {
-    macro_rules! ppp {
-        ($string_cache:ident, $key_check:ident) => {
-            PythonParser::<$string_cache, $key_check>::parse(py, json_data, allow_inf_nan, allow_partial)
-        };
-    }
+#[derive(Default)]
+#[allow(clippy::struct_excessive_bools)]
+pub struct PythonParseBuilder {
+    /// Whether to allow `(-)Infinity` and `NaN` values.
+    pub allow_inf_nan: bool,
+    /// Whether to cache strings to avoid constructing new Python objects,
+    pub cache_mode: StringCacheMode,
+    /// Whether to allow partial JSON data.
+    pub allow_partial: bool,
+    /// Whether to catch duplicate keys in objects.
+    pub catch_duplicate_keys: bool,
+    /// Whether to preserve full detail on floats using [`LosslessFloat`]
+    pub lossless_floats: bool,
+}
+
+impl PythonParseBuilder {
+    /// Parse a JSON value from a byte slice and return a Python object.
+    ///
+    /// # Arguments
+    ///
+    /// - `py`: [Python](https://docs.rs/pyo3/latest/pyo3/marker/struct.Python.html) marker token.
+    /// - `json_data`: The JSON data to parse.
+    /// this should have a significant improvement on performance but increases memory slightly.
+    ///
+    /// # Returns
+    ///
+    /// A [PyObject](https://docs.rs/pyo3/latest/pyo3/type.PyObject.html) representing the parsed JSON value.
+    pub fn python_parse<'py>(self, py: Python<'py>, json_data: &[u8]) -> JsonResult<Bound<'py, PyAny>> {
+        macro_rules! ppp {
+            ($string_cache:ident, $key_check:ident, $parse_number:ident) => {
+                PythonParser::<$string_cache, $key_check, $parse_number>::parse(
+                    py,
+                    json_data,
+                    self.allow_inf_nan,
+                    self.allow_partial,
+                )
+            };
+        }
+        macro_rules! ppp_group {
+            ($string_cache:ident) => {
+                match (self.catch_duplicate_keys, self.lossless_floats) {
+                    (true, true) => ppp!($string_cache, DuplicateKeyCheck, ParseNumberLossless),
+                    (true, false) => ppp!($string_cache, DuplicateKeyCheck, ParseNumberLossy),
+                    (false, true) => ppp!($string_cache, NoopKeyCheck, ParseNumberLossless),
+                    (false, false) => ppp!($string_cache, NoopKeyCheck, ParseNumberLossy),
+                }
+            };
+        }
 
-    match cache_mode {
-        StringCacheMode::All => match catch_duplicate_keys {
-            true => ppp!(StringCacheAll, DuplicateKeyCheck),
-            false => ppp!(StringCacheAll, NoopKeyCheck),
-        },
-        StringCacheMode::Keys => match catch_duplicate_keys {
-            true => ppp!(StringCacheKeys, DuplicateKeyCheck),
-            false => ppp!(StringCacheKeys, NoopKeyCheck),
-        },
-        StringCacheMode::None => match catch_duplicate_keys {
-            true => ppp!(StringNoCache, DuplicateKeyCheck),
-            false => ppp!(StringNoCache, NoopKeyCheck),
-        },
+        match self.cache_mode {
+            StringCacheMode::All => ppp_group!(StringCacheAll),
+            StringCacheMode::Keys => ppp_group!(StringCacheKeys),
+            StringCacheMode::None => ppp_group!(StringNoCache),
+        }
     }
 }
 
@@ -64,9 +78,10 @@ pub fn map_json_error(json_data: &[u8], json_error: &JsonError) -> PyErr {
     PyValueError::new_err(json_error.description(json_data))
 }
 
-struct PythonParser<'j, StringCache, KeyCheck> {
+struct PythonParser<'j, StringCache, KeyCheck, ParseNumber> {
     _string_cache: PhantomData<StringCache>,
     _key_check: PhantomData<KeyCheck>,
+    _parse_number: PhantomData<ParseNumber>,
     parser: Parser<'j>,
     tape: Tape,
     recursion_limit: u8,
@@ -74,7 +89,9 @@ struct PythonParser<'j, StringCache, KeyCheck> {
     allow_partial: bool,
 }
 
-impl<'j, StringCache: StringMaybeCache, KeyCheck: MaybeKeyCheck> PythonParser<'j, StringCache, KeyCheck> {
+impl<'j, StringCache: StringMaybeCache, KeyCheck: MaybeKeyCheck, ParseNumber: MaybeParseNumber>
+    PythonParser<'j, StringCache, KeyCheck, ParseNumber>
+{
     fn parse<'py>(
         py: Python<'py>,
         json_data: &[u8],
@@ -84,6 +101,7 @@ impl<'j, StringCache: StringMaybeCache, KeyCheck: MaybeKeyCheck> PythonParser<'j
         let mut slf = PythonParser {
             _string_cache: PhantomData::<StringCache>,
             _key_check: PhantomData::<KeyCheck>,
+            _parse_number: PhantomData::<ParseNumber>,
             parser: Parser::new(json_data),
             tape: Tape::default(),
             recursion_limit: DEFAULT_RECURSION_LIMIT,
@@ -142,23 +160,7 @@ impl<'j, StringCache: StringMaybeCache, KeyCheck: MaybeKeyCheck> PythonParser<'j
                 }
                 Ok(dict.into_any())
             }
-            _ => {
-                let n = self
-                    .parser
-                    .consume_number::<NumberAny>(peek.into_inner(), self.allow_inf_nan);
-                match n {
-                    Ok(NumberAny::Int(NumberInt::Int(int))) => Ok(int.to_object(py).into_bound(py)),
-                    Ok(NumberAny::Int(NumberInt::BigInt(big_int))) => Ok(big_int.to_object(py).into_bound(py)),
-                    Ok(NumberAny::Float(float)) => Ok(float.to_object(py).into_bound(py)),
-                    Err(e) => {
-                        if !peek.is_num() {
-                            Err(json_error!(ExpectedSomeValue, self.parser.index))
-                        } else {
-                            Err(e)
-                        }
-                    }
-                }
-            }
+            _ => ParseNumber::parse_number(py, &mut self.parser, peek, self.allow_inf_nan),
         }
     }
 
@@ -259,3 +261,66 @@ impl MaybeKeyCheck for DuplicateKeyCheck {
         }
     }
 }
+
+trait MaybeParseNumber {
+    fn parse_number<'py>(
+        py: Python<'py>,
+        parser: &mut Parser,
+        peek: Peek,
+        allow_inf_nan: bool,
+    ) -> JsonResult<Bound<'py, PyAny>>;
+}
+
+struct ParseNumberLossy;
+
+impl MaybeParseNumber for ParseNumberLossy {
+    fn parse_number<'py>(
+        py: Python<'py>,
+        parser: &mut Parser,
+        peek: Peek,
+        allow_inf_nan: bool,
+    ) -> JsonResult<Bound<'py, PyAny>> {
+        match parser.consume_number::<NumberAny>(peek.into_inner(), allow_inf_nan) {
+            Ok(number) => Ok(number.to_object(py).into_bound(py)),
+            Err(e) => {
+                if !peek.is_num() {
+                    Err(json_error!(ExpectedSomeValue, parser.index))
+                } else {
+                    Err(e)
+                }
+            }
+        }
+    }
+}
+
+struct ParseNumberLossless;
+
+impl MaybeParseNumber for ParseNumberLossless {
+    fn parse_number<'py>(
+        py: Python<'py>,
+        parser: &mut Parser,
+        peek: Peek,
+        allow_inf_nan: bool,
+    ) -> JsonResult<Bound<'py, PyAny>> {
+        match parser.consume_number::<NumberRange>(peek.into_inner(), allow_inf_nan) {
+            Ok(number_range) => {
+                let bytes = parser.slice(number_range.range).unwrap();
+                let obj = if number_range.is_int {
+                    NumberAny::decode(bytes, 0, peek.into_inner(), allow_inf_nan)?
+                        .0
+                        .to_object(py)
+                } else {
+                    LosslessFloat::new_unchecked(bytes.to_vec()).into_py(py)
+                };
+                Ok(obj.into_bound(py))
+            }
+            Err(e) => {
+                if !peek.is_num() {
+                    Err(json_error!(ExpectedSomeValue, parser.index))
+                } else {
+                    Err(e)
+                }
+            }
+        }
+    }
+}


Test patch:
diff --git a/crates/jiter-python/tests/test_jiter.py b/crates/jiter-python/tests/test_jiter.py
index d8b42e19..ce9569c0 100644
--- a/crates/jiter-python/tests/test_jiter.py
+++ b/crates/jiter-python/tests/test_jiter.py
@@ -1,3 +1,5 @@
+from decimal import Decimal
+
 import jiter
 import pytest
 from math import inf
@@ -144,3 +146,50 @@ def test_unicode_cache():
     jiter.cache_clear()
     parsed = jiter.from_json(json)
     assert parsed == {"💩": "£"}
+
+
+def test_json_float():
+    f = jiter.LosslessFloat(b'123.45')
+    assert str(f) == '123.45'
+    assert repr(f) == 'LosslessFloat(123.45)'
+    assert float(f) == 123.45
+    assert f.as_decimal() == Decimal('123.45')
+    assert bytes(f) == b'123.45'
+
+
+def test_json_float_scientific():
+    f = jiter.LosslessFloat(b'123e4')
+    assert str(f) == '123e4'
+    assert float(f) == 123e4
+    assert f.as_decimal() == Decimal('123e4')
+
+
+def test_json_float_invalid():
+    with pytest.raises(ValueError, match='trailing characters at line 1 column 6'):
+        jiter.LosslessFloat(b'123.4x')
+
+
+def test_lossless_floats():
+    f = jiter.from_json(b'12.3')
+    assert isinstance(f, float)
+    assert f == 12.3
+
+    f = jiter.from_json(b'12.3', lossless_floats=True)
+    assert isinstance(f, jiter.LosslessFloat)
+    assert str(f) == '12.3'
+    assert float(f) == 12.3
+    assert f.as_decimal() == Decimal('12.3')
+
+    f = jiter.from_json(b'123.456789123456789e45', lossless_floats=True)
+    assert isinstance(f, jiter.LosslessFloat)
+    assert 123e45 < float(f) < 124e45
+    assert f.as_decimal() == Decimal('1.23456789123456789E+47')
+    assert bytes(f) == b'123.456789123456789e45'
+    assert str(f) == '123.456789123456789e45'
+    assert repr(f) == 'LosslessFloat(123.456789123456789e45)'
+
+
+def test_lossless_floats_int():
+    v = jiter.from_json(b'123', lossless_floats=True)
+    assert isinstance(v, int)
+    assert v == 123