contextpress 0.4.0__tar.gz → 0.5.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. {contextpress-0.4.0 → contextpress-0.5.0}/CHANGELOG.md +8 -0
  2. {contextpress-0.4.0 → contextpress-0.5.0}/PKG-INFO +17 -2
  3. {contextpress-0.4.0 → contextpress-0.5.0}/README.md +15 -1
  4. {contextpress-0.4.0 → contextpress-0.5.0}/contextpress/__init__.py +1 -1
  5. {contextpress-0.4.0 → contextpress-0.5.0}/contextpress/core.py +38 -0
  6. {contextpress-0.4.0 → contextpress-0.5.0}/contextpress/llm/__init__.py +2 -0
  7. {contextpress-0.4.0 → contextpress-0.5.0}/contextpress/llm/adapters.py +47 -0
  8. {contextpress-0.4.0 → contextpress-0.5.0}/contextpress/stats.py +27 -0
  9. contextpress-0.5.0/examples/benchmark_presets.py +23 -0
  10. {contextpress-0.4.0 → contextpress-0.5.0}/pyproject.toml +2 -2
  11. contextpress-0.5.0/tests/test_v05.py +66 -0
  12. {contextpress-0.4.0 → contextpress-0.5.0}/.gitignore +0 -0
  13. {contextpress-0.4.0 → contextpress-0.5.0}/CITATION.cff +0 -0
  14. {contextpress-0.4.0 → contextpress-0.5.0}/CONTRIBUTING.md +0 -0
  15. {contextpress-0.4.0 → contextpress-0.5.0}/LICENSE +0 -0
  16. {contextpress-0.4.0 → contextpress-0.5.0}/NOTICE +0 -0
  17. {contextpress-0.4.0 → contextpress-0.5.0}/contextpress/_bootstrap.py +0 -0
  18. {contextpress-0.4.0 → contextpress-0.5.0}/contextpress/compression.py +0 -0
  19. {contextpress-0.4.0 → contextpress-0.5.0}/contextpress/llm/_helpers.py +0 -0
  20. {contextpress-0.4.0 → contextpress-0.5.0}/contextpress/llm/base.py +0 -0
  21. {contextpress-0.4.0 → contextpress-0.5.0}/contextpress/models.py +0 -0
  22. {contextpress-0.4.0 → contextpress-0.5.0}/contextpress/normalizer.py +0 -0
  23. {contextpress-0.4.0 → contextpress-0.5.0}/contextpress/pipeline.py +0 -0
  24. {contextpress-0.4.0 → contextpress-0.5.0}/contextpress/profiles.py +0 -0
  25. {contextpress-0.4.0 → contextpress-0.5.0}/contextpress/py.typed +0 -0
  26. {contextpress-0.4.0 → contextpress-0.5.0}/contextpress/registry.py +0 -0
  27. {contextpress-0.4.0 → contextpress-0.5.0}/contextpress/strategies/__init__.py +0 -0
  28. {contextpress-0.4.0 → contextpress-0.5.0}/contextpress/strategies/base.py +0 -0
  29. {contextpress-0.4.0 → contextpress-0.5.0}/contextpress/strategies/budget.py +0 -0
  30. {contextpress-0.4.0 → contextpress-0.5.0}/contextpress/strategies/filler.py +0 -0
  31. {contextpress-0.4.0 → contextpress-0.5.0}/contextpress/strategies/recency.py +0 -0
  32. {contextpress-0.4.0 → contextpress-0.5.0}/contextpress/strategies/repetition.py +0 -0
  33. {contextpress-0.4.0 → contextpress-0.5.0}/contextpress/strategies/resolution.py +0 -0
  34. {contextpress-0.4.0 → contextpress-0.5.0}/contextpress/warnings_capture.py +0 -0
  35. {contextpress-0.4.0 → contextpress-0.5.0}/examples/dry_run_preview.py +0 -0
  36. {contextpress-0.4.0 → contextpress-0.5.0}/examples/estimate_and_stats.py +0 -0
  37. {contextpress-0.4.0 → contextpress-0.5.0}/examples/llm_tier_ollama.py +0 -0
  38. {contextpress-0.4.0 → contextpress-0.5.0}/examples/llm_tier_openai.py +0 -0
  39. {contextpress-0.4.0 → contextpress-0.5.0}/tests/__init__.py +0 -0
  40. {contextpress-0.4.0 → contextpress-0.5.0}/tests/test_budget.py +0 -0
  41. {contextpress-0.4.0 → contextpress-0.5.0}/tests/test_filler.py +0 -0
  42. {contextpress-0.4.0 → contextpress-0.5.0}/tests/test_llm_helpers.py +0 -0
  43. {contextpress-0.4.0 → contextpress-0.5.0}/tests/test_models.py +0 -0
  44. {contextpress-0.4.0 → contextpress-0.5.0}/tests/test_normalizer.py +0 -0
  45. {contextpress-0.4.0 → contextpress-0.5.0}/tests/test_pipeline.py +0 -0
  46. {contextpress-0.4.0 → contextpress-0.5.0}/tests/test_recency.py +0 -0
  47. {contextpress-0.4.0 → contextpress-0.5.0}/tests/test_repetition.py +0 -0
  48. {contextpress-0.4.0 → contextpress-0.5.0}/tests/test_resolution.py +0 -0
  49. {contextpress-0.4.0 → contextpress-0.5.0}/tests/test_stats.py +0 -0
  50. {contextpress-0.4.0 → contextpress-0.5.0}/tests/test_v03.py +0 -0
  51. {contextpress-0.4.0 → contextpress-0.5.0}/tests/test_v04.py +0 -0
