wherobots-python-sdk 0.5.0__tar.gz → 0.6.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 (47) hide show
  1. {wherobots_python_sdk-0.5.0/wherobots_python_sdk.egg-info → wherobots_python_sdk-0.6.0}/PKG-INFO +55 -6
  2. wherobots_python_sdk-0.5.0/PKG-INFO → wherobots_python_sdk-0.6.0/README.md +51 -28
  3. {wherobots_python_sdk-0.5.0 → wherobots_python_sdk-0.6.0}/pyproject.toml +19 -18
  4. {wherobots_python_sdk-0.5.0 → wherobots_python_sdk-0.6.0}/tests/test_files_api.py +2 -2
  5. {wherobots_python_sdk-0.5.0 → wherobots_python_sdk-0.6.0}/tests/test_models.py +4 -4
  6. wherobots_python_sdk-0.6.0/tests/test_tasks.py +296 -0
  7. {wherobots_python_sdk-0.5.0 → wherobots_python_sdk-0.6.0}/wherobots/__init__.py +23 -0
  8. {wherobots_python_sdk-0.5.0 → wherobots_python_sdk-0.6.0}/wherobots/__version__.py +1 -1
  9. {wherobots_python_sdk-0.5.0 → wherobots_python_sdk-0.6.0}/wherobots/api/__init__.py +2 -1
  10. {wherobots_python_sdk-0.5.0 → wherobots_python_sdk-0.6.0}/wherobots/api/base.py +15 -0
  11. {wherobots_python_sdk-0.5.0 → wherobots_python_sdk-0.6.0}/wherobots/api/drive.py +3 -17
  12. wherobots_python_sdk-0.6.0/wherobots/api/tasks.py +265 -0
  13. {wherobots_python_sdk-0.5.0 → wherobots_python_sdk-0.6.0}/wherobots/enums.py +33 -0
  14. {wherobots_python_sdk-0.5.0 → wherobots_python_sdk-0.6.0}/wherobots/models.py +347 -10
  15. {wherobots_python_sdk-0.5.0 → wherobots_python_sdk-0.6.0}/wherobots/sdk.py +18 -0
  16. wherobots_python_sdk-0.5.0/README.md → wherobots_python_sdk-0.6.0/wherobots_python_sdk.egg-info/PKG-INFO +77 -1
  17. {wherobots_python_sdk-0.5.0 → wherobots_python_sdk-0.6.0}/wherobots_python_sdk.egg-info/SOURCES.txt +2 -0
  18. wherobots_python_sdk-0.6.0/wherobots_python_sdk.egg-info/requires.txt +2 -0
  19. wherobots_python_sdk-0.5.0/wherobots_python_sdk.egg-info/requires.txt +0 -2
  20. {wherobots_python_sdk-0.5.0 → wherobots_python_sdk-0.6.0}/LICENSE +0 -0
  21. {wherobots_python_sdk-0.5.0 → wherobots_python_sdk-0.6.0}/setup.cfg +0 -0
  22. {wherobots_python_sdk-0.5.0 → wherobots_python_sdk-0.6.0}/tests/test_api.py +0 -0
  23. {wherobots_python_sdk-0.5.0 → wherobots_python_sdk-0.6.0}/tests/test_base_client.py +0 -0
  24. {wherobots_python_sdk-0.5.0 → wherobots_python_sdk-0.6.0}/tests/test_client.py +0 -0
  25. {wherobots_python_sdk-0.5.0 → wherobots_python_sdk-0.6.0}/tests/test_client_header.py +0 -0
  26. {wherobots_python_sdk-0.5.0 → wherobots_python_sdk-0.6.0}/tests/test_config.py +0 -0
  27. {wherobots_python_sdk-0.5.0 → wherobots_python_sdk-0.6.0}/tests/test_enums.py +0 -0
  28. {wherobots_python_sdk-0.5.0 → wherobots_python_sdk-0.6.0}/tests/test_exceptions.py +0 -0
  29. {wherobots_python_sdk-0.5.0 → wherobots_python_sdk-0.6.0}/tests/test_files_drive.py +0 -0
  30. {wherobots_python_sdk-0.5.0 → wherobots_python_sdk-0.6.0}/tests/test_init_exports.py +0 -0
  31. {wherobots_python_sdk-0.5.0 → wherobots_python_sdk-0.6.0}/tests/test_integration.py +0 -0
  32. {wherobots_python_sdk-0.5.0 → wherobots_python_sdk-0.6.0}/tests/test_logger.py +0 -0
  33. {wherobots_python_sdk-0.5.0 → wherobots_python_sdk-0.6.0}/tests/test_regressions.py +0 -0
  34. {wherobots_python_sdk-0.5.0 → wherobots_python_sdk-0.6.0}/tests/test_security.py +0 -0
  35. {wherobots_python_sdk-0.5.0 → wherobots_python_sdk-0.6.0}/tests/test_utils.py +0 -0
  36. {wherobots_python_sdk-0.5.0 → wherobots_python_sdk-0.6.0}/wherobots/api/files.py +0 -0
  37. {wherobots_python_sdk-0.5.0 → wherobots_python_sdk-0.6.0}/wherobots/api/runs.py +0 -0
  38. {wherobots_python_sdk-0.5.0 → wherobots_python_sdk-0.6.0}/wherobots/client.py +0 -0
  39. {wherobots_python_sdk-0.5.0 → wherobots_python_sdk-0.6.0}/wherobots/config.py +0 -0
  40. {wherobots_python_sdk-0.5.0 → wherobots_python_sdk-0.6.0}/wherobots/exceptions.py +0 -0
  41. {wherobots_python_sdk-0.5.0 → wherobots_python_sdk-0.6.0}/wherobots/py.typed +0 -0
  42. {wherobots_python_sdk-0.5.0 → wherobots_python_sdk-0.6.0}/wherobots/utils/__init__.py +0 -0
  43. {wherobots_python_sdk-0.5.0 → wherobots_python_sdk-0.6.0}/wherobots/utils/client_header.py +0 -0
  44. {wherobots_python_sdk-0.5.0 → wherobots_python_sdk-0.6.0}/wherobots/utils/logger.py +0 -0
  45. {wherobots_python_sdk-0.5.0 → wherobots_python_sdk-0.6.0}/wherobots/utils/validation.py +0 -0
  46. {wherobots_python_sdk-0.5.0 → wherobots_python_sdk-0.6.0}/wherobots_python_sdk.egg-info/dependency_links.txt +0 -0
  47. {wherobots_python_sdk-0.5.0 → wherobots_python_sdk-0.6.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.5.0
3
+ Version: 0.6.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
@@ -13,22 +13,21 @@ Classifier: Development Status :: 4 - Beta
13
13
  Classifier: Intended Audience :: Developers
14
14
  Classifier: Operating System :: OS Independent
15
15
  Classifier: Programming Language :: Python :: 3
16
- Classifier: Programming Language :: Python :: 3.10
17
16
  Classifier: Programming Language :: Python :: 3.11
18
17
  Classifier: Programming Language :: Python :: 3.12
19
18
  Classifier: Programming Language :: Python :: 3.13
20
19
  Classifier: Topic :: Software Development :: Libraries :: Python Modules
21
- Requires-Python: >=3.10
20
+ Requires-Python: >=3.11
22
21
  Description-Content-Type: text/markdown
23
22
  License-File: LICENSE
24
- Requires-Dist: requests>=2.33.0
25
- Requires-Dist: boto3>=1.34.0
23
+ Requires-Dist: requests>=2.34.2
24
+ Requires-Dist: boto3>=1.43.108
26
25
  Dynamic: license-file
27
26
 
28
27
  # Wherobots Python SDK
29
28
 
30
29
  [![CI](https://github.com/wherobots/wherobots-python-sdk/actions/workflows/ci.yml/badge.svg)](https://github.com/wherobots/wherobots-python-sdk/actions/workflows/ci.yml)
31
- [![Python](https://img.shields.io/badge/python-3.10%2B-blue)](https://www.python.org/downloads/)
30
+ [![Python](https://img.shields.io/badge/python-3.11%2B-blue)](https://www.python.org/downloads/)
32
31
  [![License](https://img.shields.io/badge/license-Apache--2.0-blue)](LICENSE)
33
32
  [![Version](https://img.shields.io/pypi/v/wherobots-python-sdk?color=orange)](https://pypi.org/project/wherobots-python-sdk/)
34
33
 
@@ -46,6 +45,7 @@ Additional Wherobots surfaces will be added over time.
46
45
  - Typed dataclass models for all API responses (no pydantic dependency)
47
46
  - Environment-based configuration
48
47
  - Files: list, create folders, upload, download, rename and delete in your Wherobots file area
48
+ - Tasks: list and run tasks such as RasterFlow inference, then follow their runs, logs and cost
49
49
 
50
50
  ## Installation
51
51
 
@@ -462,6 +462,47 @@ with wherobots.Wherobots() as wb:
462
462
 
463
463
  The three `Wherobots*Error` classes above subclass `WherobotsAPIError`.
464
464
 
465
+ ## Tasks
466
+
467
+ A task is a named unit of work, such as RasterFlow inference, that your organization can run.
468
+ `wb.tasks` lists and runs tasks; `wb.task_runs` follows the runs. Tasks need the Runs feature on
469
+ your organization.
470
+
471
+ ```python
472
+ import time
473
+
474
+ import wherobots
475
+
476
+ with wherobots.Wherobots() as wb:
477
+ for task in wb.tasks.list().items:
478
+ print(task.name, task.display_name, task.regions)
479
+
480
+ task = wb.tasks.get("my-task")
481
+ print(task.input_schema) # JSON Schema the inputs must satisfy
482
+
483
+ started = wb.tasks.run("my-task", {"aoi": "s3://bucket/aoi.geojson"}, name="nightly")
484
+ run = wb.task_runs.get(started.task_run_id)
485
+ while run.is_active:
486
+ time.sleep(10)
487
+ run = wb.task_runs.get(run.task_run_id)
488
+
489
+ print(run.state, run.results, run.error_message)
490
+ print(wb.task_runs.logs(run.task_run_id).entries)
491
+ print(wb.task_runs.cost(run.task_run_id).total_cost_usd)
492
+ ```
493
+
494
+ - `tasks.run` uses `region=` if passed, then the configured region, then your organization's
495
+ default region.
496
+ - Listings return an `OffsetPage`; pass `page.next_offset` as `offset=` for the next one.
497
+ - `TaskRunState.is_active` is `True` for `PENDING`, `SUBMITTED`, `RUNNING` and `CANCELLING`. A
498
+ state the SDK does not know yet is kept as its raw string and counts as not active, so a
499
+ polling loop stops on it.
500
+ - A run's state can lag the run by up to 2 minutes.
501
+ - `task_runs.logs` pages forward: pass the previous page's `next_since` as `since=`. It is `None`
502
+ once the run has finished and every line has been returned.
503
+ - `task_runs.cost` can take up to 45 minutes after a run ends to settle; `is_settling` stays
504
+ `True` until then.
505
+
465
506
  ## Enums
466
507
 
467
508
  ### JobStatus
@@ -689,6 +730,14 @@ To set up a Storage Integration, follow the
689
730
  | GET | `/runs/{id}/logs` | Get run logs |
690
731
  | GET | `/runs/{id}/metrics` | Get run metrics |
691
732
  | GET | `/runs` | List runs |
733
+ | GET | `/tasks` | List tasks |
734
+ | GET | `/tasks/{name}` | Get a task |
735
+ | POST | `/tasks/{name}/runs` | Run a task |
736
+ | GET | `/task-runs` | List task runs |
737
+ | GET | `/task-runs/{id}` | Get a task run |
738
+ | GET | `/task-runs/{id}/logs` | Get task run logs |
739
+ | GET | `/task-runs/{id}/cost` | Get task run cost |
740
+ | POST | `/task-runs/{id}/cancel` | Cancel a task run |
692
741
 
693
742
  Base URL: `https://api.cloud.wherobots.com`
694
743
 
@@ -1,34 +1,7 @@
1
- Metadata-Version: 2.4
2
- Name: wherobots-python-sdk
3
- Version: 0.5.0
4
- Summary: Python SDK for Wherobots (currently covers the Jobs REST API)
5
- Author-email: Wherobots <support@wherobots.com>
6
- License-Expression: Apache-2.0
7
- Project-URL: Homepage, https://www.wherobots.com
8
- Project-URL: Repository, https://github.com/wherobots/wherobots-python-sdk
9
- Project-URL: Issues, https://github.com/wherobots/wherobots-python-sdk/issues
10
- Project-URL: Documentation, https://docs.wherobots.com
11
- Keywords: wherobots,spark,sedona,geospatial,jobs,sdk,rest-api
12
- Classifier: Development Status :: 4 - Beta
13
- Classifier: Intended Audience :: Developers
14
- Classifier: Operating System :: OS Independent
15
- Classifier: Programming Language :: Python :: 3
16
- Classifier: Programming Language :: Python :: 3.10
17
- Classifier: Programming Language :: Python :: 3.11
18
- Classifier: Programming Language :: Python :: 3.12
19
- Classifier: Programming Language :: Python :: 3.13
20
- Classifier: Topic :: Software Development :: Libraries :: Python Modules
21
- Requires-Python: >=3.10
22
- Description-Content-Type: text/markdown
23
- License-File: LICENSE
24
- Requires-Dist: requests>=2.33.0
25
- Requires-Dist: boto3>=1.34.0
26
- Dynamic: license-file
27
-
28
1
  # Wherobots Python SDK
29
2
 
30
3
  [![CI](https://github.com/wherobots/wherobots-python-sdk/actions/workflows/ci.yml/badge.svg)](https://github.com/wherobots/wherobots-python-sdk/actions/workflows/ci.yml)
31
- [![Python](https://img.shields.io/badge/python-3.10%2B-blue)](https://www.python.org/downloads/)
4
+ [![Python](https://img.shields.io/badge/python-3.11%2B-blue)](https://www.python.org/downloads/)
32
5
  [![License](https://img.shields.io/badge/license-Apache--2.0-blue)](LICENSE)
33
6
  [![Version](https://img.shields.io/pypi/v/wherobots-python-sdk?color=orange)](https://pypi.org/project/wherobots-python-sdk/)
34
7
 
@@ -46,6 +19,7 @@ Additional Wherobots surfaces will be added over time.
46
19
  - Typed dataclass models for all API responses (no pydantic dependency)
47
20
  - Environment-based configuration
48
21
  - Files: list, create folders, upload, download, rename and delete in your Wherobots file area
22
+ - Tasks: list and run tasks such as RasterFlow inference, then follow their runs, logs and cost
49
23
 
50
24
  ## Installation
51
25
 
@@ -462,6 +436,47 @@ with wherobots.Wherobots() as wb:
462
436
 
463
437
  The three `Wherobots*Error` classes above subclass `WherobotsAPIError`.
464
438
 
439
+ ## Tasks
440
+
441
+ A task is a named unit of work, such as RasterFlow inference, that your organization can run.
442
+ `wb.tasks` lists and runs tasks; `wb.task_runs` follows the runs. Tasks need the Runs feature on
443
+ your organization.
444
+
445
+ ```python
446
+ import time
447
+
448
+ import wherobots
449
+
450
+ with wherobots.Wherobots() as wb:
451
+ for task in wb.tasks.list().items:
452
+ print(task.name, task.display_name, task.regions)
453
+
454
+ task = wb.tasks.get("my-task")
455
+ print(task.input_schema) # JSON Schema the inputs must satisfy
456
+
457
+ started = wb.tasks.run("my-task", {"aoi": "s3://bucket/aoi.geojson"}, name="nightly")
458
+ run = wb.task_runs.get(started.task_run_id)
459
+ while run.is_active:
460
+ time.sleep(10)
461
+ run = wb.task_runs.get(run.task_run_id)
462
+
463
+ print(run.state, run.results, run.error_message)
464
+ print(wb.task_runs.logs(run.task_run_id).entries)
465
+ print(wb.task_runs.cost(run.task_run_id).total_cost_usd)
466
+ ```
467
+
468
+ - `tasks.run` uses `region=` if passed, then the configured region, then your organization's
469
+ default region.
470
+ - Listings return an `OffsetPage`; pass `page.next_offset` as `offset=` for the next one.
471
+ - `TaskRunState.is_active` is `True` for `PENDING`, `SUBMITTED`, `RUNNING` and `CANCELLING`. A
472
+ state the SDK does not know yet is kept as its raw string and counts as not active, so a
473
+ polling loop stops on it.
474
+ - A run's state can lag the run by up to 2 minutes.
475
+ - `task_runs.logs` pages forward: pass the previous page's `next_since` as `since=`. It is `None`
476
+ once the run has finished and every line has been returned.
477
+ - `task_runs.cost` can take up to 45 minutes after a run ends to settle; `is_settling` stays
478
+ `True` until then.
479
+
465
480
  ## Enums
466
481
 
467
482
  ### JobStatus
@@ -689,6 +704,14 @@ To set up a Storage Integration, follow the
689
704
  | GET | `/runs/{id}/logs` | Get run logs |
690
705
  | GET | `/runs/{id}/metrics` | Get run metrics |
691
706
  | GET | `/runs` | List runs |
707
+ | GET | `/tasks` | List tasks |
708
+ | GET | `/tasks/{name}` | Get a task |
709
+ | POST | `/tasks/{name}/runs` | Run a task |
710
+ | GET | `/task-runs` | List task runs |
711
+ | GET | `/task-runs/{id}` | Get a task run |
712
+ | GET | `/task-runs/{id}/logs` | Get task run logs |
713
+ | GET | `/task-runs/{id}/cost` | Get task run cost |
714
+ | POST | `/task-runs/{id}/cancel` | Cancel a task run |
692
715
 
693
716
  Base URL: `https://api.cloud.wherobots.com`
694
717
 
@@ -1,5 +1,5 @@
1
1
  [build-system]
2
- requires = ["setuptools>=83.0.0", "wheel"]
2
+ requires = ["setuptools>=84.2.1", "wheel"]
3
3
  build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
@@ -7,7 +7,7 @@ name = "wherobots-python-sdk"
7
7
  dynamic = ["version"]
8
8
  description = "Python SDK for Wherobots (currently covers the Jobs REST API)"
9
9
  readme = "README.md"
10
- requires-python = ">=3.10"
10
+ requires-python = ">=3.11"
11
11
  license = "Apache-2.0"
12
12
  authors = [
13
13
  {name = "Wherobots", email = "support@wherobots.com"}
@@ -17,7 +17,6 @@ classifiers = [
17
17
  "Intended Audience :: Developers",
18
18
  "Operating System :: OS Independent",
19
19
  "Programming Language :: Python :: 3",
20
- "Programming Language :: Python :: 3.10",
21
20
  "Programming Language :: Python :: 3.11",
22
21
  "Programming Language :: Python :: 3.12",
23
22
  "Programming Language :: Python :: 3.13",
@@ -25,8 +24,8 @@ classifiers = [
25
24
  ]
26
25
  keywords = ["wherobots", "spark", "sedona", "geospatial", "jobs", "sdk", "rest-api"]
27
26
  dependencies = [
28
- "requests>=2.33.0",
29
- "boto3>=1.34.0",
27
+ "requests>=2.34.2",
28
+ "boto3>=1.43.108",
30
29
  ]
31
30
 
32
31
  [project.urls]
@@ -38,17 +37,17 @@ Documentation = "https://docs.wherobots.com"
38
37
  # PEP 735 dev dependencies — installed with `uv sync --group dev`.
39
38
  [dependency-groups]
40
39
  dev = [
41
- "pytest>=8.0",
42
- "pytest-cov>=4.1.0",
43
- "pytest-mock>=3.11.1",
44
- "ruff>=0.15,<0.17",
45
- "pyrefly>=0.16",
46
- "import-linter>=2.1",
47
- "pre-commit>=3.8",
48
- "hypothesis>=6.100",
49
- "cosmic-ray>=8.4",
50
- "ast-grep-cli>=0.39",
51
- "types-requests>=2.31",
40
+ "pytest==9.1.1",
41
+ "pytest-cov==7.1.0",
42
+ "pytest-mock==3.16.0",
43
+ "ruff==0.17.0",
44
+ "pyrefly==1.3.2",
45
+ "import-linter==2.15",
46
+ "pre-commit==4.7.0",
47
+ "hypothesis==6.170.2",
48
+ "cosmic-ray==8.7.0",
49
+ "ast-grep-cli==0.50.0",
50
+ "types-requests==2.33.0.20261006",
52
51
  ]
53
52
 
54
53
  [tool.setuptools.packages.find]
@@ -60,12 +59,14 @@ wherobots = ["py.typed"]
60
59
  [tool.setuptools.dynamic]
61
60
  version = {attr = "wherobots.__version__.__version__"}
62
61
 
63
- # SLOP REPELLANT ruff rail: line-length 100, py310 target, select = ["ALL"]
64
- # plus the typing.Any/typing.cast ban all come from the base config.
62
+ # SLOP REPELLANT ruff rail: line-length 100, select = ["ALL"] and the
63
+ # typing.Any/typing.cast ban come from the base config. The base targets
64
+ # py310 (the kit's floor); this project overrides it to its own py311 floor.
65
65
  # NOTE: `lint.select`/`lint.ignore` would REPLACE the base's — tune only via
66
66
  # the extend-* keys.
67
67
  [tool.ruff]
68
68
  extend = ".slop-repellant/ruff-base.toml"
69
+ target-version = "py311"
69
70
  # .slop-repellant/ is vendored, kit-MANAGED tooling (re-rendered on
70
71
  # slop-repellant-update) — linting it locally would fork the managed files.
71
72
  extend-exclude = ["env", ".venv", "venv", "build", "dist", ".slop-repellant"]
@@ -3,7 +3,7 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import logging
6
- from datetime import datetime, timedelta, timezone
6
+ from datetime import UTC, datetime, timedelta
7
7
  from unittest.mock import MagicMock, patch
8
8
 
9
9
  import pytest
@@ -45,7 +45,7 @@ def files_api(mock_env):
45
45
  @pytest.fixture
46
46
  def creds_payload():
47
47
  """Sample TemporaryAwsCredentials JSON, well in the future."""
48
- expiry = (datetime.now(timezone.utc) + timedelta(hours=1)).isoformat()
48
+ expiry = (datetime.now(UTC) + timedelta(hours=1)).isoformat()
49
49
  return {
50
50
  "accessKeyId": "ASIAEXAMPLE0001",
51
51
  "secretAccessKey": "supersecretvaluedoNotleak",
@@ -987,7 +987,7 @@ class TestStorageIntegrationTypes:
987
987
  # ---------------------------------------------------------------------------
988
988
 
989
989
 
990
- from datetime import datetime, timedelta, timezone # noqa: E402
990
+ from datetime import UTC, datetime, timedelta # noqa: E402
991
991
 
992
992
 
993
993
  class TestTemporaryAwsCredentials:
@@ -1006,7 +1006,7 @@ class TestTemporaryAwsCredentials:
1006
1006
  assert creds.expiration.year == 2026
1007
1007
 
1008
1008
  def test_is_expired_true_for_past(self):
1009
- past = datetime.now(timezone.utc) - timedelta(seconds=1)
1009
+ past = datetime.now(UTC) - timedelta(seconds=1)
1010
1010
  creds = TemporaryAwsCredentials(
1011
1011
  access_key_id="ASIA1234",
1012
1012
  secret_access_key="s",
@@ -1018,7 +1018,7 @@ class TestTemporaryAwsCredentials:
1018
1018
 
1019
1019
  def test_is_expired_respects_leeway(self):
1020
1020
  # 30s in the future, leeway 60s ⇒ considered expired.
1021
- soon = datetime.now(timezone.utc) + timedelta(seconds=30)
1021
+ soon = datetime.now(UTC) + timedelta(seconds=30)
1022
1022
  creds = TemporaryAwsCredentials(
1023
1023
  access_key_id="ASIA1234",
1024
1024
  secret_access_key="s",
@@ -1034,7 +1034,7 @@ class TestTemporaryAwsCredentials:
1034
1034
  access_key_id="ASIAEXAMPLE1234",
1035
1035
  secret_access_key="ULTRASECRETzzzzzzzz",
1036
1036
  session_token="TOKEN_payload_here",
1037
- expiration=datetime(2026, 1, 1, tzinfo=timezone.utc),
1037
+ expiration=datetime(2026, 1, 1, tzinfo=UTC),
1038
1038
  region="us-west-2",
1039
1039
  )
1040
1040
  rendered = repr(creds)
@@ -0,0 +1,296 @@
1
+ """Tests for the Tasks surface: Wherobots -> Tasks / TaskRuns."""
2
+
3
+ from datetime import UTC, datetime
4
+ from decimal import Decimal
5
+ from unittest.mock import MagicMock, patch
6
+
7
+ import pytest
8
+ import requests
9
+ from hypothesis import given
10
+ from hypothesis import strategies as st
11
+
12
+ from wherobots import (
13
+ OffsetPage,
14
+ Region,
15
+ Task,
16
+ TaskRun,
17
+ TaskRunState,
18
+ TaskRunSummary,
19
+ Wherobots,
20
+ WherobotsAPIError,
21
+ WherobotsConfig,
22
+ )
23
+
24
+ BASE = "https://api.test.wherobots.com"
25
+
26
+ TASK_RUN = {
27
+ "taskRunId": "tr_1",
28
+ "taskName": "rasterflow-inference",
29
+ "state": "SUCCEEDED",
30
+ "region": "aws-us-west-2",
31
+ "submittedAt": "2026-10-20T18:00:00Z",
32
+ "startedAt": "2026-10-20T18:00:05Z",
33
+ "endedAt": "2026-10-20T18:10:05Z",
34
+ "durationSeconds": 600,
35
+ "name": "nightly",
36
+ "inputs": {"aoi": "s3://bucket/aoi.geojson"},
37
+ "results": {"output": "s3://bucket/out.parquet"},
38
+ "cached": True,
39
+ "errorMessage": None,
40
+ }
41
+
42
+
43
+ @pytest.fixture
44
+ def session():
45
+ with patch("wherobots.api.base.requests.Session") as mock_session_cls:
46
+ sess = mock_session_cls.return_value
47
+ sess.headers = {}
48
+ yield sess
49
+
50
+
51
+ def _wb(session, region="aws-us-west-2", body=None):
52
+ session.request.return_value = _ok(body)
53
+ return Wherobots(WherobotsConfig(api_key="test-key-123", region=region, base_url=BASE))
54
+
55
+
56
+ def _ok(body=None):
57
+ resp = MagicMock()
58
+ resp.status_code = 200
59
+ resp.json.return_value = body if body is not None else {}
60
+ resp.raise_for_status.return_value = None
61
+ return resp
62
+
63
+
64
+ def _sent(session):
65
+ call = session.request.call_args
66
+ return call.args[0], call.args[1], call.kwargs["params"], call.kwargs["json"]
67
+
68
+
69
+ def test_surfaces_are_cached_on_the_client(session):
70
+ wb = _wb(session)
71
+ assert wb.tasks is wb.tasks
72
+ assert wb.task_runs is wb.task_runs
73
+
74
+
75
+ def test_list_tasks_sends_filters_and_parses_the_page(session):
76
+ body = {
77
+ "items": [{"name": "a", "displayName": "A", "regions": ["aws-us-west-2", 7]}],
78
+ "total": 3,
79
+ "limit": 1,
80
+ "offset": 1,
81
+ }
82
+ wb = _wb(session, body=body)
83
+
84
+ page = wb.tasks.list(regions=[Region.US_WEST_2, "aws-eu-west-1"], limit=1, offset=1)
85
+
86
+ assert _sent(session) == (
87
+ "GET",
88
+ f"{BASE}/tasks",
89
+ {"limit": 1, "offset": 1, "regions": ["aws-us-west-2", "aws-eu-west-1"]},
90
+ None,
91
+ )
92
+ assert [t.name for t in page.items] == ["a"]
93
+ assert page.items[0].regions == ["aws-us-west-2"]
94
+ assert page.next_offset == 2
95
+
96
+
97
+ @pytest.mark.parametrize("regions", ["aws-us-west-2", Region.US_WEST_2])
98
+ def test_one_region_is_not_split_into_characters(session, regions):
99
+ wb = _wb(session, body={"items": [], "total": 0, "limit": 50, "offset": 0})
100
+ wb.tasks.list(regions=regions)
101
+ assert _sent(session)[2]["regions"] == ["aws-us-west-2"]
102
+
103
+
104
+ def test_get_task_encodes_the_name_and_parses_schemas(session):
105
+ body = {
106
+ "name": "a.b",
107
+ "displayName": "A",
108
+ "inputSchema": {"type": "object"},
109
+ "examples": [{"name": "small", "inputs": {"n": 1}}, "junk"],
110
+ }
111
+ wb = _wb(session, body=body)
112
+
113
+ task = wb.tasks.get("a/b")
114
+
115
+ assert _sent(session)[1] == f"{BASE}/tasks/a%2Fb"
116
+ assert isinstance(task, Task)
117
+ assert task.input_schema == {"type": "object"}
118
+ assert task.output_schema is None
119
+ assert [(e.name, e.inputs) for e in task.examples] == [("small", {"n": 1})]
120
+
121
+
122
+ @pytest.mark.parametrize(
123
+ ("configured", "explicit", "expected"),
124
+ [
125
+ ("aws-us-west-2", None, "aws-us-west-2"),
126
+ ("aws-us-west-2", Region.EU_WEST_1, "aws-eu-west-1"),
127
+ (None, None, None),
128
+ ],
129
+ )
130
+ def test_run_task_picks_the_region(session, configured, explicit, expected):
131
+ wb = _wb(session, region=configured, body=TASK_RUN)
132
+
133
+ run = wb.tasks.run("rasterflow-inference", {"aoi": "x"}, region=explicit)
134
+
135
+ method, url, _, body = _sent(session)
136
+ assert (method, url) == ("POST", f"{BASE}/tasks/rasterflow-inference/runs")
137
+ assert body == {"inputs": {"aoi": "x"}} | ({"region": expected} if expected else {})
138
+ assert isinstance(run, TaskRunSummary)
139
+ assert run.task_run_id == "tr_1"
140
+
141
+
142
+ def test_run_task_sends_the_run_name(session):
143
+ wb = _wb(session, body=TASK_RUN)
144
+ wb.tasks.run("rasterflow-inference", name="nightly")
145
+ assert _sent(session)[3]["name"] == "nightly"
146
+
147
+
148
+ def test_list_task_runs_sends_filters(session):
149
+ wb = _wb(session, body={"items": [TASK_RUN], "total": 1, "limit": 50, "offset": 0})
150
+ after = datetime(2026, 10, 1, tzinfo=UTC)
151
+
152
+ page = wb.task_runs.list(
153
+ task_name="raster*", states=[TaskRunState.RUNNING, "PENDING"], created_after=after
154
+ )
155
+
156
+ assert _sent(session)[2] == {
157
+ "limit": 50,
158
+ "offset": 0,
159
+ "taskName": "raster*",
160
+ "state": ["RUNNING", "PENDING"],
161
+ "createdAfter": "2026-10-01T00:00:00+00:00",
162
+ }
163
+ assert page.items[0].state is TaskRunState.SUCCEEDED
164
+ assert page.next_offset is None
165
+
166
+
167
+ def test_list_task_runs_reads_a_naive_datetime_as_local_time(session):
168
+ wb = _wb(session, body={"items": [], "total": 0, "limit": 50, "offset": 0})
169
+ naive = datetime(2026, 10, 1, 9, 30)
170
+
171
+ wb.task_runs.list(created_after=naive, states="RUNNING")
172
+
173
+ params = _sent(session)[2]
174
+ assert datetime.fromisoformat(params["createdAfter"]) == naive.astimezone()
175
+ assert params["createdAfter"].endswith("+00:00")
176
+ assert params["state"] == ["RUNNING"]
177
+
178
+
179
+ def test_get_task_run_parses_the_outcome(session):
180
+ wb = _wb(session, body=TASK_RUN)
181
+
182
+ run = wb.task_runs.get("tr/1")
183
+
184
+ assert _sent(session)[1] == f"{BASE}/task-runs/tr%2F1"
185
+ assert isinstance(run, TaskRun)
186
+ assert run.results == {"output": "s3://bucket/out.parquet"}
187
+ assert run.cached is True
188
+ assert run.duration_seconds == 600
189
+ assert run.submitted_at == datetime(2026, 10, 20, 18, tzinfo=UTC)
190
+ assert not run.is_active
191
+
192
+
193
+ def test_task_run_keeps_an_unknown_state():
194
+ run = TaskRunSummary.from_dict({**TASK_RUN, "state": "PAUSED"})
195
+ assert run.state == "PAUSED"
196
+ assert not run.is_active
197
+
198
+
199
+ @pytest.mark.parametrize("state", list(TaskRunState))
200
+ def test_only_unfinished_states_are_active(state):
201
+ expected = state.value in {"PENDING", "SUBMITTED", "RUNNING", "CANCELLING"}
202
+ assert TaskRunSummary.from_dict({**TASK_RUN, "state": state.value}).is_active is expected
203
+
204
+
205
+ def test_logs_pass_since_and_keep_nanosecond_timestamps(session):
206
+ stamp = "2026-06-04T18:01:12.680610267Z"
207
+ body = {
208
+ "taskRunId": "tr_1",
209
+ "items": [{"timestamp": stamp, "level": "INFO", "message": "hi"}],
210
+ "nextSince": stamp,
211
+ }
212
+ wb = _wb(session, body=body)
213
+
214
+ logs = wb.task_runs.logs("tr_1", since="2026-06-04T18:00:00Z")
215
+
216
+ assert _sent(session)[1:3] == (
217
+ f"{BASE}/task-runs/tr_1/logs",
218
+ {"since": "2026-06-04T18:00:00Z"},
219
+ )
220
+ assert logs.entries[0].timestamp == stamp
221
+ assert logs.next_since == stamp
222
+
223
+
224
+ def test_cost_is_a_decimal(session):
225
+ body = {
226
+ "taskRunId": "tr_1",
227
+ "totalCostUsd": "1.10",
228
+ "units": 11,
229
+ "unitType": "RSU",
230
+ "isSettling": True,
231
+ }
232
+ wb = _wb(session, body=body)
233
+
234
+ cost = wb.task_runs.cost("tr_1")
235
+
236
+ assert _sent(session)[1] == f"{BASE}/task-runs/tr_1/cost"
237
+ assert cost.total_cost_usd == Decimal("1.10")
238
+ assert cost.is_settling is True
239
+
240
+
241
+ @pytest.mark.parametrize(
242
+ "broken",
243
+ [
244
+ {"totalCostUsd": None},
245
+ {"totalCostUsd": "n/a"},
246
+ {"totalCostUsd": "NaN"},
247
+ {"totalCostUsd": float("inf")},
248
+ {"isSettling": None},
249
+ ],
250
+ )
251
+ def test_an_unreadable_cost_is_an_error_not_zero(session, broken):
252
+ body = {"taskRunId": "tr_1", "totalCostUsd": "0", "units": 0, "unitType": "RSU"}
253
+ wb = _wb(session, body={**body, "isSettling": False, **broken})
254
+ with pytest.raises(WherobotsAPIError, match="Task run cost"):
255
+ wb.task_runs.cost("tr_1")
256
+
257
+
258
+ def test_cancel_posts(session):
259
+ wb = _wb(session)
260
+
261
+ wb.task_runs.cancel("tr_1")
262
+
263
+ assert _sent(session)[:2] == ("POST", f"{BASE}/task-runs/tr_1/cancel")
264
+
265
+
266
+ def test_a_non_object_body_is_an_api_error(session):
267
+ wb = _wb(session, body=["not", "an", "object"])
268
+ with pytest.raises(WherobotsAPIError, match="unexpected body"):
269
+ wb.task_runs.get("tr_1")
270
+
271
+
272
+ def test_http_errors_surface_as_api_errors(session):
273
+ resp = MagicMock()
274
+ resp.status_code = 404
275
+ resp.headers = {}
276
+ resp.json.return_value = {"errors": [{"message": "Task not found"}], "requestId": "r"}
277
+ resp.raise_for_status.side_effect = requests.HTTPError(response=resp)
278
+ wb = _wb(session)
279
+ session.request.return_value = resp
280
+
281
+ with pytest.raises(WherobotsAPIError) as err:
282
+ wb.tasks.get("missing")
283
+ assert err.value.status_code == 404
284
+
285
+
286
+ @given(
287
+ total=st.integers(min_value=0, max_value=500),
288
+ offset=st.integers(min_value=0, max_value=500),
289
+ count=st.integers(min_value=0, max_value=100),
290
+ )
291
+ def test_next_offset_stops_at_the_end(total, offset, count):
292
+ page = OffsetPage(items=[None] * count, total=total, limit=100, offset=offset)
293
+ if page.next_offset is None:
294
+ assert count == 0 or offset + count >= total
295
+ else:
296
+ assert page.next_offset == offset + count < total