wafer-cli 0.2.16__tar.gz → 0.2.18__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 (63) hide show
  1. {wafer_cli-0.2.16 → wafer_cli-0.2.18}/PKG-INFO +1 -1
  2. {wafer_cli-0.2.16 → wafer_cli-0.2.18}/pyproject.toml +1 -1
  3. wafer_cli-0.2.18/tests/test_auth.py +193 -0
  4. {wafer_cli-0.2.16 → wafer_cli-0.2.18}/wafer/auth.py +7 -0
  5. {wafer_cli-0.2.16 → wafer_cli-0.2.18}/wafer/evaluate.py +2 -1
  6. {wafer_cli-0.2.16 → wafer_cli-0.2.18}/wafer/workspaces.py +59 -8
  7. {wafer_cli-0.2.16 → wafer_cli-0.2.18}/wafer_cli.egg-info/PKG-INFO +1 -1
  8. {wafer_cli-0.2.16 → wafer_cli-0.2.18}/wafer_cli.egg-info/SOURCES.txt +1 -0
  9. {wafer_cli-0.2.16 → wafer_cli-0.2.18}/README.md +0 -0
  10. {wafer_cli-0.2.16 → wafer_cli-0.2.18}/setup.cfg +0 -0
  11. {wafer_cli-0.2.16 → wafer_cli-0.2.18}/tests/test_analytics.py +0 -0
  12. {wafer_cli-0.2.16 → wafer_cli-0.2.18}/tests/test_billing.py +0 -0
  13. {wafer_cli-0.2.16 → wafer_cli-0.2.18}/tests/test_cli_coverage.py +0 -0
  14. {wafer_cli-0.2.16 → wafer_cli-0.2.18}/tests/test_cli_parity_integration.py +0 -0
  15. {wafer_cli-0.2.16 → wafer_cli-0.2.18}/tests/test_config_integration.py +0 -0
  16. {wafer_cli-0.2.16 → wafer_cli-0.2.18}/tests/test_file_operations_integration.py +0 -0
  17. {wafer_cli-0.2.16 → wafer_cli-0.2.18}/tests/test_kernel_scope_cli.py +0 -0
  18. {wafer_cli-0.2.16 → wafer_cli-0.2.18}/tests/test_nsys_analyze.py +0 -0
  19. {wafer_cli-0.2.16 → wafer_cli-0.2.18}/tests/test_nsys_profile.py +0 -0
  20. {wafer_cli-0.2.16 → wafer_cli-0.2.18}/tests/test_output.py +0 -0
  21. {wafer_cli-0.2.16 → wafer_cli-0.2.18}/tests/test_rocprof_compute_integration.py +0 -0
  22. {wafer_cli-0.2.16 → wafer_cli-0.2.18}/tests/test_skill_commands.py +0 -0
  23. {wafer_cli-0.2.16 → wafer_cli-0.2.18}/tests/test_ssh_integration.py +0 -0
  24. {wafer_cli-0.2.16 → wafer_cli-0.2.18}/tests/test_targets_ops.py +0 -0
  25. {wafer_cli-0.2.16 → wafer_cli-0.2.18}/tests/test_wevin_cli.py +0 -0
  26. {wafer_cli-0.2.16 → wafer_cli-0.2.18}/tests/test_workflow_integration.py +0 -0
  27. {wafer_cli-0.2.16 → wafer_cli-0.2.18}/wafer/GUIDE.md +0 -0
  28. {wafer_cli-0.2.16 → wafer_cli-0.2.18}/wafer/__init__.py +0 -0
  29. {wafer_cli-0.2.16 → wafer_cli-0.2.18}/wafer/analytics.py +0 -0
  30. {wafer_cli-0.2.16 → wafer_cli-0.2.18}/wafer/api_client.py +0 -0
  31. {wafer_cli-0.2.16 → wafer_cli-0.2.18}/wafer/autotuner.py +0 -0
  32. {wafer_cli-0.2.16 → wafer_cli-0.2.18}/wafer/billing.py +0 -0
  33. {wafer_cli-0.2.16 → wafer_cli-0.2.18}/wafer/cli.py +0 -0
  34. {wafer_cli-0.2.16 → wafer_cli-0.2.18}/wafer/config.py +0 -0
  35. {wafer_cli-0.2.16 → wafer_cli-0.2.18}/wafer/corpus.py +0 -0
  36. {wafer_cli-0.2.16 → wafer_cli-0.2.18}/wafer/global_config.py +0 -0
  37. {wafer_cli-0.2.16 → wafer_cli-0.2.18}/wafer/gpu_run.py +0 -0
  38. {wafer_cli-0.2.16 → wafer_cli-0.2.18}/wafer/inference.py +0 -0
  39. {wafer_cli-0.2.16 → wafer_cli-0.2.18}/wafer/kernel_scope.py +0 -0
  40. {wafer_cli-0.2.16 → wafer_cli-0.2.18}/wafer/ncu_analyze.py +0 -0
  41. {wafer_cli-0.2.16 → wafer_cli-0.2.18}/wafer/nsys_analyze.py +0 -0
  42. {wafer_cli-0.2.16 → wafer_cli-0.2.18}/wafer/nsys_profile.py +0 -0
  43. {wafer_cli-0.2.16 → wafer_cli-0.2.18}/wafer/output.py +0 -0
  44. {wafer_cli-0.2.16 → wafer_cli-0.2.18}/wafer/problems.py +0 -0
  45. {wafer_cli-0.2.16 → wafer_cli-0.2.18}/wafer/rocprof_compute.py +0 -0
  46. {wafer_cli-0.2.16 → wafer_cli-0.2.18}/wafer/rocprof_sdk.py +0 -0
  47. {wafer_cli-0.2.16 → wafer_cli-0.2.18}/wafer/rocprof_systems.py +0 -0
  48. {wafer_cli-0.2.16 → wafer_cli-0.2.18}/wafer/skills/wafer-guide/SKILL.md +0 -0
  49. {wafer_cli-0.2.16 → wafer_cli-0.2.18}/wafer/ssh_keys.py +0 -0
  50. {wafer_cli-0.2.16 → wafer_cli-0.2.18}/wafer/target_lock.py +0 -0
  51. {wafer_cli-0.2.16 → wafer_cli-0.2.18}/wafer/targets.py +0 -0
  52. {wafer_cli-0.2.16 → wafer_cli-0.2.18}/wafer/targets_ops.py +0 -0
  53. {wafer_cli-0.2.16 → wafer_cli-0.2.18}/wafer/templates/__init__.py +0 -0
  54. {wafer_cli-0.2.16 → wafer_cli-0.2.18}/wafer/templates/ask_docs.py +0 -0
  55. {wafer_cli-0.2.16 → wafer_cli-0.2.18}/wafer/templates/optimize_kernel.py +0 -0
  56. {wafer_cli-0.2.16 → wafer_cli-0.2.18}/wafer/templates/optimize_kernelbench.py +0 -0
  57. {wafer_cli-0.2.16 → wafer_cli-0.2.18}/wafer/templates/trace_analyze.py +0 -0
  58. {wafer_cli-0.2.16 → wafer_cli-0.2.18}/wafer/tracelens.py +0 -0
  59. {wafer_cli-0.2.16 → wafer_cli-0.2.18}/wafer/wevin_cli.py +0 -0
  60. {wafer_cli-0.2.16 → wafer_cli-0.2.18}/wafer_cli.egg-info/dependency_links.txt +0 -0
  61. {wafer_cli-0.2.16 → wafer_cli-0.2.18}/wafer_cli.egg-info/entry_points.txt +0 -0
  62. {wafer_cli-0.2.16 → wafer_cli-0.2.18}/wafer_cli.egg-info/requires.txt +0 -0
  63. {wafer_cli-0.2.16 → wafer_cli-0.2.18}/wafer_cli.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: wafer-cli
