hivekit 0.8.2__tar.gz → 0.9.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.
- {hivekit-0.8.2 → hivekit-0.9.0}/PKG-INFO +1 -1
- {hivekit-0.8.2 → hivekit-0.9.0}/pyproject.toml +1 -1
- {hivekit-0.8.2 → hivekit-0.9.0}/src/cli/config.py +76 -9
- {hivekit-0.8.2 → hivekit-0.9.0}/src/cli/experiment.py +2 -2
- hivekit-0.9.0/src/cli/experiment_download.py +131 -0
- {hivekit-0.8.2 → hivekit-0.9.0}/src/cli/http_client.py +105 -85
- {hivekit-0.8.2 → hivekit-0.9.0}/src/cli/main.py +83 -11
- {hivekit-0.8.2 → hivekit-0.9.0}/src/cli/skills/hive-setup/SKILL.md +2 -4
- {hivekit-0.8.2 → hivekit-0.9.0}/src/cli/skills/hive-setup/references/configuration.md +8 -7
- {hivekit-0.8.2 → hivekit-0.9.0}/src/cli/skills/hive-setup/references/gpu-hardware.md +4 -15
- {hivekit-0.8.2 → hivekit-0.9.0}/src/cli/utils/url_utils.py +8 -6
- {hivekit-0.8.2 → hivekit-0.9.0}/src/hivekit.egg-info/PKG-INFO +1 -1
- {hivekit-0.8.2 → hivekit-0.9.0}/src/hivekit.egg-info/SOURCES.txt +1 -0
- {hivekit-0.8.2 → hivekit-0.9.0}/tests/test_config.py +54 -15
- {hivekit-0.8.2 → hivekit-0.9.0}/tests/test_experiment.py +9 -4
- {hivekit-0.8.2 → hivekit-0.9.0}/tests/test_main.py +398 -7
- {hivekit-0.8.2 → hivekit-0.9.0}/LICENSE +0 -0
- {hivekit-0.8.2 → hivekit-0.9.0}/README.md +0 -0
- {hivekit-0.8.2 → hivekit-0.9.0}/setup.cfg +0 -0
- {hivekit-0.8.2 → hivekit-0.9.0}/src/cli/__init__.py +0 -0
- {hivekit-0.8.2 → hivekit-0.9.0}/src/cli/auth/__init__.py +0 -0
- {hivekit-0.8.2 → hivekit-0.9.0}/src/cli/auth/auth_utils.py +0 -0
- {hivekit-0.8.2 → hivekit-0.9.0}/src/cli/auth/credential_store.py +0 -0
- {hivekit-0.8.2 → hivekit-0.9.0}/src/cli/auth/login_page.py +0 -0
- {hivekit-0.8.2 → hivekit-0.9.0}/src/cli/auth/logo.svg +0 -0
- {hivekit-0.8.2 → hivekit-0.9.0}/src/cli/auth/oidc_flow.py +0 -0
- {hivekit-0.8.2 → hivekit-0.9.0}/src/cli/auth/session_manager.py +0 -0
- {hivekit-0.8.2 → hivekit-0.9.0}/src/cli/auth/token_revoker.py +0 -0
- {hivekit-0.8.2 → hivekit-0.9.0}/src/cli/completers.py +0 -0
- {hivekit-0.8.2 → hivekit-0.9.0}/src/cli/skills/hive-setup/references/multi-evaluator.md +0 -0
- {hivekit-0.8.2 → hivekit-0.9.0}/src/cli/skills_install.py +0 -0
- {hivekit-0.8.2 → hivekit-0.9.0}/src/cli/update_check.py +0 -0
- {hivekit-0.8.2 → hivekit-0.9.0}/src/cli/utils/__init__.py +0 -0
- {hivekit-0.8.2 → hivekit-0.9.0}/src/cli/utils/archive.py +0 -0
- {hivekit-0.8.2 → hivekit-0.9.0}/src/cli/utils/attachments.py +0 -0
- {hivekit-0.8.2 → hivekit-0.9.0}/src/cli/utils/config_paths.py +0 -0
- {hivekit-0.8.2 → hivekit-0.9.0}/src/cli/utils/config_sync.py +0 -0
- {hivekit-0.8.2 → hivekit-0.9.0}/src/cli/utils/docker.py +0 -0
- {hivekit-0.8.2 → hivekit-0.9.0}/src/cli/utils/logger.py +0 -0
- {hivekit-0.8.2 → hivekit-0.9.0}/src/cli/utils/terminal.py +0 -0
- {hivekit-0.8.2 → hivekit-0.9.0}/src/cli/utils/time.py +0 -0
- {hivekit-0.8.2 → hivekit-0.9.0}/src/cli/utils/upload.py +0 -0
- {hivekit-0.8.2 → hivekit-0.9.0}/src/cli/version.py +0 -0
- {hivekit-0.8.2 → hivekit-0.9.0}/src/hivekit.egg-info/dependency_links.txt +0 -0
- {hivekit-0.8.2 → hivekit-0.9.0}/src/hivekit.egg-info/entry_points.txt +0 -0
- {hivekit-0.8.2 → hivekit-0.9.0}/src/hivekit.egg-info/requires.txt +0 -0
- {hivekit-0.8.2 → hivekit-0.9.0}/src/hivekit.egg-info/top_level.txt +0 -0
- {hivekit-0.8.2 → hivekit-0.9.0}/tests/test_attachments.py +0 -0
- {hivekit-0.8.2 → hivekit-0.9.0}/tests/test_completers.py +0 -0
- {hivekit-0.8.2 → hivekit-0.9.0}/tests/test_config_sync.py +0 -0
- {hivekit-0.8.2 → hivekit-0.9.0}/tests/test_http_client.py +0 -0
- {hivekit-0.8.2 → hivekit-0.9.0}/tests/test_login.py +0 -0
- {hivekit-0.8.2 → hivekit-0.9.0}/tests/test_logout.py +0 -0
- {hivekit-0.8.2 → hivekit-0.9.0}/tests/test_overrides.py +0 -0
- {hivekit-0.8.2 → hivekit-0.9.0}/tests/test_push_image.py +0 -0
- {hivekit-0.8.2 → hivekit-0.9.0}/tests/test_time_utils.py +0 -0
- {hivekit-0.8.2 → hivekit-0.9.0}/tests/test_update_check.py +0 -0
- {hivekit-0.8.2 → hivekit-0.9.0}/tests/test_upload.py +0 -0
- {hivekit-0.8.2 → hivekit-0.9.0}/tests/test_version.py +0 -0
|
@@ -30,12 +30,30 @@ class BaseModel(pydantic.BaseModel):
|
|
|
30
30
|
model_config = ConfigDict(extra="forbid", coerce_numbers_to_str=True)
|
|
31
31
|
|
|
32
32
|
|
|
33
|
-
#
|
|
34
|
-
#
|
|
35
|
-
#
|
|
33
|
+
# Pods created by a Job or Deployment are named "<prefix><5 random chars>".
|
|
34
|
+
# Our pod prefixes are:
|
|
35
|
+
# coordinator: "<name>-coordinator-"
|
|
36
|
+
# sandbox: "<name>-sandbox-<rs-hash>-"
|
|
37
|
+
#
|
|
38
|
+
# The AWS network policy agent identifies a pod by its name minus the last
|
|
39
|
+
# '-' segment. If the coordinator and sandbox pods end up with the same
|
|
40
|
+
# identity, the coordinator will inherit the sandbox's internet-only
|
|
41
|
+
# egress policy (so it cannot reach any in-cluster service, like the database).
|
|
42
|
+
# Reserving room for "-coordinator-", the longer of the two, keeps every pod's
|
|
43
|
+
# role intact.
|
|
44
|
+
#
|
|
45
|
+
# For example, a 51-char <name> leaves only 7 chars for the role:
|
|
46
|
+
# coordinator: "<name>-coordi" + "6sftc" -> identity "<name>"
|
|
47
|
+
# sandbox: "<name>-sandbo" + "4nxt7" -> identity "<name>" (same, broken)
|
|
48
|
+
# while a 45-char <name> keeps both roles:
|
|
49
|
+
# coordinator: "<name>-coordinator-" + "abcde" -> identity "<name>-coordinator"
|
|
50
|
+
# sandbox: "<name>-sandbox-69df" + "abcde" -> identity "<name>-sandbox"
|
|
36
51
|
K8S_LABEL_MAX_LENGTH = 63
|
|
37
|
-
|
|
38
|
-
|
|
52
|
+
K8S_GENERATED_NAME_SUFFIX_LENGTH = 5
|
|
53
|
+
RESERVED_NAME_SUFFIX_LENGTH = len("-coordinator-")
|
|
54
|
+
MAX_EXPERIMENT_NAME_LENGTH = (
|
|
55
|
+
K8S_LABEL_MAX_LENGTH - K8S_GENERATED_NAME_SUFFIX_LENGTH - RESERVED_NAME_SUFFIX_LENGTH
|
|
56
|
+
)
|
|
39
57
|
# A name ending in '-' gets a random uniqueness suffix appended (see
|
|
40
58
|
# unique_suffix), which eats into the budget the user actually controls (their
|
|
41
59
|
# typed base name).
|
|
@@ -77,11 +95,14 @@ KNOWN_MODELS = frozenset(
|
|
|
77
95
|
"anthropic.claude-opus-4-7",
|
|
78
96
|
"anthropic.claude-opus-4-8",
|
|
79
97
|
"anthropic.claude-opus-5",
|
|
98
|
+
"anthropic.claude-opus-5-5",
|
|
80
99
|
"anthropic.claude-sonnet-4-6",
|
|
81
100
|
"anthropic.claude-sonnet-5",
|
|
82
101
|
"moonshotai.kimi-k2.5",
|
|
83
102
|
"nvidia.nemotron-super-3-120b",
|
|
84
103
|
"openai.gpt-6-astra",
|
|
104
|
+
"openai.gpt-6-luna",
|
|
105
|
+
"openai.gpt-6-sol",
|
|
85
106
|
"openai.gpt-5.6-luna",
|
|
86
107
|
"openai.gpt-5.6-sol",
|
|
87
108
|
"openai.gpt-5.6-terra",
|
|
@@ -244,10 +265,6 @@ class SandboxConfig(BaseModel):
|
|
|
244
265
|
|
|
245
266
|
|
|
246
267
|
class PromptConfig(BaseModel):
|
|
247
|
-
enable_evolution: bool = Field(
|
|
248
|
-
default=False,
|
|
249
|
-
description="Whether to enable evolution for the experiment. Default to False.",
|
|
250
|
-
)
|
|
251
268
|
context: Optional[str] = Field(
|
|
252
269
|
default=None,
|
|
253
270
|
description="Some useful experiment-specific context to provide to the Hive.",
|
|
@@ -266,6 +283,20 @@ class PromptConfig(BaseModel):
|
|
|
266
283
|
),
|
|
267
284
|
)
|
|
268
285
|
|
|
286
|
+
@model_validator(mode="before")
|
|
287
|
+
@classmethod
|
|
288
|
+
def drop_enable_evolution(cls, values):
|
|
289
|
+
"""Accept and ignore the removed enable_evolution field without mutating the input."""
|
|
290
|
+
if not isinstance(values, dict) or "enable_evolution" not in values:
|
|
291
|
+
return values
|
|
292
|
+
values = dict(values)
|
|
293
|
+
values.pop("enable_evolution")
|
|
294
|
+
logger.warning(
|
|
295
|
+
"prompt.enable_evolution is deprecated and has no effect. "
|
|
296
|
+
"Remove it from your config to silence this warning."
|
|
297
|
+
)
|
|
298
|
+
return values
|
|
299
|
+
|
|
269
300
|
@model_validator(mode="before")
|
|
270
301
|
@classmethod
|
|
271
302
|
def migrate_arxiv_ids(cls, values):
|
|
@@ -513,6 +544,42 @@ class ProviderConfig(BaseModel):
|
|
|
513
544
|
"non-interruptible capacity only. Unset (the default): no constraint, "
|
|
514
545
|
"the cheapest available capacity is used.",
|
|
515
546
|
)
|
|
547
|
+
regions: Optional[list[str]] = Field(
|
|
548
|
+
default=None,
|
|
549
|
+
description="The regions the sandbox may be placed in: us, or all for "
|
|
550
|
+
"every region the platform offers, given as a single value or a list. "
|
|
551
|
+
"Unset (the default): your default regions.",
|
|
552
|
+
)
|
|
553
|
+
|
|
554
|
+
@field_validator("regions", mode="before")
|
|
555
|
+
@classmethod
|
|
556
|
+
def wrap_single_region(cls, v):
|
|
557
|
+
# `regions: all` or `regions: us` reads better than a one-item list.
|
|
558
|
+
if isinstance(v, str):
|
|
559
|
+
return [v]
|
|
560
|
+
return v
|
|
561
|
+
|
|
562
|
+
@field_validator("regions")
|
|
563
|
+
@classmethod
|
|
564
|
+
def validate_regions(cls, v):
|
|
565
|
+
# Only the format is checked here; which regions exist is up to the
|
|
566
|
+
# backend, so the list can grow without a CLI release.
|
|
567
|
+
if v is None:
|
|
568
|
+
return v
|
|
569
|
+
if not v:
|
|
570
|
+
raise ValueError("regions must not be empty; omit it to allow any region")
|
|
571
|
+
regions = []
|
|
572
|
+
for region in v:
|
|
573
|
+
region = region.strip().lower()
|
|
574
|
+
if not re.fullmatch(r"[a-z0-9_-]+", region):
|
|
575
|
+
raise ValueError(
|
|
576
|
+
f"invalid region '{region}', must contain only letters, digits, '-' and '_'"
|
|
577
|
+
)
|
|
578
|
+
# The backend treats regions as a set, so drop repeats rather than
|
|
579
|
+
# have it reject them.
|
|
580
|
+
if region not in regions:
|
|
581
|
+
regions.append(region)
|
|
582
|
+
return regions
|
|
516
583
|
|
|
517
584
|
|
|
518
585
|
class AgentModelConfig(BaseModel):
|
|
@@ -114,6 +114,8 @@ def build_experiment_crd(config: HiveConfig) -> Dict[str, Any]:
|
|
|
114
114
|
provider["name"] = config.provider.name
|
|
115
115
|
if config.provider.spot is not None:
|
|
116
116
|
provider["spot"] = config.provider.spot
|
|
117
|
+
if config.provider.regions:
|
|
118
|
+
provider["regions"] = config.provider.regions
|
|
117
119
|
if provider:
|
|
118
120
|
experiment["spec"]["provider"] = provider
|
|
119
121
|
|
|
@@ -124,8 +126,6 @@ def build_experiment_crd(config: HiveConfig) -> Dict[str, Any]:
|
|
|
124
126
|
experiment["spec"]["prompt"]["context"] = config.prompt.context
|
|
125
127
|
if config.prompt.ideas:
|
|
126
128
|
experiment["spec"]["prompt"]["ideas"] = config.prompt.ideas
|
|
127
|
-
if config.prompt.enable_evolution:
|
|
128
|
-
experiment["spec"]["prompt"]["enableEvolution"] = config.prompt.enable_evolution
|
|
129
129
|
|
|
130
130
|
# Add optional agent selection
|
|
131
131
|
if config.agents:
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# Copyright (C) 2026 Hiverge
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
|
|
5
|
+
"""Choose export destinations and save complete, validated downloads using bounded memory."""
|
|
6
|
+
|
|
7
|
+
import gzip
|
|
8
|
+
import os
|
|
9
|
+
import tempfile
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from email.message import Message
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
import requests
|
|
15
|
+
|
|
16
|
+
from cli.http_client import DOWNLOAD_SYSTEM_ERROR, DownloadError
|
|
17
|
+
|
|
18
|
+
_CHUNK_SIZE = 1 << 16
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def resolve_download_output(
|
|
22
|
+
output: str | None, *, directory_only: bool = False
|
|
23
|
+
) -> tuple[Path, str | None]:
|
|
24
|
+
"""Return the destination directory and optional filename override."""
|
|
25
|
+
if output is None:
|
|
26
|
+
return Path.cwd(), None
|
|
27
|
+
if not output:
|
|
28
|
+
raise DownloadError("--output must name a file or directory.")
|
|
29
|
+
path = Path(output)
|
|
30
|
+
if path.is_dir():
|
|
31
|
+
return path, None
|
|
32
|
+
if output.endswith(("/", os.sep)):
|
|
33
|
+
if path.exists():
|
|
34
|
+
raise DownloadError(f"Output is not a directory: {path}")
|
|
35
|
+
path.mkdir(parents=True, exist_ok=True)
|
|
36
|
+
return path, None
|
|
37
|
+
if directory_only:
|
|
38
|
+
raise DownloadError("Full Export requires a directory. Use -o 'DIRECTORY/'.")
|
|
39
|
+
if output.replace("\\", "/").rsplit("/", 1)[-1] in {".", ".."}:
|
|
40
|
+
raise DownloadError("--output must name a file or directory.")
|
|
41
|
+
_check_destination(path)
|
|
42
|
+
return path.parent, path.name
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _check_distinct(main: Path, reports: Path) -> None:
|
|
46
|
+
resolved_main, resolved_reports = main.resolve(), reports.resolve()
|
|
47
|
+
same = resolved_main == resolved_reports or (
|
|
48
|
+
main.exists() and reports.exists() and main.samefile(reports)
|
|
49
|
+
)
|
|
50
|
+
if same:
|
|
51
|
+
raise DownloadError("The main output and evolution reports must have different filenames.")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _check_destination(destination: Path) -> None:
|
|
55
|
+
if destination.is_dir():
|
|
56
|
+
raise DownloadError(f"Output is a directory: {destination}")
|
|
57
|
+
if not destination.parent.is_dir():
|
|
58
|
+
raise DownloadError(f"Output directory does not exist: {destination.parent}")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _response_filename(response: requests.Response) -> str:
|
|
62
|
+
disposition = Message()
|
|
63
|
+
disposition["Content-Disposition"] = response.headers.get("Content-Disposition", "")
|
|
64
|
+
filename = disposition.get_filename()
|
|
65
|
+
# AlphaTrion owns sanitization. Reject malformed filenames rather than altering them.
|
|
66
|
+
if (
|
|
67
|
+
not filename
|
|
68
|
+
or filename in {".", ".."}
|
|
69
|
+
or "/" in filename
|
|
70
|
+
or "\\" in filename
|
|
71
|
+
or any(ord(char) < 32 or ord(char) == 127 for char in filename)
|
|
72
|
+
):
|
|
73
|
+
raise DownloadError(DOWNLOAD_SYSTEM_ERROR)
|
|
74
|
+
return filename
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@dataclass
|
|
78
|
+
class ExportOutput:
|
|
79
|
+
directory: Path
|
|
80
|
+
output: str | None
|
|
81
|
+
|
|
82
|
+
@classmethod
|
|
83
|
+
def prepare(cls, name: str, full_export: bool, output: str | None) -> "ExportOutput":
|
|
84
|
+
"""Validate the output path before authentication or download."""
|
|
85
|
+
if not name:
|
|
86
|
+
raise DownloadError("Experiment name must be non-empty.")
|
|
87
|
+
directory, filename = resolve_download_output(output, directory_only=full_export)
|
|
88
|
+
return cls(directory, filename)
|
|
89
|
+
|
|
90
|
+
def destination(self, response: requests.Response, *, source: Path | None = None) -> Path:
|
|
91
|
+
"""Honor server filenames and check actual destinations before consuming bytes."""
|
|
92
|
+
filename = _response_filename(response)
|
|
93
|
+
destination = self.directory / (self.output if source is None and self.output else filename)
|
|
94
|
+
_check_destination(destination)
|
|
95
|
+
if source is not None:
|
|
96
|
+
_check_distinct(source, destination)
|
|
97
|
+
return destination
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def save_export(
|
|
101
|
+
response: requests.Response, destination: Path, *, suppress_empty: bool = False
|
|
102
|
+
) -> bool:
|
|
103
|
+
"""Publish only a complete gzip file; return False for suppressed empty reports.
|
|
104
|
+
|
|
105
|
+
The caller owns the response context. Temporary output lives beside the
|
|
106
|
+
destination so replacement cannot cross filesystem boundaries.
|
|
107
|
+
"""
|
|
108
|
+
temporary: Path | None = None
|
|
109
|
+
try:
|
|
110
|
+
with tempfile.NamedTemporaryFile(
|
|
111
|
+
mode="wb", dir=destination.parent, prefix=".hive-export-", suffix=".tmp", delete=False
|
|
112
|
+
) as output:
|
|
113
|
+
temporary = Path(output.name)
|
|
114
|
+
# Preserve AlphaTrion's compressed bytes, even if a proxy adds Content-Encoding.
|
|
115
|
+
for chunk in response.raw.stream(_CHUNK_SIZE, decode_content=False):
|
|
116
|
+
output.write(chunk)
|
|
117
|
+
with temporary.open("rb") as compressed:
|
|
118
|
+
# gzip accepts a zero-byte input, but a completed export must contain a frame.
|
|
119
|
+
if compressed.read(2) != b"\x1f\x8b":
|
|
120
|
+
raise DownloadError(DOWNLOAD_SYSTEM_ERROR)
|
|
121
|
+
has_content = False
|
|
122
|
+
with gzip.open(temporary, "rb") as decoded:
|
|
123
|
+
while decoded.read(_CHUNK_SIZE):
|
|
124
|
+
has_content = True
|
|
125
|
+
if suppress_empty and not has_content:
|
|
126
|
+
return False
|
|
127
|
+
os.replace(temporary, destination)
|
|
128
|
+
return True
|
|
129
|
+
finally:
|
|
130
|
+
if temporary is not None:
|
|
131
|
+
temporary.unlink(missing_ok=True)
|
|
@@ -10,6 +10,7 @@ import logging
|
|
|
10
10
|
import time
|
|
11
11
|
from http import HTTPStatus
|
|
12
12
|
from typing import Any, Callable, Dict, Optional
|
|
13
|
+
from urllib.parse import quote
|
|
13
14
|
|
|
14
15
|
import requests
|
|
15
16
|
from authlib.integrations.base_client import OAuthError
|
|
@@ -21,6 +22,44 @@ from cli.utils.url_utils import get_api_endpoint
|
|
|
21
22
|
console = Console()
|
|
22
23
|
logger = logging.getLogger("hivekit")
|
|
23
24
|
|
|
25
|
+
DOWNLOAD_SYSTEM_ERROR = "A system error prevented the download."
|
|
26
|
+
_EXPIRED_CREDENTIALS = "Your credentials have expired. Please run 'hive login' to re-authenticate."
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class DownloadError(Exception):
|
|
30
|
+
"""An export failure whose message is safe to show to the user."""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _export_error_message(response: requests.Response) -> str:
|
|
34
|
+
"""Recognize known export errors; never display arbitrary upstream diagnostics."""
|
|
35
|
+
known_errors = {
|
|
36
|
+
HTTPStatus.BAD_REQUEST: ("Invalid export mode.",),
|
|
37
|
+
HTTPStatus.FORBIDDEN: (
|
|
38
|
+
"You do not have permission to download results.",
|
|
39
|
+
"You do not have access to this experiment.",
|
|
40
|
+
),
|
|
41
|
+
HTTPStatus.NOT_FOUND: ("Experiment not found.",),
|
|
42
|
+
}
|
|
43
|
+
allowed = {
|
|
44
|
+
message.rstrip(".").casefold(): message
|
|
45
|
+
for message in known_errors.get(response.status_code, ())
|
|
46
|
+
}
|
|
47
|
+
if not allowed:
|
|
48
|
+
return DOWNLOAD_SYSTEM_ERROR
|
|
49
|
+
try:
|
|
50
|
+
payload = response.json()
|
|
51
|
+
except (ValueError, requests.exceptions.RequestException):
|
|
52
|
+
return DOWNLOAD_SYSTEM_ERROR
|
|
53
|
+
if isinstance(payload, dict):
|
|
54
|
+
for field in ("error", "detail"):
|
|
55
|
+
value = payload.get(field)
|
|
56
|
+
if isinstance(value, str):
|
|
57
|
+
key = value.strip().rstrip(".").casefold()
|
|
58
|
+
message = allowed.get("invalid export mode" if key == "invalid mode" else key)
|
|
59
|
+
if message is not None:
|
|
60
|
+
return message
|
|
61
|
+
return DOWNLOAD_SYSTEM_ERROR
|
|
62
|
+
|
|
24
63
|
|
|
25
64
|
def _http_error_detail(e: requests.exceptions.HTTPError) -> str:
|
|
26
65
|
"""Best-effort human-readable detail from an HTTP error.
|
|
@@ -153,9 +192,7 @@ class HttpClient:
|
|
|
153
192
|
try:
|
|
154
193
|
self._session.ensure_active_token(token)
|
|
155
194
|
except OAuthError as e:
|
|
156
|
-
raise AuthenticationError(
|
|
157
|
-
"Your credentials have expired. Please run 'hive login' to re-authenticate."
|
|
158
|
-
) from e
|
|
195
|
+
raise AuthenticationError(_EXPIRED_CREDENTIALS) from e
|
|
159
196
|
return self._session.token["access_token"]
|
|
160
197
|
|
|
161
198
|
def get_push_credentials(self, reference: str) -> Dict[str, Any]:
|
|
@@ -411,6 +448,28 @@ class HttpClient:
|
|
|
411
448
|
)
|
|
412
449
|
return response.json()
|
|
413
450
|
|
|
451
|
+
def stream_experiment_export(self, name: str, mode: str = "metadata") -> requests.Response:
|
|
452
|
+
"""Open a Monolith export; the caller owns and must close the response."""
|
|
453
|
+
if mode not in {"metadata", "source", "evolution-reports"}:
|
|
454
|
+
raise DownloadError("Invalid export mode.")
|
|
455
|
+
if not name:
|
|
456
|
+
raise DownloadError("Experiment name must be non-empty.")
|
|
457
|
+
try:
|
|
458
|
+
response = self._stream_get(
|
|
459
|
+
f"/experiments/{quote(name, safe='')}/contents-export",
|
|
460
|
+
params={"mode": mode},
|
|
461
|
+
headers={
|
|
462
|
+
"Accept": "application/gzip",
|
|
463
|
+
"Accept-Encoding": "identity",
|
|
464
|
+
},
|
|
465
|
+
)
|
|
466
|
+
except requests.exceptions.RequestException:
|
|
467
|
+
raise DownloadError(DOWNLOAD_SYSTEM_ERROR) from None
|
|
468
|
+
if response.status_code != HTTPStatus.OK:
|
|
469
|
+
with response:
|
|
470
|
+
raise DownloadError(_export_error_message(response))
|
|
471
|
+
return response
|
|
472
|
+
|
|
414
473
|
def list_coordinators(self) -> Dict[str, Any]:
|
|
415
474
|
"""
|
|
416
475
|
List coordinator configurations.
|
|
@@ -443,20 +502,10 @@ class HttpClient:
|
|
|
443
502
|
Returns a streaming Response object. Caller should iterate with
|
|
444
503
|
response.iter_lines() and close when done.
|
|
445
504
|
"""
|
|
446
|
-
url = f"{self.base_url}/experiments/{name}/logs"
|
|
447
505
|
params = {"source": source, "follow": "true" if follow else "false"}
|
|
448
506
|
if worker is not None:
|
|
449
507
|
params["worker"] = worker
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
response = self._request(
|
|
453
|
-
method="get",
|
|
454
|
-
url=url,
|
|
455
|
-
headers=headers,
|
|
456
|
-
params=params,
|
|
457
|
-
stream=True,
|
|
458
|
-
timeout=None,
|
|
459
|
-
)
|
|
508
|
+
response = self._stream_get(f"/experiments/{name}/logs", params=params)
|
|
460
509
|
|
|
461
510
|
if response.status_code == HTTPStatus.NOT_FOUND:
|
|
462
511
|
raise StreamNotReadyError()
|
|
@@ -520,6 +569,19 @@ class HttpClient:
|
|
|
520
569
|
"""
|
|
521
570
|
return {"Content-Type": "application/json"}
|
|
522
571
|
|
|
572
|
+
def _stream_get(
|
|
573
|
+
self, path: str, *, params: dict[str, str], headers: dict[str, str] | None = None
|
|
574
|
+
) -> requests.Response:
|
|
575
|
+
"""Open an authenticated stream with no transfer timeout; caller owns the response."""
|
|
576
|
+
return self._request(
|
|
577
|
+
method="get",
|
|
578
|
+
url=f"{self.base_url}{path}",
|
|
579
|
+
headers=self._get_headers() | (headers or {}),
|
|
580
|
+
params=params,
|
|
581
|
+
stream=True,
|
|
582
|
+
timeout=None,
|
|
583
|
+
)
|
|
584
|
+
|
|
523
585
|
def _send(
|
|
524
586
|
self,
|
|
525
587
|
method: str,
|
|
@@ -563,77 +625,35 @@ class HttpClient:
|
|
|
563
625
|
Make an HTTP request, automatically retrying once on 401 by invoking
|
|
564
626
|
the on_auth_failure callback.
|
|
565
627
|
"""
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
params=params,
|
|
596
|
-
stream=stream,
|
|
597
|
-
timeout=timeout,
|
|
598
|
-
)
|
|
599
|
-
return response
|
|
600
|
-
|
|
601
|
-
def _retry_with_reauth(
|
|
602
|
-
self,
|
|
603
|
-
method: str,
|
|
604
|
-
url: str,
|
|
605
|
-
headers: Dict[str, str],
|
|
606
|
-
json: Optional[Dict[str, Any]] = None,
|
|
607
|
-
params: Optional[Dict[str, str]] = None,
|
|
608
|
-
stream: bool = False,
|
|
609
|
-
timeout: Optional[int] = 30,
|
|
610
|
-
) -> requests.Response:
|
|
611
|
-
"""
|
|
612
|
-
Attempt re-authentication and retry the failed request.
|
|
613
|
-
"""
|
|
614
|
-
try:
|
|
615
|
-
self._session = self._on_auth_failure()
|
|
616
|
-
except AuthenticationError:
|
|
617
|
-
raise
|
|
618
|
-
except Exception as e:
|
|
619
|
-
raise AuthenticationError(
|
|
620
|
-
"Your credentials have expired. Please run 'hive login' to re-authenticate."
|
|
621
|
-
) from e
|
|
622
|
-
response = self._session.request(
|
|
623
|
-
method=method,
|
|
624
|
-
url=url,
|
|
625
|
-
headers=headers,
|
|
626
|
-
json=json,
|
|
627
|
-
params=params,
|
|
628
|
-
stream=stream,
|
|
629
|
-
timeout=timeout,
|
|
630
|
-
verify=not self._insecure,
|
|
631
|
-
)
|
|
632
|
-
if response.status_code == HTTPStatus.UNAUTHORIZED:
|
|
633
|
-
raise AuthenticationError(
|
|
634
|
-
"Your credentials have expired. Please run 'hive login' to re-authenticate."
|
|
635
|
-
)
|
|
636
|
-
return response
|
|
628
|
+
for attempt in range(2):
|
|
629
|
+
try:
|
|
630
|
+
response = self._session.request(
|
|
631
|
+
method=method,
|
|
632
|
+
url=url,
|
|
633
|
+
headers=headers,
|
|
634
|
+
json=json,
|
|
635
|
+
params=params,
|
|
636
|
+
stream=stream,
|
|
637
|
+
timeout=timeout,
|
|
638
|
+
verify=not self._insecure,
|
|
639
|
+
)
|
|
640
|
+
except OAuthError as error:
|
|
641
|
+
if attempt:
|
|
642
|
+
raise
|
|
643
|
+
logger.debug("OAuth error during request: %s. Re-authenticating.", error.error)
|
|
644
|
+
else:
|
|
645
|
+
if response.status_code != HTTPStatus.UNAUTHORIZED:
|
|
646
|
+
return response
|
|
647
|
+
response.close()
|
|
648
|
+
if attempt:
|
|
649
|
+
raise AuthenticationError(_EXPIRED_CREDENTIALS)
|
|
650
|
+
logger.debug("Received 401 response. Re-authenticating.")
|
|
651
|
+
try:
|
|
652
|
+
self._session = self._on_auth_failure()
|
|
653
|
+
except AuthenticationError:
|
|
654
|
+
raise
|
|
655
|
+
except Exception as error:
|
|
656
|
+
raise AuthenticationError(_EXPIRED_CREDENTIALS) from error
|
|
637
657
|
|
|
638
658
|
|
|
639
659
|
def create_http_client(
|