feat(rust): add python-compat crate for Python data formats (#42510)

* feat(rust): add python-compat crate for Python data formats

Add litellm-python-compat, a PyO3-free crate that reproduces the Python
data formats LiteLLM persists, so Rust readers and writers can interoperate
with state written by the Python proxy:

- literal::literal_eval: a linear recursive-descent port of
  ast.literal_eval (prefixes, escapes, implicit concatenation, numeric
  underscores and radixes, single unary sign, real +/- complex with 3.14
  mixed-mode rules, set(), Python-equality key dedup)
- repr::{repr, to_str}: byte-exact repr()/str(), with a printable table
  generated from CPython's str.isprintable (Unicode 16.0.0)
- json::{dumps, from_json, to_json}: json.dumps defaults and the
  json.loads mapping
- pickle::{loads, dumps}: plain-data pickles via serde-pickle's serde
  interface, which keeps dict insertion order
- truthy::truthy: bool() for plain data

Tests replay fixtures generated by CPython 3.14 (values across every
format and pickle protocol 0-5, plus 154 literal_eval source texts).
Accepted divergences are pinned in a KNOWN table that fails once one
starts matching. A criterion bench covers each format and literal_eval
cost by nesting depth, guarding the linear parse: the py_literal grammar
doubled per nested bracket (105 ms at 16 nested dicts; 19 us at 128 now).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* refactor(rust): split python-compat modules and harden the pickle verifier

- Disable class resolution in scripts/verify_rust_pickles.py, and truncate
  the export file once instead of removing and appending to it, so the
  verifier cannot be pointed at a pre-created file whose rows execute code
  through pickle.loads
- Move Error to error.rs and Value to value.rs, leaving lib.rs as the crate
  overview, module list and MAX_DEPTH
- Move the generator and verifier to scripts/, beside the Unicode table
  generator, leaving tests/ to the Rust tests
- Group the bench by measured surface, give every case a Throughput so
  criterion reports bytes per second, and document baseline comparison

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Yujong Lee <yujong@berri.ai>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
devin-ai-integration[bot] 2026-09-22 12:16:56 -07:00 committed by GitHub
parent 64456ce103
commit 05d7fb24bd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 5449 additions and 0 deletions

View file

@ -3072,6 +3072,21 @@ dependencies = [
"wiremock",
]
[[package]]
name = "litellm-python-compat"
version = "0.1.0"
dependencies = [
"criterion",
"hex",
"num-bigint 0.4.8",
"num-traits",
"rstest",
"serde",
"serde-pickle",
"serde_json",
"thiserror 2.0.19",
]
[[package]]
name = "litellm-secrets"
version = "0.1.0"

View file

@ -0,0 +1,23 @@
- Pure Python *data formats* in Rust, for state Python LiteLLM writes and Rust must read or write byte-compatibly
- No PyO3, no live objects: truthiness, `__str__`, descriptors of real Python objects belong to `python-bridge`'s coercion layer
- Format *choices* stay with callers: the `{timestamp, response}` envelope, diskcache modes, and the cache-key recipe live in the cache crates and only call into this crate
- Intended users
- `cache-response` codec: reading `str(dict)` values Python's sync Redis path writes (`literal_eval`)
- `cache-disk`: diskcache's pickled values (`pickle`), falsy-is-miss (`truthy`)
- Cache-key derivation: sha256 over `str(value)` must match Python byte for byte (`repr::to_str`)
- Byte-identical writes where Python compares raw values (`json::dumps`, `repr`)
- Relation to [`py_literal`](https://docs.rs/py_literal/latest/py_literal/): replaced, do not reintroduce
- Its pest grammar backtracks: parse time doubles per nested `[`/`{` (105 ms at depth 16); ours is linear (19 µs at depth 128)
- Its formatter is not `repr` (`2e-1`, always single quotes, escapes non-ASCII); it also lost `-0.0`, `(1+2j)`, `set()`
- `cache-response` and `cache-disk` still depend on it; migrate them here
- Relation to [`serde-pickle`](https://docs.rs/serde-pickle/latest/serde_pickle/): the pickle codec, used only through its serde interface
- Never `serde_pickle::Value`: its `BTreeMap` dicts reorder keys
- Accepted limits: ints beyond i64, `tuple`/`set`/`frozenset` decode as lists, class references (`GLOBAL`/`REDUCE`) fail; writes protocol 3
- Every behavior is pinned by CPython output, not by reasoning; everything under `generated/` is script output, never hand-edited
- Regenerate `generated/values.json` with `scripts/generate_fixtures.py`; add a corpus row before changing behavior
- Divergences go in `KNOWN` in `tests/fixtures.rs` with a reason; an entry that starts matching fails until deleted
- Regenerate `generated/nonprintable.rs` with `scripts/generate_nonprintable.py` when the target Python's Unicode version changes
- `scripts/verify_rust_pickles.py` checks CPython reads Rust pickles, with class resolution disabled; CI does not run Python
- Decoders recurse, so they reject nesting beyond `MAX_DEPTH` for stack safety: deliberately stricter than CPython, whose parser takes ~200 levels and whose unpickler has no limit (pinned as `nested_150`)
- Formatters (`repr`, `json`) are unbounded; values from the decoders are already capped, a hand-built `Value` is the caller's responsibility
- `literal_eval` must stay linear in depth: `tests/limits.rs` times the deepest parse, `benches/formats.rs` measures the curve but is manual, since CI runs no Rust bench

View file

@ -0,0 +1,24 @@
[package]
name = "litellm-python-compat"
version = "0.1.0"
edition.workspace = true
license.workspace = true
repository.workspace = true
description = "Python data formats (repr, literal_eval, json.dumps, pickle) reproduced for interop with persisted LiteLLM state"
[dependencies]
num-bigint = "0.4"
num-traits = "0.2"
serde.workspace = true
serde-pickle = "1.2"
serde_json = { workspace = true, features = ["preserve_order"] }
thiserror.workspace = true
[dev-dependencies]
criterion.workspace = true
hex = "0.4"
rstest.workspace = true
[[bench]]
name = "formats"
harness = false

View file

@ -0,0 +1,111 @@
//! Throughput of each format on a cached chat completion, and `literal_eval` cost by nesting.
//!
//! Run one group with `cargo bench -p litellm-python-compat -- cached_completion`, and compare
//! against a stored run with `--save-baseline <name>` / `--baseline <name>`.
//!
//! `literal_eval/nesting` guards against backtracking: the `py_literal` grammar this parser
//! replaced doubled its time per nested `[` or `{` (105 ms at depth 16), so cost must stay
//! linear in depth for every container shape.
use std::{hint::black_box, time::Duration};
use criterion::{
BatchSize, BenchmarkGroup, BenchmarkId, Criterion, Throughput, criterion_group, criterion_main,
measurement::WallTime,
};
use litellm_python_compat::{Value, json, literal::literal_eval, pickle, repr::repr};
/// `str(entry)` for the `{timestamp, response}` envelope Python's sync Redis path writes.
fn cached_completion() -> String {
let choices: Vec<String> = (0..4)
.map(|index| {
format!(
"{{'finish_reason': 'stop', 'index': {index}, 'message': {{'content': \
'Benchmarks compare the same workload under controlled conditions, so a \
change in time reflects the code rather than the environment. café \
{index}', 'role': 'assistant', 'tool_calls': None, 'function_call': None}}, \
'logprobs': None}}"
)
})
.collect();
format!(
"{{'timestamp': 1726000000.123, 'response': {{'id': 'chatcmpl-9x1', 'created': \
1726000000, 'model': 'gpt-4o-2024-08-06', 'object': 'chat.completion', \
'system_fingerprint': 'fp_1', 'choices': [{}], 'usage': {{'completion_tokens': 120, \
'prompt_tokens': 42, 'total_tokens': 162, 'completion_tokens_details': None}}}}}}",
choices.join(", ")
)
}
/// Every text format, measured against the source bytes it reads or writes.
fn text_formats(group: &mut BenchmarkGroup<'_, WallTime>, text: &str, value: &Value) {
group.throughput(Throughput::Bytes(text.len() as u64));
group.bench_function("literal_eval", |bencher| {
bencher.iter(|| literal_eval(black_box(text)))
});
group.bench_function("repr", |bencher| bencher.iter(|| repr(black_box(value))));
group.bench_function("json_dumps", |bencher| {
bencher.iter(|| json::dumps(black_box(value)))
});
group.bench_function("to_json", |bencher| {
bencher.iter(|| json::to_json(black_box(value)))
});
}
/// Pickle, measured against its own encoding rather than the source text.
fn binary_formats(group: &mut BenchmarkGroup<'_, WallTime>, value: &Value, pickled: &[u8]) {
group.throughput(Throughput::Bytes(pickled.len() as u64));
group.bench_function("pickle_dumps", |bencher| {
bencher.iter(|| pickle::dumps(black_box(value)))
});
group.bench_function("pickle_loads", |bencher| {
bencher.iter(|| pickle::loads(black_box(pickled)))
});
}
fn formats(c: &mut Criterion) {
let text = cached_completion();
let value = literal_eval(&text).expect("benchmark payload is a literal");
let pickled = pickle::dumps(&value).expect("benchmark payload pickles");
let dumped = json::dumps(&value).expect("benchmark payload is JSON serializable");
let mut group = c.benchmark_group("cached_completion");
text_formats(&mut group, &text, &value);
binary_formats(&mut group, &value, &pickled);
// `from_json` consumes its input, so each iteration gets a freshly parsed one.
group.throughput(Throughput::Bytes(dumped.len() as u64));
group.bench_function("from_json", |bencher| {
bencher.iter_batched(
|| serde_json::from_str::<serde_json::Value>(&dumped).expect("dumps output parses"),
json::from_json,
BatchSize::SmallInput,
)
});
group.finish();
}
/// One nesting level of each container shape, as `(name, open, close)`.
const SHAPES: [(&str, &str, &str); 3] = [
("list", "[", "]"),
("dict", "{'a': ", "}"),
("tuple", "(", ",)"),
];
fn literal_nesting(c: &mut Criterion) {
let mut group = c.benchmark_group("literal_eval/nesting");
group.sample_size(10);
group.measurement_time(Duration::from_secs(3));
for depth in [4, 16, 64, 128] {
for (shape, open, close) in SHAPES {
let text = format!("{}1{}", open.repeat(depth), close.repeat(depth));
group.throughput(Throughput::Bytes(text.len() as u64));
group.bench_with_input(BenchmarkId::new(shape, depth), &text, |bencher, text| {
bencher.iter(|| literal_eval(black_box(text)))
});
}
}
group.finish();
}
criterion_group!(benches, formats, literal_nesting);
criterion_main!(benches);

View file

@ -0,0 +1,745 @@
// Generated by scripts/generate_nonprintable.py from Python 3.14.7
// (Unicode 16.0.0). Do not edit by hand.
pub(crate) const UNICODE_VERSION: &str = "16.0.0";
/// Inclusive code point ranges for which Python's `str.isprintable()` is false.
pub(crate) const NONPRINTABLE: [(u32, u32); 737] = [
(0x0000, 0x001F),
(0x007F, 0x00A0),
(0x00AD, 0x00AD),
(0x0378, 0x0379),
(0x0380, 0x0383),
(0x038B, 0x038B),
(0x038D, 0x038D),
(0x03A2, 0x03A2),
(0x0530, 0x0530),
(0x0557, 0x0558),
(0x058B, 0x058C),
(0x0590, 0x0590),
(0x05C8, 0x05CF),
(0x05EB, 0x05EE),
(0x05F5, 0x0605),
(0x061C, 0x061C),
(0x06DD, 0x06DD),
(0x070E, 0x070F),
(0x074B, 0x074C),
(0x07B2, 0x07BF),
(0x07FB, 0x07FC),
(0x082E, 0x082F),
(0x083F, 0x083F),
(0x085C, 0x085D),
(0x085F, 0x085F),
(0x086B, 0x086F),
(0x088F, 0x0896),
(0x08E2, 0x08E2),
(0x0984, 0x0984),
(0x098D, 0x098E),
(0x0991, 0x0992),
(0x09A9, 0x09A9),
(0x09B1, 0x09B1),
(0x09B3, 0x09B5),
(0x09BA, 0x09BB),
(0x09C5, 0x09C6),
(0x09C9, 0x09CA),
(0x09CF, 0x09D6),
(0x09D8, 0x09DB),
(0x09DE, 0x09DE),
(0x09E4, 0x09E5),
(0x09FF, 0x0A00),
(0x0A04, 0x0A04),
(0x0A0B, 0x0A0E),
(0x0A11, 0x0A12),
(0x0A29, 0x0A29),
(0x0A31, 0x0A31),
(0x0A34, 0x0A34),
(0x0A37, 0x0A37),
(0x0A3A, 0x0A3B),
(0x0A3D, 0x0A3D),
(0x0A43, 0x0A46),
(0x0A49, 0x0A4A),
(0x0A4E, 0x0A50),
(0x0A52, 0x0A58),
(0x0A5D, 0x0A5D),
(0x0A5F, 0x0A65),
(0x0A77, 0x0A80),
(0x0A84, 0x0A84),
(0x0A8E, 0x0A8E),
(0x0A92, 0x0A92),
(0x0AA9, 0x0AA9),
(0x0AB1, 0x0AB1),
(0x0AB4, 0x0AB4),
(0x0ABA, 0x0ABB),
(0x0AC6, 0x0AC6),
(0x0ACA, 0x0ACA),
(0x0ACE, 0x0ACF),
(0x0AD1, 0x0ADF),
(0x0AE4, 0x0AE5),
(0x0AF2, 0x0AF8),
(0x0B00, 0x0B00),
(0x0B04, 0x0B04),
(0x0B0D, 0x0B0E),
(0x0B11, 0x0B12),
(0x0B29, 0x0B29),
(0x0B31, 0x0B31),
(0x0B34, 0x0B34),
(0x0B3A, 0x0B3B),
(0x0B45, 0x0B46),
(0x0B49, 0x0B4A),
(0x0B4E, 0x0B54),
(0x0B58, 0x0B5B),
(0x0B5E, 0x0B5E),
(0x0B64, 0x0B65),
(0x0B78, 0x0B81),
(0x0B84, 0x0B84),
(0x0B8B, 0x0B8D),
(0x0B91, 0x0B91),
(0x0B96, 0x0B98),
(0x0B9B, 0x0B9B),
(0x0B9D, 0x0B9D),
(0x0BA0, 0x0BA2),
(0x0BA5, 0x0BA7),
(0x0BAB, 0x0BAD),
(0x0BBA, 0x0BBD),
(0x0BC3, 0x0BC5),
(0x0BC9, 0x0BC9),
(0x0BCE, 0x0BCF),
(0x0BD1, 0x0BD6),
(0x0BD8, 0x0BE5),
(0x0BFB, 0x0BFF),
(0x0C0D, 0x0C0D),
(0x0C11, 0x0C11),
(0x0C29, 0x0C29),
(0x0C3A, 0x0C3B),
(0x0C45, 0x0C45),
(0x0C49, 0x0C49),
(0x0C4E, 0x0C54),
(0x0C57, 0x0C57),
(0x0C5B, 0x0C5C),
(0x0C5E, 0x0C5F),
(0x0C64, 0x0C65),
(0x0C70, 0x0C76),
(0x0C8D, 0x0C8D),
(0x0C91, 0x0C91),
(0x0CA9, 0x0CA9),
(0x0CB4, 0x0CB4),
(0x0CBA, 0x0CBB),
(0x0CC5, 0x0CC5),
(0x0CC9, 0x0CC9),
(0x0CCE, 0x0CD4),
(0x0CD7, 0x0CDC),
(0x0CDF, 0x0CDF),
(0x0CE4, 0x0CE5),
(0x0CF0, 0x0CF0),
(0x0CF4, 0x0CFF),
(0x0D0D, 0x0D0D),
(0x0D11, 0x0D11),
(0x0D45, 0x0D45),
(0x0D49, 0x0D49),
(0x0D50, 0x0D53),
(0x0D64, 0x0D65),
(0x0D80, 0x0D80),
(0x0D84, 0x0D84),
(0x0D97, 0x0D99),
(0x0DB2, 0x0DB2),
(0x0DBC, 0x0DBC),
(0x0DBE, 0x0DBF),
(0x0DC7, 0x0DC9),
(0x0DCB, 0x0DCE),
(0x0DD5, 0x0DD5),
(0x0DD7, 0x0DD7),
(0x0DE0, 0x0DE5),
(0x0DF0, 0x0DF1),
(0x0DF5, 0x0E00),
(0x0E3B, 0x0E3E),
(0x0E5C, 0x0E80),
(0x0E83, 0x0E83),
(0x0E85, 0x0E85),
(0x0E8B, 0x0E8B),
(0x0EA4, 0x0EA4),
(0x0EA6, 0x0EA6),
(0x0EBE, 0x0EBF),
(0x0EC5, 0x0EC5),
(0x0EC7, 0x0EC7),
(0x0ECF, 0x0ECF),
(0x0EDA, 0x0EDB),
(0x0EE0, 0x0EFF),
(0x0F48, 0x0F48),
(0x0F6D, 0x0F70),
(0x0F98, 0x0F98),
(0x0FBD, 0x0FBD),
(0x0FCD, 0x0FCD),
(0x0FDB, 0x0FFF),
(0x10C6, 0x10C6),
(0x10C8, 0x10CC),
(0x10CE, 0x10CF),
(0x1249, 0x1249),
(0x124E, 0x124F),
(0x1257, 0x1257),
(0x1259, 0x1259),
(0x125E, 0x125F),
(0x1289, 0x1289),
(0x128E, 0x128F),
(0x12B1, 0x12B1),
(0x12B6, 0x12B7),
(0x12BF, 0x12BF),
(0x12C1, 0x12C1),
(0x12C6, 0x12C7),
(0x12D7, 0x12D7),
(0x1311, 0x1311),
(0x1316, 0x1317),
(0x135B, 0x135C),
(0x137D, 0x137F),
(0x139A, 0x139F),
(0x13F6, 0x13F7),
(0x13FE, 0x13FF),
(0x1680, 0x1680),
(0x169D, 0x169F),
(0x16F9, 0x16FF),
(0x1716, 0x171E),
(0x1737, 0x173F),
(0x1754, 0x175F),
(0x176D, 0x176D),
(0x1771, 0x1771),
(0x1774, 0x177F),
(0x17DE, 0x17DF),
(0x17EA, 0x17EF),
(0x17FA, 0x17FF),
(0x180E, 0x180E),
(0x181A, 0x181F),
(0x1879, 0x187F),
(0x18AB, 0x18AF),
(0x18F6, 0x18FF),
(0x191F, 0x191F),
(0x192C, 0x192F),
(0x193C, 0x193F),
(0x1941, 0x1943),
(0x196E, 0x196F),
(0x1975, 0x197F),
(0x19AC, 0x19AF),
(0x19CA, 0x19CF),
(0x19DB, 0x19DD),
(0x1A1C, 0x1A1D),
(0x1A5F, 0x1A5F),
(0x1A7D, 0x1A7E),
(0x1A8A, 0x1A8F),
(0x1A9A, 0x1A9F),
(0x1AAE, 0x1AAF),
(0x1ACF, 0x1AFF),
(0x1B4D, 0x1B4D),
(0x1BF4, 0x1BFB),
(0x1C38, 0x1C3A),
(0x1C4A, 0x1C4C),
(0x1C8B, 0x1C8F),
(0x1CBB, 0x1CBC),
(0x1CC8, 0x1CCF),
(0x1CFB, 0x1CFF),
(0x1F16, 0x1F17),
(0x1F1E, 0x1F1F),
(0x1F46, 0x1F47),
(0x1F4E, 0x1F4F),
(0x1F58, 0x1F58),
(0x1F5A, 0x1F5A),
(0x1F5C, 0x1F5C),
(0x1F5E, 0x1F5E),
(0x1F7E, 0x1F7F),
(0x1FB5, 0x1FB5),
(0x1FC5, 0x1FC5),
(0x1FD4, 0x1FD5),
(0x1FDC, 0x1FDC),
(0x1FF0, 0x1FF1),
(0x1FF5, 0x1FF5),
(0x1FFF, 0x200F),
(0x2028, 0x202F),
(0x205F, 0x206F),
(0x2072, 0x2073),
(0x208F, 0x208F),
(0x209D, 0x209F),
(0x20C1, 0x20CF),
(0x20F1, 0x20FF),
(0x218C, 0x218F),
(0x242A, 0x243F),
(0x244B, 0x245F),
(0x2B74, 0x2B75),
(0x2B96, 0x2B96),
(0x2CF4, 0x2CF8),
(0x2D26, 0x2D26),
(0x2D28, 0x2D2C),
(0x2D2E, 0x2D2F),
(0x2D68, 0x2D6E),
(0x2D71, 0x2D7E),
(0x2D97, 0x2D9F),
(0x2DA7, 0x2DA7),
(0x2DAF, 0x2DAF),
(0x2DB7, 0x2DB7),
(0x2DBF, 0x2DBF),
(0x2DC7, 0x2DC7),
(0x2DCF, 0x2DCF),
(0x2DD7, 0x2DD7),
(0x2DDF, 0x2DDF),
(0x2E5E, 0x2E7F),
(0x2E9A, 0x2E9A),
(0x2EF4, 0x2EFF),
(0x2FD6, 0x2FEF),
(0x3000, 0x3000),
(0x3040, 0x3040),
(0x3097, 0x3098),
(0x3100, 0x3104),
(0x3130, 0x3130),
(0x318F, 0x318F),
(0x31E6, 0x31EE),
(0x321F, 0x321F),
(0xA48D, 0xA48F),
(0xA4C7, 0xA4CF),
(0xA62C, 0xA63F),
(0xA6F8, 0xA6FF),
(0xA7CE, 0xA7CF),
(0xA7D2, 0xA7D2),
(0xA7D4, 0xA7D4),
(0xA7DD, 0xA7F1),
(0xA82D, 0xA82F),
(0xA83A, 0xA83F),
(0xA878, 0xA87F),
(0xA8C6, 0xA8CD),
(0xA8DA, 0xA8DF),
(0xA954, 0xA95E),
(0xA97D, 0xA97F),
(0xA9CE, 0xA9CE),
(0xA9DA, 0xA9DD),
(0xA9FF, 0xA9FF),
(0xAA37, 0xAA3F),
(0xAA4E, 0xAA4F),
(0xAA5A, 0xAA5B),
(0xAAC3, 0xAADA),
(0xAAF7, 0xAB00),
(0xAB07, 0xAB08),
(0xAB0F, 0xAB10),
(0xAB17, 0xAB1F),
(0xAB27, 0xAB27),
(0xAB2F, 0xAB2F),
(0xAB6C, 0xAB6F),
(0xABEE, 0xABEF),
(0xABFA, 0xABFF),
(0xD7A4, 0xD7AF),
(0xD7C7, 0xD7CA),
(0xD7FC, 0xF8FF),
(0xFA6E, 0xFA6F),
(0xFADA, 0xFAFF),
(0xFB07, 0xFB12),
(0xFB18, 0xFB1C),
(0xFB37, 0xFB37),
(0xFB3D, 0xFB3D),
(0xFB3F, 0xFB3F),
(0xFB42, 0xFB42),
(0xFB45, 0xFB45),
(0xFBC3, 0xFBD2),
(0xFD90, 0xFD91),
(0xFDC8, 0xFDCE),
(0xFDD0, 0xFDEF),
(0xFE1A, 0xFE1F),
(0xFE53, 0xFE53),
(0xFE67, 0xFE67),
(0xFE6C, 0xFE6F),
(0xFE75, 0xFE75),
(0xFEFD, 0xFF00),
(0xFFBF, 0xFFC1),
(0xFFC8, 0xFFC9),
(0xFFD0, 0xFFD1),
(0xFFD8, 0xFFD9),
(0xFFDD, 0xFFDF),
(0xFFE7, 0xFFE7),
(0xFFEF, 0xFFFB),
(0xFFFE, 0xFFFF),
(0x1000C, 0x1000C),
(0x10027, 0x10027),
(0x1003B, 0x1003B),
(0x1003E, 0x1003E),
(0x1004E, 0x1004F),
(0x1005E, 0x1007F),
(0x100FB, 0x100FF),
(0x10103, 0x10106),
(0x10134, 0x10136),
(0x1018F, 0x1018F),
(0x1019D, 0x1019F),
(0x101A1, 0x101CF),
(0x101FE, 0x1027F),
(0x1029D, 0x1029F),
(0x102D1, 0x102DF),
(0x102FC, 0x102FF),
(0x10324, 0x1032C),
(0x1034B, 0x1034F),
(0x1037B, 0x1037F),
(0x1039E, 0x1039E),
(0x103C4, 0x103C7),
(0x103D6, 0x103FF),
(0x1049E, 0x1049F),
(0x104AA, 0x104AF),
(0x104D4, 0x104D7),
(0x104FC, 0x104FF),
(0x10528, 0x1052F),
(0x10564, 0x1056E),
(0x1057B, 0x1057B),
(0x1058B, 0x1058B),
(0x10593, 0x10593),
(0x10596, 0x10596),
(0x105A2, 0x105A2),
(0x105B2, 0x105B2),
(0x105BA, 0x105BA),
(0x105BD, 0x105BF),
(0x105F4, 0x105FF),
(0x10737, 0x1073F),
(0x10756, 0x1075F),
(0x10768, 0x1077F),
(0x10786, 0x10786),
(0x107B1, 0x107B1),
(0x107BB, 0x107FF),
(0x10806, 0x10807),
(0x10809, 0x10809),
(0x10836, 0x10836),
(0x10839, 0x1083B),
(0x1083D, 0x1083E),
(0x10856, 0x10856),
(0x1089F, 0x108A6),
(0x108B0, 0x108DF),
(0x108F3, 0x108F3),
(0x108F6, 0x108FA),
(0x1091C, 0x1091E),
(0x1093A, 0x1093E),
(0x10940, 0x1097F),
(0x109B8, 0x109BB),
(0x109D0, 0x109D1),
(0x10A04, 0x10A04),
(0x10A07, 0x10A0B),
(0x10A14, 0x10A14),
(0x10A18, 0x10A18),
(0x10A36, 0x10A37),
(0x10A3B, 0x10A3E),
(0x10A49, 0x10A4F),
(0x10A59, 0x10A5F),
(0x10AA0, 0x10ABF),
(0x10AE7, 0x10AEA),
(0x10AF7, 0x10AFF),
(0x10B36, 0x10B38),
(0x10B56, 0x10B57),
(0x10B73, 0x10B77),
(0x10B92, 0x10B98),
(0x10B9D, 0x10BA8),
(0x10BB0, 0x10BFF),
(0x10C49, 0x10C7F),
(0x10CB3, 0x10CBF),
(0x10CF3, 0x10CF9),
(0x10D28, 0x10D2F),
(0x10D3A, 0x10D3F),
(0x10D66, 0x10D68),
(0x10D86, 0x10D8D),
(0x10D90, 0x10E5F),
(0x10E7F, 0x10E7F),
(0x10EAA, 0x10EAA),
(0x10EAE, 0x10EAF),
(0x10EB2, 0x10EC1),
(0x10EC5, 0x10EFB),
(0x10F28, 0x10F2F),
(0x10F5A, 0x10F6F),
(0x10F8A, 0x10FAF),
(0x10FCC, 0x10FDF),
(0x10FF7, 0x10FFF),
(0x1104E, 0x11051),
(0x11076, 0x1107E),
(0x110BD, 0x110BD),
(0x110C3, 0x110CF),
(0x110E9, 0x110EF),
(0x110FA, 0x110FF),
(0x11135, 0x11135),
(0x11148, 0x1114F),
(0x11177, 0x1117F),
(0x111E0, 0x111E0),
(0x111F5, 0x111FF),
(0x11212, 0x11212),
(0x11242, 0x1127F),
(0x11287, 0x11287),
(0x11289, 0x11289),
(0x1128E, 0x1128E),
(0x1129E, 0x1129E),
(0x112AA, 0x112AF),
(0x112EB, 0x112EF),
(0x112FA, 0x112FF),
(0x11304, 0x11304),
(0x1130D, 0x1130E),
(0x11311, 0x11312),
(0x11329, 0x11329),
(0x11331, 0x11331),
(0x11334, 0x11334),
(0x1133A, 0x1133A),
(0x11345, 0x11346),
(0x11349, 0x1134A),
(0x1134E, 0x1134F),
(0x11351, 0x11356),
(0x11358, 0x1135C),
(0x11364, 0x11365),
(0x1136D, 0x1136F),
(0x11375, 0x1137F),
(0x1138A, 0x1138A),
(0x1138C, 0x1138D),
(0x1138F, 0x1138F),
(0x113B6, 0x113B6),
(0x113C1, 0x113C1),
(0x113C3, 0x113C4),
(0x113C6, 0x113C6),
(0x113CB, 0x113CB),
(0x113D6, 0x113D6),
(0x113D9, 0x113E0),
(0x113E3, 0x113FF),
(0x1145C, 0x1145C),
(0x11462, 0x1147F),
(0x114C8, 0x114CF),
(0x114DA, 0x1157F),
(0x115B6, 0x115B7),
(0x115DE, 0x115FF),
(0x11645, 0x1164F),
(0x1165A, 0x1165F),
(0x1166D, 0x1167F),
(0x116BA, 0x116BF),
(0x116CA, 0x116CF),
(0x116E4, 0x116FF),
(0x1171B, 0x1171C),
(0x1172C, 0x1172F),
(0x11747, 0x117FF),
(0x1183C, 0x1189F),
(0x118F3, 0x118FE),
(0x11907, 0x11908),
(0x1190A, 0x1190B),
(0x11914, 0x11914),
(0x11917, 0x11917),
(0x11936, 0x11936),
(0x11939, 0x1193A),
(0x11947, 0x1194F),
(0x1195A, 0x1199F),
(0x119A8, 0x119A9),
(0x119D8, 0x119D9),
(0x119E5, 0x119FF),
(0x11A48, 0x11A4F),
(0x11AA3, 0x11AAF),
(0x11AF9, 0x11AFF),
(0x11B0A, 0x11BBF),
(0x11BE2, 0x11BEF),
(0x11BFA, 0x11BFF),
(0x11C09, 0x11C09),
(0x11C37, 0x11C37),
(0x11C46, 0x11C4F),
(0x11C6D, 0x11C6F),
(0x11C90, 0x11C91),
(0x11CA8, 0x11CA8),
(0x11CB7, 0x11CFF),
(0x11D07, 0x11D07),
(0x11D0A, 0x11D0A),
(0x11D37, 0x11D39),
(0x11D3B, 0x11D3B),
(0x11D3E, 0x11D3E),
(0x11D48, 0x11D4F),
(0x11D5A, 0x11D5F),
(0x11D66, 0x11D66),
(0x11D69, 0x11D69),
(0x11D8F, 0x11D8F),
(0x11D92, 0x11D92),
(0x11D99, 0x11D9F),
(0x11DAA, 0x11EDF),
(0x11EF9, 0x11EFF),
(0x11F11, 0x11F11),
(0x11F3B, 0x11F3D),
(0x11F5B, 0x11FAF),
(0x11FB1, 0x11FBF),
(0x11FF2, 0x11FFE),
(0x1239A, 0x123FF),
(0x1246F, 0x1246F),
(0x12475, 0x1247F),
(0x12544, 0x12F8F),
(0x12FF3, 0x12FFF),
(0x13430, 0x1343F),
(0x13456, 0x1345F),
(0x143FB, 0x143FF),
(0x14647, 0x160FF),
(0x1613A, 0x167FF),
(0x16A39, 0x16A3F),
(0x16A5F, 0x16A5F),
(0x16A6A, 0x16A6D),
(0x16ABF, 0x16ABF),
(0x16ACA, 0x16ACF),
(0x16AEE, 0x16AEF),
(0x16AF6, 0x16AFF),
(0x16B46, 0x16B4F),
(0x16B5A, 0x16B5A),
(0x16B62, 0x16B62),
(0x16B78, 0x16B7C),
(0x16B90, 0x16D3F),
(0x16D7A, 0x16E3F),
(0x16E9B, 0x16EFF),
(0x16F4B, 0x16F4E),
(0x16F88, 0x16F8E),
(0x16FA0, 0x16FDF),
(0x16FE5, 0x16FEF),
(0x16FF2, 0x16FFF),
(0x187F8, 0x187FF),
(0x18CD6, 0x18CFE),
(0x18D09, 0x1AFEF),
(0x1AFF4, 0x1AFF4),
(0x1AFFC, 0x1AFFC),
(0x1AFFF, 0x1AFFF),
(0x1B123, 0x1B131),
(0x1B133, 0x1B14F),
(0x1B153, 0x1B154),
(0x1B156, 0x1B163),
(0x1B168, 0x1B16F),
(0x1B2FC, 0x1BBFF),
(0x1BC6B, 0x1BC6F),
(0x1BC7D, 0x1BC7F),
(0x1BC89, 0x1BC8F),
(0x1BC9A, 0x1BC9B),
(0x1BCA0, 0x1CBFF),
(0x1CCFA, 0x1CCFF),
(0x1CEB4, 0x1CEFF),
(0x1CF2E, 0x1CF2F),
(0x1CF47, 0x1CF4F),
(0x1CFC4, 0x1CFFF),
(0x1D0F6, 0x1D0FF),
(0x1D127, 0x1D128),
(0x1D173, 0x1D17A),
(0x1D1EB, 0x1D1FF),
(0x1D246, 0x1D2BF),
(0x1D2D4, 0x1D2DF),
(0x1D2F4, 0x1D2FF),
(0x1D357, 0x1D35F),
(0x1D379, 0x1D3FF),
(0x1D455, 0x1D455),
(0x1D49D, 0x1D49D),
(0x1D4A0, 0x1D4A1),
(0x1D4A3, 0x1D4A4),
(0x1D4A7, 0x1D4A8),
(0x1D4AD, 0x1D4AD),
(0x1D4BA, 0x1D4BA),
(0x1D4BC, 0x1D4BC),
(0x1D4C4, 0x1D4C4),
(0x1D506, 0x1D506),
(0x1D50B, 0x1D50C),
(0x1D515, 0x1D515),
(0x1D51D, 0x1D51D),
(0x1D53A, 0x1D53A),
(0x1D53F, 0x1D53F),
(0x1D545, 0x1D545),
(0x1D547, 0x1D549),
(0x1D551, 0x1D551),
(0x1D6A6, 0x1D6A7),
(0x1D7CC, 0x1D7CD),
(0x1DA8C, 0x1DA9A),
(0x1DAA0, 0x1DAA0),
(0x1DAB0, 0x1DEFF),
(0x1DF1F, 0x1DF24),
(0x1DF2B, 0x1DFFF),
(0x1E007, 0x1E007),
(0x1E019, 0x1E01A),
(0x1E022, 0x1E022),
(0x1E025, 0x1E025),
(0x1E02B, 0x1E02F),
(0x1E06E, 0x1E08E),
(0x1E090, 0x1E0FF),
(0x1E12D, 0x1E12F),
(0x1E13E, 0x1E13F),
(0x1E14A, 0x1E14D),
(0x1E150, 0x1E28F),
(0x1E2AF, 0x1E2BF),
(0x1E2FA, 0x1E2FE),
(0x1E300, 0x1E4CF),
(0x1E4FA, 0x1E5CF),
(0x1E5FB, 0x1E5FE),
(0x1E600, 0x1E7DF),
(0x1E7E7, 0x1E7E7),
(0x1E7EC, 0x1E7EC),
(0x1E7EF, 0x1E7EF),
(0x1E7FF, 0x1E7FF),
(0x1E8C5, 0x1E8C6),
(0x1E8D7, 0x1E8FF),
(0x1E94C, 0x1E94F),
(0x1E95A, 0x1E95D),
(0x1E960, 0x1EC70),
(0x1ECB5, 0x1ED00),
(0x1ED3E, 0x1EDFF),
(0x1EE04, 0x1EE04),
(0x1EE20, 0x1EE20),
(0x1EE23, 0x1EE23),
(0x1EE25, 0x1EE26),
(0x1EE28, 0x1EE28),
(0x1EE33, 0x1EE33),
(0x1EE38, 0x1EE38),
(0x1EE3A, 0x1EE3A),
(0x1EE3C, 0x1EE41),
(0x1EE43, 0x1EE46),
(0x1EE48, 0x1EE48),
(0x1EE4A, 0x1EE4A),
(0x1EE4C, 0x1EE4C),
(0x1EE50, 0x1EE50),
(0x1EE53, 0x1EE53),
(0x1EE55, 0x1EE56),
(0x1EE58, 0x1EE58),
(0x1EE5A, 0x1EE5A),
(0x1EE5C, 0x1EE5C),
(0x1EE5E, 0x1EE5E),
(0x1EE60, 0x1EE60),
(0x1EE63, 0x1EE63),
(0x1EE65, 0x1EE66),
(0x1EE6B, 0x1EE6B),
(0x1EE73, 0x1EE73),
(0x1EE78, 0x1EE78),
(0x1EE7D, 0x1EE7D),
(0x1EE7F, 0x1EE7F),
(0x1EE8A, 0x1EE8A),
(0x1EE9C, 0x1EEA0),
(0x1EEA4, 0x1EEA4),
(0x1EEAA, 0x1EEAA),
(0x1EEBC, 0x1EEEF),
(0x1EEF2, 0x1EFFF),
(0x1F02C, 0x1F02F),
(0x1F094, 0x1F09F),
(0x1F0AF, 0x1F0B0),
(0x1F0C0, 0x1F0C0),
(0x1F0D0, 0x1F0D0),
(0x1F0F6, 0x1F0FF),
(0x1F1AE, 0x1F1E5),
(0x1F203, 0x1F20F),
(0x1F23C, 0x1F23F),
(0x1F249, 0x1F24F),
(0x1F252, 0x1F25F),
(0x1F266, 0x1F2FF),
(0x1F6D8, 0x1F6DB),
(0x1F6ED, 0x1F6EF),
(0x1F6FD, 0x1F6FF),
(0x1F777, 0x1F77A),
(0x1F7DA, 0x1F7DF),
(0x1F7EC, 0x1F7EF),
(0x1F7F1, 0x1F7FF),
(0x1F80C, 0x1F80F),
(0x1F848, 0x1F84F),
(0x1F85A, 0x1F85F),
(0x1F888, 0x1F88F),
(0x1F8AE, 0x1F8AF),
(0x1F8BC, 0x1F8BF),
(0x1F8C2, 0x1F8FF),
(0x1FA54, 0x1FA5F),
(0x1FA6E, 0x1FA6F),
(0x1FA7D, 0x1FA7F),
(0x1FA8A, 0x1FA8E),
(0x1FAC7, 0x1FACD),
(0x1FADD, 0x1FADE),
(0x1FAEA, 0x1FAEF),
(0x1FAF9, 0x1FAFF),
(0x1FB93, 0x1FB93),
(0x1FBFA, 0x1FFFF),
(0x2A6E0, 0x2A6FF),
(0x2B73A, 0x2B73F),
(0x2B81E, 0x2B81F),
(0x2CEA2, 0x2CEAF),
(0x2EBE1, 0x2EBEF),
(0x2EE5E, 0x2F7FF),
(0x2FA1E, 0x2FFFF),
(0x3134B, 0x3134F),
(0x323B0, 0xE00FF),
(0xE01F0, 0x10FFFF),
];

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,352 @@
"""Regenerate generated/values.json: what CPython produces for each value in CORPUS.
python scripts/generate_fixtures.py > generated/values.json
Each row records `repr`, `str`, `json.dumps` (or its error), `bool`, and `pickle.dumps` at
every protocol. `literal` says whether `ast.literal_eval(repr(value))` gives the value back,
which is how Python reads `str(dict)` text back from a cache; the Rust tests reach the other
rows only through pickle. `view` is `repr` of the value as `pickle::loads` decodes it, with
tuples, sets, and frozensets rendered as lists. `sources` records `ast.literal_eval` on raw
source texts: its result, or the exception it raises.
"""
import ast
import json
import pickle
import sys
import warnings
# Entries are source texts, or `(name, source)` when the source is too long to read in a
# test report. `name` is what the Rust `KNOWN` table keys on.
CORPUS = [
# Scalars
"None",
"True",
"False",
"0",
"-7",
"2**63 - 1",
"-(2**63)",
"2**64",
"-(2**70)",
# Floats around CPython's repr thresholds
"0.0",
"-0.0",
"0.2",
"1.0",
"-1.5",
"0.1 + 0.2",
"123456789.123",
"1e15",
"1e16",
"1.5e16",
"9999999999999998.0",
"0.0001",
"1e-05",
"1.25e-07",
"5e-324",
"1.7976931348623157e308",
"1e22",
"float('inf')",
"float('-inf')",
"float('nan')",
# Complex
"1j",
"-1j",
"complex(0, -1)",
"1+2j",
"-1.5-0.5j",
"complex(0.0, 1e16)",
# Strings: quote selection, escapes, printable and non-printable non-ASCII
"''",
"'plain'",
'"it\'s"',
"'say \"hi\"'",
"'both \\' and \"'",
"'back\\\\slash'",
"'\\t\\n\\r'",
"'\\x00\\x1f\\x7f'",
"'\\x85\\xa0\\xad'",
"'caf\\xe9'",
"'\\u65e5\\u672c'",
"'\\u200b\\u2028\\u3000'",
"'\\U0001f600'",
"'\\U000e0001\\U0010ffff'",
"'\\b\\f'",
# Bytes
"b''",
"b'abc'",
'b"a\'b"',
"b'a\"b\\'c'",
"b'\\x00\\t\\n\\r\\x7f\\x80\\xff'",
# Containers
"[]",
"[1, 'a', None, True]",
"()",
"(1,)",
"(1, (2, 3))",
"{}",
"{'a': 1, 'b': [1.0, 2.5]}",
"{'z': 1, 'a': 2, 'm': 3}",
"{1: 'int', 2.5: 'float', True: 'bool', None: 'none'}",
"{(1, 2): 'tuple key'}",
"{'nested': {'deeper': {'deepest': [{}]}}}",
"{1}",
"set()",
"frozenset({1})",
"[[[[[[[[[[1]]]]]]]]]]",
# Deeper than the Rust decoders allow: CPython's parser accepts ~200 nested brackets
# and its unpickler has no limit, so this row records a deliberate divergence.
("nested_150", "[" * 150 + "1" + "]" * 150),
# The shape LiteLLM caches
'{\'timestamp\': 1726000000.123, \'response\': \'{"id": "chatcmpl-1", "object": "chat.completion"}\'}',
"{'model': 'gpt-4o', 'messages': [{'role': 'user', 'content': 'hi'}], 'temperature': 0.2, 'stream': False}",
]
# Source texts for `literal_eval` itself: tokenizer and evaluator edge cases, recorded with
# CPython's result or the exception it raises. Raw strings keep backslashes literal.
SOURCES = [
# Layout: leading/trailing whitespace, comments, newlines, continuations
"1",
" 1",
"\t1",
"\n1",
" \n 1",
"1\n",
"1 # comment",
"# c\n1",
"1 \\\n",
"1,",
"1, 2",
"1,\n2",
"(1,\n2)",
"[1,\n 2,\n]",
# Containers and grouping
"()",
"(1)",
"((1,))",
"(,)",
"[,]",
"{,}",
"[1,]",
"{'a': 1,}",
"{1,}",
"{'a': 1 'b': 2}",
"{1: 'a', True: 'b'}",
"{1, True, 1.0}",
"{(1, 2): 'x', (1.0, 2): 'y'}",
"{[1]: 2}",
"{{1}}",
"{(1, [2])}",
"set()",
"set( )",
"set([1])",
"frozenset()",
# Names
"True",
"False",
"None",
"Truex",
"true",
"...",
# Integers and floats
"0",
"00",
"0_0",
"01",
"007",
"1_000",
"1_",
"1__0",
"_1",
"0x1F",
"0X_1f",
"0o17",
"0b101",
"0b102",
"0x",
"1e3",
"1E-3",
"1e",
"1.e5",
".5",
"5.",
"1..",
"1.5.2",
"1_0.0_1e1_0",
"1e999",
"-1e999",
"1j",
"1.5J",
"010j",
"010.5",
"1a",
"0x1g",
# Signs and complex sums
"-1",
"+1",
"- 1",
"--1",
"-+1",
"-(1)",
"-(-1)",
"-(1+2j)",
"-True",
"-'a'",
"-[1]",
"1+2j",
"1-2j",
"1 + 2j",
"(1)+(2j)",
"1+2",
"1+2+3j",
"1+-2j",
"2j+1",
"True+1j",
"1-0j",
"0.0-0j",
"-0.0+1j",
"-0.0",
"-0",
"(-0.0)",
"[-0.0, (-0.0)]",
"2**3",
"1*2",
"1 if 1 else 2",
"(1,)(2)",
# String prefixes, quoting, and concatenation
"''",
'""',
"'a' 'b'",
"'a' \"b\" '''c'''",
"'a' b'b'",
"b'a' b'b'",
"u'x'",
"U'x'",
"r'x'",
"R'x'",
"b'x'",
"B'x'",
"br'x'",
"Rb'x'",
"rB'x'",
"ur'x'",
"bu'x'",
"f'x'",
"rf'x'",
"'''a\nb'''",
'"""a\\""""',
"'a\nb'",
"'a\\\nb'",
"r'a\\\nb'",
"'unterminated",
# Escapes
r"'\a\b\f\n\r\t\v'",
r"'\0\12\101\1011'",
r"'\777'",
r"b'\777'",
r"b'\400'",
r"'\x41'",
r"'\x4'",
r"'\u00e9'",
r"'\u00e'",
r"'\U0001F600'",
r"'\U00110000'",
r"'\ud800'",
r"'\N{BULLET}'",
r"'\q'",
r"'\\'",
r"'\''",
r'"\""',
r"b'\u0041'",
r"b'\x41\xff'",
"b'café'",
"'café'",
r"r'\d'",
r"r'\''",
r"rb'\d'",
r"r'\'",
"'\U0001f600'",
]
def named(entry):
"""Split a corpus entry into its report name and its source text."""
if isinstance(entry, tuple):
return entry
return entry, entry
def evaluate(entry):
name, source = named(entry)
with warnings.catch_warnings():
warnings.simplefilter("ignore")
try:
return {"name": name, "source": source, "repr": repr(ast.literal_eval(source))}
except Exception as error: # noqa: BLE001 - recorded, not raised
return {"name": name, "source": source, "error": type(error).__name__}
def view(value):
if isinstance(value, (list, tuple, set, frozenset)):
return "[" + ", ".join(view(item) for item in value) + "]"
if isinstance(value, dict):
return "{" + ", ".join(f"{view(key)}: {view(item)}" for key, item in value.items()) + "}"
return repr(value)
def plain(value):
"""Whether pickle can encode the value without a class reference such as `complex`."""
if isinstance(value, complex):
return False
if isinstance(value, (list, tuple, set, frozenset)):
return all(plain(item) for item in value)
if isinstance(value, dict):
return all(plain(key) and plain(item) for key, item in value.items())
return True
def is_literal(value):
try:
parsed = ast.literal_eval(repr(value))
except (ValueError, SyntaxError):
return False
return repr(parsed) == repr(value)
def row(entry):
name, source = named(entry)
value = eval(source)
entry = {
"name": name,
"source": source,
"literal": is_literal(value),
"plain": plain(value),
"repr": repr(value),
"str": str(value),
"truthy": bool(value),
}
try:
entry["json"] = json.dumps(value)
except (TypeError, ValueError) as error:
entry["json_error"] = str(error)
try:
entry["pickle"] = {str(protocol): pickle.dumps(value, protocol=protocol).hex() for protocol in range(6)}
entry["view"] = view(value)
except Exception as error: # noqa: BLE001 - recorded, not raised
entry["pickle_error"] = f"{type(error).__name__}: {error}"
return entry
if __name__ == "__main__":
json.dump(
{
"python": sys.version.split()[0],
"rows": [row(entry) for entry in CORPUS],
"sources": [evaluate(entry) for entry in SOURCES],
},
sys.stdout,
indent=2,
ensure_ascii=True,
)
sys.stdout.write("\n")

View file

@ -0,0 +1,35 @@
"""Regenerate generated/nonprintable.rs from this interpreter's `str.isprintable`.
`repr(str)` escapes exactly the characters for which `str.isprintable()` is false, so the
table must come from the Python version the gateway interoperates with.
python scripts/generate_nonprintable.py > generated/nonprintable.rs
"""
import sys
import unicodedata
ranges = []
start = None
for code in range(0x110000):
printable = chr(code).isprintable()
if not printable and start is None:
start = code
elif printable and start is not None:
ranges.append((start, code - 1))
start = None
if start is not None:
ranges.append((start, 0x10FFFF))
lines = [
f"// Generated by scripts/generate_nonprintable.py from Python {sys.version.split()[0]}",
f"// (Unicode {unicodedata.unidata_version}). Do not edit by hand.",
"",
f'pub(crate) const UNICODE_VERSION: &str = "{unicodedata.unidata_version}";',
"",
"/// Inclusive code point ranges for which Python's `str.isprintable()` is false.",
f"pub(crate) const NONPRINTABLE: [(u32, u32); {len(ranges)}] = [",
*(f" (0x{low:04X}, 0x{high:04X})," for low, high in ranges),
"];",
]
sys.stdout.write("\n".join(lines) + "\n")

View file

@ -0,0 +1,43 @@
"""Check that CPython unpickles what `pickle::dumps` writes, to the value it was given.
PYTHON_COMPAT_RUST_PICKLES=rust.tsv cargo test -p litellm-python-compat --test fixtures
python scripts/verify_rust_pickles.py rust.tsv
The rows are plain data by construction, so this refuses to resolve any class rather than
handing file-controlled bytes to an unrestricted `pickle.loads`.
"""
import ast
import io
import pickle
import sys
class PlainDataUnpickler(pickle.Unpickler):
"""An unpickler with `GLOBAL`/`REDUCE` disabled, mirroring `pickle::loads` in Rust."""
def find_class(self, module, name):
raise pickle.UnpicklingError(f"refusing to resolve {module}.{name}")
def loads(data):
return PlainDataUnpickler(io.BytesIO(data)).load()
def main(path):
failures = 0
rows = 0
with open(path, encoding="utf-8") as lines:
for line in lines:
data, expected = line.rstrip("\n").split("\t", 1)
rows += 1
actual = repr(loads(bytes.fromhex(data)))
if actual != repr(ast.literal_eval(expected)):
failures += 1
sys.stdout.write(f"mismatch: expected {expected}, got {actual}\n")
sys.stdout.write(f"{rows} Rust pickles checked, {failures} mismatches\n")
return 1 if failures or not rows else 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1]))