3
- Version: 0.2.16
3
+ Version: 0.2.18
4
4
  Summary: CLI tool for running commands on remote GPUs and GPU kernel optimization agent
5
5
  Requires-Python: >=3.11
6
6
  Requires-Dist: typer>=0.12.0
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "wafer-cli"
3
- version = "0.2.16"
3
+ version = "0.2.18"
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 = [
@@ -0,0 +1,193 @@
1
+ """Tests for authentication and credential management.
2
+
3
+ Tests the auth module's token verification and network error handling.
4
+
5
+ Run with: PYTHONPATH=. uv run pytest tests/test_auth.py -v
6
+ """
7
+
8
+ from unittest.mock import MagicMock, patch
9
+
10
+ import httpx
11
+ import pytest
12
+
13
+ # =============================================================================
14
+ # Unit Tests for get_valid_token Network Error Handling
15
+ # =============================================================================
16
+
17
+
18
+ class TestGetValidTokenNetworkErrors:
19
+ """Test that get_valid_token handles network errors gracefully."""
20
+
21
+ def test_returns_none_on_read_timeout(self) -> None:
22
+ """get_valid_token returns None when verify_token times out."""
23
+ from wafer.auth import Credentials, get_valid_token
24
+
25
+ mock_creds = Credentials(
26
+ access_token="test_token",
27
+ refresh_token="test_refresh",
28
+ email="test@example.com",
29
+ )
30
+
31
+ with patch("wafer.auth.load_credentials", return_value=mock_creds):
32
+ with patch("wafer.auth.verify_token", side_effect=httpx.ReadTimeout("timeout")):
33
+ result = get_valid_token()
34
+
35
+ assert result is None
36
+
37
+ def test_returns_none_on_connect_timeout(self) -> None:
38
+ """get_valid_token returns None when connection times out."""
39
+ from wafer.auth import Credentials, get_valid_token
40
+
41
+ mock_creds = Credentials(
42
+ access_token="test_token",
43
+ refresh_token="test_refresh",
44
+ email="test@example.com",
45
+ )
46
+
47
+ with patch("wafer.auth.load_credentials", return_value=mock_creds):
48
+ with patch("wafer.auth.verify_token", side_effect=httpx.ConnectTimeout("timeout")):
49
+ result = get_valid_token()
50
+
51
+ assert result is None
52
+
53
+ def test_returns_none_on_connection_error(self) -> None:
54
+ """get_valid_token returns None when connection is refused."""
55
+ from wafer.auth import Credentials, get_valid_token
56
+
57
+ mock_creds = Credentials(
58
+ access_token="test_token",
59
+ refresh_token="test_refresh",
60
+ email="test@example.com",
61
+ )
62
+
63
+ with patch("wafer.auth.load_credentials", return_value=mock_creds):
64
+ with patch("wafer.auth.verify_token", side_effect=httpx.ConnectError("refused")):
65
+ result = get_valid_token()
66
+
67
+ assert result is None
68
+
69
+ def test_returns_none_on_refresh_timeout(self) -> None:
70
+ """get_valid_token returns None when refresh token request times out."""
71
+ from wafer.auth import Credentials, get_valid_token
72
+
73
+ mock_creds = Credentials(
74
+ access_token="expired_token",
75
+ refresh_token="test_refresh",
76
+ email="test@example.com",
77
+ )
78
+
79
+ # Create a mock 401 response for verify_token
80
+ mock_response = MagicMock()
81
+ mock_response.status_code = 401
82
+ http_error = httpx.HTTPStatusError("401", request=MagicMock(), response=mock_response)
83
+
84
+ with patch("wafer.auth.load_credentials", return_value=mock_creds):
85
+ with patch("wafer.auth.verify_token", side_effect=http_error):
86
+ with patch("wafer.auth.refresh_access_token", side_effect=httpx.ReadTimeout("timeout")):
87
+ result = get_valid_token()
88
+
89
+ assert result is None
90
+
91
+ def test_returns_token_when_verify_succeeds(self) -> None:
92
+ """get_valid_token returns token when verification succeeds."""
93
+ from wafer.auth import Credentials, UserInfo, get_valid_token
94
+
95
+ mock_creds = Credentials(
96
+ access_token="valid_token",
97
+ refresh_token="test_refresh",
98
+ email="test@example.com",
99
+ )
100
+
101
+ mock_user_info = UserInfo(user_id="user123", email="test@example.com")
102
+
103
+ with patch("wafer.auth.load_credentials", return_value=mock_creds):
104
+ with patch("wafer.auth.verify_token", return_value=mock_user_info):
105
+ result = get_valid_token()
106
+
107
+ assert result == "valid_token"
108
+
109
+ def test_returns_none_when_no_credentials(self) -> None:
110
+ """get_valid_token returns None when no credentials are stored."""
111
+ from wafer.auth import get_valid_token
112
+
113
+ with patch("wafer.auth.load_credentials", return_value=None):
114
+ result = get_valid_token()
115
+
116
+ assert result is None
117
+
118
+
119
+ class TestVerifyTokenRaisesOnNetworkError:
120
+ """Document that verify_token raises network errors (caller must handle)."""
121
+
122
+ def test_verify_token_raises_read_timeout(self) -> None:
123
+ """verify_token raises ReadTimeout when API times out."""
124
+ from wafer.auth import verify_token
125
+
126
+ with patch("wafer.auth.get_api_url", return_value="https://api.example.com"):
127
+ with patch("httpx.Client") as mock_client_class:
128
+ mock_client = MagicMock()
129
+ mock_client.__enter__ = MagicMock(return_value=mock_client)
130
+ mock_client.__exit__ = MagicMock(return_value=False)
131
+ mock_client.post.side_effect = httpx.ReadTimeout("timeout")
132
+ mock_client_class.return_value = mock_client
133
+
134
+ with pytest.raises(httpx.ReadTimeout):
135
+ verify_token("test_token")
136
+
137
+ def test_verify_token_raises_connect_error(self) -> None:
138
+ """verify_token raises ConnectError when API is unreachable."""
139
+ from wafer.auth import verify_token
140
+
141
+ with patch("wafer.auth.get_api_url", return_value="https://api.example.com"):
142
+ with patch("httpx.Client") as mock_client_class:
143
+ mock_client = MagicMock()
144
+ mock_client.__enter__ = MagicMock(return_value=mock_client)
145
+ mock_client.__exit__ = MagicMock(return_value=False)
146
+ mock_client.post.side_effect = httpx.ConnectError("refused")
147
+ mock_client_class.return_value = mock_client
148
+
149
+ with pytest.raises(httpx.ConnectError):
150
+ verify_token("test_token")
151
+
152
+
153
+ # =============================================================================
154
+ # Unit Tests for get_auth_headers
155
+ # =============================================================================
156
+
157
+
158
+ class TestGetAuthHeaders:
159
+ """Test get_auth_headers returns empty dict on network errors."""
160
+
161
+ def test_returns_empty_dict_on_network_error(self) -> None:
162
+ """get_auth_headers returns {} when network error occurs."""
163
+ from wafer.auth import Credentials, get_auth_headers
164
+
165
+ mock_creds = Credentials(
166
+ access_token="test_token",
167
+ refresh_token="test_refresh",
168
+ email="test@example.com",
169
+ )
170
+
171
+ with patch("wafer.auth.load_credentials", return_value=mock_creds):
172
+ with patch("wafer.auth.verify_token", side_effect=httpx.ReadTimeout("timeout")):
173
+ result = get_auth_headers()
174
+
175
+ assert result == {}
176
+
177
+ def test_returns_headers_on_success(self) -> None:
178
+ """get_auth_headers returns Bearer token when successful."""
179
+ from wafer.auth import Credentials, UserInfo, get_auth_headers
180
+
181
+ mock_creds = Credentials(
182
+ access_token="valid_token",
183
+ refresh_token="test_refresh",
184
+ email="test@example.com",
185
+ )
186
+
187
+ mock_user_info = UserInfo(user_id="user123", email="test@example.com")
188
+
189
+ with patch("wafer.auth.load_credentials", return_value=mock_creds):
190
+ with patch("wafer.auth.verify_token", return_value=mock_user_info):
191
+ result = get_auth_headers()
192
+
193
+ assert result == {"Authorization": "Bearer valid_token"}
@@ -191,6 +191,10 @@ def get_valid_token() -> str | None:
191
191
  if e.response.status_code != 401:
192
192
  # Not an auth error, re-raise
193
193
  raise
194
+ except httpx.RequestError:
195
+ # Network error (timeout, connection refused, DNS failure, etc.)
196
+ # Cannot verify token - return None to trigger re-login prompt
197
+ return None
194
198
 
195
199
  # Token expired, try refresh
196
200
  if not creds.refresh_token:
@@ -203,6 +207,9 @@ def get_valid_token() -> str | None:
203
207
  except httpx.HTTPStatusError:
204
208
  # Refresh failed, need to re-login
205
209
  return None
210
+ except httpx.RequestError:
211
+ # Network error during refresh - return None to trigger re-login prompt
212
+ return None
206
213
 
207
214
 
208
215
  def _find_free_port() -> int:
@@ -354,7 +354,8 @@ def _build_docker_pip_install_cmd(target: BaremetalTarget | VMTarget) -> str:
354
354
  )
