diff --git a/.github/workflows/python-sdk.yml b/.github/workflows/python-sdk.yml index 3b3ad4d35..f5d85f95c 100644 --- a/.github/workflows/python-sdk.yml +++ b/.github/workflows/python-sdk.yml @@ -25,7 +25,7 @@ jobs: run: | uv venv /tmp/smoke uv pip install --python /tmp/smoke/bin/python dist/*.whl - /tmp/smoke/bin/python -c "import scrypted_sdk; print(scrypted_sdk.connect_scrypted_client)" + /tmp/smoke/bin/python -c "import scrypted_sdk; from scrypted_sdk.types import VideoClip, LockState; print(scrypted_sdk.connect_scrypted_client, scrypted_sdk.PluginRemote, scrypted_sdk.ScryptedInterface.MotionSensor, VideoClip, LockState)" - uses: actions/upload-artifact@v4 with: name: python-sdk-dist diff --git a/packages/python-client/hatch_build.py b/packages/python-client/hatch_build.py index 3aa61d9cb..2df771bd0 100644 --- a/packages/python-client/hatch_build.py +++ b/packages/python-client/hatch_build.py @@ -55,8 +55,20 @@ from scrypted_sdk.client import ( ScryptedConnectionError, connect_scrypted_client, ) -from scrypted_sdk.plugin_remote import DeviceManager, MediaManager, SystemManager +from scrypted_sdk.plugin_remote import ( + DeviceManager, + MediaManager, + PluginRemote, + SystemManager, +) from scrypted_sdk.scrypted_python.scrypted_sdk import ScryptedStatic +from scrypted_sdk.scrypted_python.scrypted_sdk.types import ( + ScryptedDeviceType, + ScryptedInterface, + ScryptedInterfaceMethods, + ScryptedInterfaceProperty, + ScryptedMimeTypes, +) __all__ = [ "DEFAULT_CONNECT_TIMEOUT", @@ -64,13 +76,34 @@ __all__ = [ "DeviceManager", "EioRpcTransport", "MediaManager", + "PluginRemote", "ScryptedConnectionError", + "ScryptedDeviceType", + "ScryptedInterface", + "ScryptedInterfaceMethods", + "ScryptedInterfaceProperty", + "ScryptedMimeTypes", "ScryptedStatic", "SystemManager", "connect_scrypted_client", ] ''' +TYPES_PY = '''"""Alias for the full generated type surface. + +The concrete type tree lives at scrypted_sdk.scrypted_python.scrypted_sdk.types +(an artifact of build-time package generation); this module exposes all of it +in one place:: + + from scrypted_sdk.types import ScryptedInterface, VideoClip, LockState + +The package root re-exports only the curated common names; everything else +(interface TypedDicts, settings types, the remaining enums) is available here. +""" + +from scrypted_sdk.scrypted_python.scrypted_sdk.types import * # noqa: F401,F403 +''' + _ALTERNATION = "|".join(sorted(MODULES, key=len, reverse=True)) _FROM_RE = re.compile(rf"^(\s*)from ({_ALTERNATION})((?:\.[\w.]+)?) import ") _IMPORT_RE = re.compile(rf"^(\s*)import ({_ALTERNATION})((?:\.[\w.]+)?)(\s+as\s+\w+)?\s*$") @@ -112,6 +145,7 @@ def generate(source_dir: Path, package_dir: Path) -> None: shutil.rmtree(package_dir) package_dir.mkdir(parents=True) (package_dir / "__init__.py").write_text(INIT_PY) + (package_dir / "types.py").write_text(TYPES_PY) for flat, target in MODULES.items(): src = source_dir / f"{flat}.py" diff --git a/packages/python-client/pyproject.toml b/packages/python-client/pyproject.toml index a47e4535a..aa8849850 100644 --- a/packages/python-client/pyproject.toml +++ b/packages/python-client/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "scrypted-sdk" -version = "0.1.0" +version = "0.1.1" description = "Python SDK for Scrypted: connect to a Scrypted server and use the same SDK objects plugins see" readme = "README.md" license = "ISC" diff --git a/packages/python-client/scrypted_client.py b/packages/python-client/scrypted_client.py index e0d9d1655..65b3129fb 100644 --- a/packages/python-client/scrypted_client.py +++ b/packages/python-client/scrypted_client.py @@ -119,6 +119,38 @@ class EioRpcTransport(rpc_reader.RpcTransport): self._http_session = None +async def _login( + base_url: str, + username: str, + password: str, + timeout: float, + login_session: aiohttp.ClientSession | None, +) -> dict: + """POST /login and return the parsed response. + + Uses a temporary no-verify session when none is provided (and closes it); + a caller-provided session is left open. Raises ScryptedConnectionError on + any request failure. + """ + owns_session = login_session is None + session = login_session or aiohttp.ClientSession( + connector=aiohttp.TCPConnector(ssl=False) + ) + try: + async with session.post( + f"{base_url}/login", + json={"username": username, "password": password}, + raise_for_status=True, + timeout=aiohttp.ClientTimeout(total=timeout), + ) as response: + return await response.json() + except (aiohttp.ClientError, asyncio.TimeoutError) as err: + raise ScryptedConnectionError(f"Login to {base_url} failed: {err}") from err + finally: + if owns_session: + await session.close() + + async def connect_scrypted_client( loop: asyncio.AbstractEventLoop, base_url: str, @@ -142,29 +174,21 @@ async def connect_scrypted_client( for initial system state. Raises :class:`ScryptedConnectionError` on any failure. """ - owns_login_session = login_session is None - session = login_session or aiohttp.ClientSession( - connector=aiohttp.TCPConnector(ssl=False) - ) try: - async with session.post( - f"{base_url}/login", - json={"username": username, "password": password}, - raise_for_status=True, - timeout=aiohttp.ClientTimeout(total=timeout), - ) as response: - login_response = await response.json() - except (aiohttp.ClientError, asyncio.TimeoutError) as err: - raise ScryptedConnectionError(f"Login to {base_url} failed: {err}") from err - finally: - if owns_login_session: - await session.close() - - if "authorization" not in login_response: - raise ScryptedConnectionError( - f"Login to {base_url} did not return an authorization header " - f"(response keys: {sorted(login_response)})" + login_response = await _login( + base_url, username, password, timeout, login_session ) + if "authorization" not in login_response: + raise ScryptedConnectionError( + f"Login to {base_url} did not return an authorization header " + f"(response keys: {sorted(login_response)})" + ) + except ScryptedConnectionError: + # A caller-provided transport already owns a session and a running + # send loop; failing before the engine.io phase must not leak them. + if transport is not None: + await transport.close() + raise if transport is None: transport = EioRpcTransport(loop)