hyperbrowser 1.2.2__tar.gz → 1.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (114) hide show
  1. {hyperbrowser-1.2.2 → hyperbrowser-1.3.0}/PKG-INFO +2 -1
  2. {hyperbrowser-1.2.2 → hyperbrowser-1.3.0}/README.md +1 -0
  3. {hyperbrowser-1.2.2 → hyperbrowser-1.3.0}/hyperbrowser/client/managers/async_manager/sandbox.py +205 -22
  4. {hyperbrowser-1.2.2 → hyperbrowser-1.3.0}/hyperbrowser/client/managers/async_manager/volume.py +8 -0
  5. hyperbrowser-1.3.0/hyperbrowser/client/managers/sandboxes/dockerfile_analysis.py +293 -0
  6. hyperbrowser-1.3.0/hyperbrowser/client/managers/sandboxes/dockerignore.py +254 -0
  7. hyperbrowser-1.3.0/hyperbrowser/client/managers/sandboxes/image_build.py +1484 -0
  8. {hyperbrowser-1.2.2 → hyperbrowser-1.3.0}/hyperbrowser/client/managers/sync_manager/sandbox.py +198 -21
  9. {hyperbrowser-1.2.2 → hyperbrowser-1.3.0}/hyperbrowser/client/managers/sync_manager/volume.py +8 -0
  10. {hyperbrowser-1.2.2 → hyperbrowser-1.3.0}/hyperbrowser/models/__init__.py +24 -1
  11. {hyperbrowser-1.2.2 → hyperbrowser-1.3.0}/hyperbrowser/models/agents/cua.py +15 -0
  12. {hyperbrowser-1.2.2 → hyperbrowser-1.3.0}/hyperbrowser/models/sandbox.py +81 -2
  13. {hyperbrowser-1.2.2 → hyperbrowser-1.3.0}/hyperbrowser/models/volume.py +6 -0
  14. {hyperbrowser-1.2.2 → hyperbrowser-1.3.0}/hyperbrowser/types/__init__.py +14 -0
  15. {hyperbrowser-1.2.2 → hyperbrowser-1.3.0}/hyperbrowser/types/agents.py +8 -0
  16. {hyperbrowser-1.2.2 → hyperbrowser-1.3.0}/hyperbrowser/types/sandbox.py +64 -1
  17. {hyperbrowser-1.2.2 → hyperbrowser-1.3.0}/pyproject.toml +1 -1
  18. hyperbrowser-1.2.2/hyperbrowser/client/managers/sandboxes/image_build.py +0 -375
  19. {hyperbrowser-1.2.2 → hyperbrowser-1.3.0}/LICENSE +0 -0
  20. {hyperbrowser-1.2.2 → hyperbrowser-1.3.0}/hyperbrowser/__init__.py +0 -0
  21. {hyperbrowser-1.2.2 → hyperbrowser-1.3.0}/hyperbrowser/client/_request.py +0 -0
  22. {hyperbrowser-1.2.2 → hyperbrowser-1.3.0}/hyperbrowser/client/async_client.py +0 -0
  23. {hyperbrowser-1.2.2 → hyperbrowser-1.3.0}/hyperbrowser/client/base.py +0 -0
  24. {hyperbrowser-1.2.2 → hyperbrowser-1.3.0}/hyperbrowser/client/managers/async_manager/agents/__init__.py +0 -0
  25. {hyperbrowser-1.2.2 → hyperbrowser-1.3.0}/hyperbrowser/client/managers/async_manager/agents/browser_use.py +0 -0
  26. {hyperbrowser-1.2.2 → hyperbrowser-1.3.0}/hyperbrowser/client/managers/async_manager/agents/claude_computer_use.py +0 -0
  27. {hyperbrowser-1.2.2 → hyperbrowser-1.3.0}/hyperbrowser/client/managers/async_manager/agents/cua.py +0 -0
  28. {hyperbrowser-1.2.2 → hyperbrowser-1.3.0}/hyperbrowser/client/managers/async_manager/agents/gemini_computer_use.py +0 -0
  29. {hyperbrowser-1.2.2 → hyperbrowser-1.3.0}/hyperbrowser/client/managers/async_manager/agents/grok_computer_use.py +0 -0
  30. {hyperbrowser-1.2.2 → hyperbrowser-1.3.0}/hyperbrowser/client/managers/async_manager/agents/hyper_agent.py +0 -0
  31. {hyperbrowser-1.2.2 → hyperbrowser-1.3.0}/hyperbrowser/client/managers/async_manager/computer_action.py +0 -0
  32. {hyperbrowser-1.2.2 → hyperbrowser-1.3.0}/hyperbrowser/client/managers/async_manager/crawl.py +0 -0
  33. {hyperbrowser-1.2.2 → hyperbrowser-1.3.0}/hyperbrowser/client/managers/async_manager/extension.py +0 -0
  34. {hyperbrowser-1.2.2 → hyperbrowser-1.3.0}/hyperbrowser/client/managers/async_manager/extract.py +0 -0
  35. {hyperbrowser-1.2.2 → hyperbrowser-1.3.0}/hyperbrowser/client/managers/async_manager/profile.py +0 -0
  36. {hyperbrowser-1.2.2 → hyperbrowser-1.3.0}/hyperbrowser/client/managers/async_manager/sandboxes/__init__.py +0 -0
  37. {hyperbrowser-1.2.2 → hyperbrowser-1.3.0}/hyperbrowser/client/managers/async_manager/sandboxes/sandbox_files.py +0 -0
  38. {hyperbrowser-1.2.2 → hyperbrowser-1.3.0}/hyperbrowser/client/managers/async_manager/sandboxes/sandbox_processes.py +0 -0
  39. {hyperbrowser-1.2.2 → hyperbrowser-1.3.0}/hyperbrowser/client/managers/async_manager/sandboxes/sandbox_terminal.py +0 -0
  40. {hyperbrowser-1.2.2 → hyperbrowser-1.3.0}/hyperbrowser/client/managers/async_manager/sandboxes/sandbox_transport.py +0 -0
  41. {hyperbrowser-1.2.2 → hyperbrowser-1.3.0}/hyperbrowser/client/managers/async_manager/scrape.py +0 -0
  42. {hyperbrowser-1.2.2 → hyperbrowser-1.3.0}/hyperbrowser/client/managers/async_manager/session.py +0 -0
  43. {hyperbrowser-1.2.2 → hyperbrowser-1.3.0}/hyperbrowser/client/managers/async_manager/team.py +0 -0
  44. {hyperbrowser-1.2.2 → hyperbrowser-1.3.0}/hyperbrowser/client/managers/async_manager/web/__init__.py +0 -0
  45. {hyperbrowser-1.2.2 → hyperbrowser-1.3.0}/hyperbrowser/client/managers/async_manager/web/batch_fetch.py +0 -0
  46. {hyperbrowser-1.2.2 → hyperbrowser-1.3.0}/hyperbrowser/client/managers/async_manager/web/crawl.py +0 -0
  47. {hyperbrowser-1.2.2 → hyperbrowser-1.3.0}/hyperbrowser/client/managers/sandboxes/__init__.py +0 -0
  48. {hyperbrowser-1.2.2 → hyperbrowser-1.3.0}/hyperbrowser/client/managers/sandboxes/shared.py +0 -0
  49. {hyperbrowser-1.2.2 → hyperbrowser-1.3.0}/hyperbrowser/client/managers/sync_manager/agents/__init__.py +0 -0
  50. {hyperbrowser-1.2.2 → hyperbrowser-1.3.0}/hyperbrowser/client/managers/sync_manager/agents/browser_use.py +0 -0
  51. {hyperbrowser-1.2.2 → hyperbrowser-1.3.0}/hyperbrowser/client/managers/sync_manager/agents/claude_computer_use.py +0 -0
  52. {hyperbrowser-1.2.2 → hyperbrowser-1.3.0}/hyperbrowser/client/managers/sync_manager/agents/cua.py +0 -0
  53. {hyperbrowser-1.2.2 → hyperbrowser-1.3.0}/hyperbrowser/client/managers/sync_manager/agents/gemini_computer_use.py +0 -0
  54. {hyperbrowser-1.2.2 → hyperbrowser-1.3.0}/hyperbrowser/client/managers/sync_manager/agents/grok_computer_use.py +0 -0
  55. {hyperbrowser-1.2.2 → hyperbrowser-1.3.0}/hyperbrowser/client/managers/sync_manager/agents/hyper_agent.py +0 -0
  56. {hyperbrowser-1.2.2 → hyperbrowser-1.3.0}/hyperbrowser/client/managers/sync_manager/computer_action.py +0 -0
  57. {hyperbrowser-1.2.2 → hyperbrowser-1.3.0}/hyperbrowser/client/managers/sync_manager/crawl.py +0 -0
  58. {hyperbrowser-1.2.2 → hyperbrowser-1.3.0}/hyperbrowser/client/managers/sync_manager/extension.py +0 -0
  59. {hyperbrowser-1.2.2 → hyperbrowser-1.3.0}/hyperbrowser/client/managers/sync_manager/extract.py +0 -0
  60. {hyperbrowser-1.2.2 → hyperbrowser-1.3.0}/hyperbrowser/client/managers/sync_manager/profile.py +0 -0
  61. {hyperbrowser-1.2.2 → hyperbrowser-1.3.0}/hyperbrowser/client/managers/sync_manager/sandboxes/__init__.py +0 -0
  62. {hyperbrowser-1.2.2 → hyperbrowser-1.3.0}/hyperbrowser/client/managers/sync_manager/sandboxes/sandbox_files.py +0 -0
  63. {hyperbrowser-1.2.2 → hyperbrowser-1.3.0}/hyperbrowser/client/managers/sync_manager/sandboxes/sandbox_processes.py +0 -0
  64. {hyperbrowser-1.2.2 → hyperbrowser-1.3.0}/hyperbrowser/client/managers/sync_manager/sandboxes/sandbox_terminal.py +0 -0
  65. {hyperbrowser-1.2.2 → hyperbrowser-1.3.0}/hyperbrowser/client/managers/sync_manager/sandboxes/sandbox_transport.py +0 -0
  66. {hyperbrowser-1.2.2 → hyperbrowser-1.3.0}/hyperbrowser/client/managers/sync_manager/scrape.py +0 -0
  67. {hyperbrowser-1.2.2 → hyperbrowser-1.3.0}/hyperbrowser/client/managers/sync_manager/session.py +0 -0
  68. {hyperbrowser-1.2.2 → hyperbrowser-1.3.0}/hyperbrowser/client/managers/sync_manager/team.py +0 -0
  69. {hyperbrowser-1.2.2 → hyperbrowser-1.3.0}/hyperbrowser/client/managers/sync_manager/web/__init__.py +0 -0
  70. {hyperbrowser-1.2.2 → hyperbrowser-1.3.0}/hyperbrowser/client/managers/sync_manager/web/batch_fetch.py +0 -0
  71. {hyperbrowser-1.2.2 → hyperbrowser-1.3.0}/hyperbrowser/client/managers/sync_manager/web/crawl.py +0 -0
  72. {hyperbrowser-1.2.2 → hyperbrowser-1.3.0}/hyperbrowser/client/sync.py +0 -0
  73. {hyperbrowser-1.2.2 → hyperbrowser-1.3.0}/hyperbrowser/config.py +0 -0
  74. {hyperbrowser-1.2.2 → hyperbrowser-1.3.0}/hyperbrowser/exceptions.py +0 -0
  75. {hyperbrowser-1.2.2 → hyperbrowser-1.3.0}/hyperbrowser/models/_parsers.py +0 -0
  76. {hyperbrowser-1.2.2 → hyperbrowser-1.3.0}/hyperbrowser/models/agents/browser_use.py +0 -0
  77. {hyperbrowser-1.2.2 → hyperbrowser-1.3.0}/hyperbrowser/models/agents/claude_computer_use.py +0 -0
  78. {hyperbrowser-1.2.2 → hyperbrowser-1.3.0}/hyperbrowser/models/agents/gemini_computer_use.py +0 -0
  79. {hyperbrowser-1.2.2 → hyperbrowser-1.3.0}/hyperbrowser/models/agents/grok_computer_use.py +0 -0
  80. {hyperbrowser-1.2.2 → hyperbrowser-1.3.0}/hyperbrowser/models/agents/hyper_agent.py +0 -0
  81. {hyperbrowser-1.2.2 → hyperbrowser-1.3.0}/hyperbrowser/models/computer_action.py +0 -0
  82. {hyperbrowser-1.2.2 → hyperbrowser-1.3.0}/hyperbrowser/models/consts.py +0 -0
  83. {hyperbrowser-1.2.2 → hyperbrowser-1.3.0}/hyperbrowser/models/crawl.py +0 -0
  84. {hyperbrowser-1.2.2 → hyperbrowser-1.3.0}/hyperbrowser/models/extension.py +0 -0
  85. {hyperbrowser-1.2.2 → hyperbrowser-1.3.0}/hyperbrowser/models/extract.py +0 -0
  86. {hyperbrowser-1.2.2 → hyperbrowser-1.3.0}/hyperbrowser/models/profile.py +0 -0
  87. {hyperbrowser-1.2.2 → hyperbrowser-1.3.0}/hyperbrowser/models/scrape.py +0 -0
  88. {hyperbrowser-1.2.2 → hyperbrowser-1.3.0}/hyperbrowser/models/session.py +0 -0
  89. {hyperbrowser-1.2.2 → hyperbrowser-1.3.0}/hyperbrowser/models/team.py +0 -0
  90. {hyperbrowser-1.2.2 → hyperbrowser-1.3.0}/hyperbrowser/models/web/batch_fetch.py +0 -0
  91. {hyperbrowser-1.2.2 → hyperbrowser-1.3.0}/hyperbrowser/models/web/branding.py +0 -0
  92. {hyperbrowser-1.2.2 → hyperbrowser-1.3.0}/hyperbrowser/models/web/common.py +0 -0
  93. {hyperbrowser-1.2.2 → hyperbrowser-1.3.0}/hyperbrowser/models/web/crawl.py +0 -0
  94. {hyperbrowser-1.2.2 → hyperbrowser-1.3.0}/hyperbrowser/models/web/fetch.py +0 -0
  95. {hyperbrowser-1.2.2 → hyperbrowser-1.3.0}/hyperbrowser/models/web/search.py +0 -0
  96. {hyperbrowser-1.2.2 → hyperbrowser-1.3.0}/hyperbrowser/py.typed +0 -0
  97. {hyperbrowser-1.2.2 → hyperbrowser-1.3.0}/hyperbrowser/sandbox_common.py +0 -0
  98. {hyperbrowser-1.2.2 → hyperbrowser-1.3.0}/hyperbrowser/tools/__init__.py +0 -0
  99. {hyperbrowser-1.2.2 → hyperbrowser-1.3.0}/hyperbrowser/tools/anthropic.py +0 -0
  100. {hyperbrowser-1.2.2 → hyperbrowser-1.3.0}/hyperbrowser/tools/openai.py +0 -0
  101. {hyperbrowser-1.2.2 → hyperbrowser-1.3.0}/hyperbrowser/tools/schema.py +0 -0
  102. {hyperbrowser-1.2.2 → hyperbrowser-1.3.0}/hyperbrowser/transport/async_transport.py +0 -0
  103. {hyperbrowser-1.2.2 → hyperbrowser-1.3.0}/hyperbrowser/transport/base.py +0 -0
  104. {hyperbrowser-1.2.2 → hyperbrowser-1.3.0}/hyperbrowser/transport/sync.py +0 -0
  105. {hyperbrowser-1.2.2 → hyperbrowser-1.3.0}/hyperbrowser/types/_json.py +0 -0
  106. {hyperbrowser-1.2.2 → hyperbrowser-1.3.0}/hyperbrowser/types/computer_action.py +0 -0
  107. {hyperbrowser-1.2.2 → hyperbrowser-1.3.0}/hyperbrowser/types/crawl.py +0 -0
  108. {hyperbrowser-1.2.2 → hyperbrowser-1.3.0}/hyperbrowser/types/extension.py +0 -0
  109. {hyperbrowser-1.2.2 → hyperbrowser-1.3.0}/hyperbrowser/types/extract.py +0 -0
  110. {hyperbrowser-1.2.2 → hyperbrowser-1.3.0}/hyperbrowser/types/profile.py +0 -0
  111. {hyperbrowser-1.2.2 → hyperbrowser-1.3.0}/hyperbrowser/types/scrape.py +0 -0
  112. {hyperbrowser-1.2.2 → hyperbrowser-1.3.0}/hyperbrowser/types/session.py +0 -0
  113. {hyperbrowser-1.2.2 → hyperbrowser-1.3.0}/hyperbrowser/types/volume.py +0 -0
  114. {hyperbrowser-1.2.2 → hyperbrowser-1.3.0}/hyperbrowser/types/web.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: hyperbrowser