355
355
 
356
356
  # Install uv (fast, reliable) - use pip3 for compatibility
357
- commands.append("pip3 install uv")
357
+ # Use --break-system-packages for Python 3.12+ with PEP 668 externally managed environments
358
+ commands.append("pip3 install --break-system-packages uv")
358
359
 
359
360
  # Install torch with custom index if specified (like Modal's two-phase install)
360
361
  # Use --system --break-system-packages to install to container's Python
@@ -13,7 +13,7 @@ import httpx
13
13
  from .api_client import get_api_url
14
14
  from .auth import get_auth_headers
15
15
 
16
- VALID_STATUSES = {"creating", "running"}
16
+ VALID_STATUSES = {"creating", "running", "error"}
17
17
 
18
18
 
19
19
  def _get_client() -> tuple[str, dict[str, str]]:
@@ -211,17 +211,39 @@ def list_workspaces(json_output: bool = False) -> str:
211
211
  lines = ["Workspaces:", ""]
212
212
  for ws in workspaces:
213
213
  status = ws.get("status", "unknown")
214
- status_icon = {"running": "●", "creating": "◐"}.get(status, "?")
214
+ status_icon = {"running": "●", "creating": "◐", "error": "✗"}.get(status, "?")
215
215
  lines.append(f" {status_icon} {ws['name']} ({ws['id']})")
