aiohttp-msal 1.0.6__py3-none-any.whl → 1.0.8__py3-none-any.whl

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.
aiohttp_msal/__init__.py CHANGED
@@ -1,11 +1,10 @@
1
1
  """aiohttp_msal."""
2
2
 
3
- import json
4
3
  import logging
5
4
  from collections.abc import Awaitable, Callable
6
5
  from functools import wraps
7
6
  from inspect import getfullargspec, iscoroutinefunction
8
- from typing import Any, TypeVar, TypeVarTuple, cast
7
+ from typing import cast
9
8
 
10
9
  from aiohttp import ClientSession, web
11
10
  from aiohttp_session import get_session
@@ -17,15 +16,12 @@ from aiohttp_msal.utils import retry
17
16
 
18
17
  _LOG = logging.getLogger(__name__)
19
18
 
20
- _T = TypeVar("_T")
21
- Ts = TypeVarTuple("Ts")
22
19
 
23
-
24
- def msal_session(
20
+ def msal_session[T, *Ts](
25
21
  *callbacks: Callable[[AsyncMSAL], bool | Awaitable[bool]],
26
22
  at_least_one: bool | None = False,
27
23
  ) -> Callable[
28
- [Callable[[*Ts, AsyncMSAL], Awaitable[_T]]], Callable[[*Ts], Awaitable[_T]]
24
+ [Callable[[*Ts, AsyncMSAL], Awaitable[T]]], Callable[[*Ts], Awaitable[T]]
29
25
  ]:
30
26
  """Session decorator.
31
27
 
@@ -33,10 +29,10 @@ def msal_session(
33
29
  """
34
30
 
35
31
  def check_session(
36
- func: Callable[[*Ts, AsyncMSAL], Awaitable[_T]],
37
- ) -> Callable[[*Ts], Awaitable[_T]]:
32
+ func: Callable[[*Ts, AsyncMSAL], Awaitable[T]],
33
+ ) -> Callable[[*Ts], Awaitable[T]]:
38
34
  @wraps(func)
39
- async def wrapper(*args: *Ts) -> _T:
35
+ async def wrapper(*args: *Ts) -> T:
40
36
  if len(args) < 1:
41
37
  raise AssertionError("Requires a Request as the first parameter")
42
38
  request = cast(web.Request, args[0])
@@ -92,8 +88,6 @@ async def app_init_redis_session(
92
88
  app: web.Application,
93
89
  max_age: int = 3600 * 24 * 90,
94
90
  check_proxy_cb: Callable[[], Awaitable[None]] | None = None,
95
- encoder: Callable[[object], str] = json.dumps,
96
- decoder: Callable[[str], Any] = json.loads,
97
91
  ) -> None:
