From 9e2e9af1c30aafa8dfd8a2e960075f096eb5a5c7 Mon Sep 17 00:00:00 2001 From: Aaron Fredrick Date: Mon, 8 Jun 2026 09:58:51 +0530 Subject: [PATCH 1/8] feat: implement comprehensive sync and async clients for the Filebin API --- LICENSE | 41 +++++++------- README.md | 8 +-- docs/index.md | 88 +++++++++++++++++++++++++++++++ filebin/__version__.py | 7 ++- filebin/cli/commands.py | 23 +++++--- filebin/cli/main.py | 6 +++ filebin/client/async_client.py | 31 +++++++++++ filebin/client/sync_client.py | 32 +++++++++++ filebin/core/errors.py | 27 ++++++++-- filebin/core/http.py | 26 ++++++++- filebin/core/validation.py | 48 +++++++++++++++++ mkdocs.yml | 2 +- pyproject.toml | 8 +-- tests/unit/test_errors.py | 32 +++++++++++ tests/unit/test_http_transport.py | 29 ++++++++++ tests/unit/test_validation.py | 57 ++++++++++++++++++++ 16 files changed, 424 insertions(+), 41 deletions(-) create mode 100644 filebin/core/validation.py create mode 100644 tests/unit/test_validation.py diff --git a/LICENSE b/LICENSE index 8fd0f34..893fdf3 100644 --- a/LICENSE +++ b/LICENSE @@ -1,21 +1,24 @@ -MIT License +Copyright (c) 2024, Filebin Python Client Contributors +All rights reserved. -Copyright (c) 2024 Aaron Fredrick +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + * Neither the name of the copyright holder nor the + names of its contributors may be used to endorse or promote products + derived from this software without specific prior written permission. -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE FOR ANY +DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/README.md b/README.md index 7495a8f..d70b599 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,11 @@ -# Filebin.net Python SDK +# Filebin.net Python Client ![CI](https://github.com/aaron-fredrick/filebin/actions/workflows/ci.yml/badge.svg) ![PyPI](https://img.shields.io/pypi/v/filebin) -A complete, typed, async-first Python SDK and CLI for the [Filebin.net](https://filebin.net/) API. +A complete, typed, async-first Python client and CLI for the [Filebin.net](https://filebin.net/) API. + +*Note: This is an unofficial, community-driven Python wrapper for Filebin, not affiliated with the official filebin.net service.* ## Installation @@ -16,7 +18,7 @@ With CLI formatting support: pip install filebin[cli-pretty] ``` -## Quick SDK Usage +## Quick Client Usage ```python import asyncio diff --git a/docs/index.md b/docs/index.md index e69de29..9f85e89 100644 --- a/docs/index.md +++ b/docs/index.md @@ -0,0 +1,88 @@ +# Filebin Python Client & CLI + +Welcome to the official documentation for the **Filebin Python Client**. This library provides a complete, typed, async-first Python interface and Command-Line Interface (CLI) for the [Filebin.net](https://filebin.net/) API. + +*Note: This is an unofficial, community-driven Python wrapper for Filebin, not affiliated with the official filebin.net service.* + +## Key Features + +- **Async First:** Built on `aiohttp` for high-performance, non-blocking I/O. +- **Sync Support:** A fully synchronous client (`SyncFilebinClient`) is also provided for standard blocking scripts. +- **Robust Error Handling:** Meticulous mapping of HTTP status codes to Python exceptions (`AuthenticationError`, `UploadValidationError`, etc.) based directly on the Filebin engine. +- **Strict Typing:** Extensively annotated with Python type hints for excellent IDE support and `mypy` compatibility. +- **CLI Included:** A powerful CLI tool (`fbin`) is bundled for easy use directly from your terminal. + +## Installation + +Install the package via pip: + +```bash +pip install filebin +``` + +To include the rich formatting dependencies for the CLI: + +```bash +pip install filebin[cli-pretty] +``` + +## Quick Start (Python) + +### Asynchronous Client + +```python +import asyncio +from filebin import AsyncFilebinClient + +async def main(): + async with AsyncFilebinClient() as client: + # Generate a valid local bin ID or validate a custom one + bin_model = client.create_bin("my-custom-bin-id") + + # Upload a file + file = await client.upload_file(bin_model.id, "document.pdf") + print(f"Uploaded: {file.filename}") + + # List files in a bin + bin_meta = await client.list_bin(bin_model.id) + for f in bin_meta.files: + print(f.filename) + +if __name__ == "__main__": + asyncio.run(main()) +``` + +### Synchronous Client + +```python +from filebin import SyncFilebinClient + +client = SyncFilebinClient() + +# Create a bin and upload +bin_model = client.create_bin("my-sync-bin") +file = client.upload_file(bin_model.id, "report.csv") +print(f"Uploaded {file.filename} to {bin_model.id}") +``` + +## Quick Start (CLI) + +```bash +# Upload a file (bin ID is automatically generated if omitted) +fbin upload document.pdf --bin my-bin-id + +# Download a file +fbin download my-bin-id document.pdf + +# List contents +fbin list my-bin-id + +# Create a bin +fbin create-bin --bin my-custom-bin-id +``` + +## Next Steps + +- Check out the **[API Reference](api/client.md)** for detailed method signatures. +- Read about the **[CLI Usage](cli/usage.md)**. +- Understand the **[Architecture](architecture/overview.md)** and design decisions behind the client. \ No newline at end of file diff --git a/filebin/__version__.py b/filebin/__version__.py index 234d096..4c91298 100644 --- a/filebin/__version__.py +++ b/filebin/__version__.py @@ -1,3 +1,6 @@ -from importlib.metadata import version +from importlib.metadata import PackageNotFoundError, version -__version__ = version("filebin") +try: + __version__ = version("filebin") +except PackageNotFoundError: + __version__ = "1.0.0" diff --git a/filebin/cli/commands.py b/filebin/cli/commands.py index 4a457a3..357241f 100644 --- a/filebin/cli/commands.py +++ b/filebin/cli/commands.py @@ -14,14 +14,13 @@ async def cmd_upload(args: argparse.Namespace, client: AsyncFilebinClient) -> No output.print_error(f"File not found: {path}") return - # If no bin provided, Filebin.net assigns one dynamically, but our strict typed - # client expects an ID. We let the HTTP layer POST to / if bin_id is missing, - # then redirect catches the new bin ID. - # For now, require it or generate a local random one for simplicity. - if not bin_id: - import uuid - - bin_id = uuid.uuid4().hex[:16] + # Generate or validate bin ID using the client's create_bin method + try: + bin_model = client.create_bin(bin_id) + bin_id = bin_model.id + except ValueError as e: + output.print_error(str(e)) + return file_model = await client.upload_file(bin_id, path) output.print_success(f"Uploaded {file_model.filename} to bin {bin_id}") @@ -57,3 +56,11 @@ async def cmd_lock(args: argparse.Namespace, client: AsyncFilebinClient) -> None bin_model = await client.lock_bin(args.bin) output.print_success(f"Locked bin {bin_model.id}") output.print_bin(bin_model) + + +async def cmd_create_bin(args: argparse.Namespace, client: AsyncFilebinClient) -> None: + try: + bin_model = client.create_bin(args.bin) + output.print_success(f"Created/Validated bin ID: {bin_model.id}") + except ValueError as e: + output.print_error(str(e)) diff --git a/filebin/cli/main.py b/filebin/cli/main.py index 2e578fa..0c8f25f 100644 --- a/filebin/cli/main.py +++ b/filebin/cli/main.py @@ -59,6 +59,10 @@ def build_parser() -> argparse.ArgumentParser: p_lock = subparsers.add_parser("lock", help="Lock a bin (read-only)") p_lock.add_argument("bin", help="Bin ID") + # fbin create-bin [--bin ] + p_create = subparsers.add_parser("create-bin", help="Create or validate a local bin ID") + p_create.add_argument("--bin", help="Custom bin ID to validate") + return parser @@ -82,6 +86,8 @@ async def _main(args: argparse.Namespace) -> None: await commands.cmd_archive(args, client) elif args.command == "lock": await commands.cmd_lock(args, client) + elif args.command == "create-bin": + await commands.cmd_create_bin(args, client) except FilebinError as exc: output.print_error(str(exc)) sys.exit(1) diff --git a/filebin/client/async_client.py b/filebin/client/async_client.py index 80ca745..0f18dac 100644 --- a/filebin/client/async_client.py +++ b/filebin/client/async_client.py @@ -7,6 +7,7 @@ from filebin.core.config import ClientConfig from filebin.core.http import HttpTransport +from filebin.core.validation import generate_bin_id, validate_bin_id from filebin.models.bin import BinModel from filebin.models.file import FileModel @@ -34,6 +35,36 @@ async def close(self) -> None: """Close the underlying HTTP transport session.""" await self._transport.close() + def create_bin(self, bin_id: str | None = None) -> BinModel: + """Create a new valid bin locally. + + Note: Bins in Filebin are created dynamically upon the first file upload. + This method generates a valid bin ID or validates a provided one, + allowing you to set up the bin locally before uploading. + + Args: + bin_id: Optional custom bin ID. If None, a valid random one is generated. + + Returns: + A BinModel instance containing the bin_id. + + Raises: + ValueError: If a provided bin_id is invalid. + """ + if bin_id is not None: + validate_bin_id(bin_id) + else: + bin_id = generate_bin_id() + + # Return a shell BinModel. The backend will actually create the bin on first upload. + return BinModel( + id=bin_id, + readonly=False, + bytes=0, + files=0, + downloads=0, + ) + async def upload_file(self, bin_id: str, path: Path | str) -> FileModel: """Upload a local file to a bin.""" path_obj = Path(path) diff --git a/filebin/client/sync_client.py b/filebin/client/sync_client.py index a18286d..cd1e171 100644 --- a/filebin/client/sync_client.py +++ b/filebin/client/sync_client.py @@ -39,6 +39,38 @@ class FilebinClient: def __init__(self, config: ClientConfig | None = None) -> None: self.config = config or ClientConfig() + def create_bin(self, bin_id: str | None = None) -> BinModel: + """Create a new valid bin locally. + + Note: Bins in Filebin are created dynamically upon the first file upload. + This method generates a valid bin ID or validates a provided one, + allowing you to set up the bin locally before uploading. + + Args: + bin_id: Optional custom bin ID. If None, a valid random one is generated. + + Returns: + A BinModel instance containing the bin_id. + + Raises: + ValueError: If a provided bin_id is invalid. + """ + # Since create_bin is a synchronous local operation, we don't need the async loop + from filebin.core.validation import generate_bin_id, validate_bin_id + + if bin_id is not None: + validate_bin_id(bin_id) + else: + bin_id = generate_bin_id() + + return BinModel( + id=bin_id, + readonly=False, + bytes=0, + files=0, + downloads=0, + ) + def upload_file(self, bin_id: str, path: Path | str) -> FileModel: """Upload a local file to a bin.""" _guard_no_running_loop() diff --git a/filebin/core/errors.py b/filebin/core/errors.py index dc36456..7d248b9 100644 --- a/filebin/core/errors.py +++ b/filebin/core/errors.py @@ -55,12 +55,26 @@ def __init__(self, status_code: int, body: str = "") -> None: class AuthenticationError(FilebinError): - """Raised on HTTP 403 when access is denied (bin not approved, download limit).""" + """Raised on HTTP 403 when access is denied.""" def __init__(self, reason: str, bin_id: str | None = None) -> None: super().__init__(f"Access denied: {reason}", status_code=403, bin_id=bin_id) +class ApprovalRequiredError(AuthenticationError): + """Raised on HTTP 403 when a bin requires approval before files can be downloaded.""" + + def __init__(self, bin_id: str | None = None) -> None: + super().__init__("This bin requires approval before files can be downloaded.", bin_id=bin_id) + + +class FileDownloadLimitError(AuthenticationError): + """Raised on HTTP 403 when files or bins have exceeded the download limit.""" + + def __init__(self, bin_id: str | None = None) -> None: + super().__init__("The file has been requested too many times or exceeded limits.", bin_id=bin_id) + + class BinNotFoundError(FilebinError): """Raised when a requested bin does not exist or has expired.""" @@ -88,7 +102,14 @@ def __init__(self, bin_id: str) -> None: class StorageFullError(FilebinError): - """Raised on HTTP 403 when the bin has no remaining storage capacity.""" + """Raised on HTTP 403 or 507 when the bin has no remaining storage capacity.""" def __init__(self, bin_id: str) -> None: - super().__init__(f"Storage full for bin: {bin_id!r}", status_code=403, bin_id=bin_id) + super().__init__(f"Storage full for bin: {bin_id!r}", status_code=507, bin_id=bin_id) + + +class UploadValidationError(FilebinError): + """Raised on HTTP 400 or 411 when file upload validation fails (e.g. invalid extension, size, checksums).""" + + def __init__(self, reason: str, bin_id: str | None = None) -> None: + super().__init__(f"Upload validation failed: {reason}", status_code=400, bin_id=bin_id) diff --git a/filebin/core/http.py b/filebin/core/http.py index 095688d..fb444e4 100644 --- a/filebin/core/http.py +++ b/filebin/core/http.py @@ -21,15 +21,19 @@ from filebin.core.config import ClientConfig from filebin.core.errors import ( + ApprovalRequiredError, AuthenticationError, + BinLockedError, BinNotFoundError, FilebinError, + FileDownloadLimitError, FileNotFoundError, NetworkError, RateLimitError, ServerError, StorageFullError, TimeoutError, + UploadValidationError, ) from filebin.core.retry import RetryPolicy @@ -260,10 +264,20 @@ def _raise_for_status( if status in (200, 201, 302): return + body_str = str(response.body).lower() if response.body else "" + + if status == 400 or status == 411: + raise UploadValidationError( + reason=str(response.body) or "Validation failed", bin_id=bin_id + ) + if status == 403: - body_str = str(response.body).lower() if response.body else "" if "storage" in body_str or "full" in body_str: raise StorageFullError(bin_id or "unknown") + if "approval" in body_str: + raise ApprovalRequiredError(bin_id) + if "limit" in body_str or "too many times" in body_str: + raise FileDownloadLimitError(bin_id) raise AuthenticationError( reason=str(response.body) or "forbidden", bin_id=bin_id, @@ -277,10 +291,20 @@ def _raise_for_status( raise BinNotFoundError(bin_id) raise BinNotFoundError(bin_id or "unknown") + if status == 405: + # Method not allowed. Could be locked, expired, deleted. + if "locked" in body_str or "readonly" in body_str: + raise BinLockedError(bin_id or "unknown") + if "expired" in body_str or "deleted" in body_str or "no longer available" in body_str: + raise BinNotFoundError(bin_id or "unknown") + raise ServerError(status_code=status, body=str(response.body or "")) + if status == 429: raise RateLimitError() if 500 <= status < 600: + if status == 507: + raise StorageFullError(bin_id or "unknown") raise ServerError(status_code=status, body=str(response.body or "")) _logger.warning("Unhandled HTTP status %d from Filebin.net", status) diff --git a/filebin/core/validation.py b/filebin/core/validation.py new file mode 100644 index 0000000..a77e8d2 --- /dev/null +++ b/filebin/core/validation.py @@ -0,0 +1,48 @@ +"""Validation logic for Filebin bin IDs and filenames.""" + +import re +import secrets +import string + +# Valid characters for generated bin IDs +_ID_CHARS = string.ascii_lowercase + string.digits +# Regex matching invalid characters according to filebin2 +_INVALID_BIN_PATTERN = re.compile(r"[^A-Za-z0-9-_.]") + + +def validate_bin_id(bin_id: str) -> None: + """Validate a bin ID according to filebin2 constraints. + + Args: + bin_id: The bin ID to validate. + + Raises: + ValueError: If the bin ID is invalid. + """ + if not bin_id: + raise ValueError("Bin ID cannot be empty.") + if _INVALID_BIN_PATTERN.search(bin_id): + raise ValueError("Bin ID contains invalid characters.") + if len(bin_id) < 8: + raise ValueError("Bin ID is too short (minimum 8 characters).") + if len(bin_id) > 60: + raise ValueError("Bin ID is too long (maximum 60 characters).") + if bin_id.startswith("."): + raise ValueError("Bin ID cannot start with a dot.") + + +def generate_bin_id(length: int = 16) -> str: + """Generate a random valid bin ID. + + Matches the default behaviour of filebin2 which uses 16 characters + of lowercase letters and numbers. + + Args: + length: Length of the generated ID. + + Returns: + A randomly generated valid bin ID. + """ + if length < 8 or length > 60: + raise ValueError("Generated bin length must be between 8 and 60 characters.") + return "".join(secrets.choice(_ID_CHARS) for _ in range(length)) diff --git a/mkdocs.yml b/mkdocs.yml index 458bd92..560d351 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -1,5 +1,5 @@ site_name: Filebin -site_description: Python SDK and CLI for the Filebin.net API +site_description: Python client and CLI for the Filebin.net API site_url: https://aaron-fredrick.github.io/filebin/ repo_url: https://github.com/aaron-fredrick/filebin diff --git a/pyproject.toml b/pyproject.toml index 4a43114..d5412b0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,21 +6,21 @@ build-backend = "hatchling.build" [project] name = "filebin" version = "1.0.0" -description = "Async Python SDK and CLI for the Filebin.net API" +description = "Async Python client and CLI for the Filebin.net API" readme = "README.md" requires-python = ">=3.12" -license = { text = "MIT" } +license = { text = "BSD-3-Clause" } authors = [ { name = "Aaron Fredrick", email = "Aaron_Fredrick@proton.me" }, ] -keywords = ["filebin", "api", "async", "file-upload", "file-sharing", "sdk"] +keywords = ["filebin", "api", "async", "file-upload", "file-sharing", "client"] classifiers = [ "Development Status :: 4 - Beta", "Intended Audience :: Developers", - "License :: OSI Approved :: MIT License", + "License :: OSI Approved :: BSD License", "Operating System :: OS Independent", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.12", diff --git a/tests/unit/test_errors.py b/tests/unit/test_errors.py index 5b0ffd0..491d0a6 100644 --- a/tests/unit/test_errors.py +++ b/tests/unit/test_errors.py @@ -1,11 +1,15 @@ import pytest from filebin.core.errors import ( + ApprovalRequiredError, AuthenticationError, BinNotFoundError, + FileDownloadLimitError, FileNotFoundError, RateLimitError, ServerError, + StorageFullError, + UploadValidationError, ) pytestmark = pytest.mark.unit @@ -44,3 +48,31 @@ def test_authentication_error() -> None: assert exc.status_code == 403 assert exc.bin_id == "test-123" assert str(exc) == "Access denied: Download limit reached (HTTP 403)" + + +def test_approval_required_error() -> None: + exc = ApprovalRequiredError(bin_id="test-123") + assert exc.status_code == 403 + assert exc.bin_id == "test-123" + assert "requires approval" in str(exc) + + +def test_file_download_limit_error() -> None: + exc = FileDownloadLimitError(bin_id="test-123") + assert exc.status_code == 403 + assert exc.bin_id == "test-123" + assert "exceeded limits" in str(exc) + + +def test_storage_full_error() -> None: + exc = StorageFullError(bin_id="test-123") + assert exc.status_code == 507 + assert exc.bin_id == "test-123" + assert "Storage full" in str(exc) + + +def test_upload_validation_error() -> None: + exc = UploadValidationError("File too large", bin_id="test-123") + assert exc.status_code == 400 + assert exc.bin_id == "test-123" + assert "Validation failed" in str(exc) or "Upload validation failed: File too large" in str(exc) diff --git a/tests/unit/test_http_transport.py b/tests/unit/test_http_transport.py index 154f5b3..70cd2fa 100644 --- a/tests/unit/test_http_transport.py +++ b/tests/unit/test_http_transport.py @@ -9,14 +9,18 @@ from filebin.core.config import ClientConfig from filebin.core.errors import ( + ApprovalRequiredError, AuthenticationError, + BinLockedError, BinNotFoundError, + FileDownloadLimitError, FileNotFoundError, NetworkError, RateLimitError, ServerError, StorageFullError, TimeoutError, + UploadValidationError, ) from filebin.core.http import HttpTransport, ParsedResponse @@ -139,11 +143,36 @@ def test_raise_for_status_403_storage_full() -> None: HttpTransport._raise_for_status(_parsed(403, "storage is full"), bin_id="b", filename=None) +def test_raise_for_status_403_approval_required() -> None: + with pytest.raises(ApprovalRequiredError): + HttpTransport._raise_for_status(_parsed(403, "requires approval"), bin_id="b", filename=None) + + +def test_raise_for_status_403_download_limit() -> None: + with pytest.raises(FileDownloadLimitError): + HttpTransport._raise_for_status(_parsed(403, "download limit reached"), bin_id="b", filename=None) + + def test_raise_for_status_403_auth_error() -> None: with pytest.raises(AuthenticationError): HttpTransport._raise_for_status(_parsed(403, "forbidden"), bin_id="b", filename=None) +def test_raise_for_status_400_upload_validation() -> None: + with pytest.raises(UploadValidationError): + HttpTransport._raise_for_status(_parsed(400, "invalid size"), bin_id="b", filename=None) + + +def test_raise_for_status_411_upload_validation() -> None: + with pytest.raises(UploadValidationError): + HttpTransport._raise_for_status(_parsed(411, "length required"), bin_id="b", filename=None) + + +def test_raise_for_status_405_locked_bin() -> None: + with pytest.raises(BinLockedError): + HttpTransport._raise_for_status(_parsed(405, "bin is locked"), bin_id="b", filename=None) + + def test_raise_for_status_404_file_not_found() -> None: with pytest.raises(FileNotFoundError): HttpTransport._raise_for_status(_parsed(404), bin_id="b", filename="f.txt") diff --git a/tests/unit/test_validation.py b/tests/unit/test_validation.py new file mode 100644 index 0000000..6a4cb3b --- /dev/null +++ b/tests/unit/test_validation.py @@ -0,0 +1,57 @@ +import pytest +from filebin.core.validation import generate_bin_id, validate_bin_id + + +def test_generate_bin_id_length(): + """Test generating a bin ID with various lengths.""" + bin_id = generate_bin_id(16) + assert len(bin_id) == 16 + + bin_id = generate_bin_id(60) + assert len(bin_id) == 60 + + bin_id = generate_bin_id(8) + assert len(bin_id) == 8 + + +def test_generate_bin_id_invalid_length(): + """Test generating a bin ID with invalid lengths raises ValueError.""" + with pytest.raises(ValueError, match="must be between 8 and 60"): + generate_bin_id(7) + + with pytest.raises(ValueError, match="must be between 8 and 60"): + generate_bin_id(61) + + +def test_validate_bin_id_valid(): + """Test validating valid bin IDs.""" + validate_bin_id("a1b2c3d4e5") + validate_bin_id("test-bin_123.abc") + validate_bin_id("12345678") + + +def test_validate_bin_id_invalid_chars(): + """Test validating bin IDs with invalid characters raises ValueError.""" + with pytest.raises(ValueError, match="invalid characters"): + validate_bin_id("invalid/bin") + + with pytest.raises(ValueError, match="invalid characters"): + validate_bin_id("invalid@bin") + + with pytest.raises(ValueError, match="invalid characters"): + validate_bin_id("invalid bin") + + +def test_validate_bin_id_invalid_length(): + """Test validating bin IDs with invalid lengths raises ValueError.""" + with pytest.raises(ValueError, match="too short"): + validate_bin_id("short") + + with pytest.raises(ValueError, match="too long"): + validate_bin_id("a" * 61) + + +def test_validate_bin_id_invalid_start(): + """Test validating bin IDs starting with a dot raises ValueError.""" + with pytest.raises(ValueError, match="cannot start with a dot"): + validate_bin_id(".invalidstart") From 31e489fbbadd8839e3d6b62a26139219f30cb612 Mon Sep 17 00:00:00 2001 From: Aaron Fredrick Date: Mon, 8 Jun 2026 10:01:33 +0530 Subject: [PATCH 2/8] feat: implement async/sync clients and CLI command interface for Filebin API --- filebin/cli/commands.py | 6 ++++-- filebin/client/async_client.py | 30 +++++++++++++++++------------- filebin/client/sync_client.py | 27 ++++++++++++++++----------- 3 files changed, 37 insertions(+), 26 deletions(-) diff --git a/filebin/cli/commands.py b/filebin/cli/commands.py index 357241f..965c973 100644 --- a/filebin/cli/commands.py +++ b/filebin/cli/commands.py @@ -16,7 +16,7 @@ async def cmd_upload(args: argparse.Namespace, client: AsyncFilebinClient) -> No # Generate or validate bin ID using the client's create_bin method try: - bin_model = client.create_bin(bin_id) + bin_model = await client.create_bin(bin_id) bin_id = bin_model.id except ValueError as e: output.print_error(str(e)) @@ -60,7 +60,9 @@ async def cmd_lock(args: argparse.Namespace, client: AsyncFilebinClient) -> None async def cmd_create_bin(args: argparse.Namespace, client: AsyncFilebinClient) -> None: try: - bin_model = client.create_bin(args.bin) + bin_model = await client.create_bin(args.bin) output.print_success(f"Created/Validated bin ID: {bin_model.id}") + if bin_model.files > 0: + output.print_bin(bin_model) except ValueError as e: output.print_error(str(e)) diff --git a/filebin/client/async_client.py b/filebin/client/async_client.py index 0f18dac..d73da1b 100644 --- a/filebin/client/async_client.py +++ b/filebin/client/async_client.py @@ -7,6 +7,7 @@ from filebin.core.config import ClientConfig from filebin.core.http import HttpTransport +from filebin.core.errors import BinNotFoundError from filebin.core.validation import generate_bin_id, validate_bin_id from filebin.models.bin import BinModel from filebin.models.file import FileModel @@ -35,18 +36,18 @@ async def close(self) -> None: """Close the underlying HTTP transport session.""" await self._transport.close() - def create_bin(self, bin_id: str | None = None) -> BinModel: - """Create a new valid bin locally. + async def create_bin(self, bin_id: str | None = None) -> BinModel: + """Create a new valid bin locally and fetch its metadata if it exists. Note: Bins in Filebin are created dynamically upon the first file upload. - This method generates a valid bin ID or validates a provided one, - allowing you to set up the bin locally before uploading. + This method generates a valid bin ID or validates a provided one. If the bin + already exists, its metadata is fetched and returned. Args: bin_id: Optional custom bin ID. If None, a valid random one is generated. Returns: - A BinModel instance containing the bin_id. + A BinModel instance containing the bin_id and any existing metadata. Raises: ValueError: If a provided bin_id is invalid. @@ -56,14 +57,17 @@ def create_bin(self, bin_id: str | None = None) -> BinModel: else: bin_id = generate_bin_id() - # Return a shell BinModel. The backend will actually create the bin on first upload. - return BinModel( - id=bin_id, - readonly=False, - bytes=0, - files=0, - downloads=0, - ) + try: + return await self.list_bin(bin_id) + except BinNotFoundError: + # Return a shell BinModel. The backend will actually create the bin on first upload. + return BinModel( + id=bin_id, + readonly=False, + bytes=0, + files=0, + downloads=0, + ) async def upload_file(self, bin_id: str, path: Path | str) -> FileModel: """Upload a local file to a bin.""" diff --git a/filebin/client/sync_client.py b/filebin/client/sync_client.py index cd1e171..dc777a3 100644 --- a/filebin/client/sync_client.py +++ b/filebin/client/sync_client.py @@ -40,22 +40,24 @@ def __init__(self, config: ClientConfig | None = None) -> None: self.config = config or ClientConfig() def create_bin(self, bin_id: str | None = None) -> BinModel: - """Create a new valid bin locally. + """Create a new valid bin locally and fetch its metadata if it exists. Note: Bins in Filebin are created dynamically upon the first file upload. - This method generates a valid bin ID or validates a provided one, - allowing you to set up the bin locally before uploading. + This method generates a valid bin ID or validates a provided one. If the bin + already exists, its metadata is fetched and returned. Args: bin_id: Optional custom bin ID. If None, a valid random one is generated. Returns: - A BinModel instance containing the bin_id. + A BinModel instance containing the bin_id and any existing metadata. Raises: ValueError: If a provided bin_id is invalid. """ # Since create_bin is a synchronous local operation, we don't need the async loop + # Wait, if we fetch metadata, we do need the async loop + from filebin.core.errors import BinNotFoundError from filebin.core.validation import generate_bin_id, validate_bin_id if bin_id is not None: @@ -63,13 +65,16 @@ def create_bin(self, bin_id: str | None = None) -> BinModel: else: bin_id = generate_bin_id() - return BinModel( - id=bin_id, - readonly=False, - bytes=0, - files=0, - downloads=0, - ) + try: + return self.list_bin(bin_id) + except BinNotFoundError: + return BinModel( + id=bin_id, + readonly=False, + bytes=0, + files=0, + downloads=0, + ) def upload_file(self, bin_id: str, path: Path | str) -> FileModel: """Upload a local file to a bin.""" From 7eec54c922679208cc7f8a284f43ec1505e74a7f Mon Sep 17 00:00:00 2001 From: Aaron Fredrick Date: Mon, 8 Jun 2026 10:04:25 +0530 Subject: [PATCH 3/8] chore: update copyright notices to include original and current contributors --- LICENSE | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/LICENSE b/LICENSE index 893fdf3..508b785 100644 --- a/LICENSE +++ b/LICENSE @@ -1,4 +1,5 @@ -Copyright (c) 2024, Filebin Python Client Contributors +Copyright (c) 2024, Aaron Fredrick and Filebin Python Client Contributors +Copyright (c) 2015-2020, Espen Braastad (Original Filebin.net engine) All rights reserved. Redistribution and use in source and binary forms, with or without From 40668860b81f55c9b5f414d5d5b66435a5342296 Mon Sep 17 00:00:00 2001 From: Aaron Fredrick Date: Mon, 8 Jun 2026 10:08:15 +0530 Subject: [PATCH 4/8] feat: implement async and sync Filebin SDK clients with core error handling and test coverage --- filebin/cli/commands.py | 2 +- filebin/client/async_client.py | 18 ++++++++++-------- filebin/client/sync_client.py | 18 ++++++++++-------- filebin/core/errors.py | 15 ++++++++++++--- tests/unit/test_http_transport.py | 8 ++++++-- tests/unit/test_validation.py | 13 +++++++------ 6 files changed, 46 insertions(+), 28 deletions(-) diff --git a/filebin/cli/commands.py b/filebin/cli/commands.py index 965c973..5ee0292 100644 --- a/filebin/cli/commands.py +++ b/filebin/cli/commands.py @@ -62,7 +62,7 @@ async def cmd_create_bin(args: argparse.Namespace, client: AsyncFilebinClient) - try: bin_model = await client.create_bin(args.bin) output.print_success(f"Created/Validated bin ID: {bin_model.id}") - if bin_model.files > 0: + if len(bin_model.files) > 0: output.print_bin(bin_model) except ValueError as e: output.print_error(str(e)) diff --git a/filebin/client/async_client.py b/filebin/client/async_client.py index d73da1b..17d6904 100644 --- a/filebin/client/async_client.py +++ b/filebin/client/async_client.py @@ -6,8 +6,8 @@ from typing import Literal from filebin.core.config import ClientConfig -from filebin.core.http import HttpTransport from filebin.core.errors import BinNotFoundError +from filebin.core.http import HttpTransport from filebin.core.validation import generate_bin_id, validate_bin_id from filebin.models.bin import BinModel from filebin.models.file import FileModel @@ -38,17 +38,17 @@ async def close(self) -> None: async def create_bin(self, bin_id: str | None = None) -> BinModel: """Create a new valid bin locally and fetch its metadata if it exists. - + Note: Bins in Filebin are created dynamically upon the first file upload. This method generates a valid bin ID or validates a provided one. If the bin already exists, its metadata is fetched and returned. - + Args: bin_id: Optional custom bin ID. If None, a valid random one is generated. - + Returns: A BinModel instance containing the bin_id and any existing metadata. - + Raises: ValueError: If a provided bin_id is invalid. """ @@ -56,7 +56,7 @@ async def create_bin(self, bin_id: str | None = None) -> BinModel: validate_bin_id(bin_id) else: bin_id = generate_bin_id() - + try: return await self.list_bin(bin_id) except BinNotFoundError: @@ -65,8 +65,10 @@ async def create_bin(self, bin_id: str | None = None) -> BinModel: id=bin_id, readonly=False, bytes=0, - files=0, - downloads=0, + created_at=None, + updated_at=None, + expired_at=None, + files=[], ) async def upload_file(self, bin_id: str, path: Path | str) -> FileModel: diff --git a/filebin/client/sync_client.py b/filebin/client/sync_client.py index dc777a3..675fdb2 100644 --- a/filebin/client/sync_client.py +++ b/filebin/client/sync_client.py @@ -41,17 +41,17 @@ def __init__(self, config: ClientConfig | None = None) -> None: def create_bin(self, bin_id: str | None = None) -> BinModel: """Create a new valid bin locally and fetch its metadata if it exists. - + Note: Bins in Filebin are created dynamically upon the first file upload. This method generates a valid bin ID or validates a provided one. If the bin already exists, its metadata is fetched and returned. - + Args: bin_id: Optional custom bin ID. If None, a valid random one is generated. - + Returns: A BinModel instance containing the bin_id and any existing metadata. - + Raises: ValueError: If a provided bin_id is invalid. """ @@ -59,12 +59,12 @@ def create_bin(self, bin_id: str | None = None) -> BinModel: # Wait, if we fetch metadata, we do need the async loop from filebin.core.errors import BinNotFoundError from filebin.core.validation import generate_bin_id, validate_bin_id - + if bin_id is not None: validate_bin_id(bin_id) else: bin_id = generate_bin_id() - + try: return self.list_bin(bin_id) except BinNotFoundError: @@ -72,8 +72,10 @@ def create_bin(self, bin_id: str | None = None) -> BinModel: id=bin_id, readonly=False, bytes=0, - files=0, - downloads=0, + created_at=None, + updated_at=None, + expired_at=None, + files=[], ) def upload_file(self, bin_id: str, path: Path | str) -> FileModel: diff --git a/filebin/core/errors.py b/filebin/core/errors.py index 7d248b9..ac384d6 100644 --- a/filebin/core/errors.py +++ b/filebin/core/errors.py @@ -65,14 +65,20 @@ class ApprovalRequiredError(AuthenticationError): """Raised on HTTP 403 when a bin requires approval before files can be downloaded.""" def __init__(self, bin_id: str | None = None) -> None: - super().__init__("This bin requires approval before files can be downloaded.", bin_id=bin_id) + super().__init__( + "This bin requires approval before files can be downloaded.", + bin_id=bin_id, + ) class FileDownloadLimitError(AuthenticationError): """Raised on HTTP 403 when files or bins have exceeded the download limit.""" def __init__(self, bin_id: str | None = None) -> None: - super().__init__("The file has been requested too many times or exceeded limits.", bin_id=bin_id) + super().__init__( + "The file has been requested too many times or exceeded limits.", + bin_id=bin_id, + ) class BinNotFoundError(FilebinError): @@ -109,7 +115,10 @@ def __init__(self, bin_id: str) -> None: class UploadValidationError(FilebinError): - """Raised on HTTP 400 or 411 when file upload validation fails (e.g. invalid extension, size, checksums).""" + """Raised on HTTP 400 or 411 when file upload validation fails. + + (e.g. invalid extension, size, checksums). + """ def __init__(self, reason: str, bin_id: str | None = None) -> None: super().__init__(f"Upload validation failed: {reason}", status_code=400, bin_id=bin_id) diff --git a/tests/unit/test_http_transport.py b/tests/unit/test_http_transport.py index 70cd2fa..d70fd81 100644 --- a/tests/unit/test_http_transport.py +++ b/tests/unit/test_http_transport.py @@ -145,12 +145,16 @@ def test_raise_for_status_403_storage_full() -> None: def test_raise_for_status_403_approval_required() -> None: with pytest.raises(ApprovalRequiredError): - HttpTransport._raise_for_status(_parsed(403, "requires approval"), bin_id="b", filename=None) + HttpTransport._raise_for_status( + _parsed(403, "requires approval"), bin_id="b", filename=None + ) def test_raise_for_status_403_download_limit() -> None: with pytest.raises(FileDownloadLimitError): - HttpTransport._raise_for_status(_parsed(403, "download limit reached"), bin_id="b", filename=None) + HttpTransport._raise_for_status( + _parsed(403, "download limit reached"), bin_id="b", filename=None + ) def test_raise_for_status_403_auth_error() -> None: diff --git a/tests/unit/test_validation.py b/tests/unit/test_validation.py index 6a4cb3b..4e4bb4a 100644 --- a/tests/unit/test_validation.py +++ b/tests/unit/test_validation.py @@ -1,4 +1,5 @@ import pytest + from filebin.core.validation import generate_bin_id, validate_bin_id @@ -6,10 +7,10 @@ def test_generate_bin_id_length(): """Test generating a bin ID with various lengths.""" bin_id = generate_bin_id(16) assert len(bin_id) == 16 - + bin_id = generate_bin_id(60) assert len(bin_id) == 60 - + bin_id = generate_bin_id(8) assert len(bin_id) == 8 @@ -18,7 +19,7 @@ def test_generate_bin_id_invalid_length(): """Test generating a bin ID with invalid lengths raises ValueError.""" with pytest.raises(ValueError, match="must be between 8 and 60"): generate_bin_id(7) - + with pytest.raises(ValueError, match="must be between 8 and 60"): generate_bin_id(61) @@ -34,10 +35,10 @@ def test_validate_bin_id_invalid_chars(): """Test validating bin IDs with invalid characters raises ValueError.""" with pytest.raises(ValueError, match="invalid characters"): validate_bin_id("invalid/bin") - + with pytest.raises(ValueError, match="invalid characters"): validate_bin_id("invalid@bin") - + with pytest.raises(ValueError, match="invalid characters"): validate_bin_id("invalid bin") @@ -46,7 +47,7 @@ def test_validate_bin_id_invalid_length(): """Test validating bin IDs with invalid lengths raises ValueError.""" with pytest.raises(ValueError, match="too short"): validate_bin_id("short") - + with pytest.raises(ValueError, match="too long"): validate_bin_id("a" * 61) From f0ac4f4dcff3f24290c2d96bf953c77ca51b4286 Mon Sep 17 00:00:00 2001 From: Aaron Fredrick Date: Mon, 8 Jun 2026 10:11:58 +0530 Subject: [PATCH 5/8] feat: add GitHub Actions workflows for CI, CD, and documentation deployment --- .github/workflows/cd.yml | 3 +++ .github/workflows/ci.yml | 6 +++++- .github/workflows/docs.yml | 3 +++ 3 files changed, 11 insertions(+), 1 deletion(-) diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index e1f9dc1..2299bb8 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -5,6 +5,9 @@ on: tags: - "v*" +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + jobs: ci: uses: ./.github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a70125f..bed1328 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,6 +7,9 @@ on: branches: ["main"] workflow_call: +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + jobs: build: runs-on: ubuntu-latest @@ -98,9 +101,10 @@ jobs: token: ${{ secrets.CODECOV_TOKEN }} - name: Upload test results to Codecov if: ${{ !cancelled() }} - uses: codecov/test-results-action@v1 + uses: codecov/codecov-action@v5 with: token: ${{ secrets.CODECOV_TOKEN }} + report_type: test_results publish-testpypi: needs: [build, lint, static, security, test] diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 82f28f7..deb9791 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -4,6 +4,9 @@ on: push: branches: ["main"] +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + permissions: contents: write From 258eb2f073f38f757673c1f703c3b1ed0122890c Mon Sep 17 00:00:00 2001 From: Aaron Fredrick Date: Mon, 8 Jun 2026 10:18:06 +0530 Subject: [PATCH 6/8] feat: add CI workflow for build, lint, testing, and TestPyPI publishing --- .github/workflows/ci.yml | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bed1328..81eb59a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -76,6 +76,26 @@ jobs: - name: Security (Bandit) run: uv run bandit -r filebin/ -ll + codeql: + name: CodeQL + runs-on: ubuntu-latest + permissions: + actions: read + contents: read + security-events: write + steps: + - name: Checkout repository + uses: actions/checkout@v4 + - name: Initialize CodeQL + uses: github/codeql-action/init@v3 + with: + languages: python + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v3 + with: + category: "/language:python" + + test: runs-on: ubuntu-latest steps: @@ -107,7 +127,7 @@ jobs: report_type: test_results publish-testpypi: - needs: [build, lint, static, security, test] + needs: [build, lint, static, security, codeql, test] if: github.event_name == 'push' && !startsWith(github.ref, 'refs/tags/') runs-on: ubuntu-latest From 441c0e43e82813999af28b1b42eb5c66708377ca Mon Sep 17 00:00:00 2001 From: Aaron Fredrick Date: Mon, 8 Jun 2026 10:28:28 +0530 Subject: [PATCH 7/8] test: add unit test suite for HttpTransport, sync/async clients, and validation logic --- tests/unit/test_async_client.py | 48 +++++++++++++++++++++++++++++++ tests/unit/test_http_transport.py | 26 +++++++++++++++++ tests/unit/test_sync_client.py | 36 +++++++++++++++++++++++ tests/unit/test_validation.py | 6 ++++ 4 files changed, 116 insertions(+) diff --git a/tests/unit/test_async_client.py b/tests/unit/test_async_client.py index 086e18c..a5426dc 100644 --- a/tests/unit/test_async_client.py +++ b/tests/unit/test_async_client.py @@ -3,6 +3,7 @@ import pytest from filebin.client.async_client import AsyncFilebinClient +from filebin.core.errors import BinNotFoundError from filebin.models.bin import BinModel from filebin.models.file import FileModel @@ -130,3 +131,50 @@ async def test_download_archive(client, mock_transport, tmp_path) -> None: assert dest.read_bytes() == b"archive-data" assert dest.name == "test-bin.zip" mock_transport.get.assert_awaited_once_with("/archive/test-bin/zip", bin_id="test-bin") + + +@pytest.mark.asyncio +async def test_create_bin_returns_existing_bin(client, mock_transport) -> None: + """When the bin already exists, create_bin should return the fetched metadata.""" + mock_response = AsyncMock() + mock_response.body = {"bin": {"id": "existing-bin"}, "files": []} + mock_transport.get.return_value = mock_response + + result = await client.create_bin("existing-bin") + + assert isinstance(result, BinModel) + assert result.id == "existing-bin" + mock_transport.get.assert_awaited_once_with("/existing-bin", bin_id="existing-bin") + + +@pytest.mark.asyncio +async def test_create_bin_returns_shell_for_new_bin(client, mock_transport) -> None: + """When the bin does not exist yet, create_bin should return a shell BinModel.""" + mock_transport.get.side_effect = BinNotFoundError("brand-new-bin") + + result = await client.create_bin("brand-new-bin") + + assert isinstance(result, BinModel) + assert result.id == "brand-new-bin" + assert result.files == [] + assert result.bytes == 0 + + +@pytest.mark.asyncio +async def test_create_bin_generates_id_when_none_provided(client, mock_transport) -> None: + """When no bin_id is given, create_bin should generate a valid one.""" + mock_transport.get.side_effect = BinNotFoundError("auto") + + result = await client.create_bin() + + assert isinstance(result, BinModel) + assert len(result.id) == 16 + + +@pytest.mark.asyncio +async def test_create_bin_raises_on_invalid_id(client, mock_transport) -> None: + """An invalid custom bin ID should raise ValueError before any network call.""" + with pytest.raises(ValueError): + await client.create_bin("!!invalid!!") + + mock_transport.get.assert_not_awaited() diff --git a/tests/unit/test_http_transport.py b/tests/unit/test_http_transport.py index d70fd81..bf765ee 100644 --- a/tests/unit/test_http_transport.py +++ b/tests/unit/test_http_transport.py @@ -177,6 +177,32 @@ def test_raise_for_status_405_locked_bin() -> None: HttpTransport._raise_for_status(_parsed(405, "bin is locked"), bin_id="b", filename=None) +def test_raise_for_status_405_expired_bin() -> None: + with pytest.raises(BinNotFoundError): + HttpTransport._raise_for_status( + _parsed(405, "bin has expired"), bin_id="b", filename=None + ) + + +def test_raise_for_status_405_deleted_bin() -> None: + with pytest.raises(BinNotFoundError): + HttpTransport._raise_for_status( + _parsed(405, "bin deleted"), bin_id="b", filename=None + ) + + +def test_raise_for_status_405_generic_fallback() -> None: + with pytest.raises(ServerError): + HttpTransport._raise_for_status( + _parsed(405, "method not allowed"), bin_id="b", filename=None + ) + + +def test_raise_for_status_507_storage_full() -> None: + with pytest.raises(StorageFullError): + HttpTransport._raise_for_status(_parsed(507), bin_id="b", filename=None) + + def test_raise_for_status_404_file_not_found() -> None: with pytest.raises(FileNotFoundError): HttpTransport._raise_for_status(_parsed(404), bin_id="b", filename="f.txt") diff --git a/tests/unit/test_sync_client.py b/tests/unit/test_sync_client.py index b36f07f..9b92990 100644 --- a/tests/unit/test_sync_client.py +++ b/tests/unit/test_sync_client.py @@ -4,6 +4,7 @@ import pytest from filebin.client.sync_client import FilebinClient, _guard_no_running_loop +from filebin.core.errors import BinNotFoundError from filebin.models.bin import BinModel from filebin.models.file import FileModel @@ -92,3 +93,38 @@ def test_download_archive(client, mock_async_client, tmp_path) -> None: result = client.download_archive("test-bin", "zip", tmp_path) assert result == expected_path mock_async_client.download_archive.assert_awaited_once_with("test-bin", "zip", tmp_path) + + +def test_create_bin_returns_existing_bin(client) -> None: + """When the bin exists, create_bin should return the fetched metadata.""" + existing = BinModel.from_api_dict({"bin": {"id": "existing-bin"}, "files": []}) + with patch.object(FilebinClient, "list_bin", return_value=existing): + result = client.create_bin("existing-bin") + assert isinstance(result, BinModel) + assert result.id == "existing-bin" + + +def test_create_bin_returns_shell_for_new_bin(client) -> None: + """When the bin does not exist, create_bin should return a shell BinModel.""" + with patch.object(FilebinClient, "list_bin", side_effect=BinNotFoundError("new-bin-01")): + result = client.create_bin("new-bin-01") + assert isinstance(result, BinModel) + assert result.id == "new-bin-01" + assert result.files == [] + assert result.bytes == 0 + + +def test_create_bin_generates_id_when_none_provided(client) -> None: + """When no bin_id is provided, create_bin should auto-generate one.""" + with patch.object(FilebinClient, "list_bin", side_effect=BinNotFoundError("auto")): + result = client.create_bin() + assert isinstance(result, BinModel) + assert len(result.id) == 16 + + +def test_create_bin_raises_on_invalid_id(client) -> None: + """An invalid custom bin_id should raise ValueError without a network call.""" + with patch.object(FilebinClient, "list_bin") as mock_list_bin: + with pytest.raises(ValueError): + client.create_bin("!!invalid!!") + mock_list_bin.assert_not_called() diff --git a/tests/unit/test_validation.py b/tests/unit/test_validation.py index 4e4bb4a..e8fd8ba 100644 --- a/tests/unit/test_validation.py +++ b/tests/unit/test_validation.py @@ -56,3 +56,9 @@ def test_validate_bin_id_invalid_start(): """Test validating bin IDs starting with a dot raises ValueError.""" with pytest.raises(ValueError, match="cannot start with a dot"): validate_bin_id(".invalidstart") + + +def test_validate_bin_id_empty_string(): + """Test validating an empty bin ID raises ValueError.""" + with pytest.raises(ValueError, match="cannot be empty"): + validate_bin_id("") From 06bc074b8df8b79a16e5ac0ea17f527a646c57f1 Mon Sep 17 00:00:00 2001 From: Aaron Fredrick Date: Mon, 8 Jun 2026 10:30:16 +0530 Subject: [PATCH 8/8] test: add unit tests for HttpTransport lifecycle, status handling, and response decoding --- tests/unit/test_http_transport.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/tests/unit/test_http_transport.py b/tests/unit/test_http_transport.py index bf765ee..5d2ca9c 100644 --- a/tests/unit/test_http_transport.py +++ b/tests/unit/test_http_transport.py @@ -179,16 +179,12 @@ def test_raise_for_status_405_locked_bin() -> None: def test_raise_for_status_405_expired_bin() -> None: with pytest.raises(BinNotFoundError): - HttpTransport._raise_for_status( - _parsed(405, "bin has expired"), bin_id="b", filename=None - ) + HttpTransport._raise_for_status(_parsed(405, "bin has expired"), bin_id="b", filename=None) def test_raise_for_status_405_deleted_bin() -> None: with pytest.raises(BinNotFoundError): - HttpTransport._raise_for_status( - _parsed(405, "bin deleted"), bin_id="b", filename=None - ) + HttpTransport._raise_for_status(_parsed(405, "bin deleted"), bin_id="b", filename=None) def test_raise_for_status_405_generic_fallback() -> None: