Skip to content

Commit 917bfef

Browse files
authored
Lazy-import boto3 in NexityAuthStrategy (#1990)
## Summary - Adds `AuthContext.update_from_token` for shared OAuth token handling across auth strategies - Lazy-imports `boto3` and `warrant-lite` in NexityAuthStrategy so they're only loaded when actually needed - Users who don't use Nexity no longer pay the boto3 import cost ## Test plan - [x] Nexity auth tests pass - [x] Other auth strategies unaffected - [x] Verify boto3 is not imported at module load time
1 parent 3c520ad commit 917bfef

File tree

3 files changed

+54
-35
lines changed

3 files changed

+54
-35
lines changed

pyoverkiz/auth/base.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
import datetime
66
from collections.abc import Mapping
77
from dataclasses import dataclass
8-
from typing import Protocol
8+
from typing import Any, Protocol
99

1010

1111
@dataclass(slots=True)
@@ -25,6 +25,18 @@ def is_expired(self, *, skew_seconds: int = 5) -> bool:
2525
datetime.UTC
2626
) >= self.expires_at - datetime.timedelta(seconds=skew_seconds)
2727

28+
def update_from_token(self, token: dict[str, Any]) -> None:
29+
"""Update context from an OAuth token response."""
30+
self.access_token = str(token["access_token"])
31+
self.refresh_token = (
32+
str(token["refresh_token"]) if "refresh_token" in token else None
33+
)
34+
expires_in = token.get("expires_in")
35+
if expires_in is not None:
36+
self.expires_at = datetime.datetime.now(datetime.UTC) + datetime.timedelta(
37+
seconds=int(expires_in) - 5
38+
)
39+
2840

2941
class AuthStrategy(Protocol):
3042
"""Protocol for authentication strategies."""

pyoverkiz/auth/strategies.py

Lines changed: 16 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -5,18 +5,15 @@
55
import asyncio
66
import base64
77
import binascii
8-
import datetime
98
import json
109
import ssl
1110
from collections.abc import Mapping
12-
from typing import Any, cast
11+
from typing import TYPE_CHECKING, Any, cast
12+
13+
if TYPE_CHECKING:
14+
from botocore.client import BaseClient
1315

14-
import boto3
1516
from aiohttp import ClientSession, FormData
16-
from botocore.client import BaseClient
17-
from botocore.config import Config
18-
from botocore.exceptions import ClientError
19-
from warrant_lite import WarrantLite
2017

