hyperbrowser 1.7.0__tar.gz → 1.8.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 (121) hide show
  1. {hyperbrowser-1.7.0 → hyperbrowser-1.8.0}/PKG-INFO +37 -1
  2. {hyperbrowser-1.7.0 → hyperbrowser-1.8.0}/README.md +36 -0
  3. hyperbrowser-1.8.0/hyperbrowser/build_context.py +8 -0
  4. {hyperbrowser-1.7.0 → hyperbrowser-1.8.0}/hyperbrowser/client/managers/async_manager/sandbox.py +6 -0
  5. {hyperbrowser-1.7.0 → hyperbrowser-1.8.0}/hyperbrowser/client/managers/sandboxes/image_build.py +197 -43
  6. {hyperbrowser-1.7.0 → hyperbrowser-1.8.0}/hyperbrowser/client/managers/sync_manager/sandbox.py +6 -0
  7. {hyperbrowser-1.7.0 → hyperbrowser-1.8.0}/hyperbrowser/models/sandbox.py +1 -0
  8. {hyperbrowser-1.7.0 → hyperbrowser-1.8.0}/pyproject.toml +1 -1
  9. {hyperbrowser-1.7.0 → hyperbrowser-1.8.0}/LICENSE +0 -0
  10. {hyperbrowser-1.7.0 → hyperbrowser-1.8.0}/hyperbrowser/__init__.py +0 -0
  11. {hyperbrowser-1.7.0 → hyperbrowser-1.8.0}/hyperbrowser/client/_request.py +0 -0
  12. {hyperbrowser-1.7.0 → hyperbrowser-1.8.0}/hyperbrowser/client/async_client.py +0 -0
  13. {hyperbrowser-1.7.0 → hyperbrowser-1.8.0}/hyperbrowser/client/base.py +0 -0
  14. {hyperbrowser-1.7.0 → hyperbrowser-1.8.0}/hyperbrowser/client/managers/async_manager/agents/__init__.py +0 -0
  15. {hyperbrowser-1.7.0 → hyperbrowser-1.8.0}/hyperbrowser/client/managers/async_manager/agents/browser_use.py +0 -0
  16. {hyperbrowser-1.7.0 → hyperbrowser-1.8.0}/hyperbrowser/client/managers/async_manager/agents/claude_computer_use.py +0 -0
  17. {hyperbrowser-1.7.0 → hyperbrowser-1.8.0}/hyperbrowser/client/managers/async_manager/agents/cua.py +0 -0
  18. {hyperbrowser-1.7.0 → hyperbrowser-1.8.0}/hyperbrowser/client/managers/async_manager/agents/gemini_computer_use.py +0 -0
  19. {hyperbrowser-1.7.0 → hyperbrowser-1.8.0}/hyperbrowser/client/managers/async_manager/agents/grok_computer_use.py +0 -0
  20. {hyperbrowser-1.7.0 → hyperbrowser-1.8.0}/hyperbrowser/client/managers/async_manager/agents/hyper_agent.py +0 -0
  21. {hyperbrowser-1.7.0 → hyperbrowser-1.8.0}/hyperbrowser/client/managers/async_manager/agents/jev_computer_use.py +0 -0
  22. {hyperbrowser-1.7.0 → hyperbrowser-1.8.0}/hyperbrowser/client/managers/async_manager/agents/meta_computer_use.py +0 -0
  23. {hyperbrowser-1.7.0 → hyperbrowser-1.8.0}/hyperbrowser/client/managers/async_manager/computer_action.py +0 -0
  24. {hyperbrowser-1.7.0 → hyperbrowser-1.8.0}/hyperbrowser/client/managers/async_manager/crawl.py +0 -0
  25. {hyperbrowser-1.7.0 → hyperbrowser-1.8.0}/hyperbrowser/client/managers/async_manager/extension.py +0 -0
  26. {hyperbrowser-1.7.0 → hyperbrowser-1.8.0}/hyperbrowser/client/managers/async_manager/extract.py +0 -0
  27. {hyperbrowser-1.7.0 → hyperbrowser-1.8.0}/hyperbrowser/client/managers/async_manager/profile.py +0 -0
  28. {hyperbrowser-1.7.0 → hyperbrowser-1.8.0}/hyperbrowser/client/managers/async_manager/sandboxes/__init__.py +0 -0
  29. {hyperbrowser-1.7.0 → hyperbrowser-1.8.0}/hyperbrowser/client/managers/async_manager/sandboxes/sandbox_files.py +0 -0
  30. {hyperbrowser-1.7.0 → hyperbrowser-1.8.0}/hyperbrowser/client/managers/async_manager/sandboxes/sandbox_processes.py +0 -0
  31. {hyperbrowser-1.7.0 → hyperbrowser-1.8.0}/hyperbrowser/client/managers/async_manager/sandboxes/sandbox_terminal.py +0 -0
  32. {hyperbrowser-1.7.0 → hyperbrowser-1.8.0}/hyperbrowser/client/managers/async_manager/sandboxes/sandbox_transport.py +0 -0
  33. {hyperbrowser-1.7.0 → hyperbrowser-1.8.0}/hyperbrowser/client/managers/async_manager/scrape.py +0 -0
  34. {hyperbrowser-1.7.0 → hyperbrowser-1.8.0}/hyperbrowser/client/managers/async_manager/session.py +0 -0
  35. {hyperbrowser-1.7.0 → hyperbrowser-1.8.0}/hyperbrowser/client/managers/async_manager/team.py +0 -0
  36. {hyperbrowser-1.7.0 → hyperbrowser-1.8.0}/hyperbrowser/client/managers/async_manager/volume.py +0 -0
  37. {hyperbrowser-1.7.0 → hyperbrowser-1.8.0}/hyperbrowser/client/managers/async_manager/web/__init__.py +0 -0
  38. {hyperbrowser-1.7.0 → hyperbrowser-1.8.0}/hyperbrowser/client/managers/async_manager/web/batch_fetch.py +0 -0
  39. {hyperbrowser-1.7.0 → hyperbrowser-1.8.0}/hyperbrowser/client/managers/async_manager/web/crawl.py +0 -0
  40. {hyperbrowser-1.7.0 → hyperbrowser-1.8.0}/hyperbrowser/client/managers/sandboxes/__init__.py +0 -0
  41. {hyperbrowser-1.7.0 → hyperbrowser-1.8.0}/hyperbrowser/client/managers/sandboxes/dockerfile_analysis.py +0 -0
  42. {hyperbrowser-1.7.0 → hyperbrowser-1.8.0}/hyperbrowser/client/managers/sandboxes/dockerignore.py +0 -0
  43. {hyperbrowser-1.7.0 → hyperbrowser-1.8.0}/hyperbrowser/client/managers/sandboxes/process_output.py +0 -0
  44. {hyperbrowser-1.7.0 → hyperbrowser-1.8.0}/hyperbrowser/client/managers/sandboxes/shared.py +0 -0
  45. {hyperbrowser-1.7.0 → hyperbrowser-1.8.0}/hyperbrowser/client/managers/sync_manager/agents/__init__.py +0 -0
  46. {hyperbrowser-1.7.0 → hyperbrowser-1.8.0}/hyperbrowser/client/managers/sync_manager/agents/browser_use.py +0 -0
  47. {hyperbrowser-1.7.0 → hyperbrowser-1.8.0}/hyperbrowser/client/managers/sync_manager/agents/claude_computer_use.py +0 -0
  48. {hyperbrowser-1.7.0 → hyperbrowser-1.8.0}/hyperbrowser/client/managers/sync_manager/agents/cua.py +0 -0
  49. {hyperbrowser-1.7.0 → hyperbrowser-1.8.0}/hyperbrowser/client/managers/sync_manager/agents/gemini_computer_use.py +0 -0
  50. {hyperbrowser-1.7.0 → hyperbrowser-1.8.0}/hyperbrowser/client/managers/sync_manager/agents/grok_computer_use.py +0 -0
  51. {hyperbrowser-1.7.0 → hyperbrowser-1.8.0}/hyperbrowser/client/managers/sync_manager/agents/hyper_agent.py +0 -0
  52. {hyperbrowser-1.7.0 → hyperbrowser-1.8.0}/hyperbrowser/client/managers/sync_manager/agents/jev_computer_use.py +0 -0
  53. {hyperbrowser-1.7.0 → hyperbrowser-1.8.0}/hyperbrowser/client/managers/sync_manager/agents/meta_computer_use.py +0 -0
  54. {hyperbrowser-1.7.0 → hyperbrowser-1.8.0}/hyperbrowser/client/managers/sync_manager/computer_action.py +0 -0
  55. {hyperbrowser-1.7.0 → hyperbrowser-1.8.0}/hyperbrowser/client/managers/sync_manager/crawl.py +0 -0
  56. {hyperbrowser-1.7.0 → hyperbrowser-1.8.0}/hyperbrowser/client/managers/sync_manager/extension.py +0 -0
  57. {hyperbrowser-1.7.0 → hyperbrowser-1.8.0}/hyperbrowser/client/managers/sync_manager/extract.py +0 -0
  58. {hyperbrowser-1.7.0 → hyperbrowser-1.8.0}/hyperbrowser/client/managers/sync_manager/profile.py +0 -0
  59. {hyperbrowser-1.7.0 → hyperbrowser-1.8.0}/hyperbrowser/client/managers/sync_manager/sandboxes/__init__.py +0 -0
  60. {hyperbrowser-1.7.0 → hyperbrowser-1.8.0}/hyperbrowser/client/managers/sync_manager/sandboxes/sandbox_files.py +0 -0
  61. {hyperbrowser-1.7.0 → hyperbrowser-1.8.0}/hyperbrowser/client/managers/sync_manager/sandboxes/sandbox_processes.py +0 -0
  62. {hyperbrowser-1.7.0 → hyperbrowser-1.8.0}/hyperbrowser/client/managers/sync_manager/sandboxes/sandbox_terminal.py +0 -0
  63. {hyperbrowser-1.7.0 → hyperbrowser-1.8.0}/hyperbrowser/client/managers/sync_manager/sandboxes/sandbox_transport.py +0 -0
  64. {hyperbrowser-1.7.0 → hyperbrowser-1.8.0}/hyperbrowser/client/managers/sync_manager/scrape.py +0 -0
  65. {hyperbrowser-1.7.0 → hyperbrowser-1.8.0}/hyperbrowser/client/managers/sync_manager/session.py +0 -0
  66. {hyperbrowser-1.7.0 → hyperbrowser-1.8.0}/hyperbrowser/client/managers/sync_manager/team.py +0 -0
  67. {hyperbrowser-1.7.0 → hyperbrowser-1.8.0}/hyperbrowser/client/managers/sync_manager/volume.py +0 -0
  68. {hyperbrowser-1.7.0 → hyperbrowser-1.8.0}/hyperbrowser/client/managers/sync_manager/web/__init__.py +0 -0
  69. {hyperbrowser-1.7.0 → hyperbrowser-1.8.0}/hyperbrowser/client/managers/sync_manager/web/batch_fetch.py +0 -0
  70. {hyperbrowser-1.7.0 → hyperbrowser-1.8.0}/hyperbrowser/client/managers/sync_manager/web/crawl.py +0 -0
  71. {hyperbrowser-1.7.0 → hyperbrowser-1.8.0}/hyperbrowser/client/sync.py +0 -0
  72. {hyperbrowser-1.7.0 → hyperbrowser-1.8.0}/hyperbrowser/config.py +0 -0
  73. {hyperbrowser-1.7.0 → hyperbrowser-1.8.0}/hyperbrowser/exceptions.py +0 -0
  74. {hyperbrowser-1.7.0 → hyperbrowser-1.8.0}/hyperbrowser/models/__init__.py +0 -0
  75. {hyperbrowser-1.7.0 → hyperbrowser-1.8.0}/hyperbrowser/models/_parsers.py +0 -0
  76. {hyperbrowser-1.7.0 → hyperbrowser-1.8.0}/hyperbrowser/models/agents/browser_use.py +0 -0
  77. {hyperbrowser-1.7.0 → hyperbrowser-1.8.0}/hyperbrowser/models/agents/claude_computer_use.py +0 -0
  78. {hyperbrowser-1.7.0 → hyperbrowser-1.8.0}/hyperbrowser/models/agents/cua.py +0 -0
  79. {hyperbrowser-1.7.0 → hyperbrowser-1.8.0}/hyperbrowser/models/agents/gemini_computer_use.py +0 -0
  80. {hyperbrowser-1.7.0 → hyperbrowser-1.8.0}/hyperbrowser/models/agents/grok_computer_use.py +0 -0
  81. {hyperbrowser-1.7.0 → hyperbrowser-1.8.0}/hyperbrowser/models/agents/hyper_agent.py +0 -0
  82. {hyperbrowser-1.7.0 → hyperbrowser-1.8.0}/hyperbrowser/models/agents/jev_computer_use.py +0 -0
  83. {hyperbrowser-1.7.0 → hyperbrowser-1.8.0}/hyperbrowser/models/agents/meta_computer_use.py +0 -0
  84. {hyperbrowser-1.7.0 → hyperbrowser-1.8.0}/hyperbrowser/models/computer_action.py +0 -0
  85. {hyperbrowser-1.7.0 → hyperbrowser-1.8.0}/hyperbrowser/models/consts.py +0 -0
  86. {hyperbrowser-1.7.0 → hyperbrowser-1.8.0}/hyperbrowser/models/crawl.py +0 -0
  87. {hyperbrowser-1.7.0 → hyperbrowser-1.8.0}/hyperbrowser/models/extension.py +0 -0
  88. {hyperbrowser-1.7.0 → hyperbrowser-1.8.0}/hyperbrowser/models/extract.py +0 -0
  89. {hyperbrowser-1.7.0 → hyperbrowser-1.8.0}/hyperbrowser/models/profile.py +0 -0
  90. {hyperbrowser-1.7.0 → hyperbrowser-1.8.0}/hyperbrowser/models/scrape.py +0 -0
  91. {hyperbrowser-1.7.0 → hyperbrowser-1.8.0}/hyperbrowser/models/session.py +0 -0
  92. {hyperbrowser-1.7.0 → hyperbrowser-1.8.0}/hyperbrowser/models/team.py +0 -0
  93. {hyperbrowser-1.7.0 → hyperbrowser-1.8.0}/hyperbrowser/models/volume.py +0 -0
  94. {hyperbrowser-1.7.0 → hyperbrowser-1.8.0}/hyperbrowser/models/web/batch_fetch.py +0 -0
  95. {hyperbrowser-1.7.0 → hyperbrowser-1.8.0}/hyperbrowser/models/web/branding.py +0 -0
  96. {hyperbrowser-1.7.0 → hyperbrowser-1.8.0}/hyperbrowser/models/web/common.py +0 -0
  97. {hyperbrowser-1.7.0 → hyperbrowser-1.8.0}/hyperbrowser/models/web/crawl.py +0 -0
  98. {hyperbrowser-1.7.0 → hyperbrowser-1.8.0}/hyperbrowser/models/web/fetch.py +0 -0
  99. {hyperbrowser-1.7.0 → hyperbrowser-1.8.0}/hyperbrowser/models/web/search.py +0 -0
  100. {hyperbrowser-1.7.0 → hyperbrowser-1.8.0}/hyperbrowser/py.typed +0 -0
  101. {hyperbrowser-1.7.0 → hyperbrowser-1.8.0}/hyperbrowser/sandbox_common.py +0 -0
  102. {hyperbrowser-1.7.0 → hyperbrowser-1.8.0}/hyperbrowser/tools/__init__.py +0 -0
  103. {hyperbrowser-1.7.0 → hyperbrowser-1.8.0}/hyperbrowser/tools/anthropic.py +0 -0
  104. {hyperbrowser-1.7.0 → hyperbrowser-1.8.0}/hyperbrowser/tools/openai.py +0 -0
  105. {hyperbrowser-1.7.0 → hyperbrowser-1.8.0}/hyperbrowser/tools/schema.py +0 -0
  106. {hyperbrowser-1.7.0 → hyperbrowser-1.8.0}/hyperbrowser/transport/async_transport.py +0 -0
  107. {hyperbrowser-1.7.0 → hyperbrowser-1.8.0}/hyperbrowser/transport/base.py +0 -0
  108. {hyperbrowser-1.7.0 → hyperbrowser-1.8.0}/hyperbrowser/transport/sync.py +0 -0
  109. {hyperbrowser-1.7.0 → hyperbrowser-1.8.0}/hyperbrowser/types/__init__.py +0 -0
  110. {hyperbrowser-1.7.0 → hyperbrowser-1.8.0}/hyperbrowser/types/_json.py +0 -0
  111. {hyperbrowser-1.7.0 → hyperbrowser-1.8.0}/hyperbrowser/types/agents.py +0 -0
  112. {hyperbrowser-1.7.0 → hyperbrowser-1.8.0}/hyperbrowser/types/computer_action.py +0 -0
  113. {hyperbrowser-1.7.0 → hyperbrowser-1.8.0}/hyperbrowser/types/crawl.py +0 -0
  114. {hyperbrowser-1.7.0 → hyperbrowser-1.8.0}/hyperbrowser/types/extension.py +0 -0
  115. {hyperbrowser-1.7.0 → hyperbrowser-1.8.0}/hyperbrowser/types/extract.py +0 -0
  116. {hyperbrowser-1.7.0 → hyperbrowser-1.8.0}/hyperbrowser/types/profile.py +0 -0
  117. {hyperbrowser-1.7.0 → hyperbrowser-1.8.0}/hyperbrowser/types/sandbox.py +0 -0
  118. {hyperbrowser-1.7.0 → hyperbrowser-1.8.0}/hyperbrowser/types/scrape.py +0 -0
  119. {hyperbrowser-1.7.0 → hyperbrowser-1.8.0}/hyperbrowser/types/session.py +0 -0
  120. {hyperbrowser-1.7.0 → hyperbrowser-1.8.0}/hyperbrowser/types/volume.py +0 -0
  121. {hyperbrowser-1.7.0 → hyperbrowser-1.8.0}/hyperbrowser/types/web.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: hyperbrowser
3
- Version: 1.7.0
3
+ Version: 1.8.0
4
4
  Summary: Python SDK for hyperbrowser
5
5
  License: MIT
6
6
  License-File: LICENSE
@@ -380,6 +380,42 @@ for event in connection.events():
380
380
  print(event)
381
381
  ```
382
382
 
383
+ ### Cache remote Dockerfile builds
384
+
385
+ Use the public context fingerprint when deriving a cache name. It uses the same
386
+ Dockerfile source selection and `.dockerignore` rules as remote packaging,
387
+ including file contents, modes, paths, and symlinks. It ignores timestamps and
388
+ does not compress or stage the context on disk.
389
+
390
+ ```python
391
+ from hyperbrowser.build_context import docker_build_context_fingerprint
392
+
393
+ fingerprint = docker_build_context_fingerprint("./app")
394
+ # Include build options such as platform and image_init in your cache key too.
395
+ image_name = f"app-{fingerprint[:32]}"
396
+ build = client.sandboxes.build_image_from_dockerfile(
397
+ context_path="./app",
398
+ image_name=image_name,
399
+ expected_context_fingerprint=fingerprint,
400
+ )
401
+ ```
402
+
403
+ If the archived inputs differ from the fingerprint, the SDK raises
404
+ `DockerBuildContextChangedError` before creating or uploading a build. Compute a
405
+ fresh fingerprint and repeat the lookup/build operation. Use the same `dockerfile`
406
+ and `force_full_context` selection when fingerprinting and building (the latter
407
+ is named `remote_full_context` on the build method). The expected fingerprint is
408
+ supported only for remote builds.
409
+
410
+ Fingerprinting streams included files and performs blocking I/O. Async callers
411
+ should use `await asyncio.to_thread(docker_build_context_fingerprint, "./app")`
412
+ (Python 3.9+) or an executor. It does not resolve mutable base-image tags or
413
+ network resources fetched by a Dockerfile; rebuild explicitly when those change.
414
+
415
+ Image listings distinguish `ready` from `uploaded`: a completed team image can
416
+ be ready to launch before its durability backup is uploaded. `ready` is `None`
417
+ when talking to an older server. Keep the returned image ID to pin that revision.
418
+
383
419
  ## License
384
420
 
385
421
  This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
@@ -352,6 +352,42 @@ for event in connection.events():
352
352
  print(event)
353
353
  ```
354
354
 
355
+ ### Cache remote Dockerfile builds
356
+
357
+ Use the public context fingerprint when deriving a cache name. It uses the same
358
+ Dockerfile source selection and `.dockerignore` rules as remote packaging,
359
+ including file contents, modes, paths, and symlinks. It ignores timestamps and
360
+ does not compress or stage the context on disk.
361
+
362
+ ```python
363
+ from hyperbrowser.build_context import docker_build_context_fingerprint
364
+
365
+ fingerprint = docker_build_context_fingerprint("./app")
366
+ # Include build options such as platform and image_init in your cache key too.
367
+ image_name = f"app-{fingerprint[:32]}"
368
+ build = client.sandboxes.build_image_from_dockerfile(
369
+ context_path="./app",
370
+ image_name=image_name,
371
+ expected_context_fingerprint=fingerprint,
372
+ )
373
+ ```
374
+
375
+ If the archived inputs differ from the fingerprint, the SDK raises
376
+ `DockerBuildContextChangedError` before creating or uploading a build. Compute a
377
+ fresh fingerprint and repeat the lookup/build operation. Use the same `dockerfile`
378
+ and `force_full_context` selection when fingerprinting and building (the latter
379
+ is named `remote_full_context` on the build method). The expected fingerprint is
380
+ supported only for remote builds.
381
+
382
+ Fingerprinting streams included files and performs blocking I/O. Async callers
383
+ should use `await asyncio.to_thread(docker_build_context_fingerprint, "./app")`
384
+ (Python 3.9+) or an executor. It does not resolve mutable base-image tags or
385
+ network resources fetched by a Dockerfile; rebuild explicitly when those change.
386
+
387
+ Image listings distinguish `ready` from `uploaded`: a completed team image can
388
+ be ready to launch before its durability backup is uploaded. `ready` is `None`
389
+ when talking to an older server. Keep the returned image ID to pin that revision.
390
+
355
391
  ## License
356
392
 
357
393
  This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
@@ -0,0 +1,8 @@
1
+ """Public helpers for identifying inputs to remote Dockerfile builds."""
2
+
3
+ from .client.managers.sandboxes.image_build import (
4
+ DockerBuildContextChangedError,
5
+ docker_build_context_fingerprint,
6
+ )
7
+
8
+ __all__ = ["DockerBuildContextChangedError", "docker_build_context_fingerprint"]
@@ -725,6 +725,7 @@ class SandboxManager:
725
725
  dockerfile,
726
726
  platform: str,
727
727
  remote_full_context: bool,
728
+ expected_context_fingerprint: Optional[str],
728
729
  image_init: Optional[Union[SandboxImageInitDict, SandboxImageInit]],
729
730
  image_config_user: Optional[str],
730
731
  builder_cpus: Optional[int],
@@ -741,6 +742,7 @@ class SandboxManager:
741
742
  context_path,
742
743
  dockerfile=dockerfile,
743
744
  force_full_context=remote_full_context,
745
+ expected_context_fingerprint=expected_context_fingerprint,
744
746
  temp_dir=temp_dir,
745
747
  )
746
748
  build_id = None
@@ -803,6 +805,7 @@ class SandboxManager:
803
805
  dockerfile="Dockerfile",
