futurehouse-client 0.3.18.dev186__py3-none-any.whl → 0.3.19__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.
@@ -1,3 +1,4 @@
1
+ import copy
1
2
  import json
2
3
  import os
3
4
  import re
@@ -675,7 +676,8 @@ class TaskResponse(BaseModel):
675
676
 
676
677
  @model_validator(mode="before")
677
678
  @classmethod
678
- def validate_fields(cls, data: Mapping[str, Any]) -> Mapping[str, Any]:
679
+ def validate_fields(cls, original_data: Mapping[str, Any]) -> Mapping[str, Any]:
680
+ data = copy.deepcopy(original_data) # Avoid mutating the original data
679
681
  # Extract fields from environment frame state
680
682
  if not isinstance(data, dict):
681
683
  return data
@@ -690,7 +692,72 @@ class TaskResponse(BaseModel):
690
692
  return data
691
693
 
692
694
 
695
+ class PhoenixTaskResponse(TaskResponse):
696
+ """
697
+ Response scheme for tasks executed with Phoenix.
698
+
699
+ Additional fields:
700
+ answer: Final answer from Phoenix
701
+ """
702
+
703
+ model_config = ConfigDict(extra="ignore")
704
+ answer: str | None = None
705
+
706
+ @model_validator(mode="before")
707
+ @classmethod
708
+ def validate_phoenix_fields(
709
+ cls, original_data: Mapping[str, Any]
710
+ ) -> Mapping[str, Any]:
711
+ data = copy.deepcopy(original_data)
712
+ if not isinstance(data, dict):
713
+ return data
714
+ if not (env_frame := data.get("environment_frame", {})):
715
+ return data
716
+ state = env_frame.get("state", {}).get("state", {})
717
+ data["answer"] = state.get("answer")
718
+ return data
719
+
720
+
721
+ class FinchTaskResponse(TaskResponse):
722
+ """
723
+ Response scheme for tasks executed with Finch.
724
+
725
+ Additional fields:
726
+ answer: Final answer from Finch
727
+ notebook: a dictionary with `cells` and `metadata` regarding the notebook content
728
+ """
729
+
730
+ model_config = ConfigDict(extra="ignore")
731
+ answer: str | None = None
732
+ notebook: dict[str, Any] | None = None
733
+
734
+ @model_validator(mode="before")
735
+ @classmethod
736
+ def validate_finch_fields(
737
+ cls, original_data: Mapping[str, Any]
738
+ ) -> Mapping[str, Any]:
739
+ data = copy.deepcopy(original_data)
740
+ if not isinstance(data, dict):
741
+ return data
742
+ if not (env_frame := data.get("environment_frame", {})):
743
+ return data
744
+ state = env_frame.get("state", {}).get("state", {})
745
+ data["answer"] = state.get("answer")
746
+ data["notebook"] = state.get("nb_state")
747
+ return data
748
+
749
+
693
750
  class PQATaskResponse(TaskResponse):
751
+ """
752
+ Response scheme for tasks executed with PQA.
753
+
754
+ Additional fields:
755
+ answer: Final answer from PQA
756
+ formatted_answer: Formatted answer from PQA
757
+ answer_reasoning: Reasoning used to generate the final answer, if available
758
+ has_successful_answer: Whether the answer is successful
759
+ """
760
+
694
761
  model_config = ConfigDict(extra="ignore")
695
762
 
696
763
  answer: str | None = None
@@ -702,7 +769,8 @@ class PQATaskResponse(TaskResponse):
702
769
 
703
770
  @model_validator(mode="before")
704
771
  @classmethod
705
- def validate_pqa_fields(cls, data: Mapping[str, Any]) -> Mapping[str, Any]:
772
+ def validate_pqa_fields(cls, original_data: Mapping[str, Any]) -> Mapping[str, Any]:
773
+ data = copy.deepcopy(original_data) # Avoid mutating the original data
706
774
  if not isinstance(data, dict):
707
775
  return data
708
776
  if not (env_frame := data.get("environment_frame", {})):
File without changes
@@ -1,107 +1,92 @@
1
- import asyncio
2
1
  import logging
3
- from collections.abc import Callable, Coroutine
4
- from functools import wraps
5
- from typing import Any, Final, Optional, ParamSpec, TypeVar, overload
2
+ from collections.abc import Collection, Generator
3
+ from typing import ClassVar, Final
6
4
 
7
5
  import httpx
8
- from httpx import HTTPStatusError
9
6
 
