hopx-ai 0.1.17__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.

Potentially problematic release.


This version of hopx-ai might be problematic. Click here for more details.

@@ -0,0 +1,47 @@
1
+ """
2
+ Template Building Module
3
+
4
+ Provides fluent API for building custom templates.
5
+ """
6
+
7
+ from .builder import Template, create_template
8
+ from .build_flow import get_logs
9
+ from .ready_checks import (
10
+ wait_for_port,
11
+ wait_for_url,
12
+ wait_for_file,
13
+ wait_for_process,
14
+ wait_for_command,
15
+ )
16
+ from .types import (
17
+ StepType,
18
+ Step,
19
+ ReadyCheck,
20
+ ReadyCheckType,
21
+ BuildOptions,
22
+ BuildResult,
23
+ CreateVMOptions,
24
+ VM,
25
+ LogsResponse,
26
+ )
27
+
28
+ __all__ = [
29
+ "Template",
30
+ "create_template",
31
+ "get_logs",
32
+ "wait_for_port",
33
+ "wait_for_url",
34
+ "wait_for_file",
35
+ "wait_for_process",
36
+ "wait_for_command",
37
+ "StepType",
38
+ "Step",
39
+ "ReadyCheck",
40
+ "ReadyCheckType",
41
+ "BuildOptions",
42
+ "BuildResult",
43
+ "CreateVMOptions",
44
+ "VM",
45
+ "LogsResponse",
46
+ ]
47
+
@@ -0,0 +1,597 @@
1
+ """
2
+ Build Flow - Orchestrates the complete build process
3
+ """
4
+
5
+ import os
6
+ import time
7
+ import asyncio
8
+ import aiohttp
9
+ from typing import List, Optional, Set
10
+ from dataclasses import dataclass, asdict
11
+
12
+ from .types import (
13
+ Step,
14
+ StepType,
15
+ BuildOptions,
16
+ BuildResult,
17
+ CreateVMOptions,
18
+ VM,
19
+ UploadLinkResponse,
20
+ BuildResponse,
21
+ BuildStatusResponse,
22
+ )
23
+ from .file_hasher import FileHasher
24
+ from .tar_creator import TarCreator
25
+
26
+
27
+ DEFAULT_BASE_URL = "https://api.your-domain.com"
28
+
29
+
30
+ def _validate_template(template) -> None:
31
+ """Validate template before building"""
32
+ steps = template.get_steps()
33
+
34
+ if not steps:
35
+ raise ValueError("Template must have at least one step")
36
+
37
+ # Check for FROM step
38
+ has_from = any(step.type == StepType.FROM for step in steps)
39
+ if not has_from:
40
+ raise ValueError(
41
+ "Template must start with a FROM step.\n"
42
+ "Examples:\n"
43
+ " .from_ubuntu_image('22.04')\n"
44
+ " .from_python_image('3.12')\n"
45
+ " .from_node_image('20')"
46
+ )
47
+
48
+ # Check for meaningful steps (not just FROM + ENV)
49
+ meaningful_steps = [
50
+ step for step in steps
51
+ if step.type not in [StepType.FROM, StepType.ENV, StepType.WORKDIR, StepType.USER]
52
+ ]
53
+
54
+ if not meaningful_steps:
55
+ raise ValueError(
56
+ "Template must have at least one build step besides FROM/ENV/WORKDIR/USER.\n"
57
+ "Environment variables can be set when creating a sandbox.\n"
58
+ "Add at least one of:\n"
59
+ " .run_cmd('...') - Execute shell command\n"
60
+ " .apt_install(...) - Install system packages\n"
61
+ " .pip_install(...) - Install Python packages\n"
62
+ " .npm_install(...) - Install Node packages\n"
63
+ " .copy('src', 'dst') - Copy files"
64
+ )
65
+
66
+
67
+ async def build_template(template, options: BuildOptions) -> BuildResult:
68
+ """
69
+ Build a template
70
+
71
+ Args:
72
+ template: Template instance
73
+ options: Build options
74
+
75
+ Returns:
76
+ BuildResult with template ID and helpers
77
+ """
78
+ base_url = options.base_url or DEFAULT_BASE_URL
79
+ context_path = options.context_path or os.getcwd()
80
+
81
+ # Validate template
82
+ _validate_template(template)
83
+
84
+ # Step 1: Calculate file hashes for COPY steps
85
+ steps_with_hashes = await calculate_step_hashes(
86
+ template.get_steps(),
87
+ context_path,
88
+ options
89
+ )
90
+
91
+ # Step 2: Upload files for COPY steps
92
+ await upload_files(steps_with_hashes, context_path, base_url, options)
93
+
94
+ # Step 3: Trigger build
95
+ build_response = await trigger_build(
96
+ steps_with_hashes,
97
+ template.get_start_cmd(),
98
+ template.get_ready_check(),
99
+ base_url,
100
+ options,
101
+ )
102
+
103
+ # Step 4: Stream logs (if callback provided)
104
+ if options.on_log or options.on_progress:
105
+ await stream_logs(build_response.build_id, base_url, options)
106
+
107
+ # Step 5: Poll status until complete
108
+ final_status = await poll_status(build_response.build_id, base_url, options)
109
+
110
+ # Status "active" means template is ready
111
+ if final_status.status not in ["active", "success"]:
112
+ raise Exception(f"Build failed: {final_status.error or 'Unknown error'}")
113
+
114
+ # Step 6: Wait for template to be published (background job: publishing → active)
115
+ # Build is done, but template needs to be published to public API
116
+ template_id = final_status.template_id
117
+ await wait_for_template_active(template_id, base_url, options)
118
+
119
+ # Calculate duration
120
+ try:
121
+ # Try parsing with timezone first
122
+ from datetime import datetime
123
+ started = datetime.fromisoformat(final_status.started_at.replace('Z', '+00:00'))
124
+ duration = int(time.time() * 1000) - int(started.timestamp() * 1000)
125
+ except Exception:
126
+ # Fallback: use current time
127
+ duration = 0
128
+
129
+ # Create VM helper function
130
+ async def create_vm_helper(vm_options: CreateVMOptions = None) -> VM:
131
+ return await create_vm_from_template(
132
+ final_status.template_id,
133
+ base_url,
134
+ options,
135
+ vm_options or CreateVMOptions()
136
+ )
137
+
138
+ # Return result
139
+ return BuildResult(
140
+ build_id=build_response.build_id,
141
+ template_id=final_status.template_id,
142
+ duration=duration,
143
+ _create_vm_func=create_vm_helper,
144
+ )
145
+
146
+
147
+ async def calculate_step_hashes(
148
+ steps: List[Step],
149
+ context_path: str,
150
+ options: BuildOptions
151
+ ) -> List[Step]:
152
+ """Calculate file hashes for COPY steps"""
153
+ hasher = FileHasher()
154
+ result = []
155
+
156
+ for step in steps:
157
+ if step.type == StepType.COPY:
158
+ src, dest = step.args[0], step.args[1]
159
+ sources = src.split(',')
160
+
161
+ # Calculate hash for all sources
162
+ hash_value = await hasher.calculate_multi_hash(
163
+ [(s, dest) for s in sources],
164
+ context_path
165
+ )
166
+
167
+ # Create new step with hash
168
+ new_step = Step(
169
+ type=step.type,
170
+ args=step.args,
171
+ files_hash=hash_value,
172
+ skip_cache=step.skip_cache,
173
+ )
174
+ result.append(new_step)
175
+ else:
176
+ result.append(step)
177
+
178
+ return result
179
+
180
+
181
+ async def upload_files(
182
+ steps: List[Step],
183
+ context_path: str,
184
+ base_url: str,
185
+ options: BuildOptions,
186
+ ) -> None:
187
+ """Upload files for COPY steps"""
188
+ tar_creator = TarCreator()
189
+ uploaded_hashes: Set[str] = set()
190
+
191
+ async with aiohttp.ClientSession() as session:
192
+ for step in steps:
193
+ if step.type == StepType.COPY and step.files_hash:
194
+ # Skip if already uploaded
195
+ if step.files_hash in uploaded_hashes:
196
+ continue
197
+
198
+ # Get sources
199
+ src = step.args[0]
200
+ sources = src.split(',')
201
+
202
+ # Create tar.gz
203
+ tar_result = await tar_creator.create_multi_tar_gz(sources, context_path)
204
+
205
+ try:
206
+ # Request upload link
207
+ upload_link = await get_upload_link(
208
+ step.files_hash,
209
+ tar_result.size,
210
+ base_url,
211
+ options.api_key,
212
+ session,
213
+ )
214
+
215
+ # Upload if not already present
216
+ if not upload_link.present and upload_link.upload_url:
217
+ await upload_file(upload_link.upload_url, tar_result, session)
218
+
219
+ uploaded_hashes.add(step.files_hash)
220
+ finally:
221
+ # Cleanup temporary file
222
+ tar_result.cleanup()
223
+
224
+
225
+ async def get_upload_link(
226
+ files_hash: str,
227
+ content_length: int,
228
+ base_url: str,
229
+ api_key: str,
230
+ session: aiohttp.ClientSession,
231
+ ) -> UploadLinkResponse:
232
+ """Get presigned upload URL"""
233
+ async with session.post(
234
+ f"{base_url}/v1/templates/files/upload-link",
235
+ headers={
236
+ "Authorization": f"Bearer {api_key}",
237
+ "Content-Type": "application/json",
238
+ },
239
+ json={
240
+ "files_hash": files_hash,
241
+ "content_length": content_length,
242
+ },
243
+ ) as response:
244
+ if not response.ok:
245
+ raise Exception(f"Failed to get upload link: {response.status}")
246
+
247
+ data = await response.json()
248
+ return UploadLinkResponse(**data)
249
+
250
+
251
+ async def upload_file(
252
+ upload_url: str,
253
+ tar_result,
254
+ session: aiohttp.ClientSession,
255
+ ) -> None:
256
+ """Upload file to R2"""
257
+ with tar_result.open('rb') as f:
258
+ file_content = f.read()
259
+
260
+ async with session.put(
261
+ upload_url,
262
+ headers={
263
+ "Content-Type": "application/gzip",
264
+ "Content-Length": str(tar_result.size),
265
+ },
266
+ data=file_content,
267
+ ) as response:
268
+ if not response.ok:
269
+ raise Exception(f"Upload failed: {response.status}")
270
+
271
+
272
+ async def trigger_build(
273
+ steps: List[Step],
274
+ start_cmd: Optional[str],
275
+ ready_cmd: Optional[dict],
276
+ base_url: str,
277
+ options: BuildOptions,
278
+ ) -> BuildResponse:
279
+ """Trigger build"""
280
+ # Convert steps to dict
281
+ steps_dict = []
282
+ for step in steps:
283
+ step_dict = {
284
+ "type": step.type.value,
285
+ "args": step.args,
286
+ }
287
+ if step.files_hash:
288
+ step_dict["filesHash"] = step.files_hash
289
+ if step.skip_cache:
290
+ step_dict["skipCache"] = True
291
+ steps_dict.append(step_dict)
292
+
293
+ # Convert ready check to dict
294
+ ready_cmd_dict = None
295
+ if ready_cmd:
296
+ ready_cmd_dict = {
297
+ "type": ready_cmd.type.value,
298
+ "timeout": ready_cmd.timeout,
299
+ "interval": ready_cmd.interval,
300
+ }
301
+ if ready_cmd.port:
302
+ ready_cmd_dict["port"] = ready_cmd.port
303
+ if ready_cmd.url:
304
+ ready_cmd_dict["url"] = ready_cmd.url
305
+ if ready_cmd.path:
306
+ ready_cmd_dict["path"] = ready_cmd.path
307
+ if ready_cmd.process_name:
308
+ ready_cmd_dict["processName"] = ready_cmd.process_name
309
+ if ready_cmd.command:
310
+ ready_cmd_dict["command"] = ready_cmd.command
311
+
312
+ async with aiohttp.ClientSession() as session:
313
+ async with session.post(
314
+ f"{base_url}/v1/templates/build",
315
+ headers={
316
+ "Authorization": f"Bearer {options.api_key}",
317
+ "Content-Type": "application/json",
318
+ },
319
+ json={
320
+ "alias": options.alias,
321
+ "steps": steps_dict,
322
+ "startCmd": start_cmd,
323
+ "readyCmd": ready_cmd_dict,
324
+ "cpu": options.cpu,
325
+ "memory": options.memory,
326
+ "diskGB": options.disk_gb,
327
+ "skipCache": options.skip_cache,
328
+ },
329
+ ) as response:
330
+ if not response.ok:
331
+ raise Exception(f"Build trigger failed: {response.status}")
332
+
333
+ data = await response.json()
334
+ return BuildResponse(**data)
335
+
336
+
337
+ async def stream_logs(
338
+ build_id: str,
339
+ base_url: str,
340
+ options: BuildOptions,
341
+ ) -> None:
342
+ """Stream logs via polling (offset-based)"""
343
+ offset = 0
344
+ last_progress = -1
345
+
346
+ async with aiohttp.ClientSession() as session:
347
+ while True:
348
+ try:
349
+ async with session.get(
350
+ f"{base_url}/v1/templates/build/{build_id}/logs",
351
+ params={"offset": offset},
352
+ headers={
353
+ "Authorization": f"Bearer {options.api_key}",
354
+ },
355
+ ) as response:
356
+ if not response.ok:
357
+ return # Stop streaming on error
358
+
359
+ data = await response.json()
360
+ logs = data.get("logs", "")
361
+ offset = data.get("offset", offset)
362
+ status = data.get("status", "unknown")
363
+ complete = data.get("complete", False)
364
+
365
+ # Output logs line by line
366
+ if logs and options.on_log:
367
+ for line in logs.split('\n'):
368
+ if line.strip():
369
+ # Extract log level if present
370
+ level = "INFO"
371
+ if "❌" in line or "ERROR" in line:
372
+ level = "ERROR"
373
+ elif "✅" in line:
374
+ level = "INFO"
375
+ elif "⚠" in line or "WARN" in line:
376
+ level = "WARN"
377
+
378
+ options.on_log({
379
+ "level": level,
380
+ "message": line,
381
+ "timestamp": ""
382
+ })
383
+
384
+ # Update progress (estimate based on status)
385
+ if options.on_progress and status == "building":
386
+ # Simple progress estimation
387
+ progress = 50 # Building phase
388
+ if progress != last_progress:
389
+ options.on_progress(progress)
390
+ last_progress = progress
391
+
392
+ # Check if complete
393
+ if complete or status in ["active", "success", "failed"]:
394
+ if options.on_progress and status in ["active", "success"]:
395
+ options.on_progress(100)
396
+ return
397
+
398
+ # Wait before next poll
399
+ await asyncio.sleep(2)
400
+
401
+ except Exception as e:
402
+ # Stop streaming on error
403
+ return
404
+
405
+
406
+ async def poll_status(
407
+ build_id: str,
408
+ base_url: str,
409
+ options: BuildOptions,
410
+ interval_ms: int = 2000,
411
+ ) -> BuildStatusResponse:
412
+ """Poll build status (building → success/failed)"""
413
+ async with aiohttp.ClientSession() as session:
414
+ while True:
415
+ async with session.get(
416
+ f"{base_url}/v1/templates/build/{build_id}/status",
417
+ headers={
418
+ "Authorization": f"Bearer {options.api_key}",
419
+ },
420
+ ) as response:
421
+ if not response.ok:
422
+ raise Exception(f"Status check failed: {response.status}")
423
+
424
+ data = await response.json()
425
+ status = BuildStatusResponse(**data)
426
+
427
+ # Status can be: building, active (success), failed
428
+ if status.status in ["active", "success", "failed"]:
429
+ return status
430
+
431
+ # Wait before next poll
432
+ await asyncio.sleep(interval_ms / 1000)
433
+
434
+
435
+ async def wait_for_template_active(
436
+ template_id: str,
437
+ base_url: str,
438
+ options: BuildOptions,
439
+ max_wait_seconds: int = 60,
440
+ ) -> None:
441
+ """
442
+ Wait for template to be published and active in public API.
443
+
444
+ After build completes, a background job publishes the template:
445
+ - Build done (success) → publishing → active
446
+
447
+ This ensures the template is immediately usable after Template.build() returns.
448
+ """
449
+ async with aiohttp.ClientSession() as session:
450
+ start_time = time.time()
451
+
452
+ while time.time() - start_time < max_wait_seconds:
453
+ try:
454
+ async with session.get(
455
+ f"{base_url}/v1/templates/{template_id}",
456
+ headers={
457
+ "Authorization": f"Bearer {options.api_key}",
458
+ },
459
+ ) as response:
460
+ if response.ok:
461
+ data = await response.json()
462
+ status = data.get('status', '')
463
+
464
+ if status == 'active':
465
+ # Template is published and ready!
466
+ if options.on_log:
467
+ options.on_log({'message': f'✅ Template published and active (ID: {template_id})'})
468
+ return
469
+ elif status == 'failed':
470
+ raise Exception(f"Template publishing failed")
471
+ elif status in ['building', 'publishing']:
472
+ # Still processing, wait more
473
+ if options.on_log:
474
+ options.on_log({'message': f'⏳ Template status: {status}, waiting for active...'})
475
+
476
+ except Exception as e:
477
+ # Template might not be visible yet, continue waiting
478
+ pass
479
+
480
+ await asyncio.sleep(2) # Check every 2 seconds
481
+
482
+ # Timeout - but don't fail, template might still become active later
483
+ if options.on_log:
484
+ options.on_log({'message': f'⚠️ Template not yet active after {max_wait_seconds}s, but build succeeded'})
485
+
486
+
487
+ async def get_logs(
488
+ build_id: str,
489
+ api_key: str,
490
+ offset: int = 0,
491
+ base_url: str = None,
492
+ ) -> "LogsResponse":
493
+ """
494
+ Get build logs with offset-based polling
495
+
496
+ Args:
497
+ build_id: Build ID
498
+ api_key: API key
499
+ offset: Starting offset (default: 0)
500
+ base_url: Base URL (default: https://api.hopx.dev)
501
+
502
+ Returns:
503
+ LogsResponse with logs, offset, status, complete
504
+
505
+ Example:
506
+ ```python
507
+ from bunnyshell.template import get_logs
508
+
509
+ # Get logs from beginning
510
+ response = await get_logs("123", "api_key")
511
+ print(response.logs)
512
+
513
+ # Get new logs from last offset
514
+ response = await get_logs("123", "api_key", offset=response.offset)
515
+ ```
516
+ """
517
+ from .types import LogsResponse
518
+
519
+ if base_url is None:
520
+ base_url = DEFAULT_BASE_URL
521
+
522
+ async with aiohttp.ClientSession() as session:
523
+ async with session.get(
524
+ f"{base_url}/v1/templates/build/{build_id}/logs",
525
+ params={"offset": offset},
526
+ headers={
527
+ "Authorization": f"Bearer {api_key}",
528
+ },
529
+ ) as response:
530
+ if not response.ok:
531
+ raise Exception(f"Get logs failed: {response.status}")
532
+
533
+ data = await response.json()
534
+ return LogsResponse(
535
+ logs=data.get("logs", ""),
536
+ offset=data.get("offset", 0),
537
+ status=data.get("status", "unknown"),
538
+ complete=data.get("complete", False),
539
+ request_id=data.get("request_id"),
540
+ )
541
+
542
+
543
+ async def create_vm_from_template(
544
+ template_id: str,
545
+ base_url: str,
546
+ build_options: BuildOptions,
547
+ vm_options: CreateVMOptions,
548
+ ) -> VM:
549
+ """Create VM from template"""
550
+ async with aiohttp.ClientSession() as session:
551
+ async with session.post(
552
+ f"{base_url}/v1/vms/create",
553
+ headers={
554
+ "Authorization": f"Bearer {build_options.api_key}",
555
+ "Content-Type": "application/json",
556
+ },
557
+ json={
558
+ "templateID": template_id,
559
+ "alias": vm_options.alias,
560
+ "cpu": vm_options.cpu,
561
+ "memory": vm_options.memory,
562
+ "diskGB": vm_options.disk_gb,
563
+ "envVars": vm_options.env_vars,
564
+ },
565
+ ) as response:
566
+ if not response.ok:
567
+ raise Exception(f"VM creation failed: {response.status}")
568
+
569
+ vm_data = await response.json()
570
+
571
+ # Create delete function
572
+ async def delete_func():
573
+ await delete_vm(vm_data["vmID"], base_url, build_options.api_key)
574
+
575
+ return VM(
576
+ vm_id=vm_data["vmID"],
577
+ template_id=vm_data["templateID"],
578
+ status=vm_data["status"],
579
+ ip=vm_data["ip"],
580
+ agent_url=vm_data["agentUrl"],
581
+ started_at=vm_data["startedAt"],
582
+ _delete_func=delete_func,
583
+ )
584
+
585
+
586
+ async def delete_vm(vm_id: str, base_url: str, api_key: str) -> None:
587
+ """Delete VM"""
588
+ async with aiohttp.ClientSession() as session:
589
+ async with session.delete(
590
+ f"{base_url}/v1/vms/{vm_id}",
591
+ headers={
592
+ "Authorization": f"Bearer {api_key}",
593
+ },
594
+ ) as response:
595
+ if not response.ok:
596
+ raise Exception(f"VM deletion failed: {response.status}")
597
+