View file

@ -0,0 +1,25 @@
use crate::MAX_DEPTH;
/// A failure to read or write a Python format. Messages quote CPython's own wording where
/// the Python side raises (`TypeError`, `ValueError`), so callers can log them as is.
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("malformed Python literal at byte {0}")]
InvalidLiteral(usize),
#[error("unhashable type: '{0}'")]
Unhashable(&'static str),
#[error("value nests deeper than {MAX_DEPTH} levels")]
TooDeep,
#[error("invalid pickle: {0}")]
InvalidPickle(String),
#[error("Object of type {0} is not JSON serializable")]
NotJsonSerializable(&'static str),
#[error("keys must be str, int, float, bool or None, not {0}")]
InvalidJsonKey(&'static str),
#[error("Out of range float values are not JSON compliant")]
NonFiniteFloat,
#[error("integer does not fit in the JSON number range")]
IntegerOutOfRange,
#[error("Object of type {0} cannot be pickled as plain data")]
NotPicklable(&'static str),
}

View file

@ -0,0 +1,168 @@
//! `json.dumps` with CPython's default options, and the JSON value `json.loads` returns.
//!
//! Defaults are `ensure_ascii=True`, `allow_nan=True`, separators `(", ", ": ")`, and no key
//! sorting. Python's `json.loads` is mapped by [`from_json`]; a `serde_json` number that does
//! not fit `i64` or `u64` arrives as a float, where Python would keep an `int`.
use std::fmt::Write;
use serde_json::{Map, Number};
use crate::{Error, Value, repr::float_repr};
/// `json.dumps(value)`.
pub fn dumps(value: &Value) -> Result<String, Error> {
let mut out = String::new();
write_value(&mut out, value)?;
Ok(out)
}
/// The Python value `json.loads` returns for a JSON document.
pub fn from_json(value: serde_json::Value) -> Value {
match value {
serde_json::Value::Null => Value::None,
serde_json::Value::Bool(value) => Value::Bool(value),
serde_json::Value::Number(number) => {
if let Some(value) = number.as_i64() {
Value::Int(value.into())
} else if let Some(value) = number.as_u64() {
Value::Int(value.into())
} else {
Value::Float(number.as_f64().unwrap_or(f64::NAN))
}
}
serde_json::Value::String(text) => Value::Str(text),
serde_json::Value::Array(values) => {
Value::List(values.into_iter().map(from_json).collect())
}
serde_json::Value::Object(entries) => Value::Dict(
entries
.into_iter()
.map(|(key, value)| (Value::Str(key), from_json(value)))
.collect(),
),
}
}
/// `json.loads(json.dumps(value))` as a `serde_json` value: tuples become arrays and dict
/// keys are coerced to strings as `json.dumps` does. Non-finite floats, which Python writes
/// as `NaN` and `Infinity`, have no `serde_json` form and fail with [`Error::NonFiniteFloat`].
pub fn to_json(value: &Value) -> Result<serde_json::Value, Error> {
Ok(match value {
Value::None => serde_json::Value::Null,
Value::Bool(value) => serde_json::Value::Bool(*value),
Value::Int(value) => {
let text = value.to_string();
serde_json::Value::Number(
text.parse::<i64>()
.map(Number::from)
.or_else(|_| text.parse::<u64>().map(Number::from))
.map_err(|_| Error::IntegerOutOfRange)?,
)
}
Value::Float(value) => {
serde_json::Value::Number(Number::from_f64(*value).ok_or(Error::NonFiniteFloat)?)
}
Value::Str(text) => serde_json::Value::String(text.clone()),
Value::List(values) | Value::Tuple(values) => {
serde_json::Value::Array(values.iter().map(to_json).collect::<Result<Vec<_>, _>>()?)
}
Value::Dict(entries) => serde_json::Value::Object(
entries
.iter()
.map(|(key, value)| Ok((json_key(key)?, to_json(value)?)))
.collect::<Result<Map<_, _>, Error>>()?,
),
value @ (Value::Bytes(_) | Value::Set(_) | Value::Complex { .. }) => {
return Err(Error::NotJsonSerializable(value.type_name()));
}
})
}
fn write_value(out: &mut String, value: &Value) -> Result<(), Error> {
match value {
Value::None => out.push_str("null"),
Value::Bool(true) => out.push_str("true"),
Value::Bool(false) => out.push_str("false"),
Value::Int(value) => {
let _ = write!(out, "{value}");
}
Value::Float(value) => out.push_str(&float_text(*value)),
Value::Str(text) => write_string(out, text),
Value::List(values) | Value::Tuple(values) => {
out.push('[');
for (index, value) in values.iter().enumerate() {
if index > 0 {
out.push_str(", ");
}
write_value(out, value)?;
}
out.push(']');
}
Value::Dict(entries) => {
out.push('{');
for (index, (key, value)) in entries.iter().enumerate() {
if index > 0 {
out.push_str(", ");
}
write_string(out, &json_key(key)?);
out.push_str(": ");
write_value(out, value)?;
}
out.push('}');
}
value @ (Value::Bytes(_) | Value::Set(_) | Value::Complex { .. }) => {
return Err(Error::NotJsonSerializable(value.type_name()));
}
}
Ok(())
}
/// `json.encoder.JSONEncoder.iterencode`'s `floatstr` with `allow_nan=True`.
fn float_text(value: f64) -> String {
if value.is_nan() {
"NaN".to_owned()
} else if value.is_infinite() {
if value > 0.0 { "Infinity" } else { "-Infinity" }.to_owned()
} else {
float_repr(value)
}
}
/// Dict key coercion in `json.dumps`: scalars become their JSON text, other keys fail.
fn json_key(key: &Value) -> Result<String, Error> {
Ok(match key {
Value::Str(text) => text.clone(),
Value::Int(value) => value.to_string(),
Value::Float(value) => float_text(*value),
Value::Bool(true) => "true".to_owned(),
Value::Bool(false) => "false".to_owned(),
Value::None => "null".to_owned(),
key => return Err(Error::InvalidJsonKey(key.type_name())),
})
}
/// `py_encode_basestring_ascii`: escape `"`, `\`, control characters, and everything outside
/// printable ASCII as `\uXXXX`, with surrogate pairs above the BMP.
fn write_string(out: &mut String, text: &str) {
out.push('"');
for ch in text.chars() {
match ch {
'"' => out.push_str("\\\""),
'\\' => out.push_str("\\\\"),
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
'\t' => out.push_str("\\t"),
'\u{8}' => out.push_str("\\b"),
'\u{c}' => out.push_str("\\f"),
' '..='~' => out.push(ch),
ch => {
let mut units = [0u16; 2];
for unit in ch.encode_utf16(&mut units) {
let _ = write!(out, "\\u{unit:04x}");
}
}
}
}
out.push('"');
}

View file

@ -0,0 +1,39 @@
//! Python data formats reproduced in Rust, for state that Python LiteLLM writes and reads.
//!
//! Each module mirrors one Python operation over plain data values, and its tests replay
//! fixtures generated by that operation in CPython (`scripts/generate_fixtures.py`):
//!
//! | Module | Python operation |
//! |---|---|
//! | [`literal`] | `ast.literal_eval(text)` |
//! | [`repr`] | `repr(value)` and `str(value)` |
//! | [`json`] | `json.dumps(value)`, and `json.loads(json.dumps(value))` as a JSON value |
//! | [`pickle`] | `pickle.loads(data)` and `pickle.dumps(value)` for plain data |
//! | [`truthy`] | `bool(value)` |
//!
//! [`Value`] is the closed data model these formats share. Live Python objects
//! (descriptors, `__bool__`, `__str__`, callbacks) are out of scope: those belong to the
//! PyO3 boundary in `litellm-python-bridge`, which runs the real protocol.
//!
//! Known limits, each pinned by a test:
//! - `set` iteration order follows Python's hash order, which this crate does not model
//! (string hashes are randomized per process). Sets keep their literal order.
//! - `str` values are Rust `String`s, so lone surrogates cannot be represented.
//! - [`pickle::loads`] decodes `tuple`, `set`, and `frozenset` as lists.
mod error;
pub mod json;
pub mod literal;
pub mod pickle;
pub mod repr;
pub mod truthy;
mod value;
pub use error::Error;
pub use num_bigint::BigInt;
pub use value::Value;
/// Nesting limit for the decoders, which recurse. It keeps untrusted persisted data from
/// overflowing the Rust stack, and is deliberately stricter than CPython, whose parser takes
/// about 200 nested brackets and whose unpickler has no limit.
pub const MAX_DEPTH: usize = 128;

View file

@ -0,0 +1,745 @@
//! `ast.literal_eval(text)`, as a single-pass recursive-descent parser.
//!
//! Tokens follow CPython's tokenizer (string prefixes, escapes, implicit concatenation,
//! numeric underscores and radixes, comments, line continuations). Expressions follow
//! `ast.literal_eval`'s evaluator:
//!
//! - one unary `+`/`-`, applied only to a numeric constant (`-(1)` is fine, `--1` is not)
//! - `a + b` / `a - b` only as a signed real plus or minus a complex constant, with 3.14's
//! mixed-mode rules (`1 - 0j` is `(1-0j)`)
//! - parentheses group without making a tuple; `set()` is the only call
//! - dict keys and set members are deduplicated with Python equality (`1 == 1.0 == True`)
//!
//! Not supported, each pinned by a fixture: `\N{NAME}` escapes, `...`, and escapes that
//! produce lone surrogates.
use std::collections::{HashMap, hash_map::Entry};
use num_bigint::BigInt;
use num_traits::{FromPrimitive, ToPrimitive};
use crate::{Error, MAX_DEPTH, Value};
pub fn literal_eval(text: &str) -> Result<Value, Error> {
let mut parser = Parser {
bytes: text.trim_start_matches([' ', '\t']).as_bytes(),
offset: text.len() - text.trim_start_matches([' ', '\t']).len(),
pos: 0,
depth: 0,
brackets: 0,
};
parser.skip_leading_lines()?;
let value = parser.top_level()?.value;
parser.skip_trivia(true);
if parser.pos != parser.bytes.len() {
return Err(parser.error());
}
Ok(value)
}
/// How a parsed term may take part in `+`/`-`, per `ast.literal_eval`'s `_convert_num`
/// (`Constant`) and `_convert_signed_num` (`Signed`). `Other` is any other node.
#[derive(Clone, Copy, PartialEq)]
enum Kind {
Constant,
Signed,
Other,
}
struct Term {
value: Value,
kind: Kind,
}
impl Term {
fn other(value: Value) -> Self {
Self {
value,
kind: Kind::Other,
}
}
fn is_number(&self) -> bool {
matches!(
self.value,
Value::Int(_) | Value::Float(_) | Value::Complex { .. }
)
}
}
struct Parser<'a> {
bytes: &'a [u8],
/// Bytes stripped before `bytes` starts, so errors report offsets into the input.
offset: usize,
pos: usize,
depth: usize,
/// Open brackets: newlines are insignificant only inside them.
brackets: usize,
}
impl Parser<'_> {
fn error(&self) -> Error {
Error::InvalidLiteral(self.offset + self.pos)
}
fn peek(&self) -> Option<u8> {
self.bytes.get(self.pos).copied()
}
fn peek_at(&self, ahead: usize) -> Option<u8> {
self.bytes.get(self.pos + ahead).copied()
}
fn expect(&mut self, byte: u8) -> Result<(), Error> {
if self.peek() != Some(byte) {
return Err(self.error());
}
self.pos += 1;
Ok(())
}
fn enter(&mut self) -> Result<(), Error> {
self.depth += 1;
if self.depth > MAX_DEPTH {
return Err(Error::TooDeep);
}
Ok(())
}
/// Whitespace, comments, and backslash continuations; newlines too when `newlines`.
fn skip_trivia(&mut self, newlines: bool) {
while let Some(byte) = self.peek() {
match byte {
b' ' | b'\t' | b'\x0c' => self.pos += 1,
b'#' => {
while !matches!(self.peek(), None | Some(b'\n' | b'\r')) {
self.pos += 1;
}
}
// A continuation joins two lines; one that ends the input is an EOF error.
b'\\' if matches!(self.peek_at(1), Some(b'\n' | b'\r')) => {
let len = match (self.peek_at(1), self.peek_at(2)) {
(Some(b'\r'), Some(b'\n')) => 3,
_ => 2,
};
if self.pos + len >= self.bytes.len() {
break;
}
self.pos += len;
}
b'\n' | b'\r' if newlines => self.pos += 1,
_ => break,
}
}
}
/// Blank and comment-only lines may precede the expression, whose own line must not be
/// indented (CPython raises `IndentationError`).
fn skip_leading_lines(&mut self) -> Result<(), Error> {
loop {
let line_start = self.pos;
self.skip_trivia(false);
match self.peek() {
Some(b'\n' | b'\r') => self.pos += 1,
Some(_) if line_start > 0 && self.pos > line_start => {
self.pos = line_start;
return Err(self.error());
}
_ => return Ok(()),
}
}
}
fn at_logical_line_end(&self) -> bool {
matches!(self.peek(), None | Some(b'\n' | b'\r'))
}
/// The `eval` input: an expression, or a tuple without parentheses.
fn top_level(&mut self) -> Result<Term, Error> {
let first = self.expression()?;
self.skip_trivia(false);
if self.peek() != Some(b',') {
return Ok(first);
}
let mut values = vec![first.value];
while self.peek() == Some(b',') {
self.pos += 1;
self.skip_trivia(false);
if self.at_logical_line_end() {
break;
}
values.push(self.expression()?.value);
self.skip_trivia(false);
}
Ok(Term::other(Value::Tuple(values)))
}
/// A sum of unary terms, checked as `ast.literal_eval` checks `BinOp`.
fn expression(&mut self) -> Result<Term, Error> {
let mut left = self.unary()?;
loop {
self.skip_trivia(self.brackets > 0);
let subtract = match self.peek() {
Some(b'+') => false,
Some(b'-') => true,
_ => return Ok(left),
};
let at = self.pos;
self.pos += 1;
let right = self.unary()?;
left = complex_sum(left, subtract, right)
.ok_or(Error::InvalidLiteral(self.offset + at))?;
}
}
fn unary(&mut self) -> Result<Term, Error> {
self.skip_trivia(self.brackets > 0);
let negative = match self.peek() {
Some(b'+') => false,
Some(b'-') => true,
_ => return self.primary(),
};
let at = self.pos;
self.pos += 1;
self.enter()?;
let operand = self.unary()?;
self.depth -= 1;
if operand.kind != Kind::Constant || !operand.is_number() {
return Err(Error::InvalidLiteral(self.offset + at));
}
let value = if !negative {
operand.value
} else {
match operand.value {
Value::Int(value) => Value::Int(-value),
Value::Float(value) => Value::Float(-value),
Value::Complex { re, im } => Value::Complex { re: -re, im: -im },
_ => unreachable!("checked numeric above"),
}
};
Ok(Term {
value,
kind: Kind::Signed,
})
}
fn primary(&mut self) -> Result<Term, Error> {
match self.peek() {
Some(b'(') => self.parenthesized(),
Some(b'[') => self.list(),
Some(b'{') => self.braced(),
Some(b'0'..=b'9') => self.number(),
Some(b'.') if matches!(self.peek_at(1), Some(b'0'..=b'9')) => self.number(),
Some(b'\'' | b'"') => self.strings(),
Some(byte) if byte.is_ascii_alphabetic() || byte == b'_' => {
if self.string_prefix_len().is_some() {
return self.strings();
}
self.name()
}
_ => Err(self.error()),
}
}
fn open(&mut self) -> Result<(), Error> {
self.enter()?;
self.brackets += 1;
self.pos += 1;
Ok(())
}
fn close(&mut self, byte: u8) -> Result<(), Error> {
self.skip_trivia(true);
self.expect(byte)?;
self.brackets -= 1;
self.depth -= 1;
Ok(())
}
/// Comma-separated expressions up to `close`, with an optional trailing comma.
fn elements(&mut self, close: u8) -> Result<Vec<Value>, Error> {
let mut values = Vec::new();
loop {
self.skip_trivia(true);
if self.peek() == Some(close) {
return Ok(values);
}
values.push(self.expression()?.value);
self.skip_trivia(true);
if self.peek() != Some(b',') {
return Ok(values);
}
self.pos += 1;
}
}
fn parenthesized(&mut self) -> Result<Term, Error> {
self.open()?;
self.skip_trivia(true);
if self.peek() == Some(b')') {
self.close(b')')?;
return Ok(Term::other(Value::Tuple(Vec::new())));
}
let first = self.expression()?;
self.skip_trivia(true);
if self.peek() != Some(b',') {
self.close(b')')?;
return Ok(first);
}
self.pos += 1;
let mut values = vec![first.value];
values.extend(self.elements(b')')?);
self.close(b')')?;
Ok(Term::other(Value::Tuple(values)))
}
fn list(&mut self) -> Result<Term, Error> {
self.open()?;
let values = self.elements(b']')?;
self.close(b']')?;
Ok(Term::other(Value::List(values)))
}
fn braced(&mut self) -> Result<Term, Error> {
self.open()?;
self.skip_trivia(true);
if self.peek() == Some(b'}') {
self.close(b'}')?;
return Ok(Term::other(Value::Dict(Vec::new())));
}
let first = self.expression()?.value;
self.skip_trivia(true);
if self.peek() != Some(b':') {
let mut members = UniqueValues::default();
members.insert(first, None)?;
if self.peek() == Some(b',') {
self.pos += 1;
for member in self.elements(b'}')? {
members.insert(member, None)?;
}
}
self.close(b'}')?;
return Ok(Term::other(Value::Set(members.keys)));
}
let mut entries = UniqueValues::default();
let mut key = first;
loop {
self.expect(b':')?;
let value = self.expression()?.value;
entries.insert(key, Some(value))?;
self.skip_trivia(true);
if self.peek() != Some(b',') {
break;
}
self.pos += 1;
self.skip_trivia(true);
if self.peek() == Some(b'}') {
break;
}
key = self.expression()?.value;
self.skip_trivia(true);
}
self.close(b'}')?;
Ok(Term::other(Value::Dict(entries.into_entries())))
}
fn identifier(&mut self) -> &[u8] {
let start = self.pos;
while matches!(self.peek(), Some(byte) if byte.is_ascii_alphanumeric() || byte == b'_') {
self.pos += 1;
}
&self.bytes[start..self.pos]
}
fn name(&mut self) -> Result<Term, Error> {
let at = self.pos;
let value = match self.identifier() {
b"True" => Value::Bool(true),
b"False" => Value::Bool(false),
b"None" => Value::None,
b"set" => {
self.skip_trivia(self.brackets > 0);
self.expect(b'(')?;
self.skip_trivia(true);
self.expect(b')')?;
return Ok(Term::other(Value::Set(Vec::new())));
}
_ => return Err(Error::InvalidLiteral(self.offset + at)),
};
Ok(Term {
value,
kind: Kind::Constant,
})
}
/// Digits with single underscores between them, as CPython's `digitpart`.
fn digits(&mut self, radix: u32, out: &mut String) -> Result<(), Error> {
let start = out.len();
loop {
match self.peek() {
Some(byte) if (byte as char).is_digit(radix) => {
out.push(byte as char);
self.pos += 1;
}
Some(b'_')
if out.len() > start
&& matches!(self.peek_at(1), Some(next) if (next as char).is_digit(radix)) =>
{
self.pos += 1;
}
_ => break,
}
}
if out.len() == start {
return Err(self.error());
}
Ok(())
}
fn number(&mut self) -> Result<Term, Error> {
let start = self.pos;
let radix = match (
self.peek(),
self.peek_at(1).map(|byte| byte.to_ascii_lowercase()),
) {
(Some(b'0'), Some(b'x')) => Some(16),
(Some(b'0'), Some(b'o')) => Some(8),
(Some(b'0'), Some(b'b')) => Some(2),
_ => None,
};
let mut text = String::new();
let value = if let Some(radix) = radix {
self.pos += 2;
if self.peek() == Some(b'_') {
self.pos += 1;
}
self.digits(radix, &mut text)?;
Value::Int(BigInt::parse_bytes(text.as_bytes(), radix).ok_or(self.error())?)
} else {
let mut is_float = false;
if self.peek() != Some(b'.') {
self.digits(10, &mut text)?;
}
let integer_digits = text.clone();
if self.peek() == Some(b'.') {
is_float = true;
self.pos += 1;
text.push('.');
if matches!(self.peek(), Some(b'0'..=b'9')) {
self.digits(10, &mut text)?;
}
}
if matches!(self.peek(), Some(b'e' | b'E')) {
is_float = true;
self.pos += 1;
text.push('e');
if let Some(sign @ (b'+' | b'-')) = self.peek() {
text.push(sign as char);
self.pos += 1;
}
self.digits(10, &mut text)?;
}
if matches!(self.peek(), Some(b'j' | b'J')) {
self.pos += 1;
let im = text.parse::<f64>().map_err(|_| self.error())?;
Value::Complex { re: 0.0, im }
} else if is_float {
Value::Float(text.parse::<f64>().map_err(|_| self.error())?)
} else {
if integer_digits.len() > 1
&& integer_digits.starts_with('0')
&& integer_digits.bytes().any(|digit| digit != b'0')
{
return Err(Error::InvalidLiteral(self.offset + start));
}
Value::Int(BigInt::parse_bytes(integer_digits.as_bytes(), 10).ok_or(self.error())?)
}
};
if matches!(self.peek(), Some(byte) if byte.is_ascii_alphanumeric() || byte == b'_' || byte == b'.')
{
return Err(self.error());
}
Ok(Term {
value,
kind: Kind::Constant,
})
}
/// The length of a valid string prefix (`r`, `u`, `b`, `br`, `rb`, any case) directly
/// followed by a quote.
fn string_prefix_len(&self) -> Option<usize> {
let mut len = 0;
while matches!(self.peek_at(len), Some(byte) if byte.is_ascii_alphabetic()) && len < 3 {
len += 1;
}
if !matches!(self.peek_at(len), Some(b'\'' | b'"')) {
return None;
}
let prefix: Vec<u8> = self.bytes[self.pos..self.pos + len]
.iter()
.map(u8::to_ascii_lowercase)
.collect();
matches!(prefix.as_slice(), b"" | b"r" | b"u" | b"b" | b"br" | b"rb").then_some(len)
}
/// Adjacent string literals concatenate; `str` and `bytes` cannot mix.
fn strings(&mut self) -> Result<Term, Error> {
let mut text: Option<String> = None;
let mut bytes: Option<Vec<u8>> = None;
loop {
let at = self.pos;
let Some(prefix_len) = self.string_prefix_len() else {
break;
};
let prefix = &self.bytes[self.pos..self.pos + prefix_len];
let raw = prefix.iter().any(|byte| byte.eq_ignore_ascii_case(&b'r'));
let is_bytes = prefix.iter().any(|byte| byte.eq_ignore_ascii_case(&b'b'));
self.pos += prefix_len;
let mut out = Vec::new();
self.string_body(raw, is_bytes, &mut out)?;
if is_bytes {
if text.is_some() {
return Err(Error::InvalidLiteral(self.offset + at));
}
bytes.get_or_insert_with(Vec::new).extend(out);
} else {
if bytes.is_some() {
return Err(Error::InvalidLiteral(self.offset + at));
}
let piece =
String::from_utf8(out).map_err(|_| Error::InvalidLiteral(self.offset + at))?;
text.get_or_insert_with(String::new).push_str(&piece);
}
self.skip_trivia(self.brackets > 0);
}
let value = match (text, bytes) {
(Some(text), None) => Value::Str(text),
(None, Some(bytes)) => Value::Bytes(bytes),
_ => return Err(self.error()),
};
Ok(Term {
value,
kind: Kind::Constant,
})
}
/// One quoted body, decoded into UTF-8 (`str`) or raw bytes (`bytes`).
fn string_body(&mut self, raw: bool, is_bytes: bool, out: &mut Vec<u8>) -> Result<(), Error> {
let quote = self.bytes[self.pos];
let triple = self.peek_at(1) == Some(quote) && self.peek_at(2) == Some(quote);
self.pos += if triple { 3 } else { 1 };
loop {
let Some(byte) = self.peek() else {
return Err(self.error());
};
if byte == quote
&& (!triple || (self.peek_at(1) == Some(quote) && self.peek_at(2) == Some(quote)))
{
self.pos += if triple { 3 } else { 1 };
return Ok(());
}
match byte {
b'\n' | b'\r' if !triple => return Err(self.error()),
b'\\' if raw => {
let Some(next) = self.peek_at(1) else {
return Err(self.error());
};
out.push(b'\\');
self.pos += 1;
if next == b'\n' || next == b'\r' || next == quote || next == b'\\' {
out.push(next);
self.pos += 1;
}
}
b'\\' => {
self.pos += 1;
self.escape(is_bytes, out)?;
}
byte if is_bytes && !byte.is_ascii() => return Err(self.error()),
byte => {
out.push(byte);
self.pos += 1;
}
}
}
}
fn escape(&mut self, is_bytes: bool, out: &mut Vec<u8>) -> Result<(), Error> {
let Some(byte) = self.peek() else {
return Err(self.error());
};
self.pos += 1;
let simple = match byte {
b'\n' => return Ok(()),
b'\r' => {
if self.peek() == Some(b'\n') {
self.pos += 1;
}
return Ok(());
}
b'\\' | b'\'' | b'"' => byte,
b'a' => 0x07,
b'b' => 0x08,
b'f' => 0x0c,
b'n' => b'\n',
b'r' => b'\r',
b't' => b'\t',
b'v' => 0x0b,
b'0'..=b'7' => {
let mut code = u32::from(byte - b'0');
for _ in 0..2 {
match self.peek() {
Some(digit @ b'0'..=b'7') => {
code = code * 8 + u32::from(digit - b'0');
self.pos += 1;
}
_ => break,
}
}
// Bytes keep the low eight bits of `\400`-`\777`, as CPython does.
return self.push_code(if is_bytes { code & 0xff } else { code }, is_bytes, out);
}
b'x' => {
let code = self.hex(2)?;
return self.push_code(code, is_bytes, out);
}
b'u' if !is_bytes => {
let code = self.hex(4)?;
return self.push_code(code, is_bytes, out);
}
b'U' if !is_bytes => {
let code = self.hex(8)?;
return self.push_code(code, is_bytes, out);
}
b'N' if !is_bytes => return Err(self.error()),
_ => {
// Unknown escapes keep the backslash (a `SyntaxWarning` in CPython).
out.push(b'\\');
self.pos -= 1;
return Ok(());
}
};
out.push(simple);
Ok(())
}
fn hex(&mut self, count: usize) -> Result<u32, Error> {
let mut code = 0u32;
for _ in 0..count {
let digit = self
.peek()
.and_then(|byte| (byte as char).to_digit(16))
.ok_or(self.error())?;
code = code * 16 + digit;
self.pos += 1;
}
Ok(code)
}
fn push_code(&self, code: u32, is_bytes: bool, out: &mut Vec<u8>) -> Result<(), Error> {
if is_bytes {
out.push(u8::try_from(code).map_err(|_| self.error())?);
return Ok(());
}
let ch = char::from_u32(code).ok_or(self.error())?;
let mut buffer = [0u8; 4];
out.extend_from_slice(ch.encode_utf8(&mut buffer).as_bytes());
Ok(())
}
}
/// `left + right` or `left - right` as `ast.literal_eval` permits: a signed real on the
/// left and an unsigned complex constant on the right, combined with CPython 3.14's
/// mixed-mode rules, which leave the imaginary part untouched by the real operand.
fn complex_sum(left: Term, subtract: bool, right: Term) -> Option<Term> {
if left.kind == Kind::Other || right.kind != Kind::Constant {
return None;
}
let real = match &left.value {
Value::Int(value) => value.to_f64().filter(|value| value.is_finite())?,
Value::Float(value) => *value,
_ => return None,
};
let Value::Complex { re, im } = right.value else {
return None;
};
let value = if subtract {
Value::Complex {
re: real - re,
im: -im,
}
} else {
Value::Complex { re: real + re, im }
};
Some(Term::other(value))
}
/// Python equality for hashable literal values: numbers compare by value across `bool`,
/// `int`, `float`, and `complex`, so `1`, `1.0`, `True`, and `(1+0j)` are one key.
#[derive(Hash, PartialEq, Eq)]
enum KeyId {
None,
Int(BigInt),
Float(u64),
Complex(u64, u64),
Str(String),
Bytes(Vec<u8>),
Tuple(Vec<KeyId>),
}
fn float_key(value: f64) -> KeyId {
if value.fract() == 0.0
&& let Some(integer) = BigInt::from_f64(value)
{
return KeyId::Int(integer);
}
KeyId::Float(value.to_bits())
}
fn key_id(value: &Value) -> Result<KeyId, Error> {
Ok(match value {
Value::None => KeyId::None,
Value::Bool(value) => KeyId::Int(BigInt::from(u8::from(*value))),
Value::Int(value) => KeyId::Int(value.clone()),
Value::Float(value) => float_key(*value),
Value::Complex { re, im } if *im == 0.0 => float_key(*re),
Value::Complex { re, im } => KeyId::Complex((re + 0.0).to_bits(), (im + 0.0).to_bits()),
Value::Str(text) => KeyId::Str(text.clone()),
Value::Bytes(bytes) => KeyId::Bytes(bytes.clone()),
Value::Tuple(values) => KeyId::Tuple(values.iter().map(key_id).collect::<Result<_, _>>()?),
value @ (Value::List(_) | Value::Dict(_) | Value::Set(_)) => {
return Err(Error::Unhashable(value.type_name()));
}
})
}
/// Dict entries or set members in first-seen order: a repeated key keeps its first
/// position and, for dicts, takes the latest value.
#[derive(Default)]
struct UniqueValues {
keys: Vec<Value>,
values: Vec<Value>,
index: HashMap<KeyId, usize>,
}
impl UniqueValues {
fn insert(&mut self, key: Value, value: Option<Value>) -> Result<(), Error> {
match self.index.entry(key_id(&key)?) {
Entry::Occupied(slot) => {
if let Some(value) = value {
self.values[*slot.get()] = value;
}
}
Entry::Vacant(slot) => {
slot.insert(self.keys.len());
self.keys.push(key);
self.values.extend(value);
}
}
Ok(())
}
fn into_entries(self) -> Vec<(Value, Value)> {
self.keys.into_iter().zip(self.values).collect()
}
}

View file

@ -0,0 +1,187 @@
//! `pickle.loads` and `pickle.dumps` for plain data, as diskcache stores LiteLLM values.
//!
//! Both directions go through `serde-pickle`'s serde interface rather than
//! `serde_pickle::Value`, because that value type keeps dicts in a `BTreeMap` and would
//! reorder keys. The serde interface keeps insertion order, at the cost of reporting
//! `tuple`, `set`, and `frozenset` as sequences: [`loads`] decodes all three as lists.
//! Python objects that need a class (`GLOBAL`/`REDUCE`) and recursive structures fail.
use std::fmt;
use serde::{
Deserializer, Serialize, Serializer,
de::{self, DeserializeSeed, MapAccess, SeqAccess, Visitor},
ser::{SerializeMap, SerializeSeq, SerializeTuple},
};
use serde_pickle::{DeOptions, SerOptions};
use crate::{Error, MAX_DEPTH, Value};
/// `pickle.loads(data)` for any protocol from 0 to 5.
pub fn loads(data: &[u8]) -> Result<Value, Error> {
let mut deserializer = serde_pickle::Deserializer::new(data, DeOptions::new());
let value = Seed { depth: 0 }
.deserialize(&mut deserializer)
.map_err(|error| Error::InvalidPickle(error.to_string()))?;
deserializer
.end()
.map_err(|error| Error::InvalidPickle(error.to_string()))?;
Ok(value)
}
/// `pickle.dumps(value, protocol=3)`. Every Python 3 reads protocol 3, whatever its own
/// default. Sets and complex numbers are rejected rather than silently changing type.
pub fn dumps(value: &Value) -> Result<Vec<u8>, Error> {
check_picklable(value, 0)?;
serde_pickle::to_vec(&Pickled(value), SerOptions::new())
.map_err(|error| Error::InvalidPickle(error.to_string()))
}
fn check_picklable(value: &Value, depth: usize) -> Result<(), Error> {
if depth > MAX_DEPTH {
return Err(Error::TooDeep);
}
match value {
Value::Int(value) if i64::try_from(value).is_err() => Err(Error::IntegerOutOfRange),
value @ (Value::Set(_) | Value::Complex { .. }) => {
Err(Error::NotPicklable(value.type_name()))
}
Value::List(values) | Value::Tuple(values) => values
.iter()
.try_for_each(|value| check_picklable(value, depth + 1)),
Value::Dict(entries) => entries.iter().try_for_each(|(key, value)| {
check_picklable(key, depth + 1)?;
check_picklable(value, depth + 1)
}),
_ => Ok(()),
}
}
struct Pickled<'a>(&'a Value);
impl Serialize for Pickled<'_> {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
match self.0 {
Value::None => serializer.serialize_unit(),
Value::Bool(value) => serializer.serialize_bool(*value),
Value::Int(value) => {
let value = i64::try_from(value)
.map_err(|_| serde::ser::Error::custom("integer out of i64 range"))?;
serializer.serialize_i64(value)
}
Value::Float(value) => serializer.serialize_f64(*value),
Value::Str(text) => serializer.serialize_str(text),
Value::Bytes(bytes) => serializer.serialize_bytes(bytes),
Value::List(values) => {
let mut seq = serializer.serialize_seq(Some(values.len()))?;
for value in values {
seq.serialize_element(&Pickled(value))?;
}
seq.end()
}
Value::Tuple(values) => {
let mut tuple = serializer.serialize_tuple(values.len())?;
for value in values {
tuple.serialize_element(&Pickled(value))?;
}
tuple.end()
}
Value::Dict(entries) => {
let mut map = serializer.serialize_map(Some(entries.len()))?;
for (key, value) in entries {
map.serialize_entry(&Pickled(key), &Pickled(value))?;
}
map.end()
}
value @ (Value::Set(_) | Value::Complex { .. }) => Err(serde::ser::Error::custom(
format!("{} cannot be pickled as plain data", value.type_name()),
)),
}
}
}
#[derive(Clone, Copy)]
struct Seed {
depth: usize,
}
impl Seed {
fn child<E: de::Error>(self) -> Result<Self, E> {
if self.depth >= MAX_DEPTH {
return Err(E::custom(Error::TooDeep));
}
Ok(Self {
depth: self.depth + 1,
})
}
}
impl<'de> DeserializeSeed<'de> for Seed {
type Value = Value;
fn deserialize<D: Deserializer<'de>>(self, deserializer: D) -> Result<Value, D::Error> {
deserializer.deserialize_any(self)
}
}
impl<'de> Visitor<'de> for Seed {
type Value = Value;
fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str("a plain Python data value")
}
fn visit_unit<E>(self) -> Result<Value, E> {
Ok(Value::None)
}
fn visit_bool<E>(self, value: bool) -> Result<Value, E> {
Ok(Value::Bool(value))
}
fn visit_i64<E>(self, value: i64) -> Result<Value, E> {
Ok(Value::Int(value.into()))
}
fn visit_u64<E>(self, value: u64) -> Result<Value, E> {
Ok(Value::Int(value.into()))
}
fn visit_f64<E>(self, value: f64) -> Result<Value, E> {
Ok(Value::Float(value))
}
fn visit_str<E>(self, value: &str) -> Result<Value, E> {
Ok(Value::Str(value.to_owned()))
}
fn visit_string<E>(self, value: String) -> Result<Value, E> {
Ok(Value::Str(value))
}
fn visit_bytes<E>(self, value: &[u8]) -> Result<Value, E> {
Ok(Value::Bytes(value.to_vec()))
}
fn visit_byte_buf<E>(self, value: Vec<u8>) -> Result<Value, E> {
Ok(Value::Bytes(value))
}
fn visit_seq<A: SeqAccess<'de>>(self, mut seq: A) -> Result<Value, A::Error> {
let child = self.child()?;
let mut values = Vec::with_capacity(seq.size_hint().unwrap_or(0).min(4096));
while let Some(value) = seq.next_element_seed(child)? {
values.push(value);
}
Ok(Value::List(values))
}
fn visit_map<A: MapAccess<'de>>(self, mut map: A) -> Result<Value, A::Error> {
let child = self.child()?;
let mut entries = Vec::with_capacity(map.size_hint().unwrap_or(0).min(4096));
while let Some(key) = map.next_key_seed(child)? {
entries.push((key, map.next_value_seed(child)?));
}
Ok(Value::Dict(entries))
}
}

