hivekit 0.3.0__tar.gz → 0.4.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 (48) hide show
  1. {hivekit-0.3.0 → hivekit-0.4.0}/PKG-INFO +2 -1
  2. {hivekit-0.3.0 → hivekit-0.4.0}/pyproject.toml +2 -1
  3. {hivekit-0.3.0 → hivekit-0.4.0}/src/cli/config.py +80 -38
  4. {hivekit-0.3.0 → hivekit-0.4.0}/src/cli/http_client.py +69 -0
  5. {hivekit-0.3.0 → hivekit-0.4.0}/src/cli/main.py +362 -28
  6. hivekit-0.4.0/src/cli/utils/terminal.py +347 -0
  7. {hivekit-0.3.0 → hivekit-0.4.0}/src/cli/utils/time.py +5 -6
  8. {hivekit-0.3.0 → hivekit-0.4.0}/src/hivekit.egg-info/PKG-INFO +2 -1
  9. {hivekit-0.3.0 → hivekit-0.4.0}/src/hivekit.egg-info/SOURCES.txt +1 -0
  10. {hivekit-0.3.0 → hivekit-0.4.0}/src/hivekit.egg-info/requires.txt +1 -0
  11. {hivekit-0.3.0 → hivekit-0.4.0}/tests/test_config.py +97 -35
  12. {hivekit-0.3.0 → hivekit-0.4.0}/tests/test_experiment.py +1 -2
  13. {hivekit-0.3.0 → hivekit-0.4.0}/tests/test_http_client.py +165 -0
  14. {hivekit-0.3.0 → hivekit-0.4.0}/tests/test_main.py +235 -0
  15. {hivekit-0.3.0 → hivekit-0.4.0}/tests/test_overrides.py +2 -2
  16. {hivekit-0.3.0 → hivekit-0.4.0}/LICENSE +0 -0
  17. {hivekit-0.3.0 → hivekit-0.4.0}/README.md +0 -0
  18. {hivekit-0.3.0 → hivekit-0.4.0}/setup.cfg +0 -0
  19. {hivekit-0.3.0 → hivekit-0.4.0}/src/cli/__init__.py +0 -0
  20. {hivekit-0.3.0 → hivekit-0.4.0}/src/cli/auth/__init__.py +0 -0
  21. {hivekit-0.3.0 → hivekit-0.4.0}/src/cli/auth/auth_utils.py +0 -0
  22. {hivekit-0.3.0 → hivekit-0.4.0}/src/cli/auth/credential_store.py +0 -0
  23. {hivekit-0.3.0 → hivekit-0.4.0}/src/cli/auth/login_page.py +0 -0
  24. {hivekit-0.3.0 → hivekit-0.4.0}/src/cli/auth/logo.svg +0 -0
  25. {hivekit-0.3.0 → hivekit-0.4.0}/src/cli/auth/oidc_flow.py +0 -0
  26. {hivekit-0.3.0 → hivekit-0.4.0}/src/cli/auth/session_manager.py +0 -0
  27. {hivekit-0.3.0 → hivekit-0.4.0}/src/cli/auth/token_revoker.py +0 -0
  28. {hivekit-0.3.0 → hivekit-0.4.0}/src/cli/completers.py +0 -0
  29. {hivekit-0.3.0 → hivekit-0.4.0}/src/cli/experiment.py +0 -0
  30. {hivekit-0.3.0 → hivekit-0.4.0}/src/cli/utils/__init__.py +0 -0
  31. {hivekit-0.3.0 → hivekit-0.4.0}/src/cli/utils/archive.py +0 -0
  32. {hivekit-0.3.0 → hivekit-0.4.0}/src/cli/utils/config_paths.py +0 -0
  33. {hivekit-0.3.0 → hivekit-0.4.0}/src/cli/utils/config_sync.py +0 -0
  34. {hivekit-0.3.0 → hivekit-0.4.0}/src/cli/utils/docker.py +0 -0
  35. {hivekit-0.3.0 → hivekit-0.4.0}/src/cli/utils/logger.py +0 -0
  36. {hivekit-0.3.0 → hivekit-0.4.0}/src/cli/utils/upload.py +0 -0
  37. {hivekit-0.3.0 → hivekit-0.4.0}/src/cli/utils/url_utils.py +0 -0
  38. {hivekit-0.3.0 → hivekit-0.4.0}/src/cli/version.py +0 -0
  39. {hivekit-0.3.0 → hivekit-0.4.0}/src/hivekit.egg-info/dependency_links.txt +0 -0
  40. {hivekit-0.3.0 → hivekit-0.4.0}/src/hivekit.egg-info/entry_points.txt +0 -0
  41. {hivekit-0.3.0 → hivekit-0.4.0}/src/hivekit.egg-info/top_level.txt +0 -0
  42. {hivekit-0.3.0 → hivekit-0.4.0}/tests/test_completers.py +0 -0
  43. {hivekit-0.3.0 → hivekit-0.4.0}/tests/test_config_sync.py +0 -0
  44. {hivekit-0.3.0 → hivekit-0.4.0}/tests/test_logout.py +0 -0
  45. {hivekit-0.3.0 → hivekit-0.4.0}/tests/test_push_image.py +0 -0
  46. {hivekit-0.3.0 → hivekit-0.4.0}/tests/test_time_utils.py +0 -0
  47. {hivekit-0.3.0 → hivekit-0.4.0}/tests/test_upload.py +0 -0
  48. {hivekit-0.3.0 → hivekit-0.4.0}/tests/test_version.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: hivekit
