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..81eb59a 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 @@ -73,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: @@ -98,12 +121,13 @@ 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] + needs: [build, lint, static, security, codeql, test] if: github.event_name == 'push' && !startsWith(github.ref, 'refs/tags/') runs-on: ubuntu-latest 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 diff --git a/LICENSE b/LICENSE index 8fd0f34..508b785 100644 --- a/LICENSE +++ b/LICENSE @@ -1,21 +1,25 @@ -MIT License +Copyright (c) 2024, Aaron Fredrick and Filebin Python Client Contributors +Copyright (c) 2015-2020, Espen Braastad (Original Filebin.net engine) +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..5ee0292 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 = await 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,13 @@ 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 = await client.create_bin(args.bin) + output.print_success(f"Created/Validated bin ID: {bin_model.id}") + if len(bin_model.files) > 0: + output.print_bin(bin_model) + 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..17d6904 100644 --- a/filebin/client/async_client.py +++ b/filebin/client/async_client.py @@ -6,7 +6,9 @@ from typing import Literal from filebin.core.config import ClientConfig +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 @@ -34,6 +36,41 @@ async def close(self) -> None: """Close the underlying HTTP transport session.""" await self._transport.close() + 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. + """ + if bin_id is not None: + validate_bin_id(bin_id) + else: + bin_id = generate_bin_id() + + 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, + created_at=None, + updated_at=None, + expired_at=None, + files=[], + ) + 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..675fdb2 100644 --- a/filebin/client/sync_client.py +++ b/filebin/client/sync_client.py @@ -39,6 +39,45 @@ 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 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. + """ + # 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: + validate_bin_id(bin_id) + else: + bin_id = generate_bin_id() + + try: + return self.list_bin(bin_id) + except BinNotFoundError: + return BinModel( + id=bin_id, + readonly=False, + bytes=0, + created_at=None, + updated_at=None, + expired_at=None, + files=[], + ) + 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..ac384d6 100644 --- a/filebin/core/errors.py +++ b/filebin/core/errors.py @@ -55,12 +55,32 @@ 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 +108,17 @@ 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_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_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..5d2ca9c 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,62 @@ 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_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 new file mode 100644 index 0000000..e8fd8ba --- /dev/null +++ b/tests/unit/test_validation.py @@ -0,0 +1,64 @@ +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") + + +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("")