hivekit 0.3.0__tar.gz → 0.3.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 (47) hide show
  1. {hivekit-0.3.0 → hivekit-0.3.1}/PKG-INFO +1 -1
  2. {hivekit-0.3.0 → hivekit-0.3.1}/pyproject.toml +1 -1
  3. {hivekit-0.3.0 → hivekit-0.3.1}/src/cli/config.py +105 -36
  4. {hivekit-0.3.0 → hivekit-0.3.1}/src/hivekit.egg-info/PKG-INFO +1 -1
  5. {hivekit-0.3.0 → hivekit-0.3.1}/src/hivekit.egg-info/top_level.txt +1 -0
  6. {hivekit-0.3.0 → hivekit-0.3.1}/tests/test_config.py +97 -35
  7. {hivekit-0.3.0 → hivekit-0.3.1}/tests/test_experiment.py +1 -2
  8. {hivekit-0.3.0 → hivekit-0.3.1}/tests/test_overrides.py +2 -2
  9. {hivekit-0.3.0 → hivekit-0.3.1}/LICENSE +0 -0
  10. {hivekit-0.3.0 → hivekit-0.3.1}/README.md +0 -0
  11. {hivekit-0.3.0 → hivekit-0.3.1}/setup.cfg +0 -0
  12. {hivekit-0.3.0 → hivekit-0.3.1}/src/cli/__init__.py +0 -0
  13. {hivekit-0.3.0 → hivekit-0.3.1}/src/cli/auth/__init__.py +0 -0
  14. {hivekit-0.3.0 → hivekit-0.3.1}/src/cli/auth/auth_utils.py +0 -0
  15. {hivekit-0.3.0 → hivekit-0.3.1}/src/cli/auth/credential_store.py +0 -0
  16. {hivekit-0.3.0 → hivekit-0.3.1}/src/cli/auth/login_page.py +0 -0
  17. {hivekit-0.3.0 → hivekit-0.3.1}/src/cli/auth/logo.svg +0 -0
  18. {hivekit-0.3.0 → hivekit-0.3.1}/src/cli/auth/oidc_flow.py +0 -0
  19. {hivekit-0.3.0 → hivekit-0.3.1}/src/cli/auth/session_manager.py +0 -0
  20. {hivekit-0.3.0 → hivekit-0.3.1}/src/cli/auth/token_revoker.py +0 -0
  21. {hivekit-0.3.0 → hivekit-0.3.1}/src/cli/completers.py +0 -0
  22. {hivekit-0.3.0 → hivekit-0.3.1}/src/cli/experiment.py +0 -0
  23. {hivekit-0.3.0 → hivekit-0.3.1}/src/cli/http_client.py +0 -0
  24. {hivekit-0.3.0 → hivekit-0.3.1}/src/cli/main.py +0 -0
  25. {hivekit-0.3.0 → hivekit-0.3.1}/src/cli/utils/__init__.py +0 -0
  26. {hivekit-0.3.0 → hivekit-0.3.1}/src/cli/utils/archive.py +0 -0
  27. {hivekit-0.3.0 → hivekit-0.3.1}/src/cli/utils/config_paths.py +0 -0
  28. {hivekit-0.3.0 → hivekit-0.3.1}/src/cli/utils/config_sync.py +0 -0
  29. {hivekit-0.3.0 → hivekit-0.3.1}/src/cli/utils/docker.py +0 -0
  30. {hivekit-0.3.0 → hivekit-0.3.1}/src/cli/utils/logger.py +0 -0
  31. {hivekit-0.3.0 → hivekit-0.3.1}/src/cli/utils/time.py +0 -0
  32. {hivekit-0.3.0 → hivekit-0.3.1}/src/cli/utils/upload.py +0 -0
  33. {hivekit-0.3.0 → hivekit-0.3.1}/src/cli/utils/url_utils.py +0 -0
  34. {hivekit-0.3.0 → hivekit-0.3.1}/src/cli/version.py +0 -0
  35. {hivekit-0.3.0 → hivekit-0.3.1}/src/hivekit.egg-info/SOURCES.txt +0 -0
  36. {hivekit-0.3.0 → hivekit-0.3.1}/src/hivekit.egg-info/dependency_links.txt +0 -0
  37. {hivekit-0.3.0 → hivekit-0.3.1}/src/hivekit.egg-info/entry_points.txt +0 -0
  38. {hivekit-0.3.0 → hivekit-0.3.1}/src/hivekit.egg-info/requires.txt +0 -0
  39. {hivekit-0.3.0 → hivekit-0.3.1}/tests/test_completers.py +0 -0
  40. {hivekit-0.3.0 → hivekit-0.3.1}/tests/test_config_sync.py +0 -0
  41. {hivekit-0.3.0 → hivekit-0.3.1}/tests/test_http_client.py +0 -0
  42. {hivekit-0.3.0 → hivekit-0.3.1}/tests/test_logout.py +0 -0
  43. {hivekit-0.3.0 → hivekit-0.3.1}/tests/test_main.py +0 -0
  44. {hivekit-0.3.0 → hivekit-0.3.1}/tests/test_push_image.py +0 -0
  45. {hivekit-0.3.0 → hivekit-0.3.1}/tests/test_time_utils.py +0 -0
  46. {hivekit-0.3.0 → hivekit-0.3.1}/tests/test_upload.py +0 -0
  47. {hivekit-0.3.0 → hivekit-0.3.1}/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.3.1