View file

@ -0,0 +1,237 @@
//! `repr(value)` and `str(value)` byte for byte.
//!
//! LiteLLM hashes `str(value)` into cache keys and writes `str(dict)` into Redis, so these
//! strings are persisted identifiers rather than display text: every quote choice, escape,
//! and float digit must match CPython.
use std::fmt::Write;
use crate::Value;
/// Generated by `scripts/generate_nonprintable.py`; see `generated/nonprintable.rs`.
mod nonprintable {
include!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/generated/nonprintable.rs"
));
}
/// Unicode version of the printable-character table, from the Python that generated it.
pub const UNICODE_VERSION: &str = nonprintable::UNICODE_VERSION;
/// `repr(value)`.
pub fn repr(value: &Value) -> String {
let mut out = String::new();
write_repr(&mut out, value);
out
}
/// `str(value)`: a string's own contents, and `repr` for every other value.
pub fn to_str(value: &Value) -> String {
match value {
Value::Str(text) => text.clone(),
value => repr(value),
}
}
/// `repr(float)`, the shortest round-trip form with CPython's exponent thresholds.
pub fn float_repr(value: f64) -> String {
format_float(value, true)
}
fn write_repr(out: &mut String, value: &Value) {
match value {
Value::None => out.push_str("None"),
Value::Bool(true) => out.push_str("True"),
Value::Bool(false) => out.push_str("False"),
Value::Int(value) => {
let _ = write!(out, "{value}");
}
Value::Float(value) => out.push_str(&float_repr(*value)),
Value::Complex { re, im } => write_complex(out, *re, *im),
Value::Str(text) => write_str(out, text),
Value::Bytes(bytes) => write_bytes(out, bytes),
Value::List(values) => write_sequence(out, '[', values, ']'),
Value::Tuple(values) if values.len() == 1 => {
out.push('(');
write_repr(out, &values[0]);
out.push_str(",)");
}
Value::Tuple(values) => write_sequence(out, '(', values, ')'),
Value::Set(values) if values.is_empty() => out.push_str("set()"),
Value::Set(values) => write_sequence(out, '{', values, '}'),
Value::Dict(entries) => {
out.push('{');
for (index, (key, value)) in entries.iter().enumerate() {
if index > 0 {
out.push_str(", ");
}
write_repr(out, key);
out.push_str(": ");
write_repr(out, value);
}
out.push('}');
}
}
}
fn write_sequence(out: &mut String, open: char, values: &[Value], close: char) {
out.push(open);
for (index, value) in values.iter().enumerate() {
if index > 0 {
out.push_str(", ");
}
write_repr(out, value);
}
out.push(close);
}
/// `complex.__repr__`: a `+0.0` real part prints only the imaginary part, without parens.
fn write_complex(out: &mut String, re: f64, im: f64) {
if re == 0.0 && re.is_sign_positive() {
out.push_str(&format_float(im, false));
out.push('j');
return;
}
out.push('(');
out.push_str(&format_float(re, false));
let im_text = format_float(im, false);
if !im_text.starts_with('-') {
out.push('+');
}
out.push_str(&im_text);
out.push_str("j)");
}
/// `PyOS_double_to_string(value, 'r', 0, flags)`: scientific notation below 1e-4 and from
/// 1e16 up, with a signed exponent of at least two digits. `add_dot_0` is
/// `Py_DTSF_ADD_DOT_0`, which `float` sets and `complex` does not.
fn format_float(value: f64, add_dot_0: bool) -> String {
if value.is_nan() {
return "nan".to_owned();
}
if value.is_infinite() {
return if value > 0.0 { "inf" } else { "-inf" }.to_owned();
}
// Rust's `{:e}` prints the shortest round-trip digits, like CPython's 'r' mode.
let scientific = format!("{value:e}");
let (mantissa, exponent) = scientific
.split_once('e')
.expect("`{:e}` always prints an exponent");
let exponent: i32 = exponent.parse().expect("`{:e}` exponent is an integer");
let (sign, mantissa) = match mantissa.strip_prefix('-') {
Some(mantissa) => ("-", mantissa),
None => ("", mantissa),
};
let digits: String = mantissa.chars().filter(|ch| *ch != '.').collect();
let mut out = String::from(sign);
if !(-4..16).contains(&exponent) {
out.push_str(&digits[..1]);
if digits.len() > 1 {
out.push('.');
out.push_str(&digits[1..]);
}
let _ = write!(
out,
"e{}{:02}",
if exponent < 0 { '-' } else { '+' },
exponent.unsigned_abs()
);
} else if exponent < 0 {
out.push_str("0.");
out.extend(std::iter::repeat_n('0', (-exponent - 1) as usize));
out.push_str(&digits);
} else {
let integer_digits = exponent as usize + 1;
if digits.len() > integer_digits {
out.push_str(&digits[..integer_digits]);
out.push('.');
out.push_str(&digits[integer_digits..]);
} else {
out.push_str(&digits);
out.extend(std::iter::repeat_n('0', integer_digits - digits.len()));
if add_dot_0 {
out.push_str(".0");
}
}
}
out
}
/// `unicode_repr`: single quotes unless the text has a `'` and no `"`. Printable non-ASCII
/// stays literal; everything `str.isprintable()` rejects is escaped.
fn write_str(out: &mut String, text: &str) {
let quote = if text.contains('\'') && !text.contains('"') {
'"'
} else {
'\''
};
out.push(quote);
for ch in text.chars() {
match ch {
'\\' => out.push_str("\\\\"),
'\t' => out.push_str("\\t"),
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
ch if ch == quote => {
out.push('\\');
out.push(ch);
}
ch if is_printable(ch) => out.push(ch),
ch => {
let code = ch as u32;
let _ = match code {
0..=0xff => write!(out, "\\x{code:02x}"),
0x100..=0xffff => write!(out, "\\u{code:04x}"),
_ => write!(out, "\\U{code:08x}"),
};
}
}
}
out.push(quote);
}
/// `bytes.__repr__`: the same quote rule as `str`, with every byte outside printable ASCII
/// escaped as `\xhh`.
fn write_bytes(out: &mut String, bytes: &[u8]) {
let quote = if bytes.contains(&b'\'') && !bytes.contains(&b'"') {
b'"'
} else {
b'\''
};
out.push('b');
out.push(quote as char);
for &byte in bytes {
match byte {
b'\\' => out.push_str("\\\\"),
b'\t' => out.push_str("\\t"),
b'\n' => out.push_str("\\n"),
b'\r' => out.push_str("\\r"),
byte if byte == quote => {
out.push('\\');
out.push(byte as char);
}
0x20..=0x7e => out.push(byte as char),
byte => {
let _ = write!(out, "\\x{byte:02x}");
}
}
}
out.push(quote as char);
}
fn is_printable(ch: char) -> bool {
let code = ch as u32;
nonprintable::NONPRINTABLE
.binary_search_by(|&(low, high)| {
if high < code {
std::cmp::Ordering::Less
} else if low > code {
std::cmp::Ordering::Greater
} else {
std::cmp::Ordering::Equal
}
})
.is_err()
}

