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.
@@ -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