3
- Version: 1.2.2
3
+ Version: 1.3.0
4
4
  Summary: Python SDK for hyperbrowser
5
5
  License: MIT
6
6
  License-File: LICENSE
@@ -250,6 +250,7 @@ sandbox = client.sandboxes.create(
250
250
  )
251
251
 
252
252
  sandbox.stop()
253
+ client.volumes.delete(same_volume.id)
253
254
  client.close()
254
255
  ```
255
256
 
@@ -223,6 +223,7 @@ sandbox = client.sandboxes.create(
223
223
  )
224
224
 
225
225
  sandbox.stop()
226
+ client.volumes.delete(same_volume.id)
226
227
  client.close()
227
228
  ```
228
229
 
@@ -9,12 +9,14 @@ from ....models.sandbox import (
9
9
  CompleteSandboxImageBuildParams,
10
10
  CreateSandboxParams,
11
11
  CreateSandboxImageBuildParams,
12
+ ReuseSandboxDockerImageParams,
12
13
  SandboxDetail,
13
14
  SandboxExecParams,
14
15
  SandboxExposeParams,
15
16
  SandboxExposeResult,
16
17
  SandboxImageBuild,
17
18
  SandboxImageBuildCreateResult,
19
+ SandboxDockerImageReuseResult,
18
20
  SandboxImageBuildListParams,
19
21
  SandboxImageBuildListResponse,
20
22
  SandboxImageListParams,
@@ -39,6 +41,7 @@ from ....models.session import BasicResponse
39
41
  from ....types import (
40
42
  CompleteSandboxImageBuildParams as CompleteSandboxImageBuildParamsDict,
41
43
  CreateSandboxImageBuildParams as CreateSandboxImageBuildParamsDict,
44
+ ReuseSandboxDockerImageParams as ReuseSandboxDockerImageParamsDict,
42
45
  CreateSandboxParams as CreateSandboxParamsDict,
43
46
  SandboxExecParams as SandboxExecParamsDict,
44
47
  SandboxExposeParams as SandboxExposeParamsDict,
@@ -67,9 +70,12 @@ from ..sandboxes.image_build import (
67
70
  build_docker_image_from_dockerfile,
68
71
  is_terminal_image_build_status,
69
72
  make_temp_docker_tag,
70
- package_docker_image,
73
+ merge_image_init,
74
+ package_docker_build_context_manifest,
75
+ package_docker_image_manifest,
76
+ prepare_docker_image_manifest_source,
71
77
  remove_docker_image,
72
- upload_image_build_artifact,
78
+ upload_missing_image_build_artifacts,
73
79
  )
74
80
  from .sandboxes.sandbox_files import (
75
81
  DEFAULT_WATCH_TIMEOUT_MS,
@@ -494,6 +500,20 @@ class SandboxManager:
494
500
  payload = await self._request("GET", f"/images/builds/{build_id}")
495
501
  return SandboxImageBuild(**payload["build"])
496
502
 
503
+ async def reuse_docker_image(
504
+ self,
505
+ params: Union[
506
+ ReuseSandboxDockerImageParamsDict,
507
+ ReuseSandboxDockerImageParams,
508
+ ],
509
+ ) -> SandboxDockerImageReuseResult:
510
+ payload = await self._request(
511
+ "POST",
512
+ "/images/builds/reuse",
513
+ data=dump_request(params, ReuseSandboxDockerImageParams),
514
+ )
515
+ return SandboxDockerImageReuseResult(**payload)
516
+
497
517
  async def complete_image_build(
498
518
  self,
499
519
  build_id: str,
@@ -566,18 +586,158 @@ class SandboxManager:
566
586
  temp_dir: Optional[str] = None,
567
587
  upload_timeout: Optional[float] = None,
568
588
  ) -> SandboxImageBuild:
569
- artifact = await _run_blocking(
570
- package_docker_image,
589
+ source = await _run_blocking(
590
+ prepare_docker_image_manifest_source,
571
591
  docker_image,
572
592
  platform=platform,
593
+ )
594
+ try:
595
+ explicit_image_init = (
596
+ coerce_request(image_init, SandboxImageInit, name="image_init")
597
+ if image_init is not None
598
+ else None
599
+ )
600
+ normalized_image_init = merge_image_init(
601
+ source.image_init,
602
+ explicit_image_init,
603
+ )
604
+ normalized_image_config_user = (
605
+ image_config_user
606
+ if image_config_user is not None
607
+ else source.image_config_user
608
+ )
609
+ try:
610
+ reused = await self.reuse_docker_image(
611
+ ReuseSandboxDockerImageParams(
612
+ image_name=image_name,
613
+ source_image_digest=source.image_digest,
614
+ source_platform=platform,
615
+ image_config_user=normalized_image_config_user,
616
+ image_init=normalized_image_init,
617
+ )
618
+ )
619
+ except HyperbrowserError as error:
620
+ if error.status_code != 404:
621
+ raise
622
+ reused = None
623
+ if reused is not None and reused.hit:
624
+ if reused.build is None:
625
+ raise RuntimeError(
626
+ "exact image cache response is missing its completed build"
627
+ )
628
+ return reused.build
629
+
630
+ packaged = await _run_blocking(
631
+ package_docker_image_manifest,
632
+ docker_image,
633
+ source.image_digest,
634
+ source.config,
635
+ platform=platform,
636
+ temp_dir=temp_dir,
637
+ )
638
+ build_id = None
639
+ build_started = False
640
+ try:
641
+ artifact = packaged.artifact
642
+ create_result = await self.create_image_build(
643
+ CreateSandboxImageBuildParams(
644
+ image_name=image_name,
645
+ input_sha256=artifact.sha256_hex,
646
+ input_size_bytes=artifact.size_bytes,
647
+ input_format=artifact.input_format,
648
+ source_platform=artifact.source_platform,
649
+ image_config_user=normalized_image_config_user,
650
+ image_init=normalized_image_init,
651
+ docker_image_manifest=packaged.manifest,
652
+ )
653
+ )
654
+ build_id = create_result.build.id
655
+ await _run_blocking(
656
+ upload_missing_image_build_artifacts,
657
+ create_result.uploads,
658
+ packaged.layers,
659
+ label="Docker image layer",
660
+ timeout=upload_timeout,
661
+ )
662
+ build = await self._complete_image_build_resilient(
663
+ build_id,
664
+ artifact,
665
+ )
666
+ build_started = True
667
+ if wait:
668
+ return await self.wait_for_image_build(
669
+ build.id,
670
+ poll_interval=poll_interval,
671
+ timeout=wait_timeout,
672
+ )
673
+ return build
674
+ except Exception:
675
+ if build_id is not None and not build_started:
676
+ try:
677
+ await self.cancel_image_build(build_id)
678
+ except Exception:
679
+ pass
680
+ raise
681
+ finally:
682
+ await _run_blocking(packaged.cleanup)
683
+ finally:
684
+ await _run_blocking(source.cleanup)
685
+
686
+ async def _complete_image_build_resilient(
687
+ self,
688
+ build_id: str,
689
+ artifact,
690
+ ) -> SandboxImageBuild:
691
+ params = CompleteSandboxImageBuildParams(
692
+ input_sha256=artifact.sha256_hex,
693
+ input_size_bytes=artifact.size_bytes,
694
+ input_format=artifact.input_format,
695
+ )
696
+ try:
697
+ return await self.complete_image_build(build_id, params)
698
+ except HyperbrowserError as exc:
699
+ if exc.status_code != 409:
700
+ raise
701
+ if "already in progress" in str(exc).lower():
702
+ return await self.get_image_build(build_id)
703
+ current = await self.get_image_build(build_id)
704
+ if current.status not in ("awaiting_upload", "upload_verified"):
705
+ raise
706
+ await asyncio.sleep(2)
707
+ return await self.complete_image_build(build_id, params)
708
+
709
+ async def _build_image_from_remote_dockerfile(
710
+ self,
711
+ *,
712
+ context_path,
713
+ image_name: str,
714
+ dockerfile,
715
+ platform: str,
716
+ remote_full_context: bool,
717
+ image_init: Optional[Union[SandboxImageInitDict, SandboxImageInit]],
718
+ image_config_user: Optional[str],
719
+ wait: bool,
720
+ poll_interval: float,
721
+ wait_timeout: Optional[float],
722
+ temp_dir: Optional[str],
723
+ upload_timeout: Optional[float],
724
+ ) -> SandboxImageBuild:
725
+ packaged = await _run_blocking(
726
+ package_docker_build_context_manifest,
727
+ context_path,
728
+ dockerfile=dockerfile,
729
+ force_full_context=remote_full_context,
573
730
  temp_dir=temp_dir,
574
731
  )
732
+ build_id = None
733
+ build_started = False
575
734
  try:
576
735
  normalized_image_init = (
577
736
  coerce_request(image_init, SandboxImageInit, name="image_init")
578
737
  if image_init is not None
579
- else artifact.image_init
738
+ else None
580
739
  )
740
+ artifact = packaged.artifact
581
741
  create_result = await self.create_image_build(
582
742
  CreateSandboxImageBuildParams(
583
743
  image_name=image_name,
@@ -585,28 +745,22 @@ class SandboxManager:
585
745
  input_size_bytes=artifact.size_bytes,
586
746
  input_format=artifact.input_format,
587
747
  source_platform=artifact.source_platform,
588
- image_config_user=(
589
- image_config_user
590
- if image_config_user is not None
591
- else artifact.image_config_user
592
- ),
748
+ dockerfile_path=packaged.manifest.dockerfile_path,
749
+ image_config_user=image_config_user,
593
750
  image_init=normalized_image_init,
751
+ context_manifest=packaged.manifest,
594
752
  )
595
753
  )
754
+ build_id = create_result.build.id
596
755
  await _run_blocking(
597
- upload_image_build_artifact,
598
- create_result.upload,
599
- artifact.path,
756
+ upload_missing_image_build_artifacts,
757
+ create_result.uploads,
758
+ packaged.bundles,
759
+ label="build context bundle",
600
760
  timeout=upload_timeout,
601
761
  )
602
- build = await self.complete_image_build(
603
- create_result.build.id,
604
- CompleteSandboxImageBuildParams(
605
- input_sha256=artifact.sha256_hex,
606
- input_size_bytes=artifact.size_bytes,
607
- input_format=artifact.input_format,
608
- ),
609
- )
762
+ build = await self._complete_image_build_resilient(build_id, artifact)
763
+ build_started = True
610
764
  if wait:
611
765
  return await self.wait_for_image_build(
612
766
  build.id,
@@ -614,8 +768,15 @@ class SandboxManager:
614
768
  timeout=wait_timeout,
615
769
  )
616
770
  return build
771
+ except Exception:
772
+ if build_id is not None and not build_started:
773
+ try:
774
+ await self.cancel_image_build(build_id)
775
+ except Exception:
776
+ pass
777
+ raise
617
778
  finally:
618
- artifact.cleanup()
779
+ await _run_blocking(packaged.cleanup)
619
780
 
620
781
  async def build_image_from_dockerfile(
621
782
  self,
@@ -623,6 +784,8 @@ class SandboxManager:
623
784
  context_path,
624
785
  image_name: str,
625
786
  dockerfile="Dockerfile",
787
+ remote: bool = True,
788
+ remote_full_context: bool = False,
626
789
  docker_tag: Optional[str] = None,
627
790
  platform: str = IMAGE_BUILD_SOURCE_PLATFORM,
628
791
  build_args: Optional[Dict[str, str]] = None,
@@ -634,6 +797,26 @@ class SandboxManager:
634
797
  temp_dir: Optional[str] = None,
635
798
  upload_timeout: Optional[float] = None,
636
799
  ) -> SandboxImageBuild:
800
+ if remote:
801
+ if docker_tag is not None or build_args:
802
+ raise ValueError(
803
+ "docker_tag and build_args require remote=False; remote "
804
+ "Dockerfile builds send the build context to Hyperbrowser"
805
+ )
806
+ return await self._build_image_from_remote_dockerfile(
807
+ context_path=context_path,
808
+ image_name=image_name,
809
+ dockerfile=dockerfile,
810
+ platform=platform,
811
+ remote_full_context=remote_full_context,
812
+ image_init=image_init,
813
+ image_config_user=image_config_user,
814
+ wait=wait,
815
+ poll_interval=poll_interval,
816
+ wait_timeout=wait_timeout,
817
+ temp_dir=temp_dir,
818
+ upload_timeout=upload_timeout,
819
+ )
637
820
  tag = docker_tag or make_temp_docker_tag()
638
821
  remove_tag = docker_tag is None
639
822
  try:
@@ -4,6 +4,7 @@ from hyperbrowser.client._request import dump_request
4
4
  from hyperbrowser.models.volume import (
5
5
  CreateVolumeParams,
6
6
  Volume,
7
+ VolumeDeleteResult,
7
8
  VolumeListParams,
8
9
  VolumeListResponse,
9
10
  )
@@ -43,3 +44,10 @@ class VolumeManager:
43
44
  self._client._build_url(f"/volume/{volume_id}")
44
45
  )
45
46
  return Volume(**response.data)
47
+
48
+ async def delete(self, volume_id: str) -> VolumeDeleteResult:
49
+ """Delete a volume by id or name. Ambiguous names and active mounts return 409."""
50
+ response = await self._client.transport.delete(
51
+ self._client._build_url(f"/volume/{volume_id}")
52
+ )
53
+ return VolumeDeleteResult(**response.data)
@@ -0,0 +1,293 @@
1
+ """Conservative Dockerfile analysis for remote build context selection.
2
+
3
+ This module intentionally does not try to execute or fully reproduce the
4
+ BuildKit Dockerfile frontend. It recognizes the context-consuming syntax the
5
+ SDK can analyze safely and requests a full context for anything ambiguous.
6
+ That keeps sparse uploads an optimization rather than a correctness
7
+ requirement.
8
+ """
9
+
10
+ import json
11
+ import re
12
+ import shlex
13
+ from typing import Dict, List, Optional, Tuple
14
+
15
+
16
+ _KNOWN_INSTRUCTIONS = frozenset(
17
+ {
18
+ "ADD",
19
+ "ARG",
20
+ "CMD",
21
+ "COPY",
22
+ "ENTRYPOINT",
23
+ "ENV",
24
+ "EXPOSE",
25
+ "FROM",
26
+ "HEALTHCHECK",
27
+ "LABEL",
28
+ "MAINTAINER",
29
+ "ONBUILD",
30
+ "RUN",
31
+ "SHELL",
32
+ "STOPSIGNAL",
33
+ "USER",
34
+ "VOLUME",
35
+ "WORKDIR",
36
+ }
37
+ )
38
+ _KNOWN_RUN_FLAGS = frozenset({"device", "mount", "network", "security"})
39
+ _INSTRUCTION_PATTERN = re.compile(r"^([A-Za-z]+)(?:[ \t]+(.*))?$")
40
+ _DIRECTIVE_PATTERN = re.compile(
41
+ r"(?im)^\s*#\s*(?P<name>escape|syntax)\s*=\s*(?P<value>\S+)"
42
+ )
43
+ _FLAG_NAME_PATTERN = re.compile(r"^[a-z][a-z0-9-]*$")
44
+ _SCP_GIT_SOURCE_PATTERN = re.compile(r"^git@[^:/\s]+:.+")
45
+
46
+
47
+ class _AnalysisFallback(Exception):
48
+ def __init__(self, reason: str):
49
+ super().__init__(reason)
50
+ self.reason = reason
51
+
52
+
53
+ class _DockerfileSourceAnalyzer:
54
+ def __init__(self, data: bytes):
55
+ self._data = data
56
+
57
+ def analyze(self) -> Tuple[List[List[str]], str]:
58
+ try:
59
+ text = self._data.decode("utf-8")
60
+ except UnicodeDecodeError:
61
+ return [], "dockerfile_parse_failed"
62
+
63
+ try:
64
+ self._validate_parser_directives(text)
65
+ # Heredocs are valid BuildKit syntax, but treating the entire
66
+ # Dockerfile as full context is safer than partially parsing them.
67
+ if "<<" in text:
68
+ raise _AnalysisFallback("dockerfile_instruction_parse_failed")
69
+ logical_lines = self._logical_lines(text)
70
+ return self._analyze_lines(logical_lines), ""
71
+ except _AnalysisFallback as exc:
72
+ return [], exc.reason
73
+
74
+ @staticmethod
75
+ def _validate_parser_directives(text: str) -> None:
76
+ for match in _DIRECTIVE_PATTERN.finditer(text):
77
+ name = match.group("name").lower()
78
+ value = match.group("value")
79
+ if name == "escape" and value != "\\":
80
+ raise _AnalysisFallback("dockerfile_parse_failed")
81
+ if name == "syntax" and not _is_official_dockerfile_frontend(value):
82
+ raise _AnalysisFallback("custom_dockerfile_frontend")
83
+
84
+ @staticmethod
85
+ def _logical_lines(text: str) -> List[str]:
86
+ logical_lines = []
87
+ parts: List[str] = []
88
+ for raw_line in text.splitlines():
89
+ stripped = raw_line.strip()
90
+ if not parts and (not stripped or stripped.startswith("#")):
91
+ continue
92
+ if parts and (not stripped or stripped.startswith("#")):
93
+ # BuildKit has nuanced rules for comments and empty lines in a
94
+ # continuation. Full context is the safe answer here.
95
+ raise _AnalysisFallback("dockerfile_parse_failed")
96
+ if stripped.endswith("\\"):
97
+ parts.append(stripped[:-1].rstrip())
98
+ continue
99
+ parts.append(stripped)
100
+ logical_lines.append(" ".join(parts))
101
+ parts = []
102
+ if parts:
103
+ raise _AnalysisFallback("dockerfile_parse_failed")
104
+ return logical_lines
105
+
106
+ def _analyze_lines(self, logical_lines: List[str]) -> List[List[str]]:
107
+ groups = []
108
+ for line in logical_lines:
109
+ instruction, body = _parse_instruction(line)
110
+ if instruction not in _KNOWN_INSTRUCTIONS:
111
+ raise _AnalysisFallback("dockerfile_instruction_parse_failed")
112
+ if instruction in ("COPY", "ADD"):
113
+ group = self._analyze_copy_or_add(instruction, body)
114
+ if group:
115
+ groups.append(group)
116
+ elif instruction == "RUN":
117
+ groups.extend(self._analyze_run(body))
118
+ return groups
119
+
120
+ @staticmethod
121
+ def _analyze_copy_or_add(instruction: str, body: str) -> List[str]:
122
+ try:
123
+ flags, values = _parse_copy_add_values(body)
124
+ except (ValueError, json.JSONDecodeError):
125
+ raise _AnalysisFallback("dockerfile_instruction_parse_failed")
126
+
127
+ if instruction == "COPY" and "from" in flags:
128
+ return []
129
+
130
+ local_sources = []
131
+ for source in values[:-1]:
132
+ if "$" in source or "\x00" in source:
133
+ reason = (
134
+ "copy_source_requires_expansion"
135
+ if instruction == "COPY"
136
+ else "add_source_requires_expansion"
137
+ )
138
+ raise _AnalysisFallback(reason)
139
+ if "\\" in source:
140
+ # A backslash is a path character on Unix but a separator on
141
+ # Windows. Full context keeps selection platform-independent.
142
+ raise _AnalysisFallback("dockerfile_source_path")
143
+ if instruction == "ADD" and _is_remote_add_source(source):
144
+ continue
145
+ if _source_has_pattern(source):
146
+ # Python glob semantics differ from Go filepath.Match for
147
+ # dotfiles, character classes, and recursive patterns.
148
+ raise _AnalysisFallback("dockerfile_source_pattern")
149
+ local_sources.append(source)
150
+ return local_sources
151
+
152
+ @staticmethod
153
+ def _analyze_run(body: str) -> List[List[str]]:
154
+ try:
155
+ flags, _ = _split_leading_flags(body)
156
+ except ValueError:
157
+ raise _AnalysisFallback("run_mount_parse_failed")
158
+
159
+ groups = []
160
+ for name, value in flags:
161
+ if name not in _KNOWN_RUN_FLAGS:
162
+ raise _AnalysisFallback("dockerfile_instruction_parse_failed")
163
+ if name != "mount":
164
+ continue
165
+ if not value or any(character in value for character in ('"', "'")):
166
+ raise _AnalysisFallback("run_mount_parse_failed")
167
+ options = _parse_mount_options(value)
168
+ if options.get("type", "bind") != "bind" or options.get("from"):
169
+ continue
170
+ source = options.get("source") or options.get("src") or "."
171
+ if "$" in source or "\x00" in source:
172
+ raise _AnalysisFallback("run_bind_source_requires_expansion")
173
+ groups.append([source])
174
+ return groups
175
+
176
+
177
+ def analyze_dockerfile_sources(data: bytes) -> Tuple[List[List[str]], str]:
178
+ """Return local source groups or a reason to upload the full context."""
179
+
180
+ return _DockerfileSourceAnalyzer(data).analyze()
181
+
182
+
183
+ def _parse_instruction(line: str) -> Tuple[str, str]:
184
+ match = _INSTRUCTION_PATTERN.fullmatch(line)
185
+ if match is None:
186
+ raise _AnalysisFallback("dockerfile_parse_failed")
187
+ return match.group(1).upper(), match.group(2) or ""
188
+
189
+
190
+ def _parse_copy_add_values(body: str) -> Tuple[Dict[str, str], List[str]]:
191
+ parsed_flags, remaining = _split_leading_flags(body)
192
+ flags = dict(parsed_flags)
193
+ if remaining.startswith("["):
194
+ values = json.loads(remaining)
195
+ if not isinstance(values, list) or not all(
196
+ isinstance(value, str) for value in values
197
+ ):
198
+ raise ValueError("invalid JSON COPY/ADD")
199
+ else:
200
+ if "\\" in remaining:
201
+ # BuildKit's shell-form COPY/ADD word splitting is not POSIX shlex.
202
+ # Backslash-containing forms use the full context rather than risk
203
+ # selecting a different set of sources.
204
+ raise ValueError("escaped shell COPY/ADD requires full context")
205
+ values = shlex.split(remaining, posix=True)
206
+ if len(values) < 2:
207
+ raise ValueError("COPY/ADD is missing source or destination")
208
+ return flags, values
209
+
210
+
211
+ def _split_leading_flags(body: str) -> Tuple[List[Tuple[str, str]], str]:
212
+ flags = []
213
+ remaining = body.lstrip()
214
+ while remaining.startswith("--"):
215
+ raw_token, remaining = _take_word(remaining)
216
+ decoded = shlex.split(raw_token, posix=True)
217
+ if len(decoded) != 1 or not decoded[0].startswith("--"):
218
+ raise ValueError("invalid Dockerfile instruction flag")
219
+ key, has_value, value = decoded[0][2:].partition("=")
220
+ key = key.lower()
221
+ if not _FLAG_NAME_PATTERN.fullmatch(key):
222
+ raise ValueError("invalid Dockerfile instruction flag")
223
+ if key == "mount" and any(character in raw_token for character in ('"', "'")):
224
+ # BuildKit parses mount values as CSV after Dockerfile word
225
+ # processing. Quoted CSV values are deliberately outside the
226
+ # subset implemented here.
227
+ raise ValueError("quoted RUN mount requires full context")
228
+ flags.append((key, value if has_value else ""))
229
+ remaining = remaining.lstrip()
230
+ return flags, remaining
231
+
232
+
233
+ def _take_word(value: str) -> Tuple[str, str]:
234
+ quote: Optional[str] = None
235
+ escaped = False
236
+ for index, character in enumerate(value):
237
+ if escaped:
238
+ escaped = False
239
+ continue
240
+ if character == "\\" and quote != "'":
241
+ escaped = True
242
+ continue
243
+ if quote is not None:
244
+ if character == quote:
245
+ quote = None
246
+ continue
247
+ if character in ('"', "'"):
248
+ quote = character
249
+ elif character.isspace():
250
+ return value[:index], value[index:]
251
+ if quote is not None or escaped:
252
+ raise ValueError("unterminated Dockerfile instruction word")
253
+ return value, ""
254
+
255
+
256
+ def _parse_mount_options(value: str) -> Dict[str, str]:
257
+ options = {}
258
+ for field in value.split(","):
259
+ key, has_value, option_value = field.partition("=")
260
+ key = key.strip().lower()
261
+ if not key:
262
+ raise _AnalysisFallback("run_mount_parse_failed")
263
+ options[key] = option_value.strip() if has_value else ""
264
+ return options
265
+
266
+
267
+ def _source_has_pattern(source: str) -> bool:
268
+ return any(character in source for character in ("*", "?", "["))
269
+
270
+
271
+ def _is_official_dockerfile_frontend(reference: str) -> bool:
272
+ normalized = reference.strip()
273
+ if normalized.startswith("docker-image://"):
274
+ normalized = normalized[len("docker-image://") :]
275
+ normalized = normalized.split("@", 1)[0]
276
+ last_slash = normalized.rfind("/")
277
+ last_colon = normalized.rfind(":")
278
+ if last_colon > last_slash:
279
+ normalized = normalized[:last_colon]
280
+ return normalized in {
281
+ "docker/dockerfile",
282
+ "docker.io/docker/dockerfile",
283
+ "index.docker.io/docker/dockerfile",
284
+ "docker/dockerfile-upstream",
285
+ "docker.io/docker/dockerfile-upstream",
286
+ "index.docker.io/docker/dockerfile-upstream",
287
+ }
288
+
289
+
290
+ def _is_remote_add_source(source: str) -> bool:
291
+ if _SCP_GIT_SOURCE_PATTERN.match(source):
292
+ return True
293
+ return source.startswith(("http://", "https://", "git://", "ssh://"))