oauth-haystack 1.0.0__tar.gz → 2.0.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- oauth_haystack-2.0.0/CHANGELOG.md +25 -0
- {oauth_haystack-1.0.0 → oauth_haystack-2.0.0}/PKG-INFO +5 -4
- {oauth_haystack-1.0.0 → oauth_haystack-2.0.0}/README.md +1 -0
- {oauth_haystack-1.0.0 → oauth_haystack-2.0.0}/pyproject.toml +3 -2
- {oauth_haystack-1.0.0 → oauth_haystack-2.0.0}/src/haystack_integrations/utils/oauth/sources.py +25 -9
- oauth_haystack-2.0.0/tests/conftest.py +11 -0
- {oauth_haystack-1.0.0 → oauth_haystack-2.0.0}/tests/test_resolver.py +4 -1
- {oauth_haystack-1.0.0 → oauth_haystack-2.0.0}/tests/test_sources.py +40 -0
- {oauth_haystack-1.0.0 → oauth_haystack-2.0.0}/.gitignore +0 -0
- {oauth_haystack-1.0.0 → oauth_haystack-2.0.0}/LICENSE.txt +0 -0
- {oauth_haystack-1.0.0 → oauth_haystack-2.0.0}/pydoc/config_docusaurus.yml +0 -0
- {oauth_haystack-1.0.0 → oauth_haystack-2.0.0}/src/haystack_integrations/components/connectors/oauth/__init__.py +0 -0
- {oauth_haystack-1.0.0 → oauth_haystack-2.0.0}/src/haystack_integrations/components/connectors/oauth/resolver.py +0 -0
- {oauth_haystack-1.0.0 → oauth_haystack-2.0.0}/src/haystack_integrations/components/connectors/py.typed +0 -0
- {oauth_haystack-1.0.0 → oauth_haystack-2.0.0}/src/haystack_integrations/utils/oauth/__init__.py +0 -0
- {oauth_haystack-1.0.0 → oauth_haystack-2.0.0}/src/haystack_integrations/utils/oauth/errors.py +0 -0
- {oauth_haystack-1.0.0 → oauth_haystack-2.0.0}/src/haystack_integrations/utils/oauth/protocols.py +0 -0
- {oauth_haystack-1.0.0 → oauth_haystack-2.0.0}/src/haystack_integrations/utils/py.typed +0 -0
- {oauth_haystack-1.0.0 → oauth_haystack-2.0.0}/tests/__init__.py +0 -0
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## [integrations/oauth-v1.0.1] - 2026-08-24
|
|
4
|
+
|
|
5
|
+
### 🐛 Bug Fixes
|
|
6
|
+
|
|
7
|
+
- Fix new issues raised by ruff 0.16.0 (#3670)
|
|
8
|
+
- *(oauth)* Rebuild the OAuthRefreshTokenSource refresh lock per event loop (#3790)
|
|
9
|
+
|
|
10
|
+
### 🧪 Testing
|
|
11
|
+
|
|
12
|
+
- Trust test modules under Haystack 3.0's deserialization allowlist (#3537)
|
|
13
|
+
|
|
14
|
+
### 🧹 Chores
|
|
15
|
+
|
|
16
|
+
- Re-standardize READMEs (#3768)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
## [integrations/oauth-v1.0.0] - 2026-06-18
|
|
20
|
+
|
|
21
|
+
### 🚀 Features
|
|
22
|
+
|
|
23
|
+
- *(oauth)* Add OAuth integration (#3419)
|
|
24
|
+
|
|
25
|
+
<!-- generated by git-cliff -->
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: oauth-haystack
|
|
3
|
-
Version:
|
|
3
|
+
Version: 2.0.0
|
|
4
4
|
Summary: Haystack integration for oauth
|
|
5
5
|
Project-URL: Documentation, https://github.com/deepset-ai/haystack-core-integrations/tree/main/integrations/oauth#readme
|
|
6
6
|
Project-URL: Issues, https://github.com/deepset-ai/haystack-core-integrations/issues
|
|
@@ -12,14 +12,14 @@ Keywords: authentication,haystack,oauth
|
|
|
12
12
|
Classifier: Development Status :: 4 - Beta
|
|
13
13
|
Classifier: License :: OSI Approved :: Apache Software License
|
|
14
14
|
Classifier: Programming Language :: Python
|
|
15
|
-
Classifier: Programming Language :: Python :: 3.10
|
|
16
15
|
Classifier: Programming Language :: Python :: 3.11
|
|
17
16
|
Classifier: Programming Language :: Python :: 3.12
|
|
18
17
|
Classifier: Programming Language :: Python :: 3.13
|
|
19
18
|
Classifier: Programming Language :: Python :: 3.14
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.15
|
|
20
20
|
Classifier: Programming Language :: Python :: Implementation :: CPython
|
|
21
21
|
Classifier: Programming Language :: Python :: Implementation :: PyPy
|
|
22
|
-
Requires-Python: >=3.
|
|
22
|
+
Requires-Python: >=3.11
|
|
23
23
|
Requires-Dist: haystack-ai>=2.23.0
|
|
24
24
|
Requires-Dist: httpx>=0.28.0
|
|
25
25
|
Description-Content-Type: text/markdown
|
|
@@ -29,6 +29,7 @@ Description-Content-Type: text/markdown
|
|
|
29
29
|
[](https://pypi.org/project/oauth-haystack)
|
|
30
30
|
[](https://pypi.org/project/oauth-haystack)
|
|
31
31
|
|
|
32
|
+
- [Integration page](https://haystack.deepset.ai/integrations/oauth)
|
|
32
33
|
- [Changelog](https://github.com/deepset-ai/haystack-core-integrations/blob/main/integrations/oauth/CHANGELOG.md)
|
|
33
34
|
|
|
34
35
|
---
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
[](https://pypi.org/project/oauth-haystack)
|
|
4
4
|
[](https://pypi.org/project/oauth-haystack)
|
|
5
5
|
|
|
6
|
+
- [Integration page](https://haystack.deepset.ai/integrations/oauth)
|
|
6
7
|
- [Changelog](https://github.com/deepset-ai/haystack-core-integrations/blob/main/integrations/oauth/CHANGELOG.md)
|
|
7
8
|
|
|
8
9
|
---
|
|
@@ -7,7 +7,7 @@ name = "oauth-haystack"
|
|
|
7
7
|
dynamic = ["version"]
|
|
8
8
|
description = "Haystack integration for oauth"
|
|
9
9
|
readme = "README.md"
|
|
10
|
-
requires-python = ">=3.
|
|
10
|
+
requires-python = ">=3.11"
|
|
11
11
|
license = "Apache-2.0"
|
|
12
12
|
keywords = [
|
|
13
13
|
"haystack",
|
|
@@ -19,11 +19,11 @@ classifiers = [
|
|
|
19
19
|
"License :: OSI Approved :: Apache Software License",
|
|
20
20
|
"Development Status :: 4 - Beta",
|
|
21
21
|
"Programming Language :: Python",
|
|
22
|
-
"Programming Language :: Python :: 3.10",
|
|
23
22
|
"Programming Language :: Python :: 3.11",
|
|
24
23
|
"Programming Language :: Python :: 3.12",
|
|
25
24
|
"Programming Language :: Python :: 3.13",
|
|
26
25
|
"Programming Language :: Python :: 3.14",
|
|
26
|
+
"Programming Language :: Python :: 3.15",
|
|
27
27
|
"Programming Language :: Python :: Implementation :: CPython",
|
|
28
28
|
"Programming Language :: Python :: Implementation :: PyPy",
|
|
29
29
|
]
|
|
@@ -132,6 +132,7 @@ ignore = [
|
|
|
132
132
|
"PLR0912",
|
|
133
133
|
"PLR0913",
|
|
134
134
|
"PLR0915",
|
|
135
|
+
"PLR0917",
|
|
135
136
|
# Allow `Any` type - used legitimately for dynamic types and SDK boundaries
|
|
136
137
|
"ANN401",
|
|
137
138
|
]
|
{oauth_haystack-1.0.0 → oauth_haystack-2.0.0}/src/haystack_integrations/utils/oauth/sources.py
RENAMED
|
@@ -12,7 +12,7 @@ from typing import Any, Literal
|
|
|
12
12
|
|
|
13
13
|
import httpx
|
|
14
14
|
from haystack import default_from_dict, default_to_dict, logging
|
|
15
|
-
from haystack.utils import Secret, deserialize_callable,
|
|
15
|
+
from haystack.utils import Secret, deserialize_callable, serialize_callable
|
|
16
16
|
|
|
17
17
|
from .errors import OAuthConfigError, TokenRefreshError
|
|
18
18
|
|
|
@@ -121,7 +121,10 @@ class OAuthRefreshTokenSource:
|
|
|
121
121
|
|
|
122
122
|
# Runtime state — never serialized.
|
|
123
123
|
self._sync_lock = Lock()
|
|
124
|
+
# `_async_lock_guard` only guards the creation of `_async_lock`; see `_get_async_lock`.
|
|
125
|
+
self._async_lock_guard = Lock()
|
|
124
126
|
self._async_lock: asyncio.Lock | None = None
|
|
127
|
+
self._async_lock_loop: asyncio.AbstractEventLoop | None = None
|
|
125
128
|
self._cached_token: str | None = None
|
|
126
129
|
self._expires_at: float = 0.0
|
|
127
130
|
self._current_refresh_token: str | None = None
|
|
@@ -178,13 +181,29 @@ class OAuthRefreshTokenSource:
|
|
|
178
181
|
)
|
|
179
182
|
return self._handle_response(response)
|
|
180
183
|
|
|
184
|
+
def _get_async_lock(self) -> asyncio.Lock:
|
|
185
|
+
"""
|
|
186
|
+
Return the refresh lock for the running event loop, building it if there isn't one yet.
|
|
187
|
+
|
|
188
|
+
The lock cannot be created in `__init__`, which is synchronous and may run with no loop. It also cannot be
|
|
189
|
+
created once and kept for the lifetime of the source: an `asyncio.Lock` binds itself to the loop that first
|
|
190
|
+
awaits it under contention, and raises `RuntimeError` when awaited from any other one. A source routinely
|
|
191
|
+
outlives the loop that first used it — one `asyncio.run` per request is a common deployment — so the lock is
|
|
192
|
+
rebuilt whenever the running loop changes.
|
|
193
|
+
|
|
194
|
+
`_async_lock_guard` is held for the check and the assignment only, never across an await, so that two threads
|
|
195
|
+
driving separate event loops cannot each install a lock and lose mutual exclusion between them.
|
|
196
|
+
"""
|
|
197
|
+
loop = asyncio.get_running_loop()
|
|
198
|
+
with self._async_lock_guard:
|
|
199
|
+
if self._async_lock is None or self._async_lock_loop is not loop:
|
|
200
|
+
self._async_lock = asyncio.Lock()
|
|
201
|
+
self._async_lock_loop = loop
|
|
202
|
+
return self._async_lock
|
|
203
|
+
|
|
181
204
|
async def resolve_async(self) -> str:
|
|
182
205
|
"""Asynchronous counterpart of `resolve`. Use a single instance in either sync or async mode, not both."""
|
|
183
|
-
|
|
184
|
-
# is sync and may run without one.
|
|
185
|
-
if self._async_lock is None:
|
|
186
|
-
self._async_lock = asyncio.Lock()
|
|
187
|
-
async with self._async_lock:
|
|
206
|
+
async with self._get_async_lock():
|
|
188
207
|
cached = self._cached_if_valid()
|
|
189
208
|
if cached is not None:
|
|
190
209
|
return cached
|
|
@@ -216,7 +235,6 @@ class OAuthRefreshTokenSource:
|
|
|
216
235
|
def from_dict(cls, data: dict[str, Any]) -> "OAuthRefreshTokenSource":
|
|
217
236
|
"""Deserialize the source from a dictionary."""
|
|
218
237
|
init_params = data["init_parameters"]
|
|
219
|
-
deserialize_secrets_inplace(init_params, keys=["refresh_token", "client_secret"])
|
|
220
238
|
if init_params.get("on_rotate"):
|
|
221
239
|
init_params["on_rotate"] = deserialize_callable(init_params["on_rotate"])
|
|
222
240
|
return default_from_dict(cls, data)
|
|
@@ -431,7 +449,6 @@ class OAuthTokenExchangeSource:
|
|
|
431
449
|
@classmethod
|
|
432
450
|
def from_dict(cls, data: dict[str, Any]) -> "OAuthTokenExchangeSource":
|
|
433
451
|
"""Deserialize the source from a dictionary."""
|
|
434
|
-
deserialize_secrets_inplace(data["init_parameters"], keys=["client_secret"])
|
|
435
452
|
return default_from_dict(cls, data)
|
|
436
453
|
|
|
437
454
|
|
|
@@ -482,5 +499,4 @@ class OAuthStaticTokenSource:
|
|
|
482
499
|
@classmethod
|
|
483
500
|
def from_dict(cls, data: dict[str, Any]) -> "OAuthStaticTokenSource":
|
|
484
501
|
"""Deserialize the source from a dictionary."""
|
|
485
|
-
deserialize_secrets_inplace(data["init_parameters"], keys=["token"])
|
|
486
502
|
return default_from_dict(cls, data)
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
@pytest.fixture(autouse=True)
|
|
5
|
+
def allow_deserialization_of_test_modules(monkeypatch):
|
|
6
|
+
"""
|
|
7
|
+
haystack-ai >= 3.0 refuses to deserialize classes and callables from modules outside its
|
|
8
|
+
trusted-module allowlist. Tools and callbacks defined in the test modules live outside that
|
|
9
|
+
allowlist, so trust them explicitly; haystack-ai 2.x ignores this environment variable.
|
|
10
|
+
"""
|
|
11
|
+
monkeypatch.setenv("HAYSTACK_DESERIALIZATION_ALLOWLIST", "tests,test_*")
|
|
@@ -7,6 +7,7 @@ from typing import Any, Literal
|
|
|
7
7
|
|
|
8
8
|
import pytest
|
|
9
9
|
from haystack import Pipeline
|
|
10
|
+
from haystack.core.errors import DeserializationError
|
|
10
11
|
from haystack.utils import Secret
|
|
11
12
|
|
|
12
13
|
from haystack_integrations.components.connectors.oauth import OAuthTokenResolver
|
|
@@ -183,7 +184,9 @@ class TestOAuthTokenResolverSerialization:
|
|
|
183
184
|
"type": "haystack_integrations.components.connectors.oauth.resolver.OAuthTokenResolver",
|
|
184
185
|
"init_parameters": {"token_source": {"type": "some.unknown.Source", "init_parameters": {}}},
|
|
185
186
|
}
|
|
186
|
-
|
|
187
|
+
# haystack-ai 2.x fails to import the unknown module; haystack-ai >= 3.0 refuses it upfront
|
|
188
|
+
# because it is not on the trusted-module allowlist
|
|
189
|
+
with pytest.raises((ImportError, DeserializationError)):
|
|
187
190
|
OAuthTokenResolver.from_dict(data)
|
|
188
191
|
|
|
189
192
|
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
#
|
|
3
3
|
# SPDX-License-Identifier: Apache-2.0
|
|
4
4
|
|
|
5
|
+
import asyncio
|
|
5
6
|
from unittest.mock import patch
|
|
6
7
|
|
|
7
8
|
import httpx
|
|
@@ -148,6 +149,45 @@ class TestOAuthRefreshTokenSource:
|
|
|
148
149
|
assert token == "acc-async"
|
|
149
150
|
assert mp.call_count == 1
|
|
150
151
|
|
|
152
|
+
@pytest.mark.asyncio
|
|
153
|
+
async def test_concurrent_resolve_async_refreshes_once(self):
|
|
154
|
+
"""The refresh lock exists to collapse a burst of concurrent callers into a single network refresh."""
|
|
155
|
+
source = self._source()
|
|
156
|
+
with patch("httpx.AsyncClient.post", side_effect=self._slow_post("acc-async")) as mp:
|
|
157
|
+
tokens = await asyncio.gather(*(source.resolve_async() for _ in range(4)))
|
|
158
|
+
assert tokens == ["acc-async"] * 4
|
|
159
|
+
assert mp.call_count == 1
|
|
160
|
+
|
|
161
|
+
def test_resolve_async_works_in_a_second_event_loop(self):
|
|
162
|
+
"""
|
|
163
|
+
A source outlives the loop that first used it: one `asyncio.run` per request is a common deployment.
|
|
164
|
+
|
|
165
|
+
An `asyncio.Lock` binds to the loop that first awaits it *under contention* and rejects every other one, so
|
|
166
|
+
a lock built once and cached on the instance made the second loop's first contended refresh raise
|
|
167
|
+
`RuntimeError: ... is bound to a different event loop`. Both halves of this test must contend for the lock,
|
|
168
|
+
otherwise the lock is never bound and the bug does not reproduce.
|
|
169
|
+
"""
|
|
170
|
+
source = self._source()
|
|
171
|
+
|
|
172
|
+
async def two_concurrent_callers() -> list[str]:
|
|
173
|
+
# expires_in=0 keeps the cache from serving the second round, so both rounds really refresh.
|
|
174
|
+
with patch("httpx.AsyncClient.post", side_effect=self._slow_post("acc-async", expires_in=0)):
|
|
175
|
+
return list(await asyncio.gather(source.resolve_async(), source.resolve_async()))
|
|
176
|
+
|
|
177
|
+
assert asyncio.run(two_concurrent_callers()) == ["acc-async"] * 2
|
|
178
|
+
# Same source, brand new event loop.
|
|
179
|
+
assert asyncio.run(two_concurrent_callers()) == ["acc-async"] * 2
|
|
180
|
+
|
|
181
|
+
@staticmethod
|
|
182
|
+
def _slow_post(access_token: str, expires_in: int = 3600):
|
|
183
|
+
"""A patched `post` that yields to the loop, so a second caller reaches the lock while the first holds it."""
|
|
184
|
+
|
|
185
|
+
async def _post(*_args, **_kwargs):
|
|
186
|
+
await asyncio.sleep(0)
|
|
187
|
+
return _json(access_token=access_token, expires_in=expires_in)
|
|
188
|
+
|
|
189
|
+
return _post
|
|
190
|
+
|
|
151
191
|
|
|
152
192
|
class TestOAuthTokenExchangeSource:
|
|
153
193
|
def _source(self, **kwargs):
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{oauth_haystack-1.0.0 → oauth_haystack-2.0.0}/src/haystack_integrations/utils/oauth/__init__.py
RENAMED
|
File without changes
|
{oauth_haystack-1.0.0 → oauth_haystack-2.0.0}/src/haystack_integrations/utils/oauth/errors.py
RENAMED
|
File without changes
|
{oauth_haystack-1.0.0 → oauth_haystack-2.0.0}/src/haystack_integrations/utils/oauth/protocols.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|