804
806
  remote: bool = True,
805
807
  remote_full_context: bool = False,
808
+ expected_context_fingerprint: Optional[str] = None,
806
809
  docker_tag: Optional[str] = None,
807
810
  platform: str = IMAGE_BUILD_SOURCE_PLATFORM,
808
811
  build_args: Optional[Dict[str, str]] = None,
@@ -829,6 +832,7 @@ class SandboxManager:
829
832
  dockerfile=dockerfile,
830
833
  platform=platform,
831
834
  remote_full_context=remote_full_context,
835
+ expected_context_fingerprint=expected_context_fingerprint,
832
836
  image_init=image_init,
833
837
  image_config_user=image_config_user,
834
838
  builder_cpus=builder_cpus,
@@ -840,6 +844,8 @@ class SandboxManager:
840
844
  temp_dir=temp_dir,
841
845
  upload_timeout=upload_timeout,
842
846
  )
847
+ if expected_context_fingerprint is not None:
848
+ raise ValueError("expected_context_fingerprint requires remote=True")
843
849
  tag = docker_tag or make_temp_docker_tag()
844
850
  remove_tag = docker_tag is None
845
851
  try:
@@ -7,6 +7,7 @@ import os
7
7
  import posixpath
8
8
  import re
9
9
  import shutil
10
+ import stat
10
11
  import subprocess
11
12
  import tarfile
12
13
  import tempfile
@@ -25,6 +26,7 @@ from typing import (
25
26
  Sequence,
26
27
  Set,
27
28
  Tuple,
29
+ Union,
28
30
  )
29
31
 
30
32
  import httpx
@@ -99,6 +101,7 @@ class PackagedDockerBuildContext:
99
101
  manifest: SandboxBuildContextManifest
100
102
  bundles: Dict[str, DockerImageBuildArtifact]
101
103
  workspace: str
104
+ fingerprint: str
102
105
 
103
106
  def cleanup(self) -> None:
104
107
  shutil.rmtree(self.workspace, ignore_errors=True)
@@ -155,6 +158,85 @@ class _HashingCountingWriter:
155
158
  return self._fileobj.flush()
156
159
 
157
160
 
