diff --git a/README.md b/README.md index 8396e9b..6443f27 100644 --- a/README.md +++ b/README.md @@ -248,7 +248,6 @@ SonicBit._complete_tutorial("token") This will mark the tutorial as completed and allow the user to access their account. - ## Contributing Contributions are welcome! If you find a bug or have a suggestion for a new feature, please open an issue or submit a pull request on the GitHub repository. diff --git a/pyproject.toml b/pyproject.toml index 0c7796f..a14bf36 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,7 +14,7 @@ classifiers = [ ] dependencies = [ "httpx>=0.28.1", - "pydantic>=2.12.5", + "pydantic>=2.0.0", "tenacity>=9.1.4", ] diff --git a/sonicbit/base.py b/sonicbit/base.py index 4b1ca62..ab1d06b 100644 --- a/sonicbit/base.py +++ b/sonicbit/base.py @@ -14,16 +14,17 @@ class SonicBitBase: """Base class for all SonicBit modules.""" - MAX_API_RETRIES = 5 + MAX_API_RETRIES = 3 + REQUEST_TIMEOUT = 15 # seconds; override at class level if needed def __init__(self): - transport = httpx.HTTPTransport(retries=3) - self.session = httpx.Client(transport=transport) + transport = httpx.HTTPTransport(retries=2) + self.session = httpx.Client(transport=transport, timeout=self.REQUEST_TIMEOUT) @retry( stop=stop_after_attempt(MAX_API_RETRIES), - wait=wait_exponential(multiplier=1, min=4, max=10), - retry=retry_if_exception_type(httpx.ConnectError), + wait=wait_exponential(multiplier=1, min=1, max=5), + retry=retry_if_exception_type((httpx.ConnectError, httpx.TimeoutException)), ) def _request(self, *args, **kwargs): return self.session.request(*args, **kwargs) @@ -31,10 +32,11 @@ def _request(self, *args, **kwargs): @staticmethod @retry( stop=stop_after_attempt(MAX_API_RETRIES), - wait=wait_exponential(multiplier=1, min=4, max=10), - retry=retry_if_exception_type(httpx.ConnectError), + wait=wait_exponential(multiplier=1, min=1, max=5), + retry=retry_if_exception_type((httpx.ConnectError, httpx.TimeoutException)), ) def _static_request(*args, **kwargs): + kwargs.setdefault("timeout", SonicBitBase.REQUEST_TIMEOUT) return httpx.request(*args, **kwargs) @staticmethod diff --git a/sonicbit/client.py b/sonicbit/client.py index 9530a97..69c0432 100644 --- a/sonicbit/client.py +++ b/sonicbit/client.py @@ -14,6 +14,8 @@ def __init__( email: str, password: str, token: str | None = None, - token_handler: TokenHandler = TokenFileHandler(), + token_handler: TokenHandler | None = None, ): + if token_handler is None: + token_handler = TokenFileHandler() super().__init__(email, password, token, token_handler) diff --git a/sonicbit/handlers/token_handler.py b/sonicbit/handlers/token_handler.py index ba10439..85b2cc8 100644 --- a/sonicbit/handlers/token_handler.py +++ b/sonicbit/handlers/token_handler.py @@ -1,12 +1,32 @@ +from abc import ABC, abstractmethod + from sonicbit.models.auth_response import AuthResponse -class TokenHandler: - def __init__(self): - pass +class TokenHandler(ABC): + """Abstract base class for token storage backends. + + Subclass this and pass an instance to SonicBit() to store tokens in + a database, secrets manager, or any other medium. + """ + @abstractmethod def write(self, email: str, auth: AuthResponse) -> None: - print(f"{email}'s token is {auth.token}") + """Persist the token returned after a successful login. + + Args: + email: The account email used to key the stored token. + auth: The AuthResponse containing the new token. + """ + @abstractmethod def read(self, email: str) -> str | None: - return input(f"Enter {email}'s token: ") + """Return a previously persisted token, or None if absent. + + Args: + email: The account email to look up. + + Returns: + A token string if one is cached, otherwise None so the SDK + falls back to a fresh login. + """ diff --git a/sonicbit/models/remote_download/remote_task_list.py b/sonicbit/models/remote_download/remote_task_list.py index 2e31c06..67202a7 100644 --- a/sonicbit/models/remote_download/remote_task_list.py +++ b/sonicbit/models/remote_download/remote_task_list.py @@ -1,10 +1,11 @@ from datetime import datetime +from json import JSONDecodeError from httpx import Response from pydantic import BaseModel, ConfigDict, Field from sonicbit.base import SonicBitBase -from sonicbit.errors import SonicBitError +from sonicbit.errors import InvalidResponseError, SonicBitError from sonicbit.models.path_info import PathInfo from .remote_task import RemoteTask @@ -19,7 +20,12 @@ class RemoteTaskList(BaseModel): @staticmethod def from_response(client: SonicBitBase, response: Response) -> "RemoteTaskList": - json_data = response.json() + try: + json_data = response.json() + except JSONDecodeError: + raise InvalidResponseError( + f"Server returned invalid JSON data: {response.text}" + ) from None if "message" in json_data: raise SonicBitError( diff --git a/sonicbit/modules/auth.py b/sonicbit/modules/auth.py index b90d682..b4addf1 100644 --- a/sonicbit/modules/auth.py +++ b/sonicbit/modules/auth.py @@ -1,4 +1,5 @@ import logging +import threading from sonicbit.base import SonicBitBase from sonicbit.constants import Constants @@ -17,7 +18,7 @@ def __init__( token_handler: TokenHandler, ): super().__init__() - self._refreshing = False + self._refresh_lock = threading.Lock() # prevents concurrent token refreshes logger.debug("Initializing auth for email=%s", email) self._email = email self._password = password @@ -49,14 +50,11 @@ def _refresh_token(self) -> str: def _request(self, *args, **kwargs): response = super()._request(*args, **kwargs) - if response.status_code == 401 and not self._refreshing: - logger.debug("Received 401, refreshing token for email=%s", self._email) - self._refreshing = True - try: + if response.status_code == 401: + with self._refresh_lock: + logger.debug("Received 401, refreshing token for email=%s", self._email) self._refresh_token() response = super()._request(*args, **kwargs) - finally: - self._refreshing = False return response diff --git a/sonicbit/modules/remote_download.py b/sonicbit/modules/remote_download.py index d091361..1667f53 100644 --- a/sonicbit/modules/remote_download.py +++ b/sonicbit/modules/remote_download.py @@ -13,26 +13,26 @@ class RemoteDownload(SonicBitBase): def add_remote_download(self, url: str, path: PathInfo) -> bool: logger.debug("Adding remote download url=%s path=%s", url, path.path) - data = {"url": url, "path": path.path} + json_data = self._request( + method="POST", + url=self.url("/remote_download/task/add"), + json={"url": url, "path": path.path}, + ).json() - reponse = self._request( - method="POST", url=self.url("/remote_download/task/add"), json=data - ) - - json_data = reponse.json() - if json_data.get("success", False): - return True + if not json_data.get("success", False): + raise SonicBitError( + f"Failed to add remote download: {json_data.get('msg')}" + ) - error_message = json_data.get("msg") - if error_message: - raise SonicBitError(f"Failed to add remote download: {error_message}") + return True def list_remote_downloads(self) -> RemoteTaskList: logger.debug("Listing all remote downloads") - params = {"action": RemoteDownloadCommand.LIST_REMOTE_DOWNLOADS} response = self._request( - method="POST", url=self.url("/remote_download/task/list"), params=params + method="POST", + url=self.url("/remote_download/task/list"), + params={"action": RemoteDownloadCommand.LIST_REMOTE_DOWNLOADS}, ) return RemoteTaskList.from_response(self, response) @@ -40,19 +40,15 @@ def list_remote_downloads(self) -> RemoteTaskList: def delete_remote_download(self, id: int) -> bool: logger.debug("Deleting remote download id=%s", id) - data = { - "task_id": id, - } - response = self._request( - method="POST", url=self.url("/remote_download/task/delete"), json=data - ) - - json_data = response.json() - if json_data.get("success", False): - return True + json_data = self._request( + method="POST", + url=self.url("/remote_download/task/delete"), + json={"task_id": id}, + ).json() - error_message = json_data.get("msg") - if error_message: - raise SonicBitError(f"Failed to delete remote download: {error_message}") + if not json_data.get("success", False): + raise SonicBitError( + f"Failed to delete remote download: {json_data.get('msg')}" + ) - return False + return True diff --git a/sonicbit/modules/signup.py b/sonicbit/modules/signup.py index 2634942..2f4d40e 100644 --- a/sonicbit/modules/signup.py +++ b/sonicbit/modules/signup.py @@ -42,7 +42,7 @@ def submit_otp(otp: str) -> str: otp = otp.strip() - if not otp.isdigit() and len(otp) == 6: + if not otp.isdigit() or len(otp) != 6: raise SonicBitError("OTP must be a 6 digit number") data = {"code": otp.strip(), "type": "registration", "platform": "Web_Dash_V4"} @@ -70,8 +70,7 @@ def _complete_tutorial(token: str) -> bool: data = {"delete": True} - headers = Constants.API_HEADERS - headers["Authorization"] = f"Bearer {token}" + headers = {**Constants.API_HEADERS, "Authorization": f"Bearer {token}"} logger.debug("Completing tutorial for token=%s...", token[:8]) response = SonicBitBase._static_request( diff --git a/sonicbit/modules/torrent.py b/sonicbit/modules/torrent.py index 690b3a6..bda19e2 100644 --- a/sonicbit/modules/torrent.py +++ b/sonicbit/modules/torrent.py @@ -72,17 +72,20 @@ def add_torrent_file( f"Failed to upload local torrent file: '{local_path}'. File does NOT exist" ) - post_data = { - "command": (None, TorrentCommand.UPLOAD_TORRENT_FILE), - "file": (file_name, open(local_path, "rb"), "application/octet-stream"), - "name": (None, file_name), - "size": (None, str(os.stat(local_path).st_size)), - "auto_start": (None, "1" if auto_start else "0"), - "path": path.path, - } - response = self._request( - method="POST", url=self.url("/app/seedbox/torrent/upload"), files=post_data - ) + with open(local_path, "rb") as torrent_file: + post_data = { + "command": (None, TorrentCommand.UPLOAD_TORRENT_FILE), + "file": (file_name, torrent_file, "application/octet-stream"), + "name": (None, file_name), + "size": (None, str(os.stat(local_path).st_size)), + "auto_start": (None, "1" if auto_start else "0"), + "path": (None, path.path), + } + response = self._request( + method="POST", + url=self.url("/app/seedbox/torrent/upload"), + files=post_data, + ) try: json_data = response.json() except JSONDecodeError: diff --git a/uv.lock b/uv.lock index 4908111..97ef0bd 100644 --- a/uv.lock +++ b/uv.lock @@ -26,14 +26,14 @@ wheels = [ [[package]] name = "autoflake" -version = "2.3.1" +version = "2.3.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyflakes" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/2a/cb/486f912d6171bc5748c311a2984a301f4e2d054833a1da78485866c71522/autoflake-2.3.1.tar.gz", hash = "sha256:c98b75dc5b0a86459c4f01a1d32ac7eb4338ec4317a4469515ff1e687ecd909e", size = 27642, upload-time = "2024-03-13T03:41:28.977Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/0b/70c277eef225133763bf05c02c88df182e57d5c5c0730d3998958096a82e/autoflake-2.3.3.tar.gz", hash = "sha256:c24809541e23999f7a7b0d2faadf15deb0bc04cdde49728a2fd943a0c8055504", size = 16515, upload-time = "2026-02-20T05:01:43.448Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a2/ee/3fd29bf416eb4f1c5579cf12bf393ae954099258abd7bde03c4f9716ef6b/autoflake-2.3.1-py3-none-any.whl", hash = "sha256:3ae7495db9084b7b32818b4140e6dc4fc280b712fb414f5b8fe57b0a8e85a840", size = 32483, upload-time = "2024-03-13T03:41:26.969Z" }, + { url = "https://files.pythonhosted.org/packages/da/21/26f1680ec3a598ea31768f9ebcd427e42986d077a005416094b580635532/autoflake-2.3.3-py3-none-any.whl", hash = "sha256:a51a3412aff16135ee5b3ec25922459fef10c1f23ce6d6c4977188df859e8b53", size = 17715, upload-time = "2026-02-20T05:01:42.137Z" }, ] [[package]] @@ -75,11 +75,11 @@ wheels = [ [[package]] name = "certifi" -version = "2026.1.4" +version = "2026.2.25" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e0/2d/a891ca51311197f6ad14a7ef42e2399f36cf2f9bd44752b3dc4eab60fdc5/certifi-2026.1.4.tar.gz", hash = "sha256:ac726dd470482006e014ad384921ed6438c457018f4b3d204aea4281258b2120", size = 154268, upload-time = "2026-01-04T02:42:41.825Z" } +sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029, upload-time = "2026-02-25T02:54:17.342Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e6/ad/3cc14f097111b4de0040c83a525973216457bbeeb63739ef1ed275c1c021/certifi-2026.1.4-py3-none-any.whl", hash = "sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c", size = 152900, upload-time = "2026-01-04T02:42:40.15Z" }, + { url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" }, ] [[package]] @@ -151,11 +151,11 @@ wheels = [ [[package]] name = "isort" -version = "7.0.0" +version = "8.0.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/63/53/4f3c058e3bace40282876f9b553343376ee687f3c35a525dc79dbd450f88/isort-7.0.0.tar.gz", hash = "sha256:5513527951aadb3ac4292a41a16cbc50dd1642432f5e8c20057d414bdafb4187", size = 805049, upload-time = "2025-10-11T13:30:59.107Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ef/7c/ec4ab396d31b3b395e2e999c8f46dec78c5e29209fac49d1f4dace04041d/isort-8.0.1.tar.gz", hash = "sha256:171ac4ff559cdc060bcfff550bc8404a486fee0caab245679c2abe7cb253c78d", size = 769592, upload-time = "2026-02-28T10:08:20.685Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7f/ed/e3705d6d02b4f7aea715a353c8ce193efd0b5db13e204df895d38734c244/isort-7.0.0-py3-none-any.whl", hash = "sha256:1bcabac8bc3c36c7fb7b98a76c8abb18e0f841a3ba81decac7691008592499c1", size = 94672, upload-time = "2025-10-11T13:30:57.665Z" }, + { url = "https://files.pythonhosted.org/packages/3e/95/c7c34aa53c16353c56d0b802fba48d5f5caa2cdee7958acbcb795c830416/isort-8.0.1-py3-none-any.whl", hash = "sha256:28b89bc70f751b559aeca209e6120393d43fbe2490de0559662be7a9787e3d75", size = 89733, upload-time = "2026-02-28T10:08:19.466Z" }, ] [[package]] @@ -187,11 +187,11 @@ wheels = [ [[package]] name = "platformdirs" -version = "4.9.1" +version = "4.9.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6c/d5/763666321efaded11112de8b7a7f2273dd8d1e205168e73c334e54b0ab9a/platformdirs-4.9.1.tar.gz", hash = "sha256:f310f16e89c4e29117805d8328f7c10876eeff36c94eac879532812110f7d39f", size = 28392, upload-time = "2026-02-14T21:02:44.973Z" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/04/fea538adf7dbbd6d186f551d595961e564a3b6715bdf276b477460858672/platformdirs-4.9.2.tar.gz", hash = "sha256:9a33809944b9db043ad67ca0db94b14bf452cc6aeaac46a88ea55b26e2e9d291", size = 28394, upload-time = "2026-02-16T03:56:10.574Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/70/77/e8c95e95f1d4cdd88c90a96e31980df7e709e51059fac150046ad67fac63/platformdirs-4.9.1-py3-none-any.whl", hash = "sha256:61d8b967d34791c162d30d60737369cbbd77debad5b981c4bfda1842e71e0d66", size = 21307, upload-time = "2026-02-14T21:02:43.492Z" }, + { url = "https://files.pythonhosted.org/packages/48/31/05e764397056194206169869b50cf2fee4dbbbc71b344705b9c0d878d4d8/platformdirs-4.9.2-py3-none-any.whl", hash = "sha256:9170634f126f8efdae22fb58ae8a0eaa86f38365bc57897a6c4f781d1f5875bd", size = 21168, upload-time = "2026-02-16T03:56:08.891Z" }, ] [[package]] @@ -368,7 +368,7 @@ dev = [ [package.metadata] requires-dist = [ { name = "httpx", specifier = ">=0.28.1" }, - { name = "pydantic", specifier = ">=2.12.5" }, + { name = "pydantic", specifier = ">=2.0.0" }, { name = "tenacity", specifier = ">=9.1.4" }, ]