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()) } 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 + +