View file

@ -0,0 +1,18 @@
use num_bigint::Sign;
use crate::Value;
/// `bool(value)` for plain data: `None`, `False`, zero, and empty containers are false.
pub fn truthy(value: &Value) -> bool {
match value {
Value::None => false,
Value::Bool(value) => *value,
Value::Int(value) => value.sign() != Sign::NoSign,
Value::Float(value) => *value != 0.0,
Value::Complex { re, im } => *re != 0.0 || *im != 0.0,
Value::Str(value) => !value.is_empty(),
Value::Bytes(value) => !value.is_empty(),
Value::Tuple(values) | Value::List(values) | Value::Set(values) => !values.is_empty(),
Value::Dict(entries) => !entries.is_empty(),
}
}

View file

@ -0,0 +1,53 @@
use num_bigint::BigInt;
/// A Python value built only from literals: what `ast.literal_eval` can return.
#[derive(Clone, Debug, PartialEq)]
pub enum Value {
None,
Bool(bool),
Int(BigInt),
Float(f64),
Complex {
re: f64,
im: f64,
},
Str(String),
Bytes(Vec<u8>),
Tuple(Vec<Value>),
List(Vec<Value>),
/// Insertion-ordered, with Python's key equality already applied.
Dict(Vec<(Value, Value)>),
/// Literal order, with Python's member equality already applied.
Set(Vec<Value>),
}
impl Value {
/// Python's type name, as it appears in `TypeError` messages.
pub fn type_name(&self) -> &'static str {
match self {
Value::None => "NoneType",
Value::Bool(_) => "bool",
Value::Int(_) => "int",
Value::Float(_) => "float",
Value::Complex { .. } => "complex",
Value::Str(_) => "str",
Value::Bytes(_) => "bytes",
Value::Tuple(_) => "tuple",
Value::List(_) => "list",
Value::Dict(_) => "dict",
Value::Set(_) => "set",
}
}
}
impl From<i64> for Value {
fn from(value: i64) -> Self {
Value::Int(value.into())
}
}
impl From<&str> for Value {
fn from(value: &str) -> Self {
Value::Str(value.to_owned())
}
}

