Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
name: CI

on:
push:
branches: [main]
pull_request:

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

jobs:
test:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
python-version: ["3.11", "3.12", "3.13"]
steps:
- uses: actions/checkout@v4

- name: Install uv
uses: astral-sh/setup-uv@v5
with:
enable-cache: true

- name: Set up Python ${{ matrix.python-version }}
run: uv python install ${{ matrix.python-version }}

- name: Install the project
run: uv sync --python ${{ matrix.python-version }} --extra dev

- name: Lint
run: uv run ruff check .

- name: Type-check
run: uv run mypy

- name: Test
run: uv run pytest --cov --cov-report=term-missing

build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- name: Install uv
uses: astral-sh/setup-uv@v5

- name: Build wheel and sdist
run: uv build

- name: Check the wheel ships the py.typed marker
run: |
python -m zipfile -l dist/*.whl | grep -q 'clever_cloud/py.typed' \
|| { echo "py.typed missing from the wheel"; exit 1; }

- uses: actions/upload-artifact@v4
with:
name: dist
path: dist/
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,8 @@ venv/

# uv
uv.lock

# Coverage
.coverage
coverage.xml
htmlcov/
94 changes: 94 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
# Changelog

All notable changes to this project are documented in this file.

## 0.2.0

Addresses the security, correctness and design audit tracked in
[issue #3](https://github.com/CleverCloud/clevercloud-sdk-python/issues/3).

### Security

- **OAuth requests are now fully signed.** Every request carries
`oauth_signature_method`, `oauth_timestamp`, `oauth_nonce` and `oauth_version`,
and is signed with HMAC-SHA512 over its method, URL, query and form body. The
previous header was static and could be replayed by anyone who observed it.
`SignatureMethod.PLAINTEXT` remains available as an explicitly selected
compatibility mode, and `SignatureMethod.HMAC_SHA256` is also supported.
- **Credentials no longer appear in representations.** `ApiTokenCredentials`,
`OAuthCredentials`, `OAuthConsumer` and `RequestToken` redact their secrets in
`repr()`, including when nested in a container that a logger reprs.
- **Path parameters are percent-encoded.** Identifiers such as `../self`,
`x?override=1` or `x/y` can no longer change which endpoint a request reaches.
- **Clear-text base URLs are refused.** An `http://` base URL raises unless
`allow_insecure_http=True` is passed explicitly.
- **The OAuth dance validates its callback.** `oauth_callback_confirmed` is
checked, and the verifier is only accepted when the callback carries the very
request token this dance obtained.
- Exception messages and attributes no longer copy an entire response body;
bodies are truncated to 2 KiB.

### Fixed

- Successful responses with an empty body (202, 205, and 200 on some endpoints)
no longer raise `JSONDecodeError`. Undecodable JSON now raises
`InvalidResponseError`.
- Unfollowed 3xx responses are no longer treated as successful responses.
- Missing dates are no longer replaced with the current time, and every parsed
date is normalized to a timezone-aware UTC datetime.
- Runtime versions are ordered naturally, so `resolve_instance_slug()` picks
`10` over `9`.
- The JSON `Content-Type` is no longer forced onto every request; HTTPX derives
it from the body actually sent. A GET carries no `Content-Type` at all, and a
form body is correctly labelled `application/x-www-form-urlencoded`.
- TLS and mTLS are configured through an `ssl.SSLContext` instead of the HTTPX
arguments deprecated in 0.28. Per-request cookies were removed from the OAuth
dance for the same reason.
- HTTP 403 is reported as `AuthorizationError` rather than an authentication
failure.
- Transport failures are wrapped in `TransportError`, inside the
`CleverCloudError` hierarchy.

### Added

- Idempotent requests (GET, HEAD, OPTIONS, PUT, DELETE) retry on 429, 502, 503,
504 and network errors, with exponential backoff, jitter and `Retry-After`
support, capped by `max_retry_wait`. Each attempt is re-signed with a fresh
nonce. Configure with `max_retries` (2 by default; `0` disables retries).
- The instance catalogue is cached per client, so repeated `instance_slug`
resolutions no longer re-download it. `list_instances(refresh=True)` forces a
new fetch.
- `NotFoundError` and `RateLimitError` (which exposes `retry_after`).
- `OAuthDance.parse_callback_url()` for the browser-based flow, and a
configurable `mfa_kind` on `login()`.
- `OAuthCredentials.expiration_date` and `is_expired()`, populated from the
access-token exchange.
- A `py.typed` marker, so the declared `Typing :: Typed` classifier is honoured.
- A test suite (217 tests, no network access) plus CI running lint, strict type
checking and tests on Python 3.11, 3.12 and 3.13.

### Breaking changes

- `Auth.get_authorization_header()` now takes the request method and URL, since
a signature is bound to them. Custom `Auth` subclasses must be updated.
- Response models are parsed strictly: a payload missing a required field raises
`InvalidResponseError` instead of yielding a model filled with empty strings,
zeroes or a fabricated date. Genuinely optional fields are now typed
`| None` and default to `None` rather than `""`.
- `Profile.creation_date` and `Application.creation_date` are `datetime | None`.
- `NetworkGroup.members`, `.peers` and `.tags` are tuples, and
`PeerCreated.raw` is a read-only mapping, so `frozen=True` means what it says.
- An unknown `MemberKind` is rejected instead of being coerced to `EXTERNAL`.
- `list_domains()` and `get_primary_domain()` no longer swallow HTTP 404. They
raise `NotFoundError`, because the API reports "no such application" and "no
domain" with the same status; the caller decides how to treat it.
- HTTP 403 raises `AuthorizationError`, which is *not* a subclass of
`AuthenticationError`. Code catching `AuthenticationError` for 403 must be
updated.
- Redirections raise `InvalidResponseError` instead of returning the redirect
body.
- `httpx>=0.28` is now required.

## 0.1.0

- Initial public release.
104 changes: 98 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@ from clever_cloud import CleverCloudClient, ApiTokenCredentials

async with CleverCloudClient(ApiTokenCredentials(token="...")) as client:
profile = await client.get_profile()
print(f"Hello, {profile.name}!")
# name is optional on the API side, hence the fallback
print(f"Hello, {profile.name or profile.email}!")
```

You can also use OAuth credentials:
Expand All @@ -37,9 +38,74 @@ async with CleverCloudClient(credentials) as client:
...
```

Every OAuth request is signed with HMAC-SHA512 over its method, URL, query
string and form body, with a timestamp, a nonce and the OAuth version, so an
intercepted `Authorization` header cannot be replayed. To talk to a deployment
that still requires the legacy format, select the compatibility mode explicitly:

```python
from clever_cloud import SignatureMethod

credentials = OAuthCredentials(..., signature_method=SignatureMethod.PLAINTEXT)
```

### Obtaining OAuth credentials

The browser flow is the supported way to obtain credentials:

```python
import webbrowser
from clever_cloud import OAuthConsumer, OAuthDance

with OAuthDance(OAuthConsumer(key="...", secret="..."),
callback_url="https://my-app.example/callback") as dance:
request_token = dance.get_request_token()
webbrowser.open(dance.get_authorization_url(request_token))

# ... your callback receives the redirect; pass its full URL back:
verifier = dance.parse_callback_url(callback_url, request_token)
credentials = dance.get_access_token(request_token, verifier)
```

`parse_callback_url()` checks that the callback carries the token this dance
requested before accepting the verifier. `OAuthDance.login()` remains available
for browser-less automation, but it drives the console's internal session
endpoints with the account password and is not a supported OAuth flow.

### Errors

All errors derive from `CleverCloudError`:

| Exception | Raised when |
| --- | --- |
| `AuthenticationError` | HTTP 401: credentials missing or invalid |
| `AuthorizationError` | HTTP 403: credentials valid, access denied |
| `NotFoundError` | HTTP 404 |
| `RateLimitError` | HTTP 429, exposes `retry_after` |
| `HttpError` | Any other HTTP error status |
| `TransportError` | Network, timeout or TLS failure |
| `InvalidResponseError` | Undecodable body, unexpected redirect, or a payload that does not match the endpoint's contract |
| `OAuthError` | Failure during the OAuth dance, with its `step` |

Response bodies attached to exceptions are truncated, so a large or sensitive
error payload does not end up whole in your logs.

### Retries

Idempotent requests (GET, HEAD, OPTIONS, PUT, DELETE) are retried on HTTP 429,
502, 503, 504 and on network errors, using exponential backoff with jitter and
honouring `Retry-After`. Each attempt is signed again with a fresh nonce.

```python
async with CleverCloudClient(credentials, max_retries=0) as client: # opt out
...
```

### Custom CA bundle and mTLS

The client accepts a custom CA bundle and a client certificate for mutual TLS, useful when targeting an API behind a private PKI or requiring client authentication:
The client accepts a custom CA bundle and a client certificate for mutual TLS,
useful when targeting an API behind a private PKI or requiring client
authentication:

```python
async with CleverCloudClient(
Expand All @@ -50,21 +116,36 @@ async with CleverCloudClient(
...
```

`verify_ssl=False` disables server certificate verification entirely (not recommended outside of local testing).
Both are loaded into an `ssl.SSLContext`, so no deprecated HTTPX argument is
used. `verify_ssl=False` disables server certificate verification entirely (not
recommended outside of local testing).

A clear-text `http://` base URL is refused by default, because credentials
would travel unencrypted; pass `allow_insecure_http=True` to override it against
a local development server.

### Response models

Models are parsed strictly: a response missing a field the endpoint is
documented to return raises `InvalidResponseError` rather than producing a model
filled with empty strings, zeroes or a fabricated timestamp. Optional fields are
typed `| None`, dates are timezone-aware UTC datetimes, and collections are
tuples, so `frozen=True` models are immutable all the way down.

## Available features

This SDK is still a work in progress, but it already provides the following features:
This SDK is still a work in progress, but it already provides the following
features:

- Get user profile
- List instance types
- List instance types (cached per client)
- Create application
- Redeploy application
- Create TCP redirection
- List domains
- Get primary domain
- Custom CA bundle and mTLS client certificate support
- Tolerant response handling (non-JSON bodies, relaxed `Accept` header)
- Automatic retries with backoff on transient failures
- NetworkGroups: create / get / delete / search, manage members, peers and external peers

### NetworkGroups example
Expand All @@ -84,6 +165,17 @@ await client.create_networkgroup_member(
)
```

## Development

```bash
uv sync --extra dev
uv run pytest # test suite, no network access
uv run ruff check . # lint
uv run mypy # strict type checking
```

See [CHANGELOG.md](CHANGELOG.md) for release notes, including breaking changes.

## License

Apache 2.0 - See [LICENSE](LICENSE) for details.
51 changes: 49 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "hatchling.build"

[project]
name = "clevercloud-sdk"
version = "0.1.0"
version = "0.2.0"
description = "Python SDK for Clever Cloud"
readme = "README.md"
license = "Apache-2.0"
Expand All @@ -26,7 +26,16 @@ classifiers = [
"Typing :: Typed",
]
dependencies = [
"httpx>=0.25.0",
"httpx>=0.28.0",
]

[project.optional-dependencies]
dev = [
"pytest>=8.0",
"pytest-asyncio>=0.24",
"pytest-cov>=5.0",
"mypy>=1.11",
"ruff>=0.6",
]

[project.urls]
Expand All @@ -38,7 +47,45 @@ Issues = "https://github.com/CleverCloud/clevercloud-sdk-python/issues"
[tool.hatch.build.targets.sdist]
include = [
"/src",
"/tests",
]

[tool.hatch.build.targets.wheel]
packages = ["src/clever_cloud"]

[tool.pytest.ini_options]
testpaths = ["tests"]
asyncio_mode = "auto"
addopts = "-q --strict-markers"

[tool.coverage.run]
source = ["clever_cloud"]

[tool.ruff]
line-length = 100
target-version = "py311"
src = ["src", "tests"]

[tool.ruff.lint]
select = [
"E", "F", "W", # pycodestyle / pyflakes
"I", # isort
"B", # bugbear
"UP", # pyupgrade
"S", # bandit
"RUF",
]
ignore = [
"S101", # assert is expected in tests
"UP042", # str+Enum is kept over StrEnum: str() of a member must not change
]

[tool.ruff.lint.per-file-ignores]
"tests/*" = ["S105", "S106"] # hardcoded fake credentials in fixtures

[tool.mypy]
python_version = "3.11"
strict = true
files = ["src", "tests"]
warn_unused_ignores = true
disallow_any_explicit = false
Loading
Loading