tokenshrink 0.2.0__tar.gz → 0.2.1__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 (26) hide show
  1. tokenshrink-0.2.1/Dockerfile +21 -0
  2. {tokenshrink-0.2.0 → tokenshrink-0.2.1}/PKG-INFO +1 -1
  3. tokenshrink-0.2.1/docker-compose.test.yml +47 -0
  4. {tokenshrink-0.2.0 → tokenshrink-0.2.1}/pyproject.toml +1 -1
  5. {tokenshrink-0.2.0 → tokenshrink-0.2.1}/src/tokenshrink/__init__.py +1 -1
  6. tokenshrink-0.2.1/src/tokenshrink/__main__.py +4 -0
  7. {tokenshrink-0.2.0 → tokenshrink-0.2.1}/src/tokenshrink/cli.py +19 -0
  8. {tokenshrink-0.2.0 → tokenshrink-0.2.1}/src/tokenshrink/pipeline.py +1 -1
  9. tokenshrink-0.2.1/tests/conftest.py +211 -0
  10. tokenshrink-0.2.1/tests/test_cli.py +248 -0
  11. tokenshrink-0.2.1/tests/test_integration.py +263 -0
  12. tokenshrink-0.2.1/tests/test_pipeline.py +411 -0
  13. tokenshrink-0.2.1/tests/test_stress.py +264 -0
  14. tokenshrink-0.2.1/tests/test_utils.py +255 -0
  15. {tokenshrink-0.2.0 → tokenshrink-0.2.1}/.github/ISSUE_TEMPLATE/feedback.md +0 -0
  16. {tokenshrink-0.2.0 → tokenshrink-0.2.1}/.gitignore +0 -0
  17. {tokenshrink-0.2.0 → tokenshrink-0.2.1}/LICENSE +0 -0
  18. {tokenshrink-0.2.0 → tokenshrink-0.2.1}/README.md +0 -0
  19. {tokenshrink-0.2.0 → tokenshrink-0.2.1}/docs/ASSETS.md +0 -0
  20. {tokenshrink-0.2.0 → tokenshrink-0.2.1}/docs/index.html +0 -0
  21. {tokenshrink-0.2.0 → tokenshrink-0.2.1}/docs/marketing/origin-story-post.md +0 -0
  22. {tokenshrink-0.2.0 → tokenshrink-0.2.1}/docs/marketing/reddit-log.md +0 -0
  23. {tokenshrink-0.2.0 → tokenshrink-0.2.1}/docs/marketing/reddit-posts.md +0 -0
  24. {tokenshrink-0.2.0 → tokenshrink-0.2.1}/docs/marketing/reddit-routine.md +0 -0
  25. {tokenshrink-0.2.0 → tokenshrink-0.2.1}/docs/monitoring-log.md +0 -0
  26. {tokenshrink-0.2.0 → tokenshrink-0.2.1}/site/index.html +0 -0
@@ -0,0 +1,21 @@
1
+ FROM python:3.12-slim
2
+
3
+ WORKDIR /app
4
+
5
+ # Install system deps for FAISS
6
+ RUN apt-get update && apt-get install -y --no-install-recommends \
7
+ build-essential \
8
+ && rm -rf /var/lib/apt/lists/*
9
+
10
+ # Copy project
11
+ COPY pyproject.toml README.md LICENSE ./
12
+ COPY src/ ./src/
13
+
14
+ # Install package with dev deps (no compression — too heavy for test image)
15
+ RUN pip install --no-cache-dir -e ".[dev]"
16
+
17
+ # Copy tests
18
+ COPY tests/ ./tests/
19
+
20
+ # Default: run all tests
21
+ CMD ["pytest", "tests/", "-v", "--tb=short", "-x"]
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tokenshrink
3
- Version: 0.2.0
3
+ Version: 0.2.1
4
4
  Summary: Cut your AI costs 50-80%. FAISS retrieval + LLMLingua compression + REFRAG-inspired adaptive optimization.
5
5
  Project-URL: Homepage, https://tokenshrink.dev
6
6
  Project-URL: Repository, https://github.com/MusashiMiyamoto1-cloud/tokenshrink
@@ -0,0 +1,47 @@
1
+ version: "3.8"
2
+
3
+ services:
4
+ # Full test suite
5
+ test-all:
6
+ build:
7
+ context: .
8
+ dockerfile: Dockerfile
9
+ command: pytest tests/ -v --tb=short -x
10
+ environment:
11
+ - TOKENIZERS_PARALLELISM=false
12
+
13
+ # Unit tests only (fast)
14
+ test-unit:
15
+ build:
16
+ context: .
17
+ dockerfile: Dockerfile
18
+ command: pytest tests/test_utils.py tests/test_pipeline.py -v --tb=short
19
+ environment:
20
+ - TOKENIZERS_PARALLELISM=false
21
+
22
+ # CLI tests
23
+ test-cli:
24
+ build:
25
+ context: .
26
+ dockerfile: Dockerfile
27
+ command: pytest tests/test_cli.py -v --tb=short
28
+ environment:
29
+ - TOKENIZERS_PARALLELISM=false
30
+
31
+ # Integration tests
32
+ test-integration:
33
+ build:
34
+ context: .
35
+ dockerfile: Dockerfile
36
+ command: pytest tests/test_integration.py -v --tb=short
37
+ environment:
38
+ - TOKENIZERS_PARALLELISM=false
39
+
40
+ # Stress tests
41
+ test-stress:
42
+ build:
43
+ context: .
44
+ dockerfile: Dockerfile
45
+ command: pytest tests/test_stress.py -v --tb=short -s
46
+ environment:
47
+ - TOKENIZERS_PARALLELISM=false
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "tokenshrink"
7
- version = "0.2.0"
7
+ version = "0.2.1"
8
8
  description = "Cut your AI costs 50-80%. FAISS retrieval + LLMLingua compression + REFRAG-inspired adaptive optimization."
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -25,5 +25,5 @@ CLI:
25
25
 
26
26
  from tokenshrink.pipeline import TokenShrink, ShrinkResult, ChunkScore
27
27
 
28
- __version__ = "0.2.0"
28
+ __version__ = "0.2.1"
29
29
  __all__ = ["TokenShrink", "ShrinkResult", "ChunkScore"]
@@ -0,0 +1,4 @@
1
+ """Allow running with `python -m tokenshrink`."""
2
+ from tokenshrink.cli import main
3
+
4
+ main()
@@ -32,6 +32,11 @@ def main():
32
32
  action="store_true",
33
33
  help="Output as JSON",
34
34
  )
35
+ parser.add_argument(
36
+ "--quiet",
37
+ action="store_true",
38
+ help="Suppress model loading messages",
39
+ )
35
40
 
36
41
  subparsers = parser.add_subparsers(dest="command", help="Commands")
37
42
 
@@ -118,6 +123,17 @@ def main():
118
123
  parser.print_help()
119
124
  sys.exit(0)
120
125
 
126
+ # Suppress noisy output when --quiet or --json
127
+ if args.quiet or args.json:
128
+ import os, logging, warnings
129
+ os.environ["TRANSFORMERS_VERBOSITY"] = "error"
130
+ os.environ["HF_HUB_DISABLE_PROGRESS_BARS"] = "1"
131
+ os.environ["TOKENIZERS_PARALLELISM"] = "false"
132
+ logging.getLogger("sentence_transformers").setLevel(logging.ERROR)
133
+ logging.getLogger("transformers").setLevel(logging.ERROR)
134
+ logging.getLogger("huggingface_hub").setLevel(logging.ERROR)
135
+ warnings.filterwarnings("ignore", message=".*unauthenticated.*")
136
+
121
137
  # Determine compression setting
122
138
  compression = True
123
139
  if hasattr(args, 'no_compress') and args.no_compress:
@@ -195,6 +211,9 @@ def main():
195
211
  print(f"Sources: {', '.join(Path(s).name for s in result.sources)}")
196
212
  print(f"Stats: {result.savings}")
197
213
 
214
+ if result.savings_pct == 0.0:
215
+ print(" Tip: Install llmlingua for compression: pip install llmlingua")
216
+
198
217
  if getattr(args, 'scores', False) and result.chunk_scores:
199
218
  print("\nChunk Importance Scores:")
200
219
  for cs in result.chunk_scores:
@@ -613,7 +613,7 @@ class TokenShrink:
613
613
  "ratio": total_compressed / total_original if total_original else 1.0,
614
614
  }
615
615
 
616
- def search(self, question: str, k: int = 5, min_score: float = 0.3) -> list[dict]:
616
+ def search(self, question: str, k: int = 5, min_score: float = 0.15) -> list[dict]:
617
617
  """Search without compression. Returns raw chunks with scores."""
618
618
  if self._index.ntotal == 0:
619
619
  return []
@@ -0,0 +1,211 @@
1
+ """Shared fixtures for TokenShrink test suite."""
2
+
3
+ import os
4
+ import json
5
+ import shutil
6
+ import tempfile
7
+ from pathlib import Path
8
+
9
+ import pytest
10
+ from sentence_transformers import SentenceTransformer
11
+
12
+
13
+ # Session-scoped: load the embedding model ONCE for all tests
14
+ @pytest.fixture(scope="session")
15
+ def shared_model():
16
+ """Load embedding model once per test session."""
17
+ return SentenceTransformer("all-MiniLM-L6-v2")
18
+
19
+
20
+ @pytest.fixture
21
+ def tmp_dir():
22
+ """Create a temporary directory, clean up after."""
23
+ d = tempfile.mkdtemp(prefix="tokenshrink_test_")
24
+ yield Path(d)
25
+ shutil.rmtree(d, ignore_errors=True)
26
+
27
+
28
+ @pytest.fixture
29
+ def sample_docs(tmp_dir):
30
+ """Create sample documents for indexing."""
31
+ docs_dir = tmp_dir / "docs"
32
+ docs_dir.mkdir()
33
+
34
+ # Auth documentation
35
+ (docs_dir / "auth.md").write_text(
36
+ "# Authentication\n\n"
37
+ "All API requests require a Bearer token in the Authorization header. "
38
+ "Tokens expire after 24 hours and must be refreshed using the /auth/refresh endpoint. "
39
+ "Rate limiting is enforced at 100 requests per minute per token. "
40
+ "If you exceed the rate limit, you'll receive a 429 status code. "
41
+ "OAuth2 flows are supported for third-party integrations. "
42
+ "The client_id and client_secret must be stored securely. "
43
+ "Never expose credentials in client-side code or version control. "
44
+ "Use environment variables or a secrets manager for production deployments. "
45
+ "Multi-factor authentication is required for admin endpoints. "
46
+ "Session tokens are tied to IP address for security. "
47
+ * 3
48
+ )
49
+
50
+ # Rate limiting documentation
51
+ (docs_dir / "rate-limits.md").write_text(
52
+ "# Rate Limits\n\n"
53
+ "The API enforces the following rate limits:\n"
54
+ "- Free tier: 10 requests per minute\n"
55
+ "- Pro tier: 100 requests per minute\n"
56
+ "- Enterprise: 1000 requests per minute\n\n"
57
+ "Rate limit headers are included in every response:\n"
58
+ "- X-RateLimit-Limit: Maximum requests allowed\n"
59
+ "- X-RateLimit-Remaining: Requests remaining\n"
60
+ "- X-RateLimit-Reset: Unix timestamp when limit resets\n\n"
61
+ "When rate limited, the response includes a Retry-After header. "
62
+ "Implement exponential backoff in your client. "
63
+ "Batch endpoints have separate, higher limits. "
64
+ "WebSocket connections have a message rate limit of 60 messages per minute. "
65
+ "Exceeding limits temporarily blocks the API key for 5 minutes. "
66
+ * 3
67
+ )
68
+
69
+ # Deployment guide
70
+ (docs_dir / "deployment.md").write_text(
71
+ "# Deployment Guide\n\n"
72
+ "## Docker\n"
73
+ "Build the image: `docker build -t myapp .`\n"
74
+ "Run with: `docker run -p 8080:8080 myapp`\n\n"
75
+ "## Kubernetes\n"
76
+ "Apply manifests: `kubectl apply -f k8s/`\n"
77
+ "The service uses a HorizontalPodAutoscaler with CPU target of 70%. "
78
+ "Minimum 2 replicas, maximum 10 replicas. "
79
+ "Persistent volumes are required for the database. "
80
+ "Use ConfigMaps for environment-specific settings. "
81
+ "Secrets should be managed via external-secrets-operator. "
82
+ "Health checks are configured on /health and /ready endpoints. "
83
+ "The readiness probe has an initial delay of 10 seconds. "
84
+ "Rolling updates with maxUnavailable=1 and maxSurge=1. "
85
+ * 3
86
+ )
87
+
88
+ # Near-duplicate of auth (for dedup testing)
89
+ (docs_dir / "auth2.md").write_text(
90
+ "# Authentication Guide\n\n"
91
+ "All API requests require a Bearer token in the Authorization header. "
92
+ "Tokens expire after 24 hours and must be refreshed using the /auth/refresh endpoint. "
93
+ "Rate limiting is enforced at 100 requests per minute per token. "
94
+ "If you exceed the rate limit, you'll receive a 429 status code. "
95
+ "OAuth2 flows are supported for third-party integrations. "
96
+ "The client_id and client_secret must be stored securely. "
97
+ "Never expose credentials in client-side code or version control. "
98
+ "Use environment variables or a secrets manager for production deployments. "
99
+ "Multi-factor authentication is required for admin endpoints. "
100
+ "Session tokens are tied to IP address for extra security measures. "
101
+ * 3
102
+ )
103
+
104
+ # Python code file
105
+ (docs_dir / "client.py").write_text(
106
+ '"""\nAPI Client for the service.\n"""\n\n'
107
+ "import requests\n"
108
+ "import time\n\n"
109
+ "class APIClient:\n"
110
+ ' """HTTP client with retry and rate limit handling."""\n\n'
111
+ " def __init__(self, base_url: str, token: str):\n"
112
+ " self.base_url = base_url\n"
113
+ " self.token = token\n"
114
+ " self.session = requests.Session()\n"
115
+ ' self.session.headers["Authorization"] = f"Bearer {token}"\n\n'
116
+ " def get(self, path: str, **kwargs):\n"
117
+ ' """GET request with retry."""\n'
118
+ " for attempt in range(3):\n"
119
+ " resp = self.session.get(f'{self.base_url}{path}', **kwargs)\n"
120
+ " if resp.status_code == 429:\n"
121
+ " wait = int(resp.headers.get('Retry-After', 5))\n"
122
+ " time.sleep(wait)\n"
123
+ " continue\n"
124
+ " return resp\n"
125
+ " raise Exception('Rate limited after 3 retries')\n\n"
126
+ " def post(self, path: str, data=None, **kwargs):\n"
127
+ ' """POST request with retry."""\n'
128
+ " for attempt in range(3):\n"
129
+ " resp = self.session.post(f'{self.base_url}{path}', json=data, **kwargs)\n"
130
+ " if resp.status_code == 429:\n"
131
+ " wait = int(resp.headers.get('Retry-After', 5))\n"
132
+ " time.sleep(wait)\n"
133
+ " continue\n"
134
+ " return resp\n"
135
+ " raise Exception('Rate limited after 3 retries')\n"
136
+ )
137
+
138
+ return docs_dir
139
+
140
+
141
+ @pytest.fixture
142
+ def large_docs(tmp_dir):
143
+ """Create a large document set for stress testing."""
144
+ docs_dir = tmp_dir / "large_docs"
145
+ docs_dir.mkdir()
146
+
147
+ topics = [
148
+ ("machine-learning", "Machine learning models use gradient descent to optimize loss functions. "
149
+ "Neural networks consist of layers of interconnected nodes. "),
150
+ ("databases", "PostgreSQL supports JSONB for semi-structured data storage. "
151
+ "Indexes improve query performance on frequently accessed columns. "),
152
+ ("networking", "TCP provides reliable ordered delivery of data between applications. "
153
+ "DNS resolves domain names to IP addresses using a hierarchical system. "),
154
+ ("security", "TLS encrypts data in transit between client and server. "
155
+ "CORS headers control which origins can access API resources. "),
156
+ ("devops", "CI/CD pipelines automate building, testing, and deploying code. "
157
+ "Infrastructure as code tools like Terraform manage cloud resources. "),
158
+ ("frontend", "React components re-render when state or props change. "
159
+ "CSS Grid and Flexbox provide powerful layout capabilities. "),
160
+ ("api-design", "REST APIs use HTTP methods to perform CRUD operations. "
161
+ "GraphQL allows clients to request exactly the data they need. "),
162
+ ("testing", "Unit tests verify individual functions in isolation. "
163
+ "Integration tests check that components work together correctly. "),
164
+ ("monitoring", "Prometheus collects metrics from instrumented applications. "
165
+ "Grafana dashboards visualize time-series data for observability. "),
166
+ ("caching", "Redis provides in-memory key-value storage with persistence options. "
167
+ "CDN edge caching reduces latency for static assets. "),
168
+ ]
169
+
170
+ for i in range(50):
171
+ topic_name, content = topics[i % len(topics)]
172
+ filename = f"{topic_name}-{i:03d}.md"
173
+ # Each file ~2000 words
174
+ (docs_dir / filename).write_text(
175
+ f"# {topic_name.replace('-', ' ').title()} - Part {i}\n\n"
176
+ + (content * 80)
177
+ )
178
+
179
+ return docs_dir
180
+
181
+
182
+ @pytest.fixture
183
+ def indexed_ts(tmp_dir, sample_docs, shared_model):
184
+ """Return a TokenShrink instance with sample docs already indexed."""
185
+ from tokenshrink import TokenShrink
186
+
187
+ ts = TokenShrink(
188
+ index_dir=str(tmp_dir / ".tokenshrink"),
189
+ compression=False,
190
+ adaptive=True,
191
+ dedup=True,
192
+ )
193
+ # Inject the shared model to avoid reloading
194
+ ts._model = shared_model
195
+ ts.index(str(sample_docs))
196
+ return ts
197
+
198
+
199
+ @pytest.fixture
200
+ def make_ts(tmp_dir, shared_model):
201
+ """Factory fixture: creates a TokenShrink with the shared model."""
202
+ from tokenshrink import TokenShrink
203
+
204
+ def _make(**kwargs):
205
+ kwargs.setdefault("index_dir", str(tmp_dir / ".ts"))
206
+ kwargs.setdefault("compression", False)
207
+ ts = TokenShrink(**kwargs)
208
+ ts._model = shared_model
209
+ return ts
210
+
211
+ return _make
@@ -0,0 +1,248 @@
1
+ """Tests for the CLI interface."""
2
+
3
+ import json
4
+ import subprocess
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ import pytest
9
+
10
+
11
+ @pytest.fixture
12
+ def cli_env(tmp_dir, sample_docs):
13
+ """Set up CLI test environment."""
14
+ return {
15
+ "docs_dir": str(sample_docs),
16
+ "index_dir": str(tmp_dir / ".ts"),
17
+ "tmp_dir": tmp_dir,
18
+ }
19
+
20
+
21
+ def run_cli(*args, cwd=None):
22
+ """Run tokenshrink CLI and return result."""
23
+ cmd = [sys.executable, "-m", "tokenshrink.cli"] + list(args)
24
+ result = subprocess.run(
25
+ cmd,
26
+ capture_output=True,
27
+ text=True,
28
+ cwd=cwd,
29
+ timeout=120,
30
+ )
31
+ return result
32
+
33
+
34
+ class TestCLIIndex:
35
+ """Test CLI index command."""
36
+
37
+ def test_index_basic(self, cli_env):
38
+ r = run_cli("--index-dir", cli_env["index_dir"], "index", cli_env["docs_dir"])
39
+ assert r.returncode == 0
40
+ assert "Indexed" in r.stdout
41
+ assert "Chunks" in r.stdout
42
+
43
+ def test_index_json_output(self, cli_env):
44
+ r = run_cli("--index-dir", cli_env["index_dir"], "--json", "index", cli_env["docs_dir"])
45
+ assert r.returncode == 0
46
+ data = json.loads(r.stdout)
47
+ assert "files_indexed" in data
48
+ assert "chunks_added" in data
49
+ assert data["files_indexed"] > 0
50
+
51
+ def test_index_with_extensions(self, cli_env):
52
+ r = run_cli(
53
+ "--index-dir", cli_env["index_dir"],
54
+ "--json",
55
+ "index", cli_env["docs_dir"],
56
+ "-e", ".md",
57
+ )
58
+ assert r.returncode == 0
59
+ data = json.loads(r.stdout)
60
+ assert data["files_indexed"] == 4 # Only .md files
61
+
62
+ def test_index_force(self, cli_env):
63
+ run_cli("--index-dir", cli_env["index_dir"], "index", cli_env["docs_dir"])
64
+ r = run_cli(
65
+ "--index-dir", cli_env["index_dir"],
66
+ "--json",
67
+ "index", cli_env["docs_dir"],
68
+ "-f",
69
+ )
70
+ assert r.returncode == 0
71
+ data = json.loads(r.stdout)
72
+ assert data["files_indexed"] > 0 # Re-indexed with force
73
+
74
+
75
+ class TestCLIQuery:
76
+ """Test CLI query command."""
77
+
78
+ def test_query_basic(self, cli_env):
79
+ run_cli("--index-dir", cli_env["index_dir"], "index", cli_env["docs_dir"])
80
+ r = run_cli(
81
+ "--index-dir", cli_env["index_dir"],
82
+ "query", "authentication tokens",
83
+ "--no-compress",
84
+ )
85
+ assert r.returncode == 0
86
+ assert "Sources:" in r.stdout or "No relevant" in r.stdout
87
+
88
+ def test_query_json(self, cli_env):
89
+ run_cli("--index-dir", cli_env["index_dir"], "index", cli_env["docs_dir"])
90
+ r = run_cli(
91
+ "--index-dir", cli_env["index_dir"],
92
+ "--json",
93
+ "query", "authentication",
94
+ "--no-compress",
95
+ )
96
+ assert r.returncode == 0
97
+ data = json.loads(r.stdout)
98
+ assert "context" in data
99
+ assert "sources" in data
100
+ assert "original_tokens" in data
101
+
102
+ def test_query_with_scores(self, cli_env):
103
+ run_cli("--index-dir", cli_env["index_dir"], "index", cli_env["docs_dir"])
104
+ r = run_cli(
105
+ "--index-dir", cli_env["index_dir"],
106
+ "query", "rate limits",
107
+ "--no-compress",
108
+ "--scores",
109
+ )
110
+ assert r.returncode == 0
111
+ assert "Chunk Importance Scores" in r.stdout
112
+ assert "sim=" in r.stdout
113
+ assert "density=" in r.stdout
114
+
115
+ def test_query_json_with_scores(self, cli_env):
116
+ run_cli("--index-dir", cli_env["index_dir"], "index", cli_env["docs_dir"])
117
+ r = run_cli(
118
+ "--index-dir", cli_env["index_dir"],
119
+ "--json",
120
+ "query", "deployment kubernetes",
121
+ "--no-compress",
122
+ "--scores",
123
+ )
124
+ assert r.returncode == 0
125
+ data = json.loads(r.stdout)
126
+ assert "chunk_scores" in data
127
+ for cs in data["chunk_scores"]:
128
+ assert "similarity" in cs
129
+ assert "density" in cs
130
+ assert "importance" in cs
131
+
132
+ def test_query_no_dedup(self, cli_env):
133
+ run_cli("--index-dir", cli_env["index_dir"], "index", cli_env["docs_dir"])
134
+ r = run_cli(
135
+ "--index-dir", cli_env["index_dir"],
136
+ "--json",
137
+ "query", "authentication",
138
+ "--no-compress",
139
+ "--no-dedup",
140
+ )
141
+ assert r.returncode == 0
142
+ data = json.loads(r.stdout)
143
+ assert data["dedup_removed"] == 0
144
+
145
+ def test_query_k_param(self, cli_env):
146
+ run_cli("--index-dir", cli_env["index_dir"], "index", cli_env["docs_dir"])
147
+ r = run_cli(
148
+ "--index-dir", cli_env["index_dir"],
149
+ "--json",
150
+ "query", "authentication",
151
+ "--no-compress",
152
+ "-k", "2",
153
+ )
154
+ assert r.returncode == 0
155
+ data = json.loads(r.stdout)
156
+ assert len(data.get("sources", [])) <= 2
157
+
158
+
159
+ class TestCLISearch:
160
+ """Test CLI search command."""
161
+
162
+ def test_search_basic(self, cli_env):
163
+ run_cli("--index-dir", cli_env["index_dir"], "index", cli_env["docs_dir"])
164
+ r = run_cli(
165
+ "--index-dir", cli_env["index_dir"],
166
+ "search", "rate limits",
167
+ )
168
+ assert r.returncode == 0
169
+ assert "score:" in r.stdout
170
+
171
+ def test_search_json(self, cli_env):
172
+ run_cli("--index-dir", cli_env["index_dir"], "index", cli_env["docs_dir"])
173
+ r = run_cli(
174
+ "--index-dir", cli_env["index_dir"],
175
+ "--json",
176
+ "search", "rate limits",
177
+ )
178
+ assert r.returncode == 0
179
+ data = json.loads(r.stdout)
180
+ assert isinstance(data, list)
181
+ assert len(data) > 0
182
+
183
+ def test_search_empty_index(self, cli_env):
184
+ r = run_cli(
185
+ "--index-dir", cli_env["index_dir"],
186
+ "search", "anything",
187
+ )
188
+ assert r.returncode == 0
189
+ assert "No results" in r.stdout
190
+
191
+
192
+ class TestCLIStats:
193
+ """Test CLI stats command."""
194
+
195
+ def test_stats_empty(self, cli_env):
196
+ r = run_cli("--index-dir", cli_env["index_dir"], "stats")
197
+ assert r.returncode == 0
198
+ assert "Chunks: 0" in r.stdout
199
+
200
+ def test_stats_after_index(self, cli_env):
201
+ run_cli("--index-dir", cli_env["index_dir"], "index", cli_env["docs_dir"])
202
+ r = run_cli("--index-dir", cli_env["index_dir"], "stats")
203
+ assert r.returncode == 0
204
+ assert "Chunks:" in r.stdout
205
+ assert "Files:" in r.stdout
206
+
207
+ def test_stats_json(self, cli_env):
208
+ run_cli("--index-dir", cli_env["index_dir"], "index", cli_env["docs_dir"])
209
+ r = run_cli("--index-dir", cli_env["index_dir"], "--json", "stats")
210
+ assert r.returncode == 0
211
+ data = json.loads(r.stdout)
212
+ assert "total_chunks" in data
213
+ assert data["total_chunks"] > 0
214
+
215
+
216
+ class TestCLIClear:
217
+ """Test CLI clear command."""
218
+
219
+ def test_clear(self, cli_env):
220
+ run_cli("--index-dir", cli_env["index_dir"], "index", cli_env["docs_dir"])
221
+ r = run_cli("--index-dir", cli_env["index_dir"], "clear")
222
+ assert r.returncode == 0
223
+ assert "cleared" in r.stdout.lower()
224
+
225
+ def test_clear_json(self, cli_env):
226
+ run_cli("--index-dir", cli_env["index_dir"], "index", cli_env["docs_dir"])
227
+ r = run_cli("--index-dir", cli_env["index_dir"], "--json", "clear")
228
+ assert r.returncode == 0
229
+ data = json.loads(r.stdout)
230
+ assert data["status"] == "cleared"
231
+
232
+
233
+ class TestCLIMisc:
234
+ """Test miscellaneous CLI behavior."""
235
+
236
+ def test_version(self):
237
+ r = run_cli("--version")
238
+ assert r.returncode == 0
239
+ assert "0.2.0" in r.stdout
240
+
241
+ def test_no_command(self):
242
+ r = run_cli()
243
+ assert r.returncode == 0 # Just prints help
244
+
245
+ def test_help(self):
246
+ r = run_cli("--help")
247
+ assert r.returncode == 0
248
+ assert "tokenshrink" in r.stdout.lower()