Skip to content
147 changes: 140 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,12 @@ To install from pip:
pip install pydo
```

For async support, install with the `aio` extra:

```shell
pip install pydo[aio]
```

## **`pydo` Quickstart**

> A quick guide to getting started with the client.
Expand All @@ -36,6 +42,22 @@ from pydo import Client
client = Client(token=os.getenv("DIGITALOCEAN_TOKEN"))
```

For asynchronous operations, use the `AsyncClient`:

```python
import os
import asyncio
from pydo import AsyncClient

async def main():
client = AsyncClient(token=os.getenv("DIGITALOCEAN_TOKEN"))
# Use await for async operations
result = await client.ssh_keys.list()
print(result)

asyncio.run(main())
```

#### Example of Using `pydo` to Access DO Resources

Find below a working example for GETting a ssh_key ([per this http request](https://docs.digitalocean.com/reference/api/api-reference/#operation/sshKeys_list)) and printing the ID associated with the ssh key. If you'd like to try out this quick example, you can follow [these instructions](https://docs.digitalocean.com/products/droplets/how-to/add-ssh-keys/) to add ssh keys to your DO account.
Expand All @@ -62,10 +84,67 @@ ID: 123457, NAME: my_prod_ssh_key, FINGERPRINT: eb:76:c7:2a:d3:3e:80:5d:ef:2e:ca

**Note**: More working examples can be found [here](https://github.com/digitalocean/pydo/tree/main/examples).

#### Type Hints and Models

PyDo includes comprehensive type hints for better IDE support and type checking:

```python
from pydo import Client
from pydo.types import Droplet, SSHKey, DropletsResponse

client = Client(token=os.getenv("DIGITALOCEAN_TOKEN"))

# Type hints help with autocomplete and validation
droplets: DropletsResponse = client.droplets.list()
for droplet in droplets["droplets"]:
# droplet is properly typed as Droplet
print(f"ID: {droplet['id']}, Name: {droplet['name']}")

# Use specific types for better type safety
def process_droplet(droplet: Droplet) -> None:
print(f"Processing {droplet['name']} in {droplet['region']['slug']}")

# Available types: Droplet, SSHKey, Region, Size, Image, Volume, etc.
# Response types: DropletsResponse, SSHKeysResponse, etc.
```

#### Custom Exceptions for Better Error Handling

PyDo includes custom exceptions for better error handling and debugging:

```python
from pydo import Client
from pydo.exceptions import AuthenticationError, ResourceNotFoundError, RateLimitError

client = Client(token=os.getenv("DIGITALOCEAN_TOKEN"))

try:
# This will raise AuthenticationError if token is invalid
droplets = client.droplets.list()
except AuthenticationError as e:
print(f"Authentication failed: {e.message}")
except RateLimitError as e:
print(f"Rate limit exceeded: {e.message}")
except ResourceNotFoundError as e:
print(f"Resource not found: {e.message}")
except Exception as e:
print(f"Other error: {e}")

# Available exceptions:
# - AuthenticationError (401)
# - PermissionDeniedError (403)
# - ResourceNotFoundError (404)
# - ValidationError (400)
# - ConflictError (409)
# - RateLimitError (429)
# - ServerError (5xx)
# - ServiceUnavailableError (503)
```

#### Pagination Example

Below is an example on handling pagination. One must parse the URL to find the
next page.
##### Manual Pagination (Traditional Approach)
Below is an example of handling pagination manually by parsing URLs:

```python
import os
Expand All @@ -91,21 +170,75 @@ while paginated:
paginated = False
```

##### Automatic Pagination (New Helper Method)
The client now includes a `paginate()` helper method that automatically handles pagination:

```python
import os
from pydo import Client

client = Client(token=os.getenv("DIGITALOCEAN_TOKEN"))

# Automatically paginate through all SSH keys
for key in client.paginate(client.ssh_keys.list, per_page=50):
print(f"ID: {key['id']}, NAME: {key['name']}, FINGERPRINT: {key['fingerprint']}")

# Works with any paginated endpoint
for droplet in client.paginate(client.droplets.list):
print(f"Droplet: {droplet['name']} - {droplet['status']}")
```

#### Retries and Backoff

By default the client uses the same retry policy as the [Azure SDK for Python](https://learn.microsoft.com/en-us/python/api/azure-core/azure.core.pipeline.policies.retrypolicy?view=azure-python).
retry policy. If you'd like to modify any of these values, you can pass them as keywords to your client initialization:
The client includes intelligent retry logic to handle transient network issues and server errors. By default, it retries on HTTP status codes 429 (rate limit), 500, 502, 503, and 504.

##### Basic Retry Configuration

```python
client = Client(token=os.getenv("DIGITALOCEAN_TOKEN"), retry_total=3)
# Use default retry settings (3 retries, 0.5s backoff factor)
client = Client(token=os.getenv("DIGITALOCEAN_TOKEN"))

# Customize retry attempts
client = Client(token=os.getenv("DIGITALOCEAN_TOKEN"), retry_total=5)

# Customize backoff timing
client = Client(token=os.getenv("DIGITALOCEAN_TOKEN"), retry_backoff_factor=1.0)

# Customize which status codes to retry on
client = Client(
token=os.getenv("DIGITALOCEAN_TOKEN"),
retry_status_codes=[429, 500, 502, 503, 504, 408] # Add timeout retries
)
```

or
##### Advanced Retry Configuration

For full control over retry behavior, you can provide a custom retry policy:

```python
client = Client(token=os.getenv("DIGITALOCEAN_TOKEN"), retry_policy=MyRetryPolicy())
from azure.core.pipeline.policies import RetryPolicy

custom_retry_policy = RetryPolicy(
retry_total=3,
retry_backoff_factor=0.8,
retry_on_status_codes=[429, 500, 502, 503, 504],
retry_on_exceptions=[ConnectionError, TimeoutError]
)

client = Client(
token=os.getenv("DIGITALOCEAN_TOKEN"),
retry_policy=custom_retry_policy
)
```

##### Retry Behavior

- **Exponential Backoff**: Delays increase exponentially (0.5s, 1.0s, 2.0s, etc.)
- **Jitter**: Random variation prevents thundering herd problems
- **Smart Status Codes**: Only retries on recoverable errors
- **Timeout Handling**: Automatic retry on network timeouts
- **Rate Limit Respect**: Built-in handling of 429 responses

# **Contributing**

>Visit our [Contribuing Guide](CONTRIBUTING.md) for more information on getting
Expand Down
156 changes: 152 additions & 4 deletions src/pydo/_patch.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,17 @@

Follow our quickstart for examples: https://aka.ms/azsdk/python/dpcodegen/python/customize
"""
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, Generator, Any, Dict, Callable, Optional, Union

from azure.core.credentials import AccessToken
from azure.core.exceptions import HttpResponseError
from azure.core.pipeline.policies import RetryPolicy

from pydo.custom_policies import CustomHttpLoggingPolicy
from pydo import GeneratedClient, _version
from pydo.aio import AsyncClient
from pydo import types
from pydo import exceptions

if TYPE_CHECKING:
# pylint: disable=unused-import,ungrouped-imports
Expand All @@ -36,9 +41,42 @@ class Client(GeneratedClient): # type: ignore
:type token: str
:keyword endpoint: Service URL. Default value is "https://api.digitalocean.com".
:paramtype endpoint: str
:keyword retry_total: Total number of retries for failed requests. Default is 3.
:paramtype retry_total: int
:keyword retry_backoff_factor: Backoff factor for retry delays. Default is 0.5.
:paramtype retry_backoff_factor: float
:keyword retry_status_codes: HTTP status codes to retry on. Default is [429, 500, 502, 503, 504].
:paramtype retry_status_codes: list[int]
:keyword timeout: Request timeout in seconds. Default is 120.
:paramtype timeout: int
"""

def __init__(self, token: str, *, timeout: int = 120, **kwargs):
def __init__(
self,
token: str,
*,
retry_total: int = 3,
retry_backoff_factor: float = 0.5,
retry_status_codes: Optional[list[int]] = None,
timeout: int = 120,
**kwargs
):
# Set default retry status codes if not provided
if retry_status_codes is None:
retry_status_codes = [429, 500, 502, 503, 504]

# Create custom retry policy with user-specified parameters
retry_policy = RetryPolicy(
retry_total=retry_total,
retry_backoff_factor=retry_backoff_factor,
retry_on_status_codes=retry_status_codes,
)

# Add retry policy to kwargs if not already specified
if 'retry_policy' not in kwargs:
kwargs['retry_policy'] = retry_policy

# Handle logging policy
logger = kwargs.get("logger")
if logger is not None and kwargs.get("http_logging_policy") == "":
kwargs["http_logging_policy"] = CustomHttpLoggingPolicy(logger=logger)
Expand All @@ -48,8 +86,118 @@ def __init__(self, token: str, *, timeout: int = 120, **kwargs):
TokenCredentials(token), timeout=timeout, sdk_moniker=sdk_moniker, **kwargs
)


__all__ = ["Client"]
def paginate(self, method: Callable[..., Dict[str, Any]], *args, **kwargs) -> Generator[Dict[str, Any], None, None]:
"""Automatically paginate through all results from a method that returns paginated data.

:param method: The method to call (e.g., self.droplets.list)
:param args: Positional arguments to pass to the method
:param kwargs: Keyword arguments to pass to the method
:return: Generator yielding all items from all pages
:rtype: Generator[Dict[str, Any], None, None]
"""
page = 1
per_page = kwargs.get('per_page', 20) # Default per_page if not specified

while True:
# Set the current page
kwargs['page'] = page
kwargs['per_page'] = per_page

# Call the method
result = method(*args, **kwargs)

# Yield items from this page
items_key = None
if hasattr(result, 'keys') and callable(getattr(result, 'keys')):
# Find the key that contains the list of items
for key in result.keys():
if key.endswith('s') and isinstance(result[key], list): # e.g., 'droplets', 'ssh_keys'
items_key = key
break

if items_key and items_key in result:
yield from result[items_key]
else:
# If we can't find the items key, yield the whole result once
yield result
break

# Check if there's a next page
links = result.get('links', {})
pages = links.get('pages', {})
if 'next' not in pages:
break

page += 1

@staticmethod
def _handle_http_error(error: HttpResponseError) -> exceptions.DigitalOceanError:
"""Convert HTTP errors to appropriate DigitalOcean custom exceptions.

:param error: The HttpResponseError from azure
:return: Appropriate DigitalOcean exception
:rtype: exceptions.DigitalOceanError
"""
status_code = getattr(error, 'status', None) or getattr(error.response, 'status_code', None)

if status_code == 401:
return exceptions.AuthenticationError(
"Authentication failed. Please check your API token.",
status_code=status_code,
response=error.response
)
elif status_code == 403:
return exceptions.PermissionDeniedError(
"Access forbidden. You don't have permission to perform this action.",
status_code=status_code,
response=error.response
)
elif status_code == 404:
return exceptions.ResourceNotFoundError(
"Resource not found. The requested resource does not exist.",
status_code=status_code,
response=error.response
)
elif status_code == 400:
return exceptions.ValidationError(
"Bad request. Please check your request parameters.",
status_code=status_code,
response=error.response
)
elif status_code == 409:
return exceptions.ConflictError(
"Conflict. The resource is in a state that conflicts with the request.",
status_code=status_code,
response=error.response
)
elif status_code == 429:
return exceptions.RateLimitError(
"Rate limit exceeded. Please wait before making more requests.",
status_code=status_code,
response=error.response
)
elif status_code and status_code >= 500:
return exceptions.ServerError(
"Server error. Please try again later.",
status_code=status_code,
response=error.response
)
elif status_code == 503:
return exceptions.ServiceUnavailableError(
"Service temporarily unavailable. Please try again later.",
status_code=status_code,
response=error.response
)
else:
# Fallback to generic DigitalOcean error
return exceptions.DigitalOceanError(
f"API request failed: {str(error)}",
status_code=status_code,
response=error.response
)


__all__ = ["Client", "AsyncClient", "types", "exceptions"]


def patch_sdk():
Expand Down
4 changes: 4 additions & 0 deletions src/pydo/aio/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,12 @@
_patch_all = []
from ._patch import patch_sdk as _patch_sdk

# Alias Client as AsyncClient for easier access
AsyncClient = Client

__all__ = [
"GeneratedClient",
"AsyncClient",
]
__all__.extend([p for p in _patch_all if p not in __all__])

Expand Down
Loading