usegradient 1.3.1__py3-none-any.whl
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.
- gradient_sdk/__init__.py +122 -0
- gradient_sdk/agent.py +368 -0
- gradient_sdk/client.py +1059 -0
- gradient_sdk/context.py +91 -0
- gradient_sdk/credentials.py +92 -0
- gradient_sdk/decorators.py +202 -0
- gradient_sdk/errors.py +30 -0
- gradient_sdk/graph.py +190 -0
- gradient_sdk/integrations/__init__.py +7 -0
- gradient_sdk/integrations/agent.py +263 -0
- gradient_sdk/integrations/anthropic.py +51 -0
- gradient_sdk/integrations/openai.py +105 -0
- gradient_sdk/model_catalog.py +75 -0
- gradient_sdk/models.py +370 -0
- gradient_sdk/py.typed +1 -0
- gradient_sdk/runtime.py +1474 -0
- gradient_sdk/session.py +132 -0
- gradient_sdk/tools.py +156 -0
- gradient_sdk/tracing.py +361 -0
- usegradient-1.3.1.dist-info/METADATA +235 -0
- usegradient-1.3.1.dist-info/RECORD +22 -0
- usegradient-1.3.1.dist-info/WHEEL +4 -0
gradient_sdk/runtime.py
ADDED
|
@@ -0,0 +1,1474 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import copy
|
|
5
|
+
import fnmatch
|
|
6
|
+
import hashlib
|
|
7
|
+
import inspect
|
|
8
|
+
import io
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import posixpath
|
|
12
|
+
import sys
|
|
13
|
+
import tarfile
|
|
14
|
+
import time
|
|
15
|
+
from dataclasses import dataclass, field
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Any, Callable, Iterable, Mapping
|
|
18
|
+
|
|
19
|
+
from .client import GradientClient
|
|
20
|
+
from .errors import GradientAPIError
|
|
21
|
+
|
|
22
|
+
DEFAULT_EXCLUDES = [
|
|
23
|
+
".git/**",
|
|
24
|
+
".gradient/**",
|
|
25
|
+
".venv/**",
|
|
26
|
+
"venv/**",
|
|
27
|
+
"__pycache__/**",
|
|
28
|
+
"*.pyc",
|
|
29
|
+
"node_modules/**",
|
|
30
|
+
".next/**",
|
|
31
|
+
"dist/**",
|
|
32
|
+
"build/**",
|
|
33
|
+
".env",
|
|
34
|
+
".env.*",
|
|
35
|
+
"**/.env",
|
|
36
|
+
"**/.env.*",
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(slots=True)
|
|
41
|
+
class TracePolicy:
|
|
42
|
+
preset: str
|
|
43
|
+
semantic: bool
|
|
44
|
+
runtime: bool
|
|
45
|
+
stdout: bool
|
|
46
|
+
stderr: bool
|
|
47
|
+
process: bool
|
|
48
|
+
fs: bool
|
|
49
|
+
syscalls: bool
|
|
50
|
+
network: bool
|
|
51
|
+
sample_rate: float = 1.0
|
|
52
|
+
redactions: list[str] = field(default_factory=list)
|
|
53
|
+
extra: dict[str, Any] = field(default_factory=dict)
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def trace_mode(self) -> str:
|
|
57
|
+
if self.preset == "off":
|
|
58
|
+
return "proxy"
|
|
59
|
+
if self.preset == "semantic":
|
|
60
|
+
return "proxy"
|
|
61
|
+
if self.network or self.fs or self.syscalls:
|
|
62
|
+
return "full"
|
|
63
|
+
if self.process or self.runtime:
|
|
64
|
+
return "process"
|
|
65
|
+
return "proxy"
|
|
66
|
+
|
|
67
|
+
@property
|
|
68
|
+
def trace_exec(self) -> bool:
|
|
69
|
+
return self.preset != "off" and self.runtime
|
|
70
|
+
|
|
71
|
+
def capture_policy(self) -> dict[str, Any]:
|
|
72
|
+
payload = {
|
|
73
|
+
"preset": self.preset,
|
|
74
|
+
"semantic": self.semantic,
|
|
75
|
+
"runtime": self.runtime,
|
|
76
|
+
"stdout": self.stdout,
|
|
77
|
+
"stderr": self.stderr,
|
|
78
|
+
"process": self.process,
|
|
79
|
+
"fs": self.fs,
|
|
80
|
+
"syscalls": self.syscalls,
|
|
81
|
+
"network": self.network,
|
|
82
|
+
"sample_rate": self.sample_rate,
|
|
83
|
+
"sampleRate": self.sample_rate,
|
|
84
|
+
"redactions": list(self.redactions),
|
|
85
|
+
"redactAttributePatterns": list(self.redactions),
|
|
86
|
+
}
|
|
87
|
+
payload.update(self.extra)
|
|
88
|
+
return payload
|
|
89
|
+
|
|
90
|
+
def to_payload(self) -> dict[str, Any]:
|
|
91
|
+
return {
|
|
92
|
+
"trace_mode": self.trace_mode,
|
|
93
|
+
"capture_policy": self.capture_policy(),
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
@classmethod
|
|
97
|
+
def from_value(
|
|
98
|
+
cls,
|
|
99
|
+
value: "TracePolicy | Mapping[str, Any] | str | None",
|
|
100
|
+
*,
|
|
101
|
+
fallback: "TracePolicy | None" = None,
|
|
102
|
+
) -> "TracePolicy":
|
|
103
|
+
if isinstance(value, TracePolicy):
|
|
104
|
+
return value
|
|
105
|
+
if value is None:
|
|
106
|
+
return fallback or Trace.full()
|
|
107
|
+
if isinstance(value, str):
|
|
108
|
+
if value == "off":
|
|
109
|
+
return Trace.off()
|
|
110
|
+
if value == "semantic":
|
|
111
|
+
return Trace.semantic()
|
|
112
|
+
if value == "runtime":
|
|
113
|
+
return Trace.runtime()
|
|
114
|
+
if value == "full":
|
|
115
|
+
return Trace.full()
|
|
116
|
+
return TracePolicy(
|
|
117
|
+
preset=value,
|
|
118
|
+
semantic=value in {"proxy", "full"},
|
|
119
|
+
runtime=value in {"process", "fs", "network", "full"},
|
|
120
|
+
stdout=value in {"process", "fs", "network", "full"},
|
|
121
|
+
stderr=value in {"process", "fs", "network", "full"},
|
|
122
|
+
process=value in {"process", "fs", "network", "full"},
|
|
123
|
+
fs=value in {"fs", "full"},
|
|
124
|
+
syscalls=value in {"process", "fs", "network", "full"},
|
|
125
|
+
network=value in {"network", "full"},
|
|
126
|
+
)
|
|
127
|
+
raw = dict(value)
|
|
128
|
+
preset = str(raw.pop("preset", raw.pop("mode", "custom")))
|
|
129
|
+
sample_rate = raw.pop("sample_rate", None)
|
|
130
|
+
if sample_rate is None:
|
|
131
|
+
sample_rate = raw.pop("sampleRate", None)
|
|
132
|
+
if sample_rate is None:
|
|
133
|
+
sample_rate = raw.pop("sampling_rate", 1.0)
|
|
134
|
+
redactions = raw.pop("redactions", None)
|
|
135
|
+
if redactions is None:
|
|
136
|
+
redactions = raw.pop("redactAttributePatterns", [])
|
|
137
|
+
return TracePolicy(
|
|
138
|
+
preset=preset,
|
|
139
|
+
semantic=bool(raw.pop("semantic", preset in {"semantic", "full"})),
|
|
140
|
+
runtime=bool(raw.pop("runtime", preset in {"runtime", "full"})),
|
|
141
|
+
stdout=bool(raw.pop("stdout", preset in {"runtime", "full"})),
|
|
142
|
+
stderr=bool(raw.pop("stderr", preset in {"runtime", "full"})),
|
|
143
|
+
process=bool(raw.pop("process", preset in {"runtime", "full"})),
|
|
144
|
+
fs=bool(raw.pop("fs", preset in {"runtime", "full"})),
|
|
145
|
+
syscalls=bool(raw.pop("syscalls", preset in {"runtime", "full"})),
|
|
146
|
+
network=bool(raw.pop("network", preset in {"runtime", "full"})),
|
|
147
|
+
sample_rate=float(sample_rate),
|
|
148
|
+
redactions=list(redactions),
|
|
149
|
+
extra=raw,
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
class Trace:
|
|
154
|
+
@staticmethod
|
|
155
|
+
def off(**extra: Any) -> TracePolicy:
|
|
156
|
+
return TracePolicy(
|
|
157
|
+
preset="off",
|
|
158
|
+
semantic=False,
|
|
159
|
+
runtime=False,
|
|
160
|
+
stdout=False,
|
|
161
|
+
stderr=False,
|
|
162
|
+
process=False,
|
|
163
|
+
fs=False,
|
|
164
|
+
syscalls=False,
|
|
165
|
+
network=False,
|
|
166
|
+
extra=dict(extra),
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
@staticmethod
|
|
170
|
+
def semantic(**extra: Any) -> TracePolicy:
|
|
171
|
+
return TracePolicy(
|
|
172
|
+
preset="semantic",
|
|
173
|
+
semantic=True,
|
|
174
|
+
runtime=False,
|
|
175
|
+
stdout=False,
|
|
176
|
+
stderr=False,
|
|
177
|
+
process=False,
|
|
178
|
+
fs=False,
|
|
179
|
+
syscalls=False,
|
|
180
|
+
network=False,
|
|
181
|
+
extra=dict(extra),
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
@staticmethod
|
|
185
|
+
def runtime(**extra: Any) -> TracePolicy:
|
|
186
|
+
return TracePolicy(
|
|
187
|
+
preset="runtime",
|
|
188
|
+
semantic=False,
|
|
189
|
+
runtime=True,
|
|
190
|
+
stdout=True,
|
|
191
|
+
stderr=True,
|
|
192
|
+
process=True,
|
|
193
|
+
fs=True,
|
|
194
|
+
syscalls=True,
|
|
195
|
+
network=True,
|
|
196
|
+
extra=dict(extra),
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
@staticmethod
|
|
200
|
+
def full(**extra: Any) -> TracePolicy:
|
|
201
|
+
return TracePolicy(
|
|
202
|
+
preset="full",
|
|
203
|
+
semantic=True,
|
|
204
|
+
runtime=True,
|
|
205
|
+
stdout=True,
|
|
206
|
+
stderr=True,
|
|
207
|
+
process=True,
|
|
208
|
+
fs=True,
|
|
209
|
+
syscalls=True,
|
|
210
|
+
network=True,
|
|
211
|
+
extra=dict(extra),
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
@dataclass(slots=True)
|
|
216
|
+
class Environment:
|
|
217
|
+
runtime: str = "python"
|
|
218
|
+
python_version: str = "3.12"
|
|
219
|
+
node_version: str = "22"
|
|
220
|
+
vcpu: int = 2
|
|
221
|
+
memory_gb: int = 4
|
|
222
|
+
disk_gb: int = 10
|
|
223
|
+
region: str = "sjc"
|
|
224
|
+
packages: list[str] = field(default_factory=list)
|
|
225
|
+
system_packages: list[str] = field(default_factory=list)
|
|
226
|
+
env: dict[str, str] = field(default_factory=dict)
|
|
227
|
+
image: str | None = None
|
|
228
|
+
install_at_runtime: bool = False
|
|
229
|
+
|
|
230
|
+
@classmethod
|
|
231
|
+
def python(
|
|
232
|
+
cls,
|
|
233
|
+
*,
|
|
234
|
+
version: str = "3.12",
|
|
235
|
+
python: str | None = None,
|
|
236
|
+
vcpu: int = 2,
|
|
237
|
+
memory_gb: int = 4,
|
|
238
|
+
disk_gb: int = 10,
|
|
239
|
+
packages: Iterable[str] | None = None,
|
|
240
|
+
system_packages: Iterable[str] | None = None,
|
|
241
|
+
env: Mapping[str, str] | None = None,
|
|
242
|
+
image: str | None = None,
|
|
243
|
+
install_at_runtime: bool = False,
|
|
244
|
+
region: str = "sjc",
|
|
245
|
+
) -> "Environment":
|
|
246
|
+
return cls(
|
|
247
|
+
runtime="python",
|
|
248
|
+
python_version=python or version,
|
|
249
|
+
vcpu=vcpu,
|
|
250
|
+
memory_gb=memory_gb,
|
|
251
|
+
disk_gb=disk_gb,
|
|
252
|
+
packages=list(packages or []),
|
|
253
|
+
system_packages=list(system_packages or []),
|
|
254
|
+
env=dict(env or {}),
|
|
255
|
+
image=image,
|
|
256
|
+
install_at_runtime=install_at_runtime,
|
|
257
|
+
region=region,
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
@classmethod
|
|
261
|
+
def node(
|
|
262
|
+
cls,
|
|
263
|
+
*,
|
|
264
|
+
version: str = "22",
|
|
265
|
+
node: str | None = None,
|
|
266
|
+
vcpu: int = 2,
|
|
267
|
+
memory_gb: int = 4,
|
|
268
|
+
disk_gb: int = 10,
|
|
269
|
+
packages: Iterable[str] | None = None,
|
|
270
|
+
system_packages: Iterable[str] | None = None,
|
|
271
|
+
env: Mapping[str, str] | None = None,
|
|
272
|
+
image: str | None = None,
|
|
273
|
+
install_at_runtime: bool = False,
|
|
274
|
+
region: str = "sjc",
|
|
275
|
+
) -> "Environment":
|
|
276
|
+
return cls(
|
|
277
|
+
runtime="node",
|
|
278
|
+
node_version=node or version,
|
|
279
|
+
vcpu=vcpu,
|
|
280
|
+
memory_gb=memory_gb,
|
|
281
|
+
disk_gb=disk_gb,
|
|
282
|
+
packages=list(packages or []),
|
|
283
|
+
system_packages=list(system_packages or []),
|
|
284
|
+
env=dict(env or {}),
|
|
285
|
+
image=image,
|
|
286
|
+
install_at_runtime=install_at_runtime,
|
|
287
|
+
region=region,
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
def to_spec(self) -> dict[str, Any]:
|
|
291
|
+
spec: dict[str, Any] = {
|
|
292
|
+
"runtime": self.runtime,
|
|
293
|
+
"python": self.python_version,
|
|
294
|
+
"node": self.node_version,
|
|
295
|
+
"vcpu": self.vcpu,
|
|
296
|
+
"memory_gb": self.memory_gb,
|
|
297
|
+
"disk_gb": self.disk_gb,
|
|
298
|
+
"region": self.region,
|
|
299
|
+
"packages": self.packages,
|
|
300
|
+
"system_packages": self.system_packages,
|
|
301
|
+
"env": self.env,
|
|
302
|
+
"install_at_runtime": self.install_at_runtime,
|
|
303
|
+
}
|
|
304
|
+
if self.image:
|
|
305
|
+
spec["image"] = self.image
|
|
306
|
+
return spec
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
@dataclass(slots=True)
|
|
310
|
+
class ProgressEvent:
|
|
311
|
+
stage: str
|
|
312
|
+
status: str
|
|
313
|
+
message: str
|
|
314
|
+
duration_ms: int = 0
|
|
315
|
+
cached: bool = False
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
@dataclass(slots=True)
|
|
319
|
+
class BuildHandle:
|
|
320
|
+
id: str
|
|
321
|
+
environment_hash: str
|
|
322
|
+
source_hash: str
|
|
323
|
+
image_ref: str
|
|
324
|
+
cache: dict[str, Any]
|
|
325
|
+
progress: list[ProgressEvent]
|
|
326
|
+
timings: dict[str, int]
|
|
327
|
+
environment_version_id: str = ""
|
|
328
|
+
source_manifest: dict[str, Any] = field(default_factory=dict)
|
|
329
|
+
environment_spec: dict[str, Any] = field(default_factory=dict)
|
|
330
|
+
client: GradientClient | None = field(default=None, repr=False)
|
|
331
|
+
|
|
332
|
+
def logs(self) -> list[str]:
|
|
333
|
+
return [event.message for event in self.progress]
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
@dataclass(slots=True)
|
|
337
|
+
class RunHandle:
|
|
338
|
+
run_id: str
|
|
339
|
+
machine_id: str
|
|
340
|
+
environment_version_id: str
|
|
341
|
+
project: str
|
|
342
|
+
stdout: str
|
|
343
|
+
stderr: str
|
|
344
|
+
exit_code: int
|
|
345
|
+
proxy_url: str
|
|
346
|
+
progress: list[ProgressEvent]
|
|
347
|
+
timings: dict[str, int]
|
|
348
|
+
status: str = "completed"
|
|
349
|
+
client: GradientClient | None = field(default=None, repr=False)
|
|
350
|
+
input: Any | None = field(default=None, repr=False)
|
|
351
|
+
|
|
352
|
+
@property
|
|
353
|
+
def id(self) -> str:
|
|
354
|
+
return self.run_id
|
|
355
|
+
|
|
356
|
+
@property
|
|
357
|
+
def build_id(self) -> str:
|
|
358
|
+
return self.environment_version_id
|
|
359
|
+
|
|
360
|
+
@property
|
|
361
|
+
def trace_id(self) -> str:
|
|
362
|
+
return self.run_id
|
|
363
|
+
|
|
364
|
+
@property
|
|
365
|
+
def trace_url(self) -> str:
|
|
366
|
+
if self.client is None:
|
|
367
|
+
return ""
|
|
368
|
+
return f"{self.client.api_url.rstrip('/')}/traces/{self.run_id}"
|
|
369
|
+
|
|
370
|
+
def result(self) -> "RunHandle":
|
|
371
|
+
return self
|
|
372
|
+
|
|
373
|
+
def cancel(self) -> None:
|
|
374
|
+
if self.client is None:
|
|
375
|
+
raise RuntimeError("RunHandle.cancel requires a Gradient client")
|
|
376
|
+
if self.machine_id:
|
|
377
|
+
self.client.delete_machine(self.machine_id)
|
|
378
|
+
if self.run_id:
|
|
379
|
+
try:
|
|
380
|
+
self.client.finish_trace_run(self.run_id)
|
|
381
|
+
except Exception:
|
|
382
|
+
pass
|
|
383
|
+
self.status = "cancelled"
|
|
384
|
+
|
|
385
|
+
def replay(
|
|
386
|
+
self,
|
|
387
|
+
*,
|
|
388
|
+
seed: int | None = None,
|
|
389
|
+
freeze_time: str | None = None,
|
|
390
|
+
egress: str | None = None,
|
|
391
|
+
trace: TracePolicy | Mapping[str, Any] | str | None = None,
|
|
392
|
+
) -> "RunHandle":
|
|
393
|
+
if self.client is None:
|
|
394
|
+
raise RuntimeError("RunHandle.replay requires a Gradient client")
|
|
395
|
+
policy = TracePolicy.from_value(trace)
|
|
396
|
+
replay_config = {
|
|
397
|
+
key: value
|
|
398
|
+
for key, value in {
|
|
399
|
+
"seed": seed,
|
|
400
|
+
"freeze_time": freeze_time,
|
|
401
|
+
"egress": egress,
|
|
402
|
+
}.items()
|
|
403
|
+
if value is not None
|
|
404
|
+
}
|
|
405
|
+
replayed = self.client.replay_machine(
|
|
406
|
+
self.run_id,
|
|
407
|
+
trace_mode=policy.trace_mode,
|
|
408
|
+
capture_policy=policy.capture_policy(),
|
|
409
|
+
replay_config=replay_config or None,
|
|
410
|
+
metadata={"project": self.project, "sdk_surface": "RunHandle.replay"},
|
|
411
|
+
)
|
|
412
|
+
run = replayed["trace_run"]
|
|
413
|
+
machine = replayed["machine"]
|
|
414
|
+
return RunHandle(
|
|
415
|
+
run_id=str(run["id"]),
|
|
416
|
+
machine_id=str(machine["id"]),
|
|
417
|
+
environment_version_id=self.environment_version_id,
|
|
418
|
+
project=self.project,
|
|
419
|
+
stdout="",
|
|
420
|
+
stderr="",
|
|
421
|
+
exit_code=0,
|
|
422
|
+
proxy_url=str(replayed.get("full_proxy_url", "")),
|
|
423
|
+
progress=[],
|
|
424
|
+
timings={},
|
|
425
|
+
status=str(run.get("status", "active")),
|
|
426
|
+
client=self.client,
|
|
427
|
+
input=self.input,
|
|
428
|
+
)
|
|
429
|
+
|
|
430
|
+
def events(self, *, limit: int = 1000) -> list[Any]:
|
|
431
|
+
if self.client is None:
|
|
432
|
+
return []
|
|
433
|
+
return self.client.list_trace_events(self.run_id, limit=limit)
|
|
434
|
+
|
|
435
|
+
def logs(self) -> list[dict[str, str]]:
|
|
436
|
+
rows = []
|
|
437
|
+
if self.stdout:
|
|
438
|
+
rows.append({"stream": "stdout", "message": self.stdout})
|
|
439
|
+
if self.stderr:
|
|
440
|
+
rows.append({"stream": "stderr", "message": self.stderr})
|
|
441
|
+
return rows
|
|
442
|
+
|
|
443
|
+
def spans(self, **filters: Any) -> list[Any]:
|
|
444
|
+
if self.client is None:
|
|
445
|
+
return []
|
|
446
|
+
return self.client.list_trace_spans(self.run_id, **filters)
|
|
447
|
+
|
|
448
|
+
def artifacts(self) -> list[dict[str, Any]]:
|
|
449
|
+
return []
|
|
450
|
+
|
|
451
|
+
def to_dataset(
|
|
452
|
+
self,
|
|
453
|
+
*,
|
|
454
|
+
name: str | None = None,
|
|
455
|
+
dataset_id: str | None = None,
|
|
456
|
+
expected_output: Any | None = None,
|
|
457
|
+
metadata: Mapping[str, Any] | None = None,
|
|
458
|
+
) -> dict[str, Any]:
|
|
459
|
+
if self.client is None:
|
|
460
|
+
raise RuntimeError("RunHandle.to_dataset requires a Gradient client")
|
|
461
|
+
if not dataset_id:
|
|
462
|
+
if not name:
|
|
463
|
+
name = f"{self.project}-runs"
|
|
464
|
+
dataset_id = str(self.client.create_dataset(name)["id"])
|
|
465
|
+
return self.client.add_dataset_example(
|
|
466
|
+
dataset_id,
|
|
467
|
+
input=self.input,
|
|
468
|
+
expected_output=expected_output if expected_output is not None else self.stdout,
|
|
469
|
+
source_trace_id=self.trace_id,
|
|
470
|
+
metadata={"source_run_id": self.run_id, **dict(metadata or {})},
|
|
471
|
+
)
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
RemoteRunResult = RunHandle
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
@dataclass(slots=True)
|
|
478
|
+
class RemoteDeployment:
|
|
479
|
+
name: str
|
|
480
|
+
project: str
|
|
481
|
+
environment_version_id: str
|
|
482
|
+
machine_ids: list[str]
|
|
483
|
+
proxy_url: str
|
|
484
|
+
stable_url: str
|
|
485
|
+
trace_run_id: str | None
|
|
486
|
+
status: str
|
|
487
|
+
progress: list[ProgressEvent]
|
|
488
|
+
timings: dict[str, int]
|
|
489
|
+
client: GradientClient = field(repr=False)
|
|
490
|
+
|
|
491
|
+
@property
|
|
492
|
+
def url(self) -> str:
|
|
493
|
+
return self.stable_url or self.proxy_url
|
|
494
|
+
|
|
495
|
+
@property
|
|
496
|
+
def revision(self) -> str:
|
|
497
|
+
return self.environment_version_id
|
|
498
|
+
|
|
499
|
+
def refresh(self) -> "RemoteDeployment":
|
|
500
|
+
return _deployment_from_response(
|
|
501
|
+
self.client.get_deployment(self.name),
|
|
502
|
+
self.client,
|
|
503
|
+
self.progress,
|
|
504
|
+
self.timings,
|
|
505
|
+
)
|
|
506
|
+
|
|
507
|
+
def stop(self) -> "RemoteDeployment":
|
|
508
|
+
return _deployment_from_response(
|
|
509
|
+
self.client.stop_deployment(self.name),
|
|
510
|
+
self.client,
|
|
511
|
+
self.progress,
|
|
512
|
+
self.timings,
|
|
513
|
+
)
|
|
514
|
+
|
|
515
|
+
def start(self) -> "RemoteDeployment":
|
|
516
|
+
return _deployment_from_response(
|
|
517
|
+
self.client.start_deployment(self.name),
|
|
518
|
+
self.client,
|
|
519
|
+
self.progress,
|
|
520
|
+
self.timings,
|
|
521
|
+
)
|
|
522
|
+
|
|
523
|
+
def scale(self, machines: int) -> "RemoteDeployment":
|
|
524
|
+
return _deployment_from_response(
|
|
525
|
+
self.client.scale_deployment(self.name, machines),
|
|
526
|
+
self.client,
|
|
527
|
+
self.progress,
|
|
528
|
+
self.timings,
|
|
529
|
+
)
|
|
530
|
+
|
|
531
|
+
def delete(self) -> dict[str, Any]:
|
|
532
|
+
return self.client.delete_deployment(self.name)
|
|
533
|
+
|
|
534
|
+
def runs(self, *, limit: int = 50) -> list[Any]:
|
|
535
|
+
return self.client.list_trace_runs(limit=limit)
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
DeploymentHandle = RemoteDeployment
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
class Gradient:
|
|
542
|
+
def __init__(
|
|
543
|
+
self,
|
|
544
|
+
project: str | None = None,
|
|
545
|
+
environment: Environment | Mapping[str, Any] | None = None,
|
|
546
|
+
source: str | Path | None = None,
|
|
547
|
+
*,
|
|
548
|
+
client: GradientClient | None = None,
|
|
549
|
+
show_progress: bool = True,
|
|
550
|
+
keep_machine: bool = False,
|
|
551
|
+
source_root: str | Path | None = None,
|
|
552
|
+
excludes: Iterable[str] | None = None,
|
|
553
|
+
env_file: bool | str = True,
|
|
554
|
+
env_files: Iterable[str | Path] | None = None,
|
|
555
|
+
trace: TracePolicy | Mapping[str, Any] | str | None = None,
|
|
556
|
+
) -> None:
|
|
557
|
+
if project is None:
|
|
558
|
+
raise TypeError("project is required")
|
|
559
|
+
if env_file or env_files:
|
|
560
|
+
for path in _env_file_candidates(env_file, env_files):
|
|
561
|
+
self.load_env(path)
|
|
562
|
+
self.project = project
|
|
563
|
+
if isinstance(environment, Environment):
|
|
564
|
+
self.environment = environment
|
|
565
|
+
else:
|
|
566
|
+
env_values = dict(environment or {})
|
|
567
|
+
if "python" in env_values and "python_version" not in env_values:
|
|
568
|
+
env_values["python_version"] = env_values.pop("python")
|
|
569
|
+
if "node" in env_values and "node_version" not in env_values:
|
|
570
|
+
env_values["node_version"] = env_values.pop("node")
|
|
571
|
+
self.environment = Environment(**env_values)
|
|
572
|
+
self.client = client or GradientClient.from_credentials()
|
|
573
|
+
self.show_progress = show_progress
|
|
574
|
+
self.keep_machine = keep_machine
|
|
575
|
+
self.source_root = Path(source_root or source or Path.cwd()).resolve()
|
|
576
|
+
self.excludes = [*DEFAULT_EXCLUDES, *(excludes or [])]
|
|
577
|
+
self.progress: list[ProgressEvent] = []
|
|
578
|
+
self.default_trace = TracePolicy.from_value(trace)
|
|
579
|
+
|
|
580
|
+
def load_env(self, path: str | Path = ".env", *, override: bool = False) -> dict[str, str]:
|
|
581
|
+
loaded = _load_env_file(Path(path), override=override)
|
|
582
|
+
return loaded
|
|
583
|
+
|
|
584
|
+
def build(
|
|
585
|
+
self,
|
|
586
|
+
target: Callable[[Any], Any] | None = None,
|
|
587
|
+
*,
|
|
588
|
+
display_name: str | None = None,
|
|
589
|
+
) -> BuildHandle:
|
|
590
|
+
timings: dict[str, int] = {}
|
|
591
|
+
self.progress = []
|
|
592
|
+
self._stage("credentials", "ready", "Loaded Gradient credentials")
|
|
593
|
+
|
|
594
|
+
source = _package_source(self.source_root, target, self.excludes)
|
|
595
|
+
timings["package_source_ms"] = source.duration_ms
|
|
596
|
+
self._stage(
|
|
597
|
+
"source",
|
|
598
|
+
"ready",
|
|
599
|
+
f"Packaged {source.file_count} files ({len(source.archive_b64)} base64 bytes), source hash {source.source_hash[:12]}",
|
|
600
|
+
source.duration_ms,
|
|
601
|
+
)
|
|
602
|
+
|
|
603
|
+
t0 = time.perf_counter()
|
|
604
|
+
resolved = self.client.resolve_environment(
|
|
605
|
+
project=self.project,
|
|
606
|
+
environment=self.environment.to_spec(),
|
|
607
|
+
source=source.manifest,
|
|
608
|
+
display_name=display_name,
|
|
609
|
+
)
|
|
610
|
+
timings["resolve_environment_ms"] = _elapsed_ms(t0)
|
|
611
|
+
data = resolved["data"] if "data" in resolved and "environmentVersion" in resolved["data"] else resolved
|
|
612
|
+
for item in data.get("progress", []):
|
|
613
|
+
self._stage(
|
|
614
|
+
str(item.get("stage", "environment")),
|
|
615
|
+
str(item.get("status", "ready")),
|
|
616
|
+
str(item.get("message", "")),
|
|
617
|
+
int(item.get("duration_ms", 0) or 0),
|
|
618
|
+
)
|
|
619
|
+
env_version = data["environmentVersion"]
|
|
620
|
+
environment_spec = dict(env_version.get("environment_spec") or self.environment.to_spec())
|
|
621
|
+
source_manifest = dict(env_version.get("source_manifest") or source.manifest)
|
|
622
|
+
cache = dict(data.get("cache") or {})
|
|
623
|
+
self._stage(
|
|
624
|
+
"build",
|
|
625
|
+
"cached" if cache.get("environment") == "hit" else "ready",
|
|
626
|
+
f"Resolved build {env_version['id']} for source {source.source_hash[:12]}",
|
|
627
|
+
0,
|
|
628
|
+
cached=cache.get("environment") == "hit",
|
|
629
|
+
)
|
|
630
|
+
return BuildHandle(
|
|
631
|
+
id=str(env_version["id"]),
|
|
632
|
+
environment_version_id=str(env_version["id"]),
|
|
633
|
+
environment_hash=str(env_version.get("spec_hash") or _stable_hash(environment_spec)),
|
|
634
|
+
source_hash=str(env_version.get("source_hash") or source.source_hash),
|
|
635
|
+
image_ref=str(env_version.get("image_ref") or env_version.get("base_image") or ""),
|
|
636
|
+
cache=cache,
|
|
637
|
+
progress=list(self.progress),
|
|
638
|
+
timings=timings,
|
|
639
|
+
source_manifest=source_manifest,
|
|
640
|
+
environment_spec=environment_spec,
|
|
641
|
+
client=self.client,
|
|
642
|
+
)
|
|
643
|
+
|
|
644
|
+
def run(
|
|
645
|
+
self,
|
|
646
|
+
target: Callable[[Any], Any],
|
|
647
|
+
input: Any | None = None,
|
|
648
|
+
*,
|
|
649
|
+
trace: TracePolicy | Mapping[str, Any] | str | None = None,
|
|
650
|
+
trace_mode: str | None = None,
|
|
651
|
+
keep: bool | None = None,
|
|
652
|
+
wait_timeout_seconds: int = 60,
|
|
653
|
+
run_timeout_seconds: int = 300,
|
|
654
|
+
watch: Iterable[str] | None = None,
|
|
655
|
+
) -> RunHandle:
|
|
656
|
+
if os.getenv("GRADIENT_REMOTE_EXECUTION") == "1":
|
|
657
|
+
return target(input) # type: ignore[return-value]
|
|
658
|
+
|
|
659
|
+
trace_policy = _trace_policy(trace, trace_mode, self.default_trace)
|
|
660
|
+
keep_machine = self.keep_machine if keep is None else keep
|
|
661
|
+
timings: dict[str, int] = {}
|
|
662
|
+
overall = time.perf_counter()
|
|
663
|
+
self.progress = []
|
|
664
|
+
|
|
665
|
+
self._stage("credentials", "ready", "Loaded GRADIENT_API_KEY and default Gradient endpoints")
|
|
666
|
+
t0 = time.perf_counter()
|
|
667
|
+
self.client.ensure_routable()
|
|
668
|
+
timings["ensure_routable_ms"] = _elapsed_ms(t0)
|
|
669
|
+
self._stage("routing", "ready", "Gradient proxy routing is ready", timings["ensure_routable_ms"])
|
|
670
|
+
|
|
671
|
+
source = _package_source(self.source_root, target, self.excludes)
|
|
672
|
+
timings["package_source_ms"] = source.duration_ms
|
|
673
|
+
self._stage(
|
|
674
|
+
"source",
|
|
675
|
+
"ready",
|
|
676
|
+
f"Packaged {source.file_count} files ({len(source.archive_b64)} base64 bytes), source hash {source.source_hash[:12]}",
|
|
677
|
+
source.duration_ms,
|
|
678
|
+
)
|
|
679
|
+
|
|
680
|
+
t0 = time.perf_counter()
|
|
681
|
+
resolved = self.client.resolve_environment(
|
|
682
|
+
project=self.project,
|
|
683
|
+
environment=self.environment.to_spec(),
|
|
684
|
+
source=source.manifest,
|
|
685
|
+
)
|
|
686
|
+
timings["resolve_environment_ms"] = _elapsed_ms(t0)
|
|
687
|
+
data = resolved["data"] if "data" in resolved and "environmentVersion" in resolved["data"] else resolved
|
|
688
|
+
for item in data.get("progress", []):
|
|
689
|
+
self._stage(
|
|
690
|
+
str(item.get("stage", "environment")),
|
|
691
|
+
str(item.get("status", "ready")),
|
|
692
|
+
str(item.get("message", "")),
|
|
693
|
+
int(item.get("duration_ms", 0) or 0),
|
|
694
|
+
)
|
|
695
|
+
env_version = data["environmentVersion"]
|
|
696
|
+
template = env_version["machine_template"]
|
|
697
|
+
|
|
698
|
+
t0 = time.perf_counter()
|
|
699
|
+
traced = self.client.create_traced_machine(
|
|
700
|
+
template,
|
|
701
|
+
machine_template=_with_source_bundle(template, source),
|
|
702
|
+
trace_mode=trace_policy.trace_mode,
|
|
703
|
+
capture_policy=trace_policy.capture_policy(),
|
|
704
|
+
metadata={
|
|
705
|
+
"project": self.project,
|
|
706
|
+
"environment_version_id": env_version["id"],
|
|
707
|
+
"source_hash": source.source_hash,
|
|
708
|
+
"entrypoint": source.entrypoint,
|
|
709
|
+
"target": source.target_ref,
|
|
710
|
+
"trace_preset": trace_policy.preset,
|
|
711
|
+
"sdk_surface": "Gradient.run",
|
|
712
|
+
},
|
|
713
|
+
wait=True,
|
|
714
|
+
wait_timeout_seconds=wait_timeout_seconds,
|
|
715
|
+
)
|
|
716
|
+
timings["cold_start_ms"] = _elapsed_ms(t0)
|
|
717
|
+
machine = traced["machine"]
|
|
718
|
+
machine_id = machine["id"]
|
|
719
|
+
run_id = traced["trace_run"]["id"]
|
|
720
|
+
proxy_url = traced["full_proxy_url"]
|
|
721
|
+
self._stage("cold-start", "ready", f"Started machine {machine_id} for trace {run_id}", timings["cold_start_ms"])
|
|
722
|
+
|
|
723
|
+
try:
|
|
724
|
+
timings["upload_source_ms"] = 0
|
|
725
|
+
self._stage("upload", "ready", "Mounted source bundle when the machine was created")
|
|
726
|
+
|
|
727
|
+
setup_command = env_version.get("setup_command")
|
|
728
|
+
if setup_command and self.environment.install_at_runtime:
|
|
729
|
+
t0 = time.perf_counter()
|
|
730
|
+
setup = self._exec_machine_with_retry(
|
|
731
|
+
machine_id,
|
|
732
|
+
command=["sh", "-lc", f"cd /workspace && {setup_command}"],
|
|
733
|
+
timeout_seconds=run_timeout_seconds,
|
|
734
|
+
trace=False,
|
|
735
|
+
stage="dependencies",
|
|
736
|
+
)
|
|
737
|
+
timings["runtime_setup_ms"] = _elapsed_ms(t0)
|
|
738
|
+
self._stage(
|
|
739
|
+
"dependencies",
|
|
740
|
+
"ready" if setup.exit_code == 0 else "failed",
|
|
741
|
+
"Runtime dependency setup completed" if setup.exit_code == 0 else setup.stderr,
|
|
742
|
+
timings["runtime_setup_ms"],
|
|
743
|
+
)
|
|
744
|
+
if setup.exit_code != 0:
|
|
745
|
+
raise RuntimeError(setup.stderr or setup.stdout)
|
|
746
|
+
else:
|
|
747
|
+
self._stage("dependencies", "cached", "Skipped runtime install; using cached environment image", cached=True)
|
|
748
|
+
|
|
749
|
+
payload = base64.b64encode(json.dumps(input).encode("utf-8")).decode("ascii")
|
|
750
|
+
remote_code = _remote_bootstrap(source.target_ref, payload)
|
|
751
|
+
t0 = time.perf_counter()
|
|
752
|
+
result = self._exec_machine_with_retry(
|
|
753
|
+
machine_id,
|
|
754
|
+
command=["python", "-c", remote_code],
|
|
755
|
+
timeout_seconds=run_timeout_seconds,
|
|
756
|
+
trace=trace_policy.trace_exec,
|
|
757
|
+
watch=list(watch or ["/workspace"]),
|
|
758
|
+
stage="run",
|
|
759
|
+
)
|
|
760
|
+
timings["remote_run_ms"] = _elapsed_ms(t0)
|
|
761
|
+
self._stage(
|
|
762
|
+
"run",
|
|
763
|
+
"ready" if result.exit_code == 0 else "failed",
|
|
764
|
+
f"Remote agent exited with {result.exit_code}",
|
|
765
|
+
timings["remote_run_ms"],
|
|
766
|
+
)
|
|
767
|
+
|
|
768
|
+
timings["total_ms"] = _elapsed_ms(overall)
|
|
769
|
+
return RunHandle(
|
|
770
|
+
run_id=run_id,
|
|
771
|
+
machine_id=machine_id,
|
|
772
|
+
environment_version_id=str(env_version["id"]),
|
|
773
|
+
project=self.project,
|
|
774
|
+
stdout=result.stdout,
|
|
775
|
+
stderr=result.stderr,
|
|
776
|
+
exit_code=result.exit_code,
|
|
777
|
+
proxy_url=proxy_url,
|
|
778
|
+
progress=list(self.progress),
|
|
779
|
+
timings=timings,
|
|
780
|
+
status="completed" if result.exit_code == 0 else "failed",
|
|
781
|
+
client=self.client,
|
|
782
|
+
input=input,
|
|
783
|
+
)
|
|
784
|
+
finally:
|
|
785
|
+
if not keep_machine:
|
|
786
|
+
t0 = time.perf_counter()
|
|
787
|
+
try:
|
|
788
|
+
self.client.delete_machine(machine_id)
|
|
789
|
+
self._stage("cleanup", "ready", f"Deleted machine {machine_id}", _elapsed_ms(t0))
|
|
790
|
+
except Exception as exc:
|
|
791
|
+
self._stage("cleanup", "failed", f"Could not delete machine {machine_id}: {exc}", _elapsed_ms(t0))
|
|
792
|
+
|
|
793
|
+
def deploy(
|
|
794
|
+
self,
|
|
795
|
+
target: Callable[[Any], Any],
|
|
796
|
+
*,
|
|
797
|
+
name: str | None = None,
|
|
798
|
+
trace: TracePolicy | Mapping[str, Any] | str | None = None,
|
|
799
|
+
trace_mode: str | None = None,
|
|
800
|
+
scale: int | None = None,
|
|
801
|
+
wait_timeout_seconds: int = 60,
|
|
802
|
+
run_timeout_seconds: int = 300,
|
|
803
|
+
watch: Iterable[str] | None = None,
|
|
804
|
+
min_machines: int = 1,
|
|
805
|
+
max_machines: int = 1,
|
|
806
|
+
) -> RemoteDeployment:
|
|
807
|
+
if os.getenv("GRADIENT_REMOTE_EXECUTION") == "1":
|
|
808
|
+
raise RuntimeError("Gradient.deploy cannot be called from inside a remote Gradient machine")
|
|
809
|
+
|
|
810
|
+
trace_policy = _trace_policy(trace, trace_mode, self.default_trace)
|
|
811
|
+
if scale is not None:
|
|
812
|
+
min_machines = scale
|
|
813
|
+
max_machines = scale
|
|
814
|
+
timings: dict[str, int] = {}
|
|
815
|
+
overall = time.perf_counter()
|
|
816
|
+
self.progress = []
|
|
817
|
+
|
|
818
|
+
deployment_name = name or self.project
|
|
819
|
+
self._stage("credentials", "ready", "Loaded GRADIENT_API_KEY and default Gradient endpoints")
|
|
820
|
+
t0 = time.perf_counter()
|
|
821
|
+
self.client.ensure_routable()
|
|
822
|
+
timings["ensure_routable_ms"] = _elapsed_ms(t0)
|
|
823
|
+
self._stage("routing", "ready", "Gradient proxy routing is ready", timings["ensure_routable_ms"])
|
|
824
|
+
|
|
825
|
+
source = _package_source(self.source_root, target, self.excludes)
|
|
826
|
+
timings["package_source_ms"] = source.duration_ms
|
|
827
|
+
self._stage(
|
|
828
|
+
"source",
|
|
829
|
+
"ready",
|
|
830
|
+
f"Packaged {source.file_count} files ({len(source.archive_b64)} base64 bytes), source hash {source.source_hash[:12]}",
|
|
831
|
+
source.duration_ms,
|
|
832
|
+
)
|
|
833
|
+
|
|
834
|
+
t0 = time.perf_counter()
|
|
835
|
+
resolved = self.client.resolve_environment(
|
|
836
|
+
project=self.project,
|
|
837
|
+
environment=self.environment.to_spec(),
|
|
838
|
+
source=source.manifest,
|
|
839
|
+
)
|
|
840
|
+
timings["resolve_environment_ms"] = _elapsed_ms(t0)
|
|
841
|
+
data = resolved["data"] if "data" in resolved and "environmentVersion" in resolved["data"] else resolved
|
|
842
|
+
for item in data.get("progress", []):
|
|
843
|
+
self._stage(
|
|
844
|
+
str(item.get("stage", "environment")),
|
|
845
|
+
str(item.get("status", "ready")),
|
|
846
|
+
str(item.get("message", "")),
|
|
847
|
+
int(item.get("duration_ms", 0) or 0),
|
|
848
|
+
)
|
|
849
|
+
env_version = data["environmentVersion"]
|
|
850
|
+
|
|
851
|
+
t0 = time.perf_counter()
|
|
852
|
+
deployment_data = self.client.create_deployment(
|
|
853
|
+
name=deployment_name,
|
|
854
|
+
project=self.project,
|
|
855
|
+
template=_with_source_bundle(
|
|
856
|
+
env_version["machine_template"],
|
|
857
|
+
source,
|
|
858
|
+
service_code=_remote_service_bootstrap(source.target_ref),
|
|
859
|
+
),
|
|
860
|
+
environment_version_id=str(env_version["id"]),
|
|
861
|
+
trace_mode=trace_policy.trace_mode,
|
|
862
|
+
capture_policy=trace_policy.capture_policy(),
|
|
863
|
+
metadata={
|
|
864
|
+
"source_hash": source.source_hash,
|
|
865
|
+
"entrypoint": source.entrypoint,
|
|
866
|
+
"target": source.target_ref,
|
|
867
|
+
"trace_preset": trace_policy.preset,
|
|
868
|
+
},
|
|
869
|
+
min_machines=min_machines,
|
|
870
|
+
max_machines=max_machines,
|
|
871
|
+
)
|
|
872
|
+
timings["deploy_create_ms"] = _elapsed_ms(t0)
|
|
873
|
+
deployment = _deployment_from_response(
|
|
874
|
+
deployment_data,
|
|
875
|
+
self.client,
|
|
876
|
+
self.progress,
|
|
877
|
+
timings,
|
|
878
|
+
)
|
|
879
|
+
self._stage(
|
|
880
|
+
"deploy",
|
|
881
|
+
"ready",
|
|
882
|
+
f"Created deployment {deployment.name} with {len(deployment.machine_ids)} machine(s)",
|
|
883
|
+
timings["deploy_create_ms"],
|
|
884
|
+
)
|
|
885
|
+
|
|
886
|
+
if deployment.machine_ids:
|
|
887
|
+
machine_id = deployment.machine_ids[0]
|
|
888
|
+
t0 = time.perf_counter()
|
|
889
|
+
try:
|
|
890
|
+
self.client.wait_machine_state(machine_id, timeout_seconds=wait_timeout_seconds)
|
|
891
|
+
except Exception:
|
|
892
|
+
pass
|
|
893
|
+
timings["deploy_wait_ms"] = _elapsed_ms(t0)
|
|
894
|
+
self._stage("cold-start", "ready", f"Deployment machine {machine_id} is accepting setup", timings["deploy_wait_ms"])
|
|
895
|
+
|
|
896
|
+
timings["upload_source_ms"] = 0
|
|
897
|
+
self._stage("upload", "ready", "Mounted deployment source bundle at machine creation")
|
|
898
|
+
|
|
899
|
+
setup_command = env_version.get("setup_command")
|
|
900
|
+
if setup_command and self.environment.install_at_runtime:
|
|
901
|
+
t0 = time.perf_counter()
|
|
902
|
+
setup = self._exec_machine_with_retry(
|
|
903
|
+
machine_id,
|
|
904
|
+
command=["sh", "-lc", f"cd /workspace && {setup_command}"],
|
|
905
|
+
timeout_seconds=run_timeout_seconds,
|
|
906
|
+
trace=False,
|
|
907
|
+
stage="dependencies",
|
|
908
|
+
)
|
|
909
|
+
timings["runtime_setup_ms"] = _elapsed_ms(t0)
|
|
910
|
+
self._stage(
|
|
911
|
+
"dependencies",
|
|
912
|
+
"ready" if setup.exit_code == 0 else "failed",
|
|
913
|
+
"Runtime dependency setup completed" if setup.exit_code == 0 else setup.stderr,
|
|
914
|
+
timings["runtime_setup_ms"],
|
|
915
|
+
)
|
|
916
|
+
if setup.exit_code != 0:
|
|
917
|
+
raise RuntimeError(setup.stderr or setup.stdout)
|
|
918
|
+
else:
|
|
919
|
+
self._stage("dependencies", "cached", "Skipped runtime install; using cached environment image", cached=True)
|
|
920
|
+
|
|
921
|
+
t0 = time.perf_counter()
|
|
922
|
+
started = self._exec_machine_with_retry(
|
|
923
|
+
machine_id,
|
|
924
|
+
command=[
|
|
925
|
+
"sh",
|
|
926
|
+
"-lc",
|
|
927
|
+
"cd /workspace && nohup python -u /tmp/gradient-service.py >/tmp/gradient-service.log 2>&1 &",
|
|
928
|
+
],
|
|
929
|
+
timeout_seconds=30,
|
|
930
|
+
trace=trace_policy.trace_exec,
|
|
931
|
+
watch=list(watch or ["/workspace"]),
|
|
932
|
+
stage="serve",
|
|
933
|
+
)
|
|
934
|
+
timings["serve_start_ms"] = _elapsed_ms(t0)
|
|
935
|
+
self._stage(
|
|
936
|
+
"serve",
|
|
937
|
+
"ready" if started.exit_code == 0 else "failed",
|
|
938
|
+
f"Deployment service is listening at {deployment.proxy_url}",
|
|
939
|
+
timings["serve_start_ms"],
|
|
940
|
+
)
|
|
941
|
+
if started.exit_code != 0:
|
|
942
|
+
raise RuntimeError(started.stderr or started.stdout)
|
|
943
|
+
|
|
944
|
+
timings["total_ms"] = _elapsed_ms(overall)
|
|
945
|
+
deployment.timings.update(timings)
|
|
946
|
+
deployment.progress[:] = list(self.progress)
|
|
947
|
+
return deployment
|
|
948
|
+
|
|
949
|
+
def replay(
|
|
950
|
+
self,
|
|
951
|
+
run_id: str,
|
|
952
|
+
*,
|
|
953
|
+
seed: int | None = None,
|
|
954
|
+
freeze_time: str | None = None,
|
|
955
|
+
egress: str | None = None,
|
|
956
|
+
trace: TracePolicy | Mapping[str, Any] | str | None = None,
|
|
957
|
+
wait_timeout_seconds: int = 30,
|
|
958
|
+
) -> RunHandle:
|
|
959
|
+
trace_policy = TracePolicy.from_value(trace, fallback=self.default_trace)
|
|
960
|
+
replay_config = {
|
|
961
|
+
key: value
|
|
962
|
+
for key, value in {
|
|
963
|
+
"seed": seed,
|
|
964
|
+
"freeze_time": freeze_time,
|
|
965
|
+
"egress": egress,
|
|
966
|
+
}.items()
|
|
967
|
+
if value is not None
|
|
968
|
+
}
|
|
969
|
+
replayed = self.client.replay_machine(
|
|
970
|
+
run_id,
|
|
971
|
+
trace_mode=trace_policy.trace_mode,
|
|
972
|
+
capture_policy=trace_policy.capture_policy(),
|
|
973
|
+
replay_config=replay_config or None,
|
|
974
|
+
metadata={
|
|
975
|
+
"project": self.project,
|
|
976
|
+
"trace_preset": trace_policy.preset,
|
|
977
|
+
"sdk_surface": "Gradient.replay",
|
|
978
|
+
},
|
|
979
|
+
wait=True,
|
|
980
|
+
wait_timeout_seconds=wait_timeout_seconds,
|
|
981
|
+
)
|
|
982
|
+
run = replayed["trace_run"]
|
|
983
|
+
machine = replayed["machine"]
|
|
984
|
+
return RunHandle(
|
|
985
|
+
run_id=str(run["id"]),
|
|
986
|
+
machine_id=str(machine["id"]),
|
|
987
|
+
environment_version_id="",
|
|
988
|
+
project=self.project,
|
|
989
|
+
stdout="",
|
|
990
|
+
stderr="",
|
|
991
|
+
exit_code=0,
|
|
992
|
+
proxy_url=str(replayed.get("full_proxy_url", "")),
|
|
993
|
+
progress=[],
|
|
994
|
+
timings={},
|
|
995
|
+
status=str(run.get("status", "active")),
|
|
996
|
+
client=self.client,
|
|
997
|
+
)
|
|
998
|
+
|
|
999
|
+
def instrument(self, target: Any) -> Any:
|
|
1000
|
+
from .integrations import wrap_anthropic, wrap_openai
|
|
1001
|
+
|
|
1002
|
+
module_name = target.__class__.__module__.lower()
|
|
1003
|
+
class_name = target.__class__.__name__.lower()
|
|
1004
|
+
if "openai" in module_name or "openai" in class_name:
|
|
1005
|
+
return wrap_openai(target)
|
|
1006
|
+
if "anthropic" in module_name or "anthropic" in class_name:
|
|
1007
|
+
return wrap_anthropic(target)
|
|
1008
|
+
if hasattr(target, "invoke") or hasattr(target, "stream"):
|
|
1009
|
+
return _InstrumentedRunnable(target)
|
|
1010
|
+
return target
|
|
1011
|
+
|
|
1012
|
+
def _exec_machine_with_retry(
|
|
1013
|
+
self,
|
|
1014
|
+
machine_id: str,
|
|
1015
|
+
*,
|
|
1016
|
+
command: list[str],
|
|
1017
|
+
stdin: str | None = None,
|
|
1018
|
+
timeout_seconds: int,
|
|
1019
|
+
trace: bool,
|
|
1020
|
+
watch: list[str] | None = None,
|
|
1021
|
+
stage: str,
|
|
1022
|
+
attempts: int = 4,
|
|
1023
|
+
) -> Any:
|
|
1024
|
+
last_error: Exception | None = None
|
|
1025
|
+
for attempt in range(1, attempts + 1):
|
|
1026
|
+
try:
|
|
1027
|
+
return self.client.exec_machine(
|
|
1028
|
+
machine_id,
|
|
1029
|
+
command,
|
|
1030
|
+
stdin=stdin,
|
|
1031
|
+
timeout_seconds=timeout_seconds,
|
|
1032
|
+
trace=trace,
|
|
1033
|
+
watch=watch,
|
|
1034
|
+
)
|
|
1035
|
+
except GradientAPIError as error:
|
|
1036
|
+
last_error = error
|
|
1037
|
+
if not _retryable_exec_error(error) or attempt == attempts:
|
|
1038
|
+
raise
|
|
1039
|
+
if _machine_not_running_error(error):
|
|
1040
|
+
try:
|
|
1041
|
+
self.client.wait_machine_state(machine_id, timeout_seconds=15)
|
|
1042
|
+
except Exception:
|
|
1043
|
+
pass
|
|
1044
|
+
delay_ms = attempt * 1500
|
|
1045
|
+
self._stage(
|
|
1046
|
+
stage,
|
|
1047
|
+
"ready",
|
|
1048
|
+
f"Machine reported started but exec was not ready yet, retrying in {delay_ms}ms (attempt {attempt + 1}/{attempts})",
|
|
1049
|
+
)
|
|
1050
|
+
time.sleep(delay_ms / 1000)
|
|
1051
|
+
if last_error is not None:
|
|
1052
|
+
raise last_error
|
|
1053
|
+
raise RuntimeError("unreachable exec retry state")
|
|
1054
|
+
|
|
1055
|
+
def benchmark(
|
|
1056
|
+
self,
|
|
1057
|
+
target: Callable[[Any], Any],
|
|
1058
|
+
input: Any | None = None,
|
|
1059
|
+
*,
|
|
1060
|
+
iterations: int = 3,
|
|
1061
|
+
trace_mode: str = "full",
|
|
1062
|
+
) -> list[RemoteRunResult]:
|
|
1063
|
+
results: list[RemoteRunResult] = []
|
|
1064
|
+
for index in range(max(1, iterations)):
|
|
1065
|
+
self._stage("benchmark", "ready", f"Starting iteration {index + 1}/{iterations}")
|
|
1066
|
+
results.append(self.run(target, input, trace_mode=trace_mode))
|
|
1067
|
+
cold_starts = [item.timings.get("cold_start_ms", 0) for item in results]
|
|
1068
|
+
if cold_starts:
|
|
1069
|
+
avg = sum(cold_starts) // len(cold_starts)
|
|
1070
|
+
self._stage(
|
|
1071
|
+
"benchmark",
|
|
1072
|
+
"ready",
|
|
1073
|
+
f"Cold-start timings ms: min={min(cold_starts)} avg={avg} max={max(cold_starts)}",
|
|
1074
|
+
)
|
|
1075
|
+
return results
|
|
1076
|
+
|
|
1077
|
+
def _stage(
|
|
1078
|
+
self,
|
|
1079
|
+
stage: str,
|
|
1080
|
+
status: str,
|
|
1081
|
+
message: str,
|
|
1082
|
+
duration_ms: int = 0,
|
|
1083
|
+
*,
|
|
1084
|
+
cached: bool | None = None,
|
|
1085
|
+
) -> None:
|
|
1086
|
+
event = ProgressEvent(
|
|
1087
|
+
stage=stage,
|
|
1088
|
+
status=status,
|
|
1089
|
+
message=message,
|
|
1090
|
+
duration_ms=duration_ms,
|
|
1091
|
+
cached=(cached if cached is not None else status == "cached"),
|
|
1092
|
+
)
|
|
1093
|
+
self.progress.append(event)
|
|
1094
|
+
if self.show_progress:
|
|
1095
|
+
suffix = f" {duration_ms}ms" if duration_ms else ""
|
|
1096
|
+
marker = "cached" if event.cached else status
|
|
1097
|
+
print(f"[gradient] {stage}: {marker} - {message}{suffix}", flush=True)
|
|
1098
|
+
|
|
1099
|
+
|
|
1100
|
+
@dataclass(slots=True)
|
|
1101
|
+
class SourceBundle:
|
|
1102
|
+
archive_b64: str
|
|
1103
|
+
source_hash: str
|
|
1104
|
+
manifest: dict[str, Any]
|
|
1105
|
+
entrypoint: str
|
|
1106
|
+
target_ref: str
|
|
1107
|
+
file_count: int
|
|
1108
|
+
duration_ms: int
|
|
1109
|
+
|
|
1110
|
+
|
|
1111
|
+
class _InstrumentedRunnable:
|
|
1112
|
+
def __init__(self, target: Any) -> None:
|
|
1113
|
+
self.target = target
|
|
1114
|
+
|
|
1115
|
+
def invoke(self, *args: Any, **kwargs: Any) -> Any:
|
|
1116
|
+
from .decorators import trace_context
|
|
1117
|
+
|
|
1118
|
+
with trace_context(f"{self.target.__class__.__name__}.invoke", kind="CHAIN"):
|
|
1119
|
+
return self.target.invoke(*args, **kwargs)
|
|
1120
|
+
|
|
1121
|
+
def stream(self, *args: Any, **kwargs: Any) -> Any:
|
|
1122
|
+
from .decorators import trace_context
|
|
1123
|
+
|
|
1124
|
+
with trace_context(f"{self.target.__class__.__name__}.stream", kind="CHAIN"):
|
|
1125
|
+
yield from self.target.stream(*args, **kwargs)
|
|
1126
|
+
|
|
1127
|
+
def __getattr__(self, name: str) -> Any:
|
|
1128
|
+
return getattr(self.target, name)
|
|
1129
|
+
|
|
1130
|
+
|
|
1131
|
+
def _with_source_bundle(
|
|
1132
|
+
template: Mapping[str, Any],
|
|
1133
|
+
source: SourceBundle,
|
|
1134
|
+
*,
|
|
1135
|
+
service_code: str | None = None,
|
|
1136
|
+
) -> dict[str, Any]:
|
|
1137
|
+
result = copy.deepcopy(dict(template))
|
|
1138
|
+
config = dict(result.get("config") or {})
|
|
1139
|
+
env = dict(config.get("env") or {})
|
|
1140
|
+
env["GRADIENT_SOURCE_ARCHIVE_B64"] = source.archive_b64
|
|
1141
|
+
init_steps = [
|
|
1142
|
+
"rm -rf /workspace",
|
|
1143
|
+
"mkdir -p /workspace",
|
|
1144
|
+
"printf '%s' \"$GRADIENT_SOURCE_ARCHIVE_B64\" | base64 -d | tar -xzf - -C /workspace",
|
|
1145
|
+
]
|
|
1146
|
+
if service_code is not None:
|
|
1147
|
+
env["GRADIENT_SERVICE_CODE_B64"] = base64.b64encode(
|
|
1148
|
+
service_code.encode("utf-8")
|
|
1149
|
+
).decode("ascii")
|
|
1150
|
+
init_steps.append(
|
|
1151
|
+
"printf '%s' \"$GRADIENT_SERVICE_CODE_B64\" | base64 -d > /tmp/gradient-service.py"
|
|
1152
|
+
)
|
|
1153
|
+
init_steps.append("exec sleep infinity")
|
|
1154
|
+
config["env"] = env
|
|
1155
|
+
config["init"] = {
|
|
1156
|
+
"cmd": [
|
|
1157
|
+
"sh",
|
|
1158
|
+
"-lc",
|
|
1159
|
+
" && ".join(init_steps),
|
|
1160
|
+
]
|
|
1161
|
+
}
|
|
1162
|
+
result["config"] = config
|
|
1163
|
+
return result
|
|
1164
|
+
|
|
1165
|
+
|
|
1166
|
+
def _package_source(root: Path, target: Callable[..., Any] | None, excludes: list[str]) -> SourceBundle:
|
|
1167
|
+
started = time.perf_counter()
|
|
1168
|
+
unwrapped_target: Callable[..., Any] | None = (
|
|
1169
|
+
inspect.unwrap(target)
|
|
1170
|
+
if target is not None and (inspect.isfunction(target) or inspect.ismethod(target))
|
|
1171
|
+
else target
|
|
1172
|
+
)
|
|
1173
|
+
entrypoint = ""
|
|
1174
|
+
target_ref = ""
|
|
1175
|
+
if unwrapped_target is not None:
|
|
1176
|
+
entrypoint, target_ref = _target_entrypoint_and_ref(root, unwrapped_target)
|
|
1177
|
+
|
|
1178
|
+
files: list[tuple[Path, str]] = []
|
|
1179
|
+
for path in root.rglob("*"):
|
|
1180
|
+
if path.is_dir():
|
|
1181
|
+
continue
|
|
1182
|
+
rel = path.relative_to(root).as_posix()
|
|
1183
|
+
if _excluded(rel, excludes):
|
|
1184
|
+
continue
|
|
1185
|
+
files.append((path, rel))
|
|
1186
|
+
|
|
1187
|
+
digest = hashlib.sha256()
|
|
1188
|
+
raw = io.BytesIO()
|
|
1189
|
+
with tarfile.open(fileobj=raw, mode="w:gz") as archive:
|
|
1190
|
+
for path, rel in sorted(files, key=lambda item: item[1]):
|
|
1191
|
+
data = path.read_bytes()
|
|
1192
|
+
digest.update(rel.encode("utf-8"))
|
|
1193
|
+
digest.update(b"\0")
|
|
1194
|
+
digest.update(hashlib.sha256(data).digest())
|
|
1195
|
+
info = tarfile.TarInfo(rel)
|
|
1196
|
+
info.size = len(data)
|
|
1197
|
+
info.mode = path.stat().st_mode & 0o777
|
|
1198
|
+
info.mtime = 0
|
|
1199
|
+
archive.addfile(info, io.BytesIO(data))
|
|
1200
|
+
encoded = base64.b64encode(raw.getvalue()).decode("ascii")
|
|
1201
|
+
if len(encoded) > 950_000:
|
|
1202
|
+
raise ValueError(
|
|
1203
|
+
"source bundle is larger than the current 1MB upload limit; add ignores or use a prebuilt environment image"
|
|
1204
|
+
)
|
|
1205
|
+
source_hash = digest.hexdigest()
|
|
1206
|
+
manifest = {
|
|
1207
|
+
"hash": source_hash,
|
|
1208
|
+
"entrypoint": entrypoint,
|
|
1209
|
+
"target": target_ref,
|
|
1210
|
+
"file_count": len(files),
|
|
1211
|
+
"archive_bytes": len(raw.getvalue()),
|
|
1212
|
+
"ignored": excludes,
|
|
1213
|
+
}
|
|
1214
|
+
return SourceBundle(
|
|
1215
|
+
archive_b64=encoded,
|
|
1216
|
+
source_hash=source_hash,
|
|
1217
|
+
manifest=manifest,
|
|
1218
|
+
entrypoint=entrypoint,
|
|
1219
|
+
target_ref=target_ref,
|
|
1220
|
+
file_count=len(files),
|
|
1221
|
+
duration_ms=_elapsed_ms(started),
|
|
1222
|
+
)
|
|
1223
|
+
|
|
1224
|
+
|
|
1225
|
+
def _target_entrypoint_and_ref(root: Path, target: Callable[..., Any]) -> tuple[str, str]:
|
|
1226
|
+
if inspect.isfunction(target) or inspect.ismethod(target):
|
|
1227
|
+
target_file = Path(inspect.getsourcefile(target) or "").resolve()
|
|
1228
|
+
if not target_file.exists():
|
|
1229
|
+
raise ValueError("target must be a function defined in a source file")
|
|
1230
|
+
try:
|
|
1231
|
+
entrypoint = target_file.relative_to(root).as_posix()
|
|
1232
|
+
except ValueError as exc:
|
|
1233
|
+
raise ValueError(f"target file {target_file} is not under source root {root}") from exc
|
|
1234
|
+
module_name = target.__module__
|
|
1235
|
+
if module_name == "__main__":
|
|
1236
|
+
module_name = entrypoint[:-3].replace("/", ".") if entrypoint.endswith(".py") else entrypoint.replace("/", ".")
|
|
1237
|
+
if "<locals>" in target.__qualname__:
|
|
1238
|
+
raise ValueError("remote Gradient.run targets must be top-level functions so the sandbox can import them")
|
|
1239
|
+
return entrypoint, f"{module_name}:{target.__qualname__}"
|
|
1240
|
+
|
|
1241
|
+
found = _find_global_object_ref(root, target)
|
|
1242
|
+
if found is not None:
|
|
1243
|
+
return found
|
|
1244
|
+
raise ValueError(
|
|
1245
|
+
"remote Gradient.run targets must be top-level functions or global callable Agent objects"
|
|
1246
|
+
)
|
|
1247
|
+
|
|
1248
|
+
|
|
1249
|
+
def _find_global_object_ref(root: Path, target: Any) -> tuple[str, str] | None:
|
|
1250
|
+
for module in list(sys.modules.values()):
|
|
1251
|
+
module_file = getattr(module, "__file__", None)
|
|
1252
|
+
if not module_file:
|
|
1253
|
+
continue
|
|
1254
|
+
path = Path(module_file).resolve()
|
|
1255
|
+
try:
|
|
1256
|
+
entrypoint = path.relative_to(root).as_posix()
|
|
1257
|
+
except ValueError:
|
|
1258
|
+
continue
|
|
1259
|
+
for name, value in vars(module).items():
|
|
1260
|
+
if value is target and not name.startswith("_"):
|
|
1261
|
+
return entrypoint, f"{module.__name__}:{name}"
|
|
1262
|
+
return None
|
|
1263
|
+
|
|
1264
|
+
|
|
1265
|
+
def _remote_bootstrap(target_ref: str, payload_b64: str) -> str:
|
|
1266
|
+
module, _, qualname = target_ref.partition(":")
|
|
1267
|
+
if not module or not qualname:
|
|
1268
|
+
raise ValueError("target ref must be module:qualname")
|
|
1269
|
+
return "\n".join(
|
|
1270
|
+
[
|
|
1271
|
+
"import base64, importlib, json, os, sys",
|
|
1272
|
+
"sys.path.insert(0, '/workspace')",
|
|
1273
|
+
"os.environ['GRADIENT_REMOTE_EXECUTION'] = '1'",
|
|
1274
|
+
f"mod = importlib.import_module({module!r})",
|
|
1275
|
+
"obj = mod",
|
|
1276
|
+
f"for part in {qualname.split('.')!r}: obj = getattr(obj, part)",
|
|
1277
|
+
_remote_trace_setup_code(),
|
|
1278
|
+
f"payload = json.loads(base64.b64decode({payload_b64!r}).decode('utf-8'))",
|
|
1279
|
+
f"emit_trace_event('gradient.sdk.remote.start', {{'target': {target_ref!r}}})",
|
|
1280
|
+
"try:",
|
|
1281
|
+
" result = obj(payload)",
|
|
1282
|
+
f" emit_trace_event('gradient.sdk.remote.success', {{'target': {target_ref!r}, 'output': result}})",
|
|
1283
|
+
" print(json.dumps(result, default=str))",
|
|
1284
|
+
"except Exception as exc:",
|
|
1285
|
+
f" emit_trace_event('gradient.sdk.remote.error', {{'target': {target_ref!r}, 'error': str(exc), 'error_type': type(exc).__name__}})",
|
|
1286
|
+
" raise",
|
|
1287
|
+
"finally:",
|
|
1288
|
+
" tracer.flush()",
|
|
1289
|
+
" tracer.shutdown()",
|
|
1290
|
+
]
|
|
1291
|
+
)
|
|
1292
|
+
|
|
1293
|
+
|
|
1294
|
+
def _remote_service_bootstrap(target_ref: str) -> str:
|
|
1295
|
+
module, _, qualname = target_ref.partition(":")
|
|
1296
|
+
if not module or not qualname:
|
|
1297
|
+
raise ValueError("target ref must be module:qualname")
|
|
1298
|
+
return "\n".join(
|
|
1299
|
+
[
|
|
1300
|
+
"import importlib, json, os, sys",
|
|
1301
|
+
"from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer",
|
|
1302
|
+
"sys.path.insert(0, '/workspace')",
|
|
1303
|
+
"os.environ['GRADIENT_REMOTE_EXECUTION'] = '1'",
|
|
1304
|
+
f"mod = importlib.import_module({module!r})",
|
|
1305
|
+
"obj = mod",
|
|
1306
|
+
f"for part in {qualname.split('.')!r}: obj = getattr(obj, part)",
|
|
1307
|
+
_remote_trace_setup_code(),
|
|
1308
|
+
"class Handler(BaseHTTPRequestHandler):",
|
|
1309
|
+
" def do_GET(self):",
|
|
1310
|
+
" self.send_response(200)",
|
|
1311
|
+
" self.send_header('content-type', 'application/json')",
|
|
1312
|
+
" self.end_headers()",
|
|
1313
|
+
" self.wfile.write(json.dumps({'ok': True, 'service': 'gradient'}).encode())",
|
|
1314
|
+
" def do_POST(self):",
|
|
1315
|
+
" try:",
|
|
1316
|
+
" length = int(self.headers.get('content-length') or '0')",
|
|
1317
|
+
" raw = self.rfile.read(length) if length else b'{}'",
|
|
1318
|
+
" payload = json.loads(raw.decode('utf-8') or '{}')",
|
|
1319
|
+
f" emit_trace_event('gradient.sdk.remote.request', {{'target': {target_ref!r}, 'path': self.path}})",
|
|
1320
|
+
" result = obj(payload)",
|
|
1321
|
+
f" emit_trace_event('gradient.sdk.remote.success', {{'target': {target_ref!r}, 'path': self.path, 'output': result}})",
|
|
1322
|
+
" tracer.flush()",
|
|
1323
|
+
" body = json.dumps({'ok': True, 'result': result}, default=str).encode()",
|
|
1324
|
+
" self.send_response(200)",
|
|
1325
|
+
" except Exception as exc:",
|
|
1326
|
+
f" emit_trace_event('gradient.sdk.remote.error', {{'target': {target_ref!r}, 'path': self.path, 'error': str(exc), 'error_type': type(exc).__name__}})",
|
|
1327
|
+
" body = json.dumps({'ok': False, 'error': str(exc)}).encode()",
|
|
1328
|
+
" self.send_response(500)",
|
|
1329
|
+
" self.send_header('content-type', 'application/json')",
|
|
1330
|
+
" self.end_headers()",
|
|
1331
|
+
" self.wfile.write(body)",
|
|
1332
|
+
" def log_message(self, fmt, *args):",
|
|
1333
|
+
" print(fmt % args, flush=True)",
|
|
1334
|
+
"port = int(os.environ.get('PORT') or os.environ.get('GRADIENT_PORT') or '8080')",
|
|
1335
|
+
"ThreadingHTTPServer(('0.0.0.0', port), Handler).serve_forever()",
|
|
1336
|
+
]
|
|
1337
|
+
)
|
|
1338
|
+
|
|
1339
|
+
|
|
1340
|
+
def _remote_trace_setup_code() -> str:
|
|
1341
|
+
return "\n".join(
|
|
1342
|
+
[
|
|
1343
|
+
"from gradient_sdk.tracing import SpanExporter, Tracer, set_tracer",
|
|
1344
|
+
"import urllib.request",
|
|
1345
|
+
"trace_token = os.environ.get('GRADIENT_TRACE_TOKEN', '')",
|
|
1346
|
+
"trace_run_id = os.environ.get('GRADIENT_TRACE_RUN_ID', '')",
|
|
1347
|
+
"trace_event_endpoint = os.environ.get('GRADIENT_TRACE_ENDPOINT', '')",
|
|
1348
|
+
"trace_endpoint = os.environ.get('OTEL_EXPORTER_OTLP_TRACES_ENDPOINT', '')",
|
|
1349
|
+
"if not trace_endpoint and os.environ.get('GRADIENT_TRACE_ENDPOINT', '').endswith('/api/v1/runs/ingest'):",
|
|
1350
|
+
" trace_endpoint = os.environ['GRADIENT_TRACE_ENDPOINT'][:-len('/api/v1/runs/ingest')] + '/v1/traces'",
|
|
1351
|
+
"project_name = os.environ.get('GRADIENT_PROJECT') or os.environ.get('OTEL_SERVICE_NAME') or 'gradient'",
|
|
1352
|
+
"def emit_trace_event(event_type, data):",
|
|
1353
|
+
" if not trace_token or not trace_run_id or not trace_event_endpoint:",
|
|
1354
|
+
" print('[gradient remote] trace event skipped: missing trace env', file=sys.stderr, flush=True)",
|
|
1355
|
+
" return",
|
|
1356
|
+
" try:",
|
|
1357
|
+
" body = json.dumps({'run_id': trace_run_id, 'events': [{'source': 'sdk', 'type': event_type, 'data': data}]}, default=str).encode('utf-8')",
|
|
1358
|
+
" req = urllib.request.Request(trace_event_endpoint, data=body, method='POST', headers={'Authorization': 'Bearer ' + trace_token, 'Content-Type': 'application/json', 'Accept': 'application/json'})",
|
|
1359
|
+
" with urllib.request.urlopen(req, timeout=5) as res:",
|
|
1360
|
+
" res.read()",
|
|
1361
|
+
" print('[gradient remote] emitted ' + event_type, file=sys.stderr, flush=True)",
|
|
1362
|
+
" except Exception as emit_exc:",
|
|
1363
|
+
" print('[gradient remote] trace event failed: ' + str(emit_exc), file=sys.stderr, flush=True)",
|
|
1364
|
+
"if trace_token and trace_endpoint:",
|
|
1365
|
+
" exporter = SpanExporter(endpoint=trace_endpoint, ingest_token=trace_token, resource={'service.name': project_name, 'openinference.project.name': project_name, 'gradient.run.id': os.environ.get('GRADIENT_TRACE_RUN_ID', '')})",
|
|
1366
|
+
" tracer = Tracer(project=project_name, exporter=exporter)",
|
|
1367
|
+
" set_tracer(tracer)",
|
|
1368
|
+
"else:",
|
|
1369
|
+
" from gradient_sdk.tracing import get_tracer",
|
|
1370
|
+
" tracer = get_tracer()",
|
|
1371
|
+
]
|
|
1372
|
+
)
|
|
1373
|
+
|
|
1374
|
+
|
|
1375
|
+
def _deployment_from_response(
|
|
1376
|
+
data: Mapping[str, Any],
|
|
1377
|
+
client: GradientClient,
|
|
1378
|
+
progress: list[ProgressEvent],
|
|
1379
|
+
timings: dict[str, int],
|
|
1380
|
+
) -> RemoteDeployment:
|
|
1381
|
+
payload = data["data"] if "data" in data and isinstance(data["data"], Mapping) else data
|
|
1382
|
+
return RemoteDeployment(
|
|
1383
|
+
name=str(payload.get("name") or payload.get("id") or ""),
|
|
1384
|
+
project=str(payload.get("project") or ""),
|
|
1385
|
+
environment_version_id=str(payload.get("environment_version_id") or ""),
|
|
1386
|
+
machine_ids=[str(item) for item in payload.get("machine_ids", [])],
|
|
1387
|
+
proxy_url=str(payload.get("proxy_url") or payload.get("stable_url") or ""),
|
|
1388
|
+
stable_url=str(payload.get("stable_url") or payload.get("proxy_url") or ""),
|
|
1389
|
+
trace_run_id=str(payload.get("trace_run_id")) if payload.get("trace_run_id") else None,
|
|
1390
|
+
status=str(payload.get("status") or "unknown"),
|
|
1391
|
+
progress=list(progress),
|
|
1392
|
+
timings=dict(timings),
|
|
1393
|
+
client=client,
|
|
1394
|
+
)
|
|
1395
|
+
|
|
1396
|
+
|
|
1397
|
+
def _excluded(rel: str, patterns: list[str]) -> bool:
|
|
1398
|
+
normalized = rel.replace(os.sep, posixpath.sep)
|
|
1399
|
+
return any(
|
|
1400
|
+
fnmatch.fnmatch(normalized, pattern) or fnmatch.fnmatch(normalized, pattern.rstrip("/**"))
|
|
1401
|
+
for pattern in patterns
|
|
1402
|
+
)
|
|
1403
|
+
|
|
1404
|
+
|
|
1405
|
+
def _elapsed_ms(started: float) -> int:
|
|
1406
|
+
return int((time.perf_counter() - started) * 1000)
|
|
1407
|
+
|
|
1408
|
+
|
|
1409
|
+
def _stable_hash(value: Any) -> str:
|
|
1410
|
+
return hashlib.sha256(
|
|
1411
|
+
json.dumps(value, sort_keys=True, separators=(",", ":"), default=str).encode("utf-8")
|
|
1412
|
+
).hexdigest()
|
|
1413
|
+
|
|
1414
|
+
|
|
1415
|
+
def _trace_policy(
|
|
1416
|
+
trace: TracePolicy | Mapping[str, Any] | str | None,
|
|
1417
|
+
trace_mode: str | None,
|
|
1418
|
+
fallback: TracePolicy,
|
|
1419
|
+
) -> TracePolicy:
|
|
1420
|
+
if trace is not None and trace_mode is not None:
|
|
1421
|
+
raise ValueError("pass either trace or trace_mode, not both")
|
|
1422
|
+
if trace_mode is not None:
|
|
1423
|
+
return TracePolicy.from_value(trace_mode, fallback=fallback)
|
|
1424
|
+
return TracePolicy.from_value(trace, fallback=fallback)
|
|
1425
|
+
|
|
1426
|
+
|
|
1427
|
+
def _env_file_candidates(
|
|
1428
|
+
env_file: bool | str,
|
|
1429
|
+
env_files: Iterable[str | Path] | None,
|
|
1430
|
+
) -> list[Path]:
|
|
1431
|
+
paths: list[Path] = []
|
|
1432
|
+
if isinstance(env_file, str):
|
|
1433
|
+
paths.append(Path(env_file))
|
|
1434
|
+
elif env_file:
|
|
1435
|
+
paths.extend([Path(".env.local"), Path(".env"), Path(".env.gradient")])
|
|
1436
|
+
paths.extend(Path(path) for path in env_files or [])
|
|
1437
|
+
return paths
|
|
1438
|
+
|
|
1439
|
+
|
|
1440
|
+
def _load_env_file(path: Path, *, override: bool = False) -> dict[str, str]:
|
|
1441
|
+
if not path.exists() or not path.is_file():
|
|
1442
|
+
return {}
|
|
1443
|
+
loaded: dict[str, str] = {}
|
|
1444
|
+
for raw_line in path.read_text(encoding="utf-8").splitlines():
|
|
1445
|
+
line = raw_line.strip()
|
|
1446
|
+
if not line or line.startswith("#"):
|
|
1447
|
+
continue
|
|
1448
|
+
if line.startswith("export "):
|
|
1449
|
+
line = line[len("export ") :].lstrip()
|
|
1450
|
+
key, separator, raw_value = line.partition("=")
|
|
1451
|
+
if not separator:
|
|
1452
|
+
continue
|
|
1453
|
+
key = key.strip()
|
|
1454
|
+
if not key:
|
|
1455
|
+
continue
|
|
1456
|
+
value = raw_value.strip()
|
|
1457
|
+
if len(value) >= 2 and value[0] == value[-1] and value[0] in {"'", '"'}:
|
|
1458
|
+
value = value[1:-1]
|
|
1459
|
+
if override or key not in os.environ:
|
|
1460
|
+
os.environ[key] = value
|
|
1461
|
+
loaded[key] = value
|
|
1462
|
+
return loaded
|
|
1463
|
+
|
|
1464
|
+
|
|
1465
|
+
def _retryable_exec_error(error: GradientAPIError) -> bool:
|
|
1466
|
+
if error.status != 412:
|
|
1467
|
+
return False
|
|
1468
|
+
body = (error.response_body or error.message).lower()
|
|
1469
|
+
return "exec request failed: eof" in body or "machine not running" in body
|
|
1470
|
+
|
|
1471
|
+
|
|
1472
|
+
def _machine_not_running_error(error: GradientAPIError) -> bool:
|
|
1473
|
+
body = (error.response_body or error.message).lower()
|
|
1474
|
+
return "machine not running" in body
|