161
+ class _ContextHashingReader:
162
+ def __init__(self, source, hasher):
163
+ self._source = source
164
+ self._hasher = hasher
165
+
166
+ def read(self, size):
167
+ data = self._source.read(size)
168
+ self._hasher.update(data)
169
+ return data
170
+
171
+
172
+ class DockerBuildContextChangedError(ValueError):
173
+ """The packaged context no longer matches the caller's cache fingerprint."""
174
+
175
+
176
+ @dataclass
177
+ class _DockerBuildContextSelection:
178
+ root: Path
179
+ dockerfile: str
180
+ mode: Literal["sparse", "full"]
181
+ fallback_reason: Optional[str]
182
+ entry_groups: List[Set[str]]
183
+
184
+ def fingerprint(self, bundle_hashes: Sequence[str]) -> str:
185
+ # Hash entry metadata and contents, not transport encoding: cache
186
+ # identity must not depend on tar headers or a compression library.
187
+ identity = {
188
+ "version": 1,
189
+ "dockerfile": self.dockerfile,
190
+ "contextMode": self.mode,
191
+ "bundles": sorted(set(bundle_hashes)),
192
+ }
193
+ return hashlib.sha256(
194
+ json.dumps(identity, sort_keys=True, separators=(",", ":")).encode()
195
+ ).hexdigest()
196
+
197
+
198
+ def docker_build_context_fingerprint(
199
+ context_path: Union[str, Path],
200
+ *,
201
+ dockerfile: str = "Dockerfile",
202
+ force_full_context: bool = False,
203
+ ) -> str:
204
+ """Fingerprint the effective remote context without compressing or staging it.
205
+
206
+ Uses the same Dockerfile source selection, ignore rules, and normalized tar
207
+ entries as remote packaging. Reads file contents with bounded memory. This
208
+ is blocking I/O; async callers should run it in an executor. Pass the result
209
+ as ``expected_context_fingerprint`` when building to detect context changes.
210
+ Build options outside the context (e.g. platform or image_init) must also be
211
+ included in the caller's cache key. Mutable base tags and network resources
212
+ are not resolved by this fingerprint.
213
+ """
214
+ selection = _select_docker_build_context(
215
+ context_path, dockerfile=dockerfile, force_full_context=force_full_context
216
+ )
217
+ hashes = []
218
+ for entries in selection.entry_groups:
219
+ hasher = hashlib.sha256()
220
+ for relative in sorted(entries):
221
+ info = _context_entry_info(selection.root, relative)
222
+ if info is None:
223
+ continue
224
+ _hash_context_entry_metadata(hasher, info)
225
+ if info.isfile():
226
+ with open(selection.root / relative, "rb") as source:
227
+ reader = _ContextHashingReader(source, hasher)
228
+ remaining = info.size
229
+ while remaining:
230
+ data = reader.read(min(64 * 1024, remaining))
231
+ if not data:
232
+ raise OSError(
233
+ f'build context file "{relative}" was truncated'
234
+ )
235
+ remaining -= len(data)
236
+ hashes.append(hasher.hexdigest())
237
+ return selection.fingerprint(hashes)
238
+
239
+
158
240
  def build_docker_image_from_dockerfile(
159
241
  *,
160
242
  context_path,
@@ -188,13 +270,12 @@ def build_docker_image_from_dockerfile(
188
270
  _run_command(args)
189
271
 
190
272
 
191
- def package_docker_build_context_manifest(
273
+ def _select_docker_build_context(
192
274
  context_path,
193
275
  *,
194
276
  dockerfile="Dockerfile",
195
277
  force_full_context: bool = False,
196
- temp_dir: Optional[str] = None,
197
- ) -> PackagedDockerBuildContext:
278
+ ) -> _DockerBuildContextSelection:
198
279
  context_root = Path(context_path).expanduser().resolve(strict=True)
199
280
  if not context_root.is_dir():
200
281
  raise ValueError("Docker build context must be a directory")
@@ -288,28 +369,59 @@ def package_docker_build_context_manifest(
288
369
  ]
289
370
 
290
371
  entry_groups = _remove_subsumed_entry_groups(entry_groups)
372
+ return _DockerBuildContextSelection(
373
+ context_root, dockerfile_relative, context_mode, fallback_reason, entry_groups
374
+ )
375
+
376
+
377
+ def package_docker_build_context_manifest(
378
+ context_path,
379
+ *,
380
+ dockerfile="Dockerfile",
381
+ force_full_context: bool = False,
382
+ temp_dir: Optional[str] = None,
383
+ expected_context_fingerprint: Optional[str] = None,
384
+ ) -> PackagedDockerBuildContext:
385
+ if expected_context_fingerprint is not None and not _SHA256_PATTERN.fullmatch(
386
+ expected_context_fingerprint
387
+ ):
388
+ raise ValueError("expected_context_fingerprint must be a SHA-256 hex digest")
389
+ selection = _select_docker_build_context(
390
+ context_path, dockerfile=dockerfile, force_full_context=force_full_context
391
+ )
291
392
  workspace = tempfile.mkdtemp(prefix="hb-docker-context-", dir=temp_dir)
292
393
  try:
293
394
  bundles = {}
294
395
  descriptors = []
295
- for index, entries in enumerate(entry_groups):
296
- artifact, descriptor = _package_context_bundle(
297
- context_root,
396
+ bundle_hashes = []
397
+ for index, entries in enumerate(selection.entry_groups):
398
+ artifact, descriptor, bundle_hash = _package_context_bundle(
399
+ selection.root,
298
400
  sorted(entries),
299
401
  workspace,
300
402
  index,
301
403
  )
404
+ bundle_hashes.append(bundle_hash)
302
405
  if descriptor.sha256 in bundles:
303
406
  artifact.cleanup()
304
407
  continue
305
408
  bundles[descriptor.sha256] = artifact
306
409
  descriptors.append(descriptor)
307
410
  descriptors.sort(key=lambda item: item.sha256)
411
+ fingerprint = selection.fingerprint(bundle_hashes)
412
+ if (
413
+ expected_context_fingerprint is not None
414
+ and fingerprint != expected_context_fingerprint
415
+ ):
416
+ raise DockerBuildContextChangedError(
417
+ "Docker build context changed after its cache fingerprint was "
418
+ "computed. Retry the build with a fresh fingerprint."
419
+ )
308
420
  manifest = SandboxBuildContextManifest(
309
421
  version=1,
310
- dockerfile_path=dockerfile_relative,
311
- context_mode=context_mode,
312
- fallback_reason=fallback_reason or None,
422
+ dockerfile_path=selection.dockerfile,
423
+ context_mode=selection.mode,
424
+ fallback_reason=selection.fallback_reason or None,
313
425
  bundles=descriptors,
314
426
  )
315
427
  manifest_bytes = _canonical_model_json(manifest)
@@ -324,6 +436,7 @@ def package_docker_build_context_manifest(
324
436
  manifest=manifest,
325
437
  bundles=bundles,
326
438
  workspace=workspace,
439
+ fingerprint=fingerprint,
327
440
  )
328
441
  except Exception:
329
442
  shutil.rmtree(workspace, ignore_errors=True)
@@ -1192,11 +1305,10 @@ def _package_context_bundle(
1192
1305
  entries: Sequence[str],
1193
1306
  workspace: str,
1194
1307
  index: int,
1195
- ) -> Tuple[DockerImageBuildArtifact, SandboxBuildContextBundle]:
1308
+ ) -> Tuple[DockerImageBuildArtifact, SandboxBuildContextBundle, str]:
1196
1309
  bundle_path = os.path.join(workspace, f"bundle-{index:04d}.tar.gz")
1197
1310
  hasher = hashlib.sha256()
1198
- uncompressed_size = 0
1199
- entry_count = 0
1311
+ context_hasher = hashlib.sha256()
1200
1312
  with open(bundle_path, "wb") as destination:
1201
1313
  writer = _HashingCountingWriter(destination, hasher)
1202
1314
  with gzip.GzipFile(
@@ -1206,36 +1318,12 @@ def _package_context_bundle(
1206
1318
  compresslevel=1,
1207
1319
  mtime=0,
1208
1320
  ) as compressed:
1209
- with tarfile.open(
1210
- fileobj=compressed,
1211
- mode="w",
1212
- format=tarfile.PAX_FORMAT,
1213
- ) as archive:
1214
- for relative in entries:
1215
- _validate_archive_relative_path(relative)
1216
- absolute = context_root / relative
1217
- info = archive.gettarinfo(str(absolute), arcname=relative)
1218
- if not (info.isfile() or info.isdir() or info.issym()):
1219
- continue
1220
- info.uid = 0
1221
- info.gid = 0
1222
- info.uname = ""
1223
- info.gname = ""
1224
- info.mtime = 0
1225
- info.pax_headers = {}
1226
- if info.isdir() and not info.name.endswith("/"):
1227
- info.name += "/"
1228
- if info.issym() and not info.linkname:
1229
- raise ValueError(
1230
- f'build context symlink "{relative}" has an invalid target'
1231
- )
1232
- if info.isfile():
1233
- with open(absolute, "rb") as source:
1234
- archive.addfile(info, source)
1235
- uncompressed_size += info.size
1236
- else:
1237
- archive.addfile(info)
1238
- entry_count += 1
1321
+ uncompressed_size, entry_count = _write_context_archive(
1322
+ context_root,
1323
+ entries,
1324
+ compressed,
1325
+ context_hasher,
1326
+ )
1239
1327
  size_bytes = os.path.getsize(bundle_path)
1240
1328
  sha256_hex = hasher.hexdigest()
1241
1329
  artifact = DockerImageBuildArtifact(
@@ -1250,7 +1338,73 @@ def _package_context_bundle(
1250
1338
  uncompressed_size_bytes=uncompressed_size,
1251
1339
  entry_count=entry_count,
1252
1340
  )
1253
- return artifact, descriptor
1341
+ return artifact, descriptor, context_hasher.hexdigest()
1342
+
1343
+
1344
+ def _context_entry_info(context_root: Path, relative: str) -> Optional[tarfile.TarInfo]:
1345
+ _validate_archive_relative_path(relative)
1346
+ absolute = context_root / relative
1347
+ metadata = absolute.lstat()
1348
+ # Construct normalized metadata directly. gettarinfo() resolves owner names
1349
+ # that we discard and converts repeated host inodes to hard-link entries.
1350
+ # Every regular path must instead be present with its own contents; preserve
1351
+ # symlinks without following them.
1352
+ info = tarfile.TarInfo(relative)
1353
+ info.mode = stat.S_IMODE(metadata.st_mode)
1354
+ if stat.S_ISREG(metadata.st_mode):
1355
+ info.type = tarfile.REGTYPE
1356
+ info.size = metadata.st_size
1357
+ elif stat.S_ISDIR(metadata.st_mode):
1358
+ info.type = tarfile.DIRTYPE
1359
+ info.name += "/"
1360
+ elif stat.S_ISLNK(metadata.st_mode):
1361
+ info.type = tarfile.SYMTYPE
1362
+ info.linkname = os.readlink(absolute)
1363
+ if not info.linkname:
1364
+ raise ValueError(
1365
+ f'build context symlink "{relative}" has an invalid target'
1366
+ )
1367
+ else:
1368
+ return None
1369
+ return info
1370
+
1371
+
1372
+ def _hash_context_entry_metadata(hasher, info: tarfile.TarInfo) -> None:
1373
+ metadata = json.dumps(
1374
+ [
1375
+ info.name.rstrip("/") if info.isdir() else info.name,
1376
+ "file" if info.isfile() else "directory" if info.isdir() else "symlink",
1377
+ info.mode,
1378
+ info.size,
1379
+ info.linkname,
1380
+ ],
1381
+ separators=(",", ":"),
1382
+ ).encode()
1383
+ hasher.update(len(metadata).to_bytes(8, "big"))
1384
+ hasher.update(metadata)
1385
+
1386
+
1387
+ def _write_context_archive(
1388
+ context_root: Path, entries: Sequence[str], destination, hasher
1389
+ ):
1390
+ uncompressed_size = 0
1391
+ entry_count = 0
1392
+ with tarfile.open(
1393
+ fileobj=destination, mode="w", format=tarfile.PAX_FORMAT
1394
+ ) as archive:
1395
+ for relative in entries:
1396
+ info = _context_entry_info(context_root, relative)
1397
+ if info is None:
1398
+ continue
1399
+ _hash_context_entry_metadata(hasher, info)
1400
+ if info.isfile():
1401
+ with open(context_root / relative, "rb") as source:
1402
+ archive.addfile(info, _ContextHashingReader(source, hasher))
1403
+ uncompressed_size += info.size
1404
+ else:
1405
+ archive.addfile(info)
1406
+ entry_count += 1
1407
+ return uncompressed_size, entry_count
1254
1408
 
1255
1409
 
1256
1410
  def _normalize_docker_save_entry_name(raw: str) -> str:
@@ -711,6 +711,7 @@ class SandboxManager:
711
711
  dockerfile,
712
712
  platform: str,
713
713
  remote_full_context: bool,
714
+ expected_context_fingerprint: Optional[str],
714
715
  image_init: Optional[Union[SandboxImageInitDict, SandboxImageInit]],
715
716
  image_config_user: Optional[str],
716
717
  builder_cpus: Optional[int],
@@ -726,6 +727,7 @@ class SandboxManager:
726
727
  context_path,
727
728
  dockerfile=dockerfile,
728
729
  force_full_context=remote_full_context,
730
+ expected_context_fingerprint=expected_context_fingerprint,
729
731
  temp_dir=temp_dir,
730
732
  )
731
733
  build_id = None
@@ -787,6 +789,7 @@ class SandboxManager:
787
789
  dockerfile="Dockerfile",
788
790
  remote: bool = True,
789
791
  remote_full_context: bool = False,
792
+ expected_context_fingerprint: Optional[str] = None,
790
793
  docker_tag: Optional[str] = None,
791
794
  platform: str = IMAGE_BUILD_SOURCE_PLATFORM,
792
795
  build_args: Optional[Dict[str, str]] = None,
@@ -813,6 +816,7 @@ class SandboxManager:
813
816
  dockerfile=dockerfile,
814
817
  platform=platform,
815
818
  remote_full_context=remote_full_context,
819
+ expected_context_fingerprint=expected_context_fingerprint,
816
820
  image_init=image_init,
817
821
  image_config_user=image_config_user,
818
822
  builder_cpus=builder_cpus,
@@ -824,6 +828,8 @@ class SandboxManager:
824
828
  temp_dir=temp_dir,
825
829
  upload_timeout=upload_timeout,
826
830
  )
831
+ if expected_context_fingerprint is not None:
832
+ raise ValueError("expected_context_fingerprint requires remote=True")
827
833
  tag = docker_tag or make_temp_docker_tag()
828
834
  remove_tag = docker_tag is None
829
835
  try:
@@ -306,6 +306,7 @@ class SandboxImageSummary(SandboxBaseModel):
306
306
  source: Optional[str] = None
307
307
  image_init: Optional[SandboxImageInit] = Field(default=None, alias="imageInit")
308
308
  uploaded: bool
309
+ ready: Optional[bool] = None
309
310
  created_at: datetime = Field(alias="createdAt")
310
311
  updated_at: datetime = Field(alias="updatedAt")
311
312
 
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "hyperbrowser"
3
- version = "1.7.0"
3
+ version = "1.8.0"
4
4
  description = "Python SDK for hyperbrowser"
5
5
  authors = ["Nikhil Shahi <nshahi1998@gmail.com>"]
6
6
  license = "MIT"
File without changes