wherobots-python-sdk 0.2.1__tar.gz → 0.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 (41) hide show
  1. {wherobots_python_sdk-0.2.1 → wherobots_python_sdk-0.3.0}/PKG-INFO +78 -3
  2. {wherobots_python_sdk-0.2.1 → wherobots_python_sdk-0.3.0}/README.md +76 -2
  3. {wherobots_python_sdk-0.2.1 → wherobots_python_sdk-0.3.0}/pyproject.toml +8 -0
  4. {wherobots_python_sdk-0.2.1 → wherobots_python_sdk-0.3.0}/tests/test_client.py +164 -8
  5. wherobots_python_sdk-0.3.0/tests/test_files_api.py +735 -0
  6. {wherobots_python_sdk-0.2.1 → wherobots_python_sdk-0.3.0}/tests/test_init_exports.py +8 -0
  7. {wherobots_python_sdk-0.2.1 → wherobots_python_sdk-0.3.0}/tests/test_models.py +111 -0
  8. {wherobots_python_sdk-0.2.1 → wherobots_python_sdk-0.3.0}/wherobots/__init__.py +4 -0
  9. {wherobots_python_sdk-0.2.1 → wherobots_python_sdk-0.3.0}/wherobots/__version__.py +1 -1
  10. wherobots_python_sdk-0.3.0/wherobots/api/files.py +579 -0
  11. {wherobots_python_sdk-0.2.1 → wherobots_python_sdk-0.3.0}/wherobots/client.py +88 -17
  12. {wherobots_python_sdk-0.2.1 → wherobots_python_sdk-0.3.0}/wherobots/models.py +132 -3
  13. {wherobots_python_sdk-0.2.1 → wherobots_python_sdk-0.3.0}/wherobots_python_sdk.egg-info/PKG-INFO +78 -3
  14. {wherobots_python_sdk-0.2.1 → wherobots_python_sdk-0.3.0}/wherobots_python_sdk.egg-info/requires.txt +1 -0
  15. wherobots_python_sdk-0.2.1/tests/test_files_api.py +0 -622
  16. wherobots_python_sdk-0.2.1/wherobots/api/files.py +0 -386
  17. {wherobots_python_sdk-0.2.1 → wherobots_python_sdk-0.3.0}/LICENSE +0 -0
  18. {wherobots_python_sdk-0.2.1 → wherobots_python_sdk-0.3.0}/setup.cfg +0 -0
  19. {wherobots_python_sdk-0.2.1 → wherobots_python_sdk-0.3.0}/tests/test_api.py +0 -0
  20. {wherobots_python_sdk-0.2.1 → wherobots_python_sdk-0.3.0}/tests/test_base_client.py +0 -0
  21. {wherobots_python_sdk-0.2.1 → wherobots_python_sdk-0.3.0}/tests/test_config.py +0 -0
  22. {wherobots_python_sdk-0.2.1 → wherobots_python_sdk-0.3.0}/tests/test_enums.py +0 -0
  23. {wherobots_python_sdk-0.2.1 → wherobots_python_sdk-0.3.0}/tests/test_exceptions.py +0 -0
  24. {wherobots_python_sdk-0.2.1 → wherobots_python_sdk-0.3.0}/tests/test_integration.py +0 -0
  25. {wherobots_python_sdk-0.2.1 → wherobots_python_sdk-0.3.0}/tests/test_logger.py +0 -0
  26. {wherobots_python_sdk-0.2.1 → wherobots_python_sdk-0.3.0}/tests/test_regressions.py +0 -0
  27. {wherobots_python_sdk-0.2.1 → wherobots_python_sdk-0.3.0}/tests/test_security.py +0 -0
  28. {wherobots_python_sdk-0.2.1 → wherobots_python_sdk-0.3.0}/tests/test_utils.py +0 -0
  29. {wherobots_python_sdk-0.2.1 → wherobots_python_sdk-0.3.0}/wherobots/api/__init__.py +0 -0
  30. {wherobots_python_sdk-0.2.1 → wherobots_python_sdk-0.3.0}/wherobots/api/base.py +0 -0
  31. {wherobots_python_sdk-0.2.1 → wherobots_python_sdk-0.3.0}/wherobots/api/runs.py +0 -0
  32. {wherobots_python_sdk-0.2.1 → wherobots_python_sdk-0.3.0}/wherobots/config.py +0 -0
  33. {wherobots_python_sdk-0.2.1 → wherobots_python_sdk-0.3.0}/wherobots/enums.py +0 -0
  34. {wherobots_python_sdk-0.2.1 → wherobots_python_sdk-0.3.0}/wherobots/exceptions.py +0 -0
  35. {wherobots_python_sdk-0.2.1 → wherobots_python_sdk-0.3.0}/wherobots/py.typed +0 -0
  36. {wherobots_python_sdk-0.2.1 → wherobots_python_sdk-0.3.0}/wherobots/utils/__init__.py +0 -0
  37. {wherobots_python_sdk-0.2.1 → wherobots_python_sdk-0.3.0}/wherobots/utils/logger.py +0 -0
  38. {wherobots_python_sdk-0.2.1 → wherobots_python_sdk-0.3.0}/wherobots/utils/validation.py +0 -0
  39. {wherobots_python_sdk-0.2.1 → wherobots_python_sdk-0.3.0}/wherobots_python_sdk.egg-info/SOURCES.txt +0 -0
  40. {wherobots_python_sdk-0.2.1 → wherobots_python_sdk-0.3.0}/wherobots_python_sdk.egg-info/dependency_links.txt +0 -0
  41. {wherobots_python_sdk-0.2.1 → wherobots_python_sdk-0.3.0}/wherobots_python_sdk.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: wherobots-python-sdk
