futurehouse-client 0.3.19.dev111__tar.gz → 0.3.19.dev129__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 (29) hide show
  1. {futurehouse_client-0.3.19.dev111 → futurehouse_client-0.3.19.dev129}/PKG-INFO +1 -1
  2. {futurehouse_client-0.3.19.dev111 → futurehouse_client-0.3.19.dev129}/futurehouse_client/clients/rest_client.py +76 -79
  3. {futurehouse_client-0.3.19.dev111 → futurehouse_client-0.3.19.dev129}/futurehouse_client.egg-info/PKG-INFO +1 -1
  4. futurehouse_client-0.3.19.dev129/tests/test_rest.py +680 -0
  5. futurehouse_client-0.3.19.dev111/tests/test_rest.py +0 -235
  6. {futurehouse_client-0.3.19.dev111 → futurehouse_client-0.3.19.dev129}/LICENSE +0 -0
  7. {futurehouse_client-0.3.19.dev111 → futurehouse_client-0.3.19.dev129}/README.md +0 -0
  8. {futurehouse_client-0.3.19.dev111 → futurehouse_client-0.3.19.dev129}/docs/__init__.py +0 -0
  9. {futurehouse_client-0.3.19.dev111 → futurehouse_client-0.3.19.dev129}/docs/client_notebook.ipynb +0 -0
  10. {futurehouse_client-0.3.19.dev111 → futurehouse_client-0.3.19.dev129}/futurehouse_client/__init__.py +0 -0
  11. {futurehouse_client-0.3.19.dev111 → futurehouse_client-0.3.19.dev129}/futurehouse_client/clients/__init__.py +0 -0
  12. {futurehouse_client-0.3.19.dev111 → futurehouse_client-0.3.19.dev129}/futurehouse_client/clients/job_client.py +0 -0
  13. {futurehouse_client-0.3.19.dev111 → futurehouse_client-0.3.19.dev129}/futurehouse_client/models/__init__.py +0 -0
  14. {futurehouse_client-0.3.19.dev111 → futurehouse_client-0.3.19.dev129}/futurehouse_client/models/app.py +0 -0
  15. {futurehouse_client-0.3.19.dev111 → futurehouse_client-0.3.19.dev129}/futurehouse_client/models/client.py +0 -0
  16. {futurehouse_client-0.3.19.dev111 → futurehouse_client-0.3.19.dev129}/futurehouse_client/models/rest.py +0 -0
  17. {futurehouse_client-0.3.19.dev111 → futurehouse_client-0.3.19.dev129}/futurehouse_client/utils/__init__.py +0 -0
  18. {futurehouse_client-0.3.19.dev111 → futurehouse_client-0.3.19.dev129}/futurehouse_client/utils/auth.py +0 -0
  19. {futurehouse_client-0.3.19.dev111 → futurehouse_client-0.3.19.dev129}/futurehouse_client/utils/general.py +0 -0
  20. {futurehouse_client-0.3.19.dev111 → futurehouse_client-0.3.19.dev129}/futurehouse_client/utils/module_utils.py +0 -0
  21. {futurehouse_client-0.3.19.dev111 → futurehouse_client-0.3.19.dev129}/futurehouse_client/utils/monitoring.py +0 -0
  22. {futurehouse_client-0.3.19.dev111 → futurehouse_client-0.3.19.dev129}/futurehouse_client.egg-info/SOURCES.txt +0 -0
  23. {futurehouse_client-0.3.19.dev111 → futurehouse_client-0.3.19.dev129}/futurehouse_client.egg-info/dependency_links.txt +0 -0
  24. {futurehouse_client-0.3.19.dev111 → futurehouse_client-0.3.19.dev129}/futurehouse_client.egg-info/requires.txt +0 -0
  25. {futurehouse_client-0.3.19.dev111 → futurehouse_client-0.3.19.dev129}/futurehouse_client.egg-info/top_level.txt +0 -0
  26. {futurehouse_client-0.3.19.dev111 → futurehouse_client-0.3.19.dev129}/pyproject.toml +0 -0
  27. {futurehouse_client-0.3.19.dev111 → futurehouse_client-0.3.19.dev129}/setup.cfg +0 -0
  28. {futurehouse_client-0.3.19.dev111 → futurehouse_client-0.3.19.dev129}/tests/test_client.py +0 -0
  29. {futurehouse_client-0.3.19.dev111 → futurehouse_client-0.3.19.dev129}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: futurehouse-client
3
- Version: 0.3.19.dev111
3
+ Version: 0.3.19.dev129
4
4
  Summary: A client for interacting with endpoints of the FutureHouse service.
5
5
  Author-email: FutureHouse technical staff <hello@futurehouse.org>
6
6
  Classifier: Operating System :: OS Independent
@@ -444,37 +444,36 @@ class RestClient:
444
444
  self, task_id: str | None = None, history: bool = False, verbose: bool = False
445
445
  ) -> "TaskResponse":
446
446
  """Get details for a specific task."""
447
- try:
448
- task_id = task_id or self.trajectory_id
449
- url = f"/v0.1/trajectories/{task_id}"
450
- full_url = f"{self.base_url}{url}"
451
-
452
- with (
453
- external_trace(
454
- url=full_url,
455
- method="GET",
456
- library="httpx",
457
- custom_params={
458
- "operation": "get_job",
459
- "job_id": task_id,
460
- },
461
- ),
462
- self.client.stream("GET", url, params={"history": history}) as response,
463
- ):
464
- response.raise_for_status()
465
- json_data = "".join(response.iter_text(chunk_size=1024))
466
- data = json.loads(json_data)
467
- if "id" not in data:
468
- data["id"] = task_id
469
- verbose_response = TaskResponseVerbose(**data)
447
+ task_id = task_id or self.trajectory_id
448
+ url = f"/v0.1/trajectories/{task_id}"
449
+ full_url = f"{self.base_url}{url}"
470
450
 