98
92
  """Init an aiohttp_session with Redis storage helper.
99
93
 
@@ -107,12 +101,13 @@ async def app_init_redis_session(
107
101
  else:
108
102
  await check_proxy()
109
103
 
110
- _LOG.info("Connect to Redis %s", ENV.REDIS)
111
- try:
112
- ENV.database = from_url(ENV.REDIS)
113
- # , encoding="utf-8", decode_responses=True
114
- except ConnectionRefusedError as err:
115
- raise ConnectionError("Could not connect to REDIS server") from err
104
+ if ENV.database is None:
105
+ _LOG.info("Connect to Redis %s", ENV.REDIS)
106
+ try:
107
+ ENV.database = from_url(ENV.REDIS)
108
+ # , encoding="utf-8", decode_responses=True
109
+ except ConnectionRefusedError as err:
110
+ raise ConnectionError("Could not connect to REDIS server") from err
116
111
 
117
112
  storage = redis_storage.RedisStorage(
118
113
  ENV.database,
@@ -123,8 +118,8 @@ async def app_init_redis_session(
123
118
  secure=True,
124
119
  domain=ENV.DOMAIN,
125
120
  cookie_name=ENV.COOKIE_NAME,
126
- encoder=encoder,
127
- decoder=decoder,
121
+ encoder=ENV.json_dumps,
122
+ decoder=ENV.json_loads,
128
123
  )
129
124
  _setup(app, storage)
130
125
 
@@ -132,13 +127,13 @@ async def app_init_redis_session(
132
127
  @retry
133
128
  async def check_proxy() -> None:
134
129
  """Test if we have Internet connectivity through proxies etc."""
135
- try:
136
- async with ClientSession(trust_env=True) as cses:
137
- async with cses.get("http://httpbin.org/get") as resp:
130
+ print("Check Internet connectivity for OAuth", end="", flush=True)
131
+ async with ClientSession(trust_env=True) as cses:
132
+ for url in ("https://www.google.com", "http://httpbin.org/get"):
133
+ async with cses.get(url) as resp:
138
134
  if resp.ok:
135
+ print(" ... ok", flush=True)
139
136
  return
140
- raise ConnectionError(await resp.text())
141
- except Exception as err:
142
- raise ConnectionError(
143
- "No connection to the Internet. Required for OAuth. Check your Proxy?"
144
- ) from err
137
+ raise ConnectionError(
138
+ "No connection to the Internet. Required for OAuth. Check your Proxy?"
139
+ )
@@ -6,11 +6,10 @@ Once you have the OAuth tokens store in the session, you are free to make reques
6
6
  """
7
7
 
8
8
  import asyncio
9
- import json
10
9
  import logging
11
10
  from collections.abc import Callable
12
11
  from functools import cached_property, partialmethod
13
- from typing import Any, ClassVar, Literal, Self, TypeVar, Unpack, cast
12
+ from typing import Any, ClassVar, Literal, Self, Unpack, cast
14
13
 
15
14
  import attrs
16
15
  from aiohttp import web
@@ -38,12 +37,10 @@ HTTP_PATCH = "patch"
38
37
  HTTP_DELETE = "delete"
39
38
  HTTP_ALLOWED = [HTTP_GET, HTTP_POST, HTTP_PUT, HTTP_PATCH, HTTP_DELETE]
40
39
 
41
- T = TypeVar("T")
42
-
43
40
 
44
41
  @attrs.define(slots=False)
45
42
  class AsyncMSAL:
46
- """AsycMSAL class.
43
+ """AsyncMSAL class.
47
44
 
48
45
  Authorization Code Flow Helper. Learn more about auth-code-flow at
49
46
  https://learn.microsoft.com/en-us/entra/identity-platform/v2-oauth2-auth-code-flow
@@ -220,7 +217,7 @@ class AsyncMSAL:
220
217
  elif method in [HTTP_POST, HTTP_PUT, HTTP_PATCH]:
221
218
  headers["Content-type"] = "application/json"
222
219
  if "data" in kwargs:
223
- kwargs["data"] = json.dumps(kwargs["data"]) # auto convert to json
220
+ kwargs["data"] = ENV.json_dumps(kwargs["data"]) # auto convert to json
224
221
 
225
222
  if not AsyncMSAL.client_session:
226
223
  AsyncMSAL.client_session = ClientSession(trust_env=True)
@@ -1,17 +1,16 @@
1
1
  """Redis tools for sessions."""
2
2
 
3
3
  import asyncio
4
- import json
5
4
  import logging
6
5
  import time
7
6
  from collections.abc import AsyncGenerator
8
7
  from contextlib import AsyncExitStack, asynccontextmanager
9
- from typing import Any, TypeVar
8
+ from typing import Any
10
9
 
11
10
  from redis.asyncio import Redis, from_url
12
11
 
13
12
  from aiohttp_msal.msal_async import AsyncMSAL
14
- from aiohttp_msal.settings import ENV as MENV
13
+ from aiohttp_msal.settings import ENV
15
14
 
16
15
  _LOG = logging.getLogger(__name__)
17
16
 
@@ -21,17 +20,17 @@ SES_KEYS = ("mail", "name", "m_mail", "m_name")
21
20
  @asynccontextmanager