3
- Version: 0.2.1
3
+ Version: 0.3.0
4
4
  Summary: Python SDK for Wherobots (currently covers the Jobs REST API)
5
5
  Author-email: Wherobots <support@wherobots.com>
6
6
  License-Expression: Apache-2.0
@@ -22,6 +22,7 @@ Requires-Python: >=3.10
22
22
  Description-Content-Type: text/markdown
23
23
  License-File: LICENSE
24
24
  Requires-Dist: requests>=2.33.0
25
+ Requires-Dist: boto3>=1.34.0
25
26
  Provides-Extra: dev
26
27
  Requires-Dist: pytest>=7.4.0; extra == "dev"
27
28
  Requires-Dist: pytest-cov>=4.1.0; extra == "dev"
@@ -187,7 +188,7 @@ WherobotsJob(
187
188
  spark_driver_disk_gb: int | None = None, # Driver disk size (GB)
188
189
  spark_executor_disk_gb: int | None = None, # Executor disk size (GB)
189
190
  jar_main_class: str | None = None, # Main class (required for JARs)
190
- auto_upload: bool = True, # Upload local files to S3
191
+ auto_upload: bool = True, # Upload local files to S3 (managed storage)
191
192
  base_url: str | None = None, # Override API URL
192
193
  request_timeout_seconds: int | None = None, # HTTP timeout
193
194
  config: WherobotsConfig | None = None, # Full config object
@@ -235,7 +236,7 @@ with WherobotsJob(script="s3://bucket/script.py", name="my-job") as job:
235
236
  | `from_run_id(run_id, api_key=None, ...)` | `WherobotsJob` | Attach to an existing run for read-only log/metric access. No script required. `submit()` is disabled on the returned instance. |
236
237
  | `list_runs(...)` | `RunListPage` | List runs with optional filters. No instance required. |
237
238
  | `add_pypi_dependency(name, version)` | `dict` | Create a PyPI dependency dict for the `dependencies` parameter. |
238
- | `add_file_dependency(file_path)` | `dict` | Create a file dependency dict (`.jar`, `.whl`, `.zip`, `.json`). |
239
+ | `add_file_dependency(file_path)` | `dict` | Create a file dependency dict (`.jar`, `.whl`, `.zip`, `.json`). Accepts an `s3://` URI or a local path — local paths are auto-uploaded to **managed storage** at `submit()` time. For non-managed destinations, upload via `FilesAPI` first and pass the resulting `s3://` URI. |
239
240
 
240
241
  #### Attaching to an Existing Run
241
242
 
@@ -307,6 +308,80 @@ job = WherobotsJob(
307
308
  )
308
309
  ```
309
310
 
311
+ ##### Local Dependency Files
312
+
313
+ `add_file_dependency` also accepts a **local path** to a `.whl`, `.jar`, `.zip`,
314
+ or `.json`. The SDK uploads the file just before the job is submitted, fetching
315
+ short-lived AWS STS credentials from the Wherobots API (`POST /storage/credentials`)
316
+ and using boto3 multipart under the hood — no AWS credentials need to leave your
317
+ environment.
318
+
319
+ ```python
320
+ job = WherobotsJob(
321
+ script="my_job.py", # local script auto-upload still works
322
+ name="job-with-local-wheel",
323
+ dependencies=[
324
+ # Local wheel — uploaded to managed storage at submit() time
325
+ WherobotsJob.add_file_dependency("dist/mypkg-0.1.0-py3-none-any.whl"),
326
+ # Already-uploaded artifact — used as-is
327
+ WherobotsJob.add_file_dependency("s3://bucket/shared-libs/lib-2.0.jar"),
328
+ WherobotsJob.add_pypi_dependency("pandas", "2.0.3"),
329
+ ],
330
+ )
331
+ job.submit()
332
+ ```
333
+
334
+ Auto-upload always targets the org's **managed storage**:
335
+
336
+ | Artifact | Destination |
337
+ |---|---|
338
+ | Script (when `script` is a local path) | `s3://<managed.path>/scripts/<filename>` |
339
+ | Dependency (when path is a local file) | `s3://<managed.path>/dependencies/<filename>` |
340
+
341
+ Local dependency uploads honor the same `auto_upload` flag as the script —
342
+ if `auto_upload=False`, attempting to submit a job with a local dependency
343
+ path raises `WherobotsValidationError`. The 5 GiB cap on a single upload
344
+ applies; for larger artifacts, pre-upload and pass an `s3://` URI.
345
+
346
+ ### Uploading to a Storage Integration
347
+
348
+ `WherobotsJob` stays focused on job orchestration — it doesn't take any
349
+ "where files go" kwargs. To upload to a [Storage Integration](https://docs.wherobots.com/latest/develop/storage-management/storage/)
350
+ or a custom subdirectory, use `FilesAPI` directly and pass the resulting
351
+ `s3://` URI to `WherobotsJob`:
352
+
353
+ ```python
354
+ from wherobots import FilesAPI, WherobotsJob
355
+ from wherobots.config import WherobotsConfig
356
+
357
+ with FilesAPI.from_config(WherobotsConfig.from_env()) as files:
358
+ si = files.resolve_integration("customer-bucket")
359
+ # Build explicit destinations; upload_file is the "upload this file
360
+ # to this exact s3 path" primitive.
361
+ script_uri = files.upload_file(
362
+ "my_job.py",
363
+ files.dest_uri_for(si, "my_job.py", "job-scripts/sdk-e2e"),
364
+ )
365
+ wheel_uri = files.upload_file(
366
+ "dist/internal-lib-1.0.whl",
367
+ files.dest_uri_for(si, "internal-lib-1.0.whl", "job-scripts/sdk-e2e"),
368
+ )
369
+
370
+ job = WherobotsJob(
371
+ script=script_uri, # already-uploaded URI
372
+ name="job-in-integration",
373
+ dependencies=[WherobotsJob.add_file_dependency(wheel_uri)],
374
+ )
375
+ job.submit()
376
+ ```
377
+
378
+ Both artifacts land under `s3://<integration.path>/job-scripts/sdk-e2e/`.
379
+
380
+ `FilesAPI` is also where to look for low-level controls (custom managed
381
+ subdirs, raw `s3://` destinations, STS credential fetch for BYO upload
382
+ tooling). See `FilesAPI.upload_file`, `upload_managed_file`,
383
+ `upload_dependency`, `dest_uri_for`, and `get_storage_credentials`.
384
+
310
385
  #### JAR Jobs
311
386
 
312
387
  ```python
@@ -154,7 +154,7 @@ WherobotsJob(
154
154
  spark_driver_disk_gb: int | None = None, # Driver disk size (GB)
155
155
  spark_executor_disk_gb: int | None = None, # Executor disk size (GB)
156
156
  jar_main_class: str | None = None, # Main class (required for JARs)
157
- auto_upload: bool = True, # Upload local files to S3
157
+ auto_upload: bool = True, # Upload local files to S3 (managed storage)
158
158
  base_url: str | None = None, # Override API URL
159
159
  request_timeout_seconds: int | None = None, # HTTP timeout
160
160
  config: WherobotsConfig | None = None, # Full config object
@@ -202,7 +202,7 @@ with WherobotsJob(script="s3://bucket/script.py", name="my-job") as job:
202
202
  | `from_run_id(run_id, api_key=None, ...)` | `WherobotsJob` | Attach to an existing run for read-only log/metric access. No script required. `submit()` is disabled on the returned instance. |
203
203
  | `list_runs(...)` | `RunListPage` | List runs with optional filters. No instance required. |
204
204
  | `add_pypi_dependency(name, version)` | `dict` | Create a PyPI dependency dict for the `dependencies` parameter. |
205
- | `add_file_dependency(file_path)` | `dict` | Create a file dependency dict (`.jar`, `.whl`, `.zip`, `.json`). |
205
+ | `add_file_dependency(file_path)` | `dict` | Create a file dependency dict (`.jar`, `.whl`, `.zip`, `.json`). Accepts an `s3://` URI or a local path — local paths are auto-uploaded to **managed storage** at `submit()` time. For non-managed destinations, upload via `FilesAPI` first and pass the resulting `s3://` URI. |
206
206
 
207
207
  #### Attaching to an Existing Run
208
208
 
@@ -274,6 +274,80 @@ job = WherobotsJob(
274
274
  )
275
275
  ```
276
276
 
277
+ ##### Local Dependency Files
278
+
279
+ `add_file_dependency` also accepts a **local path** to a `.whl`, `.jar`, `.zip`,
280
+ or `.json`. The SDK uploads the file just before the job is submitted, fetching
281
+ short-lived AWS STS credentials from the Wherobots API (`POST /storage/credentials`)
282
+ and using boto3 multipart under the hood — no AWS credentials need to leave your
283
+ environment.
284
+
285
+ ```python
286
+ job = WherobotsJob(
287
+ script="my_job.py", # local script auto-upload still works
288
+ name="job-with-local-wheel",
289
+ dependencies=[
290
+ # Local wheel — uploaded to managed storage at submit() time
291
+ WherobotsJob.add_file_dependency("dist/mypkg-0.1.0-py3-none-any.whl"),
292
+ # Already-uploaded artifact — used as-is
293
+ WherobotsJob.add_file_dependency("s3://bucket/shared-libs/lib-2.0.jar"),
294
+ WherobotsJob.add_pypi_dependency("pandas", "2.0.3"),
295
+ ],
296
+ )
297
+ job.submit()
298
+ ```
299
+
300
+ Auto-upload always targets the org's **managed storage**:
301
+
302
+ | Artifact | Destination |
303
+ |---|---|
304
+ | Script (when `script` is a local path) | `s3://<managed.path>/scripts/<filename>` |
305
+ | Dependency (when path is a local file) | `s3://<managed.path>/dependencies/<filename>` |
306
+
307
+ Local dependency uploads honor the same `auto_upload` flag as the script —
308
+ if `auto_upload=False`, attempting to submit a job with a local dependency
309
+ path raises `WherobotsValidationError`. The 5 GiB cap on a single upload
310
+ applies; for larger artifacts, pre-upload and pass an `s3://` URI.
311
+
312
+ ### Uploading to a Storage Integration
313
+
314
+ `WherobotsJob` stays focused on job orchestration — it doesn't take any
315
+ "where files go" kwargs. To upload to a [Storage Integration](https://docs.wherobots.com/latest/develop/storage-management/storage/)
316
+ or a custom subdirectory, use `FilesAPI` directly and pass the resulting
317
+ `s3://` URI to `WherobotsJob`:
318
+
319
+ ```python
320
+ from wherobots import FilesAPI, WherobotsJob
321
+ from wherobots.config import WherobotsConfig
322
+
323
+ with FilesAPI.from_config(WherobotsConfig.from_env()) as files:
324
+ si = files.resolve_integration("customer-bucket")
325
+ # Build explicit destinations; upload_file is the "upload this file
326
+ # to this exact s3 path" primitive.
327
+ script_uri = files.upload_file(
328
+ "my_job.py",
329
+ files.dest_uri_for(si, "my_job.py", "job-scripts/sdk-e2e"),
330
+ )
331
+ wheel_uri = files.upload_file(
332
+ "dist/internal-lib-1.0.whl",
333
+ files.dest_uri_for(si, "internal-lib-1.0.whl", "job-scripts/sdk-e2e"),
334
+ )
335
+
336
+ job = WherobotsJob(
337
+ script=script_uri, # already-uploaded URI
338
+ name="job-in-integration",
339
+ dependencies=[WherobotsJob.add_file_dependency(wheel_uri)],
340
+ )
341
+ job.submit()
342
+ ```
343
+
344
+ Both artifacts land under `s3://<integration.path>/job-scripts/sdk-e2e/`.
345
+
346
+ `FilesAPI` is also where to look for low-level controls (custom managed
347
+ subdirs, raw `s3://` destinations, STS credential fetch for BYO upload
348
+ tooling). See `FilesAPI.upload_file`, `upload_managed_file`,
349
+ `upload_dependency`, `dest_uri_for`, and `get_storage_credentials`.
350
+
277
351
  #### JAR Jobs
278
352
 
279
353
  ```python
@@ -26,6 +26,7 @@ classifiers = [
26
26
  keywords = ["wherobots", "spark", "sedona", "geospatial", "jobs", "sdk", "rest-api"]
27
27
  dependencies = [
28
28
  "requests>=2.33.0",
29
+ "boto3>=1.34.0",
29
30
  ]
30
31
 
31
32
  [project.urls]
@@ -85,6 +86,13 @@ show_error_codes = true
85
86
  module = "tests.*"
86
87
  ignore_errors = true
87
88
 
89
+ [[tool.mypy.overrides]]
90
+ # boto3 / botocore ship runtime types in some versions but the
91
+ # pre-commit mypy env doesn't install them. Declare the policy once
92
+ # here rather than scattering per-import ``# type: ignore`` comments.
93
+ module = ["boto3", "boto3.*", "botocore", "botocore.*"]
94
+ ignore_missing_imports = true
95
+
88
96
  [tool.pytest.ini_options]
89
97
  testpaths = ["tests"]
90
98
  python_files = ["test_*.py"]
@@ -329,7 +329,7 @@ class TestPresignedUpload:
329
329
  def test_presigned_happy_path(self, mock_from_config, mock_env):
330
330
  """Presigned upload succeeds via _prepare_script_uri()."""
331
331
  mock_files_api = MagicMock()
332
- mock_files_api.upload_file.return_value = (
332
+ mock_files_api.upload_managed_file.return_value = (
333
333
  "s3://managed-bucket/org/data/shared/scripts/my_script.py"
334
334
  )
335
335
  mock_from_config.return_value = mock_files_api
@@ -342,14 +342,14 @@ class TestPresignedUpload:
342
342
  uri = job._prepare_script_uri()
343
343
 
344
344
  assert uri == "s3://managed-bucket/org/data/shared/scripts/my_script.py"
345
- mock_files_api.upload_file.assert_called_once_with("my_script.py")
345
+ mock_files_api.upload_managed_file.assert_called_once_with("my_script.py", subdir="scripts")
346
346
  mock_files_api.close.assert_called_once()
347
347
 
348
348
  @patch("wherobots.api.files.FilesAPI.from_config")
349
349
  def test_presigned_success_caches_uri(self, mock_from_config, mock_env):
350
350
  """_prepare_script_uri() caches the result and doesn't re-upload."""
351
351
  mock_files_api = MagicMock()
352
- mock_files_api.upload_file.return_value = (
352
+ mock_files_api.upload_managed_file.return_value = (
353
353
  "s3://managed-bucket/org/data/shared/scripts/my_script.py"
354
354
  )
355
355
  mock_from_config.return_value = mock_files_api
@@ -363,14 +363,16 @@ class TestPresignedUpload:
363
363
  uri2 = job._prepare_script_uri()
364
364
 
365
365
  assert uri1 == uri2
366
- # upload_file should only be called once (cached on second call)
367
- mock_files_api.upload_file.assert_called_once()
366
+ # upload_managed_file should only be called once (cached on second call)
367
+ mock_files_api.upload_managed_file.assert_called_once()
368
368
 
369
369
  @patch("wherobots.api.files.FilesAPI.from_config")
370
370
  def test_presigned_fails_raises_directly(self, mock_from_config, mock_env):
371
371
  """Presigned upload fails → WherobotsAPIError raised with no fallback."""
372
372
  mock_files_api = MagicMock()
373
- mock_files_api.upload_file.side_effect = WherobotsAPIError("presigned 500", status_code=500)
373
+ mock_files_api.upload_managed_file.side_effect = WherobotsAPIError(
374
+ "presigned 500", status_code=500
375
+ )
374
376
  mock_from_config.return_value = mock_files_api
375
377
 
376
378
  job = WherobotsJob(
@@ -387,7 +389,7 @@ class TestPresignedUpload:
387
389
  def test_presigned_closes_files_api_on_success(self, mock_from_config, mock_env):
388
390
  """FilesAPI.close() is called even when upload succeeds."""
389
391
  mock_files_api = MagicMock()
390
- mock_files_api.upload_file.return_value = "s3://bucket/key"
392
+ mock_files_api.upload_managed_file.return_value = "s3://bucket/key"
391
393
  mock_from_config.return_value = mock_files_api
392
394
 
393
395
  job = WherobotsJob(
@@ -403,7 +405,7 @@ class TestPresignedUpload:
403
405
  def test_presigned_closes_files_api_on_failure(self, mock_from_config, mock_env):
404
406
  """FilesAPI.close() is called even when upload fails."""
405
407
  mock_files_api = MagicMock()
406
- mock_files_api.upload_file.side_effect = WherobotsAPIError("boom")
408
+ mock_files_api.upload_managed_file.side_effect = WherobotsAPIError("boom")
407
409
  mock_from_config.return_value = mock_files_api
408
410
 
409
411
  job = WherobotsJob(
@@ -665,3 +667,157 @@ class TestPollForLogsDrain:
665
667
  job.poll_for_logs(follow=False, log_handler=captured.append)
666
668
 
667
669
  assert job._api.get_logs.call_count == 1
670
+
671
+
672
+ # ── add_file_dependency local-path / lazy upload ─────────────────────────
673
+
674
+
675
+ class TestAddFileDependency:
676
+ def test_s3_uri_unchanged(self):
677
+ dep = WherobotsJob.add_file_dependency("s3://bucket/lib.whl")
678
+ assert dep == {"sourceType": "FILE", "filePath": "s3://bucket/lib.whl"}
679
+ assert "_pendingUpload" not in dep
680
+
681
+ def test_local_path_marks_pending(self):
682
+ dep = WherobotsJob.add_file_dependency("dist/lib.whl")
683
+ assert dep["filePath"] == "dist/lib.whl"
684
+ assert dep["_pendingUpload"] is True
685
+ # Storage-integration routing belongs to FilesAPI now — sentinel
686
+ # keys for SI / dest_subdir are not present.
687
+ assert "_storageIntegration" not in dep
688
+ assert "_destSubdir" not in dep
689
+
690
+ def test_bad_extension_raises(self):
691
+ with pytest.raises(WherobotsValidationError, match="extensions"):
692
+ WherobotsJob.add_file_dependency("dist/lib.tar.gz")
693
+
694
+
695
+ class TestPrepareDependencies:
696
+ @patch("wherobots.api.files.FilesAPI.from_config")
697
+ def test_local_dep_is_uploaded_and_sentinels_stripped(self, mock_from_config, mock_env):
698
+ mock_files = MagicMock()
699
+ mock_files.upload_dependency.return_value = "s3://managed/org/dependencies/lib.whl"
700
+ mock_from_config.return_value = mock_files
701
+
702
+ job = WherobotsJob(
703
+ script="s3://bucket/main.py",
704
+ name="local-dep-job",
705
+ runtime="tiny",
706
+ dependencies=[
707
+ WherobotsJob.add_file_dependency("dist/lib.whl"),
708
+ WherobotsJob.add_pypi_dependency("pandas", "2.0.3"),
709
+ ],
710
+ )
711
+ job._prepare_dependencies()
712
+
713
+ # First dep resolved + sentinel keys gone.
714
+ local_dep = job.dependencies[0]
715
+ assert local_dep["filePath"] == "s3://managed/org/dependencies/lib.whl"
716
+ assert "_pendingUpload" not in local_dep
717
+ assert "_storageIntegration" not in local_dep
718
+ # PyPI dep untouched.
719
+ assert job.dependencies[1] == {
720
+ "sourceType": "PYPI",
721
+ "libraryName": "pandas",
722
+ "libraryVersion": "2.0.3",
723
+ }
724
+ mock_files.upload_dependency.assert_called_once_with("dist/lib.whl")
725
+ mock_files.close.assert_called_once()
726
+
727
+ def test_no_pending_deps_skips_files_api(self, mock_env):
728
+ """If everything is already s3://, no FilesAPI session is created."""
729
+ with patch("wherobots.api.files.FilesAPI.from_config") as mock_from_config:
730
+ job = WherobotsJob(
731
+ script="s3://bucket/main.py",
732
+ name="all-s3-deps",
733
+ runtime="tiny",
734
+ dependencies=[
735
+ WherobotsJob.add_file_dependency("s3://b/a.whl"),
736
+ WherobotsJob.add_pypi_dependency("x", "1"),
737
+ ],
738
+ )
739
+ job._prepare_dependencies()
740
+ mock_from_config.assert_not_called()
741
+
742
+ @patch("wherobots.api.files.FilesAPI.from_config")
743
+ def test_partial_failure_leaves_dependencies_untouched(self, mock_from_config, mock_env):
744
+ """If upload_dependency raises mid-list, ``self.dependencies``
745
+ stays in its original sentinel-tagged form so a retry runs the
746
+ same work cleanly. No partially-resolved state ships."""
747
+ mock_files = MagicMock()
748
+ mock_files.upload_dependency.side_effect = [
749
+ "s3://managed/dependencies/first.whl",
750
+ WherobotsAPIError("upload boom"),
751
+ ]
752
+ mock_from_config.return_value = mock_files
753
+
754
+ job = WherobotsJob(
755
+ script="s3://bucket/main.py",
756
+ name="partial-failure-job",
757
+ runtime="tiny",
758
+ dependencies=[
759
+ WherobotsJob.add_file_dependency("dist/first.whl"),
760
+ WherobotsJob.add_file_dependency("dist/second.whl"),
761
+ ],
762
+ )
763
+ before = [dict(d) for d in job.dependencies]
764
+
765
+ with pytest.raises(WherobotsAPIError, match="upload boom"):
766
+ job._prepare_dependencies()
767
+
768
+ # Both sentinel-tagged dicts still untouched on the instance.
769
+ assert job.dependencies == before
770
+ assert all(d.get("_pendingUpload") is True for d in job.dependencies)
771
+
772
+ def test_auto_upload_false_raises_on_local_dep(self, mock_env):
773
+ job = WherobotsJob(
774
+ script="s3://bucket/main.py",
775
+ name="no-auto-upload",
776
+ runtime="tiny",
777
+ auto_upload=False,
778
+ dependencies=[WherobotsJob.add_file_dependency("dist/lib.whl")],
779
+ )
780
+ with pytest.raises(WherobotsValidationError, match="auto_upload"):
781
+ job._prepare_dependencies()
782
+
783
+ @patch("wherobots.api.files.FilesAPI.from_config")
784
+ def test_build_payload_runs_dependency_prep(self, mock_from_config, mock_env):
785
+ """_build_payload must call _prepare_dependencies before serializing."""
786
+ mock_files = MagicMock()
787
+ mock_files.upload_dependency.return_value = "s3://managed/dependencies/lib.whl"
788
+ mock_from_config.return_value = mock_files
789
+
790
+ job = WherobotsJob(
791
+ script="s3://bucket/main.py",
792
+ name="build-payload-job",
793
+ runtime="tiny",
794
+ dependencies=[WherobotsJob.add_file_dependency("dist/lib.whl")],
795
+ )
796
+ payload = job._build_payload()
797
+
798
+ # The CreateRunPayload must serialize cleanly — no sentinel keys leak.
799
+ body = payload.to_dict()
800
+ env = body["environment"]
801
+ assert env["dependencies"] == [
802
+ {"sourceType": "FILE", "filePath": "s3://managed/dependencies/lib.whl"}
803
+ ]
804
+
805
+
806
+ # ── Script auto-upload stays scoped to managed storage ────────────────
807
+
808
+
809
+ class TestScriptAutoUpload:
810
+ @patch("wherobots.api.files.FilesAPI.from_config")
811
+ def test_script_auto_upload_routes_to_managed(self, mock_from_config, mock_env):
812
+ """``WherobotsJob`` auto-upload always targets managed storage.
813
+ Integration uploads are an explicit ``FilesAPI`` step."""
814
+ mock_files = MagicMock()
815
+ mock_files.upload_managed_file.return_value = "s3://managed/scripts/job.py"
816
+ mock_from_config.return_value = mock_files
817
+
818
+ job = WherobotsJob(script="job.py", name="default-script", runtime="tiny")
819
+ uri = job._prepare_script_uri()
820
+
821
+ assert uri == "s3://managed/scripts/job.py"
822
+ mock_files.upload_managed_file.assert_called_once_with("job.py", subdir="scripts")
823
+ mock_files.upload_dependency.assert_not_called()