futurehouse-client 0.3.20.dev266__py3-none-any.whl → 0.3.20.dev411__py3-none-any.whl

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.
@@ -26,21 +26,14 @@ from httpx import (
26
26
  AsyncClient,
27
27
  Client,
28
28
  CloseError,
29
- ConnectError,
30
- ConnectTimeout,
31
29
  HTTPStatusError,
32
- NetworkError,
33
- ReadError,
34
- ReadTimeout,
35
30
  RemoteProtocolError,
36
31
  codes,
37
32
  )
38
33
  from ldp.agent import AgentConfig
39
- from requests.exceptions import RequestException, Timeout
40
34
  from tenacity import (
41
35
  before_sleep_log,
42
36
  retry,
43
- retry_if_exception_type,
44
37
  stop_after_attempt,
45
38
  wait_exponential,
46
39
  )
@@ -48,6 +41,7 @@ from tqdm import tqdm as sync_tqdm
48
41
  from tqdm.asyncio import tqdm
49
42
 
50
43
  from futurehouse_client.clients import JobNames
44
+ from futurehouse_client.clients.data_storage_methods import DataStorageMethods
51
45
  from futurehouse_client.models.app import (
52
46
  AuthType,
53
47
  JobDeploymentConfig,
@@ -58,6 +52,7 @@ from futurehouse_client.models.app import (
58
52
  TrajectoryQueryParams,
59
53
  )
60
54
  from futurehouse_client.models.rest import (
55
+ DiscoveryResponse,
61
56
  ExecutionStatus,
62
57
  UserAgentRequest,
63
58
  UserAgentRequestPostPayload,
@@ -67,7 +62,10 @@ from futurehouse_client.models.rest import (
67
62
  WorldModelResponse,
68
63
  )
69
64
  from futurehouse_client.utils.auth import RefreshingJWT
70
- from futurehouse_client.utils.general import gather_with_concurrency
65
+ from futurehouse_client.utils.general import (
66
+ create_retry_if_connection_error,
67
+ gather_with_concurrency,
68
+ )
71
69
  from futurehouse_client.utils.module_utils import (
72
70
  OrganizationSelector,
73
71
  fetch_environment_function_docstring,
@@ -135,10 +133,22 @@ class WorldModelCreationError(RestClientError):
135
133
  """Raised when there's an error creating a world model."""
136
134
 
137
135
 
136
+ class WorldModelDeletionError(RestClientError):
137
+ """Raised when there's an error deleting a world model."""
138
+
139
+
138
140
  class ProjectError(RestClientError):
139
141
  """Raised when there's an error with trajectory group operations."""
140
142
 
141
143
 
144
+ class DiscoveryCreationError(RestClientError):
145
+ """Raised when there's an error creating a discovery."""
146
+
147
+
148
+ class DiscoveryFetchError(RestClientError):
149
+ """Raised when there's an error fetching a discovery."""
150
+
151
+
142
152
  class InvalidTaskDescriptionError(Exception):
143
153
  """Raised when the task description is invalid or empty."""
144
154
 
@@ -147,28 +157,15 @@ class FileUploadError(RestClientError):
147
157
  """Raised when there's an error uploading a file."""
148
158
 
149
159
 
150
- retry_if_connection_error = retry_if_exception_type((
151
- # From requests
152
- Timeout,
153
- ConnectionError,
154
- RequestException,
155
- # From httpx
156
- ConnectError,
157
- ConnectTimeout,
158
- ReadTimeout,
159
- ReadError,
160
- NetworkError,
161
- RemoteProtocolError,
162
- CloseError,
163
- FileUploadError,
164
- ))
160
+ retry_if_connection_error = create_retry_if_connection_error(FileUploadError)
165
161
 
166
162
  DEFAULT_AGENT_TIMEOUT: int = 2400 # seconds
167
163
 
168
164
 
169
165
  # pylint: disable=too-many-public-methods
170
- class RestClient:
171
- REQUEST_TIMEOUT: ClassVar[float] = 30.0 # sec
166
+ class RestClient(DataStorageMethods):
167
+ REQUEST_TIMEOUT: ClassVar[float] = 30.0 # sec - for general API calls
168
+ FILE_UPLOAD_TIMEOUT: ClassVar[float] = 600.0 # 10 minutes - for file uploads
172
169
  MAX_RETRY_ATTEMPTS: ClassVar[int] = 3
173
170
  RETRY_MULTIPLIER: ClassVar[int] = 1
174
171
  MAX_RETRY_WAIT: ClassVar[int] = 10
@@ -226,11 +223,35 @@ class RestClient:
226
223
  """Authenticated HTTP client for multipart uploads."""
227
224
  return cast(Client, self.get_client(None, authenticated=True))
228
225
 
226
+ @property
227
+ def file_upload_client(self) -> Client:
228
+ """Authenticated HTTP client with extended timeout for file uploads."""
229
+ return cast(
230
+ Client,
231
+ self.get_client(
232
+ "application/json", authenticated=True, timeout=self.FILE_UPLOAD_TIMEOUT
233
+ ),
234
+ )
235
+
236
+ @property
237
+ def async_file_upload_client(self) -> AsyncClient:
238
+ """Authenticated async HTTP client with extended timeout for file uploads."""
239
+ return cast(
240
+ AsyncClient,
241
+ self.get_client(
242
+ "application/json",
243
+ authenticated=True,
244
+ async_client=True,
245
+ timeout=self.FILE_UPLOAD_TIMEOUT,
246
+ ),
247
+ )
248
+
229
249
  def get_client(
230
250
  self,
231
251
  content_type: str | None = "application/json",
232
252
  authenticated: bool = True,
233
253
  async_client: bool = False,
254
+ timeout: float | None = None,
234
255
  ) -> Client | AsyncClient:
235
256
  """Return a cached HTTP client or create one if needed.
236
257
 
@@ -238,12 +259,13 @@ class RestClient:
238
259
  content_type: The desired content type header. Use None for multipart uploads.
239
260
  authenticated: Whether the client should include authentication.
240
261
  async_client: Whether to use an async client.
262
+ timeout: Custom timeout in seconds. Uses REQUEST_TIMEOUT if not provided.
241
263
 
242
264
  Returns:
243
265
  An HTTP client configured with the appropriate headers.
244
266
  """
245
- # Create a composite key based on content type and auth flag
246
- key = f"{content_type or 'multipart'}_{authenticated}_{async_client}"
267
+ client_timeout = timeout or self.REQUEST_TIMEOUT
268
+ key = f"{content_type or 'multipart'}_{authenticated}_{async_client}_{client_timeout}"
247
269
 
248
270
  if key not in self._clients:
249
271
  headers = copy.deepcopy(self.headers)
@@ -269,14 +291,14 @@ class RestClient:
269
291
  AsyncClient(
270
292
  base_url=self.base_url,
271
293
  headers=headers,
272
- timeout=self.REQUEST_TIMEOUT,
294
+ timeout=client_timeout,
273
295
  auth=auth,
274
296
  )
275
297
  if async_client
276
298
  else Client(
277
299
  base_url=self.base_url,
278
300
  headers=headers,
279
- timeout=self.REQUEST_TIMEOUT,
301
+ timeout=client_timeout,
280
302
  auth=auth,
281
303
  )
282
304
  )
@@ -1584,6 +1606,48 @@ class RestClient:
1584
1606
  except Exception as e:
1585
1607
  raise WorldModelFetchError(f"An unexpected error occurred: {e!r}.") from e
1586
1608
 
1609
+ @retry(
1610
+ stop=stop_after_attempt(MAX_RETRY_ATTEMPTS),
1611
+ wait=wait_exponential(multiplier=RETRY_MULTIPLIER, max=MAX_RETRY_WAIT),
1612
+ retry=retry_if_connection_error,
1613
+ )
1614
+ def search_world_models(
1615
+ self,
1616
+ query: str,
1617
+ size: int = 10,
1618
+ total_search_size: int = 50,
1619
+ search_all_versions: bool = False,
1620
+ ) -> list[str]:
1621
+ """Search for world models.
1622
+
1623
+ Args:
1624
+ query: The search query.
1625
+ size: The number of results to return.
1626
+ total_search_size: The number of results to search for.
1627
+ search_all_versions: Whether to search all versions of the world model or just the latest one.
1628
+
1629
+ Returns:
1630
+ A list of world model names.
1631
+ """
1632
+ try:
1633
+ response = self.client.get(
1634
+ "/v0.1/world-models/search/",
1635
+ params={
1636
+ "query": query,
1637
+ "size": size,
1638
+ "total_search_size": total_search_size,
1639
+ "search_all_versions": search_all_versions,
1640
+ },
1641
+ )
1642
+ response.raise_for_status()
1643
+ return response.json()
1644
+ except HTTPStatusError as e:
1645
+ raise WorldModelFetchError(
1646
+ f"Error searching world models: {e.response.status_code} - {e.response.text}"
1647
+ ) from e
1648
+ except Exception as e:
1649
+ raise WorldModelFetchError(f"An unexpected error occurred: {e!r}.") from e
1650
+
1587
1651
  @retry(
1588
1652
  stop=stop_after_attempt(MAX_RETRY_ATTEMPTS),
1589
1653
  wait=wait_exponential(multiplier=RETRY_MULTIPLIER, max=MAX_RETRY_WAIT),
@@ -1659,6 +1723,32 @@ class RestClient:
1659
1723
  f"An unexpected error occurred during world model creation: {e!r}."
1660
1724
  ) from e
1661
1725
 
1726
+ @retry(
1727
+ stop=stop_after_attempt(MAX_RETRY_ATTEMPTS),
1728
+ wait=wait_exponential(multiplier=RETRY_MULTIPLIER, max=MAX_RETRY_WAIT),
1729
+ retry=retry_if_connection_error,
1730
+ )
1731
+ async def delete_world_model(self, world_model_id: UUID) -> None:
1732
+ """Delete a world model snapshot by its ID.
1733
+
1734
+ Args:
1735
+ world_model_id: The unique ID of the world model snapshot to delete.
1736
+
1737
+ Raises:
1738
+ WorldModelDeletionError: If the API call fails.
1739
+ """
1740
+ try:
1741
+ response = await self.async_client.delete(
1742
+ f"/v0.1/world-models/{world_model_id}"
1743
+ )
1744
+ response.raise_for_status()
1745
+ except HTTPStatusError as e:
1746
+ raise WorldModelDeletionError(
1747
+ f"Error deleting world model: {e.response.status_code} - {e.response.text}"
1748
+ ) from e
1749
+ except Exception as e:
1750
+ raise WorldModelDeletionError(f"An unexpected error occurred: {e}") from e
1751
+
1662
1752
  @retry(
1663
1753
  stop=stop_after_attempt(MAX_RETRY_ATTEMPTS),
1664
1754
  wait=wait_exponential(multiplier=RETRY_MULTIPLIER, max=MAX_RETRY_WAIT),
@@ -1737,11 +1827,18 @@ class RestClient:
1737
1827
  wait=wait_exponential(multiplier=RETRY_MULTIPLIER, max=MAX_RETRY_WAIT),
1738
1828
  retry=retry_if_connection_error,
1739
1829
  )
1740
- def create_project(self, name: str) -> str:
1830
+ def create_project(
1831
+ self,
1832
+ name: str,
1833
+ shared_with_organizations: list[int] | None = None,
1834
+ shared_with_users: list[str] | None = None,
1835
+ ) -> str:
1741
1836
  """Create a new project.
1742
1837
 
1743
1838
  Args:
1744
1839
  name: The name for the project
1840
+ shared_with_organizations: List of organization IDs to share the project with
1841
+ shared_with_users: List of user emails to share the project with
1745
1842
 
1746
1843
  Returns:
1747
1844
  UUID of the created project
@@ -1750,7 +1847,11 @@ class RestClient:
1750
1847
  ProjectError: If there's an error creating the project
1751
1848
  """
1752
1849
  try:
1753
- data = {"name": name}
1850
+ data = {
1851
+ "name": name,
1852
+ "shared_with_organizations": shared_with_organizations,
1853
+ "shared_with_users": shared_with_users,
1854
+ }
1754
1855
  response = self.client.post("/v0.1/projects", json=data)
1755
1856
  response.raise_for_status()
1756
1857
  return response.json()
@@ -1802,11 +1903,18 @@ class RestClient:
1802
1903
  wait=wait_exponential(multiplier=RETRY_MULTIPLIER, max=MAX_RETRY_WAIT),
1803
1904
  retry=retry_if_connection_error,
1804
1905
  )
1805
- async def acreate_project(self, name: str) -> str:
1906
+ async def acreate_project(
1907
+ self,
1908
+ name: str,
1909
+ shared_with_organizations: list[int] | None = None,
1910
+ shared_with_users: list[str] | None = None,
1911
+ ) -> str:
1806
1912
  """Asynchronously create a new project.
1807
1913
 
1808
1914
  Args:
1809
1915
  name: The name for the project
1916
+ shared_with_organizations: List of organization IDs to share the project with
1917
+ shared_with_users: List of user emails to share the project with
1810
1918
 
1811
1919
  Returns:
1812
1920
  UUID of the created project
@@ -1815,7 +1923,11 @@ class RestClient:
1815
1923
  ProjectError: If there's an error creating the project
1816
1924
  """
1817
1925
  try:
1818
- data = {"name": name}
1926
+ data = {
1927
+ "name": name,
1928
+ "shared_with_organizations": shared_with_organizations,
1929
+ "shared_with_users": shared_with_users,
1930
+ }
1819
1931
  response = await self.async_client.post("/v0.1/projects", json=data)
1820
1932
  response.raise_for_status()
1821
1933
  return response.json()
@@ -2326,6 +2438,108 @@ class RestClient:
2326
2438
  f"An unexpected error occurred while responding to the request: {e!r}."
2327
2439
  ) from e
