Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
26 changes: 25 additions & 1 deletion rust/src/claims.rs
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,30 @@ fn py_to_json_depth(value: &Bound<'_, PyAny>, depth: usize) -> PyResult<Value> {
))
}

/// 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<Bound<'py, PyAny>> {
match value {
Value::Null => Ok(py.None().into_bound(py)),
Expand Down Expand Up @@ -122,7 +146,7 @@ fn json_to_bound<'py>(py: Python<'py>, value: &Value) -> PyResult<Bound<'py, PyA
Value::Object(values) => {
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())
}
Expand Down
32 changes: 32 additions & 0 deletions tests/test_encode_decode.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Loading