10
- logger = logging.getLogger(__name__)
11
-
12
- T = TypeVar("T")
13
- P = ParamSpec("P")
14
-
15
- AUTH_ERRORS_TO_RETRY_ON: Final[set[int]] = {
16
- httpx.codes.UNAUTHORIZED,
17
- httpx.codes.FORBIDDEN,
18
- }
19
-
20
-
21
- class AuthError(Exception):
22
- """Raised when authentication fails with 401/403 status."""
23
-
24
- def __init__(self, status_code: int, message: str, request=None, response=None):
25
- self.status_code = status_code
26
- self.request = request
27
- self.response = response
28
- super().__init__(message)
29
-
30
-
31
- def is_auth_error(e: Exception) -> bool:
32
- if isinstance(e, AuthError):
33
- return True
34
- if isinstance(e, HTTPStatusError):
35
- return e.response.status_code in AUTH_ERRORS_TO_RETRY_ON
36
- return False
37
-
38
-
39
- def get_status_code(e: Exception) -> Optional[int]:
40
- if isinstance(e, AuthError):
41
- return e.status_code
42
- if isinstance(e, HTTPStatusError):
43
- return e.response.status_code
44
- return None
7
+ from futurehouse_client.models.app import APIKeyPayload, AuthType
45
8
 
9
+ logger = logging.getLogger(__name__)
46
10
 
47
- @overload
48
- def refresh_token_on_auth_error(
49
- func: Callable[P, Coroutine[Any, Any, T]],
50
- ) -> Callable[P, Coroutine[Any, Any, T]]: ...
51
-
52
-
53
- @overload
54
- def refresh_token_on_auth_error(
55
- func: None = None, *, max_retries: int = ...
56
- ) -> Callable[[Callable[P, T]], Callable[P, T]]: ...
57
-
58
-
59
- def refresh_token_on_auth_error(func=None, max_retries=1):
60
- """Decorator that refreshes JWT token on 401/403 auth errors."""
61
-
62
- def decorator(fn):
63
- @wraps(fn)
64
- def sync_wrapper(self, *args, **kwargs):
65
- retries = 0
66
- while True:
67
- try:
68
- return fn(self, *args, **kwargs)
69
- except Exception as e:
70
- if is_auth_error(e) and retries < max_retries:
71
- retries += 1
72
- status = get_status_code(e) or "Unknown"
73
- logger.info(
74
- f"Received auth error {status}, "
75
- f"refreshing token and retrying (attempt {retries}/{max_retries})..."
76
- )
77
- self.auth_jwt = self._run_auth()
78
- self._clients = {}
79
- continue
80
- raise
81
-
82
- @wraps(fn)
83
- async def async_wrapper(self, *args, **kwargs):
84
- retries = 0
85
- while True:
86
- try:
87
- return await fn(self, *args, **kwargs)
88
- except Exception as e:
89
- if is_auth_error(e) and retries < max_retries:
90
- retries += 1
91
- status = get_status_code(e) or "Unknown"
92
- logger.info(
93
- f"Received auth error {status}, "
94
- f"refreshing token and retrying (attempt {retries}/{max_retries})..."
95
- )
96
- self.auth_jwt = self._run_auth()
97
- self._clients = {}
98
- continue
99
- raise
100
-
101
- if asyncio.iscoroutinefunction(fn):
102
- return async_wrapper
103
- return sync_wrapper
104
-
105
- if callable(func):
106
- return decorator(func)
107
- return decorator
11
+ INVALID_REFRESH_TYPE_MSG: Final[str] = (
12
+ "API key auth is required to refresh auth tokens."
13
+ )
14
+ JWT_TOKEN_CACHE_EXPIRY: int = 300 # seconds
15
+
16
+
17
+ def _run_auth(
18
+ client: httpx.Client,
19
+ auth_type: AuthType = AuthType.API_KEY,
20
+ api_key: str | None = None,
21
+ jwt: str | None = None,
22
+ ) -> str:
23
+ auth_payload: APIKeyPayload | None
24
+ if auth_type == AuthType.API_KEY:
25
+ auth_payload = APIKeyPayload(api_key=api_key)
26
+ elif auth_type == AuthType.JWT:
27
+ auth_payload = None
28
+ try:
29
+ if auth_payload:
30
+ response = client.post("/auth/login", json=auth_payload.model_dump())
31
+ response.raise_for_status()
32
+ token_data = response.json()
33
+ elif jwt:
34
+ token_data = {"access_token": jwt, "expires_in": JWT_TOKEN_CACHE_EXPIRY}
35
+ else:
36
+ raise ValueError("JWT token required for JWT authentication.")
37
+
38
+ return token_data["access_token"]
39
+ except Exception as e:
40
+ raise Exception("Failed to authenticate") from e # noqa: TRY002
41
+
42
+
43
+ class RefreshingJWT(httpx.Auth):
44
+ """Automatically (re-)inject a JWT and transparently retry exactly once when we hit a 401/403."""
45
+
46
+ RETRY_STATUSES: ClassVar[Collection[httpx.codes]] = {
47
+ httpx.codes.UNAUTHORIZED,
48
+ httpx.codes.FORBIDDEN,
49
+ }
50
+
51
+ def __init__(
52
+ self,
53
+ auth_client: httpx.Client,
54
+ auth_type: AuthType = AuthType.API_KEY,
55
+ api_key: str | None = None,
56
+ jwt: str | None = None,
57
+ ):
58
+ self.auth_type = auth_type
59
+ self.auth_client = auth_client
60
+ self.api_key = api_key
61
+ self._jwt = _run_auth(
62
+ client=auth_client,
63
+ jwt=jwt,
64
+ auth_type=auth_type,
65
+ api_key=api_key,
66
+ )
67
+
68
+ def refresh_token(self) -> None:
69
+ if self.auth_type == AuthType.JWT:
70
+ logger.error(INVALID_REFRESH_TYPE_MSG)
71
+ raise ValueError(INVALID_REFRESH_TYPE_MSG)
72
+ self._jwt = _run_auth(
73
+ client=self.auth_client,
74
+ auth_type=self.auth_type,
75
+ api_key=self.api_key,
76
+ )
77
+
78
+ def auth_flow(
79
+ self, request: httpx.Request
80
+ ) -> Generator[httpx.Request, httpx.Response, None]:
81
+ request.headers["Authorization"] = f"Bearer {self._jwt}"
82
+ response = yield request
83
+
84
+ # If it failed, refresh once and replay the request
85
+ if response.status_code in self.RETRY_STATUSES:
86
+ logger.info(
87
+ "Received %s, refreshing token and retrying …",
88
+ response.status_code,
89
+ )
90
+ self.refresh_token()
91
+ request.headers["Authorization"] = f"Bearer {self._jwt}"
92
+ yield request # second (and final) attempt, again or use a while loop
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: futurehouse-client
3
- Version: 0.3.18.dev186
3
+ Version: 0.3.19
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
@@ -8,10 +8,9 @@ Classifier: Programming Language :: Python :: 3 :: Only
8
8
  Classifier: Programming Language :: Python :: 3.11
