gumloop 0.3.4__tar.gz → 0.3.5__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 (82) hide show
  1. {gumloop-0.3.4 → gumloop-0.3.5}/PKG-INFO +1 -1
  2. {gumloop-0.3.4 → gumloop-0.3.5}/pyproject.toml +1 -1
  3. {gumloop-0.3.4 → gumloop-0.3.5}/src/gumloop/__init__.py +1 -1
  4. gumloop-0.3.5/src/gumloop/_auth.py +35 -0
  5. {gumloop-0.3.4 → gumloop-0.3.5}/src/gumloop/_client.py +3 -2
  6. {gumloop-0.3.4 → gumloop-0.3.5}/src/gumloop/_http.py +9 -18
  7. {gumloop-0.3.4 → gumloop-0.3.5}/tests/sdk/test_client.py +69 -0
  8. {gumloop-0.3.4 → gumloop-0.3.5}/uv.lock +1 -1
  9. {gumloop-0.3.4 → gumloop-0.3.5}/.env.example +0 -0
  10. {gumloop-0.3.4 → gumloop-0.3.5}/.gitignore +0 -0
  11. {gumloop-0.3.4 → gumloop-0.3.5}/.pre-commit-config.yaml +0 -0
  12. {gumloop-0.3.4 → gumloop-0.3.5}/.python-version +0 -0
  13. {gumloop-0.3.4 → gumloop-0.3.5}/LICENSE +0 -0
  14. {gumloop-0.3.4 → gumloop-0.3.5}/README.md +0 -0
  15. {gumloop-0.3.4 → gumloop-0.3.5}/scripts/check +0 -0
  16. {gumloop-0.3.4 → gumloop-0.3.5}/scripts/fmt +0 -0
  17. {gumloop-0.3.4 → gumloop-0.3.5}/scripts/sdk_examples.py +0 -0
  18. {gumloop-0.3.4 → gumloop-0.3.5}/src/gumloop/cli/__init__.py +0 -0
  19. {gumloop-0.3.4 → gumloop-0.3.5}/src/gumloop/cli/commands/__init__.py +0 -0
  20. {gumloop-0.3.4 → gumloop-0.3.5}/src/gumloop/cli/commands/_args_input.py +0 -0
  21. {gumloop-0.3.4 → gumloop-0.3.5}/src/gumloop/cli/commands/_downloads.py +0 -0
  22. {gumloop-0.3.4 → gumloop-0.3.5}/src/gumloop/cli/commands/agents.py +0 -0
  23. {gumloop-0.3.4 → gumloop-0.3.5}/src/gumloop/cli/commands/artifacts.py +0 -0
  24. {gumloop-0.3.4 → gumloop-0.3.5}/src/gumloop/cli/commands/auth.py +0 -0
  25. {gumloop-0.3.4 → gumloop-0.3.5}/src/gumloop/cli/commands/brain.py +0 -0
  26. {gumloop-0.3.4 → gumloop-0.3.5}/src/gumloop/cli/commands/chat.py +0 -0
  27. {gumloop-0.3.4 → gumloop-0.3.5}/src/gumloop/cli/commands/mcp.py +0 -0
  28. {gumloop-0.3.4 → gumloop-0.3.5}/src/gumloop/cli/commands/sessions.py +0 -0
  29. {gumloop-0.3.4 → gumloop-0.3.5}/src/gumloop/cli/commands/skills.py +0 -0
  30. {gumloop-0.3.4 → gumloop-0.3.5}/src/gumloop/cli/console.py +0 -0
  31. {gumloop-0.3.4 → gumloop-0.3.5}/src/gumloop/cli/context.py +0 -0
  32. {gumloop-0.3.4 → gumloop-0.3.5}/src/gumloop/cli/credentials.py +0 -0
  33. {gumloop-0.3.4 → gumloop-0.3.5}/src/gumloop/cli/errors.py +0 -0
  34. {gumloop-0.3.4 → gumloop-0.3.5}/src/gumloop/cli/main.py +0 -0
  35. {gumloop-0.3.4 → gumloop-0.3.5}/src/gumloop/cli/oauth.py +0 -0
  36. {gumloop-0.3.4 → gumloop-0.3.5}/src/gumloop/errors.py +0 -0
  37. {gumloop-0.3.4 → gumloop-0.3.5}/src/gumloop/oauth.py +0 -0
  38. {gumloop-0.3.4 → gumloop-0.3.5}/src/gumloop/py.typed +0 -0
  39. {gumloop-0.3.4 → gumloop-0.3.5}/src/gumloop/resources/__init__.py +0 -0
  40. {gumloop-0.3.4 → gumloop-0.3.5}/src/gumloop/resources/agents.py +0 -0
  41. {gumloop-0.3.4 → gumloop-0.3.5}/src/gumloop/resources/artifacts.py +0 -0
  42. {gumloop-0.3.4 → gumloop-0.3.5}/src/gumloop/resources/brain.py +0 -0
  43. {gumloop-0.3.4 → gumloop-0.3.5}/src/gumloop/resources/chat.py +0 -0
  44. {gumloop-0.3.4 → gumloop-0.3.5}/src/gumloop/resources/mcp.py +0 -0
  45. {gumloop-0.3.4 → gumloop-0.3.5}/src/gumloop/resources/sessions.py +0 -0
  46. {gumloop-0.3.4 → gumloop-0.3.5}/src/gumloop/resources/skills.py +0 -0
  47. {gumloop-0.3.4 → gumloop-0.3.5}/src/gumloop/resources/teams.py +0 -0
  48. {gumloop-0.3.4 → gumloop-0.3.5}/src/gumloop/spec/__init__.py +0 -0
  49. {gumloop-0.3.4 → gumloop-0.3.5}/src/gumloop/spec/_compat.py +0 -0
  50. {gumloop-0.3.4 → gumloop-0.3.5}/src/gumloop/spec/_extensions.py +0 -0
  51. {gumloop-0.3.4 → gumloop-0.3.5}/src/gumloop/types.py +0 -0
  52. {gumloop-0.3.4 → gumloop-0.3.5}/tests/__init__.py +0 -0
  53. {gumloop-0.3.4 → gumloop-0.3.5}/tests/cli/__init__.py +0 -0
  54. {gumloop-0.3.4 → gumloop-0.3.5}/tests/cli/conftest.py +0 -0
  55. {gumloop-0.3.4 → gumloop-0.3.5}/tests/cli/test_agents.py +0 -0
  56. {gumloop-0.3.4 → gumloop-0.3.5}/tests/cli/test_args_input.py +0 -0
  57. {gumloop-0.3.4 → gumloop-0.3.5}/tests/cli/test_artifacts.py +0 -0
  58. {gumloop-0.3.4 → gumloop-0.3.5}/tests/cli/test_brain.py +0 -0
  59. {gumloop-0.3.4 → gumloop-0.3.5}/tests/cli/test_chat.py +0 -0
  60. {gumloop-0.3.4 → gumloop-0.3.5}/tests/cli/test_context.py +0 -0
  61. {gumloop-0.3.4 → gumloop-0.3.5}/tests/cli/test_credentials.py +0 -0
  62. {gumloop-0.3.4 → gumloop-0.3.5}/tests/cli/test_login.py +0 -0
  63. {gumloop-0.3.4 → gumloop-0.3.5}/tests/cli/test_main.py +0 -0
  64. {gumloop-0.3.4 → gumloop-0.3.5}/tests/cli/test_mcp.py +0 -0
  65. {gumloop-0.3.4 → gumloop-0.3.5}/tests/cli/test_sessions.py +0 -0
  66. {gumloop-0.3.4 → gumloop-0.3.5}/tests/cli/test_skills.py +0 -0
  67. {gumloop-0.3.4 → gumloop-0.3.5}/tests/conftest.py +0 -0
  68. {gumloop-0.3.4 → gumloop-0.3.5}/tests/integration/conftest.py +0 -0
  69. {gumloop-0.3.4 → gumloop-0.3.5}/tests/integration/test_live.py +0 -0
  70. {gumloop-0.3.4 → gumloop-0.3.5}/tests/sdk/__init__.py +0 -0
  71. {gumloop-0.3.4 → gumloop-0.3.5}/tests/sdk/conftest.py +0 -0
  72. {gumloop-0.3.4 → gumloop-0.3.5}/tests/sdk/helpers.py +0 -0
  73. {gumloop-0.3.4 → gumloop-0.3.5}/tests/sdk/test_agents.py +0 -0
  74. {gumloop-0.3.4 → gumloop-0.3.5}/tests/sdk/test_artifacts.py +0 -0
  75. {gumloop-0.3.4 → gumloop-0.3.5}/tests/sdk/test_brain.py +0 -0
  76. {gumloop-0.3.4 → gumloop-0.3.5}/tests/sdk/test_chat.py +0 -0
  77. {gumloop-0.3.4 → gumloop-0.3.5}/tests/sdk/test_mcp.py +0 -0
  78. {gumloop-0.3.4 → gumloop-0.3.5}/tests/sdk/test_oauth.py +0 -0
  79. {gumloop-0.3.4 → gumloop-0.3.5}/tests/sdk/test_sessions.py +0 -0
  80. {gumloop-0.3.4 → gumloop-0.3.5}/tests/sdk/test_skills.py +0 -0
  81. {gumloop-0.3.4 → gumloop-0.3.5}/tests/sdk/test_teams.py +0 -0
  82. {gumloop-0.3.4 → gumloop-0.3.5}/tests/test_client.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gumloop
