From 024ee86ed1fd0b02427a27602422004b62e19ddd Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Mon, 28 Sep 2026 13:41:52 +0300 Subject: [PATCH 1/2] perf(claims): intern common claim/header keys in json_to_bound json_to_bound allocated a fresh PyString for every dict key on every decode, including the same standard claim/header names every time: exp, iat, nbf, sub, aud, iss, jti, alg, typ, kid. Those ten keys now come from pyo3::intern!, which caches each key in a call-site-local static rather than allocating it fresh. Interning also registers the string in CPython's own intern table, so a subsequent payload.get("exp") on the Python side (a str literal, which CPython also interns) can hit the identity-comparison fast path during dict lookup instead of a full string comparison. Any other key still falls back to an ordinary, uninterned PyString, unchanged from before. Measured (native decode, 8-claim payload, release build): ~2.05us -> ~1.92us (~6% less). No behavioural change. Closes #123 --- CHANGELOG.md | 12 ++++++++++++ rust/src/claims.rs | 26 +++++++++++++++++++++++++- 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 125348b..5fd05b9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -45,6 +45,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 wrapper overhead over the native call dropped from ~0.85 microseconds to ~0.57 microseconds (~33% less); with no time claims at all, ~0.37 microseconds (~57% less). No behavioural change. (#122) +- **Common claim/header names are interned instead of allocated per + decode.** `json_to_bound` (used by every `decode` and `decode_complete` + call) used to allocate a fresh `PyString` for every dict key, including + the same standard names — `exp`, `iat`, `nbf`, `sub`, `aud`, `iss`, `jti`, + `alg`, `typ`, `kid` — on every single call. Those ten keys now come from + `pyo3::intern!`, a per-key cache that both skips the repeated allocation + and interns the string in CPython's own intern table, so a later + `payload.get("exp")` on the Python side can hit the identity-comparison + fast path for dict lookups. Any other key still gets an ordinary, + uninterned `PyString`, unchanged from before. Measured on an 8-claim + payload: native `decode` dropped from ~2.05 µs to ~1.92 µs (~6% less). No + behavioural change. (#123) ## [0.7.0] — 2026-08-26 diff --git a/rust/src/claims.rs b/rust/src/claims.rs index 0f56b58..266ee1b 100644 --- a/rust/src/claims.rs +++ b/rust/src/claims.rs @@ -95,6 +95,30 @@ fn py_to_json_depth(value: &Bound<'_, PyAny>, depth: usize) -> PyResult { )) } +/// Standard JWT claim / header names, interned once per key rather than +/// allocated fresh on every `set_item`. `pyo3::intern!` caches each key in a +/// call-site-local static, both skipping the per-call `PyString` allocation +/// and interning the string in CPython's own intern table, so a subsequent +/// Python-side `dict.get("exp")` (a `str` literal, which CPython also +/// interns) can hit the identity-comparison fast path during dict lookup +/// instead of a full string comparison. Anything outside this list falls +/// back to an ordinary, uninterned `PyString`. +fn claim_key<'py>(py: Python<'py>, key: &str) -> Bound<'py, PyString> { + match key { + "exp" => pyo3::intern!(py, "exp").clone(), + "iat" => pyo3::intern!(py, "iat").clone(), + "nbf" => pyo3::intern!(py, "nbf").clone(), + "sub" => pyo3::intern!(py, "sub").clone(), + "aud" => pyo3::intern!(py, "aud").clone(), + "iss" => pyo3::intern!(py, "iss").clone(), + "jti" => pyo3::intern!(py, "jti").clone(), + "alg" => pyo3::intern!(py, "alg").clone(), + "typ" => pyo3::intern!(py, "typ").clone(), + "kid" => pyo3::intern!(py, "kid").clone(), + _ => PyString::new(py, key), + } +} + fn json_to_bound<'py>(py: Python<'py>, value: &Value) -> PyResult> { match value { Value::Null => Ok(py.None().into_bound(py)), @@ -122,7 +146,7 @@ fn json_to_bound<'py>(py: Python<'py>, value: &Value) -> PyResult { let dict = PyDict::new(py); for (key, value) in values { - dict.set_item(key, json_to_bound(py, value)?)?; + dict.set_item(claim_key(py, key), json_to_bound(py, value)?)?; } Ok(dict.into_any()) } From a167bde39694bca8c58b8368f5dc91e52396a1a4 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Mon, 28 Sep 2026 13:41:52 +0300 Subject: [PATCH 2/2] test(claims): cover claim/header key interning Regression coverage for the previous commit: each standard claim name and each standard header field must decode back as the exact same interned str object pyo3 caches for it (identity, not just equality), while a key outside that list must still decode correctly as an ordinary, uninterned str. --- tests/test_encode_decode.py | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/tests/test_encode_decode.py b/tests/test_encode_decode.py index 64c58a8..72896ca 100644 --- a/tests/test_encode_decode.py +++ b/tests/test_encode_decode.py @@ -183,3 +183,35 @@ def test_encode_deeply_nested_claims_rejected() -> None: oxyjwt._oxyjwt.encode(nested, "secret", "HS256") +@pytest.mark.parametrize("claim", ["exp", "iat", "nbf", "sub", "aud", "iss", "jti"]) +def test_standard_claim_key_is_interned(claim: str) -> None: + """A standard claim name comes back as the same interned `str` object + pyo3 caches for it, matching a same-spelling literal by identity (`is`) + rather than just equality. + """ + token = oxyjwt.encode({claim: "v"}, "secret", "HS256") + payload = oxyjwt.decode_unverified(token) + (key,) = payload.keys() + assert key is claim + + +@pytest.mark.parametrize("field", ["alg", "typ", "kid"]) +def test_standard_header_key_is_interned(field: str) -> None: + token = oxyjwt.encode({"sub": "u"}, "secret", "HS256", headers={"kid": "k1"}) + header = oxyjwt.get_unverified_header(token) + (key,) = (k for k in header if k == field) + assert key is field + + +def test_custom_claim_key_is_not_interned() -> None: + """A key outside the standard list must still decode correctly, as an + ordinary (uninterned) `str` rather than being pulled from the cache. + """ + custom_key = "a_custom_claim" + token = oxyjwt.encode({custom_key: "v"}, "secret", "HS256") + payload = oxyjwt.decode_unverified(token) + (key,) = payload.keys() + assert key == custom_key + assert key is not custom_key + +