python3-commons 0.22.6__tar.gz → 0.22.8__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. {python3_commons-0.22.6 → python3_commons-0.22.8}/PKG-INFO +1 -1
  2. {python3_commons-0.22.6 → python3_commons-0.22.8}/src/python3_commons/auth.py +50 -30
  3. {python3_commons-0.22.6 → python3_commons-0.22.8}/src/python3_commons/db/__init__.py +1 -1
  4. {python3_commons-0.22.6 → python3_commons-0.22.8}/src/python3_commons.egg-info/PKG-INFO +1 -1
  5. {python3_commons-0.22.6 → python3_commons-0.22.8}/src/python3_commons.egg-info/SOURCES.txt +1 -0
  6. {python3_commons-0.22.6 → python3_commons-0.22.8}/src/python3_commons.egg-info/scm_file_list.json +45 -44
  7. python3_commons-0.22.8/src/python3_commons.egg-info/scm_version.json +8 -0
  8. {python3_commons-0.22.6 → python3_commons-0.22.8}/tests/unit/db/test_async_session_manager.py +3 -3
  9. python3_commons-0.22.8/tests/unit/test_auth.py +111 -0
  10. python3_commons-0.22.6/src/python3_commons.egg-info/scm_version.json +0 -8
  11. {python3_commons-0.22.6 → python3_commons-0.22.8}/.coveragerc +0 -0
  12. {python3_commons-0.22.6 → python3_commons-0.22.8}/.devcontainer/Dockerfile +0 -0
  13. {python3_commons-0.22.6 → python3_commons-0.22.8}/.devcontainer/devcontainer.json +0 -0
  14. {python3_commons-0.22.6 → python3_commons-0.22.8}/.devcontainer/docker-compose.yml +0 -0
  15. {python3_commons-0.22.6 → python3_commons-0.22.8}/.env_template +0 -0
  16. {python3_commons-0.22.6 → python3_commons-0.22.8}/.github/workflows/checks.yml +0 -0
  17. {python3_commons-0.22.6 → python3_commons-0.22.8}/.github/workflows/python-publish.yaml +0 -0
  18. {python3_commons-0.22.6 → python3_commons-0.22.8}/.github/workflows/release-on-tag-push.yml +0 -0
  19. {python3_commons-0.22.6 → python3_commons-0.22.8}/.gitignore +0 -0
  20. {python3_commons-0.22.6 → python3_commons-0.22.8}/.pre-commit-config.yaml +0 -0
  21. {python3_commons-0.22.6 → python3_commons-0.22.8}/.python-version +0 -0
  22. {python3_commons-0.22.6 → python3_commons-0.22.8}/AUTHORS.rst +0 -0
  23. {python3_commons-0.22.6 → python3_commons-0.22.8}/CHANGELOG.rst +0 -0
  24. {python3_commons-0.22.6 → python3_commons-0.22.8}/LICENSE +0 -0
  25. {python3_commons-0.22.6 → python3_commons-0.22.8}/README.md +0 -0
  26. {python3_commons-0.22.6 → python3_commons-0.22.8}/README.rst +0 -0
  27. {python3_commons-0.22.6 → python3_commons-0.22.8}/docs/Makefile +0 -0
  28. {python3_commons-0.22.6 → python3_commons-0.22.8}/docs/_static/.gitignore +0 -0
  29. {python3_commons-0.22.6 → python3_commons-0.22.8}/docs/authors.rst +0 -0
  30. {python3_commons-0.22.6 → python3_commons-0.22.8}/docs/changelog.rst +0 -0
  31. {python3_commons-0.22.6 → python3_commons-0.22.8}/docs/conf.py +0 -0
  32. {python3_commons-0.22.6 → python3_commons-0.22.8}/docs/index.rst +0 -0
  33. {python3_commons-0.22.6 → python3_commons-0.22.8}/docs/license.rst +0 -0
  34. {python3_commons-0.22.6 → python3_commons-0.22.8}/pyproject.toml +0 -0
  35. {python3_commons-0.22.6 → python3_commons-0.22.8}/setup.cfg +0 -0
  36. {python3_commons-0.22.6 → python3_commons-0.22.8}/src/python3_commons/__init__.py +0 -0
  37. {python3_commons-0.22.6 → python3_commons-0.22.8}/src/python3_commons/api_client.py +0 -0
  38. {python3_commons-0.22.6 → python3_commons-0.22.8}/src/python3_commons/async_functools.py +0 -0
  39. {python3_commons-0.22.6 → python3_commons-0.22.8}/src/python3_commons/audit.py +0 -0
  40. {python3_commons-0.22.6 → python3_commons-0.22.8}/src/python3_commons/cache.py +0 -0
  41. {python3_commons-0.22.6 → python3_commons-0.22.8}/src/python3_commons/conf.py +0 -0
  42. {python3_commons-0.22.6 → python3_commons-0.22.8}/src/python3_commons/db/helpers.py +0 -0
  43. {python3_commons-0.22.6 → python3_commons-0.22.8}/src/python3_commons/db/models/__init__.py +0 -0
  44. {python3_commons-0.22.6 → python3_commons-0.22.8}/src/python3_commons/db/models/auth.py +0 -0
  45. {python3_commons-0.22.6 → python3_commons-0.22.8}/src/python3_commons/db/models/common.py +0 -0
  46. {python3_commons-0.22.6 → python3_commons-0.22.8}/src/python3_commons/db/models/rbac.py +0 -0
  47. {python3_commons-0.22.6 → python3_commons-0.22.8}/src/python3_commons/db/models/users.py +0 -0
  48. {python3_commons-0.22.6 → python3_commons-0.22.8}/src/python3_commons/exceptions.py +0 -0
  49. {python3_commons-0.22.6 → python3_commons-0.22.8}/src/python3_commons/fs.py +0 -0
  50. {python3_commons-0.22.6 → python3_commons-0.22.8}/src/python3_commons/generators.py +0 -0
  51. {python3_commons-0.22.6 → python3_commons-0.22.8}/src/python3_commons/helpers.py +0 -0
  52. {python3_commons-0.22.6 → python3_commons-0.22.8}/src/python3_commons/log/__init__.py +0 -0
  53. {python3_commons-0.22.6 → python3_commons-0.22.8}/src/python3_commons/log/filters.py +0 -0
  54. {python3_commons-0.22.6 → python3_commons-0.22.8}/src/python3_commons/log/formatters.py +0 -0
  55. {python3_commons-0.22.6 → python3_commons-0.22.8}/src/python3_commons/object_storage.py +0 -0
  56. {python3_commons-0.22.6 → python3_commons-0.22.8}/src/python3_commons/permissions.py +0 -0
  57. {python3_commons-0.22.6 → python3_commons-0.22.8}/src/python3_commons/serializers/__init__.py +0 -0
  58. {python3_commons-0.22.6 → python3_commons-0.22.8}/src/python3_commons/serializers/common.py +0 -0
  59. {python3_commons-0.22.6 → python3_commons-0.22.8}/src/python3_commons/serializers/json.py +0 -0
  60. {python3_commons-0.22.6 → python3_commons-0.22.8}/src/python3_commons/serializers/msgpack.py +0 -0
  61. {python3_commons-0.22.6 → python3_commons-0.22.8}/src/python3_commons/serializers/msgspec.py +0 -0
  62. {python3_commons-0.22.6 → python3_commons-0.22.8}/src/python3_commons/soap_client.py +0 -0
  63. {python3_commons-0.22.6 → python3_commons-0.22.8}/src/python3_commons.egg-info/dependency_links.txt +0 -0
  64. {python3_commons-0.22.6 → python3_commons-0.22.8}/src/python3_commons.egg-info/requires.txt +0 -0
  65. {python3_commons-0.22.6 → python3_commons-0.22.8}/src/python3_commons.egg-info/top_level.txt +0 -0
  66. {python3_commons-0.22.6 → python3_commons-0.22.8}/tests/__init__.py +0 -0
  67. {python3_commons-0.22.6 → python3_commons-0.22.8}/tests/integration/__init__.py +0 -0
  68. {python3_commons-0.22.6 → python3_commons-0.22.8}/tests/integration/conftest.py +0 -0
  69. {python3_commons-0.22.6 → python3_commons-0.22.8}/tests/integration/test_auth.py +0 -0
  70. {python3_commons-0.22.6 → python3_commons-0.22.8}/tests/integration/test_cache.py +0 -0
  71. {python3_commons-0.22.6 → python3_commons-0.22.8}/tests/integration/test_osc.py +0 -0
  72. {python3_commons-0.22.6 → python3_commons-0.22.8}/tests/unit/__init__.py +0 -0
  73. {python3_commons-0.22.6 → python3_commons-0.22.8}/tests/unit/conftest.py +0 -0
  74. {python3_commons-0.22.6 → python3_commons-0.22.8}/tests/unit/db/__init__.py +0 -0
  75. {python3_commons-0.22.6 → python3_commons-0.22.8}/tests/unit/log/__init__.py +0 -0
  76. {python3_commons-0.22.6 → python3_commons-0.22.8}/tests/unit/log/test_formatters.py +0 -0
  77. {python3_commons-0.22.6 → python3_commons-0.22.8}/tests/unit/test_async_functools.py +0 -0
  78. {python3_commons-0.22.6 → python3_commons-0.22.8}/tests/unit/test_audit.py +0 -0
  79. {python3_commons-0.22.6 → python3_commons-0.22.8}/tests/unit/test_helpers.py +0 -0
  80. {python3_commons-0.22.6 → python3_commons-0.22.8}/tests/unit/test_msgpack.py +0 -0
  81. {python3_commons-0.22.6 → python3_commons-0.22.8}/tests/unit/test_msgspec.py +0 -0
  82. {python3_commons-0.22.6 → python3_commons-0.22.8}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python3-commons