@@ -4,6 +4,14 @@ All notable changes to `contextpress` are recorded here.
4
4
  The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/)
5
5
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
+ ## [0.5.0] - 2026-07-22
8
+
9
+ - **`compress_async()`** — async wrapper around ``compress()`` for asyncio apps.
10
+ - **`compare_presets()`** — dry-run low/medium/high on the same messages and compare stats.
11
+ - **`CompressionStats.to_dict()` / `CompressionResult.to_dict()`** — JSON-friendly export.
12
+ - **`GeminiBackend`** — Google Gemini Tier 2 adapter (optional ``google-generativeai``).
13
+ - Example: `examples/benchmark_presets.py`.
14
+
7
15
  ## [0.4.0] - 2026-07-20
8
16
 
9
17
  - **`preview()` / `dry_run=True`** — simulate compression and get stats without changing messages (Tier 1 only; no LLM calls).
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: contextpress
3
- Version: 0.4.0
3
+ Version: 0.5.0
4
4
  Summary: Deterministic context compression for LLM chat, RAG, and agent pipelines
5
5
  Project-URL: Homepage, https://github.com/Taha-azizi/contextpress
6
6
  Project-URL: Documentation, https://github.com/Taha-azizi/contextpress#readme
@@ -234,6 +234,7 @@ Requires-Dist: pytest>=7.0; extra == 'dev'
234
234
  Requires-Dist: ruff; extra == 'dev'
235
235
  Provides-Extra: llm
236
236
  Requires-Dist: anthropic>=0.18; extra == 'llm'
237
+ Requires-Dist: google-generativeai>=0.8.0; extra == 'llm'
237
238
  Requires-Dist: ollama>=0.3.0; extra == 'llm'
238
239
  Requires-Dist: openai>=1.0; extra == 'llm'
239
240
  Description-Content-Type: text/markdown
@@ -307,6 +308,20 @@ if cm.fits_budget(messages, 4000):
307
308
  ...
308
309
  ```
309
310
 
311
+ **Compare presets** (0.5+):
312
+
313
+ ```python
314
+ rows = cm.compare_presets(messages, token_budget=500)
315
+ for preset, stats in rows.items():
316
+ print(preset, stats.tokens_saved)
317
+ ```
318
+
319
+ **Async** (0.5+):
320
+
321
+ ```python
322
+ out = await cm.compress_async(messages, token_budget=2000)
323
+ ```
324
+
310
325
  ### Custom stages (0.3+)
311
326
 
312
327
  Register a `BaseStrategy` and include it in `stages=`:
@@ -386,7 +401,7 @@ ContextManager(type="agent")
386
401
  | **Techniques** | Rules, TF–IDF, cosine similarity, NLTK, Sumy extractive summarization, tiktoken | Your provider’s chat/completions API (you supply the client) |
387
402
  | **API key** | None | Required for your chosen provider (OpenAI, Anthropic, …) |
388
403
  | **Determinism** | Deterministic for a fixed input and settings | Non-deterministic (model sampling) |
389
- | **How to enable** | Default: `ContextManager()` runs Tier 1 only | Pass `llm_backend=` (`OpenAIBackend`, `AnthropicBackend`, **`OllamaBackend`**, or custom `LLMBackend`) |
404
+ | **How to enable** | Default: `ContextManager()` runs Tier 1 only | Pass `llm_backend=` (`OpenAIBackend`, `AnthropicBackend`, `GeminiBackend`, **`OllamaBackend`**, or custom `LLMBackend`) |
390
405
 
391
406
  **Note:** `ContextManager(model="gpt-4")` is only for **tiktoken** encoding when counting tokens in the **budget** stage. It does **not** call that model unless you also pass **`llm_backend`**.
392
407
 
@@ -67,6 +67,20 @@ if cm.fits_budget(messages, 4000):
67
67
  ...
68
68
  ```
