Skip to content

Commit dd5dc89

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

76 files changed

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

headroom/cache/compression_store.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -970,9 +970,9 @@ def _create_default_ccr_backend() -> CompressionStoreBackend | None:
970970
)
971971
return None
972972
try:
973-
from importlib.metadata import entry_points
973+
from headroom._compat import entry_points_group
974974

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)