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
42 changes: 42 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# cachebox

In-memory cache for Python. The hot path is a PyO3 extension (`cachebox._core`); `cachebox/` is the public package.

## Working rules

- Keep diffs small and match the surrounding style. Do not add a new language, framework, or packaging tool.
- After a behavior change, rebuild with maturin and run pytest.
- Do not edit `target/`, `*.so`, `__pycache__`, `.venv`, or other build output. Do not edit `Cargo.lock` or `uv.lock` unless a dependency changed.
- Do not read or write secrets or `.env`.
- `src/hashbrown/` is vendored hashbrown (see `LICENSE-THIRD-PARTY`). Change it only when the table itself must change.
- v6 is the public API. A breaking change needs a note in `docs/docs/migration.md` and a matching update to `cachebox/_core.pyi` and the MkDocs pages.
- `use-small-offset` is a test-only Cargo feature. Release and publish builds must not enable it.

## Layout

- `src/policies/` — eviction (Cache, FIFO, RR, LRU, LFU, TTL, VTTL).
- `src/pyclasses/` — one PyO3 class per policy, plus key/value/item iterators.
- `src/internal/` — pickle, `OnceInit` (`__new__` / `__init__`), linked list, hash helpers.
- `cachebox/_cachebox.py` — public `TTLCache` and `VTTLCache` (subclasses of the Rust types). Other classes are re-exported from `_core`.
- `cachebox/utils.py` and `_wrappers.py` — `@cached`, key makers, stampede locks.
- `tests/` — pytest mixins shared across implementations. Rust `#[cfg(test)]` covers the vendored table only.

## Commands

```bash
uv venv .venv && uv pip install --group ci
maturin develop --features use-small-offset # local tests; CI does this
pytest -v -n auto # CI also sets HYPOTHESIS_PROFILE=slow
typos --config typos.toml
zizmor .github/
mkdocs serve --config-file docs/mkdocs.yml
```

`maturin develop --release` is the documented source install. Wheels are built only by `.github/workflows/CI.yml` on a tag.

## Style

- `rustfmt.toml` sets `imports_granularity = "Item"`.
- Rust edition is whatever `Cargo.toml` says. Edition 2024 denies `unsafe_op_in_unsafe_fn`; the crate allows it in `src/lib.rs`.
- Python docstrings are Google style. MkDocs (`mkdocstrings`) renders them.
- Clippy warns on `dbg!` and `print!`. Clippy, rustfmt, and mypy are not CI jobs.
2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
[package]
name = "cachebox"
version = "6.2.7"
edition = "2021"
edition = "2024"
description = "The fastest memoizing and caching Python library written in Rust"
readme = "README.md"
license = "MIT"
Expand Down
1 change: 0 additions & 1 deletion cachebox/_cachebox.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import threading
import time
import typing
from datetime import datetime, timedelta

Expand Down
7 changes: 3 additions & 4 deletions cachebox/_wrappers.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,14 @@
import inspect
import typing
from collections import namedtuple
from contextlib import AbstractAsyncContextManager, AbstractContextManager
from collections.abc import Callable, Hashable
from contextlib import AbstractAsyncContextManager, AbstractContextManager

from cachebox._core import BaseCacheImpl, Cache

_PostProcess: typing.TypeAlias = Callable[[typing.Any], typing.Any]
_Callback: typing.TypeAlias = Callable[
[int, typing.Any, typing.Any], typing.Any
]
_Callback: typing.TypeAlias = Callable[[int, typing.Any, typing.Any], typing.Any]


class _Lock:
__slots__ = ("_lock", "waiters")
Expand Down
2 changes: 1 addition & 1 deletion src/hashbrown/alloc.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
pub(crate) use self::inner::do_alloc;
#[cfg(test)]
pub(crate) use self::inner::AllocError;
pub(crate) use self::inner::Allocator;
pub(crate) use self::inner::Global;
pub(crate) use self::inner::do_alloc;