3
- Version: 0.22.6
3
+ Version: 0.22.8
4
4
  Summary: Re-usable Python3 code
5
5
  Author-email: Oleg Korsak <kamikaze.is.waiting.you@gmail.com>
6
6
  License-Expression: GPL-3.0
@@ -1,6 +1,7 @@
1
+ import asyncio
1
2
  import logging
2
- import threading
3
- from collections.abc import Mapping, Sequence
3
+ from collections.abc import Sequence
4
+ from time import monotonic
4
5
  from typing import Any, Self, TypeVar
5
6
 
6
7
  from pydantic import HttpUrl
@@ -18,9 +19,8 @@ except ImportError as e:
18
19
  import msgspec
19
20
 
20
21
  logger = logging.getLogger(__name__)
21
- _OIDC_CONFIG_LOCK = threading.Lock()
22
- _OIDC_JWKS_LOCK = threading.Lock()
23
- _OIDC_SESSION_LOCK = threading.Lock()
22
+
23
+ DEFAULT_JWKS_CACHE_TTL = 300.0
24
24
 
25
25
 
26
26
  class TokenData(msgspec.Struct):
@@ -81,6 +81,7 @@ class OIDCClient:
81
81
  timeout: float = 10.0,
82
82
  verify_cert: bool = True,
