diff --git a/docs/getting-started.md b/docs/getting-started.md index 243aaa02..0e90305a 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -25,6 +25,22 @@ uv add pyoverkiz pip install pyoverkiz ``` +### Optional extras + +Some servers require additional dependencies that are not installed by default: + +| Extra | Server | Packages | +|-------|--------|----------| +| `nexity` | Nexity | boto3, warrant-lite | + +Install an extra with: + +```bash +uv add "pyoverkiz[nexity]" +# or +pip install "pyoverkiz[nexity]" +``` + ## Choose your server Use a cloud server when you want to connect through the vendor’s public API. Use a local server when you want LAN access to a gateway. diff --git a/docs/migration-v2.md b/docs/migration-v2.md index c29976d9..116fd842 100644 --- a/docs/migration-v2.md +++ b/docs/migration-v2.md @@ -297,4 +297,4 @@ These are not breaking, but worth knowing about when migrating: - **Device helpers** — `Device.get_command_definition()` for looking up command metadata. - **Reference endpoints** — query server metadata: `get_reference_ui_classes()`, `get_reference_ui_widgets()`, `get_reference_ui_profile()`, `get_reference_controllable_types()`, etc. - **Firmware management** — `get_devices_not_up_to_date()`, `get_device_firmware_status()`, `update_device_firmware()`. -- **boto3 lazy import** — `boto3` is only imported when the Nexity auth strategy is actually used. +- **Optional Nexity dependencies** — `boto3` and `warrant-lite` are no longer installed by default. Install them with `pip install pyoverkiz[nexity]` if you use the Nexity server. A clear `ImportError` is raised at login time if the extra is missing. diff --git a/pyoverkiz/auth/strategies.py b/pyoverkiz/auth/strategies.py index b5a0e3c7..6715b4f7 100644 --- a/pyoverkiz/auth/strategies.py +++ b/pyoverkiz/auth/strategies.py @@ -9,10 +9,7 @@ import ssl from collections.abc import Mapping from http import HTTPStatus -from typing import TYPE_CHECKING, Any, cast - -if TYPE_CHECKING: - from botocore.client import BaseClient +from typing import Any, cast from aiohttp import ClientSession, FormData @@ -246,14 +243,20 @@ class NexityAuthStrategy(SessionLoginStrategy): async def login(self) -> None: """Perform login using Nexity username and password.""" - import boto3 - from botocore.config import Config - from botocore.exceptions import ClientError - from warrant_lite import WarrantLite + try: + import boto3 + from botocore.config import Config + from botocore.exceptions import ClientError + from warrant_lite import WarrantLite + except ImportError as err: + raise ImportError( + "Nexity authentication requires the 'nexity' extra. " + "Install it with: pip install pyoverkiz[nexity]" + ) from err loop = asyncio.get_running_loop() - def _client() -> BaseClient: + def _client() -> Any: return boto3.client( "cognito-idp", config=Config(region_name=NEXITY_COGNITO_REGION) ) diff --git a/pyproject.toml b/pyproject.toml index 82c6f773..725ff323 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,12 +17,14 @@ dependencies = [ "aiohttp<4.0.0,>=3.10.3", "backoff<3.0,>=1.10.0", "attrs>=21.2", - "boto3<2.0.0,>=1.18.59", - "warrant-lite<2.0.0,>=1.0.4", "cattrs>=23.2", ] [project.optional-dependencies] +nexity = [ + "boto3<2.0.0,>=1.18.59", + "warrant-lite<2.0.0,>=1.0.4", +] docs = [ "mkdocs>=1.5.0,<2.0", "mkdocs-material>=9.5.0", diff --git a/tests/test_auth.py b/tests/test_auth.py index 57172dff..c099d7d6 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -13,7 +13,13 @@ import pytest from aiohttp import ClientSession -from botocore.exceptions import ClientError + +try: + from botocore.exceptions import ClientError + + HAS_NEXITY_DEPS = True +except ImportError: + HAS_NEXITY_DEPS = False from pyoverkiz.auth.base import AuthContext from pyoverkiz.auth.credentials import ( @@ -499,6 +505,28 @@ def test_boto3_not_imported_at_module_load(self): sys.modules[mod] = value @pytest.mark.asyncio + async def test_login_raises_import_error_without_nexity_extra(self): + """Login raises ImportError with install hint when nexity extra is missing.""" + server_config = ServerConfig( + server=Server.NEXITY, + name="Nexity", + endpoint="https://api.nexity.com", + manufacturer="Nexity", + api_type=APIType.CLOUD, + ) + credentials = UsernamePasswordCredentials("user", "pass") + session = AsyncMock(spec=ClientSession) + + strategy = NexityAuthStrategy(credentials, session, server_config, True) + + with ( + patch.dict(sys.modules, {"boto3": None}), + pytest.raises(ImportError, match="pyoverkiz\\[nexity\\]"), + ): + await strategy.login() + + @pytest.mark.asyncio + @pytest.mark.skipif(not HAS_NEXITY_DEPS, reason="nexity extra not installed") async def test_login_maps_invalid_credentials_client_error(self): """Map Cognito bad-credential errors to NexityBadCredentialsError.""" server_config = ServerConfig( @@ -527,6 +555,7 @@ async def test_login_maps_invalid_credentials_client_error(self): await strategy.login() @pytest.mark.asyncio + @pytest.mark.skipif(not HAS_NEXITY_DEPS, reason="nexity extra not installed") async def test_login_propagates_non_auth_client_error(self): """Propagate non-auth Cognito errors to preserve failure context.""" server_config = ServerConfig( diff --git a/uv.lock b/uv.lock index df5ebee8..6b053056 100644 --- a/uv.lock +++ b/uv.lock @@ -1011,9 +1011,7 @@ dependencies = [ { name = "aiohttp" }, { name = "attrs" }, { name = "backoff" }, - { name = "boto3" }, { name = "cattrs" }, - { name = "warrant-lite" }, ] [package.optional-dependencies] @@ -1024,6 +1022,10 @@ docs = [ { name = "mkdocstrings", extra = ["python"] }, { name = "pymdown-extensions" }, ] +nexity = [ + { name = "boto3" }, + { name = "warrant-lite" }, +] [package.dev-dependencies] dev = [ @@ -1041,16 +1043,16 @@ requires-dist = [ { name = "aiohttp", specifier = ">=3.10.3,<4.0.0" }, { name = "attrs", specifier = ">=21.2" }, { name = "backoff", specifier = ">=1.10.0,<3.0" }, - { name = "boto3", specifier = ">=1.18.59,<2.0.0" }, + { name = "boto3", marker = "extra == 'nexity'", specifier = ">=1.18.59,<2.0.0" }, { name = "cattrs", specifier = ">=23.2" }, { name = "mkdocs", marker = "extra == 'docs'", specifier = ">=1.5.0,<2.0" }, { name = "mkdocs-autorefs", marker = "extra == 'docs'", specifier = ">=1.0.0" }, { name = "mkdocs-material", marker = "extra == 'docs'", specifier = ">=9.5.0" }, { name = "mkdocstrings", extras = ["python"], marker = "extra == 'docs'", specifier = ">=0.24.0" }, { name = "pymdown-extensions", marker = "extra == 'docs'", specifier = ">=10.0" }, - { name = "warrant-lite", specifier = ">=1.0.4,<2.0.0" }, + { name = "warrant-lite", marker = "extra == 'nexity'", specifier = ">=1.0.4,<2.0.0" }, ] -provides-extras = ["docs"] +provides-extras = ["nexity", "docs"] [package.metadata.requires-dev] dev = [