471
- if verbose:
472
- return verbose_response
473
- return JobNames.get_response_object_from_job(verbose_response.job_name)(
474
- **data
475
- )
476
- except Exception as e:
477
- raise TaskFetchError(f"Error getting task: {e!s}") from e
451
+ with (
452
+ external_trace(
453
+ url=full_url,
454
+ method="GET",
455
+ library="httpx",
456
+ custom_params={
457
+ "operation": "get_job",
458
+ "job_id": task_id,
459
+ },
460
+ ),
461
+ self.client.stream("GET", url, params={"history": history}) as response,
462
+ ):
463
+ if response.status_code in {401, 403}:
464
+ raise PermissionError(
465
+ f"Error getting task: Permission denied for task {task_id}"
466
+ )
467
+ response.raise_for_status()
468
+ json_data = "".join(response.iter_text(chunk_size=1024))
469
+ data = json.loads(json_data)
470
+ if "id" not in data:
471
+ data["id"] = task_id
472
+ verbose_response = TaskResponseVerbose(**data)
473
+
474
+ if verbose:
475
+ return verbose_response
476
+ return JobNames.get_response_object_from_job(verbose_response.job_name)(**data)
478
477
 
479
478
  @retry(
480
479
  stop=stop_after_attempt(MAX_RETRY_ATTEMPTS),
@@ -485,39 +484,36 @@ class RestClient:
485
484
  self, task_id: str | None = None, history: bool = False, verbose: bool = False
486
485
  ) -> "TaskResponse":
487
486
  """Get details for a specific task asynchronously."""
488
- try:
489
- task_id = task_id or self.trajectory_id
490
- url = f"/v0.1/trajectories/{task_id}"
491
- full_url = f"{self.base_url}{url}"
487
+ task_id = task_id or self.trajectory_id
488
+ url = f"/v0.1/trajectories/{task_id}"
489
+ full_url = f"{self.base_url}{url}"
490
+
491
+ with external_trace(
492
+ url=full_url,
493
+ method="GET",
494
+ library="httpx",
495
+ custom_params={
496
+ "operation": "get_job",
497
+ "job_id": task_id,
498
+ },
499
+ ):
500
+ async with self.async_client.stream(
501
+ "GET", url, params={"history": history}
502
+ ) as response:
503
+ if response.status_code in {401, 403}:
504
+ raise PermissionError(
505
+ f"Error getting task: Permission denied for task {task_id}"
506
+ )
507
+ response.raise_for_status()
508
+ json_data = "".join([chunk async for chunk in response.aiter_text()])
509
+ data = json.loads(json_data)
510
+ if "id" not in data:
511
+ data["id"] = task_id
512
+ verbose_response = TaskResponseVerbose(**data)
492
513
 
493
- with external_trace(
494
- url=full_url,
495
- method="GET",
496
- library="httpx",
497
- custom_params={
498
- "operation": "get_job",
499
- "job_id": task_id,
500
- },
501
- ):
502
- async with self.async_client.stream(
503
- "GET", url, params={"history": history}
504
- ) as response:
505
- response.raise_for_status()
506
- json_data = "".join([
507
- chunk async for chunk in response.aiter_text()
508
- ])
509
- data = json.loads(json_data)
510
- if "id" not in data:
511
- data["id"] = task_id
512
- verbose_response = TaskResponseVerbose(**data)
513
-
514
- if verbose:
515
- return verbose_response
516
- return JobNames.get_response_object_from_job(verbose_response.job_name)(
517
- **data
518
- )
519
- except Exception as e:
520
- raise TaskFetchError(f"Error getting task: {e!s}") from e
514
+ if verbose:
515
+ return verbose_response
516
+ return JobNames.get_response_object_from_job(verbose_response.job_name)(**data)
521
517
 
522
518
  @retry(
523
519
  stop=stop_after_attempt(MAX_RETRY_ATTEMPTS),
@@ -535,15 +531,16 @@ class RestClient:
535
531
  self.stage,
536
532
  )
537
533
 