83
83
  connection_limit: int = 100,
84
+ jwks_cache_ttl: float = DEFAULT_JWKS_CACHE_TTL,
84
85
  authority_internal_host: HttpUrl | None = None,
85
86
  audit_name: str | None = None,
86
87
  ) -> None:
@@ -97,17 +98,22 @@ class OIDCClient:
97
98
  self._timeout = timeout
98
99
  self._verify_cert = verify_cert
99
100
 
100
- self._config: Mapping[str, Any] | None = None
101
- self._jwks: Mapping[str, Any] | None = None
101
+ self._session_lock = asyncio.Lock()
102
+ self._config: dict[str, Any] | None = None
103
+ self._config_lock = asyncio.Lock()
104
+ self._jwks: dict[str, Any] | None = None
105
+ self._jwks_lock = asyncio.Lock()
106
+ self._jwks_cache_ttl = jwks_cache_ttl
107
+ self._jwks_fetched_at: float | None = None
102
108
  self._audit_name: str | None = audit_name
103
109
 
104
- def _get_session(self) -> aiohttp.ClientSession:
105
- if self._session:
106
- return self._session
110
+ async def _get_session(self) -> aiohttp.ClientSession:
111
+ if (session := self._session) and not session.closed:
112
+ return session
107
113
 
108
- with _OIDC_SESSION_LOCK:
109
- if self._session:
110
- return self._session
114
+ async with self._session_lock:
115
+ if (session := self._session) and not session.closed:
116
+ return session
111
117
 
112
118
  connector = aiohttp.TCPConnector(verify_ssl=self._verify_cert, limit=self._connection_limit)
113
119
  timeout = aiohttp.ClientTimeout(total=self._timeout)
@@ -117,7 +123,7 @@ class OIDCClient:
117
123
  return session
118
124
 