4
4
  Summary: Toolkits for using hive agents, including CLI and SDK.
5
5
  License:
6
6
  Apache License
@@ -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.3.1"
8
8
  description = "Toolkits for using hive agents, including CLI and SDK."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.12"
@@ -29,11 +29,62 @@ 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 hash appended (see now_2_hash),
39
+ # which eats into the budget the user actually controls (their typed base name).
40
+ AUTO_HASH_LENGTH = 7
41
+ MAX_EXPERIMENT_BASE_NAME_LENGTH = MAX_EXPERIMENT_NAME_LENGTH - AUTO_HASH_LENGTH
42
+
43
+ # Accelerator (GPU) types the cloud providers know how to schedule. Mirrors the
44
+ # controller's LabelsByAcceleratorType switch. Unknown types are not rejected —
45
+ # a provider may support more than we list here — but they trigger a warning.
46
+ KNOWN_ACCELERATOR_TYPES = frozenset(
47
+ {
48
+ "a10",
49
+ "a100",
50
+ "a100-40gb",
51
+ "a100-80gb",
52
+ "b200",
53
+ "h100",
54
+ "h200",
55
+ "l4",
56
+ "l40s",
57
+ "t4",
58
+ }
59
+ )
60
+
61
+ # Kubernetes caps DNS labels and label values at 63 bytes. The operator derives
62
+ # object names/labels by appending suffixes to the experiment name (the longest
63
+ # is "-coordinator"), so reserve that headroom to keep the derived names valid.
64
+ K8S_LABEL_MAX_LENGTH = 63
65
+ RESERVED_NAME_SUFFIX_LENGTH = len("-coordinator")
66
+ MAX_EXPERIMENT_NAME_LENGTH = K8S_LABEL_MAX_LENGTH - RESERVED_NAME_SUFFIX_LENGTH
67
+ # A name ending in '-' gets a random uniqueness hash appended (see now_2_hash),
68
+ # which eats into the budget the user actually controls (their typed base name).
69
+ AUTO_HASH_LENGTH = 7
70
+ MAX_EXPERIMENT_BASE_NAME_LENGTH = MAX_EXPERIMENT_NAME_LENGTH - AUTO_HASH_LENGTH
71
+ # Accelerator (GPU) types the cloud providers know how to schedule. Mirrors the
72
+ # controller's LabelsByAcceleratorType switch. Unknown types are not rejected —
73
+ # a provider may support more than we list here — but they trigger a warning.
74
+ KNOWN_ACCELERATOR_TYPES = frozenset(
75
+ {
76
+ "a10",
77
+ "a100",
78
+ "a100-40gb",
79
+ "a100-80gb",
80
+ "b200",
81
+ "h100",
82
+ "h200",
83
+ "l4",
84
+ "l40s",
85
+ "t4",
86
+ }
87
+ )
37
88
 