mod inner {
#[cfg(test)]
Expand Down
48 changes: 29 additions & 19 deletions src/hashbrown/raw.rs
Original file line number Diff line number Diff line change
@@ -1,27 +1,27 @@
use super::TryReserveError;
use super::control::BitMaskIter;
use super::control::Group;
use super::control::Tag;
use super::control::TagSliceExt;
use super::scopeguard::guard;
use super::scopeguard::ScopeGuard;
use super::scopeguard::guard;
use super::util::likely;
use super::util::unlikely;
use super::TryReserveError;
use core::array;
use core::iter::FusedIterator;
use core::marker::PhantomData;
use core::mem;
use core::ptr;
use core::ptr::NonNull;
use core::slice;
use std::alloc::handle_alloc_error;
use std::alloc::Layout;
use std::alloc::handle_alloc_error;

use super::alloc::do_alloc;
#[cfg(test)]
use super::alloc::AllocError;
use super::alloc::Allocator;
use super::alloc::Global;
use super::alloc::do_alloc;

#[inline]
unsafe fn offset_from<T>(to: *const T, from: *const T) -> usize {
Expand Down Expand Up @@ -193,6 +193,9 @@ fn bucket_mask_to_capacity(bucket_mask: usize) -> usize {
// Keep in mind that the bucket mask is one less than the bucket count.
bucket_mask
} else {
// `bucket_mask` is bounded by the maximum allocation size, so it can
// never be `usize::MAX` and the `+ 1` below cannot overflow.
debug_assert!(bucket_mask != usize::MAX);
// For larger tables we reserve 12.5% of the slots as empty.
((bucket_mask + 1) / 8) * 7
}
Expand Down Expand Up @@ -1466,21 +1469,24 @@ impl<T, A: Allocator> RawTable<T, A> {
/// should be dropped using a `RawIter` before freeing the allocation.
#[cfg_attr(feature = "inline-more", inline)]
pub fn into_allocation(self) -> Option<(NonNull<u8>, Layout, A)> {
let alloc = if self.table.is_empty_singleton() {
let this = mem::ManuallyDrop::new(self);
// SAFETY: `this` is never dropped, so ownership of the allocator is
// moved out exactly once. If the table never allocated, the allocator
// is dropped here rather than being leaked.
let alloc = unsafe { ptr::read(&raw const this.alloc) };
if this.table.is_empty_singleton() {
None
} else {
let (layout, ctrl_offset) = {
let option = Self::TABLE_LAYOUT.calculate_layout_for(self.table.num_buckets());
let option = Self::TABLE_LAYOUT.calculate_layout_for(this.table.num_buckets());
unsafe { option.unwrap_unchecked() }
};
Some((
unsafe { NonNull::new_unchecked(self.table.ctrl.as_ptr().sub(ctrl_offset).cast()) },
unsafe { NonNull::new_unchecked(this.table.ctrl.as_ptr().sub(ctrl_offset).cast()) },
layout,
unsafe { ptr::read(&raw const self.alloc) },
alloc,
))
};
mem::forget(self);
alloc
}
}
}

Expand Down Expand Up @@ -4331,10 +4337,12 @@ mod test_map {
Some(i)
);
}
assert!(table
.find(i + 100, |x| Ok::<_, ()>(*x == i + 100))
.unwrap()
.is_none());
assert!(
table
.find(i + 100, |x| Ok::<_, ()>(*x == i + 100))
.unwrap()
.is_none()
);
}

rehash_in_place(&mut table, hasher);
Expand All @@ -4349,10 +4357,12 @@ mod test_map {
Some(i)
);
}
assert!(table
.find(i + 100, |x| Ok::<_, ()>(*x == i + 100))
.unwrap()
.is_none());
assert!(
table
.find(i + 100, |x| Ok::<_, ()>(*x == i + 100))
.unwrap()
.is_none()
);
}
}

Expand Down
32 changes: 18 additions & 14 deletions src/internal/onceinit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@

use std::cell;
use std::mem;
use std::sync::atomic;
use std::sync::Arc;
use std::sync::atomic;

const UNINIT: u8 = 0;
const RUNNING: u8 = 1;
Expand Down Expand Up @@ -73,11 +73,11 @@ impl<T> OnceInit<T> {
/// Intended to be called from the PyO3 `__init__` handler once the Python-side
/// arguments have been validated and the Rust value can be constructed.
///
/// # Panics
/// # Errors
///
/// Panics if `set` has already been called on this instance.
/// Returns an error if `set` has already been called on this instance.
#[inline]
pub fn set(&self, val: T) {
pub fn set(&self, val: T) -> pyo3::PyResult<()> {
if self
.0
.state
Expand All @@ -89,25 +89,26 @@ impl<T> OnceInit<T> {
)
.is_err()
{
already_init_panic();
return Err(already_init_exception());
}
// SAFETY: we own the RUNNING token — no other thread can write value.
unsafe { (*self.0.value.get()).write(val) };
self.0.state.store(INIT, atomic::Ordering::Release);
Ok(())
}

/// Returns an immutable reference to initialized value.
///
/// # Panics
/// # Errors
///
/// Panics if called before [`set`](Self::set) has completed.
/// Returns an error if called before [`set`](Self::set) has completed.
#[inline]
pub fn get(&self) -> &T {
pub fn get(&self) -> pyo3::PyResult<&T> {
if crate::hashbrown::util::likely(self.0.state.load(atomic::Ordering::Acquire) == INIT) {
// SAFETY: state == INIT guarantees `value` was fully written and is valid.
unsafe { (*self.0.value.get()).assume_init_ref() }
Ok(unsafe { (*self.0.value.get()).assume_init_ref() })
} else {
not_init_panic()
Err(not_init_exception())
}
}
}
Expand Down Expand Up @@ -145,14 +146,17 @@ impl<T> Drop for OnceInit<T> {
/// rarely-executed stub and does not bloat the hot path of [`lock`](OnceInit::lock).
#[cold]
#[inline(never)]
fn not_init_panic() -> ! {
panic!("Object not initialized (__init__ not called)")
fn not_init_exception() -> pyo3::PyErr {
new_py_error!(
PyRuntimeError,
"Object not initialized (__init__ not called)"
)
}

/// Marked `#[cold]` and `#[inline(never)]` so it is compiled as a separate,
/// rarely-executed stub and does not bloat the hot path of [`set`](OnceInit::set).
#[cold]
#[inline(never)]
fn already_init_panic() -> ! {
panic!("Object already initialized")
fn already_init_exception() -> pyo3::PyErr {
new_py_error!(PyRuntimeError, "Object already initialized")
}
2 changes: 2 additions & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
#![allow(unsafe_op_in_unsafe_fn)]

#[macro_use]
mod macro_rules;
mod hashbrown;
Expand Down
Loading
Loading