119
125
  async def __aenter__(self) -> Self:
120
- self._get_session()
126
+ await self._get_session()
121
127
 
122
128
  return self
123
129
 
@@ -130,27 +136,27 @@ class OIDCClient:
130
136
  Fetch the OpenID configuration (including JWKS URI) from OIDC authority.
131
137
  """
132
138
  async with api_client.request(
133
- self._get_session(),
139
+ await self._get_session(),
134
140
  str(self._authority_url),
135
141
  '/.well-known/openid-configuration',
136
142
  audit_name=self._audit_name,
137
143
  ) as response:
138
144
  return await response.json()
139
145
 
140
- async def get_config(self) -> Mapping[str, Any]:
141
- if self._config:
142
- return self._config
146
+ async def get_config(self) -> dict[str, Any]:
147
+ if config := self._config:
148
+ return config
143
149
 
144
- with _OIDC_CONFIG_LOCK:
145
- if self._config:
146
- return self._config
150
+ async with self._config_lock:
151
+ if config := self._config:
152
+ return config
147
153
 
148
154
  config = await self._fetch_config()
149
155
  self._config = config
150
156
 
151
157
  return config
152
158
 
153
- async def _fetch_jwks(self, jwks_uri: str) -> dict:
159
+ async def _fetch_jwks(self, jwks_uri: str) -> dict[str, Any]:
154
160
  """
155
161
  Fetch the JSON Web Key Set (JWKS) for validating the token's signature.