38
89
 
39
90
  class ResourceConfig(BaseModel):
@@ -62,8 +113,6 @@ class ResourceConfig(BaseModel):
62
113
  raise ValueError(f"cpu is not a valid quantity (e.g. '1', '500m'), got: '{v}'")
63
114
  if cores <= 0:
64
115
  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
116
  return v
68
117
 
69
118
  @field_validator("memory")
@@ -75,10 +124,40 @@ class ResourceConfig(BaseModel):
75
124
  raise ValueError(f"memory is not a valid quantity (e.g. '2Gi', '512Mi'), got: '{v}'")
76
125
  if memory_bytes <= 0:
77
126
  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
127
  return v
81
128
 
129
+ @field_validator("accelerators")
130
+ @classmethod
131
+ def validate_accelerators(cls, v):
132
+ # NOTE: KNOWN_ACCELERATOR_TYPES is a hardcoded mirror of the controller's
133
+ # switch, so it can drift out of sync. A better approach would be a new
134
+ # backend endpoint that lists the currently available GPU types, letting
135
+ # us validate against the live source of truth instead of this static set.
136
+ if v is None:
137
+ return v
138
+ parts = v.split(":")
139
+ if len(parts) != 2:
140
+ raise ValueError(
141
+ f"accelerators must be in '<type>:<count>' format (e.g. 'a100-80gb:8'), got: '{v}'"
142
+ )
143
+ gpu_type, count = parts[0].strip(), parts[1].strip()
144
+ if not gpu_type:
145
+ raise ValueError(f"accelerators type must not be empty, got: '{v}'")
146
+ try:
147
+ n = int(count)
148
+ except ValueError:
149
+ raise ValueError(f"accelerators count must be an integer, got: '{count}'")
150
+ if n <= 0:
151
+ raise ValueError(f"accelerators count must be positive, got: '{count}'")
152
+ if gpu_type not in KNOWN_ACCELERATOR_TYPES:
153
+ logger.warning(
154
+ "accelerators type '%s' is not among the known GPU types (%s); "
155
+ "any available GPUs may be used. Please confirm with the Hiverge team.",
156
+ gpu_type,
157
+ ", ".join(sorted(KNOWN_ACCELERATOR_TYPES)),
158
+ )
159
+ return f"{gpu_type}:{n}"
160
+
82
161
 
83
162
  class KeyValueSet(BaseModel):
84
163
  name: str
@@ -225,24 +304,14 @@ class RepoConfig(BaseModel):
225
304
  return v
226
305
 
227
306
 
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
307
  class RuntimeConfig(BaseModel):
236
308
  num_agents: int = Field(
237
309
  default=1,
238
310
  description="Number of agents to use in the experiment. Default to 1.",
239
311
  )
240
312
  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
- ),
313
+ default=-1,
314
+ description="Maximum runtime for the experiment in seconds. -1 means no limit.",
246
315
  )