9
9
  Classifier: Programming Language :: Python :: 3.12
10
10
  Classifier: Programming Language :: Python
11
- Requires-Python: <3.13,>=3.11
11
+ Requires-Python: <3.14,>=3.11
12
12
  Description-Content-Type: text/markdown
13
13
  Requires-Dist: cloudpickle
14
- Requires-Dist: dm-tree<0.1.9
15
14
  Requires-Dist: fhaviary
16
15
  Requires-Dist: httpx
17
16
  Requires-Dist: ldp>=0.22.0
@@ -0,0 +1,18 @@
1
+ futurehouse_client/__init__.py,sha256=BztM_ntbgmIEjzvnBWcvPhvLjM8xGDFCK0Upf3-nIn8,488
2
+ futurehouse_client/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
3
+ futurehouse_client/clients/__init__.py,sha256=-HXNj-XJ3LRO5XM6MZ709iPs29YpApss0Q2YYg1qMZw,280
4
+ futurehouse_client/clients/job_client.py,sha256=JgB5IUAyCmnhGRsYc3bgKldA-lkM1JLwHRwwUeOCdus,11944
5
+ futurehouse_client/clients/rest_client.py,sha256=3wfVz6d2KuRQUr_nms7P25yVR6aTjsRrSkqmVs55soA,54552
6
+ futurehouse_client/models/__init__.py,sha256=5x-f9AoM1hGzJBEHcHAXSt7tPeImST5oZLuMdwp0mXc,554
7
+ futurehouse_client/models/app.py,sha256=VCtg0ygd-TSrR6DtfljTBt9jnl1eBNal8UXHFdkDg88,28587
8
+ futurehouse_client/models/client.py,sha256=n4HD0KStKLm6Ek9nL9ylP-bkK10yzAaD1uIDF83Qp_A,1828
9
+ futurehouse_client/models/rest.py,sha256=lgwkMIXz0af-49BYSkKeS7SRqvN3motqnAikDN4YGTc,789
10
+ futurehouse_client/utils/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
11
+ futurehouse_client/utils/auth.py,sha256=tgWELjKfg8eWme_qdcRmc8TjQN9DVZuHHaVXZNHLchk,2960
12
+ futurehouse_client/utils/general.py,sha256=A_rtTiYW30ELGEZlWCIArO7q1nEmqi8hUlmBRYkMQ_c,767
13
+ futurehouse_client/utils/module_utils.py,sha256=aFyd-X-pDARXz9GWpn8SSViUVYdSbuy9vSkrzcVIaGI,4955
14
+ futurehouse_client/utils/monitoring.py,sha256=UjRlufe67kI3VxRHOd5fLtJmlCbVA2Wqwpd4uZhXkQM,8728
15
+ futurehouse_client-0.3.19.dist-info/METADATA,sha256=FbtQGStv4salVccxR5wtpdlGbufSqxoiCtM44qDOHJs,12731
16
+ futurehouse_client-0.3.19.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
17
+ futurehouse_client-0.3.19.dist-info/top_level.txt,sha256=TRuLUCt_qBnggdFHCX4O_BoCu1j2X43lKfIZC-ElwWY,19
18
+ futurehouse_client-0.3.19.dist-info/RECORD,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: setuptools (80.7.1)
2
+ Generator: setuptools (80.9.0)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5
 
