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.
Files changed (19) hide show
  1. oauth_haystack-2.0.0/CHANGELOG.md +25 -0
  2. {oauth_haystack-1.0.0 → oauth_haystack-2.0.0}/PKG-INFO +5 -4
  3. {oauth_haystack-1.0.0 → oauth_haystack-2.0.0}/README.md +1 -0
  4. {oauth_haystack-1.0.0 → oauth_haystack-2.0.0}/pyproject.toml +3 -2
  5. {oauth_haystack-1.0.0 → oauth_haystack-2.0.0}/src/haystack_integrations/utils/oauth/sources.py +25 -9
  6. oauth_haystack-2.0.0/tests/conftest.py +11 -0
  7. {oauth_haystack-1.0.0 → oauth_haystack-2.0.0}/tests/test_resolver.py +4 -1
  8. {oauth_haystack-1.0.0 → oauth_haystack-2.0.0}/tests/test_sources.py +40 -0
  9. {oauth_haystack-1.0.0 → oauth_haystack-2.0.0}/.gitignore +0 -0
  10. {oauth_haystack-1.0.0 → oauth_haystack-2.0.0}/LICENSE.txt +0 -0
  11. {oauth_haystack-1.0.0 → oauth_haystack-2.0.0}/pydoc/config_docusaurus.yml +0 -0
  12. {oauth_haystack-1.0.0 → oauth_haystack-2.0.0}/src/haystack_integrations/components/connectors/oauth/__init__.py +0 -0
  13. {oauth_haystack-1.0.0 → oauth_haystack-2.0.0}/src/haystack_integrations/components/connectors/oauth/resolver.py +0 -0
  14. {oauth_haystack-1.0.0 → oauth_haystack-2.0.0}/src/haystack_integrations/components/connectors/py.typed +0 -0
  15. {oauth_haystack-1.0.0 → oauth_haystack-2.0.0}/src/haystack_integrations/utils/oauth/__init__.py +0 -0
  16. {oauth_haystack-1.0.0 → oauth_haystack-2.0.0}/src/haystack_integrations/utils/oauth/errors.py +0 -0
  17. {oauth_haystack-1.0.0 → oauth_haystack-2.0.0}/src/haystack_integrations/utils/oauth/protocols.py +0 -0
  18. {oauth_haystack-1.0.0 → oauth_haystack-2.0.0}/src/haystack_integrations/utils/py.typed +0 -0
  19. {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.4
1
+ Metadata-Version: 2.5
2
2
  Name: oauth-haystack
3
- Version: 1.0.0
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.10
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
  [![PyPI - Version](https://img.shields.io/pypi/v/oauth-haystack.svg)](https://pypi.org/project/oauth-haystack)
30
30
  [![PyPI - Python Version](https://img.shields.io/pypi/pyversions/oauth-haystack.svg)](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
  [![PyPI - Version](https://img.shields.io/pypi/v/oauth-haystack.svg)](https://pypi.org/project/oauth-haystack)
4
4
  [![PyPI - Python Version](https://img.shields.io/pypi/pyversions/oauth-haystack.svg)](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"
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
  ]
@@ -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, deserialize_secrets_inplace, serialize_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
- # Create the asyncio.Lock lazily (not in __init__): it must bind to the running event loop, but __init__
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
- with pytest.raises(ImportError):
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):