247
316
  max_iterations: int = Field(
248
317
  default=-1,
@@ -256,30 +325,21 @@ class RuntimeConfig(BaseModel):
256
325
  @field_validator("num_agents")
257
326
  @classmethod
258
327
  def validate_num_agents(cls, v):
259
- # One sandbox per agent; the platform provisions at most MAX_SANDBOXES.
328
+ # One sandbox is provisioned per agent.
260
329
  if v < 1:
261
330
  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
331
  return v
267
332
 
268
333
  @field_validator("max_runtime_seconds")
269
334
  @classmethod
270
335
  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.
336
+ # -1 is the sentinel for "no limit"
273
337
  if v == -1:
274
338
  return v
275
339
  if v < 1:
276
340
  raise ValueError(
277
341
  f"max_runtime_seconds must be -1 (no limit) or a positive integer, got {v}."
278
342
  )
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
343
  return v
284
344
 
285
345
 
@@ -295,15 +355,24 @@ class HiveConfig(BaseModel):
295
355
  @field_validator("experiment_name")
296
356
  @classmethod
297
357
  def validate_experiment_name(cls, v):
298
- if v.endswith("-"):
358
+ base_name = v
359
+ auto_hashed = v.endswith("-")
360
+ if auto_hashed:
299
361
  v = f"{v}{now_2_hash()}"
300
362
  if not re.fullmatch(r"[a-z0-9]([a-z0-9-]*[a-z0-9])?", v):
301
363
  raise ValueError(
302
- f"Experiment name must be a valid DNS label (RFC 1123): [a-z0-9] separated by '-', got: '{v}'"
364
+ f"Experiment name must be a valid DNS label (RFC 1123): [a-z0-9] separated by '-', got: '{base_name}'"
303
365
  )
304
- if len(v) > 63:
366
+ if len(v) > MAX_EXPERIMENT_NAME_LENGTH:
367
+ if auto_hashed:
368
+ raise ValueError(
369
+ f"Experiment name must be no more than {MAX_EXPERIMENT_BASE_NAME_LENGTH} "
370
+ f"characters when it ends in '-', got {len(base_name)}: '{base_name}'. "
371
+ f"Shorten the name or drop the trailing '-'."
372
+ )
305
373
  raise ValueError(
306
- f"Experiment name must be no more than 63 characters, got {len(v)}: '{v}'"
374
+ f"Experiment name must be no more than {MAX_EXPERIMENT_NAME_LENGTH} "
375
+ f"characters, got {len(v)}: '{v}'. Shorten the name."
307
376
  )
308
377
  return v
309
378
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: hivekit
3
- Version: 0.3.0
3
+ Version: 0.3.1
4
4
  Summary: Toolkits for using hive agents, including CLI and SDK.
5
5
  License:
6
6
  Apache License
@@ -10,8 +10,6 @@ import pytest
10
10
  import yaml
11
11
 
12
12
  from cli.config import (
13
- MAX_RUNTIME_SECONDS,
14
- MAX_SANDBOXES,
15
13
  HiveConfig,
16
14
  RepoConfig,
17
15
  ResourceConfig,
@@ -203,7 +201,8 @@ class TestFileExistenceValidation:
203
201
 
204
202
 
205
203
  class TestExperimentNameValidation:
206
- """RFC 1123 DNS label validation: [a-z0-9], separated by '-', max 63 chars."""
204
+ """RFC 1123 DNS label validation: [a-z0-9], separated by '-', max 51 chars
205
+ (63-byte K8s cap minus the operator's '-coordinator' suffix)."""
207
206
 
208
207
  def test_valid_name(self, base_config):
209
208
  config = load_config(base_config, overrides=["experiment_name=my-exp-1"])
@@ -229,15 +228,42 @@ class TestExperimentNameValidation:
229
228
  with pytest.raises(Exception, match="DNS label"):
230
229
  load_config(base_config, overrides=["experiment_name=my.exp"])
231
230
 
231
+ def test_dns_error_reports_typed_name_not_expanded(self, base_config):
232
+ # A trailing '-' triggers the auto-hash, but an invalid-char error must
233
+ # report what the user typed, not the non-deterministic expanded value.
234
+ with pytest.raises(Exception, match="got: 'my_exp-'"):
235
+ load_config(base_config, overrides=["experiment_name=my_exp-"])
236
+
232
237
  def test_rejects_too_long(self, base_config):
233
- with pytest.raises(Exception, match="63 characters"):
234
- load_config(base_config, overrides=["experiment_name=" + "a" * 64])
238
+ # 52 chars exceeds the effective limit (63 minus the operator's
239
+ # "-coordinator" suffix), even though it is under the raw K8s 63 cap.
240
+ with pytest.raises(Exception, match="no more than 51 characters"):
241
+ load_config(base_config, overrides=["experiment_name=" + "a" * 52])
242
+
243
+ def test_accepts_max_length(self, base_config):
244
+ config = load_config(base_config, overrides=["experiment_name=" + "a" * 51])
245
+ assert config.experiment_name == "a" * 51
246
+
247
+ def test_rejects_too_long_with_auto_hash(self, base_config):
248
+ # A trailing '-' name gets a 7-char hash appended, so the base budget is
249
+ # 44. 45 chars (incl. the '-') is under the plain 51 limit but tips over
250
+ # once the hash lands. The error must report the base, not the expansion.
251
+ name = "a" * 44 + "-" # 45 chars typed
252
+ with pytest.raises(Exception, match="no more than 44 characters when it ends in '-'"):
253
+ load_config(base_config, overrides=["experiment_name=" + name])
254
+
255
+ def test_accepts_max_base_length_with_auto_hash(self, base_config):
256
+ # 44 chars (43 'a's + '-') expands to exactly 51 with the hash.
257
+ name = "a" * 43 + "-"
258
+ config = load_config(base_config, overrides=["experiment_name=" + name])
259
+ assert config.experiment_name.startswith("a" * 43 + "-")
260
+ assert len(config.experiment_name) == 51
235
261
 
236
262
 
237
263
  class TestNumAgentsValidation:
238
- """num_agents is bounded to [1, MAX_SANDBOXES] (one sandbox per agent)."""
264
+ """Tests num_agents is valid."""
239
265
 
240
- @pytest.mark.parametrize("value", [1, 5, MAX_SANDBOXES])
266
+ @pytest.mark.parametrize("value", [1, 5])
241
267
  def test_valid(self, value):
242
268
  assert RuntimeConfig(num_agents=value).num_agents == value
243
269
 
@@ -249,37 +275,26 @@ class TestNumAgentsValidation:
249
275
  with pytest.raises(Exception, match="at least 1"):
250
276
  RuntimeConfig(num_agents=value)
251
277
 
252
- def test_rejects_above_cap(self):
253
- with pytest.raises(Exception, match=f"at most {MAX_SANDBOXES}"):
254
- RuntimeConfig(num_agents=MAX_SANDBOXES + 1)
255
-
256
278
 
257
279
  class TestMaxRuntimeSecondsValidation:
258
- """max_runtime_seconds is -1 (no limit) or in [1, MAX_RUNTIME_SECONDS]."""
280
+ """Tests max_runtime_seconds is valid."""
259
281
 
260
- @pytest.mark.parametrize("value", [-1, 1, 7200, MAX_RUNTIME_SECONDS])
282
+ @pytest.mark.parametrize("value", [-1, 1, 7200])
261
283
  def test_valid(self, value):
262
284
  assert RuntimeConfig(max_runtime_seconds=value).max_runtime_seconds == value
263
285
 
264
- def test_default_is_platform_cap(self):
265
- # Previously unlimited (-1); now bounded to the platform maximum.
266
- assert RuntimeConfig().max_runtime_seconds == MAX_RUNTIME_SECONDS
286
+ def test_default_is_inf(self):
287
+ # Default is unlimited (-1).
288
+ assert RuntimeConfig().max_runtime_seconds == -1
267
289
 
268
290
  @pytest.mark.parametrize("value", [0, -2, -100])
269
291
  def test_rejects_non_sentinel_non_positive(self, value):
270
292
  with pytest.raises(Exception, match="-1 .no limit. or a positive integer"):
271
293
  RuntimeConfig(max_runtime_seconds=value)
272
294
 
273
- def test_rejects_above_cap(self):
274
- with pytest.raises(Exception, match="at most"):
275
- RuntimeConfig(max_runtime_seconds=MAX_RUNTIME_SECONDS + 1)
276
-
277
- def test_cap_is_six_hours(self):
278
- assert MAX_RUNTIME_SECONDS == 6 * 60 * 60
279
-
280
295
 
281
296
  class TestResourceCpuValidation:
282
- """cpu is a k8s quantity bounded to (0, 64] cores, spelling-invariant."""
297
+ """cpu is a k8s quantity; must be positive, spelling-invariant."""
283
298
 
284
299
  @pytest.mark.parametrize("value", ["1", "500m", "64", "64000m", "0.5"])
285
300
  def test_valid(self, value):
@@ -288,11 +303,6 @@ class TestResourceCpuValidation:
288
303
  def test_default(self):
289
304
  assert ResourceConfig().cpu == "1"
290
305
 
291
- @pytest.mark.parametrize("value", ["65", "64001m", "128"])
292
- def test_rejects_above_cap(self, value):
293
- with pytest.raises(Exception, match="at most 64 cores"):
294
- ResourceConfig(cpu=value)
295
-
296
306
  @pytest.mark.parametrize("value", ["0", "-1"])
297
307
  def test_rejects_non_positive(self, value):
298
308
  with pytest.raises(Exception, match="must be positive"):
@@ -304,7 +314,7 @@ class TestResourceCpuValidation:
304
314
 
305
315
 
306
316
  class TestResourceMemoryValidation:
307
- """memory is a k8s quantity bounded to (0, 128Gi] bytes, spelling-invariant."""
317
+ """memory is a k8s quantity, spelling-invariant."""
308
318
 
309
319
  @pytest.mark.parametrize("value", ["2Gi", "512Mi", "128Gi", "131072Mi", "128G"])
310
320
  def test_valid(self, value):
@@ -313,11 +323,6 @@ class TestResourceMemoryValidation:
313
323
  def test_default(self):
314
324
  assert ResourceConfig().memory == "2Gi"
315
325
 
316
- @pytest.mark.parametrize("value", ["129Gi", "256Gi", "131073Mi"])
317
- def test_rejects_above_cap(self, value):
318
- with pytest.raises(Exception, match="at most 128Gi"):
319
- ResourceConfig(memory=value)
320
-
321
326
  @pytest.mark.parametrize("value", ["0", "-1Gi"])
322
327
  def test_rejects_non_positive(self, value):
323
328
  with pytest.raises(Exception, match="must be positive"):
@@ -329,6 +334,63 @@ class TestResourceMemoryValidation:
329
334
  ResourceConfig(memory="64GiB")
330
335
 
331
336
 
337
+ class TestResourceAcceleratorsValidation:
338
+ """accelerators is a '<type>:<count>' string; count is a positive int and
339
+ unknown types are warned about but not rejected."""
340
+
341
+ @pytest.mark.parametrize("value", ["a100:2", "a100-80gb:8", "h100:1", "t4:4", "l40s:16"])
342
+ def test_valid_known_types(self, value):
343
+ assert ResourceConfig(accelerators=value).accelerators == value
344
+
345
+ def test_default_is_none(self):
346
+ assert ResourceConfig().accelerators is None
347
+
348
+ def test_unknown_type_is_allowed_with_warning(self, caplog):
349
+ # The "hivekit" logger has propagate=False, so attach caplog's handler
350
+ # directly rather than relying on propagation to the root logger.
351
+ hivekit_logger = logging.getLogger("hivekit")
352
+ hivekit_logger.addHandler(caplog.handler)
353
+ try:
354
+ with caplog.at_level(logging.WARNING, logger="hivekit"):
355
+ assert ResourceConfig(accelerators="mi300x:2").accelerators == "mi300x:2"
356
+ finally:
357
+ hivekit_logger.removeHandler(caplog.handler)
358
+ assert any("not among the known GPU types" in r.getMessage() for r in caplog.records)
359
+
360
+ @pytest.mark.parametrize("value", ["a100", "a100:2:1", "a100-80gb"])
361
+ def test_rejects_bad_format(self, value):
362
+ with pytest.raises(Exception, match="<type>:<count>"):
363
+ ResourceConfig(accelerators=value)
364
+
365
+ def test_rejects_empty_type(self):
366
+ with pytest.raises(Exception, match="type must not be empty"):
367
+ ResourceConfig(accelerators=":2")
368
+
369
+ @pytest.mark.parametrize("value", ["a100:two", "a100:2.5"])
370
+ def test_rejects_non_integer_count(self, value):
371
+ with pytest.raises(Exception, match="count must be an integer"):
372
+ ResourceConfig(accelerators=value)
373
+
374
+ @pytest.mark.parametrize("value", ["a100:0", "a100:-1"])
375
+ def test_rejects_non_positive_count(self, value):
376
+ with pytest.raises(Exception, match="count must be positive"):
377
+ ResourceConfig(accelerators=value)
378
+
379
+ @pytest.mark.parametrize(
380
+ "value,expected",
381
+ [
382
+ (" a100 : 2 ", "a100:2"),
383
+ ("a100:2", "a100:2"),
384
+ ("a100-80gb : 08", "a100-80gb:8"),
385
+ ("h100:\t1", "h100:1"),
386
+ ],
387
+ )
388
+ def test_normalizes_to_canonical_form(self, value, expected):
389
+ # Incidental whitespace and non-canonical integer spellings are stripped
390
+ # so the stored value always matches the '<type>:<count>' invariant.
391
+ assert ResourceConfig(accelerators=value).accelerators == expected
392
+
393
+
332
394
  class TestRawYaml:
333
395
  """Tests for the `raw_yaml` capture on load_config."""
334
396
 
@@ -7,7 +7,6 @@ Unit tests for experiment builder functions.
7
7
  """
8
8
 
9
9
  from cli.config import (
10
- MAX_RUNTIME_SECONDS,
11
10
  HiveConfig,
12
11
  KeyValueSet,
13
12
  PortConfig,
@@ -42,7 +41,7 @@ class TestBuildExperimentCRD:
42
41
  assert result["kind"] == "Experiment"
43
42
  assert result["metadata"]["name"] == "test-exp"
44
43
  assert result["spec"]["runtime"]["numAgents"] == 1
45
- assert result["spec"]["runtime"]["maxRuntimeSeconds"] == MAX_RUNTIME_SECONDS
44
+ assert result["spec"]["runtime"]["maxRuntimeSeconds"] == -1
46
45
  assert result["spec"]["runtime"]["maxIterations"] == -1
47
46
  assert "source" not in result["spec"]["repo"]
48
47
  assert "branch" not in result["spec"]["repo"]
@@ -5,7 +5,7 @@
5
5
  import pytest
6
6
  import yaml
7
7
 
8
- from cli.config import MAX_RUNTIME_SECONDS, load_config
8
+ from cli.config import load_config
9
9
 
10
10
  MINIMAL_CONFIG = {
11
11
  "experiment_name": "test-exp",
@@ -69,7 +69,7 @@ class TestApplyOverrides:
69
69
 
70
70
  def test_delete_restores_default(self, base_config):
71
71
  config = load_config(base_config, overrides=["~runtime.max_runtime_seconds"])
72
- assert config.runtime.max_runtime_seconds == MAX_RUNTIME_SECONDS
72
+ assert config.runtime.max_runtime_seconds == -1
73
73
 
74
74
  def test_delete_nested(self, base_config):
75
75
  config = load_config(base_config, overrides=["~sandbox.resources.cpu"])
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
File without changes
File without changes
File without changes
File without changes
File without changes