538
- try:
539
- response = self.client.post(
540
- "/v0.1/crows", json=task_data.model_dump(mode="json")
534
+ response = self.client.post(
535
+ "/v0.1/crows", json=task_data.model_dump(mode="json")
536
+ )
537
+ if response.status_code in {401, 403}:
538
+ raise PermissionError(
539
+ f"Error creating task: Permission denied for task {task_data.name}"
541
540
  )
542
- response.raise_for_status()
543
- trajectory_id = response.json()["trajectory_id"]
544
- self.trajectory_id = trajectory_id
545
- except Exception as e:
546
- raise TaskFetchError(f"Error creating task: {e!s}") from e
541
+ response.raise_for_status()
542
+ trajectory_id = response.json()["trajectory_id"]
543
+ self.trajectory_id = trajectory_id
547
544
  return trajectory_id
548
545
 
549
546
  @retry(
@@ -561,16 +558,16 @@ class RestClient:
561
558
  task_data.name.name,
562
559
  self.stage,
563
560
  )
564
-
565
- try:
566
- response = await self.async_client.post(
567
- "/v0.1/crows", json=task_data.model_dump(mode="json")
561
+ response = await self.async_client.post(
562
+ "/v0.1/crows", json=task_data.model_dump(mode="json")
563
+ )
564
+ if response.status_code in {401, 403}:
565
+ raise PermissionError(
566
+ f"Error creating task: Permission denied for task {task_data.name}"
568
567
  )
569
- response.raise_for_status()
570
- trajectory_id = response.json()["trajectory_id"]
571
- self.trajectory_id = trajectory_id
572
- except Exception as e:
573
- raise TaskFetchError(f"Error creating task: {e!s}") from e
568
+ response.raise_for_status()
569
+ trajectory_id = response.json()["trajectory_id"]
570
+ self.trajectory_id = trajectory_id
574
571
  return trajectory_id
575
572
 
576
573
  async def arun_tasks_until_done(
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: futurehouse-client
3
- Version: 0.3.19.dev111
3
+ Version: 0.3.19.dev129
4
4
  Summary: A client for interacting with endpoints of the FutureHouse service.
5
5
  Author-email: FutureHouse technical staff <hello@futurehouse.org>
6
6
  Classifier: Operating System :: OS Independent
@@ -0,0 +1,680 @@
1
+ # ruff: noqa: ARG001
2
+ # ruff: noqa: SIM117
3
+ # pylint: disable=too-many-lines,import-error,too-many-public-methods
4
+ import asyncio
5
+ import os
6
+ import tempfile
7
+ import time
8
+ import types
9
+ from pathlib import Path
10
+ from unittest.mock import MagicMock, mock_open, patch
11
+
12
+ import pytest
13
+ from futurehouse_client.clients import (
14
+ JobNames,
15
+ )
16
+ from futurehouse_client.clients.rest_client import (
17
+ FileUploadError,
18
+ RestClient,
19
+ RestClientError,
20
+ )
21
+ from futurehouse_client.models.app import (
22
+ PhoenixTaskResponse,
23
+ PQATaskResponse,
24
+ Stage,
25
+ TaskRequest,
26
+ TaskResponseVerbose,
27
+ )
28
+ from futurehouse_client.models.rest import ExecutionStatus
29
+ from pytest_subtests import SubTests
30
+
31
+ ADMIN_API_KEY = os.environ["PLAYWRIGHT_ADMIN_API_KEY"]
32
+ PUBLIC_API_KEY = os.environ["PLAYWRIGHT_PUBLIC_API_KEY"]
33
+ TEST_MAX_POLLS = 100
34
+
35
+
36
+ @pytest.fixture
37
+ def admin_client():
38
+ """Create a RestClient for testing; using an admin key."""
39
+ return RestClient(
40
+ stage=Stage.DEV,
41
+ api_key=ADMIN_API_KEY,
42
+ )
43
+
44
+
45
+ @pytest.fixture
46
+ def pub_client():
47
+ """Create a RestClient for testing; using a public user key with limited access."""
48
+ return RestClient(
49
+ stage=Stage.DEV,
50
+ api_key=PUBLIC_API_KEY,
51
+ )
52
+
53
+
54
+ @pytest.fixture
55
+ def task_req():
56
+ """Create a sample task request."""
57
+ return TaskRequest(
58
+ name=JobNames.from_string("dummy"),
59
+ query="How many moons does earth have?",
60
+ )
61
+
62
+
63
+ @pytest.fixture
64
+ def pqa_task_req():
65
+ return TaskRequest(
66
+ name=JobNames.from_string("crow"),
67
+ query="How many moons does earth have?",
68
+ )
69
+
70
+
71
+ @pytest.fixture
72
+ def phoenix_task_req():
73
+ return TaskRequest(
74
+ name=JobNames.from_string("phoenix"),
75
+ query="What is the molecular weight of ascorbic acids?",
76
+ )
77
+
78
+
79
+ @pytest.mark.timeout(300)
80
+ @pytest.mark.flaky(reruns=3)
81
+ def test_futurehouse_dummy_env_crow(admin_client: RestClient, task_req: TaskRequest):
82
+ admin_client.create_task(task_req)
83
+ while (task_status := admin_client.get_task().status) in {"queued", "in progress"}:
84
+ time.sleep(5)
85
+ assert task_status == "success"
86
+
87
+
88
+ def test_insufficient_permissions_request(
89
+ pub_client: RestClient, task_req: TaskRequest
90
+ ):
91
+ # Create a new instance so that cached credentials aren't reused
92
+ with pytest.raises(PermissionError) as exc_info:
93
+ pub_client.create_task(task_req)
94
+
95
+ assert "Error creating task" in str(exc_info.value)
96
+
97
+
98
+ @pytest.mark.timeout(300)
99
+ @pytest.mark.asyncio
100
+ async def test_job_response( # noqa: PLR0915
101
+ subtests: SubTests,
102
+ admin_client: RestClient,
103
+ pqa_task_req: TaskRequest,
104
+ phoenix_task_req: TaskRequest,
105
+ ):
106
+ task_id = admin_client.create_task(pqa_task_req)
107
+ atask_id = await admin_client.acreate_task(pqa_task_req)
108
+ phoenix_task_id = admin_client.create_task(phoenix_task_req)
109
+ aphoenix_task_id = await admin_client.acreate_task(phoenix_task_req)
110
+
111
+ with subtests.test("Test TaskResponse with queued task"):
112
+ task_response = admin_client.get_task(task_id)
113
+ assert task_response.status in {"queued", "in progress"}
114
+ assert task_response.job_name == pqa_task_req.name
115
+ assert task_response.query == pqa_task_req.query
116
+ task_response = await admin_client.aget_task(atask_id)
117
+ assert task_response.status in {"queued", "in progress"}
118
+ assert task_response.job_name == pqa_task_req.name
119
+ assert task_response.query == pqa_task_req.query
120
+
121
+ for _ in range(TEST_MAX_POLLS):
122
+ task_response = admin_client.get_task(task_id)
123
+ if task_response.status in ExecutionStatus.terminal_states():
124
+ break
125
+ await asyncio.sleep(5)
126
+
127
+ for _ in range(TEST_MAX_POLLS):
128
+ task_response = await admin_client.aget_task(atask_id)
129
+ if task_response.status in ExecutionStatus.terminal_states():
130
+ break
131
+ await asyncio.sleep(5)
132
+
133
+ with subtests.test("Test PQA job response"):
134
+ task_response = admin_client.get_task(task_id)
135
+ assert isinstance(task_response, PQATaskResponse)
136
+ # assert it has general fields
137
+ assert task_response.status == "success"
138
+ assert task_response.task_id is not None
139
+ assert pqa_task_req.name in task_response.job_name
140
+ assert pqa_task_req.query in task_response.query
141
+ # assert it has PQA specific fields
142
+ assert task_response.answer is not None
143
+ # assert it's not verbose
144
+ assert not hasattr(task_response, "environment_frame")
145
+ assert not hasattr(task_response, "agent_state")
146
+
147
+ with subtests.test("Test async PQA job response"):
148
+ task_response = await admin_client.aget_task(atask_id)
149
+ assert isinstance(task_response, PQATaskResponse)
150
+ # assert it has general fields
151
+ assert task_response.status == "success"
152
+ assert task_response.task_id is not None
153
+ assert pqa_task_req.name in task_response.job_name
154
+ assert pqa_task_req.query in task_response.query
155
+ # assert it has PQA specific fields
156
+ assert task_response.answer is not None
157
+ # assert it's not verbose
158
+ assert not hasattr(task_response, "environment_frame")
159
+ assert not hasattr(task_response, "agent_state")
160
+
161
+ with subtests.test("Test Phoenix job response"):
162
+ task_response = admin_client.get_task(phoenix_task_id)
163
+ assert isinstance(task_response, PhoenixTaskResponse)
164
+ assert task_response.status == "success"
165
+ assert task_response.task_id is not None
166
+ assert phoenix_task_req.name in task_response.job_name
167
+ assert phoenix_task_req.query in task_response.query
168
+
169
+ with subtests.test("Test async Phoenix job response"):
170
+ task_response = await admin_client.aget_task(aphoenix_task_id)
171
+ assert isinstance(task_response, PhoenixTaskResponse)
172
+ assert task_response.status == "success"
173
+ assert task_response.task_id is not None
174
+ assert phoenix_task_req.name in task_response.job_name
175
+ assert phoenix_task_req.query in task_response.query
176
+
177
+ with subtests.test("Test task response with verbose"):
178
+ task_response = admin_client.get_task(task_id, verbose=True)
179
+ assert isinstance(task_response, TaskResponseVerbose)
180
+ assert task_response.status == "success"
181
+ assert task_response.environment_frame is not None
182
+ assert task_response.agent_state is not None
183
+
184
+ with subtests.test("Test task async response with verbose"):
185
+ task_response = await admin_client.aget_task(atask_id, verbose=True)
186
+ assert isinstance(task_response, TaskResponseVerbose)
187
+ assert task_response.status == "success"
188
+ assert task_response.environment_frame is not None
189
+ assert task_response.agent_state is not None
190
+
191
+
192
+ @pytest.mark.timeout(300)
193
+ @pytest.mark.flaky(reruns=3)
194
+ def test_run_until_done_futurehouse_dummy_env_crow(
195
+ admin_client: RestClient, task_req: TaskRequest
196
+ ):
197
+ tasks_to_do = [task_req, task_req]
198
+
199
+ results = admin_client.run_tasks_until_done(tasks_to_do)
200
+
201
+ assert len(results) == len(tasks_to_do), "Should return 2 tasks."
202
+ assert all(task.status == "success" for task in results)
203
+
204
+
205
+ @pytest.mark.timeout(300)
206
+ @pytest.mark.flaky(reruns=3)
207
+ @pytest.mark.asyncio
208
+ async def test_arun_until_done_futurehouse_dummy_env_crow(
209
+ admin_client: RestClient, task_req: TaskRequest
210
+ ):
211
+ tasks_to_do = [task_req, task_req]
212
+
213
+ results = await admin_client.arun_tasks_until_done(tasks_to_do)
214
+
215
+ assert len(results) == len(tasks_to_do), "Should return 2 tasks."
216
+ assert all(task.status == "success" for task in results)
217
+
218
+
219
+ @pytest.mark.timeout(300)
220
+ @pytest.mark.flaky(reruns=3)
221
+ @pytest.mark.asyncio
222
+ async def test_timeout_run_until_done_futurehouse_dummy_env_crow(
223
+ admin_client: RestClient, task_req: TaskRequest
224
+ ):
225
+ tasks_to_do = [task_req, task_req]
226
+
227
+ results = await admin_client.arun_tasks_until_done(
228
+ tasks_to_do, verbose=True, timeout=5, progress_bar=True
229
+ )
230
+
231
+ assert len(results) == len(tasks_to_do), "Should return 2 tasks."
232
+ assert all(task.status != "success" for task in results), "Should not be success."
233
+ assert all(not isinstance(task, PQATaskResponse) for task in results), (
234
+ "Should be verbose."
235
+ )
236
+
237
+ results = admin_client.run_tasks_until_done(
238
+ tasks_to_do, verbose=True, timeout=5, progress_bar=True
239
+ )
240
+
241
+ assert len(results) == len(tasks_to_do), "Should return 2 tasks."
242
+ assert all(task.status != "success" for task in results), "Should not be success."
243
+ assert all(not isinstance(task, PQATaskResponse) for task in results), (
244
+ "Should be verbose."
245
+ )
246
+
247
+
248
+ class TestParallelChunking:
249
+ """Test suite for parallel chunk upload functionality."""
250
+
251
+ @pytest.fixture
252
+ def mock_client(self):
253
+ """Create a mock RestClient for testing."""
254
+ # we don't need a real RestClient auth here
255
+ client = MagicMock(spec=RestClient)
256
+ client.CHUNK_SIZE = 16 * 1024 * 1024 # 16MB
257
+ client.MAX_CONCURRENT_CHUNKS = 12
258
+ client.multipart_client = MagicMock()
259
+
260
+ # Set up the real methods we want to test by properly binding them
261
+ client._upload_chunks_parallel = types.MethodType(
262
+ RestClient._upload_chunks_parallel, client
263
+ )
264
+ client._upload_single_chunk = types.MethodType(
265
+ RestClient._upload_single_chunk, client
266
+ )
267
+ client._upload_final_chunk = types.MethodType(
268
+ RestClient._upload_final_chunk, client
269
+ )
270
+ client._upload_single_file = types.MethodType(
271
+ RestClient._upload_single_file, client
272
+ )
273
+ client._upload_directory = types.MethodType(
274
+ RestClient._upload_directory, client
275
+ )
276
+ client.upload_file = types.MethodType(RestClient.upload_file, client)
277
+ client._wait_for_all_assemblies_completion = MagicMock(return_value=True)
278
+
279
+ return client
280
+
281
+ @pytest.fixture
282
+ def large_file_content(self):
283
+ """Create content for a large file that will be chunked."""
284
+ # Create content larger than CHUNK_SIZE (16MB)
285
+ chunk_size = 16 * 1024 * 1024
286
+ return b"A" * (chunk_size * 2 + 1000) # ~32MB + 1000 bytes
287
+
288
+ @pytest.fixture
289
+ def small_file_content(self):
290
+ """Create content for a small file that won't be chunked."""
291
+ return b"Small file content"
292
+
293
+ def test_upload_small_file_no_chunking(self, mock_client):
294
+ """Test uploading a small file that doesn't require chunking."""
295
+ job_name = "test-job"
296
+ file_content = b"Small file content"
297
+
298
+ # Mock successful response
299
+ mock_response = MagicMock()
300
+ mock_response.raise_for_status.return_value = None
301
+ mock_response.json.return_value = {"status_url": "http://test.com/status"}
302
+ mock_client.multipart_client.post.return_value = mock_response
303
+
304
+ with tempfile.NamedTemporaryFile(delete=False) as temp_file:
305
+ temp_file.write(file_content)
306
+ temp_file.flush()
307
+ temp_path = Path(temp_file.name)
308
+
309
+ try:
310
+ # Mock assembly completion
311
+ with patch.object(
312
+ mock_client,
313
+ "_wait_for_all_assemblies_completion",
314
+ return_value=True,
315
+ ):
316
+ upload_id = mock_client.upload_file(job_name, temp_path)
317
+
318
+ # Verify upload was called once (single chunk)
319
+ assert mock_client.multipart_client.post.call_count == 1
320
+ assert upload_id is not None
321
+
322
+ # Verify the post call was made with correct endpoint
323
+ call_args = mock_client.multipart_client.post.call_args
324
+ assert f"/v0.1/crows/{job_name}/upload-chunk" in call_args[0][0]
325
+ finally:
326
+ temp_path.unlink()
327
+
328
+ def test_upload_large_file_with_chunking(self, mock_client, large_file_content):
329
+ """Test uploading a large file that requires chunking and parallel uploads."""
330
+ job_name = "test-job"
331
+
332
+ # Mock successful responses for all chunks
333
+ mock_response = MagicMock()
334
+ mock_response.raise_for_status.return_value = None
335
+ mock_response.json.return_value = {"status_url": "http://test.com/status"}
336
+ mock_response.status_code = 200
337
+ mock_client.multipart_client.post.return_value = mock_response
338
+
339
+ with tempfile.NamedTemporaryFile(delete=False) as temp_file:
340
+ temp_file.write(large_file_content)
341
+ temp_file.flush()
342
+ temp_path = Path(temp_file.name)
343
+
344
+ try:
345
+ # Mock assembly completion
346
+ with patch.object(
347
+ mock_client,
348
+ "_wait_for_all_assemblies_completion",
349
+ return_value=True,
350
+ ):
351
+ upload_id = mock_client.upload_file(job_name, temp_path)
352
+
353
+ # Verify multiple chunks were uploaded
354
+ # File size: ~32MB + 1000 bytes, chunk size: 16MB
355
+ # Expected chunks: 3 (16MB + 16MB + 1000 bytes)
356
+ expected_chunks = 3
357
+ assert mock_client.multipart_client.post.call_count == expected_chunks
358
+ assert upload_id is not None
359
+
360
+ # Verify all calls were to the upload-chunk endpoint
361
+ for call in mock_client.multipart_client.post.call_args_list:
362
+ assert f"/v0.1/crows/{job_name}/upload-chunk" in call[0][0]
363
+ finally:
364
+ temp_path.unlink()
365
+
366
+ def test_upload_chunks_parallel_batch_processing(self, mock_client):
367
+ """Test that chunks are processed in parallel batches."""
368
+ job_name = "test-job"
369
+ file_path = Path("test_file.txt")
370
+ file_name = "test_file.txt"
371
+ upload_id = "test-upload-id"
372
+ num_regular_chunks = 5 # Smaller number for easier testing
373
+ total_chunks = 6
374
+
375
+ # Use patch to mock the _upload_single_chunk method
376
+ with patch.object(mock_client, "_upload_single_chunk") as mock_upload_chunk:
377
+ # Call the method - it should use ThreadPoolExecutor internally
378
+ mock_client._upload_chunks_parallel(
379
+ job_name,
380
+ file_path,
381
+ file_name,
382
+ upload_id,
383
+ num_regular_chunks,
384
+ total_chunks,
385
+ )
386
+
387
+ # Verify all chunks were processed by checking the call count
388
+ assert mock_upload_chunk.call_count == num_regular_chunks
389
+
390
+ # Verify the calls were made with correct parameters
391
+ for call_idx, call in enumerate(mock_upload_chunk.call_args_list):
392
+ args = call[0]
393
+ assert args[0] == job_name
394
+ assert args[1] == file_path
395
+ assert args[2] == file_name
396
+ assert args[3] == upload_id
397
+ assert args[4] == call_idx # chunk_index
398
+ assert args[5] == total_chunks
399
+
400
+ def test_upload_single_chunk_success(self, mock_client):
401
+ """Test successful upload of a single chunk."""
402
+ job_name = "test-job"
403
+ file_path = Path("test_file.txt")
404
+ file_name = "test_file.txt"
405
+ upload_id = "test-upload-id"
406
+ chunk_index = 0
407
+ total_chunks = 5
408
+
409
+ # Mock file content
410
+ chunk_content = b"A" * mock_client.CHUNK_SIZE
411
+
412
+ # Mock successful response
413
+ mock_response = MagicMock()
414
+ mock_response.raise_for_status.return_value = None
415
+ mock_client.multipart_client.post.return_value = mock_response
416
+
417
+ with patch("builtins.open", mock_open(read_data=chunk_content)):
418
+ with patch("tempfile.NamedTemporaryFile") as mock_temp_file:
419
+ # Setup mock temporary file
420
+ mock_temp_file.return_value.__enter__.return_value.name = "temp_chunk"
421
+
422
+ mock_client._upload_single_chunk(
423
+ job_name, file_path, file_name, upload_id, chunk_index, total_chunks
424
+ )
425
+
426
+ # Verify the upload was called with correct parameters
427
+ mock_client.multipart_client.post.assert_called_once()
428
+ call_args = mock_client.multipart_client.post.call_args
429
+
430
+ # Check endpoint
431
+ assert f"/v0.1/crows/{job_name}/upload-chunk" in call_args[0][0]
432
+
433
+ # Check data parameters
434
+ data = call_args[1]["data"]
435
+ assert data["file_name"] == file_name
436
+ assert data["chunk_index"] == chunk_index
437
+ assert data["total_chunks"] == total_chunks
438
+ assert data["upload_id"] == upload_id
439
+
440
+ def test_upload_final_chunk_with_retry_on_conflict(self, mock_client):
441
+ """Test final chunk upload with retry logic for missing chunks (409 conflict)."""
442
+ job_name = "test-job"
443
+ file_path = Path("test_file.txt")
444
+ file_name = "test_file.txt"
445
+ upload_id = "test-upload-id"
446
+ chunk_index = 2
447
+ total_chunks = 3
448
+
449
+ # Mock file content
450
+ chunk_content = b"A" * 1000
451
+
452
+ # Create mock responses: first returns 409 (conflict), second succeeds
453
+ mock_response_conflict = MagicMock()
454
+ mock_response_conflict.status_code = 409 # CONFLICT
455
+ mock_response_conflict.raise_for_status.side_effect = None
456
+
457
+ mock_response_success = MagicMock()
458
+ mock_response_success.status_code = 200
459
+ mock_response_success.raise_for_status.return_value = None
460
+ mock_response_success.json.return_value = {
461
+ "status_url": "http://test.com/status"
462
+ }
463
+
464
+ mock_client.multipart_client.post.side_effect = [
465
+ mock_response_conflict,
466
+ mock_response_success,
467
+ ]
468
+
469
+ with patch("builtins.open", mock_open(read_data=chunk_content)):
470
+ with patch("tempfile.NamedTemporaryFile") as mock_temp_file:
471
+ with patch("time.sleep") as mock_sleep: # Speed up test
472
+ mock_temp_file.return_value.__enter__.return_value.name = (
473
+ "temp_chunk"
474
+ )
475
+
476
+ status_url = mock_client._upload_final_chunk(
477
+ job_name,
478
+ file_path,
479
+ file_name,
480
+ upload_id,
481
+ chunk_index,
482
+ total_chunks,
483
+ )
484
+
485
+ # Verify retry was attempted
486
+ assert mock_client.multipart_client.post.call_count == 2
487
+ assert status_url == "http://test.com/status"
488
+ mock_sleep.assert_called_once() # Verify sleep was called for retry
489
+
490
+ def test_upload_final_chunk_max_retries_exceeded(self, mock_client):
491
+ """Test final chunk upload fails after max retries."""
492
+ job_name = "test-job"
493
+ file_path = Path("test_file.txt")
494
+ file_name = "test_file.txt"
495
+ upload_id = "test-upload-id"
496
+ chunk_index = 2
497
+ total_chunks = 3
498
+
499
+ # Mock file content
500
+ chunk_content = b"A" * 1000
501
+
502
+ # Mock response that always returns 409 (conflict) and raises an exception on raise_for_status
503
+ mock_response = MagicMock()
504
+ mock_response.status_code = 409
505
+ # Make raise_for_status raise an exception after the retries are exhausted
506
+ from httpx import HTTPStatusError, Request, codes
507
+
508
+ mock_request = MagicMock(spec=Request)
509
+ mock_response.raise_for_status.side_effect = HTTPStatusError(
510
+ "409 Conflict", request=mock_request, response=mock_response
511
+ )
512
+ mock_client.multipart_client.post.return_value = mock_response
513
+
514
+ with patch("builtins.open", mock_open(read_data=chunk_content)):
515
+ with patch("tempfile.NamedTemporaryFile") as mock_temp_file:
516
+ with patch("time.sleep"): # Speed up test
517
+ # Set up the code constant correctly
518
+ with patch("futurehouse_client.clients.rest_client.codes", codes):
519
+ mock_temp_file.return_value.__enter__.return_value.name = (
520
+ "temp_chunk"
521
+ )
522
+
523
+ with pytest.raises(
524
+ FileUploadError, match="Error uploading final chunk"
525
+ ):
526
+ mock_client._upload_final_chunk(
527
+ job_name,
528
+ file_path,
529
+ file_name,
530
+ upload_id,
531
+ chunk_index,
532
+ total_chunks,
533
+ )
534
+
535
+ # Verify that retries were attempted (should be 3 attempts total)
536
+ assert mock_client.multipart_client.post.call_count == 3
537
+
538
+ def test_upload_directory_recursive(self, mock_client):
539
+ """Test uploading a directory with nested files."""
540
+ job_name = "test-job"
541
+
542
+ # Mock successful response
543
+ mock_response = MagicMock()
544
+ mock_response.raise_for_status.return_value = None
545
+ mock_response.json.return_value = {"status_url": "http://test.com/status"}
546
+ mock_client.multipart_client.post.return_value = mock_response
547
+
548
+ with tempfile.TemporaryDirectory() as temp_dir:
549
+ temp_path = Path(temp_dir)
550
+
551
+ # Create nested directory structure
552
+ (temp_path / "subdir").mkdir()
553
+ (temp_path / "file1.txt").write_text("content1")
554
+ (temp_path / "file2.txt").write_text("content2")
555
+ (temp_path / "subdir" / "file3.txt").write_text("content3")
556
+
557
+ # Mock assembly completion
558
+ with patch.object(
559
+ mock_client, "_wait_for_all_assemblies_completion", return_value=True
560
+ ):
561
+ upload_id_result = mock_client.upload_file(job_name, temp_path)
562
+
563
+ # Verify files were uploaded (3 files total)
564
+ assert mock_client.multipart_client.post.call_count == 3
565
+ # Just check that we got some upload_id back (it will be a UUID)
566
+ assert upload_id_result is not None
567
+ assert len(upload_id_result) > 0
568
+
569
+ # Verify calls were made to upload-chunk endpoint
570
+ for call in mock_client.multipart_client.post.call_args_list:
571
+ assert f"/v0.1/crows/{job_name}/upload-chunk" in call[0][0]
572
+
573
+ def test_upload_file_assembly_failure(self, mock_client):
574
+ """Test upload_file raises error when assembly fails."""
575
+ job_name = "test-job"
576
+ file_content = b"test content"
577
+
578
+ # Mock successful upload but failed assembly
579
+ mock_response = MagicMock()
580
+ mock_response.raise_for_status.return_value = None
581
+ mock_response.json.return_value = {"status_url": "http://test.com/status"}
582
+ mock_client.multipart_client.post.return_value = mock_response
583
+
584
+ with tempfile.NamedTemporaryFile(delete=False) as temp_file:
585
+ temp_file.write(file_content)
586
+ temp_file.flush()
587
+ temp_path = Path(temp_file.name)
588
+
589
+ try:
590
+ # Mock assembly failure
591
+ with (
592
+ patch.object(
593
+ mock_client,
594
+ "_wait_for_all_assemblies_completion",
595
+ return_value=False,
596
+ ),
597
+ pytest.raises(
598
+ RestClientError, match="Assembly failed or timed out"
599
+ ),
600
+ ):
601
+ mock_client.upload_file(job_name, temp_path)
602
+ finally:
603
+ temp_path.unlink()
604
+
605
+ def test_upload_file_skip_assembly_wait(self, mock_client):
606
+ """Test upload_file with wait_for_assembly=False."""
607
+ job_name = "test-job"
608
+ file_content = b"test content"
609
+
610
+ # Mock successful response
611
+ mock_response = MagicMock()
612
+ mock_response.raise_for_status.return_value = None
613
+ mock_response.json.return_value = {"status_url": "http://test.com/status"}
614
+ mock_client.multipart_client.post.return_value = mock_response
615
+
616
+ with tempfile.NamedTemporaryFile(delete=False) as temp_file:
617
+ temp_file.write(file_content)
618
+ temp_file.flush()
619
+ temp_path = Path(temp_file.name)
620
+
621
+ try:
622
+ with patch.object(
623
+ mock_client, "_wait_for_all_assemblies_completion"
624
+ ) as mock_wait:
625
+ upload_id = mock_client.upload_file(
626
+ job_name, temp_path, wait_for_assembly=False
627
+ )
628
+
629
+ # Verify assembly wait was not called
630
+ mock_wait.assert_not_called()
631
+ assert upload_id is not None
632
+ finally:
633
+ temp_path.unlink()
634
+
635
+ def test_max_concurrent_chunks_constant(self, mock_client):
636
+ """Test that MAX_CONCURRENT_CHUNKS constant is properly set."""
637
+ assert mock_client.MAX_CONCURRENT_CHUNKS == 12
638
+ assert isinstance(mock_client.MAX_CONCURRENT_CHUNKS, int)
639
+ assert mock_client.MAX_CONCURRENT_CHUNKS > 0
640
+
641
+ def test_upload_empty_file_handled(self, mock_client):
642
+ """Test that empty files are handled gracefully."""
643
+ job_name = "test-job"
644
+
645
+ with tempfile.NamedTemporaryFile(delete=False) as temp_file:
646
+ # Create empty file
647
+ temp_file.flush()
648
+ temp_path = Path(temp_file.name)
649
+
650
+ try:
651
+ # Mock assembly completion
652
+ with patch.object(
653
+ mock_client,
654
+ "_wait_for_all_assemblies_completion",
655
+ return_value=True,
656
+ ):
657
+ upload_id = mock_client.upload_file(job_name, temp_path)
658
+
659
+ # Should not call post since empty files are skipped
660
+ assert mock_client.multipart_client.post.call_count == 0
661
+ assert upload_id is not None
662
+ finally:
663
+ temp_path.unlink()
664
+
665
+ def test_chunk_size_calculation(self, mock_client):
666
+ """Test that chunk size calculation works correctly."""
667
+ file_size = 32 * 1024 * 1024 + 1000 # 32MB + 1000 bytes
668
+ chunk_size = mock_client.CHUNK_SIZE # 16MB
669
+
670
+ expected_total_chunks = (file_size + chunk_size - 1) // chunk_size
671
+ assert expected_total_chunks == 3 # 16MB + 16MB + ~1000 bytes
672
+
673
+ # Test edge cases
674
+ assert (
675
+ (chunk_size - 1) + chunk_size - 1
676
+ ) // chunk_size == 1 # Just under 1 chunk
677
+ assert (chunk_size + chunk_size - 1) // chunk_size == 1 # Exactly 1 chunk
678
+ assert (
679
+ (chunk_size + 1) + chunk_size - 1
680
+ ) // chunk_size == 2 # Just over 1 chunk
@@ -1,235 +0,0 @@
1
- # ruff: noqa: ARG001
2
- import asyncio
3
- import os
4
- import time
5
-
6
- import pytest
7
- from futurehouse_client.clients import (
8
- JobNames,
9
- )
10
- from futurehouse_client.clients.rest_client import RestClient, TaskFetchError
11
- from futurehouse_client.models.app import (
12
- PhoenixTaskResponse,
13
- PQATaskResponse,
14
- Stage,
15
- TaskRequest,
16
- TaskResponseVerbose,
17
- )
18
- from futurehouse_client.models.rest import ExecutionStatus
19
- from pytest_subtests import SubTests
20
-
21
- ADMIN_API_KEY = os.environ["PLAYWRIGHT_ADMIN_API_KEY"]
22
- PUBLIC_API_KEY = os.environ["PLAYWRIGHT_PUBLIC_API_KEY"]
23
- TEST_MAX_POLLS = 100
24
-
25
-
26
- @pytest.fixture
27
- def admin_client():
28
- """Create a RestClient for testing; using an admin key."""
29
- return RestClient(
30
- stage=Stage.DEV,
31
- api_key=ADMIN_API_KEY,
32
- )
33
-
34
-
35
- @pytest.fixture
36
- def pub_client():
37
- """Create a RestClient for testing; using a public user key with limited access."""
38
- return RestClient(
39
- stage=Stage.DEV,
40
- api_key=PUBLIC_API_KEY,
41
- )
42
-
43
-
44
- @pytest.fixture
45
- def task_req():
46
- """Create a sample task request."""
47
- return TaskRequest(
48
- name=JobNames.from_string("dummy"),
49
- query="How many moons does earth have?",
50
- )
51
-
52
-
53
- @pytest.fixture
54
- def pqa_task_req():
55
- return TaskRequest(
56
- name=JobNames.from_string("crow"),
57
- query="How many moons does earth have?",
58
- )
59
-
60
-
61
- @pytest.fixture
62
- def phoenix_task_req():
63
- return TaskRequest(
64
- name=JobNames.from_string("phoenix"),
65
- query="What is the molecular weight of ascorbic acids?",
66
- )
67
-
68
-
69
- @pytest.mark.timeout(300)
70
- @pytest.mark.flaky(reruns=3)
71
- def test_futurehouse_dummy_env_crow(admin_client: RestClient, task_req: TaskRequest):
72
- admin_client.create_task(task_req)
73
- while (task_status := admin_client.get_task().status) in {"queued", "in progress"}:
74
- time.sleep(5)
75
- assert task_status == "success"
76
-
77
-
78
- def test_insufficient_permissions_request(
79
- pub_client: RestClient, task_req: TaskRequest
80
- ):
81
- # Create a new instance so that cached credentials aren't reused
82
- with pytest.raises(TaskFetchError) as exc_info:
83
- pub_client.create_task(task_req)
84
-
85
- assert "Error creating task" in str(exc_info.value)
86
-
87
-
88
- @pytest.mark.timeout(300)
89
- @pytest.mark.asyncio
90
- async def test_job_response( # noqa: PLR0915
91
- subtests: SubTests,
92
- admin_client: RestClient,
93
- pqa_task_req: TaskRequest,
94
- phoenix_task_req: TaskRequest,
95
- ):
96
- task_id = admin_client.create_task(pqa_task_req)
97
- atask_id = await admin_client.acreate_task(pqa_task_req)
98
- phoenix_task_id = admin_client.create_task(phoenix_task_req)
99
- aphoenix_task_id = await admin_client.acreate_task(phoenix_task_req)
100
-
101
- with subtests.test("Test TaskResponse with queued task"):
102
- task_response = admin_client.get_task(task_id)
103
- assert task_response.status in {"queued", "in progress"}
104
- assert task_response.job_name == pqa_task_req.name
105
- assert task_response.query == pqa_task_req.query
106
- task_response = await admin_client.aget_task(atask_id)
107
- assert task_response.status in {"queued", "in progress"}
108
- assert task_response.job_name == pqa_task_req.name
109
- assert task_response.query == pqa_task_req.query
110
-
111
- for _ in range(TEST_MAX_POLLS):
112
- task_response = admin_client.get_task(task_id)
113
- if task_response.status in ExecutionStatus.terminal_states():
114
- break
115
- await asyncio.sleep(5)
116
-
117
- for _ in range(TEST_MAX_POLLS):
118
- task_response = await admin_client.aget_task(atask_id)
119
- if task_response.status in ExecutionStatus.terminal_states():
120
- break
121
- await asyncio.sleep(5)
122
-
123
- with subtests.test("Test PQA job response"):
124
- task_response = admin_client.get_task(task_id)
125
- assert isinstance(task_response, PQATaskResponse)
126
- # assert it has general fields
127
- assert task_response.status == "success"
128
- assert task_response.task_id is not None
129
- assert pqa_task_req.name in task_response.job_name
130
- assert pqa_task_req.query in task_response.query
131
- # assert it has PQA specific fields
132
- assert task_response.answer is not None
133
- # assert it's not verbose
134
- assert not hasattr(task_response, "environment_frame")
135
- assert not hasattr(task_response, "agent_state")
136
-
137
- with subtests.test("Test async PQA job response"):
138
- task_response = await admin_client.aget_task(atask_id)
139
- assert isinstance(task_response, PQATaskResponse)
140
- # assert it has general fields
141
- assert task_response.status == "success"
142
- assert task_response.task_id is not None
143
- assert pqa_task_req.name in task_response.job_name
144
- assert pqa_task_req.query in task_response.query
145
- # assert it has PQA specific fields
146
- assert task_response.answer is not None
147
- # assert it's not verbose
148
- assert not hasattr(task_response, "environment_frame")
149
- assert not hasattr(task_response, "agent_state")
150
-
151
- with subtests.test("Test Phoenix job response"):
152
- task_response = admin_client.get_task(phoenix_task_id)
153
- assert isinstance(task_response, PhoenixTaskResponse)
154
- assert task_response.status == "success"
155
- assert task_response.task_id is not None
156
- assert phoenix_task_req.name in task_response.job_name
157
- assert phoenix_task_req.query in task_response.query
158
-
159
- with subtests.test("Test async Phoenix job response"):
160
- task_response = await admin_client.aget_task(aphoenix_task_id)
161
- assert isinstance(task_response, PhoenixTaskResponse)
162
- assert task_response.status == "success"
163
- assert task_response.task_id is not None
164
- assert phoenix_task_req.name in task_response.job_name
165
- assert phoenix_task_req.query in task_response.query
166
-
167
- with subtests.test("Test task response with verbose"):
168
- task_response = admin_client.get_task(task_id, verbose=True)
169
- assert isinstance(task_response, TaskResponseVerbose)
170
- assert task_response.status == "success"
171
- assert task_response.environment_frame is not None
172
- assert task_response.agent_state is not None
173
-
174
- with subtests.test("Test task async response with verbose"):
175
- task_response = await admin_client.aget_task(atask_id, verbose=True)
176
- assert isinstance(task_response, TaskResponseVerbose)
177
- assert task_response.status == "success"
178
- assert task_response.environment_frame is not None
179
- assert task_response.agent_state is not None
180
-
181
-
182
- @pytest.mark.timeout(300)
183
- @pytest.mark.flaky(reruns=3)
184
- def test_run_until_done_futurehouse_dummy_env_crow(
185
- admin_client: RestClient, task_req: TaskRequest
186
- ):
187
- tasks_to_do = [task_req, task_req]
188
-
189
- results = admin_client.run_tasks_until_done(tasks_to_do)
190
-
191
- assert len(results) == len(tasks_to_do), "Should return 2 tasks."
192
- assert all(task.status == "success" for task in results)
193
-
194
-
195
- @pytest.mark.timeout(300)
196
- @pytest.mark.flaky(reruns=3)
197
- @pytest.mark.asyncio
198
- async def test_arun_until_done_futurehouse_dummy_env_crow(
199
- admin_client: RestClient, task_req: TaskRequest
200
- ):
201
- tasks_to_do = [task_req, task_req]
202
-
203
- results = await admin_client.arun_tasks_until_done(tasks_to_do)
204
-
205
- assert len(results) == len(tasks_to_do), "Should return 2 tasks."
206
- assert all(task.status == "success" for task in results)
207
-
208
-
209
- @pytest.mark.timeout(300)
210
- @pytest.mark.flaky(reruns=3)
211
- @pytest.mark.asyncio
212
- async def test_timeout_run_until_done_futurehouse_dummy_env_crow(
213
- admin_client: RestClient, task_req: TaskRequest
214
- ):
215
- tasks_to_do = [task_req, task_req]
216
-
217
- results = await admin_client.arun_tasks_until_done(
218
- tasks_to_do, verbose=True, timeout=5, progress_bar=True
219
- )
220
-
221
- assert len(results) == len(tasks_to_do), "Should return 2 tasks."
222
- assert all(task.status != "success" for task in results), "Should not be success."
223
- assert all(not isinstance(task, PQATaskResponse) for task in results), (
224
- "Should be verbose."
225
- )
226
-
227
- results = admin_client.run_tasks_until_done(
228
- tasks_to_do, verbose=True, timeout=5, progress_bar=True
229
- )
230
-
231
- assert len(results) == len(tasks_to_do), "Should return 2 tasks."
232
- assert all(task.status != "success" for task in results), "Should not be success."
233
- assert all(not isinstance(task, PQATaskResponse) for task in results), (
234
- "Should be verbose."
235
- )