69
69
 
70
+ **Compare presets** (0.5+):
71
+
72
+ ```python
73
+ rows = cm.compare_presets(messages, token_budget=500)
74
+ for preset, stats in rows.items():
75
+ print(preset, stats.tokens_saved)
76
+ ```
77
+
78
+ **Async** (0.5+):
79
+
80
+ ```python
81
+ out = await cm.compress_async(messages, token_budget=2000)
82
+ ```
83
+
70
84
  ### Custom stages (0.3+)
71
85
 
72
86
  Register a `BaseStrategy` and include it in `stages=`:
@@ -146,7 +160,7 @@ ContextManager(type="agent")
146
160
  | **Techniques** | Rules, TF–IDF, cosine similarity, NLTK, Sumy extractive summarization, tiktoken | Your provider’s chat/completions API (you supply the client) |
147
161
  | **API key** | None | Required for your chosen provider (OpenAI, Anthropic, …) |
148
162
  | **Determinism** | Deterministic for a fixed input and settings | Non-deterministic (model sampling) |
149
- | **How to enable** | Default: `ContextManager()` runs Tier 1 only | Pass `llm_backend=` (`OpenAIBackend`, `AnthropicBackend`, **`OllamaBackend`**, or custom `LLMBackend`) |
163
+ | **How to enable** | Default: `ContextManager()` runs Tier 1 only | Pass `llm_backend=` (`OpenAIBackend`, `AnthropicBackend`, `GeminiBackend`, **`OllamaBackend`**, or custom `LLMBackend`) |
150
164
 
151
165
  **Note:** `ContextManager(model="gpt-4")` is only for **tiktoken** encoding when counting tokens in the **budget** stage. It does **not** call that model unless you also pass **`llm_backend`**.
152
166
 
@@ -16,7 +16,7 @@ __all__ = [
16
16
  "CompressionResult",
17
17
  "CompressionStats",
18
18
  ]
19
- __version__ = "0.4.0"
19
+ __version__ = "0.5.0"
20
20
 
21
21
 
22
22
  def __getattr__(name: str) -> Any:
@@ -1,5 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import asyncio
3
4
  import copy
4
5
  import warnings
5
6
  from typing import TYPE_CHECKING, Any
@@ -86,6 +87,20 @@ class ContextManager:
86
87
  )
87
88
  return preview.stats.tokens_after <= token_budget
88
89
 
