python3-commons 0.18.22__tar.gz → 0.20.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 (77) hide show
  1. {python3_commons-0.18.22 → python3_commons-0.20.0}/.env_template +6 -0
  2. {python3_commons-0.18.22/src/python3_commons.egg-info → python3_commons-0.20.0}/PKG-INFO +2 -2
  3. {python3_commons-0.18.22 → python3_commons-0.20.0}/pyproject.toml +1 -1
  4. python3_commons-0.20.0/src/python3_commons/auth.py +258 -0
  5. {python3_commons-0.18.22 → python3_commons-0.20.0/src/python3_commons.egg-info}/PKG-INFO +2 -2
  6. {python3_commons-0.18.22 → python3_commons-0.20.0}/src/python3_commons.egg-info/SOURCES.txt +2 -0
  7. {python3_commons-0.18.22 → python3_commons-0.20.0}/src/python3_commons.egg-info/requires.txt +1 -1
  8. python3_commons-0.20.0/tests/integration/conftest.py +29 -0
  9. python3_commons-0.20.0/tests/integration/test_auth.py +29 -0
  10. {python3_commons-0.18.22 → python3_commons-0.20.0}/uv.lock +19 -18
  11. python3_commons-0.18.22/src/python3_commons/auth.py +0 -198
  12. {python3_commons-0.18.22 → python3_commons-0.20.0}/.coveragerc +0 -0
  13. {python3_commons-0.18.22 → python3_commons-0.20.0}/.devcontainer/Dockerfile +0 -0
  14. {python3_commons-0.18.22 → python3_commons-0.20.0}/.devcontainer/devcontainer.json +0 -0
  15. {python3_commons-0.18.22 → python3_commons-0.20.0}/.devcontainer/docker-compose.yml +0 -0
  16. {python3_commons-0.18.22 → python3_commons-0.20.0}/.github/workflows/checks.yml +0 -0
  17. {python3_commons-0.18.22 → python3_commons-0.20.0}/.github/workflows/python-publish.yaml +0 -0
  18. {python3_commons-0.18.22 → python3_commons-0.20.0}/.github/workflows/release-on-tag-push.yml +0 -0
  19. {python3_commons-0.18.22 → python3_commons-0.20.0}/.gitignore +0 -0
  20. {python3_commons-0.18.22 → python3_commons-0.20.0}/.pre-commit-config.yaml +0 -0
  21. {python3_commons-0.18.22 → python3_commons-0.20.0}/.python-version +0 -0
  22. {python3_commons-0.18.22 → python3_commons-0.20.0}/AUTHORS.rst +0 -0
  23. {python3_commons-0.18.22 → python3_commons-0.20.0}/CHANGELOG.rst +0 -0
  24. {python3_commons-0.18.22 → python3_commons-0.20.0}/LICENSE +0 -0
  25. {python3_commons-0.18.22 → python3_commons-0.20.0}/README.md +0 -0
  26. {python3_commons-0.18.22 → python3_commons-0.20.0}/README.rst +0 -0
  27. {python3_commons-0.18.22 → python3_commons-0.20.0}/docs/Makefile +0 -0
  28. {python3_commons-0.18.22 → python3_commons-0.20.0}/docs/_static/.gitignore +0 -0
  29. {python3_commons-0.18.22 → python3_commons-0.20.0}/docs/authors.rst +0 -0
  30. {python3_commons-0.18.22 → python3_commons-0.20.0}/docs/changelog.rst +0 -0
  31. {python3_commons-0.18.22 → python3_commons-0.20.0}/docs/conf.py +0 -0
  32. {python3_commons-0.18.22 → python3_commons-0.20.0}/docs/index.rst +0 -0
  33. {python3_commons-0.18.22 → python3_commons-0.20.0}/docs/license.rst +0 -0
  34. {python3_commons-0.18.22 → python3_commons-0.20.0}/setup.cfg +0 -0
  35. {python3_commons-0.18.22 → python3_commons-0.20.0}/src/python3_commons/__init__.py +0 -0
  36. {python3_commons-0.18.22 → python3_commons-0.20.0}/src/python3_commons/api_client.py +0 -0
  37. {python3_commons-0.18.22 → python3_commons-0.20.0}/src/python3_commons/async_functools.py +0 -0
  38. {python3_commons-0.18.22 → python3_commons-0.20.0}/src/python3_commons/audit.py +0 -0
  39. {python3_commons-0.18.22 → python3_commons-0.20.0}/src/python3_commons/cache.py +0 -0
  40. {python3_commons-0.18.22 → python3_commons-0.20.0}/src/python3_commons/conf.py +0 -0
  41. {python3_commons-0.18.22 → python3_commons-0.20.0}/src/python3_commons/db/__init__.py +0 -0
  42. {python3_commons-0.18.22 → python3_commons-0.20.0}/src/python3_commons/db/helpers.py +0 -0
  43. {python3_commons-0.18.22 → python3_commons-0.20.0}/src/python3_commons/db/models/__init__.py +0 -0
  44. {python3_commons-0.18.22 → python3_commons-0.20.0}/src/python3_commons/db/models/auth.py +0 -0
  45. {python3_commons-0.18.22 → python3_commons-0.20.0}/src/python3_commons/db/models/common.py +0 -0
  46. {python3_commons-0.18.22 → python3_commons-0.20.0}/src/python3_commons/db/models/rbac.py +0 -0
  47. {python3_commons-0.18.22 → python3_commons-0.20.0}/src/python3_commons/db/models/users.py +0 -0
  48. {python3_commons-0.18.22 → python3_commons-0.20.0}/src/python3_commons/exceptions.py +0 -0
  49. {python3_commons-0.18.22 → python3_commons-0.20.0}/src/python3_commons/fs.py +0 -0
  50. {python3_commons-0.18.22 → python3_commons-0.20.0}/src/python3_commons/generators.py +0 -0
  51. {python3_commons-0.18.22 → python3_commons-0.20.0}/src/python3_commons/helpers.py +0 -0
  52. {python3_commons-0.18.22 → python3_commons-0.20.0}/src/python3_commons/log/__init__.py +0 -0
  53. {python3_commons-0.18.22 → python3_commons-0.20.0}/src/python3_commons/log/filters.py +0 -0
  54. {python3_commons-0.18.22 → python3_commons-0.20.0}/src/python3_commons/log/formatters.py +0 -0
  55. {python3_commons-0.18.22 → python3_commons-0.20.0}/src/python3_commons/object_storage.py +0 -0
  56. {python3_commons-0.18.22 → python3_commons-0.20.0}/src/python3_commons/permissions.py +0 -0
  57. {python3_commons-0.18.22 → python3_commons-0.20.0}/src/python3_commons/serializers/__init__.py +0 -0
  58. {python3_commons-0.18.22 → python3_commons-0.20.0}/src/python3_commons/serializers/common.py +0 -0
  59. {python3_commons-0.18.22 → python3_commons-0.20.0}/src/python3_commons/serializers/json.py +0 -0
  60. {python3_commons-0.18.22 → python3_commons-0.20.0}/src/python3_commons/serializers/msgpack.py +0 -0
  61. {python3_commons-0.18.22 → python3_commons-0.20.0}/src/python3_commons/serializers/msgspec.py +0 -0
  62. {python3_commons-0.18.22 → python3_commons-0.20.0}/src/python3_commons/soap_client.py +0 -0
  63. {python3_commons-0.18.22 → python3_commons-0.20.0}/src/python3_commons.egg-info/dependency_links.txt +0 -0
  64. {python3_commons-0.18.22 → python3_commons-0.20.0}/src/python3_commons.egg-info/top_level.txt +0 -0
  65. {python3_commons-0.18.22 → python3_commons-0.20.0}/tests/__init__.py +0 -0
  66. {python3_commons-0.18.22 → python3_commons-0.20.0}/tests/integration/__init__.py +0 -0
  67. {python3_commons-0.18.22 → python3_commons-0.20.0}/tests/integration/test_cache.py +0 -0
  68. {python3_commons-0.18.22 → python3_commons-0.20.0}/tests/integration/test_osc.py +0 -0
  69. {python3_commons-0.18.22 → python3_commons-0.20.0}/tests/unit/__init__.py +0 -0
  70. {python3_commons-0.18.22 → python3_commons-0.20.0}/tests/unit/conftest.py +0 -0
  71. {python3_commons-0.18.22 → python3_commons-0.20.0}/tests/unit/log/__init__.py +0 -0
  72. {python3_commons-0.18.22 → python3_commons-0.20.0}/tests/unit/log/test_formatters.py +0 -0
  73. {python3_commons-0.18.22 → python3_commons-0.20.0}/tests/unit/test_async_functools.py +0 -0
  74. {python3_commons-0.18.22 → python3_commons-0.20.0}/tests/unit/test_audit.py +0 -0
  75. {python3_commons-0.18.22 → python3_commons-0.20.0}/tests/unit/test_helpers.py +0 -0
  76. {python3_commons-0.18.22 → python3_commons-0.20.0}/tests/unit/test_msgpack.py +0 -0
  77. {python3_commons-0.18.22 → python3_commons-0.20.0}/tests/unit/test_msgspec.py +0 -0