View file

@ -0,0 +1,343 @@
//! Replays `generated/values.json`, which CPython wrote with `scripts/generate_fixtures.py`.
use std::{collections::BTreeMap, fs::File, io::Write};
use litellm_python_compat::{
Value, json,
literal::literal_eval,
pickle,
repr::{repr, to_str},
truthy::truthy,
};
use rstest::{fixture, rstest};
use serde::Deserialize;
#[derive(Deserialize)]
struct Fixtures {
rows: Vec<Row>,
sources: Vec<Source>,
}
/// `ast.literal_eval(source)`: the `repr` of its result, or the exception class it raised.
#[derive(Deserialize)]
struct Source {
name: String,
source: String,
repr: Option<String>,
error: Option<String>,
}
#[derive(Deserialize)]
struct Row {
name: String,
source: String,
literal: bool,
plain: bool,
repr: String,
str: String,
truthy: bool,
json: Option<String>,
json_error: Option<String>,
pickle: Option<BTreeMap<String, String>>,
view: Option<String>,
}
/// Parsed once for the whole test binary.
#[fixture]
#[once]
fn fixtures() -> Fixtures {
serde_json::from_str(include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/generated/values.json"
)))
.expect("values.json matches the fixture schema")
}
/// Accepted differences from CPython: `(fixture source, check prefix, reason)`. Each entry
/// must still differ, so a dependency fix that removes one fails the test until it is deleted.
const KNOWN: &[(&str, &str, &str)] = &[
(
"...",
"literal_eval source",
"`Ellipsis` is not part of the data model",
),
(
r"'\ud800'",
"literal_eval source",
"a Rust `String` cannot hold a lone surrogate",
),
(
r"'\N{BULLET}'",
"literal_eval source",
"`\\N{NAME}` needs the Unicode name table",
),
(
"nested_150",
"literal_eval",
"deeper than MAX_DEPTH: rejected for stack safety, where CPython still parses it",
),
(
"nested_150",
"pickle.loads",
"deeper than MAX_DEPTH: rejected for stack safety, where CPython has no limit",
),
(
"2**64",
"pickle.loads",
"serde-pickle's serde interface stops at i64",
),
(
"-(2**70)",
"pickle.loads",
"serde-pickle's serde interface stops at i64",
),
(
"2**64",
"pickle.dumps",
"serde-pickle's serde interface stops at i64",
),
(
"-(2**70)",
"pickle.dumps",
"serde-pickle's serde interface stops at i64",
),
(
"2**64",
"to_json",
"serde_json has no exact form for integers beyond u64",
),
(
"-(2**70)",
"to_json",
"serde_json has no exact form for integers beyond i64",
),
(
"b''",
"pickle.loads protocol 0",
"protocols 0-2 pickle `b''` as a `bytes()` call",
),
(
"b''",
"pickle.loads protocol 1",
"protocols 0-2 pickle `b''` as a `bytes()` call",
),
(
"b''",
"pickle.loads protocol 2",
"protocols 0-2 pickle `b''` as a `bytes()` call",
),
];
/// Collects every mismatch so one run reports the whole divergence set.
#[derive(Default)]
struct Mismatches {
unexpected: Vec<String>,
known_seen: Vec<usize>,
known_scope: Vec<usize>,
}
impl Mismatches {
fn known(source: &str, what: &str) -> Option<usize> {
KNOWN
.iter()
.position(|(known, prefix, _)| *known == source && what.starts_with(prefix))
}
fn check(&mut self, row: &Row, what: &str, expected: &str, actual: &str) {
self.check_source(&row.name, what, expected, actual);
}
/// `name` identifies the fixture row in reports and in [`KNOWN`].
fn check_source(&mut self, name: &str, what: &str, expected: &str, actual: &str) {
let known = Self::known(name, what);
if let Some(index) = known {
self.known_scope.push(index);
}
if expected == actual {
return;
}
match known {
Some(index) => self.known_seen.push(index),
None => self.unexpected.push(format!(
"{name:?} [{what}]\n python: {expected}\n rust: {actual}"
)),
}
}
fn finish(self) {
let resolved: Vec<_> = self
.known_scope
.iter()
.filter(|index| !self.known_seen.contains(index))
.map(|&index| format!("{} [{}]", KNOWN[index].0, KNOWN[index].1))
.collect();
assert!(
self.unexpected.is_empty() && resolved.is_empty(),
"{} mismatches with CPython:\n{}\nknown divergences that now match (delete them \
from KNOWN): {resolved:?}",
self.unexpected.len(),
self.unexpected.join("\n"),
);
}
}
fn check_value(mismatches: &mut Mismatches, row: &Row, value: &Value) {
mismatches.check(row, "repr", &row.repr, &repr(value));
mismatches.check(row, "str", &row.str, &to_str(value));
mismatches.check(
row,
"bool",
&row.truthy.to_string(),
&truthy(value).to_string(),
);
let expected = row.json.clone().or_else(|| {
row.json_error
.clone()
.map(|error| format!("error: {error}"))
});
let actual = match json::dumps(value) {
Ok(text) => text,
Err(error) => format!("error: {error}"),
};
mismatches.check(
row,
"json.dumps",
expected.as_deref().unwrap_or(""),
&actual,
);
}
#[rstest]
fn literal_rows_match_python_repr_str_bool_and_json(fixtures: &Fixtures) {
let mut mismatches = Mismatches::default();
for row in fixtures.rows.iter().filter(|row| row.literal) {
match literal_eval(&row.repr) {
Ok(value) => check_value(&mut mismatches, row, &value),
Err(error) => mismatches.check(row, "literal_eval", &row.repr, &error.to_string()),
}
}
mismatches.finish();
}
/// Errors compare by outcome only: CPython's exception class is not part of the contract.
#[rstest]
fn literal_eval_matches_python_on_source_texts(fixtures: &Fixtures) {
let mut mismatches = Mismatches::default();
for case in &fixtures.sources {
let expected = match (&case.repr, &case.error) {
(Some(repr), None) => repr.clone(),
(None, Some(_)) => "an error".to_owned(),
_ => panic!("{:?}: a source records a repr or an error", case.source),
};
let actual = match literal_eval(&case.source) {
Ok(value) => repr(&value),
Err(_) => "an error".to_owned(),
};
mismatches.check_source(&case.name, "literal_eval source", &expected, &actual);
}
mismatches.finish();
}
#[rstest]
fn pickle_loads_matches_python_at_every_protocol(fixtures: &Fixtures) {
let mut mismatches = Mismatches::default();
for row in &fixtures.rows {
let Some(pickles) = &row.pickle else { continue };
for (protocol, data) in pickles {
let data = hex::decode(data).expect("fixture pickle is hex");
let what = format!("pickle.loads protocol {protocol}");
match (pickle::loads(&data), row.plain) {
(Ok(value), true) => {
let view = row.view.as_deref().expect("picklable rows have a view");
mismatches.check(row, &what, view, &repr(&value));
}
(Err(error), true) => {
mismatches.check(row, &what, "a value", &format!("error: {error}"))
}
(Ok(value), false) => {
mismatches.check(row, &what, "a class-reference error", &repr(&value))
}
(Err(pickle_error), false) => assert!(
matches!(pickle_error, litellm_python_compat::Error::InvalidPickle(_)),
"{}: {pickle_error}",
row.source
),
}
}
}
mismatches.finish();
}
/// Non-finite floats have no literal form; pickle is how Rust receives them.
#[rstest]
fn values_reached_only_through_pickle_match_python(fixtures: &Fixtures) {
let mut mismatches = Mismatches::default();
for row in fixtures
.rows
.iter()
.filter(|row| !row.literal && row.plain && row.view.as_deref() == Some(&row.repr))
{
let data = hex::decode(&row.pickle.as_ref().expect("plain rows pickle")["5"])
.expect("fixture pickle is hex");
let value = pickle::loads(&data).expect("plain pickle decodes");
check_value(&mut mismatches, row, &value);
}
mismatches.finish();
}
/// Byte equality with CPython is not the contract: CPython adds memo opcodes and picks the
/// smallest integer opcode. `scripts/verify_rust_pickles.py` checks that CPython reads these
/// back; set `PYTHON_COMPAT_RUST_PICKLES` to a file path to export them.
#[rstest]
fn pickle_dumps_round_trips_every_plain_literal(fixtures: &Fixtures) {
// Truncate up front: the verifier must read this run's rows and nothing else.
let mut export = std::env::var_os("PYTHON_COMPAT_RUST_PICKLES")
.map(|path| File::create(path).expect("the export path is writable"));
let mut mismatches = Mismatches::default();
for row in fixtures
.rows
.iter()
.filter(|row| row.literal && row.plain && row.view.as_deref() == Some(&row.repr))
{
// Rows past `MAX_DEPTH` are covered by the literal test's own KNOWN entry.
let Ok(value) = literal_eval(&row.repr) else {
continue;
};
let data = match pickle::dumps(&value) {
Ok(data) => data,
Err(error) => {
mismatches.check(row, "pickle.dumps", "a pickle", &format!("error: {error}"));
continue;
}
};
let decoded = pickle::loads(&data).expect("rust pickle decodes");
mismatches.check(row, "pickle round trip", &row.repr, &repr(&decoded));
if let Some(file) = &mut export {
writeln!(file, "{}\t{}", hex::encode(&data), repr(&value))
.expect("the export file is writable");
}
}
mismatches.finish();
}
#[rstest]
fn to_json_matches_python_json_round_trip(fixtures: &Fixtures) {
let mut mismatches = Mismatches::default();
for row in fixtures.rows.iter().filter(|row| row.literal) {
let Some(expected) = &row.json else { continue };
let Ok(value) = literal_eval(&row.repr) else {
continue;
};
let expected: serde_json::Value =
serde_json::from_str(expected).expect("python json.dumps output parses");
match json::to_json(&value) {
Ok(actual) => {
mismatches.check(row, "to_json", &expected.to_string(), &actual.to_string())
}
Err(error) => {
mismatches.check(row, "to_json", &expected.to_string(), &error.to_string())
}
}
}
mismatches.finish();
}

