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.
Files changed (59) hide show
  1. {hivekit-0.8.2 → hivekit-0.9.0}/PKG-INFO +1 -1
  2. {hivekit-0.8.2 → hivekit-0.9.0}/pyproject.toml +1 -1
  3. {hivekit-0.8.2 → hivekit-0.9.0}/src/cli/config.py +76 -9
  4. {hivekit-0.8.2 → hivekit-0.9.0}/src/cli/experiment.py +2 -2
  5. hivekit-0.9.0/src/cli/experiment_download.py +131 -0
  6. {hivekit-0.8.2 → hivekit-0.9.0}/src/cli/http_client.py +105 -85
  7. {hivekit-0.8.2 → hivekit-0.9.0}/src/cli/main.py +83 -11
  8. {hivekit-0.8.2 → hivekit-0.9.0}/src/cli/skills/hive-setup/SKILL.md +2 -4
  9. {hivekit-0.8.2 → hivekit-0.9.0}/src/cli/skills/hive-setup/references/configuration.md +8 -7
  10. {hivekit-0.8.2 → hivekit-0.9.0}/src/cli/skills/hive-setup/references/gpu-hardware.md +4 -15
  11. {hivekit-0.8.2 → hivekit-0.9.0}/src/cli/utils/url_utils.py +8 -6
  12. {hivekit-0.8.2 → hivekit-0.9.0}/src/hivekit.egg-info/PKG-INFO +1 -1
  13. {hivekit-0.8.2 → hivekit-0.9.0}/src/hivekit.egg-info/SOURCES.txt +1 -0
  14. {hivekit-0.8.2 → hivekit-0.9.0}/tests/test_config.py +54 -15
  15. {hivekit-0.8.2 → hivekit-0.9.0}/tests/test_experiment.py +9 -4
  16. {hivekit-0.8.2 → hivekit-0.9.0}/tests/test_main.py +398 -7
  17. {hivekit-0.8.2 → hivekit-0.9.0}/LICENSE +0 -0
  18. {hivekit-0.8.2 → hivekit-0.9.0}/README.md +0 -0
  19. {hivekit-0.8.2 → hivekit-0.9.0}/setup.cfg +0 -0
  20. {hivekit-0.8.2 → hivekit-0.9.0}/src/cli/__init__.py +0 -0
  21. {hivekit-0.8.2 → hivekit-0.9.0}/src/cli/auth/__init__.py +0 -0
  22. {hivekit-0.8.2 → hivekit-0.9.0}/src/cli/auth/auth_utils.py +0 -0
  23. {hivekit-0.8.2 → hivekit-0.9.0}/src/cli/auth/credential_store.py +0 -0
  24. {hivekit-0.8.2 → hivekit-0.9.0}/src/cli/auth/login_page.py +0 -0
  25. {hivekit-0.8.2 → hivekit-0.9.0}/src/cli/auth/logo.svg +0 -0
  26. {hivekit-0.8.2 → hivekit-0.9.0}/src/cli/auth/oidc_flow.py +0 -0
  27. {hivekit-0.8.2 → hivekit-0.9.0}/src/cli/auth/session_manager.py +0 -0
  28. {hivekit-0.8.2 → hivekit-0.9.0}/src/cli/auth/token_revoker.py +0 -0
  29. {hivekit-0.8.2 → hivekit-0.9.0}/src/cli/completers.py +0 -0
  30. {hivekit-0.8.2 → hivekit-0.9.0}/src/cli/skills/hive-setup/references/multi-evaluator.md +0 -0
  31. {hivekit-0.8.2 → hivekit-0.9.0}/src/cli/skills_install.py +0 -0
  32. {hivekit-0.8.2 → hivekit-0.9.0}/src/cli/update_check.py +0 -0
  33. {hivekit-0.8.2 → hivekit-0.9.0}/src/cli/utils/__init__.py +0 -0
  34. {hivekit-0.8.2 → hivekit-0.9.0}/src/cli/utils/archive.py +0 -0
  35. {hivekit-0.8.2 → hivekit-0.9.0}/src/cli/utils/attachments.py +0 -0
  36. {hivekit-0.8.2 → hivekit-0.9.0}/src/cli/utils/config_paths.py +0 -0
  37. {hivekit-0.8.2 → hivekit-0.9.0}/src/cli/utils/config_sync.py +0 -0
  38. {hivekit-0.8.2 → hivekit-0.9.0}/src/cli/utils/docker.py +0 -0
  39. {hivekit-0.8.2 → hivekit-0.9.0}/src/cli/utils/logger.py +0 -0
  40. {hivekit-0.8.2 → hivekit-0.9.0}/src/cli/utils/terminal.py +0 -0
  41. {hivekit-0.8.2 → hivekit-0.9.0}/src/cli/utils/time.py +0 -0
  42. {hivekit-0.8.2 → hivekit-0.9.0}/src/cli/utils/upload.py +0 -0
  43. {hivekit-0.8.2 → hivekit-0.9.0}/src/cli/version.py +0 -0
  44. {hivekit-0.8.2 → hivekit-0.9.0}/src/hivekit.egg-info/dependency_links.txt +0 -0
  45. {hivekit-0.8.2 → hivekit-0.9.0}/src/hivekit.egg-info/entry_points.txt +0 -0
  46. {hivekit-0.8.2 → hivekit-0.9.0}/src/hivekit.egg-info/requires.txt +0 -0
  47. {hivekit-0.8.2 → hivekit-0.9.0}/src/hivekit.egg-info/top_level.txt +0 -0
  48. {hivekit-0.8.2 → hivekit-0.9.0}/tests/test_attachments.py +0 -0
  49. {hivekit-0.8.2 → hivekit-0.9.0}/tests/test_completers.py +0 -0
  50. {hivekit-0.8.2 → hivekit-0.9.0}/tests/test_config_sync.py +0 -0
  51. {hivekit-0.8.2 → hivekit-0.9.0}/tests/test_http_client.py +0 -0
  52. {hivekit-0.8.2 → hivekit-0.9.0}/tests/test_login.py +0 -0
  53. {hivekit-0.8.2 → hivekit-0.9.0}/tests/test_logout.py +0 -0
  54. {hivekit-0.8.2 → hivekit-0.9.0}/tests/test_overrides.py +0 -0
  55. {hivekit-0.8.2 → hivekit-0.9.0}/tests/test_push_image.py +0 -0
  56. {hivekit-0.8.2 → hivekit-0.9.0}/tests/test_time_utils.py +0 -0
  57. {hivekit-0.8.2 → hivekit-0.9.0}/tests/test_update_check.py +0 -0
  58. {hivekit-0.8.2 → hivekit-0.9.0}/tests/test_upload.py +0 -0
  59. {hivekit-0.8.2 → hivekit-0.9.0}/tests/test_version.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: hivekit