@@ -17,3 +17,9 @@ DB_DSN=postgresql+asyncpg://dev:dev@localhost:5432/fastapi-project-template
17
17
  API_AUTH_ENABLED=false
18
18
  OIDC_AUTHORITY_URL=https://oidc.provider.com/auth
19
19
  OIDC_CLIENT_ID=12345-54321
20
+
21
+ TEST_OIDC_AUTHORITY_URL=https://oidc.provider.com/auth
22
+ TEST_OIDC_CLIENT_ID=12345-54321
23
+ TEST_OIDC_CLIENT_SECRET=
24
+ TEST_OIDC_USERNAME=testuser
25
+ TEST_OIDC_PASSWORD=
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python3-commons
3
- Version: 0.18.22
3
+ Version: 0.20.0
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
@@ -36,7 +36,7 @@ Provides-Extra: cache
36
36
  Requires-Dist: valkey[libvalkey]~=6.1.1; extra == "cache"
37
37
  Provides-Extra: database
38
38
  Requires-Dist: asyncpg~=0.31.0; extra == "database"
39
- Requires-Dist: SQLAlchemy[asyncio]~=2.0.49; extra == "database"
39
+ Requires-Dist: SQLAlchemy[asyncio]~=2.0.50; extra == "database"
40
40
  Provides-Extra: object-storage
41
41
  Requires-Dist: aiobotocore~=3.7.0; extra == "object-storage"
42
42
  Requires-Dist: object-storage-client==0.0.30; extra == "object-storage"
@@ -51,7 +51,7 @@ cache = [
51
51
  ]
52
52
  database = [
53
53
  "asyncpg~=0.31.0",
54
- "SQLAlchemy[asyncio]~=2.0.49"
54
+ "SQLAlchemy[asyncio]~=2.0.50"
55
55
  ]