@@ -1,17 +0,0 @@
1
- futurehouse_client/__init__.py,sha256=ddxO7JE97c6bt7LjNglZZ2Ql8bYCGI9laSFeh9MP6VU,344
2
- futurehouse_client/clients/__init__.py,sha256=tFWqwIAY5PvwfOVsCje4imjTpf6xXNRMh_UHIKVI1_0,320
3
- futurehouse_client/clients/job_client.py,sha256=uNkqQbeZw7wbA0qDWcIOwOykrosza-jev58paJZ_mbA,11150
4
- futurehouse_client/clients/rest_client.py,sha256=W9ASP1ZKYS7UL5J9b-Km77YXEiDQ9hCf4X_9PqaZZZc,47914
5
- futurehouse_client/models/__init__.py,sha256=5x-f9AoM1hGzJBEHcHAXSt7tPeImST5oZLuMdwp0mXc,554
6
- futurehouse_client/models/app.py,sha256=w_1e4F0IiC-BKeOLqYkABYo4U-Nka1S-F64S_eHB2KM,26421
7
- futurehouse_client/models/client.py,sha256=n4HD0KStKLm6Ek9nL9ylP-bkK10yzAaD1uIDF83Qp_A,1828
8
- futurehouse_client/models/rest.py,sha256=lgwkMIXz0af-49BYSkKeS7SRqvN3motqnAikDN4YGTc,789
9
- futurehouse_client/utils/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
10
- futurehouse_client/utils/auth.py,sha256=Lq9mjSGc7iuRP6fmLICCS6KjzLHN6-tJUuhYp0XXrkE,3342
11
- futurehouse_client/utils/general.py,sha256=A_rtTiYW30ELGEZlWCIArO7q1nEmqi8hUlmBRYkMQ_c,767
12
- futurehouse_client/utils/module_utils.py,sha256=aFyd-X-pDARXz9GWpn8SSViUVYdSbuy9vSkrzcVIaGI,4955
13
- futurehouse_client/utils/monitoring.py,sha256=UjRlufe67kI3VxRHOd5fLtJmlCbVA2Wqwpd4uZhXkQM,8728
14
- futurehouse_client-0.3.18.dev186.dist-info/METADATA,sha256=PvjehEQZu2ihl7kG1uDvWJVUxyYbV7J-VmAe42Ml3zo,12767
15
- futurehouse_client-0.3.18.dev186.dist-info/WHEEL,sha256=Nw36Djuh_5VDukK0H78QzOX-_FQEo6V37m3nkm96gtU,91
16
- futurehouse_client-0.3.18.dev186.dist-info/top_level.txt,sha256=TRuLUCt_qBnggdFHCX4O_BoCu1j2X43lKfIZC-ElwWY,19
17
- futurehouse_client-0.3.18.dev186.dist-info/RECORD,,