216
216
  lines.append(f" GPU: {ws.get('gpu_type', 'N/A')} | Image: {ws.get('image', 'N/A')}")
217
- if ws.get("ssh_host") and ws.get("ssh_port") and ws.get("ssh_user"):
217
+
218
+ if status == "error":
219
+ lines.append(
220
+ f" Status: Provisioning failed. Delete and recreate: wafer workspaces delete {ws['name']}"
221
+ )
222
+ elif ws.get("ssh_host") and ws.get("ssh_port") and ws.get("ssh_user"):
223
+ ssh_line = f" SSH: ssh -p {ws['ssh_port']} {ws['ssh_user']}@{ws['ssh_host']}"
224
+ if status == "creating":
225
+ ssh_line += " (finalizing...)"
226
+ lines.append(ssh_line)
227
+ elif status == "running":
218
228
  lines.append(
219
- f" SSH: ssh -p {ws['ssh_port']} {ws['ssh_user']}@{ws['ssh_host']}"
229
+ f" Status: Running but SSH not ready. Try: wafer workspaces delete {ws['name']} && wafer workspaces create {ws['name']} --wait"
220
230
  )
221
231
  else:
222
- lines.append(" SSH: Not ready (run: wafer workspaces ssh <name>)")
232
+ lines.append(" SSH: Not ready (workspace is still creating)")
223
233
  lines.append("")