56
56
  object-storage = [
57
57
  "aiobotocore~=3.7.0",
@@ -0,0 +1,258 @@
1
+ import logging
2
+ import threading
3
+ from collections.abc import Mapping, Sequence
4
+ from http import HTTPStatus
5
+ from typing import Any, Self, TypeVar
6
+
7
+ from pydantic import HttpUrl
8
+
9
+ from python3_commons.helpers import replace_origin
10
+
11
+ try:
12
+ import aiohttp
13
+ except ImportError as e:
14
+ msg = 'Install python3-commons[authn] to use this feature'
15
+ raise RuntimeError(msg) from e
16
+
17
+ import msgspec
18
+
19
+ logger = logging.getLogger(__name__)
20
+ _OIDC_LOCK = threading.Lock()
21
+
22
+
23
+ class TokenData(msgspec.Struct):
24
+ exp: int
25
+ iat: int
26
+ iss: str
27
+ sub: str
28
+ aud: str | Sequence[str] | None = None
29
+ email: str | None = None
30
+ name: str | None = None
31
+ preferred_username: str | None = None
32
+ realm_access: dict[str, Sequence[str]] | None = None
33
+ resource_access: dict[str, dict[str, Sequence[str]]] | None = None
34
+
35
+ @property
36
+ def roles(self) -> list[str]:
37
+ roles_list = []
38
+
39
+ if self.realm_access:
40
+ roles_list.extend(self.realm_access.get('roles', []))
41
+
42
+ if self.resource_access:
43
+ for client in self.resource_access.values():
44
+ roles_list.extend(client.get('roles', []))
45
+
46
+ return list(set(roles_list))
47
+
48
+
49
+ T = TypeVar('T', bound=TokenData)
50
+
51
+
52
+ class OIDCTokenResponse(msgspec.Struct):
53
+ access_token: str
54
+ token_type: str
55
+ expires_in: int
56
+ refresh_token: str
57
+ scope: str
58
+ id_token: str
59
+ error: str | None = None
60
+ error_description: str | None = None
61
+
62
+
63
+ class OIDCError(Exception):
64
+ pass
65
+
66
+
67
+ class OIDCAuthError(OIDCError):
68
+ pass
69
+
70
+
71
+ # TODO: use api_client
72
+ class OIDCClient:
73
+ def __init__(
74
+ self,
75
+ authority_url: HttpUrl,
76
+ client_id: str,
77
+ client_secret: str | None = None,
78
+ *,
79
+ timeout: float = 10.0,
80
+ verify_ssl: bool = True,
81
+ connection_limit: int = 100,
82
+ authority_internal_host: HttpUrl | None = None,
83
+ ) -> None:
84
+ if authority_internal_host:
85
+ authority_url = replace_origin(authority_url, authority_internal_host)
86
+
87
+ self._authority_url = authority_url
88
+ self._authority_internal_host = authority_internal_host
89
+ self._client_id = client_id
90
+ self._client_secret = client_secret
91
+
92
+ self._connection_limit = connection_limit
93
+ self._session: aiohttp.ClientSession | None = None
94
+ self._timeout = timeout
95
+ self._verify_ssl = verify_ssl
96
+
97
+ self._config: Mapping[str, Any] | None = None
98
+ self._jwks: Mapping[str, Any] | None = None
99
+
100
+ def get_session(self) -> aiohttp.ClientSession:
101
+ if self._session:
102
+ return self._session
103
+
104
+ with _OIDC_LOCK:
105
+ if self._session:
106
+ return self._session
107
+
108
+ connector = aiohttp.TCPConnector(verify_ssl=self._verify_ssl, limit=self._connection_limit)
109
+ timeout = aiohttp.ClientTimeout(total=self._timeout)
110
+ session = aiohttp.ClientSession(connector=connector, timeout=timeout)
111
+ self._session = session
112
+
113
+ return session
114
+
115
+ async def __aenter__(self) -> Self:
116
+ self.get_session()
117
+
118
+ return self
119
+
120
+ async def __aexit__(self, *_: object) -> None:
121
+ if self._session:
122
+ await self._session.close()
123
+
124
+ async def _fetch_config(self) -> dict:
125
+ """
126
+ Fetch the OpenID configuration (including JWKS URI) from OIDC authority.
127
+ """
128
+ if self._session is None:
129
+ msg = 'ClientSession not initialized'
130
+ raise RuntimeError(msg)
131
+
132
+ oidc_config_url = f'{self._authority_url}/.well-known/openid-configuration'
133
+
134
+ logger.debug('Fetching OpenID configuration from: %s', oidc_config_url)
135
+
136
+ async with self._session.get(oidc_config_url) as response:
137
+ if response.status != HTTPStatus.OK:
138
+ _msg = 'Failed to fetch OpenID configuration'
139
+
140
+ raise RuntimeError(_msg)
141
+
142
+ return await response.json()
143
+
144
+ async def get_config(self) -> Mapping[str, Any]:
145
+ if self._config:
146
+ return self._config
147
+
148
+ with _OIDC_LOCK:
149
+ if self._config:
150
+ return self._config
151
+
152
+ config = await self._fetch_config()
153
+ self._config = config
154
+
155
+ return config
156
+
157
+ async def _fetch_jwks(self, jwks_uri: str) -> dict:
158
+ """
159
+ Fetch the JSON Web Key Set (JWKS) for validating the token's signature.
160
+ """
161
+ if self._session is None:
162
+ msg = 'ClientSession not initialized'
163
+ raise RuntimeError(msg)
164
+
165
+ if authority_internal_host := self._authority_internal_host:
166
+ logger.debug('Received jwks_uri: %s', jwks_uri)
167
+ logger.debug('Replacing OIDC authority host with: %s', authority_internal_host)
168
+ jwks_uri = str(replace_origin(HttpUrl(jwks_uri), authority_internal_host))
169
+ logger.debug('Modified jwks_uri: %s', jwks_uri)
170
+
171
+ async with self._session.get(jwks_uri) as response:
172
+ if response.status != HTTPStatus.OK:
173
+ _msg = 'Failed to fetch JWKS'
174
+
175
+ raise RuntimeError(_msg)
176
+
177
+ return await response.json()
178
+
179
+ async def get_jwks(self) -> Mapping[str, Any]:
180
+ if self._jwks:
181
+ return self._jwks
182
+
183
+ with _OIDC_LOCK:
184
+ if self._jwks:
185
+ return self._jwks
186
+
187
+ oidc_config = await self.get_config()
188
+
189
+ jwks = await self._fetch_jwks(oidc_config['jwks_uri'])
190
+ self._jwks = jwks
191
+
192
+ return jwks
193
+
194
+ async def fetch_token(
195
+ self,
196
+ *,
197
+ username: str,
198
+ password: str,
199
+ scope: str = 'openid profile email',
200
+ ) -> OIDCTokenResponse:
201
+ if self._session is None:
202
+ msg = 'ClientSession not initialized'
203
+ raise RuntimeError(msg)
204
+
205
+ data = {
206
+ 'grant_type': 'password',
207
+ 'username': username,
208
+ 'password': password,
209
+ 'client_id': self._client_id,
210
+ 'scope': scope,
211
+ }
212
+
213
+ if self._client_secret:
214
+ data['client_secret'] = self._client_secret
215
+
216
+ openid_config = await self.get_config()
217
+
218
+ try:
219
+ async with self._session.post(
220
+ openid_config['token_endpoint'],
221
+ data=data,
222
+ headers={'Content-Type': 'application/x-www-form-urlencoded'},
223
+ ) as response:
224
+ payload = await response.read()
225
+
226
+ try:
227
+ body = msgspec.json.decode(payload)
228
+ except Exception as e:
229
+ msg = f'Non-JSON response from OIDC provider: {payload[:300]!r}'
230
+ raise OIDCError(msg) from e
231
+
232
+ if response.status >= 400:
233
+ error = None
234
+ description = None
235
+
236
+ if isinstance(body, dict):
237
+ error = body.get('error') or body.get('code')
238
+ description = body.get('error_description') or body.get('message') or ''
239
+
240
+ error = error or f'http_{response.status}'
241
+
242
+ if error in {'invalid_grant', 'invalid_client'}:
243
+ msg = f'{error}: {description}'
244
+ raise OIDCAuthError(msg)
245
+
246
+ msg = f'{error}: {description}'
247
+ raise OIDCError(msg)
248
+
249
+ decoder = msgspec.json.Decoder(OIDCTokenResponse)
250
+
251
+ return decoder.decode(payload)
252
+
253
+ except TimeoutError as e:
254
+ msg = 'OIDC request timed out'
255
+ raise OIDCError(msg) from e
256
+ except aiohttp.ClientError as e:
257
+ msg = 'OIDC transport error'
258
+ raise OIDCError(msg) from e
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python3-commons
3
- Version: 0.18.22
3
+ Version: 0.20.0
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
@@ -36,7 +36,7 @@ Provides-Extra: cache
36
36
  Requires-Dist: valkey[libvalkey]~=6.1.1; extra == "cache"
37
37
  Provides-Extra: database
38
38
  Requires-Dist: asyncpg~=0.31.0; extra == "database"
39
- Requires-Dist: SQLAlchemy[asyncio]~=2.0.49; extra == "database"
39
+ Requires-Dist: SQLAlchemy[asyncio]~=2.0.50; extra == "database"
40
40
  Provides-Extra: object-storage
41
41
  Requires-Dist: aiobotocore~=3.7.0; extra == "object-storage"
42
42
  Requires-Dist: object-storage-client==0.0.30; extra == "object-storage"
@@ -59,6 +59,8 @@ src/python3_commons/serializers/msgpack.py
59
59
  src/python3_commons/serializers/msgspec.py
60
60
  tests/__init__.py
61
61
  tests/integration/__init__.py
62
+ tests/integration/conftest.py
63
+ tests/integration/test_auth.py
62
64
  tests/integration/test_cache.py
63
65
  tests/integration/test_osc.py
64
66
  tests/unit/__init__.py
@@ -29,7 +29,7 @@ valkey[libvalkey]~=6.1.1
29
29
 
30
30
  [database]
31
31
  asyncpg~=0.31.0
32
- SQLAlchemy[asyncio]~=2.0.49
32
+ SQLAlchemy[asyncio]~=2.0.50
33
33
 
34
34
  [object-storage]
35
35
  aiobotocore~=3.7.0
@@ -0,0 +1,29 @@
1
+ from os import getenv
2
+
3
+ import pytest
4
+ from pydantic import HttpUrl
5
+
6
+
7
+ @pytest.fixture(scope='session')
8
+ def authority_url() -> HttpUrl:
9
+ return HttpUrl(getenv('TEST_OIDC_AUTHORITY_URL', ''))
10
+
11
+
12
+ @pytest.fixture(scope='session')
13
+ def client_id() -> str:
14
+ return getenv('TEST_OIDC_CLIENT_ID', '')
15
+
16
+
17
+ @pytest.fixture(scope='session')
18
+ def client_secret() -> str:
19
+ return getenv('TEST_OIDC_CLIENT_SECRET', '')
20
+
21
+
22
+ @pytest.fixture(scope='session')
23
+ def oidc_username() -> str:
24
+ return getenv('TEST_OIDC_USERNAME', '')
25
+
26
+
27
+ @pytest.fixture(scope='session')
28
+ def oidc_password() -> str:
29
+ return getenv('TEST_OIDC_PASSWORD', '')
@@ -0,0 +1,29 @@
1
+ from typing import TYPE_CHECKING
2
+
3
+ import pytest
4
+
5
+ if TYPE_CHECKING:
6
+ from pydantic import HttpUrl
7
+
8
+ from python3_commons.auth import OIDCClient
9
+
10
+
11
+ @pytest.mark.asyncio
12
+ async def test_get_token_cognito(
13
+ authority_url: HttpUrl, client_id: str, client_secret: str, oidc_username: str, oidc_password: str
14
+ ) -> None:
15
+ async with (
16
+ OIDCClient(
17
+ authority_url=authority_url,
18
+ client_id=client_id,
19
+ client_secret=client_secret,
20
+ verify_ssl=False,
21
+ timeout=10,
22
+ ) as client,
23
+ ):
24
+ token = await client.fetch_token(
25
+ username=oidc_username,
26
+ password=oidc_password,
27
+ )
28
+
29
+ assert token.access_token
@@ -1171,7 +1171,7 @@ requires-dist = [
1171
1171
  { name = "python3-commons", extras = ["database"], marker = "extra == 'authz'" },
1172
1172
  { name = "python3-commons", extras = ["object-storage"], marker = "extra == 'audit'" },
1173
1173
  { name = "requests", marker = "extra == 'soap-client'", specifier = "~=2.34.2" },
1174
- { name = "sqlalchemy", extras = ["asyncio"], marker = "extra == 'database'", specifier = "~=2.0.49" },
1174
+ { name = "sqlalchemy", extras = ["asyncio"], marker = "extra == 'database'", specifier = "~=2.0.50" },
1175
1175
  { name = "valkey", extras = ["libvalkey"], marker = "extra == 'cache'", specifier = "~=6.1.1" },
1176
1176
  { name = "zeep", extras = ["async"], marker = "extra == 'audit'", specifier = "~=4.3.2" },
1177
1177
  { name = "zeep", extras = ["async"], marker = "extra == 'soap-client'", specifier = "~=4.3.2" },
@@ -1331,28 +1331,29 @@ wheels = [
1331
1331
 
1332
1332
  [[package]]
1333
1333
  name = "sqlalchemy"
1334
- version = "2.0.49"
1334
+ version = "2.0.50"
1335
1335
  source = { registry = "https://pypi.org/simple" }
1336
1336
  dependencies = [
1337
1337
  { name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'" },
1338
1338
  { name = "typing-extensions" },
1339
1339
  ]
1340
- sdist = { url = "https://files.pythonhosted.org/packages/09/45/461788f35e0364a8da7bda51a1fe1b09762d0c32f12f63727998d85a873b/sqlalchemy-2.0.49.tar.gz", hash = "sha256:d15950a57a210e36dd4cec1aac22787e2a4d57ba9318233e2ef8b2daf9ff2d5f", size = 9898221, upload-time = "2026-04-03T16:38:11.704Z" }
1341
- wheels = [
1342
- { url = "https://files.pythonhosted.org/packages/55/33/bf28f618c0a9597d14e0b9ee7d1e0622faff738d44fe986ee287cdf1b8d0/sqlalchemy-2.0.49-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:233088b4b99ebcbc5258c755a097aa52fbf90727a03a5a80781c4b9c54347a2e", size = 2156356, upload-time = "2026-04-03T16:53:09.914Z" },
1343
- { url = "https://files.pythonhosted.org/packages/d1/a7/5f476227576cb8644650eff68cc35fa837d3802b997465c96b8340ced1e2/sqlalchemy-2.0.49-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:57ca426a48eb2c682dae8204cd89ea8ab7031e2675120a47924fabc7caacbc2a", size = 3276486, upload-time = "2026-04-03T17:07:46.9Z" },
1344
- { url = "https://files.pythonhosted.org/packages/2e/84/efc7c0bf3a1c5eef81d397f6fddac855becdbb11cb38ff957888603014a7/sqlalchemy-2.0.49-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:685e93e9c8f399b0c96a624799820176312f5ceef958c0f88215af4013d29066", size = 3281479, upload-time = "2026-04-03T17:12:32.226Z" },
1345
- { url = "https://files.pythonhosted.org/packages/91/68/bb406fa4257099c67bd75f3f2261b129c63204b9155de0d450b37f004698/sqlalchemy-2.0.49-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9e0400fa22f79acc334d9a6b185dc00a44a8e6578aa7e12d0ddcd8434152b187", size = 3226269, upload-time = "2026-04-03T17:07:48.678Z" },
1346
- { url = "https://files.pythonhosted.org/packages/67/84/acb56c00cca9f251f437cb49e718e14f7687505749ea9255d7bd8158a6df/sqlalchemy-2.0.49-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a05977bffe9bffd2229f477fa75eabe3192b1b05f408961d1bebff8d1cd4d401", size = 3248260, upload-time = "2026-04-03T17:12:34.381Z" },
1347
- { url = "https://files.pythonhosted.org/packages/56/19/6a20ea25606d1efd7bd1862149bb2a22d1451c3f851d23d887969201633f/sqlalchemy-2.0.49-cp314-cp314-win32.whl", hash = "sha256:0f2fa354ba106eafff2c14b0cc51f22801d1e8b2e4149342023bd6f0955de5f5", size = 2118463, upload-time = "2026-04-03T17:05:47.093Z" },
1348
- { url = "https://files.pythonhosted.org/packages/cf/4f/8297e4ed88e80baa1f5aa3c484a0ee29ef3c69c7582f206c916973b75057/sqlalchemy-2.0.49-cp314-cp314-win_amd64.whl", hash = "sha256:77641d299179c37b89cf2343ca9972c88bb6eef0d5fc504a2f86afd15cd5adf5", size = 2144204, upload-time = "2026-04-03T17:05:48.694Z" },
1349
- { url = "https://files.pythonhosted.org/packages/1f/33/95e7216df810c706e0cd3655a778604bbd319ed4f43333127d465a46862d/sqlalchemy-2.0.49-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c1dc3368794d522f43914e03312202523cc89692f5389c32bea0233924f8d977", size = 3565474, upload-time = "2026-04-03T16:58:35.128Z" },
1350
- { url = "https://files.pythonhosted.org/packages/0c/a4/ed7b18d8ccf7f954a83af6bb73866f5bc6f5636f44c7731fbb741f72cc4f/sqlalchemy-2.0.49-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7c821c47ecfe05cc32140dcf8dc6fd5d21971c86dbd56eabfe5ba07a64910c01", size = 3530567, upload-time = "2026-04-03T17:06:04.587Z" },
1351
- { url = "https://files.pythonhosted.org/packages/73/a3/20faa869c7e21a827c4a2a42b41353a54b0f9f5e96df5087629c306df71e/sqlalchemy-2.0.49-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:9c04bff9a5335eb95c6ecf1c117576a0aa560def274876fd156cfe5510fccc61", size = 3474282, upload-time = "2026-04-03T16:58:37.131Z" },
1352
- { url = "https://files.pythonhosted.org/packages/b7/50/276b9a007aa0764304ad467eceb70b04822dc32092492ee5f322d559a4dc/sqlalchemy-2.0.49-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7f605a456948c35260e7b2a39f8952a26f077fd25653c37740ed186b90aaa68a", size = 3480406, upload-time = "2026-04-03T17:06:07.176Z" },
1353
- { url = "https://files.pythonhosted.org/packages/e5/c3/c80fcdb41905a2df650c2a3e0337198b6848876e63d66fe9188ef9003d24/sqlalchemy-2.0.49-cp314-cp314t-win32.whl", hash = "sha256:6270d717b11c5476b0cbb21eedc8d4dbb7d1a956fd6c15a23e96f197a6193158", size = 2149151, upload-time = "2026-04-03T17:02:07.281Z" },
1354
- { url = "https://files.pythonhosted.org/packages/05/52/9f1a62feab6ed368aff068524ff414f26a6daebc7361861035ae00b05530/sqlalchemy-2.0.49-cp314-cp314t-win_amd64.whl", hash = "sha256:275424295f4256fd301744b8f335cff367825d270f155d522b30c7bf49903ee7", size = 2184178, upload-time = "2026-04-03T17:02:08.623Z" },
1355
- { url = "https://files.pythonhosted.org/packages/e5/30/8519fdde58a7bdf155b714359791ad1dc018b47d60269d5d160d311fdc36/sqlalchemy-2.0.49-py3-none-any.whl", hash = "sha256:ec44cfa7ef1a728e88ad41674de50f6db8cfdb3e2af84af86e0041aaf02d43d0", size = 1942158, upload-time = "2026-04-03T16:53:44.135Z" },
1340
+ sdist = { url = "https://files.pythonhosted.org/packages/57/da/6fbf010c8ebb347679d0d100b22fe9ba5e13fd04046c5df7280d2f0bf706/sqlalchemy-2.0.50.tar.gz", hash = "sha256:af5607d11ef90fd6a5c0549fe0045dce1663d427426bcfb506dcb5346a85a3b9", size = 9907424, upload-time = "2026-05-24T19:20:04.018Z" }
1341
+ wheels = [
1342
+ { url = "https://files.pythonhosted.org/packages/df/32/10ac51b4be7cdecd7e93d069251c86dfbf70b7adbd7c67b48ccea6c49e1c/sqlalchemy-2.0.50-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c966932507a4d7d0a37314927dbfcd89720e3f37d2a1e3352e7ae7939fa8e8a0", size = 2158519, upload-time = "2026-05-24T19:27:56.472Z" },
1343
+ { url = "https://files.pythonhosted.org/packages/5a/76/e703d2f7681d7d66c4c891af3f07c7ccf4c76ad7f18351de035b5eda007a/sqlalchemy-2.0.50-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:faffef4bcc20a1892e65e155293d99d60855bbbc79250ab712819cfd56a8e6bb", size = 3282063, upload-time = "2026-05-24T20:09:38.57Z" },
1344
+ { url = "https://files.pythonhosted.org/packages/31/26/ef168b184a25701f9995e8fb7e503fafd7a99c1c77cda1bc1a26ea2ed486/sqlalchemy-2.0.50-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c206aec519a2e7bd08abbfb33436e325fd22c632d9c21a9047e376ce241646e", size = 3287069, upload-time = "2026-05-24T20:17:21.942Z" },
1345
+ { url = "https://files.pythonhosted.org/packages/c2/15/765acc2bc693bccc43ca4a95d5b69750da8aaf6db1b5c616536e087f8920/sqlalchemy-2.0.50-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:bef4ac756363227ef6402a75fee025a4bc690f92328e825868939b3b3a446a6d", size = 3230453, upload-time = "2026-05-24T20:09:40.398Z" },
1346
+ { url = "https://files.pythonhosted.org/packages/63/61/08e03c3adbf5db0087a0b6816746fec8f3032fb2f7fc899a9bb9b2a48ce4/sqlalchemy-2.0.50-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:96fbee6b19c19cd1556c8bf9419447cf2ec149ffcab7ab64348c23e54ef8547f", size = 3252413, upload-time = "2026-05-24T20:17:24.067Z" },
1347
+ { url = "https://files.pythonhosted.org/packages/03/0c/370a1f2db38436c615e10134c8a37de3688e74084792380695f3f5083860/sqlalchemy-2.0.50-cp314-cp314-win32.whl", hash = "sha256:8f00e3eb43ba30eb1b238ee03a8a62309486d1321eda3328bb611e0340033ad8", size = 2120063, upload-time = "2026-05-24T19:50:11.08Z" },
1348
+ { url = "https://files.pythonhosted.org/packages/7f/a0/fe92bb9817863bc13ba093bda931979a26cc2ca69f8e8f26d07add3d7c6f/sqlalchemy-2.0.50-cp314-cp314-win_amd64.whl", hash = "sha256:15708c613cd5005b7dffe1f66ee6a63ee8f5e46799f71c70ebad74178c676a39", size = 2145830, upload-time = "2026-05-24T19:50:12.452Z" },
1349
+ { url = "https://files.pythonhosted.org/packages/cc/ff/e5640a98a0b2f491eb8fde10fb6c773621a2e44340de231fafcc9370f4a9/sqlalchemy-2.0.50-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3699dac4be410e97049a1658e9480da9cde956594aa0f3aebc60b88f21c5ba70", size = 2178435, upload-time = "2026-05-24T19:42:58.889Z" },
1350
+ { url = "https://files.pythonhosted.org/packages/b7/85/337116e186f1236375b5fb70c21cfac98e8e8ab0d3a47be838dc47a59e08/sqlalchemy-2.0.50-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f96233858e3df43932ac11589e22520da6e8aeb624b03fedfeebb0e8ea213086", size = 3566059, upload-time = "2026-05-24T20:01:20.848Z" },
1351
+ { url = "https://files.pythonhosted.org/packages/96/34/bb0e190e161c3c2c24314a65add57218be14a4a9486886b7f5047c1ff7c8/sqlalchemy-2.0.50-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c4e70c46fad30c3bcc6a4708bc0130a3173e11a5b25f0ea4a9d8911b450f1f52", size = 3535366, upload-time = "2026-05-24T20:03:56.768Z" },
1352
+ { url = "https://files.pythonhosted.org/packages/df/5a/a7f759f97e4fd499c5d4e4488c760d5a7fbecf3028b465a04274fcd52384/sqlalchemy-2.0.50-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1918a3cf564d16d95bca7301005f41ab2ad50b07cd3b9da50d3ed986db148d6a", size = 3474879, upload-time = "2026-05-24T20:01:23.058Z" },
1353
+ { url = "https://files.pythonhosted.org/packages/9d/d9/2907ea38eb60687d297bf9c39e5ee58053c87b57fe8a9cae97090cecbf10/sqlalchemy-2.0.50-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b00098cdbdbd38c7be3d568b0c9c3122b8c0ec62b911b57cd5e6e0254d60a76d", size = 3486117, upload-time = "2026-05-24T20:03:59.052Z" },
1354
+ { url = "https://files.pythonhosted.org/packages/f2/e3/5aa06f167559f8c0bdae487e297d23ba548150ab016a3418265d617a4985/sqlalchemy-2.0.50-cp314-cp314t-win32.whl", hash = "sha256:1fbd55a969d7ac44a98e3dec75016074f809fa08f871585ace58dde110d1bf3e", size = 2150823, upload-time = "2026-05-24T20:08:58.644Z" },
1355
+ { url = "https://files.pythonhosted.org/packages/65/9b/112fb8f977582d7489d036e409e3723948bcf5320b3ac465f3c481bbe8f9/sqlalchemy-2.0.50-cp314-cp314t-win_amd64.whl", hash = "sha256:c5c3cdb753a9004183e1ccb634b41611654c989e61bc68617ce878e46d6f1e51", size = 2185794, upload-time = "2026-05-24T20:09:00.319Z" },
1356
+ { url = "https://files.pythonhosted.org/packages/d0/10/f7220e9b784d295d241c86ed99aeb537f92afcd469a64861f2717e9bb077/sqlalchemy-2.0.50-py3-none-any.whl", hash = "sha256:92064363517a3ff8212b5a93b8c62876579d8dfd1ca5b561335f30152d884fa9", size = 1943861, upload-time = "2026-05-24T19:59:01.119Z" },
1356
1357
  ]
1357
1358
 
1358
1359
  [package.optional-dependencies]
@@ -1,198 +0,0 @@
1
- from __future__ import annotations
2
-
3
- import logging
4
- from collections.abc import Sequence
5
- from http import HTTPStatus
6
- from typing import Self, TypeVar
7
-
8
- from pydantic import HttpUrl
9
-
10
- from python3_commons.helpers import replace_origin
11
-
12
- try:
13
- import aiohttp
14
- except ImportError as e:
15
- msg = 'Install python3-commons[authn] to use this feature'
16
- raise RuntimeError(msg) from e
17
-
18
- import msgspec
19
-
20
- from python3_commons.conf import oidc_settings
21
-
22
- logger = logging.getLogger(__name__)
23
-
24
-
25
- class TokenData(msgspec.Struct):
26
- exp: int
27
- iat: int
28
- iss: str
29
- sub: str
30
- aud: str | Sequence[str] | None = None
31
- email: str | None = None
32
- name: str | None = None
33
- preferred_username: str | None = None
34
- realm_access: dict[str, Sequence[str]] | None = None
35
- resource_access: dict[str, dict[str, Sequence[str]]] | None = None
36
-
37
- @property
38
- def roles(self) -> list[str]:
39
- roles_list = []
40
-
41
- if self.realm_access:
42
- roles_list.extend(self.realm_access.get('roles', []))
43
-
44
- if self.resource_access:
45
- for client in self.resource_access.values():
46
- roles_list.extend(client.get('roles', []))
47
-
48
- return list(set(roles_list))
49
-
50
-
51
- T = TypeVar('T', bound=TokenData)
52
-
53
-
54
- class OIDCTokenResponse(msgspec.Struct):
55
- access_token: str
56
- token_type: str
57
- expires_in: int
58
- refresh_token: str
59
- scope: str
60
- id_token: str
61
- error: str | None = None
62
- error_description: str | None = None
63
-
64
-
65
- async def fetch_openid_config() -> dict:
66
- """
67
- Fetch the OpenID configuration (including JWKS URI) from OIDC authority.
68
- """
69
- if (authority_url := oidc_settings.authority_url) is None:
70
- msg = 'OIDC authority URL is required'
71
- raise ValueError(msg)
72
-
73
- if oidc_settings.authority_internal_host:
74
- authority_url = replace_origin(authority_url, oidc_settings.authority_internal_host)
75
-
76
- oidc_config_url = f'{authority_url}/.well-known/openid-configuration'
77
-
78
- logger.debug('Fetching OpenID configuration from: %s', oidc_config_url)
79
-
80
- async with aiohttp.ClientSession() as session, session.get(oidc_config_url) as response:
81
- if response.status != HTTPStatus.OK:
82
- _msg = 'Failed to fetch OpenID configuration'
83
-
84
- raise RuntimeError(_msg)
85
-
86
- return await response.json()
87
-
88
-
89
- async def fetch_jwks(jwks_uri: str) -> dict:
90
- """
91
- Fetch the JSON Web Key Set (JWKS) for validating the token's signature.
92
- """
93
- if authority_internal_host := oidc_settings.authority_internal_host:
94
- logger.debug('Received jwks_uri: %s', jwks_uri)
95
- logger.debug('Replacing OIDC authority host with: %s', authority_internal_host)
96
- jwks_uri = str(replace_origin(HttpUrl(jwks_uri), authority_internal_host))
97
- logger.debug('Modified jwks_uri: %s', jwks_uri)
98
-
99
- async with aiohttp.ClientSession() as session, session.get(jwks_uri) as response:
100
- if response.status != HTTPStatus.OK:
101
- _msg = 'Failed to fetch JWKS'
102
-
103
- raise RuntimeError(_msg)
104
-
105
- return await response.json()
106
-
107
-
108
- class OIDCError(Exception):
109
- pass
110
-
111
-
112
- class OIDCAuthError(OIDCError):
113
- pass
114
-
115
-
116
- # TODO: use api_client
117
- class OIDCClient:
118
- def __init__(
119
- self,
120
- authority_url: str,
121
- client_id: str,
122
- client_secret: str | None = None,
123
- *,
124
- timeout: float = 10.0,
125
- session: aiohttp.ClientSession | None = None,
126
- ) -> None:
127
- self._token_url = f'{authority_url}/protocol/openid-connect/token' # TODO: get it from openid-configuration
128
- self._client_id = client_id
129
- self._client_secret = client_secret
130
- self._timeout = aiohttp.ClientTimeout(total=timeout)
131
- self._session = session
132
- self._owns_session = session is None
133
-
134
- async def __aenter__(self) -> Self:
135
- if self._session is None:
136
- self._session = aiohttp.ClientSession(timeout=self._timeout)
137
- return self
138
-
139
- async def __aexit__(self, *_: object) -> None:
140
- if self._owns_session and self._session:
141
- await self._session.close()
142
-
143
- async def fetch_token(
144
- self,
145
- *,
146
- username: str,
147
- password: str,
148
- scope: str = 'openid profile email',
149
- ) -> OIDCTokenResponse:
150
- if self._session is None:
151
- msg = 'ClientSession not initialized'
152
-
153
- raise RuntimeError(msg)
154
-
155
- data = {
156
- 'grant_type': 'password',
157
- 'username': username,
158
- 'password': password,
159
- 'client_id': self._client_id,
160
- 'scope': scope,
161
- }
162
-
163
- if self._client_secret:
164
- data['client_secret'] = self._client_secret
165
-
166
- try:
167
- async with self._session.post(
168
- self._token_url,
169
- data=data,
170
- headers={'Content-Type': 'application/x-www-form-urlencoded'},
171
- ) as resp:
172
- payload = await resp.read()
173
- decoder = msgspec.json.Decoder(type=OIDCTokenResponse)
174
- token = decoder.decode(payload)
175
-
176
- except TimeoutError as e:
177
- msg = 'OIDC request timed out'
178
-
179
- raise OIDCError(msg) from e
180
- except aiohttp.ClientError as e:
181
- msg = 'OIDC transport error'
182
-
183
- raise OIDCError(msg) from e
184
-
185
- if not resp.ok:
186
- error = token.error
187
- description = token.error_description
188
-
189
- if error in {'invalid_grant', 'invalid_client'}:
190
- msg = f'{error}: {description}'
191
-
192
- raise OIDCAuthError(msg)
193
-
194
- msg = f'{error}: {description}'
195
-
196
- raise OIDCError(msg)
197
-
198
- return token