2118
from pyoverkiz.auth.base import AuthContext, AuthStrategy
2219
from pyoverkiz.auth.credentials import (
@@ -98,7 +95,7 @@ def __init__(
9895
ssl_context: ssl.SSLContext | bool,
9996
api_type: APIType,
10097
) -> None:
101-
"""Initialize SessionLoginStrategy with given parameters."""
98+
"""Create a session-login strategy bound to the given credentials."""
10299
super().__init__(session, server, ssl_context, api_type)
103100
self.credentials = credentials
104101

@@ -142,7 +139,7 @@ def __init__(
142139
ssl_context: ssl.SSLContext | bool,
143140
api_type: APIType,
144141
) -> None:
145-
"""Initialize SomfyAuthStrategy with given parameters."""
142+
"""Create a Somfy OAuth2 strategy with a fresh auth context."""
146143
super().__init__(session, server, ssl_context, api_type)
147144
self.credentials = credentials
148145
self.context = AuthContext()
@@ -201,13 +198,7 @@ async def _request_access_token(
201198
if not access_token:
202199
raise SomfyServiceError("No Somfy access token provided.")
203200

204-
self.context.access_token = cast(str, access_token)
205-
self.context.refresh_token = token.get("refresh_token")
206-
expires_in = token.get("expires_in")
207-
if expires_in:
208-
self.context.expires_at = datetime.datetime.now(
209-
datetime.UTC
210-
) + datetime.timedelta(seconds=cast(int, expires_in) - 5)
201+
self.context.update_from_token(token)
211202

212203

213204
class CozytouchAuthStrategy(SessionLoginStrategy):
@@ -257,6 +248,11 @@ class NexityAuthStrategy(SessionLoginStrategy):
257248

258249
async def login(self) -> None:
259250
"""Perform login using Nexity username and password."""
251+
import boto3
252+
from botocore.config import Config
253+
from botocore.exceptions import ClientError
254+
from warrant_lite import WarrantLite
255+
260256
loop = asyncio.get_running_loop()
261257

262258
def _client() -> BaseClient:
@@ -307,7 +303,7 @@ def __init__(
307303
ssl_context: ssl.SSLContext | bool,
308304
api_type: APIType,
309305
) -> None:
310-
"""Initialize LocalTokenAuthStrategy with given parameters."""
306+
"""Create a local-token strategy bound to the given credentials."""
311307
super().__init__(session, server, ssl_context, api_type)
312308
self.credentials = credentials
313309

@@ -332,7 +328,7 @@ def __init__(
332328
ssl_context: ssl.SSLContext | bool,
333329
api_type: APIType,
334330
) -> None:
335-
"""Initialize RexelAuthStrategy with given parameters."""
331+
"""Create a Rexel OAuth2 strategy with a fresh auth context."""
336332
super().__init__(session, server, ssl_context, api_type)
337333
self.credentials = credentials
338334
self.context = AuthContext()
@@ -395,13 +391,7 @@ async def _exchange_token(self, payload: Mapping[str, str]) -> None:
395391
raise InvalidTokenError("No Rexel access token provided.")
396392

397393
self._ensure_consent(access_token)
398-
self.context.access_token = cast(str, access_token)
399-
self.context.refresh_token = token.get("refresh_token")
400-
expires_in = token.get("expires_in")
401-
if expires_in:
402-
self.context.expires_at = datetime.datetime.now(
403-
datetime.UTC
404-
) + datetime.timedelta(seconds=cast(int, expires_in) - 5)
394+
self.context.update_from_token(token)
405395

406396
@staticmethod
407397
def _ensure_consent(access_token: str) -> None:
@@ -423,7 +413,7 @@ def __init__(
423413
ssl_context: ssl.SSLContext | bool,
424414
api_type: APIType,
425415
) -> None:
426-
"""Initialize BearerTokenAuthStrategy with given parameters."""
416+
"""Create a bearer-token strategy bound to the given credentials."""
427417
super().__init__(session, server, ssl_context, api_type)
428418
self.credentials = credentials
429419

tests/test_auth.py

Lines changed: 25 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import base64
99
import datetime
1010
import json
11+
import sys
1112
from unittest.mock import AsyncMock, MagicMock, patch
1213

1314
import pytest
@@ -491,6 +492,26 @@ def test_auth_headers_with_token(self):
491492
class TestNexityAuthStrategy:
492493
"""Tests for Nexity auth error mapping behavior."""
493494

495+
def test_boto3_not_imported_at_module_load(self):
496+
"""Verify boto3 and warrant_lite are lazy-imported, not at module load."""
497+
saved = {}
498+
for mod in ("boto3", "botocore", "warrant_lite"):
499+
saved[mod] = sys.modules.pop(mod, None)
500+
501+
try:
502+
import importlib
503+
504+
import pyoverkiz.auth.strategies
505+
506+
importlib.reload(pyoverkiz.auth.strategies)
507+
508+
assert "boto3" not in sys.modules
509+
assert "warrant_lite" not in sys.modules
510+
finally:
511+
for mod, value in saved.items():
512+
if value is not None:
513+
sys.modules[mod] = value
514+
494515
@pytest.mark.asyncio
495516
async def test_login_maps_invalid_credentials_client_error(self):
496517
"""Map Cognito bad-credential errors to NexityBadCredentialsError."""
@@ -512,10 +533,8 @@ async def test_login_maps_invalid_credentials_client_error(self):
512533
warrant_instance.authenticate_user.side_effect = bad_credentials_error
513534

514535
with (
515-
patch("pyoverkiz.auth.strategies.boto3.client", return_value=MagicMock()),
516-
patch(
517-
"pyoverkiz.auth.strategies.WarrantLite", return_value=warrant_instance
518-
),
536+
patch("boto3.client", return_value=MagicMock()),
537+
patch("warrant_lite.WarrantLite", return_value=warrant_instance),
519538
pytest.raises(NexityBadCredentialsError),
520539
):
521540
strategy = NexityAuthStrategy(
@@ -544,10 +563,8 @@ async def test_login_propagates_non_auth_client_error(self):
544563
warrant_instance.authenticate_user.side_effect = service_error
545564

546565
with (
547-
patch("pyoverkiz.auth.strategies.boto3.client", return_value=MagicMock()),
548-
patch(
549-
"pyoverkiz.auth.strategies.WarrantLite", return_value=warrant_instance
550-
),
566+
patch("boto3.client", return_value=MagicMock()),
567+
patch("warrant_lite.WarrantLite", return_value=warrant_instance),
551568
pytest.raises(ClientError, match="InternalErrorException"),
552569
):
553570
strategy = NexityAuthStrategy(

0 commit comments

Comments
 (0)