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.
- {hivekit-0.3.0 → hivekit-0.3.1}/PKG-INFO +1 -1
- {hivekit-0.3.0 → hivekit-0.3.1}/pyproject.toml +1 -1
- {hivekit-0.3.0 → hivekit-0.3.1}/src/cli/config.py +105 -36
- {hivekit-0.3.0 → hivekit-0.3.1}/src/hivekit.egg-info/PKG-INFO +1 -1
- {hivekit-0.3.0 → hivekit-0.3.1}/src/hivekit.egg-info/top_level.txt +1 -0
- {hivekit-0.3.0 → hivekit-0.3.1}/tests/test_config.py +97 -35
- {hivekit-0.3.0 → hivekit-0.3.1}/tests/test_experiment.py +1 -2
- {hivekit-0.3.0 → hivekit-0.3.1}/tests/test_overrides.py +2 -2
- {hivekit-0.3.0 → hivekit-0.3.1}/LICENSE +0 -0
- {hivekit-0.3.0 → hivekit-0.3.1}/README.md +0 -0
- {hivekit-0.3.0 → hivekit-0.3.1}/setup.cfg +0 -0
- {hivekit-0.3.0 → hivekit-0.3.1}/src/cli/__init__.py +0 -0
- {hivekit-0.3.0 → hivekit-0.3.1}/src/cli/auth/__init__.py +0 -0
- {hivekit-0.3.0 → hivekit-0.3.1}/src/cli/auth/auth_utils.py +0 -0
- {hivekit-0.3.0 → hivekit-0.3.1}/src/cli/auth/credential_store.py +0 -0
- {hivekit-0.3.0 → hivekit-0.3.1}/src/cli/auth/login_page.py +0 -0
- {hivekit-0.3.0 → hivekit-0.3.1}/src/cli/auth/logo.svg +0 -0
- {hivekit-0.3.0 → hivekit-0.3.1}/src/cli/auth/oidc_flow.py +0 -0
- {hivekit-0.3.0 → hivekit-0.3.1}/src/cli/auth/session_manager.py +0 -0
- {hivekit-0.3.0 → hivekit-0.3.1}/src/cli/auth/token_revoker.py +0 -0
- {hivekit-0.3.0 → hivekit-0.3.1}/src/cli/completers.py +0 -0
- {hivekit-0.3.0 → hivekit-0.3.1}/src/cli/experiment.py +0 -0
- {hivekit-0.3.0 → hivekit-0.3.1}/src/cli/http_client.py +0 -0
- {hivekit-0.3.0 → hivekit-0.3.1}/src/cli/main.py +0 -0
- {hivekit-0.3.0 → hivekit-0.3.1}/src/cli/utils/__init__.py +0 -0
- {hivekit-0.3.0 → hivekit-0.3.1}/src/cli/utils/archive.py +0 -0
- {hivekit-0.3.0 → hivekit-0.3.1}/src/cli/utils/config_paths.py +0 -0
- {hivekit-0.3.0 → hivekit-0.3.1}/src/cli/utils/config_sync.py +0 -0
- {hivekit-0.3.0 → hivekit-0.3.1}/src/cli/utils/docker.py +0 -0
- {hivekit-0.3.0 → hivekit-0.3.1}/src/cli/utils/logger.py +0 -0
- {hivekit-0.3.0 → hivekit-0.3.1}/src/cli/utils/time.py +0 -0
- {hivekit-0.3.0 → hivekit-0.3.1}/src/cli/utils/upload.py +0 -0
- {hivekit-0.3.0 → hivekit-0.3.1}/src/cli/utils/url_utils.py +0 -0
- {hivekit-0.3.0 → hivekit-0.3.1}/src/cli/version.py +0 -0
- {hivekit-0.3.0 → hivekit-0.3.1}/src/hivekit.egg-info/SOURCES.txt +0 -0
- {hivekit-0.3.0 → hivekit-0.3.1}/src/hivekit.egg-info/dependency_links.txt +0 -0
- {hivekit-0.3.0 → hivekit-0.3.1}/src/hivekit.egg-info/entry_points.txt +0 -0
- {hivekit-0.3.0 → hivekit-0.3.1}/src/hivekit.egg-info/requires.txt +0 -0
- {hivekit-0.3.0 → hivekit-0.3.1}/tests/test_completers.py +0 -0
- {hivekit-0.3.0 → hivekit-0.3.1}/tests/test_config_sync.py +0 -0
- {hivekit-0.3.0 → hivekit-0.3.1}/tests/test_http_client.py +0 -0
- {hivekit-0.3.0 → hivekit-0.3.1}/tests/test_logout.py +0 -0
- {hivekit-0.3.0 → hivekit-0.3.1}/tests/test_main.py +0 -0
- {hivekit-0.3.0 → hivekit-0.3.1}/tests/test_push_image.py +0 -0
- {hivekit-0.3.0 → hivekit-0.3.1}/tests/test_time_utils.py +0 -0
- {hivekit-0.3.0 → hivekit-0.3.1}/tests/test_upload.py +0 -0
- {hivekit-0.3.0 → hivekit-0.3.1}/tests/test_version.py +0 -0
|
@@ -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
|
-
#
|
|
33
|
-
#
|
|
34
|
-
#
|
|
35
|
-
|
|
36
|
-
|
|
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
|
|
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
|
|
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"
|
|
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
|
-
|
|
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: '{
|
|
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) >
|
|
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
|
|
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
|
|
|
@@ -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
|
|
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
|
-
|
|
234
|
-
|
|
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
|
|
264
|
+
"""Tests num_agents is valid."""
|
|
239
265
|
|
|
240
|
-
@pytest.mark.parametrize("value", [1, 5
|
|
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
|
|
280
|
+
"""Tests max_runtime_seconds is valid."""
|
|
259
281
|
|
|
260
|
-
@pytest.mark.parametrize("value", [-1, 1, 7200
|
|
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
|
|
265
|
-
#
|
|
266
|
-
assert RuntimeConfig().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
|
|
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
|
|
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"] ==
|
|
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
|
|
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 ==
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|