2328
2440
 
2441
+ def create_discovery(
2442
+ self,
2443
+ project_id: UUID,
2444
+ world_model_id: UUID,
2445
+ dataset_id: UUID,
2446
+ description: str,
2447
+ associated_trajectories: list[UUID],
2448
+ validation_level: int,
2449
+ ) -> UUID:
2450
+ """Create a new discovery.
2451
+
2452
+ Args:
2453
+ project_id: The ID of the project the discovery is associated with.
2454
+ world_model_id: The ID of the world model the discovery is associated with.
2455
+ dataset_id: The ID of the dataset the discovery is associated with.
2456
+ description: The description of the discovery.
2457
+ associated_trajectories: The IDs of the trajectories to associate with the discovery.
2458
+ validation_level: The validation level of the discovery.
2459
+
2460
+ Returns:
2461
+ The ID of the created discovery.
2462
+
2463
+ Raises:
2464
+ DiscoveryCreationError: If there's an error creating the discovery.
2465
+ """
2466
+ try:
2467
+ data = {
2468
+ "project_id": str(project_id),
2469
+ "world_model_id": str(world_model_id),
2470
+ "dataset_id": str(dataset_id),
2471
+ "description": description,
2472
+ "associated_trajectories": [
2473
+ str(trajectory) for trajectory in associated_trajectories
2474
+ ],
2475
+ "validation_level": validation_level,
2476
+ }
2477
+ response = self.client.post("/v0.1/discoveries", json=data)
2478
+ response.raise_for_status()
2479
+ return UUID(response.json())
2480
+ except HTTPStatusError as e:
2481
+ raise DiscoveryCreationError(
2482
+ f"Error creating discovery: {e.response.status_code} - {e.response.text}"
2483
+ ) from e
2484
+ except Exception as e:
2485
+ raise DiscoveryCreationError(f"Error creating discovery: {e!r}") from e
2486
+
2487
+ def get_discovery(self, discovery_id: UUID) -> DiscoveryResponse:
2488
+ """Get a discovery by its ID.
2489
+
2490
+ Args:
2491
+ discovery_id: The ID of the discovery to get.
2492
+
2493
+ Returns:
2494
+ The discovery.
2495
+
2496
+ Raises:
2497
+ DiscoveryFetchError: If there's an error fetching the discovery.
2498
+ """
2499
+ try:
2500
+ response = self.client.get(f"/v0.1/discoveries/{discovery_id}")
2501
+ response.raise_for_status()
2502
+ return DiscoveryResponse.model_validate(response.json())
2503
+ except HTTPStatusError as e:
2504
+ raise DiscoveryFetchError(
2505
+ f"Error fetching discovery: {e.response.status_code} - {e.response.text}"
2506
+ ) from e
2507
+ except Exception as e:
2508
+ raise DiscoveryFetchError(f"Error fetching discovery: {e!r}") from e
2509
+
2510
+ def list_discoveries_for_project(
2511
+ self, project_id: UUID, limit: int = 50, offset: int = 0
2512
+ ) -> list[DiscoveryResponse]:
2513
+ """List discoveries for a specific project.
2514
+
2515
+ Args:
2516
+ project_id: The ID of the project to get discoveries for.
2517
+ limit: The maximum number of discoveries to return.
2518
+ offset: The number of discoveries to skip.
2519
+
2520
+ Returns:
2521
+ A list of discoveries for the specified project.
2522
+
2523
+ Raises:
2524
+ DiscoveryFetchError: If there's an error fetching the discoveries.
2525
+ """
2526
+ try:
2527
+ response = self.client.get(
2528
+ f"/v0.1/projects/{project_id}/discoveries",
2529
+ params={"limit": limit, "offset": offset},
2530
+ )
2531
+ response.raise_for_status()
2532
+ data = response.json()
2533
+ return [DiscoveryResponse.model_validate(d) for d in data]
2534
+ except HTTPStatusError as e:
2535
+ raise DiscoveryFetchError(
2536
+ f"Error fetching discoveries for project: {e.response.status_code} - {e.response.text}"
2537
+ ) from e
2538
+ except Exception as e:
2539
+ raise DiscoveryFetchError(
2540
+ f"Error fetching discoveries for project: {e!r}"
2541
+ ) from e
2542
+
2329
2543
 
2330
2544
  def get_installed_packages() -> dict[str, str]:
2331
2545
  """Returns a dictionary of installed packages and their versions."""