156
162
  """
@@ -160,21 +166,35 @@ class OIDCClient:
160
166
  jwks_uri = str(replace_origin(HttpUrl(jwks_uri), authority_internal_host))
161
167
  logger.debug('Modified jwks_uri: %s', jwks_uri)
162
168
 
163
- async with api_client.request(self._get_session(), jwks_uri, '', audit_name=self._audit_name) as response:
169
+ async with api_client.request(await self._get_session(), jwks_uri, '', audit_name=self._audit_name) as response:
164
170
  return await response.json()
165
171
 
166
- async def get_jwks(self) -> Mapping[str, Any]:
167
- if self._jwks:
168
- return self._jwks
172
+ def _is_fresh_jwks(self) -> bool:
173
+ fetched_at = self._jwks_fetched_at
174
+
175
+ if self._jwks is None or fetched_at is None:
176
+ return False
177
+
178
+ return (monotonic() - fetched_at) < self._jwks_cache_ttl
179
+
180
+ async def get_jwks(self, *, force_refresh: bool = False) -> dict[str, Any]:
181
+ if not force_refresh and (jwks := self._jwks) and self._is_fresh_jwks():
182
+ return jwks
183
+
184
+ fetched_at = self._jwks_fetched_at
185
+
186
+ async with self._jwks_lock:
187
+ if (jwks := self._jwks) and self._jwks_fetched_at != fetched_at:
188
+ return jwks
169
189
 
170
- with _OIDC_JWKS_LOCK:
171
- if self._jwks:
172
- return self._jwks
190
+ if not force_refresh and jwks and self._is_fresh_jwks():
191
+ return jwks
173
192
 
174
193
  oidc_config = await self.get_config()
175
194
 
176
195
  jwks = await self._fetch_jwks(oidc_config['jwks_uri'])
177
196
  self._jwks = jwks
197
+ self._jwks_fetched_at = monotonic()
178
198
 
179
199
  return jwks
180
200
 
@@ -200,7 +220,7 @@ class OIDCClient:
200
220
 
201
221
  try:
202
222
  async with api_client.request(
203
- self._get_session(),
223
+ await self._get_session(),
204
224
  openid_config['token_endpoint'],
205
225
  '',
206
226
  method='post',
@@ -185,7 +185,7 @@ class AsyncSessionManager:
185
185
  try:
186
186
  yield session
187
187
  except Exception:
188
- logger.exception('Error occured while db session %r was open; rolling back', name)
188
+ logger.exception('Error occurred while db session %r was open; rolling back', name)
189
189
  await session.rollback()
190
190
 
191
191
  raise
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python3-commons
3
- Version: 0.22.6
3
+ Version: 0.22.8
4
4
  Summary: Re-usable Python3 code
5
5
  Author-email: Oleg Korsak <kamikaze.is.waiting.you@gmail.com>
6
6
  License-Expression: GPL-3.0
@@ -69,6 +69,7 @@ tests/unit/__init__.py
69
69
  tests/unit/conftest.py
70
70
  tests/unit/test_async_functools.py
71
71
  tests/unit/test_audit.py
72
+ tests/unit/test_auth.py
72
73
  tests/unit/test_helpers.py
73
74
  tests/unit/test_msgpack.py
74
75
  tests/unit/test_msgspec.py
@@ -1,75 +1,76 @@
1
1
  {
2
2
  "files": [
3
+ ".gitignore",
4
+ ".python-version",
5
+ "README.md",
6
+ "AUTHORS.rst",
7
+ "CHANGELOG.rst",
8
+ "pyproject.toml",
9
+ ".env_template",
3
10
  ".coveragerc",
4
11
  ".pre-commit-config.yaml",
5
- "README.md",
6
12
  "uv.lock",
7
- ".python-version",
8
13
  "LICENSE",
9
- "pyproject.toml",
10
- "CHANGELOG.rst",
11
- ".env_template",
12
14
  "README.rst",
13
- ".gitignore",
14
- "AUTHORS.rst",
15
- ".devcontainer/Dockerfile",
16
- ".devcontainer/docker-compose.yml",
17
- ".devcontainer/devcontainer.json",
18
- "docs/Makefile",
19
- "docs/license.rst",
20
- "docs/authors.rst",
21
- "docs/index.rst",
22
- "docs/changelog.rst",
23
- "docs/conf.py",
24
- "docs/_static/.gitignore",
25
- "src/python3_commons/helpers.py",
15
+ "src/python3_commons/conf.py",
26
16
  "src/python3_commons/soap_client.py",
27
- "src/python3_commons/api_client.py",
28
- "src/python3_commons/__init__.py",
29
- "src/python3_commons/exceptions.py",
30
- "src/python3_commons/object_storage.py",
17
+ "src/python3_commons/async_functools.py",
31
18
  "src/python3_commons/permissions.py",
32
- "src/python3_commons/auth.py",
33
- "src/python3_commons/audit.py",
34
- "src/python3_commons/cache.py",
35
19
  "src/python3_commons/fs.py",
36
- "src/python3_commons/conf.py",
37
- "src/python3_commons/async_functools.py",
20
+ "src/python3_commons/api_client.py",
21
+ "src/python3_commons/audit.py",
22
+ "src/python3_commons/object_storage.py",
23
+ "src/python3_commons/exceptions.py",
24
+ "src/python3_commons/helpers.py",
38
25
  "src/python3_commons/generators.py",
26
+ "src/python3_commons/cache.py",
27
+ "src/python3_commons/auth.py",
28
+ "src/python3_commons/__init__.py",
39
29
  "src/python3_commons/db/helpers.py",
40
30
  "src/python3_commons/db/__init__.py",
31
+ "src/python3_commons/db/models/common.py",
41
32
  "src/python3_commons/db/models/rbac.py",
42
- "src/python3_commons/db/models/__init__.py",
43
- "src/python3_commons/db/models/users.py",
44
33
  "src/python3_commons/db/models/auth.py",
45
- "src/python3_commons/db/models/common.py",
46
- "src/python3_commons/log/__init__.py",
34
+ "src/python3_commons/db/models/users.py",
35
+ "src/python3_commons/db/models/__init__.py",
47
36
  "src/python3_commons/log/filters.py",
48
37
  "src/python3_commons/log/formatters.py",
49
- "src/python3_commons/serializers/__init__.py",
50
- "src/python3_commons/serializers/msgpack.py",
51
- "src/python3_commons/serializers/json.py",
38
+ "src/python3_commons/log/__init__.py",
52
39
  "src/python3_commons/serializers/msgspec.py",
40
+ "src/python3_commons/serializers/json.py",
53
41
  "src/python3_commons/serializers/common.py",
42
+ "src/python3_commons/serializers/msgpack.py",
43
+ "src/python3_commons/serializers/__init__.py",
44
+ ".devcontainer/docker-compose.yml",
45
+ ".devcontainer/Dockerfile",
46
+ ".devcontainer/devcontainer.json",
47
+ ".github/workflows/checks.yml",
48
+ ".github/workflows/release-on-tag-push.yml",
49
+ ".github/workflows/python-publish.yaml",
54
50
  "tests/__init__.py",
55
- "tests/integration/__init__.py",
56
- "tests/integration/test_auth.py",
57
51
  "tests/integration/conftest.py",
58
52
  "tests/integration/test_osc.py",
59
53
  "tests/integration/test_cache.py",
60
- "tests/unit/__init__.py",
61
- "tests/unit/test_helpers.py",
54
+ "tests/integration/test_auth.py",
55
+ "tests/integration/__init__.py",
62
56
  "tests/unit/test_async_functools.py",
63
- "tests/unit/test_msgspec.py",
64
57
  "tests/unit/conftest.py",
58
+ "tests/unit/test_msgspec.py",
65
59
  "tests/unit/test_audit.py",
60
+ "tests/unit/test_auth.py",
66
61
  "tests/unit/test_msgpack.py",
67
- "tests/unit/db/__init__.py",
62
+ "tests/unit/test_helpers.py",
63
+ "tests/unit/__init__.py",
68
64
  "tests/unit/db/test_async_session_manager.py",
69
- "tests/unit/log/__init__.py",
65
+ "tests/unit/db/__init__.py",
70
66
  "tests/unit/log/test_formatters.py",
71
- ".github/workflows/release-on-tag-push.yml",
72
- ".github/workflows/python-publish.yaml",
73
- ".github/workflows/checks.yml"
67
+ "tests/unit/log/__init__.py",
68
+ "docs/conf.py",
69
+ "docs/Makefile",
70
+ "docs/index.rst",
71
+ "docs/authors.rst",
72
+ "docs/changelog.rst",
73
+ "docs/license.rst",
74
+ "docs/_static/.gitignore"
74
75
  ]
75
76
  }
@@ -0,0 +1,8 @@
1
+ {
2
+ "tag": "0.22.8",
3
+ "distance": 0,
4
+ "node": "g4f7323e76dc3e62de5b746137a7bd21019225e5b",
5
+ "dirty": false,
6
+ "branch": "HEAD",
7
+ "node_date": "2026-07-15"
8
+ }
@@ -27,12 +27,12 @@ async def test_async_session_manager_timeout(mocker, caplog):
27
27
  get_session_ctx_func = manager.get_session_context('default')
28
28
 
29
29
  # We want to verify that TimeoutError from the driver is caught,
30
- # logged as "Database communication error", rolled back, and re-raised.
30
+ # logged as "Error occurred while db session ... was open", rolled back, and re-raised.
31
31
  with pytest.raises(TimeoutError):
32
32
  async with get_session_ctx_func() as session:
33
33
  await session.execute('SELECT 1')
34
34
 
35
- assert 'Database communication error' in caplog.text
35
+ assert 'Error occurred while db session' in caplog.text
36
36
 
37
37
 
38
38
  @pytest.mark.asyncio
@@ -72,4 +72,4 @@ async def test_async_session_manager_logging(mocker, caplog):
72
72
  async with get_session_ctx_func() as session:
73
73
  await session.execute('SELECT 1')
74
74
 
75
- assert 'Database communication error' in caplog.text
75
+ assert 'Error occurred while db session' in caplog.text
@@ -0,0 +1,111 @@
1
+ import asyncio
2
+ from typing import TYPE_CHECKING
3
+
4
+ import pytest
5
+ from pydantic import HttpUrl
6
+
7
+ from python3_commons.auth import OIDCClient
8
+
9
+ if TYPE_CHECKING:
10
+ from pytest_mock import MockerFixture
11
+
12
+ _CONFIG = {'jwks_uri': 'https://issuer.example/keys'}
13
+ _JWKS_V1 = {'keys': [{'kid': 'key-1'}]}
14
+ _JWKS_V2 = {'keys': [{'kid': 'key-2'}]}
15
+
16
+
17
+ @pytest.fixture
18
+ def client() -> OIDCClient:
19
+ return OIDCClient(HttpUrl('https://issuer.example'), 'jean', jwks_cache_ttl=100.0)
20
+
21
+
22
+ @pytest.mark.asyncio
23
+ async def test_get_jwks_serves_cached_set_within_ttl(client: OIDCClient, mocker: MockerFixture) -> None:
24
+ mocker.patch.object(client, '_fetch_config', mocker.AsyncMock(return_value=_CONFIG))
25
+ fetch_jwks = mocker.AsyncMock(return_value=_JWKS_V1)
26
+ mocker.patch.object(client, '_fetch_jwks', fetch_jwks)
27
+ mocker.patch('python3_commons.auth.monotonic', return_value=10.0)
28
+
29
+ first = await client.get_jwks()
30
+ second = await client.get_jwks()
31
+
32
+ assert first == _JWKS_V1
33
+ assert second == _JWKS_V1
34
+ assert fetch_jwks.await_count == 1
35
+
36
+
37
+ @pytest.mark.asyncio
38
+ async def test_get_jwks_refetches_after_ttl_expiry(client: OIDCClient, mocker: MockerFixture) -> None:
39
+ """A rotated signing key is picked up once the cached set goes stale."""
40
+ mocker.patch.object(client, '_fetch_config', mocker.AsyncMock(return_value=_CONFIG))
41
+ fetch_jwks = mocker.AsyncMock(side_effect=[_JWKS_V1, _JWKS_V2])
42
+ mocker.patch.object(client, '_fetch_jwks', fetch_jwks)
43
+ clock = mocker.patch('python3_commons.auth.monotonic', return_value=0.0)
44
+
45
+ first = await client.get_jwks()
46
+ clock.return_value = 100.0
47
+ second = await client.get_jwks()
48
+
49
+ assert first == _JWKS_V1
50
+ assert second == _JWKS_V2
51
+ assert fetch_jwks.await_count == 2
52
+
53
+
54
+ @pytest.mark.asyncio
55
+ async def test_get_jwks_force_refresh_bypasses_fresh_cache(client: OIDCClient, mocker: MockerFixture) -> None:
56
+ mocker.patch.object(client, '_fetch_config', mocker.AsyncMock(return_value=_CONFIG))
57
+ fetch_jwks = mocker.AsyncMock(side_effect=[_JWKS_V1, _JWKS_V2])
58
+ mocker.patch.object(client, '_fetch_jwks', fetch_jwks)
59
+ mocker.patch('python3_commons.auth.monotonic', return_value=0.0)
60
+
61
+ first = await client.get_jwks()
62
+ second = await client.get_jwks(force_refresh=True)
63
+
64
+ assert first == _JWKS_V1
65
+ assert second == _JWKS_V2
66
+ assert fetch_jwks.await_count == 2
67
+
68
+
69
+ @pytest.mark.asyncio
70
+ async def test_get_jwks_coalesces_concurrent_refreshes(client: OIDCClient, mocker: MockerFixture) -> None:
71
+ """Concurrent first-time callers share a single fetch and never deadlock on the lock."""
72
+ mocker.patch.object(client, '_fetch_config', mocker.AsyncMock(return_value=_CONFIG))
73
+ mocker.patch('python3_commons.auth.monotonic', return_value=0.0)
74
+
75
+ started = asyncio.Event()
76
+ release = asyncio.Event()
77
+
78
+ async def slow_fetch(_uri: str) -> dict:
79
+ started.set()
80
+ await release.wait()
81
+
82
+ return _JWKS_V1
83
+
84
+ fetch_jwks = mocker.AsyncMock(side_effect=slow_fetch)
85
+ mocker.patch.object(client, '_fetch_jwks', fetch_jwks)
86
+
87
+ first = asyncio.create_task(client.get_jwks())
88
+ await started.wait()
89
+ second = asyncio.create_task(client.get_jwks())
90
+ await asyncio.sleep(0)
91
+ release.set()
92
+
93
+ results = await asyncio.gather(first, second)
94
+
95
+ assert results == [_JWKS_V1, _JWKS_V1]
96
+ assert fetch_jwks.await_count == 1
97
+
98
+
99
+ @pytest.mark.asyncio
100
+ async def test_get_session_recreates_a_closed_session(client: OIDCClient) -> None:
101
+ """A cache refresh after the context manager closed the session must get a live one."""
102
+ async with client:
103
+ first = await client._get_session() # noqa: SLF001
104
+
105
+ assert first.closed
106
+
107
+ async with client:
108
+ second = await client._get_session() # noqa: SLF001
109
+
110
+ assert not second.closed
111
+ assert second is not first
@@ -1,8 +0,0 @@
1
- {
2
- "tag": "0.22.6",
3
- "distance": 0,
4
- "node": "gd66dd259ee583bc3a586ae4be41f9f159169f450",
5
- "dirty": false,
6
- "branch": "HEAD",
7
- "node_date": "2026-06-26"
8
- }