diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..a74227f --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,78 @@ +name: CI + +on: + push: + branches: [ main, develop ] + pull_request: + branches: [ main, develop ] + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ['3.8', '3.9', '3.10', '3.11', '3.12'] + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -e . + pip install -r requirements-dev.txt + + - name: Run tests + run: | + python -m pytest tests/ -v --tb=short + + - name: Check package can be imported + run: | + python -c "from bundleup import BundleUp; print('✓ Package imports successfully')" + + lint: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Check Python syntax + run: | + python -m py_compile bundleup/*.py bundleup/unify/*.py tests/*.py + + build: + runs-on: ubuntu-latest + needs: [test, lint] + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Install build dependencies + run: | + python -m pip install --upgrade pip + pip install build + + - name: Build package + run: | + python -m build + + - name: Upload artifacts + uses: actions/upload-artifact@v4 + with: + name: dist + path: dist/ diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b6a7023 --- /dev/null +++ b/.gitignore @@ -0,0 +1,134 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +pip-wheel-metadata/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +.python-version + +# pipenv +Pipfile.lock + +# PEP 582 +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# IDEs +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db diff --git a/README.md b/README.md new file mode 100644 index 0000000..7fa7916 --- /dev/null +++ b/README.md @@ -0,0 +1,221 @@ +# BundleUp Python SDK + +Official Python SDK for the BundleUp API. + +## Installation + +```bash +pip install bundleup-sdk +``` + +## Features + +- **Pythonic API Design** - Context managers, property decorators, and Python best practices +- **Custom Exception Hierarchy** - Specific exception types for different error scenarios +- **Connection Pooling** - Efficient HTTP connection reuse with `requests.Session` +- **Type Hints** - Full type annotation support for better IDE integration +- **Comprehensive Testing** - 70+ unit tests with mocked HTTP requests + +## Usage + +### Initialize the Client + +The SDK supports both regular initialization and context manager usage: + +```python +from bundleup import BundleUp + +# Regular usage +client = BundleUp("your-api-key") + +# Context manager (automatically closes connections) +with BundleUp("your-api-key") as client: + connections = client.connections.list() +``` + +### Working with Connections + +```python +# List all connections +connections = client.connections.list() + +# Create a new connection +connection = client.connections.create({ + "name": "My Connection", + "integration_id": "integration-id" +}) + +# Retrieve a specific connection +connection = client.connections.retrieve("connection-id") + +# Update a connection +updated = client.connections.update("connection-id", { + "name": "Updated Name" +}) + +# Delete a connection +client.connections.delete("connection-id") +``` + +### Working with Integrations + +```python +# List all integrations +integrations = client.integrations.list() + +# Retrieve a specific integration +integration = client.integrations.retrieve("integration-id") +``` + +### Working with Webhooks + +```python +# List all webhooks +webhooks = client.webhooks.list() + +# Create a new webhook +webhook = client.webhooks.create({ + "url": "https://example.com/webhook", + "events": ["connection.created"] +}) + +# Update a webhook +updated = client.webhooks.update("webhook-id", { + "url": "https://example.com/new-webhook" +}) + +# Delete a webhook +client.webhooks.delete("webhook-id") +``` + +### Using the Proxy API + +```python +# Create a proxy instance +proxy = client.proxy("connection-id") + +# Make requests to the connected service +response = proxy.get("/users") +response = proxy.post("/users", {"name": "John"}) +response = proxy.put("/users/123", {"name": "Jane"}) +response = proxy.patch("/users/123", {"email": "jane@example.com"}) +response = proxy.delete("/users/123") +``` + +### Using the Unify API + +The Unify API provides a standardized interface across different integrations. + +#### Chat + +```python +# Get unified chat channels +channels = client.unify("connection-id").chat.channels() + +# With pagination parameters +channels = client.unify("connection-id").chat.channels({ + "limit": 50, + "include_raw": True +}) +``` + +#### Git + +```python +unify = client.unify("connection-id") + +# Get repositories +repos = unify.git.repos() + +# Get pull requests +pulls = unify.git.pulls() + +# Get tags +tags = unify.git.tags() + +# Get releases +releases = unify.git.releases() +``` + +#### Project Management + +```python +# Get issues +issues = client.unify("connection-id").pm.issues() + +# With pagination +issues = client.unify("connection-id").pm.issues({ + "limit": 100, + "after": "cursor-id" +}) +``` + +## Error Handling + +The SDK uses a hierarchy of custom exceptions: + +```python +from bundleup import BundleUp +from bundleup.exceptions import ( + BundleUpError, # Base exception + ValidationError, # Input validation errors + APIError, # General API errors + AuthenticationError, # 401 errors + NotFoundError, # 404 errors + RateLimitError # 429 errors +) + +try: + client = BundleUp("your-api-key") + connections = client.connections.list() +except AuthenticationError: + print("Invalid API key") +except NotFoundError: + print("Resource not found") +except RateLimitError: + print("Rate limit exceeded") +except APIError as e: + print(f"API error: {e.status_code} - {e}") +except ValidationError as e: + print(f"Validation error: {e}") +``` + +## Advanced Usage + +### Custom Session + +You can provide your own `requests.Session` for advanced configuration: + +```python +import requests +from bundleup import BundleUp + +session = requests.Session() +session.timeout = 30 +session.verify = True + +client = BundleUp("your-api-key", session=session) +``` + +### Query Parameters + +The `list()` method supports query parameters: + +```python +# List with filters +connections = client.connections.list(status="active", limit=50) +``` + +## Requirements + +- Python 3.8+ +- requests +- typing-extensions + +## License + +MIT License - see LICENSE file for details. + +## Documentation + +For more information, visit [https://docs.bundleup.io](https://docs.bundleup.io) diff --git a/bundleup/__init__.py b/bundleup/__init__.py new file mode 100644 index 0000000..0f07697 --- /dev/null +++ b/bundleup/__init__.py @@ -0,0 +1,167 @@ +"""BundleUp Python SDK. + +Official Python SDK for the BundleUp API. + +Example: + >>> from bundleup import BundleUp + >>> with BundleUp("your-api-key") as client: + ... connections = client.connections.list() +""" + +from typing import Optional +import requests + +from .base import Base +from .connection import Connection, Connections +from .integration import Integration, Integrations +from .webhooks import Webhook, Webhooks +from .proxy import Proxy +from .unify import Unify +from .utils import validate_non_empty_string +from .exceptions import BundleUpError, ValidationError, APIError + +__version__ = "0.1.0" + + +class BundleUp: + """Main BundleUp SDK client with context manager support.""" + + def __init__(self, api_key: str, session: Optional[requests.Session] = None): + """ + Initialize the BundleUp client. + + Args: + api_key: Your BundleUp API key + session: Optional requests session for connection pooling + + Raises: + ValidationError: If api_key is not a valid non-empty string + + Example: + >>> client = BundleUp("your-api-key") + >>> # or use as context manager + >>> with BundleUp("your-api-key") as client: + ... connections = client.connections.list() + """ + validate_non_empty_string(api_key, "api_key") + self._api_key = api_key + self._session = session or requests.Session() + self._owns_session = session is None + + # Initialize resource instances with shared session + self._connections = Connections(api_key, self._session) + self._integrations = Integrations(api_key, self._session) + self._webhooks = Webhooks(api_key, self._session) + + def __enter__(self): + """Enter context manager.""" + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + """Exit context manager and cleanup resources.""" + self.close() + return False + + def close(self): + """Close the underlying session if owned by this client.""" + if self._owns_session and self._session: + self._session.close() + + @property + def connections(self) -> Connections: + """ + Access the Connections resource. + + Returns: + Connections resource instance + + Example: + >>> connections = client.connections.list() + """ + return self._connections + + @property + def integrations(self) -> Integrations: + """ + Access the Integrations resource. + + Returns: + Integrations resource instance + + Example: + >>> integrations = client.integrations.list() + """ + return self._integrations + + @property + def webhooks(self) -> Webhooks: + """ + Access the Webhooks resource. + + Returns: + Webhooks resource instance + + Example: + >>> webhooks = client.webhooks.list() + """ + return self._webhooks + + def proxy(self, connection_id: str) -> Proxy: + """ + Create a Proxy client for direct API calls to a connected service. + + Args: + connection_id: The connection ID to proxy requests through + + Returns: + Proxy instance for the specified connection + + Raises: + ValidationError: If connection_id is not a valid non-empty string + + Example: + >>> proxy = client.proxy("connection-id") + >>> data = proxy.get("/users") + """ + return Proxy(self._api_key, connection_id, self._session) + + def unify(self, connection_id: str) -> Unify: + """ + Create a Unify client for standardized API calls. + + Args: + connection_id: The connection ID to use for unify requests + + Returns: + Unify instance for the specified connection + + Raises: + ValidationError: If connection_id is not a valid non-empty string + + Example: + >>> unify = client.unify("connection-id") + >>> channels = unify.chat.channels() + >>> repos = unify.git.repos() + """ + return Unify(self._api_key, connection_id, self._session) + + def __repr__(self) -> str: + """Return a string representation of the client.""" + return f"BundleUp(version='{__version__}')" + + +__all__ = [ + "BundleUp", + "Base", + "Connection", + "Connections", + "Integration", + "Integrations", + "Webhook", + "Webhooks", + "Proxy", + "Unify", + "BundleUpError", + "ValidationError", + "APIError", +] diff --git a/bundleup/base.py b/bundleup/base.py new file mode 100644 index 0000000..76540e1 --- /dev/null +++ b/bundleup/base.py @@ -0,0 +1,221 @@ +"""Base class for BundleUp API resources.""" + +from abc import ABC, abstractmethod +from typing import Any, Dict, Generic, List, TypeVar +import requests + +from .utils import validate_non_empty_string, validate_dict +from .exceptions import APIError, AuthenticationError, NotFoundError, RateLimitError + + +T = TypeVar('T', bound=Dict[str, Any]) + + +class Base(ABC, Generic[T]): + """Base class for API resources with CRUD operations.""" + + base_url: str = "https://api.bundleup.io" + version: str = "v1" + + def __init__(self, api_key: str, session: requests.Session = None): + """ + Initialize the base resource. + + Args: + api_key: The BundleUp API key + session: Optional requests session for connection pooling + + Raises: + ValidationError: If api_key is not a valid non-empty string + """ + validate_non_empty_string(api_key, "api_key") + self._api_key = api_key + self._session = session or requests.Session() + + @property + @abstractmethod + def _namespace(self) -> str: + """ + Get the API namespace for this resource. + + Returns: + The namespace string (e.g., 'connections', 'integrations') + """ + pass + + @property + def _headers(self) -> Dict[str, str]: + """ + Get the headers for API requests. + + Returns: + Dictionary of HTTP headers + """ + return { + "Authorization": f"Bearer {self._api_key}", + "Content-Type": "application/json", + } + + def _build_url(self, path: str = "") -> str: + """ + Build the full API URL. + + Args: + path: Optional path to append (e.g., resource ID) + + Returns: + The complete API URL + """ + url = f"{self.base_url}/{self.version}/{self._namespace}" + if path: + url = f"{url}/{path}" + return url + + def _handle_response(self, response: requests.Response) -> Any: + """ + Handle API response and raise appropriate exceptions. + + Args: + response: The response object from requests + + Returns: + Parsed JSON response + + Raises: + AuthenticationError: For 401 status codes + NotFoundError: For 404 status codes + RateLimitError: For 429 status codes + APIError: For other error status codes + """ + try: + response.raise_for_status() + return response.json() if response.text else None + except requests.exceptions.HTTPError as e: + status_code = response.status_code + try: + error_body = response.text + except: + error_body = None + + if status_code == 401: + raise AuthenticationError( + f"Authentication failed for {self._namespace}", + status_code=status_code, + response_body=error_body + ) + elif status_code == 404: + raise NotFoundError( + f"Resource not found in {self._namespace}", + status_code=status_code, + response_body=error_body + ) + elif status_code == 429: + raise RateLimitError( + f"Rate limit exceeded for {self._namespace}", + status_code=status_code, + response_body=error_body + ) + else: + raise APIError( + f"API request failed for {self._namespace}: {str(e)}", + status_code=status_code, + response_body=error_body + ) + except requests.exceptions.RequestException as e: + raise APIError(f"Request failed for {self._namespace}: {str(e)}") + + def list(self, **params) -> List[T]: + """ + List all resources. + + Args: + **params: Optional query parameters + + Returns: + List of resources + + Raises: + APIError: If the API request fails + """ + url = self._build_url() + response = self._session.get(url, headers=self._headers, params=params) + return self._handle_response(response) + + def create(self, data: T) -> T: + """ + Create a new resource. + + Args: + data: The resource data + + Returns: + The created resource + + Raises: + ValidationError: If data is not a dictionary + APIError: If the API request fails + """ + validate_dict(data, "data") + url = self._build_url() + response = self._session.post(url, json=data, headers=self._headers) + return self._handle_response(response) + + def retrieve(self, id: str) -> T: + """ + Retrieve a specific resource by ID. + + Args: + id: The resource ID + + Returns: + The resource + + Raises: + ValidationError: If id is not a valid non-empty string + APIError: If the API request fails + """ + validate_non_empty_string(id, "id") + url = self._build_url(id) + response = self._session.get(url, headers=self._headers) + return self._handle_response(response) + + def update(self, id: str, data: T) -> T: + """ + Update a resource. + + Args: + id: The resource ID + data: The updated resource data + + Returns: + The updated resource + + Raises: + ValidationError: If id or data are invalid + APIError: If the API request fails + """ + validate_non_empty_string(id, "id") + validate_dict(data, "data") + url = self._build_url(id) + response = self._session.patch(url, json=data, headers=self._headers) + return self._handle_response(response) + + def delete(self, id: str) -> None: + """ + Delete a resource. + + Args: + id: The resource ID + + Raises: + ValidationError: If id is not a valid non-empty string + APIError: If the API request fails + """ + validate_non_empty_string(id, "id") + url = self._build_url(id) + response = self._session.delete(url, headers=self._headers) + self._handle_response(response) + + def __repr__(self) -> str: + """Return a string representation of the resource.""" + return f"{self.__class__.__name__}(namespace='{self._namespace}')" diff --git a/bundleup/connection.py b/bundleup/connection.py new file mode 100644 index 0000000..578a960 --- /dev/null +++ b/bundleup/connection.py @@ -0,0 +1,23 @@ +"""BundleUp Connections resource.""" + +from typing import Any, Dict, TypedDict +from .base import Base + + +class Connection(TypedDict, total=False): + """Connection resource type.""" + id: str + name: str + integration_id: str + status: str + created_at: str + updated_at: str + + +class Connections(Base[Connection]): + """Connections resource class for managing connection resources.""" + + @property + def _namespace(self) -> str: + """Get the API namespace for connections.""" + return "connections" diff --git a/bundleup/exceptions.py b/bundleup/exceptions.py new file mode 100644 index 0000000..5985a7f --- /dev/null +++ b/bundleup/exceptions.py @@ -0,0 +1,57 @@ +"""Custom exceptions for the BundleUp SDK.""" + + +class BundleUpError(Exception): + """Base exception for all BundleUp SDK errors.""" + + pass + + +class ValidationError(BundleUpError): + """Raised when input validation fails.""" + + pass + + +class APIError(BundleUpError): + """Raised when an API request fails.""" + + def __init__(self, message: str, status_code: int = None, response_body: str = None): + """ + Initialize the API error. + + Args: + message: Error message + status_code: HTTP status code if available + response_body: Response body if available + """ + super().__init__(message) + self.status_code = status_code + self.response_body = response_body + + def __str__(self) -> str: + """Return a formatted error message.""" + parts = [super().__str__()] + if self.status_code: + parts.append(f"Status code: {self.status_code}") + if self.response_body: + parts.append(f"Response: {self.response_body}") + return " | ".join(parts) + + +class AuthenticationError(APIError): + """Raised when authentication fails.""" + + pass + + +class NotFoundError(APIError): + """Raised when a resource is not found.""" + + pass + + +class RateLimitError(APIError): + """Raised when rate limit is exceeded.""" + + pass diff --git a/bundleup/integration.py b/bundleup/integration.py new file mode 100644 index 0000000..32ca26d --- /dev/null +++ b/bundleup/integration.py @@ -0,0 +1,26 @@ +"""BundleUp Integrations resource.""" + +from typing import Any, Dict, TypedDict +from .base import Base + + +class Integration(TypedDict, total=False): + """Integration resource type.""" + id: str + name: str + slug: str + category: str + logo_url: str + description: str + auth_type: str + created_at: str + updated_at: str + + +class Integrations(Base[Integration]): + """Integrations resource class for managing integration resources.""" + + @property + def _namespace(self) -> str: + """Get the API namespace for integrations.""" + return "integrations" diff --git a/bundleup/proxy.py b/bundleup/proxy.py new file mode 100644 index 0000000..b2804d3 --- /dev/null +++ b/bundleup/proxy.py @@ -0,0 +1,207 @@ +"""BundleUp Proxy API client.""" + +from typing import Any, Dict, Optional +import requests + +from .utils import validate_non_empty_string +from .exceptions import APIError + + +class Proxy: + """Proxy class for making direct API calls to connected services.""" + + base_url: str = "https://proxy.bundleup.io" + + def __init__(self, api_key: str, connection_id: str, session: Optional[requests.Session] = None): + """ + Initialize the Proxy client. + + Args: + api_key: The BundleUp API key + connection_id: The connection ID to proxy requests through + session: Optional requests session for connection pooling + + Raises: + ValidationError: If api_key or connection_id are invalid + """ + validate_non_empty_string(api_key, "api_key") + validate_non_empty_string(connection_id, "connection_id") + self._api_key = api_key + self._connection_id = connection_id + self._session = session or requests.Session() + + def _get_headers(self, additional_headers: Optional[Dict[str, str]] = None) -> Dict[str, str]: + """ + Get headers for proxy requests. + + Args: + additional_headers: Optional additional headers to include + + Returns: + Dictionary of HTTP headers + """ + headers = { + "Authorization": f"Bearer {self._api_key}", + "BU-Connection-Id": self._connection_id, + "Content-Type": "application/json", + } + if additional_headers: + headers.update(additional_headers) + return headers + + def _build_url(self, path: str) -> str: + """ + Build the full proxy URL. + + Args: + path: The API path + + Returns: + The complete proxy URL + """ + # Ensure path starts with / + if not path.startswith("/"): + path = f"/{path}" + return f"{self.base_url}{path}" + + def _handle_response(self, response: requests.Response) -> Any: + """ + Handle proxy response and raise appropriate exceptions. + + Args: + response: The response object from requests + + Returns: + Parsed JSON response or None + + Raises: + APIError: If the request fails + """ + try: + response.raise_for_status() + return response.json() if response.text else None + except requests.exceptions.RequestException as e: + try: + error_body = response.text + except: + error_body = None + raise APIError( + f"Proxy request failed: {str(e)}", + status_code=response.status_code if hasattr(response, 'status_code') else None, + response_body=error_body + ) + + def get(self, path: str, headers: Optional[Dict[str, str]] = None, **kwargs) -> Any: + """ + Make a GET request through the proxy. + + Args: + path: The API path + headers: Optional additional headers + **kwargs: Additional arguments passed to requests.get + + Returns: + The response data + + Raises: + ValidationError: If path is invalid + APIError: If the request fails + """ + validate_non_empty_string(path, "path") + url = self._build_url(path) + response = self._session.get(url, headers=self._get_headers(headers), **kwargs) + return self._handle_response(response) + + def post(self, path: str, data: Optional[Dict[str, Any]] = None, + headers: Optional[Dict[str, str]] = None, **kwargs) -> Any: + """ + Make a POST request through the proxy. + + Args: + path: The API path + data: Optional request body data + headers: Optional additional headers + **kwargs: Additional arguments passed to requests.post + + Returns: + The response data + + Raises: + ValidationError: If path is invalid + APIError: If the request fails + """ + validate_non_empty_string(path, "path") + url = self._build_url(path) + response = self._session.post(url, json=data, headers=self._get_headers(headers), **kwargs) + return self._handle_response(response) + + def put(self, path: str, data: Optional[Dict[str, Any]] = None, + headers: Optional[Dict[str, str]] = None, **kwargs) -> Any: + """ + Make a PUT request through the proxy. + + Args: + path: The API path + data: Optional request body data + headers: Optional additional headers + **kwargs: Additional arguments passed to requests.put + + Returns: + The response data + + Raises: + ValidationError: If path is invalid + APIError: If the request fails + """ + validate_non_empty_string(path, "path") + url = self._build_url(path) + response = self._session.put(url, json=data, headers=self._get_headers(headers), **kwargs) + return self._handle_response(response) + + def patch(self, path: str, data: Optional[Dict[str, Any]] = None, + headers: Optional[Dict[str, str]] = None, **kwargs) -> Any: + """ + Make a PATCH request through the proxy. + + Args: + path: The API path + data: Optional request body data + headers: Optional additional headers + **kwargs: Additional arguments passed to requests.patch + + Returns: + The response data + + Raises: + ValidationError: If path is invalid + APIError: If the request fails + """ + validate_non_empty_string(path, "path") + url = self._build_url(path) + response = self._session.patch(url, json=data, headers=self._get_headers(headers), **kwargs) + return self._handle_response(response) + + def delete(self, path: str, headers: Optional[Dict[str, str]] = None, **kwargs) -> Any: + """ + Make a DELETE request through the proxy. + + Args: + path: The API path + headers: Optional additional headers + **kwargs: Additional arguments passed to requests.delete + + Returns: + The response data or None + + Raises: + ValidationError: If path is invalid + APIError: If the request fails + """ + validate_non_empty_string(path, "path") + url = self._build_url(path) + response = self._session.delete(url, headers=self._get_headers(headers), **kwargs) + return self._handle_response(response) + + def __repr__(self) -> str: + """Return a string representation of the proxy.""" + return f"Proxy(connection_id='{self._connection_id}')" diff --git a/bundleup/unify/__init__.py b/bundleup/unify/__init__.py new file mode 100644 index 0000000..9589394 --- /dev/null +++ b/bundleup/unify/__init__.py @@ -0,0 +1,63 @@ +"""BundleUp Unify API module.""" + +from typing import Optional +import requests + +from .base import UnifyBase, Params, Response, Metadata +from .chat import Chat +from .git import Git +from .pm import PM +from ..utils import validate_non_empty_string + + +class Unify: + """Unify API client with access to all unify resources.""" + + def __init__(self, api_key: str, connection_id: str, session: Optional[requests.Session] = None): + """ + Initialize the Unify client. + + Args: + api_key: The BundleUp API key + connection_id: The connection ID + session: Optional requests session for connection pooling + + Raises: + ValidationError: If api_key or connection_id are invalid + """ + validate_non_empty_string(api_key, "api_key") + validate_non_empty_string(connection_id, "connection_id") + self._api_key = api_key + self._connection_id = connection_id + self._session = session or requests.Session() + + @property + def chat(self) -> Chat: + """Get the Chat unify resource.""" + return Chat(self._api_key, self._connection_id, self._session) + + @property + def git(self) -> Git: + """Get the Git unify resource.""" + return Git(self._api_key, self._connection_id, self._session) + + @property + def pm(self) -> PM: + """Get the PM (Project Management) unify resource.""" + return PM(self._api_key, self._connection_id, self._session) + + def __repr__(self) -> str: + """Return a string representation of the Unify client.""" + return f"Unify(connection_id='{self._connection_id}')" + + +__all__ = [ + "Unify", + "UnifyBase", + "Params", + "Response", + "Metadata", + "Chat", + "Git", + "PM", +] diff --git a/bundleup/unify/base.py b/bundleup/unify/base.py new file mode 100644 index 0000000..7e3ef91 --- /dev/null +++ b/bundleup/unify/base.py @@ -0,0 +1,124 @@ +"""Base class for BundleUp Unify API.""" + +from typing import Any, Dict, List, Optional, TypedDict +import requests + +from ..utils import validate_non_empty_string +from ..exceptions import APIError + + +class Params(TypedDict, total=False): + """Query parameters for Unify API requests.""" + limit: int + after: str + include_raw: bool + + +class Metadata(TypedDict, total=False): + """Metadata for paginated responses.""" + has_more: bool + next_cursor: Optional[str] + + +class Response(TypedDict): + """Unify API response structure.""" + data: List[Dict[str, Any]] + _raw: Optional[List[Dict[str, Any]]] + metadata: Metadata + + +class UnifyBase: + """Base class for Unify API resources.""" + + base_url: str = "https://unify.bundleup.io" + + def __init__(self, api_key: str, connection_id: str, session: Optional[requests.Session] = None): + """ + Initialize the Unify base client. + + Args: + api_key: The BundleUp API key + connection_id: The connection ID + session: Optional requests session for connection pooling + + Raises: + ValidationError: If api_key or connection_id are invalid + """ + validate_non_empty_string(api_key, "api_key") + validate_non_empty_string(connection_id, "connection_id") + self._api_key = api_key + self._connection_id = connection_id + self._session = session or requests.Session() + + @property + def _headers(self) -> Dict[str, str]: + """ + Get headers for Unify API requests. + + Returns: + Dictionary of HTTP headers + """ + return { + "Authorization": f"Bearer {self._api_key}", + "BU-Connection-Id": self._connection_id, + "Content-Type": "application/json", + } + + def _build_url(self, path: str) -> str: + """ + Build the full Unify API URL. + + Args: + path: The API path + + Returns: + The complete Unify API URL + """ + # Ensure path starts with / + if not path.startswith("/"): + path = f"/{path}" + return f"{self.base_url}{path}" + + def _request(self, path: str, params: Optional[Params] = None) -> Response: + """ + Make a request to the Unify API. + + Args: + path: The API path + params: Optional query parameters + + Returns: + The Unify API response + + Raises: + APIError: If the request fails + """ + url = self._build_url(path) + query_params = {} + + if params: + if "limit" in params: + query_params["limit"] = params["limit"] + if "after" in params: + query_params["after"] = params["after"] + if "include_raw" in params: + query_params["include_raw"] = str(params["include_raw"]).lower() + + try: + response = self._session.get(url, headers=self._headers, params=query_params) + response.raise_for_status() + return response.json() + except requests.exceptions.RequestException as e: + try: + error_body = response.text if hasattr(response, 'text') else None + except: + error_body = None + raise APIError( + f"Unify API request failed: {str(e)}", + status_code=response.status_code if hasattr(response, 'status_code') else None, + response_body=error_body + ) + + def __repr__(self) -> str: + """Return a string representation of the Unify base.""" + return f"{self.__class__.__name__}(connection_id='{self._connection_id}')" diff --git a/bundleup/unify/chat.py b/bundleup/unify/chat.py new file mode 100644 index 0000000..6621266 --- /dev/null +++ b/bundleup/unify/chat.py @@ -0,0 +1,23 @@ +"""BundleUp Unify Chat API.""" + +from typing import Optional +from .base import UnifyBase, Params, Response + + +class Chat(UnifyBase): + """Chat unify methods for standardized chat operations.""" + + def channels(self, params: Optional[Params] = None) -> Response: + """ + Get unified chat channels. + + Args: + params: Optional query parameters (limit, after, include_raw) + + Returns: + Unified response with chat channels + + Raises: + RuntimeError: If the request fails + """ + return self._request("/chat/channels", params) diff --git a/bundleup/unify/git.py b/bundleup/unify/git.py new file mode 100644 index 0000000..a24c6c5 --- /dev/null +++ b/bundleup/unify/git.py @@ -0,0 +1,68 @@ +"""BundleUp Unify Git API.""" + +from typing import Optional +from .base import UnifyBase, Params, Response + + +class Git(UnifyBase): + """Git unify methods for standardized git operations.""" + + def repos(self, params: Optional[Params] = None) -> Response: + """ + Get unified git repositories. + + Args: + params: Optional query parameters (limit, after, include_raw) + + Returns: + Unified response with repositories + + Raises: + RuntimeError: If the request fails + """ + return self._request("/git/repos", params) + + def pulls(self, params: Optional[Params] = None) -> Response: + """ + Get unified pull requests. + + Args: + params: Optional query parameters (limit, after, include_raw) + + Returns: + Unified response with pull requests + + Raises: + RuntimeError: If the request fails + """ + return self._request("/git/pulls", params) + + def tags(self, params: Optional[Params] = None) -> Response: + """ + Get unified git tags. + + Args: + params: Optional query parameters (limit, after, include_raw) + + Returns: + Unified response with tags + + Raises: + RuntimeError: If the request fails + """ + return self._request("/git/tags", params) + + def releases(self, params: Optional[Params] = None) -> Response: + """ + Get unified releases. + + Args: + params: Optional query parameters (limit, after, include_raw) + + Returns: + Unified response with releases + + Raises: + RuntimeError: If the request fails + """ + return self._request("/git/releases", params) diff --git a/bundleup/unify/pm.py b/bundleup/unify/pm.py new file mode 100644 index 0000000..2ba2b23 --- /dev/null +++ b/bundleup/unify/pm.py @@ -0,0 +1,23 @@ +"""BundleUp Unify Project Management API.""" + +from typing import Optional +from .base import UnifyBase, Params, Response + + +class PM(UnifyBase): + """Project Management unify methods for standardized PM operations.""" + + def issues(self, params: Optional[Params] = None) -> Response: + """ + Get unified issues. + + Args: + params: Optional query parameters (limit, after, include_raw) + + Returns: + Unified response with issues + + Raises: + RuntimeError: If the request fails + """ + return self._request("/pm/issues", params) diff --git a/bundleup/utils.py b/bundleup/utils.py new file mode 100644 index 0000000..82d260e --- /dev/null +++ b/bundleup/utils.py @@ -0,0 +1,52 @@ +"""BundleUp Python SDK utilities.""" + +from typing import Any, Dict +from .exceptions import ValidationError + + +def validate_non_empty_string(value: Any, param_name: str) -> None: + """ + Validate that a value is a non-empty string. + + Args: + value: The value to validate + param_name: The parameter name for error messages + + Raises: + ValidationError: If the value is not a non-empty string + """ + if not isinstance(value, str): + raise ValidationError(f"{param_name} must be a string") + if not value.strip(): + raise ValidationError(f"{param_name} cannot be empty") + + +def validate_dict(value: Any, param_name: str) -> None: + """ + Validate that a value is a dictionary. + + Args: + value: The value to validate + param_name: The parameter name for error messages + + Raises: + ValidationError: If the value is not a dictionary + """ + if not isinstance(value, dict): + raise ValidationError(f"{param_name} must be a dictionary") + + +def merge_headers(*headers: Dict[str, str]) -> Dict[str, str]: + """ + Merge multiple header dictionaries. + + Args: + *headers: Variable number of header dictionaries + + Returns: + Merged dictionary of headers + """ + result: Dict[str, str] = {} + for header_dict in headers: + result.update(header_dict) + return result diff --git a/bundleup/webhooks.py b/bundleup/webhooks.py new file mode 100644 index 0000000..ffdff18 --- /dev/null +++ b/bundleup/webhooks.py @@ -0,0 +1,24 @@ +"""BundleUp Webhooks resource.""" + +from typing import Any, Dict, List, TypedDict +from .base import Base + + +class Webhook(TypedDict, total=False): + """Webhook resource type.""" + id: str + url: str + events: List[str] + secret: str + active: bool + created_at: str + updated_at: str + + +class Webhooks(Base[Webhook]): + """Webhooks resource class for managing webhook resources.""" + + @property + def _namespace(self) -> str: + """Get the API namespace for webhooks.""" + return "webhooks" diff --git a/example.py b/example.py new file mode 100644 index 0000000..e5189b4 --- /dev/null +++ b/example.py @@ -0,0 +1,208 @@ +""" +Example usage of the BundleUp Python SDK. + +This script demonstrates how to use the various features of the BundleUp SDK. +Note: This is for demonstration purposes only. You'll need a valid API key +and connection ID to make actual API calls. +""" + +from bundleup import BundleUp + +# Initialize the client with your API key +client = BundleUp("your-api-key-here") + +# ============================================================================ +# Working with Connections +# ============================================================================ + +# List all connections +try: + connections = client.connections.list() + print(f"Found {len(connections)} connections") +except Exception as e: + print(f"Error listing connections: {e}") + +# Create a new connection +try: + new_connection = client.connections.create({ + "name": "My Connection", + "integration_id": "integration-id" + }) + print(f"Created connection: {new_connection.get('id')}") +except Exception as e: + print(f"Error creating connection: {e}") + +# Retrieve a specific connection +try: + connection = client.connections.retrieve("connection-id") + print(f"Retrieved connection: {connection.get('name')}") +except Exception as e: + print(f"Error retrieving connection: {e}") + +# Update a connection +try: + updated_connection = client.connections.update("connection-id", { + "name": "Updated Connection Name" + }) + print(f"Updated connection: {updated_connection.get('name')}") +except Exception as e: + print(f"Error updating connection: {e}") + +# Delete a connection +try: + client.connections.delete("connection-id") + print("Connection deleted successfully") +except Exception as e: + print(f"Error deleting connection: {e}") + +# ============================================================================ +# Working with Integrations +# ============================================================================ + +# List all integrations +try: + integrations = client.integrations.list() + print(f"Found {len(integrations)} integrations") +except Exception as e: + print(f"Error listing integrations: {e}") + +# Retrieve a specific integration +try: + integration = client.integrations.retrieve("integration-id") + print(f"Retrieved integration: {integration.get('name')}") +except Exception as e: + print(f"Error retrieving integration: {e}") + +# ============================================================================ +# Working with Webhooks +# ============================================================================ + +# List all webhooks +try: + webhooks = client.webhooks.list() + print(f"Found {len(webhooks)} webhooks") +except Exception as e: + print(f"Error listing webhooks: {e}") + +# Create a webhook +try: + webhook = client.webhooks.create({ + "url": "https://example.com/webhook", + "events": ["connection.created", "connection.updated"] + }) + print(f"Created webhook: {webhook.get('id')}") +except Exception as e: + print(f"Error creating webhook: {e}") + +# ============================================================================ +# Using the Proxy API +# ============================================================================ + +# Create a proxy for a specific connection +proxy = client.proxy("connection-id") + +# Make a GET request +try: + users = proxy.get("/users") + print(f"Retrieved users via proxy") +except Exception as e: + print(f"Error making proxy GET request: {e}") + +# Make a POST request +try: + new_user = proxy.post("/users", { + "name": "John Doe", + "email": "john@example.com" + }) + print(f"Created user via proxy: {new_user.get('id')}") +except Exception as e: + print(f"Error making proxy POST request: {e}") + +# Make a PUT request +try: + updated_user = proxy.put("/users/123", { + "name": "Jane Doe" + }) + print(f"Updated user via proxy") +except Exception as e: + print(f"Error making proxy PUT request: {e}") + +# Make a PATCH request +try: + patched_user = proxy.patch("/users/123", { + "email": "jane@example.com" + }) + print(f"Patched user via proxy") +except Exception as e: + print(f"Error making proxy PATCH request: {e}") + +# Make a DELETE request +try: + proxy.delete("/users/123") + print(f"Deleted user via proxy") +except Exception as e: + print(f"Error making proxy DELETE request: {e}") + +# ============================================================================ +# Using the Unify API +# ============================================================================ + +# Create a unify client for a specific connection +unify = client.unify("connection-id") + +# Get unified chat channels +try: + channels_response = unify.chat.channels({ + "limit": 50, + "include_raw": True + }) + channels = channels_response["data"] + print(f"Found {len(channels)} chat channels") + + if channels_response.get("metadata", {}).get("has_more"): + print("More channels available") +except Exception as e: + print(f"Error getting chat channels: {e}") + +# Get unified git repositories +try: + repos_response = unify.git.repos({"limit": 25}) + repos = repos_response["data"] + print(f"Found {len(repos)} repositories") +except Exception as e: + print(f"Error getting repositories: {e}") + +# Get unified pull requests +try: + pulls_response = unify.git.pulls() + pulls = pulls_response["data"] + print(f"Found {len(pulls)} pull requests") +except Exception as e: + print(f"Error getting pull requests: {e}") + +# Get unified git tags +try: + tags_response = unify.git.tags() + tags = tags_response["data"] + print(f"Found {len(tags)} tags") +except Exception as e: + print(f"Error getting tags: {e}") + +# Get unified releases +try: + releases_response = unify.git.releases() + releases = releases_response["data"] + print(f"Found {len(releases)} releases") +except Exception as e: + print(f"Error getting releases: {e}") + +# Get unified project management issues +try: + issues_response = unify.pm.issues({ + "limit": 100, + "after": "cursor-id" + }) + issues = issues_response["data"] + print(f"Found {len(issues)} issues") +except Exception as e: + print(f"Error getting issues: {e}") diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..5cc766a --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,44 @@ +[build-system] +requires = ["setuptools>=61.0", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "bundleup-sdk" +version = "0.1.0" +description = "Python SDK for BundleUp" +readme = "README.md" +authors = [ + {name = "BundleUp", email = "support@bundleup.io"} +] +license = {text = "MIT"} +requires-python = ">=3.8" +classifiers = [ + "Development Status :: 3 - Alpha", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", +] +dependencies = [ + "requests>=2.25.0", + "typing-extensions>=4.0.0", +] + +[project.urls] +Homepage = "https://bundleup.io" +Documentation = "https://docs.bundleup.io" +Repository = "https://github.com/bundleup/bundleup-sdk-python" + +[tool.setuptools.packages.find] +where = ["."] +include = ["bundleup*"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +python_files = ["test_*.py"] +python_classes = ["Test*"] +python_functions = ["test_*"] diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..ffa6d88 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,3 @@ +pytest>=7.0.0 +pytest-mock>=3.10.0 +responses>=0.22.0 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..82913b8 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +requests>=2.25.0 +typing-extensions>=4.0.0 diff --git a/tests/test_base.py b/tests/test_base.py new file mode 100644 index 0000000..854132c --- /dev/null +++ b/tests/test_base.py @@ -0,0 +1,195 @@ +"""Tests for the Base class.""" + +import pytest +import responses +from bundleup.base import Base +from bundleup.exceptions import ValidationError, APIError, AuthenticationError, NotFoundError + + +class TestResource(Base): + """Test resource implementation of Base.""" + + @property + def _namespace(self): + return "test-resources" + + +def test_base_init_with_valid_api_key(): + """Test Base initialization with valid API key.""" + resource = TestResource("test-api-key") + assert resource._api_key == "test-api-key" + + +def test_base_init_with_empty_api_key(): + """Test Base initialization with empty API key raises ValidationError.""" + with pytest.raises(ValidationError, match="api_key cannot be empty"): + TestResource("") + + +def test_base_headers(): + """Test _headers property returns correct headers.""" + resource = TestResource("test-api-key") + headers = resource._headers + assert headers["Authorization"] == "Bearer test-api-key" + assert headers["Content-Type"] == "application/json" + + +def test_base_build_url_without_path(): + """Test _build_url without path.""" + resource = TestResource("test-api-key") + url = resource._build_url() + assert url == "https://api.bundleup.io/v1/test-resources" + + +def test_base_build_url_with_path(): + """Test _build_url with path.""" + resource = TestResource("test-api-key") + url = resource._build_url("123") + assert url == "https://api.bundleup.io/v1/test-resources/123" + + +@responses.activate +def test_base_list(): + """Test list method.""" + responses.add( + responses.GET, + "https://api.bundleup.io/v1/test-resources", + json=[{"id": "1"}, {"id": "2"}], + status=200 + ) + + resource = TestResource("test-api-key") + result = resource.list() + assert len(result) == 2 + assert result[0]["id"] == "1" + + +@responses.activate +def test_base_create(): + """Test create method.""" + responses.add( + responses.POST, + "https://api.bundleup.io/v1/test-resources", + json={"id": "1", "name": "test"}, + status=201 + ) + + resource = TestResource("test-api-key") + result = resource.create({"name": "test"}) + assert result["id"] == "1" + assert result["name"] == "test" + + +def test_base_create_with_invalid_data(): + """Test create method with invalid data.""" + resource = TestResource("test-api-key") + with pytest.raises(ValidationError, match="data must be a dictionary"): + resource.create("not-a-dict") + + +@responses.activate +def test_base_retrieve(): + """Test retrieve method.""" + responses.add( + responses.GET, + "https://api.bundleup.io/v1/test-resources/123", + json={"id": "123", "name": "test"}, + status=200 + ) + + resource = TestResource("test-api-key") + result = resource.retrieve("123") + assert result["id"] == "123" + + +def test_base_retrieve_with_empty_id(): + """Test retrieve method with empty ID.""" + resource = TestResource("test-api-key") + with pytest.raises(ValidationError, match="id cannot be empty"): + resource.retrieve("") + + +@responses.activate +def test_base_update(): + """Test update method.""" + responses.add( + responses.PATCH, + "https://api.bundleup.io/v1/test-resources/123", + json={"id": "123", "name": "updated"}, + status=200 + ) + + resource = TestResource("test-api-key") + result = resource.update("123", {"name": "updated"}) + assert result["name"] == "updated" + + +def test_base_update_with_empty_id(): + """Test update method with empty ID.""" + resource = TestResource("test-api-key") + with pytest.raises(ValidationError, match="id cannot be empty"): + resource.update("", {"name": "test"}) + + +def test_base_update_with_invalid_data(): + """Test update method with invalid data.""" + resource = TestResource("test-api-key") + with pytest.raises(ValidationError, match="data must be a dictionary"): + resource.update("123", "not-a-dict") + + +@responses.activate +def test_base_delete(): + """Test delete method.""" + responses.add( + responses.DELETE, + "https://api.bundleup.io/v1/test-resources/123", + status=204 + ) + + resource = TestResource("test-api-key") + resource.delete("123") # Should not raise + + +def test_base_delete_with_empty_id(): + """Test delete method with empty ID.""" + resource = TestResource("test-api-key") + with pytest.raises(ValidationError, match="id cannot be empty"): + resource.delete("") + + +@responses.activate +def test_base_authentication_error(): + """Test authentication error handling.""" + responses.add( + responses.GET, + "https://api.bundleup.io/v1/test-resources", + json={"error": "Unauthorized"}, + status=401 + ) + + resource = TestResource("test-api-key") + with pytest.raises(AuthenticationError): + resource.list() + + +@responses.activate +def test_base_not_found_error(): + """Test not found error handling.""" + responses.add( + responses.GET, + "https://api.bundleup.io/v1/test-resources/999", + json={"error": "Not found"}, + status=404 + ) + + resource = TestResource("test-api-key") + with pytest.raises(NotFoundError): + resource.retrieve("999") + + +def test_base_repr(): + """Test __repr__ method.""" + resource = TestResource("test-api-key") + assert "TestResource" in repr(resource) + assert "test-resources" in repr(resource) diff --git a/tests/test_bundleup.py b/tests/test_bundleup.py new file mode 100644 index 0000000..89a9adc --- /dev/null +++ b/tests/test_bundleup.py @@ -0,0 +1,89 @@ +"""Tests for the main BundleUp client.""" + +import pytest +from bundleup import BundleUp, ValidationError +from bundleup.connection import Connections +from bundleup.integration import Integrations +from bundleup.webhooks import Webhooks +from bundleup.proxy import Proxy +from bundleup.unify import Unify + + +def test_bundleup_init_with_valid_api_key(): + """Test BundleUp initialization with valid API key.""" + client = BundleUp("test-api-key") + assert client._api_key == "test-api-key" + + +def test_bundleup_init_with_empty_api_key(): + """Test BundleUp initialization with empty API key raises ValidationError.""" + with pytest.raises(ValidationError, match="api_key cannot be empty"): + BundleUp("") + + +def test_bundleup_init_with_none_api_key(): + """Test BundleUp initialization with None API key raises ValidationError.""" + with pytest.raises(ValidationError, match="api_key must be a string"): + BundleUp(None) + + +def test_bundleup_connections_property(): + """Test connections property returns Connections instance.""" + client = BundleUp("test-api-key") + assert isinstance(client.connections, Connections) + + +def test_bundleup_integrations_property(): + """Test integrations property returns Integrations instance.""" + client = BundleUp("test-api-key") + assert isinstance(client.integrations, Integrations) + + +def test_bundleup_webhooks_property(): + """Test webhooks property returns Webhooks instance.""" + client = BundleUp("test-api-key") + assert isinstance(client.webhooks, Webhooks) + + +def test_bundleup_proxy_method(): + """Test proxy method returns Proxy instance.""" + client = BundleUp("test-api-key") + proxy = client.proxy("connection-id") + assert isinstance(proxy, Proxy) + assert proxy._connection_id == "connection-id" + + +def test_bundleup_proxy_method_with_empty_connection_id(): + """Test proxy method with empty connection_id raises ValidationError.""" + client = BundleUp("test-api-key") + with pytest.raises(ValidationError, match="connection_id cannot be empty"): + client.proxy("") + + +def test_bundleup_unify_method(): + """Test unify method returns Unify instance.""" + client = BundleUp("test-api-key") + unify = client.unify("connection-id") + assert isinstance(unify, Unify) + assert unify._connection_id == "connection-id" + + +def test_bundleup_unify_method_with_empty_connection_id(): + """Test unify method with empty connection_id raises ValidationError.""" + client = BundleUp("test-api-key") + with pytest.raises(ValidationError, match="connection_id cannot be empty"): + client.unify("") + + +def test_bundleup_context_manager(): + """Test BundleUp can be used as context manager.""" + with BundleUp("test-api-key") as client: + assert client._api_key == "test-api-key" + assert client._session is not None + + +def test_bundleup_repr(): + """Test BundleUp __repr__ method.""" + client = BundleUp("test-api-key") + assert "BundleUp" in repr(client) + assert "0.1.0" in repr(client) diff --git a/tests/test_proxy.py b/tests/test_proxy.py new file mode 100644 index 0000000..f55e765 --- /dev/null +++ b/tests/test_proxy.py @@ -0,0 +1,158 @@ +"""Tests for the Proxy class.""" + +import pytest +import responses +from bundleup.proxy import Proxy +from bundleup.exceptions import ValidationError, APIError + + +def test_proxy_init_with_valid_parameters(): + """Test Proxy initialization with valid parameters.""" + proxy = Proxy("test-api-key", "connection-id") + assert proxy._api_key == "test-api-key" + assert proxy._connection_id == "connection-id" + + +def test_proxy_init_with_empty_api_key(): + """Test Proxy initialization with empty API key raises ValidationError.""" + with pytest.raises(ValidationError, match="api_key cannot be empty"): + Proxy("", "connection-id") + + +def test_proxy_init_with_empty_connection_id(): + """Test Proxy initialization with empty connection_id raises ValidationError.""" + with pytest.raises(ValidationError, match="connection_id cannot be empty"): + Proxy("test-api-key", "") + + +def test_proxy_get_headers(): + """Test _get_headers method.""" + proxy = Proxy("test-api-key", "connection-id") + headers = proxy._get_headers() + assert headers["Authorization"] == "Bearer test-api-key" + assert headers["BU-Connection-Id"] == "connection-id" + assert headers["Content-Type"] == "application/json" + + +def test_proxy_get_headers_with_additional_headers(): + """Test _get_headers with additional headers.""" + proxy = Proxy("test-api-key", "connection-id") + headers = proxy._get_headers({"X-Custom": "value"}) + assert headers["X-Custom"] == "value" + + +def test_proxy_build_url(): + """Test _build_url method.""" + proxy = Proxy("test-api-key", "connection-id") + url = proxy._build_url("/users") + assert url == "https://proxy.bundleup.io/users" + + +def test_proxy_build_url_without_leading_slash(): + """Test _build_url adds leading slash if missing.""" + proxy = Proxy("test-api-key", "connection-id") + url = proxy._build_url("users") + assert url == "https://proxy.bundleup.io/users" + + +@responses.activate +def test_proxy_get(): + """Test GET method.""" + responses.add( + responses.GET, + "https://proxy.bundleup.io/users", + json={"users": []}, + status=200 + ) + + proxy = Proxy("test-api-key", "connection-id") + result = proxy.get("/users") + assert "users" in result + + +def test_proxy_get_with_empty_path(): + """Test GET with empty path raises ValidationError.""" + proxy = Proxy("test-api-key", "connection-id") + with pytest.raises(ValidationError, match="path cannot be empty"): + proxy.get("") + + +@responses.activate +def test_proxy_post(): + """Test POST method.""" + responses.add( + responses.POST, + "https://proxy.bundleup.io/users", + json={"id": "123"}, + status=201 + ) + + proxy = Proxy("test-api-key", "connection-id") + result = proxy.post("/users", {"name": "John"}) + assert result["id"] == "123" + + +@responses.activate +def test_proxy_put(): + """Test PUT method.""" + responses.add( + responses.PUT, + "https://proxy.bundleup.io/users/123", + json={"id": "123", "name": "Jane"}, + status=200 + ) + + proxy = Proxy("test-api-key", "connection-id") + result = proxy.put("/users/123", {"name": "Jane"}) + assert result["name"] == "Jane" + + +@responses.activate +def test_proxy_patch(): + """Test PATCH method.""" + responses.add( + responses.PATCH, + "https://proxy.bundleup.io/users/123", + json={"id": "123", "email": "jane@example.com"}, + status=200 + ) + + proxy = Proxy("test-api-key", "connection-id") + result = proxy.patch("/users/123", {"email": "jane@example.com"}) + assert result["email"] == "jane@example.com" + + +@responses.activate +def test_proxy_delete(): + """Test DELETE method.""" + responses.add( + responses.DELETE, + "https://proxy.bundleup.io/users/123", + status=204 + ) + + proxy = Proxy("test-api-key", "connection-id") + result = proxy.delete("/users/123") + assert result is None + + +@responses.activate +def test_proxy_delete_with_json_response(): + """Test DELETE method with JSON response.""" + responses.add( + responses.DELETE, + "https://proxy.bundleup.io/users/123", + json={"success": True}, + status=200 + ) + + proxy = Proxy("test-api-key", "connection-id") + result = proxy.delete("/users/123") + assert result["success"] is True + + +def test_proxy_repr(): + """Test __repr__ method.""" + proxy = Proxy("test-api-key", "connection-id") + assert "Proxy" in repr(proxy) + assert "connection-id" in repr(proxy) diff --git a/tests/test_resources.py b/tests/test_resources.py new file mode 100644 index 0000000..c4fc54e --- /dev/null +++ b/tests/test_resources.py @@ -0,0 +1,24 @@ +"""Tests for resource classes.""" + +import pytest +from bundleup.connection import Connections +from bundleup.integration import Integrations +from bundleup.webhooks import Webhooks + + +def test_connections_namespace(): + """Test Connections namespace.""" + conn = Connections("test-api-key") + assert conn._namespace == "connections" + + +def test_integrations_namespace(): + """Test Integrations namespace.""" + integ = Integrations("test-api-key") + assert integ._namespace == "integrations" + + +def test_webhooks_namespace(): + """Test Webhooks namespace.""" + hooks = Webhooks("test-api-key") + assert hooks._namespace == "webhooks" diff --git a/tests/test_unify.py b/tests/test_unify.py new file mode 100644 index 0000000..4847ec2 --- /dev/null +++ b/tests/test_unify.py @@ -0,0 +1,169 @@ +"""Tests for the Unify API.""" + +import pytest +import responses +from bundleup.unify import Unify +from bundleup.unify.chat import Chat +from bundleup.unify.git import Git +from bundleup.unify.pm import PM + + +def test_unify_init(): + """Test Unify initialization.""" + unify = Unify("test-api-key", "connection-id") + assert unify._api_key == "test-api-key" + assert unify._connection_id == "connection-id" + + +def test_unify_chat_property(): + """Test chat property returns Chat instance.""" + unify = Unify("test-api-key", "connection-id") + assert isinstance(unify.chat, Chat) + + +def test_unify_git_property(): + """Test git property returns Git instance.""" + unify = Unify("test-api-key", "connection-id") + assert isinstance(unify.git, Git) + + +def test_unify_pm_property(): + """Test pm property returns PM instance.""" + unify = Unify("test-api-key", "connection-id") + assert isinstance(unify.pm, PM) + + +@responses.activate +def test_chat_channels(): + """Test Chat.channels method.""" + responses.add( + responses.GET, + "https://unify.bundleup.io/chat/channels", + json={ + "data": [{"id": "1", "name": "general"}], + "_raw": None, + "metadata": {"has_more": False, "next_cursor": None} + }, + status=200 + ) + + chat = Chat("test-api-key", "connection-id") + result = chat.channels() + assert len(result["data"]) == 1 + assert result["data"][0]["name"] == "general" + + +@responses.activate +def test_chat_channels_with_params(): + """Test Chat.channels with parameters.""" + responses.add( + responses.GET, + "https://unify.bundleup.io/chat/channels", + json={ + "data": [{"id": "1", "name": "general"}], + "_raw": [{"original": "data"}], + "metadata": {"has_more": True, "next_cursor": "cursor123"} + }, + status=200 + ) + + chat = Chat("test-api-key", "connection-id") + result = chat.channels({"limit": 10, "include_raw": True}) + assert result["metadata"]["has_more"] is True + + +@responses.activate +def test_git_repos(): + """Test Git.repos method.""" + responses.add( + responses.GET, + "https://unify.bundleup.io/git/repos", + json={ + "data": [{"id": "1", "name": "my-repo"}], + "_raw": None, + "metadata": {"has_more": False, "next_cursor": None} + }, + status=200 + ) + + git = Git("test-api-key", "connection-id") + result = git.repos() + assert len(result["data"]) == 1 + assert result["data"][0]["name"] == "my-repo" + + +@responses.activate +def test_git_pulls(): + """Test Git.pulls method.""" + responses.add( + responses.GET, + "https://unify.bundleup.io/git/pulls", + json={ + "data": [{"id": "1", "title": "PR title"}], + "_raw": None, + "metadata": {"has_more": False, "next_cursor": None} + }, + status=200 + ) + + git = Git("test-api-key", "connection-id") + result = git.pulls() + assert len(result["data"]) == 1 + + +@responses.activate +def test_git_tags(): + """Test Git.tags method.""" + responses.add( + responses.GET, + "https://unify.bundleup.io/git/tags", + json={ + "data": [{"id": "1", "name": "v1.0.0"}], + "_raw": None, + "metadata": {"has_more": False, "next_cursor": None} + }, + status=200 + ) + + git = Git("test-api-key", "connection-id") + result = git.tags() + assert len(result["data"]) == 1 + + +@responses.activate +def test_git_releases(): + """Test Git.releases method.""" + responses.add( + responses.GET, + "https://unify.bundleup.io/git/releases", + json={ + "data": [{"id": "1", "tag": "v1.0.0"}], + "_raw": None, + "metadata": {"has_more": False, "next_cursor": None} + }, + status=200 + ) + + git = Git("test-api-key", "connection-id") + result = git.releases() + assert len(result["data"]) == 1 + + +@responses.activate +def test_pm_issues(): + """Test PM.issues method.""" + responses.add( + responses.GET, + "https://unify.bundleup.io/pm/issues", + json={ + "data": [{"id": "1", "title": "Bug fix"}], + "_raw": None, + "metadata": {"has_more": False, "next_cursor": None} + }, + status=200 + ) + + pm = PM("test-api-key", "connection-id") + result = pm.issues() + assert len(result["data"]) == 1 + assert result["data"][0]["title"] == "Bug fix" diff --git a/tests/test_utils.py b/tests/test_utils.py new file mode 100644 index 0000000..ac8ae1c --- /dev/null +++ b/tests/test_utils.py @@ -0,0 +1,75 @@ +"""Tests for utility functions.""" + +import pytest +from bundleup.utils import validate_non_empty_string, validate_dict, merge_headers +from bundleup.exceptions import ValidationError + + +def test_validate_non_empty_string_with_valid_string(): + """Test validate_non_empty_string with valid string.""" + validate_non_empty_string("test", "param") # Should not raise + + +def test_validate_non_empty_string_with_empty_string(): + """Test validate_non_empty_string with empty string.""" + with pytest.raises(ValidationError, match="param cannot be empty"): + validate_non_empty_string("", "param") + + +def test_validate_non_empty_string_with_whitespace_only(): + """Test validate_non_empty_string with whitespace only.""" + with pytest.raises(ValidationError, match="param cannot be empty"): + validate_non_empty_string(" ", "param") + + +def test_validate_non_empty_string_with_non_string(): + """Test validate_non_empty_string with non-string.""" + with pytest.raises(ValidationError, match="param must be a string"): + validate_non_empty_string(123, "param") + + +def test_validate_dict_with_valid_dict(): + """Test validate_dict with valid dictionary.""" + validate_dict({"key": "value"}, "param") # Should not raise + + +def test_validate_dict_with_empty_dict(): + """Test validate_dict with empty dictionary.""" + validate_dict({}, "param") # Should not raise + + +def test_validate_dict_with_non_dict(): + """Test validate_dict with non-dictionary.""" + with pytest.raises(ValidationError, match="param must be a dictionary"): + validate_dict("not a dict", "param") + + +def test_merge_headers_with_single_dict(): + """Test merge_headers with single dictionary.""" + result = merge_headers({"key": "value"}) + assert result == {"key": "value"} + + +def test_merge_headers_with_multiple_dicts(): + """Test merge_headers with multiple dictionaries.""" + result = merge_headers( + {"key1": "value1"}, + {"key2": "value2"}, + {"key3": "value3"} + ) + assert result == {"key1": "value1", "key2": "value2", "key3": "value3"} + + +def test_merge_headers_with_overlapping_keys(): + """Test merge_headers with overlapping keys (later values win).""" + result = merge_headers( + {"key": "value1"}, + {"key": "value2"} + ) + assert result == {"key": "value2"} + + +def test_merge_headers_with_empty(): + """Test merge_headers with no arguments.""" + result = merge_headers() + assert result == {}