From 430ecedfbd71ade074b3ca60cc1f4064986b954c Mon Sep 17 00:00:00 2001 From: RomirJ Date: Wed, 10 Jun 2026 16:21:22 -0700 Subject: [PATCH] fix(client): make `from tether import TetherClient` work + ReflexClient alias MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Audit §3.9 (client SDK H1/H2/L1/L6): - H2: `from tether import TetherClient` was promised by the pyproject comment and the reflex shim docstring but raised AttributeError — the top-level __getattr__ only lazy-loaded the validate/fixtures surface. Re-export the full client SDK (TetherClient, TetherAsyncClient, the 5 exception types, encode_image) lazily from tether.client; httpx is a base dep so no torch cost. Added to __all__. - H1: add ReflexClient / ReflexAsyncClient deprecation-alias subclasses in tether.client so pre-rename code keeps importing through the v0.13.x compat window (warns once, removed v0.14.0 — matches the `reflex` import-shim schedule). The README snippets were already fixed to TetherClient in the rename-sweep PR; this covers users with the old name in their own code. - L6: encode_image() reported "Pillow required" for genuinely-unsupported types (dict, int, …) when Pillow was absent, blaming a missing optional dep on a caller who passed garbage. Now rejects non-image-like types with "unsupported image type" BEFORE requiring Pillow; real ndarray/PIL inputs still get the Pillow-required message. Fixes the previously-failing test_encode_unsupported_type_raises. - L1: removed dead `last_exc` assignments in the sync retry loop; removed an unused `glob` import in __init__. Verified: tests/test_client.py 29 passed / 2 skipped; top-level + alias import behavior unit-checked; ruff clean on all touched files. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/tether/__init__.py | 27 ++++++++++++++++++++++++- src/tether/client/__init__.py | 38 +++++++++++++++++++++++++++++++++++ src/tether/client/client.py | 14 +++++++++++-- 3 files changed, 76 insertions(+), 3 deletions(-) diff --git a/src/tether/__init__.py b/src/tether/__init__.py index d278e95..484e305 100644 --- a/src/tether/__init__.py +++ b/src/tether/__init__.py @@ -29,8 +29,31 @@ "SUPPORTED_MODEL_TYPES", "UNSUPPORTED_MODEL_MESSAGE", "load_fixtures", + # Customer SDK surface — re-exported from tether.client so a bare + # `pip install fastcrest-tether` gives `from tether import TetherClient`. + # httpx is a base dep, so this is cheap (no torch); lazy-loaded below. + "TetherClient", + "TetherAsyncClient", + "TetherClientError", + "TetherAuthError", + "TetherServerDegradedError", + "TetherServerNotReadyError", + "TetherValidationError", + "encode_image", ] +# Names re-exported from tether.client (lazy — see __getattr__). +_CLIENT_EXPORTS = frozenset({ + "TetherClient", + "TetherAsyncClient", + "TetherClientError", + "TetherAuthError", + "TetherServerDegradedError", + "TetherServerNotReadyError", + "TetherValidationError", + "encode_image", +}) + # ─── ORT-TRT EP first-class support (v0.7) ────────────────────────────────── # ORT-TRT EP needs libnvinfer.so.10 (from the `tensorrt` pip pkg) + CUDA libs @@ -125,7 +148,6 @@ def _eager_dlopen_nvidia_libs() -> None: cached handle. No-op on macOS/Windows or when libs don't exist. """ import ctypes - import glob import os import sys @@ -193,4 +215,7 @@ def __getattr__(name: str): if name == "load_fixtures": from tether.fixtures import load_fixtures return load_fixtures + if name in _CLIENT_EXPORTS: + import tether.client as _client + return getattr(_client, name) raise AttributeError(f"module 'tether' has no attribute {name!r}") diff --git a/src/tether/client/__init__.py b/src/tether/client/__init__.py index 8f860c6..54f4c21 100644 --- a/src/tether/client/__init__.py +++ b/src/tether/client/__init__.py @@ -42,6 +42,41 @@ encode_image, ) +class ReflexClient(TetherClient): + """Deprecated alias for :class:`TetherClient`. Removed in v0.14.0. + + Kept so pre-rename code (`from tether.client import ReflexClient`) keeps + working through the v0.13.x compat window, matching the `reflex` import + shim's removal schedule. + """ + + def __init__(self, *args, **kwargs): + import warnings + + warnings.warn( + "ReflexClient is deprecated; use TetherClient. " + "The alias is removed in tether v0.14.0.", + DeprecationWarning, + stacklevel=2, + ) + super().__init__(*args, **kwargs) + + +class ReflexAsyncClient(TetherAsyncClient): + """Deprecated alias for :class:`TetherAsyncClient`. Removed in v0.14.0.""" + + def __init__(self, *args, **kwargs): + import warnings + + warnings.warn( + "ReflexAsyncClient is deprecated; use TetherAsyncClient. " + "The alias is removed in tether v0.14.0.", + DeprecationWarning, + stacklevel=2, + ) + super().__init__(*args, **kwargs) + + __all__ = [ "TetherClient", "TetherAsyncClient", @@ -51,4 +86,7 @@ "TetherServerNotReadyError", "TetherValidationError", "encode_image", + # Deprecated rename-compat aliases (removed v0.14.0). + "ReflexClient", + "ReflexAsyncClient", ] diff --git a/src/tether/client/client.py b/src/tether/client/client.py index 4f95d23..4ff4821 100644 --- a/src/tether/client/client.py +++ b/src/tether/client/client.py @@ -99,6 +99,18 @@ def encode_image(image: Any, jpeg_quality: int = 85) -> str: if len(image) >= 8 and image[:8] == b"\x89PNG\r\n\x1a\n": return base64.b64encode(image).decode("ascii") return base64.b64encode(image).decode("ascii") + # The only remaining valid inputs are numpy.ndarray and PIL.Image, both of + # which need Pillow. Reject obviously-unsupported types (dict, int, list…) + # BEFORE requiring Pillow, so the error names the real problem rather than + # blaming a missing optional dep on a caller who passed garbage. + _cls = type(image) + _image_like = ( + _cls.__module__.split(".")[0] in ("PIL", "numpy") + or hasattr(image, "__array_interface__") + or (hasattr(image, "save") and hasattr(image, "size")) + ) + if not _image_like: + raise TetherClientError(f"unsupported image type: {_cls.__name__}") try: from PIL import Image as PILImage except ImportError: @@ -211,7 +223,6 @@ def close(self) -> None: # ---- Internals ------------------------------------------------------- def _request_with_retry(self, method: str, path: str, **kw) -> httpx.Response: - last_exc: TetherClientError | None = None attempt = 0 backoff = self.initial_backoff_s while True: @@ -244,7 +255,6 @@ def _request_with_retry(self, method: str, path: str, **kw) -> httpx.Response: ) time.sleep(wait) attempt += 1 - last_exc = err # ---- Public surface --------------------------------------------------