22
21
  async def get_redis() -> AsyncGenerator[Redis, None]:
23
22
  """Get a Redis connection."""
24
- if MENV.database:
23
+ if ENV.database:
25
24
  _LOG.debug("Using redis from environment")
26
- yield MENV.database
25
+ yield ENV.database
27
26
  return
28
- _LOG.info("Connect to Redis %s", MENV.REDIS)
29
- redis = from_url(MENV.REDIS) # decode_responses=True not allowed aiohttp_session
30
- MENV.database = redis
27
+ _LOG.info("Connect to Redis %s", ENV.REDIS)
28
+ redis = from_url(ENV.REDIS) # decode_responses=True not allowed aiohttp_session
29
+ ENV.database = redis
31
30
  try:
32
31
  yield redis
33
32
  finally:
34
- MENV.database = None # type:ignore[assignment]
33
+ ENV.database = None # type:ignore[assignment]
35
34
  await redis.close()
36
35
 
37
36
 
@@ -50,14 +49,14 @@ async def session_iter(
50
49
  if match and not all(isinstance(v, str) for v in match.values()):
51
50
  raise ValueError("match values must be strings")
52
51
  async for key in redis.scan_iter(
53
- count=100, match=key_match or f"{MENV.COOKIE_NAME}*"
52
+ count=100, match=key_match or f"{ENV.COOKIE_NAME}*"
54
53
  ):
55
54
  if not isinstance(key, str):
56
55
  key = key.decode()
57
56
  sval = await redis.get(key)
58
57
  created, ses = 0, {}
59
58
  try:
60
- val = json.loads(sval) # type: ignore[arg-type]
59
+ val = ENV.json_loads(sval) # type: ignore[arg-type]
61
60
  created = int(val["created"])
62
61
  ses = val["session"]
63
62
  except Exception:
@@ -97,14 +96,14 @@ async def session_clean(
97
96
 
98
97
  async def invalid_sessions(redis: Redis, /) -> None:
99
98
  """Find & clean invalid sessions."""
100
- async for key in redis.scan_iter(count=100, match=f"{MENV.COOKIE_NAME}*"):
99
+ async for key in redis.scan_iter(count=100, match=f"{ENV.COOKIE_NAME}*"):
101
100
  if not isinstance(key, str):
102
101
  key = key.decode()
103
102
  sval = await redis.get(key)
104
103
  if sval is None:
105
104
  continue
106
105
  try:
107
- val: dict = json.loads(sval)
106
+ val: dict = ENV.json_loads(sval)
108
107
  assert isinstance(val["created"], int)
109
108
  assert isinstance(val["session"], dict)
110
109
  except Exception as err:
@@ -112,10 +111,7 @@ async def invalid_sessions(redis: Redis, /) -> None:
112
111
  await redis.delete(key)
113
112
 
114
113
 
115
- T = TypeVar("T", bound=AsyncMSAL)
116
-
117
-
118
- def async_msal_factory(
114
+ def async_msal_factory[T: AsyncMSAL](
119
115
  cls: type[T], key: str, created: int, session: dict[str, Any], /
120
116
  ) -> T:
121
117
  """Create a AsyncMSAL session with a save_callback.
@@ -127,7 +123,7 @@ def async_msal_factory(
127
123
  async def async_save_cache(_: dict) -> None:
128
124
  """Save the token cache to Redis."""
129
125
  async with get_redis() as rd2:
130
- await rd2.set(key, json.dumps({"created": created, "session": session}))
126
+ await rd2.set(key, ENV.json_dumps({"created": created, "session": session}))
131
127
 
132
128
  def save_cache(*args: Any) -> None:
133
129
  """Save the token cache to Redis."""
@@ -139,7 +135,7 @@ def async_msal_factory(
139
135
  return cls(session, save_callback=save_cache)
140
136
 
141
137
 
142
- async def get_session(
138
+ async def get_session[T: AsyncMSAL](
143
139
  cls: type[T],
144
140
  email: str,
145
141
  /,
@@ -163,11 +159,11 @@ async def get_session(
163
159
  raise ValueError(f"{msg} with scope {scope} not found ({cnt} checked)")
164
160
 
165
161
 
166
- async def redis_get_json(key: str) -> list[str] | dict[str, Any] | None:
162
+ async def redis_get_json(key: str) -> list[Any] | dict[str, Any] | None:
167
163
  """Get a key from redis."""
168
- res = await MENV.database.get(key)
164
+ res = await ENV.database.get(key)
169
165
  if isinstance(res, str | bytes | bytearray):
170
- return json.loads(res)
166
+ return ENV.json_loads(res)
171
167
  if res is not None:
172
168
  _LOG.warning("Unexpected type for %s: %s", key, type(res))
173
169
  return None
@@ -175,7 +171,7 @@ async def redis_get_json(key: str) -> list[str] | dict[str, Any] | None:
175
171
 
176
172
  async def redis_get(key: str) -> str | None:
177
173
  """Get a key from redis."""
178
- res = await MENV.database.get(key)
174
+ res = await ENV.database.get(key)
179
175
  if isinstance(res, str):
180
176
  return res
181
177
  if isinstance(res, bytes | bytearray):
@@ -189,22 +185,22 @@ async def redis_set_set(key: str, new_set: set[str]) -> None:
189
185
  """Set the value of a set in redis."""
190
186
  cur_set = set(
191
187
  s if isinstance(s, str) else s.decode()
192
- for s in await MENV.database.smembers(key)
188
+ for s in await ENV.database.smembers(key)
193
189
  )
194
190
  dif = list(cur_set - new_set)
195
191
  if dif:
196
192
  _LOG.warning("%s: removing %s", key, dif)
197
- await MENV.database.srem(key, *dif)
193
+ await ENV.database.srem(key, *dif)
198
194
 
199
195
  dif = list(new_set - cur_set)
200
196
  if dif:
201
197
  _LOG.info("%s: adding %s", key, dif)
202
- await MENV.database.sadd(key, *dif)
198
+ await ENV.database.sadd(key, *dif)
203
199
 
204
200
 
205
201
  async def redis_scan_keys(match_str: str) -> list[str]:
206
202
  """Return a list of matching keys."""
207
203
  return [
208
204
  s if isinstance(s, str) else s.decode()
209
- async for s in MENV.database.scan_iter(match=match_str)
205
+ async for s in ENV.database.scan_iter(match=match_str)
210
206
  ]
aiohttp_msal/settings.py CHANGED
@@ -1,5 +1,6 @@
1
1
  """Settings."""
2
2
 
3
+ import json
3
4
  from collections.abc import Awaitable, Callable
4
5
  from typing import TYPE_CHECKING, Any
5
6
 
@@ -39,8 +40,13 @@ class MSALSettings(SettingsBase):
39
40
 
40
41
  REDIS: str = "redis://redis1:6379"
41
42
  """OPTIONAL: Redis database connection used by app_init_redis_session()."""
42
- database: "Redis" = attrs.field(init=False)
43
+ database: "Redis" = attrs.field(init=False, default=None)
43
44
  """Store the Redis connection when using app_init_redis_session()."""
44
45
 
46
+ json_dumps: Callable[[Any], str] = attrs.field(default=json.dumps)
47
+ json_loads: Callable[[str | bytes | bytearray], Any] = attrs.field(
48
+ default=json.loads
49
+ )
50
+
45
51
 
46
52
  ENV = MSALSettings()
aiohttp_msal/utils.py CHANGED
@@ -3,13 +3,10 @@
3
3
  import asyncio
4
4
  from collections.abc import Awaitable, Callable
5
5
  from functools import partial, wraps
6
- from typing import Any, ParamSpec, TypeVar
6
+ from typing import Any
7
7
 
8
- T = TypeVar("T")
9
- P = ParamSpec("P")
10
8
 
11
-
12
- def async_wrap(
9
+ def async_wrap[T](
13
10
  func: Callable[..., T],
14
11
  ) -> Callable[..., Awaitable[T]]:
15
12
  """Wrap a function doing I/O to run in an executor thread."""
@@ -49,17 +46,7 @@ class dict_property(property):
49
46
  getattr(instance, self.dict_name, {}).__setitem__(self.prop_name, value)
50
47
 
51
48
 
52
- # def dict_property(dict_name: str, prop_name: str) -> property:
53
- # """Create properties for a dictionary."""
54
- # return property(
55
- # fget=lambda self: str(getattr(self, dict_name).get(prop_name, "")),
56
- # fset=lambda self, v: getattr(self, dict_name).set(prop_name, v),
57
- # fdel=lambda self: getattr(self, dict_name).pop(prop_name, None),
58
- # doc=f'self.{dict_name}["{prop_name}"]',
59
- # )
60
-
61
-
62
- def retry(func: Callable[P, Awaitable[T]]) -> Callable[P, Awaitable[T]]:
49
+ def retry[T, **P](func: Callable[P, Awaitable[T]]) -> Callable[P, Awaitable[T]]:
63
50
  """Retry if tenacity is installed."""
64
51
 
65
52
  @wraps(func)
@@ -1,24 +1,44 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: aiohttp-msal
3
- Version: 1.0.6
3
+ Version: 1.0.8
4
4
  Summary: Helper Library to use the Microsoft Authentication Library (MSAL) with aiohttp
5
5
  Keywords: aiohttp,asyncio,msal,oauth
6
6
  Author: Johann Kellerman
7
7
  Author-email: Johann Kellerman <kellerza@gmail.com>
8
- License: MIT
8
+ License: The MIT License (MIT)
9
+
10
+ Copyright (c) 2022-2025 kellerza
11
+
12
+ Permission is hereby granted, free of charge, to any person obtaining a copy
13
+ of this software and associated documentation files (the "Software"), to deal
14
+ in the Software without restriction, including without limitation the rights
15
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
16
+ copies of the Software, and to permit persons to whom the Software is
17
+ furnished to do so, subject to the following conditions:
18
+
19
+ The above copyright notice and this permission notice shall be included in all
20
+ copies or substantial portions of the Software.
21
+
22
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
23
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
24
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
25
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
26
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
27
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
28
+ SOFTWARE.
9
29
  Classifier: Development Status :: 4 - Beta
10
30
  Classifier: Intended Audience :: Developers
11
31
  Classifier: Natural Language :: English
12
32
  Classifier: Programming Language :: Python :: 3 :: Only
13
- Classifier: Programming Language :: Python :: 3.11
14
33
  Classifier: Programming Language :: Python :: 3.12
15
34
  Classifier: Programming Language :: Python :: 3.13
35
+ Classifier: Programming Language :: Python :: 3.14
16
36
  Requires-Dist: aiohttp>=3.11.18,<3.13
17
- Requires-Dist: aiohttp-session>=2.12.1,<3
37
+ Requires-Dist: aiohttp-session[aioredis]>=2.12.1,<3
18
38
  Requires-Dist: attrs>=25.3,<26
19
39
  Requires-Dist: msal>=1.32.3,<2
20
40
  Requires-Dist: aiohttp-session[aioredis]>=2.12.1,<3 ; extra == 'aioredis'
21
- Requires-Python: >=3.11
41
+ Requires-Python: >=3.12
22
42
  Project-URL: Homepage, https://github.com/kellerza/aiohttp_msal
23
43
  Provides-Extra: aioredis
24
44
  Description-Content-Type: text/markdown
@@ -135,8 +155,6 @@ def main()
135
155
  ```bash
136
156
  uv sync --all-extras
137
157
  uv tool install ruff
138
- uv tool install codespell
139
- uv tool install pyproject-fmt
140
158
  uv tool install prek
141
159
  prek install # add pre-commit hooks
142
160
  ```
@@ -0,0 +1,11 @@
1
+ aiohttp_msal/__init__.py,sha256=TVGbcFLA9GqGz5s43tovcESkJ_lLj1uZvn9IBtYyP0A,4308
2
+ aiohttp_msal/helpers.py,sha256=L0DFIZD9OfCepckfk64ZtxrVX0-y0aAqRFowBYipepE,2474
3
+ aiohttp_msal/msal_async.py,sha256=_7cA3zoj4GJyHuaAVhdlUubjdn2fH7t2ZOglumkqq28,11668
4
+ aiohttp_msal/redis_tools.py,sha256=9U56XgFQgbRSyELP5pBvDVzYaBbkvyNRSWJPCLm-XOM,6637
5
+ aiohttp_msal/routes.py,sha256=Cc6FHqs8dyHSTCC6AaT-yPttSDyBlsngoiz7j9QCKRU,5143
6
+ aiohttp_msal/settings.py,sha256=EwtymlatYZqUqGlAi5-ffyDeCs7UnXpqv9h3qrqtFvE,1762
7
+ aiohttp_msal/settings_base.py,sha256=WBI7HS780i9zKWUy1ZnztDbRsfoDMVr3K-otHZOhNCc,3026
8
+ aiohttp_msal/utils.py,sha256=DhIkS2mqJp87YHTzlOrI0GFpQYDUujIrIYS43QqzovU,1920
9
+ aiohttp_msal-1.0.8.dist-info/WHEEL,sha256=eh7sammvW2TypMMMGKgsM83HyA_3qQ5Lgg3ynoecH3M,79
10
+ aiohttp_msal-1.0.8.dist-info/METADATA,sha256=F6rgY5s5SmL8x2p_90dEiU88MVu7TllfrgZse8A9ymU,5782
11
+ aiohttp_msal-1.0.8.dist-info/RECORD,,
@@ -1,4 +1,4 @@
1
1
  Wheel-Version: 1.0
2
- Generator: uv 0.8.15
2
+ Generator: uv 0.8.24
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
@@ -1,11 +0,0 @@
1
- aiohttp_msal/__init__.py,sha256=bCBQhbjM5IHKnHKzSDkHQ8Lk_YgW3flO11dgaDn8-Yg,4369
2
- aiohttp_msal/helpers.py,sha256=L0DFIZD9OfCepckfk64ZtxrVX0-y0aAqRFowBYipepE,2474
3
- aiohttp_msal/msal_async.py,sha256=VG5L6PGBHzRBNg_XfIqvfU1V-Snznv_Srd_kXzBgTts,11702
4
- aiohttp_msal/redis_tools.py,sha256=vAA3pvMT8jXd1sDL7wUZ8Z1FWO0xfHTWzi3_pnA_f8E,6672
5
- aiohttp_msal/routes.py,sha256=Cc6FHqs8dyHSTCC6AaT-yPttSDyBlsngoiz7j9QCKRU,5143
6
- aiohttp_msal/settings.py,sha256=sArlq9vBDMsikLf9sTRw-UXE2_QRK_G-kzmtHvZcbwA,1559
7
- aiohttp_msal/settings_base.py,sha256=WBI7HS780i9zKWUy1ZnztDbRsfoDMVr3K-otHZOhNCc,3026
8
- aiohttp_msal/utils.py,sha256=ll303J58nwCEB9QCAm13urUOa6cPqsAE7z_iP9OlRJw,2390
9
- aiohttp_msal-1.0.6.dist-info/WHEEL,sha256=Jb20R3Ili4n9P1fcwuLup21eQ5r9WXhs4_qy7VTrgPI,79
10
- aiohttp_msal-1.0.6.dist-info/METADATA,sha256=YvbKSl1STeQhHmyb9n9vKpsoHfalctOKStQIA_NZHNk,4572
11
- aiohttp_msal-1.0.6.dist-info/RECORD,,