3
- Version: 0.3.4
3
+ Version: 0.3.5
4
4
  Summary: Python client for the Gumloop API
5
5
  Project-URL: Homepage, https://github.com/gumloop/gumloop-py
6
6
  Project-URL: Bug Tracker, https://github.com/gumloop/gumloop-py/issues
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "gumloop"
7
- version = "0.3.4"
7
+ version = "0.3.5"
8
8
  description = "Python client for the Gumloop API"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -6,7 +6,7 @@ from gumloop.errors import AuthenticationError
6
6
  from gumloop.errors import GumloopError
7
7
  from gumloop.oauth import OAuth
8
8
 
9
- __version__ = "0.3.3"
9
+ __version__ = "0.3.5"
10
10
  __all__ = [
11
11
  "APIStatusError",
12
12
  "AsyncGumloop",
@@ -0,0 +1,35 @@
1
+ """Credential resolution shared by the sync and async transports."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+
7
+ from gumloop.errors import AuthenticationError
8
+
9
+
10
+ class EnvToken(str):
11
+ """Access token sourced from ``GUMLOOP_ACCESS_TOKEN``.
12
+
13
+ Stays live: ``current()`` returns the env var's present value, so
14
+ platform-rotated tokens (e.g. per-call sandbox credentials) are picked up
15
+ on every request. Explicit tokens are plain strings and never change.
16
+ """
17
+
18
+ @classmethod
19
+ def from_env(cls) -> EnvToken | None:
20
+ token = os.environ.get("GUMLOOP_ACCESS_TOKEN")
21
+ return cls(token) if token else None
22
+
23
+ def current(self) -> str:
24
+ return os.environ.get("GUMLOOP_ACCESS_TOKEN") or str(self)
25
+
26
+
27
+ def auth_headers(access_token: str | None, user_id: str | None) -> dict[str, str]:
28
+ if isinstance(access_token, EnvToken):
29
+ access_token = access_token.current()
30
+ if not access_token:
31
+ raise AuthenticationError("access_token, api_key, or GUMLOOP_ACCESS_TOKEN is required")
32
+ headers = {"Authorization": f"Bearer {access_token}"}
33
+ if user_id:
34
+ headers["x-auth-key"] = user_id
35
+ return headers
@@ -7,6 +7,7 @@ from typing import Any
7
7
 
8
8
  import httpx
9
9
 
10
+ from gumloop._auth import EnvToken
10
11
  from gumloop._http import AsyncHttpClient
11
12
  from gumloop._http import HttpClient
12
13
  from gumloop.oauth import OAuth
@@ -59,7 +60,7 @@ class Gumloop:
59
60
  stream_timeout: float | None = DEFAULT_STREAM_TIMEOUT,
60
61
  ) -> None:
61
62
  self.api_key = api_key
62
- self.access_token = access_token or api_key or os.environ.get("GUMLOOP_ACCESS_TOKEN")
63
+ self.access_token = access_token or api_key or EnvToken.from_env()
63
64
  # Personal Gumloop API keys are user-scoped; the backend uses the
64
65
  # x-auth-key header to look up the owner's stored secret. OAuth
65
66
  # callers omit it because the bearer token already identifies them.
@@ -115,7 +116,7 @@ class AsyncGumloop:
115
116
  stream_timeout: float | None = DEFAULT_STREAM_TIMEOUT,
116
117
  ) -> None:
117
118
  self.api_key = api_key
118
- self.access_token = access_token or api_key or os.environ.get("GUMLOOP_ACCESS_TOKEN")
119
+ self.access_token = access_token or api_key or EnvToken.from_env()
119
120
  self.user_id = user_id or os.environ.get("GUMLOOP_USER_ID")
120
121
  # Defaults to production; GUMLOOP_BASE_URL is an optional override.
121
122
  self.base_url = (base_url or os.environ.get("GUMLOOP_BASE_URL") or DEFAULT_BASE_URL).rstrip("/")
@@ -13,7 +13,7 @@ from httpx_sse import ServerSentEvent
13
13
  from pydantic import BaseModel
14
14
  from pydantic import ValidationError
15
15
 
16
- from gumloop.errors import AuthenticationError
16
+ from gumloop._auth import auth_headers
17
17
  from gumloop.errors import to_api_error
18
18
  from gumloop.types import StreamEvent
19
19
 
@@ -23,15 +23,6 @@ _DONE_SENTINEL = "[DONE]"
23
23
  _T = TypeVar("_T", bound=BaseModel)
24
24
 
25
25
 
26
- def _auth_headers(access_token: str | None, user_id: str | None) -> dict[str, str]:
27
- if not access_token:
28
- raise AuthenticationError("access_token, api_key, or GUMLOOP_ACCESS_TOKEN is required")
29
- headers = {"Authorization": f"Bearer {access_token}"}
30
- if user_id:
31
- headers["x-auth-key"] = user_id
32
- return headers
33
-
34
-
35
26
  def _omit_none_params(params: Mapping[str, Any] | None) -> dict[str, Any] | None:
36
27
  # Backend treats absent ``?foo`` and ``?foo=`` as different signals
37
28
  # ("not provided" vs "empty string"); drop None values so the wire URL
@@ -120,7 +111,7 @@ class HttpClient:
120
111
  # Endpoints whose streaming variant lives on the stream host (e.g. chat
121
112
  # completions) must accept their unary counterparts at the same host —
122
113
  # the api host has no handler for them.
123
- headers = _auth_headers(self.access_token, self.user_id)
114
+ headers = auth_headers(self.access_token, self.user_id)
124
115
  headers["Content-Type"] = "application/json"
125
116
  response = self._client.post(
126
117
  f"{self._stream_base_url}/{path.lstrip('/')}",
@@ -140,7 +131,7 @@ class HttpClient:
140
131
  json: Any = None,
141
132
  params: Mapping[str, Any] | None = None,
142
133
  ) -> Iterator[StreamEvent]:
143
- headers = {**_auth_headers(self.access_token, self.user_id), "Accept": "text/event-stream"}
134
+ headers = {**auth_headers(self.access_token, self.user_id), "Accept": "text/event-stream"}
144
135
  with self._client.stream(
145
136
  method,
146
137
  f"{self._stream_base_url}/{path.lstrip('/')}",
@@ -166,7 +157,7 @@ class HttpClient:
166
157
  ) -> Iterator[_T]:
167
158
  # Skips the StreamEvent envelope and honors OpenRouter's `data: [DONE]`
168
159
  # terminator. Unparseable events (keep-alives, comments) are skipped.
169
- headers = {**_auth_headers(self.access_token, self.user_id), "Accept": "text/event-stream"}
160
+ headers = {**auth_headers(self.access_token, self.user_id), "Accept": "text/event-stream"}
170
161
  with self._client.stream(
171
162
  method,
172
163
  f"{self._stream_base_url}/{path.lstrip('/')}",
@@ -194,7 +185,7 @@ class HttpClient:
194
185
  def _request(self, method: str, path: str, **kwargs: Any) -> Any:
195
186
  # Headers are rebuilt per request so ``access_token`` / ``user_id``
196
187
  # can be rotated on a live client without reconstructing it.
197
- headers = _auth_headers(self.access_token, self.user_id)
188
+ headers = auth_headers(self.access_token, self.user_id)
198
189
  if not kwargs.get("files"):
199
190
  headers["Content-Type"] = "application/json"
200
191
  response = self._client.request(method, path, headers=headers, **kwargs)
@@ -261,7 +252,7 @@ class AsyncHttpClient:
261
252
  return await self._request("DELETE", path)
262
253
 
263
254
  async def post_to_stream_host(self, path: str, *, json: Any = None) -> Any:
264
- headers = _auth_headers(self.access_token, self.user_id)
255
+ headers = auth_headers(self.access_token, self.user_id)
265
256
  headers["Content-Type"] = "application/json"
266
257
  response = await self._client.post(
267
258
  f"{self._stream_base_url}/{path.lstrip('/')}",
@@ -281,7 +272,7 @@ class AsyncHttpClient:
281
272
  json: Any = None,
282
273
  params: Mapping[str, Any] | None = None,
283
274
  ) -> AsyncIterator[StreamEvent]:
284
- headers = {**_auth_headers(self.access_token, self.user_id), "Accept": "text/event-stream"}
275
+ headers = {**auth_headers(self.access_token, self.user_id), "Accept": "text/event-stream"}
285
276
  async with self._client.stream(
286
277
  method,
287
278
  f"{self._stream_base_url}/{path.lstrip('/')}",
@@ -305,7 +296,7 @@ class AsyncHttpClient:
305
296
  json: Any = None,
306
297
  params: Mapping[str, Any] | None = None,
307
298
  ) -> AsyncIterator[_T]:
308
- headers = {**_auth_headers(self.access_token, self.user_id), "Accept": "text/event-stream"}
299
+ headers = {**auth_headers(self.access_token, self.user_id), "Accept": "text/event-stream"}
309
300
  async with self._client.stream(
310
301
  method,
311
302
  f"{self._stream_base_url}/{path.lstrip('/')}",
@@ -331,7 +322,7 @@ class AsyncHttpClient:
331
322
  continue
332
323
 
333
324
  async def _request(self, method: str, path: str, **kwargs: Any) -> Any:
334
- headers = _auth_headers(self.access_token, self.user_id)
325
+ headers = auth_headers(self.access_token, self.user_id)
335
326
  if not kwargs.get("files"):
336
327
  headers["Content-Type"] = "application/json"
337
328
  response = await self._client.request(method, path, headers=headers, **kwargs)
@@ -60,6 +60,75 @@ def test_missing_credential_raises_clear_auth_error(monkeypatch: pytest.MonkeyPa
60
60
  Gumloop().models.list()
61
61
 
62
62
 
63
+ @respx.mock
64
+ def test_env_sourced_token_follows_env_rotation(monkeypatch: pytest.MonkeyPatch) -> None:
65
+ """A client built from GUMLOOP_ACCESS_TOKEN must send the CURRENT env
66
+ value per request: platforms (e.g. the agent sandbox) rotate the token
67
+ between requests on a live client."""
68
+ monkeypatch.setenv("GUMLOOP_ACCESS_TOKEN", "tok-1")
69
+ route = respx.get(f"{API_BASE}/models").mock(return_value=httpx.Response(200, json={"model_groups": []}))
70
+ client = Gumloop()
71
+
72
+ client.models.list()
73
+ monkeypatch.setenv("GUMLOOP_ACCESS_TOKEN", "tok-2")
74
+ client.models.list()
75
+
76
+ assert auth_header(route.calls[0].request) == "Bearer tok-1"
77
+ assert auth_header(route.calls[1].request) == "Bearer tok-2"
78
+
79
+
80
+ @respx.mock
81
+ def test_env_sourced_token_falls_back_to_snapshot_when_env_cleared(monkeypatch: pytest.MonkeyPatch) -> None:
82
+ monkeypatch.setenv("GUMLOOP_ACCESS_TOKEN", "tok-1")
83
+ route = respx.get(f"{API_BASE}/models").mock(return_value=httpx.Response(200, json={"model_groups": []}))
84
+ client = Gumloop()
85
+
86
+ monkeypatch.delenv("GUMLOOP_ACCESS_TOKEN")
87
+ client.models.list()
88
+
89
+ assert auth_header(route.calls[0].request) == "Bearer tok-1"
90
+
91
+
92
+ @pytest.mark.parametrize(
93
+ "kwargs, expected_header",
94
+ [
95
+ ({"access_token": "explicit-tok"}, "Bearer explicit-tok"),
96
+ ({"api_key": "gum_xxx"}, "Bearer gum_xxx"),
97
+ ],
98
+ )
99
+ @respx.mock
100
+ def test_explicit_credential_ignores_env_rotation(
101
+ monkeypatch: pytest.MonkeyPatch, kwargs: dict[str, str], expected_header: str
102
+ ) -> None:
103
+ """Explicitly passed credentials are immutable — a env var appearing or
104
+ rotating later must never hijack the client's identity."""
105
+ monkeypatch.setenv("GUMLOOP_ACCESS_TOKEN", "env-tok")
106
+ route = respx.get(f"{API_BASE}/models").mock(return_value=httpx.Response(200, json={"model_groups": []}))
107
+ client = Gumloop(**kwargs)
108
+
109
+ monkeypatch.setenv("GUMLOOP_ACCESS_TOKEN", "env-tok-2")
110
+ client.models.list()
111
+
112
+ assert auth_header(route.calls[0].request) == expected_header
113
+
114
+
115
+ @respx.mock
116
+ def test_async_env_sourced_token_follows_env_rotation(monkeypatch: pytest.MonkeyPatch) -> None:
117
+ monkeypatch.setenv("GUMLOOP_ACCESS_TOKEN", "tok-1")
118
+ route = respx.get(f"{API_BASE}/models").mock(return_value=httpx.Response(200, json={"model_groups": []}))
119
+
120
+ async def run() -> None:
121
+ async with AsyncGumloop() as client:
122
+ await client.models.list()
123
+ monkeypatch.setenv("GUMLOOP_ACCESS_TOKEN", "tok-2")
124
+ await client.models.list()
125
+
126
+ asyncio.run(run())
127
+
128
+ assert auth_header(route.calls[0].request) == "Bearer tok-1"
129
+ assert auth_header(route.calls[1].request) == "Bearer tok-2"
130
+
131
+
63
132
  @respx.mock
64
133
  def test_non_success_response_raises_gumloop_status_error(client: Gumloop) -> None:
65
134
  error = {
@@ -355,7 +355,7 @@ wheels = [
355
355
 
356
356
  [[package]]
357
357
  name = "gumloop"
358
- version = "0.3.4"
358
+ version = "0.3.5"
359
359
  source = { editable = "." }
360
360
  dependencies = [
361
361
  { name = "click" },
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes