wafer-cli 0.2.1__tar.gz → 0.2.2__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.
- {wafer_cli-0.2.1 → wafer_cli-0.2.2}/PKG-INFO +1 -1
- {wafer_cli-0.2.1 → wafer_cli-0.2.2}/pyproject.toml +2 -2
- wafer_cli-0.2.2/tests/test_billing.py +531 -0
- {wafer_cli-0.2.1 → wafer_cli-0.2.2}/tests/test_cli_coverage.py +0 -1
- wafer_cli-0.2.2/wafer/GUIDE.md +107 -0
- wafer_cli-0.2.2/wafer/billing.py +233 -0
- {wafer_cli-0.2.1 → wafer_cli-0.2.2}/wafer/cli.py +576 -45
- wafer_cli-0.2.2/wafer/skills/wafer-guide/SKILL.md +116 -0
- {wafer_cli-0.2.1 → wafer_cli-0.2.2}/wafer/wevin_cli.py +1 -0
- {wafer_cli-0.2.1 → wafer_cli-0.2.2}/wafer/workspaces.py +7 -0
- {wafer_cli-0.2.1 → wafer_cli-0.2.2}/wafer_cli.egg-info/PKG-INFO +1 -1
- {wafer_cli-0.2.1 → wafer_cli-0.2.2}/wafer_cli.egg-info/SOURCES.txt +3 -0
- wafer_cli-0.2.1/wafer/GUIDE.md +0 -313
- {wafer_cli-0.2.1 → wafer_cli-0.2.2}/README.md +0 -0
- {wafer_cli-0.2.1 → wafer_cli-0.2.2}/setup.cfg +0 -0
- {wafer_cli-0.2.1 → wafer_cli-0.2.2}/tests/test_cli_parity_integration.py +0 -0
- {wafer_cli-0.2.1 → wafer_cli-0.2.2}/tests/test_config_integration.py +0 -0
- {wafer_cli-0.2.1 → wafer_cli-0.2.2}/tests/test_file_operations_integration.py +0 -0
- {wafer_cli-0.2.1 → wafer_cli-0.2.2}/tests/test_isa_cli.py +0 -0
- {wafer_cli-0.2.1 → wafer_cli-0.2.2}/tests/test_rocprof_compute_integration.py +0 -0
- {wafer_cli-0.2.1 → wafer_cli-0.2.2}/tests/test_ssh_integration.py +0 -0
- {wafer_cli-0.2.1 → wafer_cli-0.2.2}/tests/test_wevin_cli.py +0 -0
- {wafer_cli-0.2.1 → wafer_cli-0.2.2}/tests/test_workflow_integration.py +0 -0
- {wafer_cli-0.2.1 → wafer_cli-0.2.2}/wafer/__init__.py +0 -0
- {wafer_cli-0.2.1 → wafer_cli-0.2.2}/wafer/api_client.py +0 -0
- {wafer_cli-0.2.1 → wafer_cli-0.2.2}/wafer/auth.py +0 -0
- {wafer_cli-0.2.1 → wafer_cli-0.2.2}/wafer/autotuner.py +0 -0
- {wafer_cli-0.2.1 → wafer_cli-0.2.2}/wafer/config.py +0 -0
- {wafer_cli-0.2.1 → wafer_cli-0.2.2}/wafer/corpus.py +0 -0
- {wafer_cli-0.2.1 → wafer_cli-0.2.2}/wafer/evaluate.py +0 -0
- {wafer_cli-0.2.1 → wafer_cli-0.2.2}/wafer/global_config.py +0 -0
- {wafer_cli-0.2.1 → wafer_cli-0.2.2}/wafer/gpu_run.py +0 -0
- {wafer_cli-0.2.1 → wafer_cli-0.2.2}/wafer/inference.py +0 -0
- {wafer_cli-0.2.1 → wafer_cli-0.2.2}/wafer/ncu_analyze.py +0 -0
- {wafer_cli-0.2.1 → wafer_cli-0.2.2}/wafer/nsys_analyze.py +0 -0
- {wafer_cli-0.2.1 → wafer_cli-0.2.2}/wafer/rocprof_compute.py +0 -0
- {wafer_cli-0.2.1 → wafer_cli-0.2.2}/wafer/rocprof_sdk.py +0 -0
- {wafer_cli-0.2.1 → wafer_cli-0.2.2}/wafer/rocprof_systems.py +0 -0
- {wafer_cli-0.2.1 → wafer_cli-0.2.2}/wafer/targets.py +0 -0
- {wafer_cli-0.2.1 → wafer_cli-0.2.2}/wafer/templates/__init__.py +0 -0
- {wafer_cli-0.2.1 → wafer_cli-0.2.2}/wafer/templates/ask_docs.py +0 -0
- {wafer_cli-0.2.1 → wafer_cli-0.2.2}/wafer/templates/optimize_kernel.py +0 -0
- {wafer_cli-0.2.1 → wafer_cli-0.2.2}/wafer/templates/trace_analyze.py +0 -0
- {wafer_cli-0.2.1 → wafer_cli-0.2.2}/wafer/tracelens.py +0 -0
- {wafer_cli-0.2.1 → wafer_cli-0.2.2}/wafer_cli.egg-info/dependency_links.txt +0 -0
- {wafer_cli-0.2.1 → wafer_cli-0.2.2}/wafer_cli.egg-info/entry_points.txt +0 -0
- {wafer_cli-0.2.1 → wafer_cli-0.2.2}/wafer_cli.egg-info/requires.txt +0 -0
- {wafer_cli-0.2.1 → wafer_cli-0.2.2}/wafer_cli.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "wafer-cli"
|
|
3
|
-
version = "0.2.
|
|
3
|
+
version = "0.2.2"
|
|
4
4
|
description = "CLI tool for running commands on remote GPUs and GPU kernel optimization agent"
|
|
5
5
|
requires-python = ">=3.11"
|
|
6
6
|
dependencies = [
|
|
@@ -35,7 +35,7 @@ where = ["."]
|
|
|
35
35
|
include = ["wafer*"]
|
|
36
36
|
|
|
37
37
|
[tool.setuptools.package-data]
|
|
38
|
-
wafer = ["GUIDE.md"]
|
|
38
|
+
wafer = ["GUIDE.md", "skills/*/SKILL.md"]
|
|
39
39
|
|
|
40
40
|
[tool.ruff]
|
|
41
41
|
line-length = 100
|
|
@@ -0,0 +1,531 @@
|
|
|
1
|
+
"""Tests for billing commands.
|
|
2
|
+
|
|
3
|
+
Tests the wafer billing, wafer billing topup, and wafer billing portal commands.
|
|
4
|
+
|
|
5
|
+
Run with: PYTHONPATH=. uv run pytest tests/test_billing.py -v
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
from unittest.mock import MagicMock, patch
|
|
10
|
+
|
|
11
|
+
import httpx
|
|
12
|
+
import pytest
|
|
13
|
+
from typer.testing import CliRunner
|
|
14
|
+
|
|
15
|
+
from wafer.cli import app
|
|
16
|
+
|
|
17
|
+
runner = CliRunner()
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
# =============================================================================
|
|
21
|
+
# Unit Tests for Business Logic
|
|
22
|
+
# =============================================================================
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class TestFormatCents:
|
|
26
|
+
"""Test the format_cents helper function."""
|
|
27
|
+
|
|
28
|
+
def test_format_2500_cents(self) -> None:
|
|
29
|
+
"""2500 cents formats to $25.00."""
|
|
30
|
+
from wafer.billing import format_cents
|
|
31
|
+
|
|
32
|
+
assert format_cents(2500) == "$25.00"
|
|
33
|
+
|
|
34
|
+
def test_format_0_cents(self) -> None:
|
|
35
|
+
"""0 cents formats to $0.00."""
|
|
36
|
+
from wafer.billing import format_cents
|
|
37
|
+
|
|
38
|
+
assert format_cents(0) == "$0.00"
|
|
39
|
+
|
|
40
|
+
def test_format_99_cents(self) -> None:
|
|
41
|
+
"""99 cents formats to $0.99."""
|
|
42
|
+
from wafer.billing import format_cents
|
|
43
|
+
|
|
44
|
+
assert format_cents(99) == "$0.99"
|
|
45
|
+
|
|
46
|
+
def test_format_10000_cents(self) -> None:
|
|
47
|
+
"""10000 cents formats to $100.00."""
|
|
48
|
+
from wafer.billing import format_cents
|
|
49
|
+
|
|
50
|
+
assert format_cents(10000) == "$100.00"
|
|
51
|
+
|
|
52
|
+
def test_format_150_cents(self) -> None:
|
|
53
|
+
"""150 cents formats to $1.50."""
|
|
54
|
+
from wafer.billing import format_cents
|
|
55
|
+
|
|
56
|
+
assert format_cents(150) == "$1.50"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class TestValidateTopupAmount:
|
|
60
|
+
"""Test the validate_topup_amount helper function."""
|
|
61
|
+
|
|
62
|
+
def test_valid_minimum_amount(self) -> None:
|
|
63
|
+
"""$10 (1000 cents) should be valid."""
|
|
64
|
+
from wafer.billing import validate_topup_amount
|
|
65
|
+
|
|
66
|
+
# Should not raise
|
|
67
|
+
validate_topup_amount(1000)
|
|
68
|
+
|
|
69
|
+
def test_valid_maximum_amount(self) -> None:
|
|
70
|
+
"""$500 (50000 cents) should be valid."""
|
|
71
|
+
from wafer.billing import validate_topup_amount
|
|
72
|
+
|
|
73
|
+
# Should not raise
|
|
74
|
+
validate_topup_amount(50000)
|
|
75
|
+
|
|
76
|
+
def test_valid_middle_amount(self) -> None:
|
|
77
|
+
"""$25 (2500 cents) should be valid."""
|
|
78
|
+
from wafer.billing import validate_topup_amount
|
|
79
|
+
|
|
80
|
+
# Should not raise
|
|
81
|
+
validate_topup_amount(2500)
|
|
82
|
+
|
|
83
|
+
def test_amount_below_minimum(self) -> None:
|
|
84
|
+
"""$9 (900 cents) should raise ValueError."""
|
|
85
|
+
from wafer.billing import validate_topup_amount
|
|
86
|
+
|
|
87
|
+
with pytest.raises(ValueError, match="at least"):
|
|
88
|
+
validate_topup_amount(900)
|
|
89
|
+
|
|
90
|
+
def test_amount_above_maximum(self) -> None:
|
|
91
|
+
"""$501 (50100 cents) should raise ValueError."""
|
|
92
|
+
from wafer.billing import validate_topup_amount
|
|
93
|
+
|
|
94
|
+
with pytest.raises(ValueError, match="at most"):
|
|
95
|
+
validate_topup_amount(50100)
|
|
96
|
+
|
|
97
|
+
def test_amount_zero(self) -> None:
|
|
98
|
+
"""0 cents should raise ValueError."""
|
|
99
|
+
from wafer.billing import validate_topup_amount
|
|
100
|
+
|
|
101
|
+
with pytest.raises(ValueError, match="at least"):
|
|
102
|
+
validate_topup_amount(0)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class TestFormatUsageText:
|
|
106
|
+
"""Test the format_usage_text helper function."""
|
|
107
|
+
|
|
108
|
+
def test_pro_tier_active(self) -> None:
|
|
109
|
+
"""Format pro tier with active status."""
|
|
110
|
+
from wafer.billing import format_usage_text
|
|
111
|
+
|
|
112
|
+
usage = {
|
|
113
|
+
"tier": "pro",
|
|
114
|
+
"status": "active",
|
|
115
|
+
"credits_used_cents": 5000,
|
|
116
|
+
"credits_limit_cents": 10000,
|
|
117
|
+
"credits_remaining_cents": 5000,
|
|
118
|
+
"topup_balance_cents": 2000,
|
|
119
|
+
"has_hardware_counters": True,
|
|
120
|
+
"has_slack_access": True,
|
|
121
|
+
"period_ends_at": "2024-02-15T00:00:00Z",
|
|
122
|
+
}
|
|
123
|
+
text = format_usage_text(usage)
|
|
124
|
+
|
|
125
|
+
assert "Pro" in text
|
|
126
|
+
assert "$50.00" in text # used
|
|
127
|
+
assert "$100.00" in text # limit
|
|
128
|
+
assert "$20.00" in text # topup
|
|
129
|
+
assert "active" in text.lower()
|
|
130
|
+
|
|
131
|
+
def test_start_tier_shows_upgrade_prompt(self) -> None:
|
|
132
|
+
"""Start tier should suggest upgrade."""
|
|
133
|
+
from wafer.billing import format_usage_text
|
|
134
|
+
|
|
135
|
+
usage = {
|
|
136
|
+
"tier": "start",
|
|
137
|
+
"status": "active",
|
|
138
|
+
"credits_used_cents": 0,
|
|
139
|
+
"credits_limit_cents": 0,
|
|
140
|
+
"credits_remaining_cents": 0,
|
|
141
|
+
"topup_balance_cents": 0,
|
|
142
|
+
"has_hardware_counters": False,
|
|
143
|
+
"has_slack_access": False,
|
|
144
|
+
"period_ends_at": None,
|
|
145
|
+
}
|
|
146
|
+
text = format_usage_text(usage)
|
|
147
|
+
|
|
148
|
+
assert "Start" in text
|
|
149
|
+
assert "upgrade" in text.lower() or "portal" in text.lower()
|
|
150
|
+
|
|
151
|
+
def test_enterprise_tier_shows_unlimited(self) -> None:
|
|
152
|
+
"""Enterprise tier should show unlimited credits."""
|
|
153
|
+
from wafer.billing import format_usage_text
|
|
154
|
+
|
|
155
|
+
usage = {
|
|
156
|
+
"tier": "enterprise",
|
|
157
|
+
"status": "active",
|
|
158
|
+
"credits_used_cents": 50000,
|
|
159
|
+
"credits_limit_cents": -1, # Unlimited
|
|
160
|
+
"credits_remaining_cents": -1,
|
|
161
|
+
"topup_balance_cents": 0,
|
|
162
|
+
"has_hardware_counters": True,
|
|
163
|
+
"has_slack_access": True,
|
|
164
|
+
"period_ends_at": "2024-02-15T00:00:00Z",
|
|
165
|
+
}
|
|
166
|
+
text = format_usage_text(usage)
|
|
167
|
+
|
|
168
|
+
assert "Enterprise" in text
|
|
169
|
+
assert "Unlimited" in text or "unlimited" in text
|
|
170
|
+
|
|
171
|
+
def test_past_due_status_shows_warning(self) -> None:
|
|
172
|
+
"""past_due status should show warning."""
|
|
173
|
+
from wafer.billing import format_usage_text
|
|
174
|
+
|
|
175
|
+
usage = {
|
|
176
|
+
"tier": "pro",
|
|
177
|
+
"status": "past_due",
|
|
178
|
+
"credits_used_cents": 5000,
|
|
179
|
+
"credits_limit_cents": 10000,
|
|
180
|
+
"credits_remaining_cents": 5000,
|
|
181
|
+
"topup_balance_cents": 0,
|
|
182
|
+
"has_hardware_counters": True,
|
|
183
|
+
"has_slack_access": True,
|
|
184
|
+
"period_ends_at": "2024-02-15T00:00:00Z",
|
|
185
|
+
}
|
|
186
|
+
text = format_usage_text(usage)
|
|
187
|
+
|
|
188
|
+
assert "past_due" in text.lower() or "warning" in text.lower() or "⚠" in text
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
# =============================================================================
|
|
192
|
+
# Integration Tests with Mocked HTTP
|
|
193
|
+
# =============================================================================
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
class TestBillingUsageCommand:
|
|
197
|
+
"""Test wafer billing (usage) command."""
|
|
198
|
+
|
|
199
|
+
def test_not_logged_in(self) -> None:
|
|
200
|
+
"""Should error with login guidance when not authenticated."""
|
|
201
|
+
with patch("wafer.billing.get_auth_headers") as mock_auth:
|
|
202
|
+
mock_auth.return_value = {} # No auth header
|
|
203
|
+
|
|
204
|
+
with patch("wafer.billing.httpx.Client") as mock_client:
|
|
205
|
+
mock_response = MagicMock()
|
|
206
|
+
mock_response.status_code = 401
|
|
207
|
+
mock_response.text = '{"detail": "Not authenticated"}'
|
|
208
|
+
mock_response.raise_for_status.side_effect = httpx.HTTPStatusError(
|
|
209
|
+
"401", request=MagicMock(), response=mock_response
|
|
210
|
+
)
|
|
211
|
+
mock_client.return_value.__enter__.return_value.get.return_value = mock_response
|
|
212
|
+
|
|
213
|
+
result = runner.invoke(app, ["billing"])
|
|
214
|
+
|
|
215
|
+
assert result.exit_code != 0
|
|
216
|
+
assert "login" in result.output.lower()
|
|
217
|
+
|
|
218
|
+
def test_json_output(self) -> None:
|
|
219
|
+
"""Should return raw JSON with --json flag."""
|
|
220
|
+
usage_data = {
|
|
221
|
+
"tier": "pro",
|
|
222
|
+
"status": "active",
|
|
223
|
+
"credits_used_cents": 5000,
|
|
224
|
+
"credits_limit_cents": 10000,
|
|
225
|
+
"credits_remaining_cents": 5000,
|
|
226
|
+
"topup_balance_cents": 2000,
|
|
227
|
+
"has_hardware_counters": True,
|
|
228
|
+
"has_slack_access": True,
|
|
229
|
+
"period_ends_at": "2024-02-15T00:00:00Z",
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
with patch("wafer.billing.get_auth_headers") as mock_auth:
|
|
233
|
+
mock_auth.return_value = {"Authorization": "Bearer test"}
|
|
234
|
+
|
|
235
|
+
with patch("wafer.billing.get_api_url") as mock_url:
|
|
236
|
+
mock_url.return_value = "https://api.example.com"
|
|
237
|
+
|
|
238
|
+
with patch("wafer.billing.httpx.Client") as mock_client:
|
|
239
|
+
mock_response = MagicMock()
|
|
240
|
+
mock_response.status_code = 200
|
|
241
|
+
mock_response.json.return_value = usage_data
|
|
242
|
+
mock_response.raise_for_status.return_value = None
|
|
243
|
+
mock_client.return_value.__enter__.return_value.get.return_value = mock_response
|
|
244
|
+
|
|
245
|
+
result = runner.invoke(app, ["billing", "--json"])
|
|
246
|
+
|
|
247
|
+
assert result.exit_code == 0
|
|
248
|
+
data = json.loads(result.stdout)
|
|
249
|
+
assert data["tier"] == "pro"
|
|
250
|
+
|
|
251
|
+
def test_formatted_output(self) -> None:
|
|
252
|
+
"""Should return formatted text without --json flag."""
|
|
253
|
+
usage_data = {
|
|
254
|
+
"tier": "pro",
|
|
255
|
+
"status": "active",
|
|
256
|
+
"credits_used_cents": 5000,
|
|
257
|
+
"credits_limit_cents": 10000,
|
|
258
|
+
"credits_remaining_cents": 5000,
|
|
259
|
+
"topup_balance_cents": 2000,
|
|
260
|
+
"has_hardware_counters": True,
|
|
261
|
+
"has_slack_access": True,
|
|
262
|
+
"period_ends_at": "2024-02-15T00:00:00Z",
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
with patch("wafer.billing.get_auth_headers") as mock_auth:
|
|
266
|
+
mock_auth.return_value = {"Authorization": "Bearer test"}
|
|
267
|
+
|
|
268
|
+
with patch("wafer.billing.get_api_url") as mock_url:
|
|
269
|
+
mock_url.return_value = "https://api.example.com"
|
|
270
|
+
|
|
271
|
+
with patch("wafer.billing.httpx.Client") as mock_client:
|
|
272
|
+
mock_response = MagicMock()
|
|
273
|
+
mock_response.status_code = 200
|
|
274
|
+
mock_response.json.return_value = usage_data
|
|
275
|
+
mock_response.raise_for_status.return_value = None
|
|
276
|
+
mock_client.return_value.__enter__.return_value.get.return_value = mock_response
|
|
277
|
+
|
|
278
|
+
result = runner.invoke(app, ["billing"])
|
|
279
|
+
|
|
280
|
+
assert result.exit_code == 0
|
|
281
|
+
assert "Pro" in result.output
|
|
282
|
+
assert "$" in result.output
|
|
283
|
+
|
|
284
|
+
def test_api_error(self) -> None:
|
|
285
|
+
"""Should show graceful error on API failure."""
|
|
286
|
+
with patch("wafer.billing.get_auth_headers") as mock_auth:
|
|
287
|
+
mock_auth.return_value = {"Authorization": "Bearer test"}
|
|
288
|
+
|
|
289
|
+
with patch("wafer.billing.get_api_url") as mock_url:
|
|
290
|
+
mock_url.return_value = "https://api.example.com"
|
|
291
|
+
|
|
292
|
+
with patch("wafer.billing.httpx.Client") as mock_client:
|
|
293
|
+
mock_client.return_value.__enter__.return_value.get.side_effect = (
|
|
294
|
+
httpx.RequestError("Connection failed")
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
result = runner.invoke(app, ["billing"])
|
|
298
|
+
|
|
299
|
+
assert result.exit_code != 0
|
|
300
|
+
assert "error" in result.output.lower() or "reach" in result.output.lower()
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
class TestBillingTopupCommand:
|
|
304
|
+
"""Test wafer billing topup command."""
|
|
305
|
+
|
|
306
|
+
def test_not_logged_in(self) -> None:
|
|
307
|
+
"""Should error with login guidance when not authenticated."""
|
|
308
|
+
with patch("wafer.billing.get_auth_headers") as mock_auth:
|
|
309
|
+
mock_auth.return_value = {}
|
|
310
|
+
|
|
311
|
+
with patch("wafer.billing.httpx.Client") as mock_client:
|
|
312
|
+
mock_response = MagicMock()
|
|
313
|
+
mock_response.status_code = 401
|
|
314
|
+
mock_response.text = '{"detail": "Not authenticated"}'
|
|
315
|
+
mock_response.raise_for_status.side_effect = httpx.HTTPStatusError(
|
|
316
|
+
"401", request=MagicMock(), response=mock_response
|
|
317
|
+
)
|
|
318
|
+
mock_client.return_value.__enter__.return_value.post.return_value = mock_response
|
|
319
|
+
|
|
320
|
+
result = runner.invoke(app, ["billing", "topup"])
|
|
321
|
+
|
|
322
|
+
assert result.exit_code != 0
|
|
323
|
+
assert "login" in result.output.lower()
|
|
324
|
+
|
|
325
|
+
def test_default_amount_25(self) -> None:
|
|
326
|
+
"""Default amount should be $25."""
|
|
327
|
+
checkout_data = {
|
|
328
|
+
"checkout_url": "https://checkout.stripe.com/test",
|
|
329
|
+
"session_id": "cs_test_123",
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
with patch("wafer.billing.get_auth_headers") as mock_auth:
|
|
333
|
+
mock_auth.return_value = {"Authorization": "Bearer test"}
|
|
334
|
+
|
|
335
|
+
with patch("wafer.billing.get_api_url") as mock_url:
|
|
336
|
+
mock_url.return_value = "https://api.example.com"
|
|
337
|
+
|
|
338
|
+
with patch("wafer.billing.httpx.Client") as mock_client:
|
|
339
|
+
mock_response = MagicMock()
|
|
340
|
+
mock_response.status_code = 200
|
|
341
|
+
mock_response.json.return_value = checkout_data
|
|
342
|
+
mock_response.raise_for_status.return_value = None
|
|
343
|
+
mock_client.return_value.__enter__.return_value.post.return_value = mock_response
|
|
344
|
+
|
|
345
|
+
with patch("webbrowser.open") as mock_browser:
|
|
346
|
+
result = runner.invoke(app, ["billing", "topup"])
|
|
347
|
+
|
|
348
|
+
assert result.exit_code == 0
|
|
349
|
+
# Verify $25 = 2500 cents was sent
|
|
350
|
+
call_args = mock_client.return_value.__enter__.return_value.post.call_args
|
|
351
|
+
assert call_args[1]["json"]["amount_cents"] == 2500
|
|
352
|
+
mock_browser.assert_called_once()
|
|
353
|
+
|
|
354
|
+
def test_custom_amount_100(self) -> None:
|
|
355
|
+
"""Custom amount $100 should work."""
|
|
356
|
+
checkout_data = {
|
|
357
|
+
"checkout_url": "https://checkout.stripe.com/test",
|
|
358
|
+
"session_id": "cs_test_123",
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
with patch("wafer.billing.get_auth_headers") as mock_auth:
|
|
362
|
+
mock_auth.return_value = {"Authorization": "Bearer test"}
|
|
363
|
+
|
|
364
|
+
with patch("wafer.billing.get_api_url") as mock_url:
|
|
365
|
+
mock_url.return_value = "https://api.example.com"
|
|
366
|
+
|
|
367
|
+
with patch("wafer.billing.httpx.Client") as mock_client:
|
|
368
|
+
mock_response = MagicMock()
|
|
369
|
+
mock_response.status_code = 200
|
|
370
|
+
mock_response.json.return_value = checkout_data
|
|
371
|
+
mock_response.raise_for_status.return_value = None
|
|
372
|
+
mock_client.return_value.__enter__.return_value.post.return_value = mock_response
|
|
373
|
+
|
|
374
|
+
with patch("webbrowser.open") as mock_browser:
|
|
375
|
+
result = runner.invoke(app, ["billing", "topup", "100"])
|
|
376
|
+
|
|
377
|
+
assert result.exit_code == 0
|
|
378
|
+
call_args = mock_client.return_value.__enter__.return_value.post.call_args
|
|
379
|
+
assert call_args[1]["json"]["amount_cents"] == 10000
|
|
380
|
+
mock_browser.assert_called_once()
|
|
381
|
+
|
|
382
|
+
def test_amount_below_minimum(self) -> None:
|
|
383
|
+
"""Amount below $10 should error."""
|
|
384
|
+
result = runner.invoke(app, ["billing", "topup", "5"])
|
|
385
|
+
|
|
386
|
+
assert result.exit_code != 0
|
|
387
|
+
assert "10" in result.output # Should mention minimum
|
|
388
|
+
|
|
389
|
+
def test_amount_above_maximum(self) -> None:
|
|
390
|
+
"""Amount above $500 should error."""
|
|
391
|
+
result = runner.invoke(app, ["billing", "topup", "600"])
|
|
392
|
+
|
|
393
|
+
assert result.exit_code != 0
|
|
394
|
+
assert "500" in result.output # Should mention maximum
|
|
395
|
+
|
|
396
|
+
def test_start_tier_blocked(self) -> None:
|
|
397
|
+
"""Start tier users should be blocked with upgrade message."""
|
|
398
|
+
with patch("wafer.billing.get_auth_headers") as mock_auth:
|
|
399
|
+
mock_auth.return_value = {"Authorization": "Bearer test"}
|
|
400
|
+
|
|
401
|
+
with patch("wafer.billing.get_api_url") as mock_url:
|
|
402
|
+
mock_url.return_value = "https://api.example.com"
|
|
403
|
+
|
|
404
|
+
with patch("wafer.billing.httpx.Client") as mock_client:
|
|
405
|
+
mock_response = MagicMock()
|
|
406
|
+
mock_response.status_code = 403
|
|
407
|
+
mock_response.text = '{"detail": "Topup not available for Start tier"}'
|
|
408
|
+
mock_response.raise_for_status.side_effect = httpx.HTTPStatusError(
|
|
409
|
+
"403", request=MagicMock(), response=mock_response
|
|
410
|
+
)
|
|
411
|
+
mock_client.return_value.__enter__.return_value.post.return_value = mock_response
|
|
412
|
+
|
|
413
|
+
result = runner.invoke(app, ["billing", "topup"])
|
|
414
|
+
|
|
415
|
+
assert result.exit_code != 0
|
|
416
|
+
assert "upgrade" in result.output.lower() or "portal" in result.output.lower()
|
|
417
|
+
|
|
418
|
+
def test_no_browser_flag(self) -> None:
|
|
419
|
+
"""--no-browser should print URL instead of opening browser."""
|
|
420
|
+
checkout_data = {
|
|
421
|
+
"checkout_url": "https://checkout.stripe.com/test",
|
|
422
|
+
"session_id": "cs_test_123",
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
with patch("wafer.billing.get_auth_headers") as mock_auth:
|
|
426
|
+
mock_auth.return_value = {"Authorization": "Bearer test"}
|
|
427
|
+
|
|
428
|
+
with patch("wafer.billing.get_api_url") as mock_url:
|
|
429
|
+
mock_url.return_value = "https://api.example.com"
|
|
430
|
+
|
|
431
|
+
with patch("wafer.billing.httpx.Client") as mock_client:
|
|
432
|
+
mock_response = MagicMock()
|
|
433
|
+
mock_response.status_code = 200
|
|
434
|
+
mock_response.json.return_value = checkout_data
|
|
435
|
+
mock_response.raise_for_status.return_value = None
|
|
436
|
+
mock_client.return_value.__enter__.return_value.post.return_value = mock_response
|
|
437
|
+
|
|
438
|
+
with patch("webbrowser.open") as mock_browser:
|
|
439
|
+
result = runner.invoke(app, ["billing", "topup", "--no-browser"])
|
|
440
|
+
|
|
441
|
+
assert result.exit_code == 0
|
|
442
|
+
assert "https://checkout.stripe.com/test" in result.output
|
|
443
|
+
mock_browser.assert_not_called()
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
class TestBillingPortalCommand:
|
|
447
|
+
"""Test wafer billing portal command."""
|
|
448
|
+
|
|
449
|
+
def test_not_logged_in(self) -> None:
|
|
450
|
+
"""Should error with login guidance when not authenticated."""
|
|
451
|
+
with patch("wafer.billing.get_auth_headers") as mock_auth:
|
|
452
|
+
mock_auth.return_value = {}
|
|
453
|
+
|
|
454
|
+
with patch("wafer.billing.httpx.Client") as mock_client:
|
|
455
|
+
mock_response = MagicMock()
|
|
456
|
+
mock_response.status_code = 401
|
|
457
|
+
mock_response.text = '{"detail": "Not authenticated"}'
|
|
458
|
+
mock_response.raise_for_status.side_effect = httpx.HTTPStatusError(
|
|
459
|
+
"401", request=MagicMock(), response=mock_response
|
|
460
|
+
)
|
|
461
|
+
mock_client.return_value.__enter__.return_value.post.return_value = mock_response
|
|
462
|
+
|
|
463
|
+
result = runner.invoke(app, ["billing", "portal"])
|
|
464
|
+
|
|
465
|
+
assert result.exit_code != 0
|
|
466
|
+
assert "login" in result.output.lower()
|
|
467
|
+
|
|
468
|
+
def test_success_opens_browser(self) -> None:
|
|
469
|
+
"""Should open browser with portal URL."""
|
|
470
|
+
portal_data = {"portal_url": "https://billing.stripe.com/test"}
|
|
471
|
+
|
|
472
|
+
with patch("wafer.billing.get_auth_headers") as mock_auth:
|
|
473
|
+
mock_auth.return_value = {"Authorization": "Bearer test"}
|
|
474
|
+
|
|
475
|
+
with patch("wafer.billing.get_api_url") as mock_url:
|
|
476
|
+
mock_url.return_value = "https://api.example.com"
|
|
477
|
+
|
|
478
|
+
with patch("wafer.billing.httpx.Client") as mock_client:
|
|
479
|
+
mock_response = MagicMock()
|
|
480
|
+
mock_response.status_code = 200
|
|
481
|
+
mock_response.json.return_value = portal_data
|
|
482
|
+
mock_response.raise_for_status.return_value = None
|
|
483
|
+
mock_client.return_value.__enter__.return_value.post.return_value = mock_response
|
|
484
|
+
|
|
485
|
+
with patch("webbrowser.open") as mock_browser:
|
|
486
|
+
result = runner.invoke(app, ["billing", "portal"])
|
|
487
|
+
|
|
488
|
+
assert result.exit_code == 0
|
|
489
|
+
mock_browser.assert_called_once_with("https://billing.stripe.com/test")
|
|
490
|
+
|
|
491
|
+
def test_no_browser_flag(self) -> None:
|
|
492
|
+
"""--no-browser should print URL instead of opening browser."""
|
|
493
|
+
portal_data = {"portal_url": "https://billing.stripe.com/test"}
|
|
494
|
+
|
|
495
|
+
with patch("wafer.billing.get_auth_headers") as mock_auth:
|
|
496
|
+
mock_auth.return_value = {"Authorization": "Bearer test"}
|
|
497
|
+
|
|
498
|
+
with patch("wafer.billing.get_api_url") as mock_url:
|
|
499
|
+
mock_url.return_value = "https://api.example.com"
|
|
500
|
+
|
|
501
|
+
with patch("wafer.billing.httpx.Client") as mock_client:
|
|
502
|
+
mock_response = MagicMock()
|
|
503
|
+
mock_response.status_code = 200
|
|
504
|
+
mock_response.json.return_value = portal_data
|
|
505
|
+
mock_response.raise_for_status.return_value = None
|
|
506
|
+
mock_client.return_value.__enter__.return_value.post.return_value = mock_response
|
|
507
|
+
|
|
508
|
+
with patch("webbrowser.open") as mock_browser:
|
|
509
|
+
result = runner.invoke(app, ["billing", "portal", "--no-browser"])
|
|
510
|
+
|
|
511
|
+
assert result.exit_code == 0
|
|
512
|
+
assert "https://billing.stripe.com/test" in result.output
|
|
513
|
+
mock_browser.assert_not_called()
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
# =============================================================================
|
|
517
|
+
# Tests for 402 Error Handling
|
|
518
|
+
# =============================================================================
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
class TestInsufficientCreditsError:
|
|
522
|
+
"""Test 402 error handling in _friendly_error."""
|
|
523
|
+
|
|
524
|
+
def test_402_error_message(self) -> None:
|
|
525
|
+
"""402 should show billing guidance."""
|
|
526
|
+
from wafer.workspaces import _friendly_error
|
|
527
|
+
|
|
528
|
+
message = _friendly_error(402, '{"detail": "Insufficient credits"}', "test-workspace")
|
|
529
|
+
|
|
530
|
+
assert "credit" in message.lower()
|
|
531
|
+
assert "wafer billing" in message.lower()
|
|
@@ -82,7 +82,6 @@ class TestGuideCommand:
|
|
|
82
82
|
result = runner.invoke(app, ["guide"])
|
|
83
83
|
assert result.exit_code == 0, f"Failed with output: {result.output}"
|
|
84
84
|
assert "Wafer CLI Guide" in result.stdout
|
|
85
|
-
assert "Quick Start" in result.stdout
|
|
86
85
|
assert "wafer nvidia ncu" in result.stdout
|
|
87
86
|
|
|
88
87
|
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# Wafer CLI Guide
|
|
2
|
+
|
|
3
|
+
GPU development primitives for LLM agents.
|
|
4
|
+
|
|
5
|
+
## Quick Start: Cloud GPU (No Setup)
|
|
6
|
+
|
|
7
|
+
Run code on cloud GPUs instantly with workspaces:
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
wafer login # One-time auth
|
|
11
|
+
wafer workspaces create dev --gpu B200 # Create workspace
|
|
12
|
+
wafer workspaces exec dev -- python -c "import torch; print(torch.cuda.get_device_name(0))"
|
|
13
|
+
wafer workspaces sync dev ./my-project # Sync files
|
|
14
|
+
wafer workspaces exec dev -- python train.py
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Documentation Lookup
|
|
18
|
+
|
|
19
|
+
Answer GPU programming questions from indexed documentation.
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
# Download corpus (one-time)
|
|
23
|
+
wafer corpus download cuda
|
|
24
|
+
wafer corpus download cutlass
|
|
25
|
+
wafer corpus download hip
|
|
26
|
+
|
|
27
|
+
# Query documentation
|
|
28
|
+
wafer agent -t ask-docs --corpus cuda "What is warp divergence?"
|
|
29
|
+
wafer agent -t ask-docs --corpus cutlass "What is a TiledMma?"
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Trace Analysis
|
|
33
|
+
|
|
34
|
+
Analyze performance traces from NCU, NSYS, or PyTorch profiler.
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
# AI-assisted analysis
|
|
38
|
+
wafer agent -t trace-analyze --args trace=./profile.ncu-rep "Why is this kernel slow?"
|
|
39
|
+
wafer agent -t trace-analyze --args trace=./trace.json "What's the bottleneck?"
|
|
40
|
+
|
|
41
|
+
# Direct trace queries (PyTorch/Perfetto JSON)
|
|
42
|
+
wafer nvidia perfetto tables trace.json
|
|
43
|
+
wafer nvidia perfetto query trace.json \
|
|
44
|
+
"SELECT name, dur/1e6 as ms FROM slice WHERE cat='kernel' ORDER BY dur DESC LIMIT 10"
|
|
45
|
+
|
|
46
|
+
# NCU/NSYS analysis
|
|
47
|
+
wafer nvidia ncu analyze profile.ncu-rep
|
|
48
|
+
wafer nvidia nsys analyze profile.nsys-rep
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Kernel Evaluation
|
|
52
|
+
|
|
53
|
+
Test kernel correctness and measure speedup against a reference.
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
# Using workspaces (no target setup required):
|
|
57
|
+
wafer workspaces create dev --gpu B200
|
|
58
|
+
wafer workspaces exec --sync ./my-kernel dev -- python test_kernel.py
|
|
59
|
+
|
|
60
|
+
# Or using configured targets (for your own hardware):
|
|
61
|
+
wafer evaluate make-template ./my-kernel
|
|
62
|
+
wafer evaluate \
|
|
63
|
+
--impl ./my-kernel/kernel.py \
|
|
64
|
+
--reference ./my-kernel/reference.py \
|
|
65
|
+
--test-cases ./my-kernel/test_cases.json \
|
|
66
|
+
--target <target-name>
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
For target setup, see `wafer config targets --help`.
|
|
70
|
+
|
|
71
|
+
## Kernel Optimization (AI-assisted)
|
|
72
|
+
|
|
73
|
+
Iteratively optimize a kernel with evaluation feedback.
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
wafer agent -t optimize-kernel \
|
|
77
|
+
--args kernel=./my_kernel.cu \
|
|
78
|
+
--args target=H100 \
|
|
79
|
+
"Optimize this GEMM for memory bandwidth"
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
## Workspaces
|
|
83
|
+
|
|
84
|
+
Cloud GPU environments with no setup required.
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
wafer workspaces create dev --gpu B200 # Create
|
|
88
|
+
wafer workspaces list # List all
|
|
89
|
+
wafer workspaces sync dev ./project # Sync files
|
|
90
|
+
wafer workspaces exec dev -- ./run.sh # Run commands
|
|
91
|
+
wafer workspaces ssh dev # Interactive SSH
|
|
92
|
+
wafer workspaces delete dev # Cleanup
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
See `wafer workspaces --help` for details.
|
|
96
|
+
|
|
97
|
+
## Command Reference
|
|
98
|
+
|
|
99
|
+
```bash
|
|
100
|
+
wafer corpus list|download|path # Manage documentation corpora
|
|
101
|
+
wafer workspaces # Cloud GPU environments (no setup)
|
|
102
|
+
wafer evaluate # Test kernel correctness/performance
|
|
103
|
+
wafer nvidia ncu|nsys|perfetto # NVIDIA profiling tools
|
|
104
|
+
wafer amd isa|rocprof-compute # AMD profiling tools
|
|
105
|
+
wafer agent -t <template> # AI-assisted workflows
|
|
106
|
+
wafer config targets # Configure your own GPU targets
|
|
107
|
+
```
|