3
- Version: 0.8.2
3
+ Version: 0.9.0
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.8.2"
7
+ version = "0.9.0"
8
8
  description = "Toolkits for using hive agents, including CLI and SDK."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.12"
@@ -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
- # Kubernetes caps DNS labels and label values at 63 bytes. The operator derives
34
- # object names/labels by appending suffixes to the experiment name (the longest
35
- # is "-coordinator"), so reserve that headroom to keep the derived names valid.
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
- RESERVED_NAME_SUFFIX_LENGTH = len("-coordinator")
38
- MAX_EXPERIMENT_NAME_LENGTH = K8S_LABEL_MAX_LENGTH - RESERVED_NAME_SUFFIX_LENGTH
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
- headers = self._get_headers()
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
- try:
567
- response = self._session.request(
568
- method=method,
569
- url=url,
570
- headers=headers,
571
- json=json,
572
- params=params,
573
- stream=stream,
574
- timeout=timeout,
575
- verify=not self._insecure,
576
- )
577
- except OAuthError as e:
578
- logger.debug(f"OAuth error during request: {e.error}. Attempting to re-authenticate.")
579
- return self._retry_with_reauth(
580
- method=method,
581
- url=url,
582
- headers=headers,
583
- json=json,
584
- params=params,
585
- stream=stream,
586
- timeout=timeout,
587
- )
588
- if response.status_code == HTTPStatus.UNAUTHORIZED:
589
- logger.debug("Received 401 response. Attempting to re-authenticate.")
590
- return self._retry_with_reauth(
591
- method=method,
592
- url=url,
593
- headers=headers,
594
- json=json,
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(