90
+ def compare_presets(
91
+ self,
92
+ messages: Any,
93
+ token_budget: int | None = None,
94
+ *,
95
+ presets: tuple[str, ...] = ("low", "medium", "high"),
96
+ ) -> dict[str, CompressionStats]:
97
+ """Dry-run each compression preset and return stats keyed by preset name."""
98
+ results: dict[str, CompressionStats] = {}
99
+ for preset in presets:
100
+ preview = self.preview(messages, token_budget=token_budget, compression=preset)
101
+ results[preset] = preview.stats
102
+ return results
103
+
89
104
  def preview(
90
105
  self,
91
106
  messages: Any,
@@ -176,6 +191,29 @@ class ContextManager:
176
191
  return CompressionResult(messages=messages_out, stats=stats)
177
192
  return messages_out
178
193
 
194
+ async def compress_async(
195
+ self,
196
+ messages: Any,
197
+ token_budget: int | None = None,
198
+ *,
199
+ compression: str | None = None,
200
+ stages: list[str] | None = None,
201
+ disable: list[str] | None = None,
202
+ return_stats: bool = False,
203
+ dry_run: bool = False,
204
+ ) -> Any | CompressionResult:
205
+ """Async wrapper around ``compress()`` (runs in a worker thread)."""
206
+ return await asyncio.to_thread(
207
+ self.compress,
208
+ messages,
209
+ token_budget,
210
+ compression=compression,
211
+ stages=stages,
212
+ disable=disable,
213
+ return_stats=return_stats,
214
+ dry_run=dry_run,
215
+ )
216
+
179
217
  def set_compression(self, compression: str) -> None:
180
218
  """Change the default preset for subsequent ``compress()`` calls (low / medium / high)."""
181
219
  self._compression = normalize_compression_level(compression)
@@ -1,5 +1,6 @@
1
1
  from contextpress.llm.adapters import (
2
2
  AnthropicBackend,
3
+ GeminiBackend,
3
4
  OllamaBackend,
4
5
  OpenAIBackend,
5
6
  OpenAICompatibleBackend,
@@ -11,5 +12,6 @@ __all__ = [
11
12
  "OpenAIBackend",
12
13
  "OpenAICompatibleBackend",
13
14
  "AnthropicBackend",
15
+ "GeminiBackend",
14
16
  "OllamaBackend",
15
17
  ]
@@ -262,3 +262,50 @@ class OpenAICompatibleBackend(OpenAIBackend):
262
262
  client=OpenAI(base_url=base_url, api_key=api_key),
263
263
  model=model,
264
264
  )
265
+
266
+
267
+ class GeminiBackend(LLMBackend):
268
+ """
269
+ Adapter for Google Gemini via ``google-generativeai``.
270
+
271
+ Requires: ``pip install google-generativeai``
272
+
273
+ Usage::
274
+
275
+ import google.generativeai as genai
276
+ from contextpress.llm.adapters import GeminiBackend
277
+
278
+ genai.configure(api_key=\"...\")
279
+ backend = GeminiBackend(model=genai.GenerativeModel(\"gemini-2.0-flash\"))
280
+ cm = ContextManager(type=\"chat\", llm_backend=backend)
281
+ """
282
+
283
+ def __init__(self, model: Any):
284
+ self.model = model
285
+
286
+ def _generate(self, prompt: str, *, max_tokens: int) -> str:
287
+ resp = self.model.generate_content(
288
+ prompt,
289
+ generation_config={"max_output_tokens": max(64, int(max_tokens))},
290
+ )
291
+ text = getattr(resp, "text", None)
292
+ return str(text).strip() if text else ""
293
+
294
+ def summarize(self, text: str, max_tokens: int) -> str:
295
+ try:
296
+ out = self._generate(f"Summarize concisely:\n\n{text}", max_tokens=max_tokens)
297
+ return out if out else text
298
+ except Exception as exc:
299
+ warnings.warn(f"contextpress GeminiBackend.summarize failed: {exc}", stacklevel=2)
300
+ raise
301
+
302
+ def deduplicate(self, turns: list[str]) -> list[int]:
303
+ if len(turns) <= 1:
304
+ return list(range(len(turns)))
305
+ prompt = f"{DEDUP_SYSTEM_PROMPT}\n\n{format_numbered_turns(turns)}"
306
+ try:
307
+ out = self._generate(prompt, max_tokens=128)
308
+ return parse_keep_indices(out, len(turns))
309
+ except Exception as exc:
310
+ warnings.warn(f"contextpress GeminiBackend.deduplicate failed: {exc}", stacklevel=2)
311
+ return list(range(len(turns)))
@@ -60,6 +60,27 @@ class CompressionStats:
60
60
  def tokens_saved(self) -> int:
61
61
  return max(0, self.tokens_before - self.tokens_after)
62
62
 
63
+ def to_dict(self) -> dict[str, Any]:
64
+ """JSON-serializable snapshot of this run."""
65
+ return {
66
+ "turns_before": self.turns_before,
67
+ "turns_after": self.turns_after,
68
+ "turns_removed": self.turns_removed,
69
+ "tokens_before": self.tokens_before,
70
+ "tokens_after": self.tokens_after,
71
+ "tokens_saved": self.tokens_saved,
72
+ "stages_run": list(self.stages_run),
73
+ "turn_delta_by_stage": dict(self.turn_delta_by_stage),
74
+ "llm_tier_applied": self.llm_tier_applied,
75
+ "llm_dedup_turns_before": self.llm_dedup_turns_before,
76
+ "llm_dedup_turns_after": self.llm_dedup_turns_after,
77
+ "compression_level": self.compression_level,
78
+ "context_type": self.context_type,
79
+ "token_budget": self.token_budget,
80
+ "dry_run": self.dry_run,
81
+ "warnings_emitted": list(self.warnings_emitted),
82
+ }
83
+
63
84
 
64
85
  @dataclass
65
86
  class CompressionResult:
@@ -67,3 +88,9 @@ class CompressionResult:
67
88
 
68
89
  messages: Any
69
90
  stats: CompressionStats
91
+
92
+ def to_dict(self, *, include_messages: bool = True) -> dict[str, Any]:
93
+ data = {"stats": self.stats.to_dict()}
94
+ if include_messages:
95
+ data["messages"] = self.messages
96
+ return data
@@ -0,0 +1,23 @@
1
+ """Compare compression presets on the same fixture (dry run)."""
2
+
3
+ from contextpress import ContextManager
4
+
5
+ messages = [
6
+ {"role": "system", "content": "You are helpful."},
7
+ {"role": "user", "content": "Old topic: " + "blah " * 40},
8
+ {"role": "assistant", "content": "Basically, honestly, " + "yak " * 40},
9
+ {"role": "user", "content": "What is 2+2?"},
10
+ {"role": "assistant", "content": "Four."},
11
+ ]
12
+
13
+ cm = ContextManager(type="chat")
14
+ rows = cm.compare_presets(messages, token_budget=200)
15
+
16
+ print(f"{'preset':<8} {'tokens':>12} {'saved':>8} {'turns':>12}")
17
+ for preset, stats in rows.items():
18
+ print(
19
+ f"{preset:<8} "
20
+ f"{stats.tokens_before:>5}->{stats.tokens_after:<5} "
21
+ f"{stats.tokens_saved:>8} "
22
+ f"{stats.turns_before:>5}->{stats.turns_after:<5}"
23
+ )
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "contextpress"
7
- version = "0.4.0"
7
+ version = "0.5.0"
8
8
  description = "Deterministic context compression for LLM chat, RAG, and agent pipelines"
9
9
  readme = "README.md"
10
10
  license = { file = "LICENSE" }
@@ -44,7 +44,7 @@ dependencies = [
44
44
 
45
45
  [project.optional-dependencies]
46
46
  dev = ["pytest>=7.0", "pytest-cov>=4.0", "black", "ruff"]
47
- llm = ["openai>=1.0", "anthropic>=0.18", "ollama>=0.3.0"]
47
+ llm = ["openai>=1.0", "anthropic>=0.18", "ollama>=0.3.0", "google-generativeai>=0.8.0"]
48
48
 
49
49
  [project.urls]
50
50
  Homepage = "https://github.com/Taha-azizi/contextpress"
@@ -0,0 +1,66 @@
1
+ import asyncio
2
+ from unittest.mock import MagicMock
3
+
4
+ from contextpress import ContextManager
5
+ from contextpress.llm.adapters import GeminiBackend
6
+ from contextpress.stats import CompressionStats
7
+
8
+
9
+ def test_stats_to_dict():
10
+ stats = CompressionStats(
11
+ turns_before=5,
12
+ turns_after=3,
13
+ tokens_before=100,
14
+ tokens_after=60,
15
+ stages_run=["filler", "budget"],
16
+ compression_level="medium",
17
+ )
18
+ d = stats.to_dict()
19
+ assert d["turns_removed"] == 2
20
+ assert d["tokens_saved"] == 40
21
+ assert d["stages_run"] == ["filler", "budget"]
22
+
23
+
24
+ def test_result_to_dict():
25
+ cm = ContextManager()
26
+ result = cm.preview([{"role": "user", "content": "hello basically"}], token_budget=100)
27
+ d = result.to_dict()
28
+ assert "stats" in d and "messages" in d
29
+ assert d["stats"]["dry_run"] is True
30
+
31
+
32
+ def test_compare_presets():
33
+ cm = ContextManager(type="chat")
34
+ messages = [
35
+ {"role": "user", "content": "hello basically there"},
36
+ {"role": "assistant", "content": "sounds good"},
37
+ ]
38
+ rows = cm.compare_presets(messages, token_budget=500)
39
+ assert set(rows) == {"low", "medium", "high"}
40
+ assert all(isinstance(v, CompressionStats) for v in rows.values())
41
+
42
+
43
+ def test_compress_async():
44
+ async def _run() -> list:
45
+ cm = ContextManager(type="chat")
46
+ return await cm.compress_async(
47
+ [{"role": "user", "content": "hello basically"}],
48
+ token_budget=200,
49
+ )
50
+
51
+ out = asyncio.run(_run())
52
+ assert isinstance(out, list)
53
+
54
+
55
+ def test_gemini_backend_summarize():
56
+ model = MagicMock()
57
+ model.generate_content.return_value = MagicMock(text="summary")
58
+ backend = GeminiBackend(model=model)
59
+ assert backend.summarize("long text", 50) == "summary"
60
+
61
+
62
+ def test_gemini_backend_deduplicate():
63
+ model = MagicMock()
64
+ model.generate_content.return_value = MagicMock(text="0, 2")
65
+ backend = GeminiBackend(model=model)
66
+ assert backend.deduplicate(["a", "b", "c", "d"]) == [0, 2]
File without changes
File without changes
File without changes
File without changes