View file

@ -0,0 +1,122 @@
use std::time::{Duration, Instant};
use litellm_python_compat::{Error, MAX_DEPTH, Value, json, literal::literal_eval, pickle};
use rstest::{fixture, rstest};
/// The bracket pair of one container shape, as `(open, close)`.
#[fixture]
fn shapes() -> [(&'static str, &'static str); 3] {
[("[", "]"), ("{'a': ", "}"), ("(", ",)")]
}
fn nested_text(open: &str, close: &str, depth: usize) -> String {
format!("{}1{}", open.repeat(depth), close.repeat(depth))
}
fn nested_list(depth: usize) -> Value {
(0..depth).fold(Value::from(1), |value, _| Value::List(vec![value]))
}
/// A protocol 3 pickle of `depth` nested lists around `1`: `EMPTY_LIST` per level, then
/// `BININT1 1`, then `APPEND` per level. Written by hand because `dumps` refuses the depth.
fn nested_list_pickle(depth: usize) -> Vec<u8> {
let mut data = vec![0x80, 3];
data.extend(std::iter::repeat_n(b']', depth));
data.extend([b'K', 1]);
data.extend(std::iter::repeat_n(b'a', depth));
data.push(b'.');
data
}
#[rstest]
fn literal_eval_accepts_the_limit_and_rejects_past_it(shapes: [(&'static str, &'static str); 3]) {
for (open, close) in shapes {
assert!(literal_eval(&nested_text(open, close, MAX_DEPTH)).is_ok());
assert!(matches!(
literal_eval(&nested_text(open, close, MAX_DEPTH + 1)),
Err(Error::TooDeep)
));
}
}
/// A backtracking parser (the `py_literal` grammar this replaced) doubles per nested level
/// and takes minutes here; the bound is loose enough to survive a slow debug build.
#[rstest]
fn literal_eval_stays_linear_in_depth(shapes: [(&'static str, &'static str); 3]) {
for (open, close) in shapes {
let text = nested_text(open, close, MAX_DEPTH);
let start = Instant::now();
assert!(literal_eval(&text).is_ok());
let elapsed = start.elapsed();
assert!(
elapsed < Duration::from_millis(50),
"{open} nested {MAX_DEPTH} deep took {elapsed:?}"
);
}
}
#[rstest]
fn literal_eval_ignores_brackets_inside_strings() {
let text = format!("'{}'", "[".repeat(MAX_DEPTH + 1));
assert!(matches!(literal_eval(&text), Ok(Value::Str(_))));
}
#[rstest]
fn pickle_nesting_is_bounded_in_both_directions() {
assert_eq!(
pickle::loads(&nested_list_pickle(MAX_DEPTH)).unwrap(),
nested_list(MAX_DEPTH)
);
assert!(matches!(
pickle::loads(&nested_list_pickle(MAX_DEPTH + 1)),
Err(Error::InvalidPickle(_))
));
assert!(pickle::dumps(&nested_list(MAX_DEPTH)).is_ok());
assert!(matches!(
pickle::dumps(&nested_list(MAX_DEPTH + 2)),
Err(Error::TooDeep)
));
}
#[rstest]
#[case("{1, 2}", "set")]
#[case("1+2j", "complex")]
fn pickle_dumps_refuses_types_it_would_change(#[case] source: &str, #[case] type_name: &str) {
let value = literal_eval(source).expect("source is a literal");
assert!(matches!(pickle::dumps(&value), Err(Error::NotPicklable(name)) if name == type_name));
}
#[rstest]
#[case(b"\x80\x02c__builtin__\ncomplex\nq\x00.".to_vec(), "a class reference")]
#[case({ let mut data = pickle::dumps(&Value::from(1)).unwrap(); data.push(b'.'); data }, "trailing data")]
fn pickle_loads_rejects(#[case] data: Vec<u8>, #[case] what: &str) {
assert!(
matches!(pickle::loads(&data), Err(Error::InvalidPickle(_))),
"{what} must not decode"
);
}
#[rstest]
#[case(Value::Bytes(b"x".to_vec()), "Object of type bytes is not JSON serializable")]
#[case(Value::Set(vec![Value::from(1)]), "Object of type set is not JSON serializable")]
#[case(Value::Complex { re: 1.0, im: 2.0 }, "Object of type complex is not JSON serializable")]
#[case(
Value::Float(f64::NAN),
"Out of range float values are not JSON compliant"
)]
fn to_json_reports_what_python_json_dumps_would_reject(
#[case] value: Value,
#[case] message: &str,
) {
let error = json::to_json(&value).expect_err("value has no serde_json form");
assert_eq!(error.to_string(), message);
}
/// `json.dumps` writes the non-finite floats that `to_json` cannot represent.
#[rstest]
#[case(f64::NAN, "NaN")]
#[case(f64::INFINITY, "Infinity")]
#[case(f64::NEG_INFINITY, "-Infinity")]
fn json_dumps_writes_non_finite_floats(#[case] value: f64, #[case] text: &str) {
assert_eq!(json::dumps(&Value::Float(value)).unwrap(), text);
}