3
- Version: 0.3.0
3
+ Version: 0.4.0
4
4
  Summary: Toolkits for using hive agents, including CLI and SDK.
5
5
  License:
6
6
  Apache License
@@ -223,6 +223,7 @@ Requires-Dist: keyring>=25.0.0
223
223
  Requires-Dist: py-machineid>=0.6.0
224
224
  Requires-Dist: cryptography>=42.0.0
225
225
  Requires-Dist: ruamel.yaml>=0.18
226
+ Requires-Dist: websocket-client>=1.7.0
226
227
  Dynamic: license-file
227
228
 
228
229
  ```
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "hivekit"
7
- version = "0.3.0"
7
+ version = "0.4.0"
8
8
  description = "Toolkits for using hive agents, including CLI and SDK."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.12"
@@ -25,6 +25,7 @@ dependencies = [
25
25
  "py-machineid>=0.6.0",
26
26
  "cryptography>=42.0.0",
27
27
  "ruamel.yaml>=0.18",
28
+ "websocket-client>=1.7.0",
28
29
  ]
29
30
 
30
31
  [dependency-groups]
@@ -20,7 +20,7 @@ from pydantic import (
20
20
  )
21
21
 
22
22
  from cli.utils.logger import set_log_level
23
- from cli.utils.time import now_2_hash
23
+ from cli.utils.time import unique_suffix
24
24
 
25
25
  logger = logging.getLogger("hivekit")
26
26
 
@@ -29,11 +29,35 @@ class BaseModel(pydantic.BaseModel):
29
29
  model_config = ConfigDict(extra="forbid", coerce_numbers_to_str=True)
30
30
 
31
31
 
32
- # Per-sandbox resource caps. cpu is in whole cores; memory is in bytes (128Gi).
33
- # Values are k8s quantities (e.g. "500m", "2Gi"), normalized via parse_quantity
34
- # before comparison so every equivalent spelling is bounded uniformly.
35
- MAX_CPU_CORES = 64
36
- MAX_MEMORY_BYTES = 128 * 1024**3
32
+ # Kubernetes caps DNS labels and label values at 63 bytes. The operator derives
33
+ # object names/labels by appending suffixes to the experiment name (the longest
34
+ # is "-coordinator"), so reserve that headroom to keep the derived names valid.
35
+ K8S_LABEL_MAX_LENGTH = 63
36
+ RESERVED_NAME_SUFFIX_LENGTH = len("-coordinator")
37
+ MAX_EXPERIMENT_NAME_LENGTH = K8S_LABEL_MAX_LENGTH - RESERVED_NAME_SUFFIX_LENGTH
38
+ # A name ending in '-' gets a random uniqueness suffix appended (see
39
+ # unique_suffix), which eats into the budget the user actually controls (their
40
+ # typed base name).
41
+ AUTO_HASH_LENGTH = 7
42
+ MAX_EXPERIMENT_BASE_NAME_LENGTH = MAX_EXPERIMENT_NAME_LENGTH - AUTO_HASH_LENGTH
43
+
44
+ # Accelerator (GPU) types the cloud providers know how to schedule. Mirrors the
45
+ # controller's LabelsByAcceleratorType switch. Unknown types are not rejected —
46
+ # a provider may support more than we list here — but they trigger a warning.
47
+ KNOWN_ACCELERATOR_TYPES = frozenset(
48
+ {
49
+ "a10",
50
+ "a100",
51
+ "a100-40gb",
52
+ "a100-80gb",
53
+ "b200",
54
+ "h100",
55
+ "h200",
56
+ "l4",
57
+ "l40s",
58
+ "t4",
59
+ }
60
+ )
37
61
 
38
62
 
39
63
  class ResourceConfig(BaseModel):
@@ -62,8 +86,6 @@ class ResourceConfig(BaseModel):
62
86
  raise ValueError(f"cpu is not a valid quantity (e.g. '1', '500m'), got: '{v}'")
63
87
  if cores <= 0:
64
88
  raise ValueError(f"cpu must be positive, got: '{v}'")
65
- if cores > MAX_CPU_CORES:
66
- raise ValueError(f"cpu must be at most {MAX_CPU_CORES} cores, got: '{v}'")
67
89
  return v
68
90
 
69
91
  @field_validator("memory")
@@ -75,10 +97,40 @@ class ResourceConfig(BaseModel):
75
97
  raise ValueError(f"memory is not a valid quantity (e.g. '2Gi', '512Mi'), got: '{v}'")
76
98
  if memory_bytes <= 0:
77
99
  raise ValueError(f"memory must be positive, got: '{v}'")
78
- if memory_bytes > MAX_MEMORY_BYTES:
79
- raise ValueError(f"memory must be at most 128Gi, got: '{v}'")
80
100
  return v
81
101
 
102
+ @field_validator("accelerators")
103
+ @classmethod
104
+ def validate_accelerators(cls, v):
105
+ # NOTE: KNOWN_ACCELERATOR_TYPES is a hardcoded mirror of the controller's
106
+ # switch, so it can drift out of sync. A better approach would be a new
107
+ # backend endpoint that lists the currently available GPU types, letting
108
+ # us validate against the live source of truth instead of this static set.
109
+ if v is None:
110
+ return v
111
+ parts = v.split(":")
112
+ if len(parts) != 2:
113
+ raise ValueError(
114
+ f"accelerators must be in '<type>:<count>' format (e.g. 'a100-80gb:8'), got: '{v}'"
115
+ )
116
+ gpu_type, count = parts[0].strip(), parts[1].strip()
117
+ if not gpu_type:
118
+ raise ValueError(f"accelerators type must not be empty, got: '{v}'")
119
+ try:
120
+ n = int(count)
121
+ except ValueError:
122
+ raise ValueError(f"accelerators count must be an integer, got: '{count}'")
123
+ if n <= 0:
124
+ raise ValueError(f"accelerators count must be positive, got: '{count}'")
125
+ if gpu_type not in KNOWN_ACCELERATOR_TYPES:
126
+ logger.warning(
127
+ "accelerators type '%s' is not among the known GPU types (%s); "
128
+ "any available GPUs may be used. Please confirm with the Hiverge team.",
129
+ gpu_type,
130
+ ", ".join(sorted(KNOWN_ACCELERATOR_TYPES)),
131
+ )
132
+ return f"{gpu_type}:{n}"
133
+
82
134
 
83
135
  class KeyValueSet(BaseModel):
84
136
  name: str
@@ -225,24 +277,14 @@ class RepoConfig(BaseModel):
225
277
  return v
226
278
 
227
279
 
228
- # Platform caps. One sandbox is provisioned per agent, so num_agents is bounded
229
- # by the max number of sandboxes. max_runtime_seconds is bounded by the maximum
230
- # runtime of an experiment (6 hours).
231
- MAX_SANDBOXES = 32
232
- MAX_RUNTIME_SECONDS = 6 * 60 * 60
233
-
234
-
235
280
  class RuntimeConfig(BaseModel):
236
281
  num_agents: int = Field(
237
282
  default=1,
238
283
  description="Number of agents to use in the experiment. Default to 1.",
239
284
  )
240
285
  max_runtime_seconds: int = Field(
241
- default=MAX_RUNTIME_SECONDS,
242
- description=(
243
- "Maximum runtime for the experiment in seconds. -1 means no limit. "
244
- f"Defaults to the platform maximum of {MAX_RUNTIME_SECONDS} (6 hours)."
245
- ),
286
+ default=-1,
287
+ description="Maximum runtime for the experiment in seconds. -1 means no limit.",
246
288
  )
247
289
  max_iterations: int = Field(
248
290
  default=-1,
@@ -256,30 +298,21 @@ class RuntimeConfig(BaseModel):
256
298
  @field_validator("num_agents")
257
299
  @classmethod
258
300
  def validate_num_agents(cls, v):
259
- # One sandbox per agent; the platform provisions at most MAX_SANDBOXES.
301
+ # One sandbox is provisioned per agent.
260
302
  if v < 1:
261
303
  raise ValueError(f"num_agents must be at least 1, got {v}.")
262
- if v > MAX_SANDBOXES:
263
- raise ValueError(
264
- f"num_agents must be at most {MAX_SANDBOXES} (one sandbox per agent), got {v}."
265
- )
266
304
  return v
267
305
 
268
306
  @field_validator("max_runtime_seconds")
269
307
  @classmethod
270
308
  def validate_max_runtime_seconds(cls, v):
271
- # -1 is the sentinel for "no limit"; any positive value is capped at the
272
- # platform's maximum experiment runtime.
309
+ # -1 is the sentinel for "no limit"
273
310
  if v == -1:
274
311
  return v
275
312
  if v < 1:
276
313
  raise ValueError(
277
314
  f"max_runtime_seconds must be -1 (no limit) or a positive integer, got {v}."
278
315
  )
279
- if v > MAX_RUNTIME_SECONDS:
280
- raise ValueError(
281
- f"max_runtime_seconds must be at most {MAX_RUNTIME_SECONDS} (6 hours), got {v}."
282
- )
283
316
  return v
284
317
 
285
318
 
@@ -295,15 +328,24 @@ class HiveConfig(BaseModel):
295
328
  @field_validator("experiment_name")
296
329
  @classmethod
297
330
  def validate_experiment_name(cls, v):
298
- if v.endswith("-"):
299
- v = f"{v}{now_2_hash()}"
331
+ base_name = v
332
+ auto_hashed = v.endswith("-")
333
+ if auto_hashed:
334
+ v = f"{v}{unique_suffix()}"
300
335
  if not re.fullmatch(r"[a-z0-9]([a-z0-9-]*[a-z0-9])?", v):
301
336
  raise ValueError(
302
- f"Experiment name must be a valid DNS label (RFC 1123): [a-z0-9] separated by '-', got: '{v}'"
337
+ f"Experiment name must be a valid DNS label (RFC 1123): [a-z0-9] separated by '-', got: '{base_name}'"
303
338
  )
304
- if len(v) > 63:
339
+ if len(v) > MAX_EXPERIMENT_NAME_LENGTH:
340
+ if auto_hashed:
341
+ raise ValueError(
342
+ f"Experiment name must be no more than {MAX_EXPERIMENT_BASE_NAME_LENGTH} "
343
+ f"characters when it ends in '-', got {len(base_name)}: '{base_name}'. "
344
+ f"Shorten the name or drop the trailing '-'."
345
+ )
305
346
  raise ValueError(
306
- f"Experiment name must be no more than 63 characters, got {len(v)}: '{v}'"
347
+ f"Experiment name must be no more than {MAX_EXPERIMENT_NAME_LENGTH} "
348
+ f"characters, got {len(v)}: '{v}'. Shorten the name."
307
349
  )
308
350
  return v
309
351
 
@@ -49,6 +49,14 @@ class StreamNotReadyError(Exception):
49
49
  """Raised when the log source is not yet available for streaming."""
50
50
 
51
51
 
52
+ class ShellNotFoundError(Exception):
53
+ """Raised when a shell no longer exists server-side (HTTP 404).
54
+
55
+ Distinct from a transient failure: the shell's CR is gone (deleted, reaped,
56
+ or never existed), so the caller must give up rather than retry.
57
+ """
58
+
59
+
52
60
  class HttpClient:
53
61
  """
54
62
  HTTP client for communicating with the Hive backend server.
@@ -89,6 +97,67 @@ class HttpClient:
89
97
  response = self._send("post", "/experiments", "create experiment", json=experiment_data)
90
98
  return response.json()
91
99
 
100
+ def create_shell(self, shell_data: Dict[str, Any]) -> Dict[str, Any]:
101
+ """
102
+ Create a new interactive shell.
103
+
104
+ Args:
105
+ shell_data: Body of the form
106
+ {"sandbox": <SandboxSpec>, "ttlSeconds": <int, optional>}.
107
+
108
+ Returns:
109
+ Server payload {"shellId": <str>, "name": <k8s Shell CR name>}.
110
+ """
111
+ response = self._send("post", "/shells", "create shell", json=shell_data)
112
+ return response.json()
113
+
114
+ def get_shell(self, shell_id: str) -> Dict[str, Any]:
115
+ """
116
+ Get a shell's readiness status.
117
+
118
+ Returns:
119
+ Server payload {"shellId", "phase", "podName", "ready": bool}.
120
+
121
+ Raises:
122
+ ShellNotFoundError: the shell is gone (HTTP 404) -- terminal, do not
123
+ retry.
124
+ Exception: any other failure (5xx, connection error) -- an "unknown"
125
+ status the caller may retry within a bounded window.
126
+ """
127
+ url = f"{self.base_url}/shells/{shell_id}"
128
+ response = self._request(method="get", url=url, headers=self._get_headers())
129
+ # 404 is the terminal "gone" signal, distinct from a retryable failure.
130
+ if response.status_code == HTTPStatus.NOT_FOUND:
131
+ raise ShellNotFoundError(shell_id)
132
+ # Any other non-2xx is "unknown": reuse _send()'s formatting so the detail
133
+ # comes from the server's JSON `error` field, not the raw body.
134
+ try:
135
+ response.raise_for_status()
136
+ except requests.exceptions.HTTPError as e:
137
+ raise Exception(f"Failed to get shell: {_http_error_detail(e)}") from e
138
+ return response.json()
139
+
140
+ def bearer_token(self) -> str:
141
+ """Return a currently-valid bearer access token for out-of-band use.
142
+
143
+ The WebSocket handshake attaches the token by hand and bypasses both the
144
+ session's automatic ``Authorization`` injection and the 401 re-auth
145
+ retry, so the token is refreshed here first (if expired/near expiry) to
146
+ avoid handing a stale credential to the ALB/Tyk edge.
147
+ """
148
+ token = getattr(self._session, "token", None)
149
+ if not token:
150
+ raise AuthenticationError(
151
+ "No access token available. Please run 'hive login' to authenticate."
152
+ )
153
+ try:
154
+ self._session.ensure_active_token(token)
155
+ except OAuthError as e:
156
+ raise AuthenticationError(
157
+ "Your credentials have expired. Please run 'hive login' to re-authenticate."
158
+ ) from e
159
+ return self._session.token["access_token"]
160
+
92
161
  def get_push_credentials(self, reference: str) -> Dict[str, Any]:
93
162
  """
94
163
  Request short-lived registry credentials for pushing an image.