224
234
 
235
+ # Add SSH tip for users with running workspaces
236
+ has_running_with_ssh = any(
237
+ ws.get("status") == "running" and ws.get("ssh_host")
238
+ for ws in workspaces
239
+ )
240
+ if has_running_with_ssh:
241
+ lines.append("Tip: SSH directly for interactive work. 'exec' is for quick commands only.")
242
+
243
+ has_error = any(ws.get("status") == "error" for ws in workspaces)
244
+ if has_error:
245
+ lines.append("Note: Error workspaces are auto-cleaned after 12 hours.")
246
+
225
247
  return "\n".join(lines)
226
248
 
227
249
 
@@ -441,6 +463,12 @@ def sync_files(
441
463
  f"Workspace {workspace_id} has invalid status '{workspace_status}'. "
442
464
  f"Valid statuses: {VALID_STATUSES}"
443
465
  )
466
+ if workspace_status == "error":
467
+ raise RuntimeError(
468
+ f"Workspace provisioning failed. Delete and recreate:\n"
469
+ f" wafer workspaces delete {workspace_id}\n"
470
+ f" wafer workspaces create {ws.get('name', workspace_id)} --wait"
471
+ )
444
472
  if workspace_status != "running":
445
473
  raise RuntimeError(
446
474
  f"Workspace is {workspace_status}. Wait for it to be running before syncing."
@@ -448,9 +476,14 @@ def sync_files(
448
476
  ssh_host = ws.get("ssh_host")
449
477
  ssh_port = ws.get("ssh_port")
450
478
  ssh_user = ws.get("ssh_user")
451
- assert ssh_host, "Workspace missing ssh_host"
479
+ if not ssh_host or not ssh_port or not ssh_user:
480
+ # Workspace is running but SSH credentials are missing - unusual state
481
+ raise RuntimeError(
482
+ f"Workspace is running but SSH not ready.\n"
483
+ f" Delete and recreate: wafer workspaces delete {workspace_id}\n"
484
+ f" Then: wafer workspaces create {ws.get('name', workspace_id)} --wait"
485
+ )
452
486
  assert isinstance(ssh_port, int) and ssh_port > 0, "Workspace missing valid ssh_port"
453
- assert ssh_user, "Workspace missing ssh_user"
454
487
 
455
488
  # Build rsync command
456
489
  # -a: archive mode (preserves permissions, etc.)
@@ -607,16 +640,34 @@ def get_workspace(workspace_id: str, json_output: bool = False) -> str:
607
640
  f" Last Used: {workspace.get('last_used_at', 'N/A')}",
608
641
  ]
609
642
 
610
- if workspace.get("ssh_host"):
643
+ if status == "error":
644
+ lines.extend([
645
+ "",
646
+ "Provisioning failed. Delete and recreate:",
647
+ f" wafer workspaces delete {workspace['name']}",
648
+ f" wafer workspaces create {workspace['name']} --wait",
649
+ "",
650
+ "Note: Error workspaces are auto-cleaned after 12 hours.",
651
+ ])
652
+ elif workspace.get("ssh_host"):
611
653
  lines.extend([
612
654
  "",
613
655
  "SSH Info:",
614
656
  f" Host: {workspace['ssh_host']}",
615
657
  f" Port: {workspace.get('ssh_port', 22)}",
616
658
  f" User: {workspace.get('ssh_user', 'root')}",
659
+ "",
660
+ "Tip: SSH directly for interactive work. 'exec' is for quick commands only.",
617
661
  ])
618
662
  elif status == "creating":
619
663
  lines.extend(["", "SSH: available once workspace is running"])
664
+ elif status == "running":
665
+ # Running but no SSH credentials - unusual state
666
+ lines.extend([
667
+ "",
668
+ "Status: Running but SSH not ready.",
669
+ f" Delete and recreate: wafer workspaces delete {workspace['name']}",
670
+ ])
620
671
 
621
672
  return "\n".join(lines)
622
673
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: wafer-cli
3
- Version: 0.2.16
3
+ Version: 0.2.18
4
4
  Summary: CLI tool for running commands on remote GPUs and GPU kernel optimization agent
5
5
  Requires-Python: >=3.11
6
6
  Requires-Dist: typer>=0.12.0
@@ -1,6 +1,7 @@
1
1
  README.md
2
2
  pyproject.toml
3
3
  tests/test_analytics.py
4
+ tests/test_auth.py
4
5
  tests/test_billing.py
5
6
  tests/test_cli_coverage.py
6
7
  tests/test_cli_parity_integration.py
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes