Skip to content

Commit 8931608

Browse files
committed
Python 3.9 compatibility
1 parent 660fa8c commit 8931608

83 files changed

Lines changed: 3690 additions & 472 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/ci.yml

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,32 @@ jobs:
118118
path: dist/*.whl
119119
retention-days: 1
120120

121+
py39-smoke:
122+
# Oldest supported Python: install the abi3 wheel and smoke-test imports/CLI.
123+
needs: [changes, build-wheel]
124+
if: needs.changes.outputs.code == 'true'
125+
runs-on: ubuntu-latest
126+
timeout-minutes: 15
127+
steps:
128+
- uses: actions/checkout@v6
129+
- uses: actions/setup-python@v6
130+
with:
131+
python-version: "3.9"
132+
- name: Download prebuilt wheel
133+
uses: actions/download-artifact@v8
134+
with:
135+
name: headroom-wheel
136+
path: dist
137+
- name: Install wheel on 3.9 and smoke-test
138+
run: |
139+
python -m pip install --upgrade pip
140+
python -m pip install dist/*.whl
141+
python -c "import headroom; print(headroom.__version__)"
142+
headroom --help >/dev/null
143+
- name: Bytecode-compile the whole package on 3.9
144+
run: |
145+
python -m compileall -q "$(python -c 'import headroom, os; print(os.path.dirname(headroom.__file__))')"
146+
121147
prefetch-model:
122148
needs: changes
123149
if: needs.changes.outputs.code == 'true'

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -253,3 +253,5 @@ uv.lock
253253
/headroom/_core.*.so
254254
/headroom/_core.so
255255
.tokensave
256+
.rtk/
257+
custom:/

CONTRIBUTING.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,7 @@ Enable or disable automatic Copilot review in **Settings → Rules → Rulesets
122122
- [Ruff](https://github.com/astral-sh/ruff) for lint + format, line length 100, PEP 8.
123123
- Type hints on public functions; Google-style docstrings.
124124
- Cover new behavior + edge cases; aim >80% coverage on new code.
125-
- Python 3.10+. Optional features go behind extras.
125+
- Python 3.9+. Optional features go behind extras.
126126

127127
## Architecture principles
128128

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ tokio = { version = "1", features = ["macros", "rt-multi-thread", "signal"] }
5959
axum = "0.7"
6060
tower = "0.5"
6161
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
62-
pyo3 = { version = "0.29", features = ["abi3-py310"] }
62+
pyo3 = { version = "0.29", features = ["abi3-py39"] }
6363
# Forwards Rust `log` records (incl. tracing events via the `log` compat
6464
# feature above) into Python's `logging` inside the _core extension module.
6565
pyo3-log = "0.13"

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,7 @@ headroom dashboard # live savings dashboard (proxy must be
103103

104104
The `headroom` CLI ships **only** via the PyPI package. The npm `headroom-ai` is the TypeScript SDK — a library you import (`import { compress } from 'headroom-ai'`), not a CLI, so it provides no `headroom` command.
105105

106-
Granular extras: `[proxy]`, `[mcp]`, `[ml]`, `[code]`, `[memory]`, `[vector]` (optional HNSW backend — needs a C++ toolchain, not in `[all]`), `[relevance]`, `[image]`, `[agno]`, `[langchain]`, `[evals]`, `[pytorch-mps]` (Apple-GPU memory-embedder offload — set `HEADROOM_EMBEDDER_RUNTIME=pytorch_mps`). Requires **Python 3.10+**.
106+
Granular extras: `[proxy]`, `[mcp]`, `[ml]`, `[code]`, `[memory]`, `[vector]` (optional HNSW backend — needs a C++ toolchain, not in `[all]`), `[relevance]`, `[image]`, `[agno]`, `[langchain]`, `[evals]`, `[pytorch-mps]` (Apple-GPU memory-embedder offload — set `HEADROOM_EMBEDDER_RUNTIME=pytorch_mps`). Requires **Python 3.9+**.
107107

108108
## Proof
109109

@@ -326,7 +326,7 @@ npm install headroom-ai # TypeScript SDK (library only — no `h
326326
docker pull ghcr.io/chopratejas/headroom:latest
327327
```
328328

329-
Granular extras: `[proxy]`, `[mcp]`, `[ml]` (Kompress-v2-base), `[code]`, `[memory]`, `[vector]` (optional HNSW backend — needs a C++ toolchain, not in `[all]`), `[relevance]`, `[image]`, `[agno]`, `[langchain]`, `[evals]`, `[pytorch-mps]` (Apple-GPU memory-embedder offload — set `HEADROOM_EMBEDDER_RUNTIME=pytorch_mps`). Requires **Python 3.10+**.
329+
Granular extras: `[proxy]`, `[mcp]`, `[ml]` (Kompress-v2-base), `[code]`, `[memory]`, `[vector]` (optional HNSW backend — needs a C++ toolchain, not in `[all]`), `[relevance]`, `[image]`, `[agno]`, `[langchain]`, `[evals]`, `[pytorch-mps]` (Apple-GPU memory-embedder offload — set `HEADROOM_EMBEDDER_RUNTIME=pytorch_mps`). Requires **Python 3.9+**.
330330

331331
> **Note**: `[all]` covers the core stack but excludes framework adapters. Install them separately: `pip install "headroom-ai[langchain]"` (also `[agno]`, `[strands]`, `[anyllm]`, `[bedrock]`).
332332

benchmarks/comprehensive_eval.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@
1717
python benchmarks/comprehensive_eval.py
1818
"""
1919

20+
from __future__ import annotations
21+
2022
import json
2123
import os
2224
import time

benchmarks/synthetic_token_cache_bust_report.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -160,7 +160,7 @@ def _build_bust_events(replay: SessionReplay) -> dict[str, list[dict[str, object
160160
(
161161
idx
162162
for idx, (prev_msg, curr_msg) in enumerate(
163-
zip(previous_forwarded_request, forwarded, strict=False)
163+
zip(previous_forwarded_request, forwarded)
164164
)
165165
if prev_msg != curr_msg
166166
),

docs/content/docs/installation.mdx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,8 @@ description: Install Headroom via pip, npm, or Docker. Includes all Python extra
1313

1414
## Python
1515

16-
Headroom requires **Python 3.10+** and is published as `headroom-ai` on PyPI.
17-
Current release wheels are built for Python **3.10 through 3.13** on Linux
16+
Headroom requires **Python 3.9+** and is published as `headroom-ai` on PyPI.
17+
Current release wheels are built for Python **3.9 through 3.14** on Linux
1818
(manylinux_2_28 x86_64 / aarch64) and macOS (Apple Silicon). Other targets —
1919
**Windows** and **Intel macOS** — fall back to building the Rust extension
2020
from the sdist and need a working native toolchain. If your installer is
@@ -244,7 +244,7 @@ These are common issues faced during initial setup and how to resolve them.
244244

245245
### Python version error
246246

247-
This project requires **Python 3.10+**.
247+
This project requires **Python 3.9+**.
248248

249249
Check your version:
250250

headroom/_compat.py

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
"""Compatibility shims for Python versions older than 3.10."""
2+
3+
from __future__ import annotations
4+
5+
import sys
6+
from collections.abc import AsyncIterator
7+
from typing import TYPE_CHECKING, Any
8+
9+
if TYPE_CHECKING:
10+
from importlib.metadata import EntryPoint
11+
12+
# `@dataclass(slots=True)` needs 3.10+. Use `@dataclass(**DATACLASS_SLOTS)` to get
13+
# slots where available without breaking 3.9.
14+
DATACLASS_SLOTS: dict[str, bool] = {"slots": True} if sys.version_info >= (3, 10) else {}
15+
16+
if sys.version_info >= (3, 10):
17+
import asyncio
18+
import importlib.metadata
19+
from contextlib import aclosing
20+
21+
def entry_points_group(group: str) -> list[EntryPoint]:
22+
"""Entry points for ``group`` (`entry_points(group=...)` needs 3.10+)."""
23+
# Resolved dynamically so tests can monkeypatch importlib.metadata.entry_points.
24+
return list(importlib.metadata.entry_points(group=group))
25+
26+
AsyncLock = asyncio.Lock
27+
AsyncEvent = asyncio.Event
28+
AsyncSemaphore = asyncio.Semaphore
29+
AsyncQueue = asyncio.Queue
30+
31+
else:
32+
import asyncio
33+
import collections
34+
import importlib.metadata
35+
from contextlib import asynccontextmanager
36+
37+
@asynccontextmanager
38+
async def aclosing(thing: Any) -> AsyncIterator[Any]:
39+
try:
40+
yield thing
41+
finally:
42+
await thing.aclose()
43+
44+
def entry_points_group(group: str) -> list[EntryPoint]:
45+
"""Entry points for ``group`` (`entry_points(group=...)` needs 3.10+)."""
46+
# Resolved dynamically so tests can monkeypatch importlib.metadata.entry_points.
47+
eps = importlib.metadata.entry_points()
48+
if isinstance(eps, dict): # 3.9 returns {group: [EntryPoint, ...]}
49+
return list(eps.get(group, []))
50+
return list(eps)
51+
52+
class _LazyLoopMixin:
53+
"""Defer event-loop binding to first use inside a running loop.
54+
55+
Python 3.9's asyncio primitives call ``get_event_loop()`` eagerly in
56+
``__init__``, which (a) raises when constructed in a sync context with
57+
no loop set, and (b) binds to a loop that may not be the one the
58+
primitive is later awaited in. Python 3.10 made binding lazy; these
59+
subclasses backport that behavior by skipping the eager binding and
60+
resolving ``self._loop`` at first use via a property.
61+
"""
62+
63+
_lazy_loop: Any = None
64+
65+
@property
66+
def _loop(self) -> Any:
67+
loop = asyncio.get_running_loop()
68+
if self._lazy_loop is None:
69+
self._lazy_loop = loop
70+
if self._lazy_loop is not loop:
71+
raise RuntimeError(f"{self!r} is bound to a different event loop")
72+
return loop
73+
74+
class AsyncLock(_LazyLoopMixin, asyncio.Lock):
75+
def __init__(self) -> None:
76+
# State from 3.9 Lock.__init__, minus the eager loop binding.
77+
self._waiters = None
78+
self._locked = False
79+
80+
class AsyncEvent(_LazyLoopMixin, asyncio.Event):
81+
def __init__(self) -> None:
82+
# State from 3.9 Event.__init__, minus the eager loop binding.
83+
self._waiters = collections.deque()
84+
self._value = False
85+
86+
class AsyncSemaphore(_LazyLoopMixin, asyncio.Semaphore):
87+
def __init__(self, value: int = 1) -> None:
88+
# State from 3.9 Semaphore.__init__, minus the eager loop binding.
89+
if value < 0:
90+
raise ValueError("Semaphore initial value must be >= 0")
91+
self._value = value
92+
self._waiters = collections.deque()
93+
self._wakeup_scheduled = False
94+
95+
class AsyncQueue(_LazyLoopMixin, asyncio.Queue):
96+
def __init__(self, maxsize: int = 0) -> None:
97+
# State from 3.9 Queue.__init__, minus the eager loop binding.
98+
self._maxsize = maxsize
99+
self._getters: collections.deque[Any] = collections.deque()
100+
self._putters: collections.deque[Any] = collections.deque()
101+
self._unfinished_tasks = 0
102+
self._finished = AsyncEvent()
103+
self._finished.set()
104+
self._init(maxsize)
105+
106+
107+
__all__ = [
108+
"AsyncEvent",
109+
"AsyncLock",
110+
"AsyncQueue",
111+
"AsyncSemaphore",
112+
"aclosing",
113+
"entry_points_group",
114+
]

headroom/cache/compression_store.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,8 @@
4242
from dataclasses import dataclass, field, replace
4343
from typing import TYPE_CHECKING, Any
4444

45+
from headroom._compat import entry_points_group
46+
4547
if TYPE_CHECKING:
4648
from ..memory.tracker import ComponentStats
4749
from .backends import CompressionStoreBackend
@@ -970,9 +972,7 @@ def _create_default_ccr_backend() -> CompressionStoreBackend | None:
970972
)
971973
return None
972974
try:
973-
from importlib.metadata import entry_points
974-
975-
all_eps = entry_points(group="headroom.ccr_backend")
975+
all_eps = entry_points_group("headroom.ccr_backend")
976976
ep = next((e for e in all_eps if e.name == backend_type), None)
977977
if ep is None:
978978
logger.warning(

0 commit comments

Comments
 (0)