From cba39d44e62c58a61307ebe5a4597c5ac5acfb53 Mon Sep 17 00:00:00 2001 From: Robin Genz Date: Mon, 1 Jun 2026 15:24:48 +0200 Subject: [PATCH 1/5] feat: add Capawesome Cloud Python SDK --- .github/workflows/ci.yml | 84 +++++ .gitignore | 26 ++ LICENSE | 21 ++ README.md | 293 +++++++++++++++++- examples/manage_live_updates.py | 35 +++ examples/trigger_native_build.py | 44 +++ pyproject.toml | 86 +++++ src/capawesome_cloud/__init__.py | 76 +++++ src/capawesome_cloud/_http.py | 159 ++++++++++ src/capawesome_cloud/_types.py | 23 ++ src/capawesome_cloud/_version.py | 1 + src/capawesome_cloud/client.py | 78 +++++ src/capawesome_cloud/exceptions.py | 138 +++++++++ src/capawesome_cloud/models.py | 190 ++++++++++++ src/capawesome_cloud/pagination.py | 71 +++++ src/capawesome_cloud/py.typed | 0 src/capawesome_cloud/resources/__init__.py | 36 +++ src/capawesome_cloud/resources/_base.py | 100 ++++++ src/capawesome_cloud/resources/apps.py | 135 ++++++++ src/capawesome_cloud/resources/automations.py | 109 +++++++ .../resources/build_sources.py | 29 ++ src/capawesome_cloud/resources/builds.py | 195 ++++++++++++ .../resources/certificates.py | 153 +++++++++ src/capawesome_cloud/resources/channels.py | 104 +++++++ src/capawesome_cloud/resources/deployments.py | 112 +++++++ .../resources/destinations.py | 117 +++++++ src/capawesome_cloud/resources/devices.py | 68 ++++ .../resources/environments.py | 153 +++++++++ src/capawesome_cloud/resources/jobs.py | 91 ++++++ src/capawesome_cloud/resources/webhooks.py | 95 ++++++ tests/conftest.py | 17 + tests/test_client.py | 31 ++ tests/test_errors_and_retries.py | 57 ++++ tests/test_pagination.py | 51 +++ tests/test_resources.py | 92 ++++++ 35 files changed, 3068 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 examples/manage_live_updates.py create mode 100644 examples/trigger_native_build.py create mode 100644 pyproject.toml create mode 100644 src/capawesome_cloud/__init__.py create mode 100644 src/capawesome_cloud/_http.py create mode 100644 src/capawesome_cloud/_types.py create mode 100644 src/capawesome_cloud/_version.py create mode 100644 src/capawesome_cloud/client.py create mode 100644 src/capawesome_cloud/exceptions.py create mode 100644 src/capawesome_cloud/models.py create mode 100644 src/capawesome_cloud/pagination.py create mode 100644 src/capawesome_cloud/py.typed create mode 100644 src/capawesome_cloud/resources/__init__.py create mode 100644 src/capawesome_cloud/resources/_base.py create mode 100644 src/capawesome_cloud/resources/apps.py create mode 100644 src/capawesome_cloud/resources/automations.py create mode 100644 src/capawesome_cloud/resources/build_sources.py create mode 100644 src/capawesome_cloud/resources/builds.py create mode 100644 src/capawesome_cloud/resources/certificates.py create mode 100644 src/capawesome_cloud/resources/channels.py create mode 100644 src/capawesome_cloud/resources/deployments.py create mode 100644 src/capawesome_cloud/resources/destinations.py create mode 100644 src/capawesome_cloud/resources/devices.py create mode 100644 src/capawesome_cloud/resources/environments.py create mode 100644 src/capawesome_cloud/resources/jobs.py create mode 100644 src/capawesome_cloud/resources/webhooks.py create mode 100644 tests/conftest.py create mode 100644 tests/test_client.py create mode 100644 tests/test_errors_and_retries.py create mode 100644 tests/test_pagination.py create mode 100644 tests/test_resources.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..a9212db --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,84 @@ +name: CI + +on: + push: + branches: + - main + paths-ignore: + - '**.md' + pull_request: + paths-ignore: + - '**.md' + workflow_dispatch: + +env: + DEFAULT_PYTHON_VERSION: '3.12' + +jobs: + build: + name: Build + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Set up Python ${{ env.DEFAULT_PYTHON_VERSION }} + uses: actions/setup-python@v5 + with: + python-version: ${{ env.DEFAULT_PYTHON_VERSION }} + cache: pip + cache-dependency-path: pyproject.toml + - name: Install build tooling + run: python -m pip install --upgrade pip build + - name: Build + run: python -m build + - name: Upload artifacts + uses: actions/upload-artifact@v4 + with: + name: dist + path: dist + + test: + name: Test (Python ${{ matrix.python-version }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ['3.9', '3.10', '3.11', '3.12', '3.13'] + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + cache: pip + cache-dependency-path: pyproject.toml + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -e ".[dev]" + - name: Run tests + run: pytest + + lint: + name: Lint + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Set up Python ${{ env.DEFAULT_PYTHON_VERSION }} + uses: actions/setup-python@v5 + with: + python-version: ${{ env.DEFAULT_PYTHON_VERSION }} + cache: pip + cache-dependency-path: pyproject.toml + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -e ".[dev]" + - name: Check formatting + run: ruff format --check src tests examples + - name: Run linter + run: ruff check src tests examples + - name: Run type checking + run: mypy diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d042c81 --- /dev/null +++ b/.gitignore @@ -0,0 +1,26 @@ +# Python +__pycache__/ +*.py[cod] +*.egg-info/ +.eggs/ +build/ +dist/ +*.egg + +# Virtual environments +.venv/ +venv/ +env/ + +# Tooling caches +.mypy_cache/ +.ruff_cache/ +.pytest_cache/ +.coverage +htmlcov/ +coverage.xml + +# Editors / OS +.idea/ +.vscode/ +.DS_Store diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..4175c42 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Robin Genz + +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. diff --git a/README.md b/README.md index 5ef633f..b6398ff 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,291 @@ -# cloud-python -⚙️ Python library for the Capawesome Cloud API. +# capawesome-cloud + +Python SDK for the [Capawesome Cloud](https://capawesome.io/cloud/) API. + +It provides a fully typed, synchronous interface for managing apps, live update channels and deployments, native builds, app store destinations, and more. + +> **Note:** The Capawesome Cloud API is still in development and may change without notice. Response types intentionally expose only the most relevant properties to minimize breaking changes. + +## SDKs + +Official SDKs for the Capawesome Cloud API: + +| Language | Package | Repository | +| -------- | ------------------------------------------------------------------------------ | --------------------------------------------------------------- | +| Node.js | [`@capawesome/cloud-sdk`](https://www.npmjs.com/package/@capawesome/cloud-sdk) | [cloud-node](https://github.com/capawesome-team/cloud-node) | +| Python | [`capawesome-cloud`](https://pypi.org/project/capawesome-cloud/) | [cloud-python](https://github.com/capawesome-team/cloud-python) | + +## Installation + +```bash +pip install capawesome-cloud +``` + +**Requirements:** Python 3.9 or later. + +## Getting started + +Create an API token in the [Capawesome Cloud Console](https://console.cloud.capawesome.io/settings/tokens) and pass it to the client (or set the `CAPAWESOME_CLOUD_TOKEN` environment variable): + +```python +from capawesome_cloud import CapawesomeCloud + +client = CapawesomeCloud(token="cap_...") + +for app in client.apps.list(): + print(app.id, app.name) +``` + +The client holds a connection pool, so reuse a single instance. Use it as a context manager (or call `client.close()`) to release connections when done: + +```python +with CapawesomeCloud(token="cap_...") as client: + ... +``` + +### Configuration + +| Option | Type | Default | Description | +| ---------------- | --------------- | --------------------------------- | ---------------------------------------------------------- | +| `token` | `str` | `CAPAWESOME_CLOUD_TOKEN` env var | API token used to authenticate. | +| `base_url` | `str` | `https://api.cloud.capawesome.io` | Base URL of the API (for self-hosting/testing). | +| `timeout` | `float` | `30.0` | Request timeout in seconds. | +| `max_retries` | `int` | `2` | Retries for `429` / `5xx` responses (exponential backoff). | +| `backoff_factor` | `float` | `0.5` | Base delay for the retry backoff. | +| `http_client` | `httpx.Client` | `None` | Bring your own pre-configured `httpx.Client`. | + +## Usage + +Resources mirror the API's path hierarchy. App-scoped resources are nested under `client.apps.*`; organization-scoped resources are on the client directly (e.g. `client.jobs`). Every app-scoped method takes `app_id` as its first argument. + +### Apps + +```python +apps = client.apps.list() +app = client.apps.get(app_id) +created = client.apps.create(name="My App", type="capacitor") +client.apps.update(app_id, name="Renamed App") +client.apps.delete(app_id) +``` + +### Live updates + +```python +# Create a channel +channel = client.apps.channels.create(app_id, name="production") + +# Pause / resume a channel +client.apps.channels.pause(app_id, channel.id) +client.apps.channels.resume(app_id, channel.id) +``` + +### Deployments + +Promote a build to a channel (live updates) or a destination (app store publishing): + +```python +deployment = client.apps.deployments.create( + app_id, + app_build_id=app_build_id, + app_channel_name="production", + rollout_percentage=0.5, +) +``` + +### Native builds + +```python +build = client.apps.builds.create(app_id, platform="ios", git_ref="main") + +# Poll the job that processes the build until it finishes +job = client.jobs.wait(build.job_id) +print(job.status) + +logs = client.jobs.logs(job.id) +``` + +#### Build artifacts + +Binary downloads return `bytes`: + +```python +data = client.apps.builds.artifacts.download(app_id, build_id, artifact_id) +with open("artifact.ipa", "wb") as file: + file.write(data) +``` + +You can also obtain a signed, time-limited download URL: + +```python +result = client.apps.builds.artifacts.get_download_url(app_id, build_id, artifact_id) +print(result["url"]) +``` + +#### Certificates + +`file` may be a path, raw `bytes`, or an open binary file object: + +```python +import os + +certificate = client.apps.certificates.create( + app_id, + name="Distribution Certificate", + platform="ios", + type="production", + file="distribution.p12", + password=os.environ["CERT_PASSWORD"], +) +``` + +#### Environments, secrets & variables + +```python +environment = client.apps.environments.create(app_id, name="production") + +client.apps.environments.secrets.create( + app_id, + environment.id, + key="API_KEY", + value=os.environ["API_KEY"], +) + +client.apps.environments.variables.create( + app_id, + environment.id, + key="API_URL", + value="https://api.example.com", +) +``` + +### Pagination + +List methods return an iterator that lazily pages through **all** results: + +```python +for device in client.apps.devices.list(app_id): + print(device.id, device.app_version_name) + +# Collect everything into a list +channels = client.apps.channels.list(app_id).to_list() +``` + +To fetch a single page (for manual offset control), use `list_page()`: + +```python +page = client.apps.channels.list_page(app_id, limit=20, offset=0) +``` + +### Responses + +Responses are typed [Pydantic](https://docs.pydantic.dev) models. App-scoped models are prefixed with `App` (`AppChannel`, `AppWebhook`, `AppBuild`, ...) to match the API's entity names. Only the most relevant fields are declared; any additional fields the API returns are still accessible (e.g. via `model_dump()`) but are **not part of the public contract** and should not be relied upon. + +```python +channel = client.apps.channels.get(app_id, channel_id) +print(channel.name, channel.created_at) # documented fields +print(channel.model_dump()) # full raw payload, incl. extra fields +``` + +### Clearing fields + +Update methods only send the arguments you pass. To **clear** a nullable field, pass `None`; omit the argument to leave it unchanged. + +```python +# Pin a device to a channel +client.apps.devices.update(app_id, device_id, forced_app_channel_id="ch_123") + +# Unpin it again (sends null) +client.apps.devices.update(app_id, device_id, forced_app_channel_id=None) +``` + +## Available resources + +| Resource | Description | +| ----------------------------------- | --------------------------------------------------- | +| `client.apps` | Create, read, update, delete and transfer apps. | +| `client.apps.channels` | Manage live update channels (incl. pause/resume). | +| `client.apps.deployments` | Promote builds to channels or destinations. | +| `client.apps.builds` | Trigger and manage native builds. | +| `client.apps.builds.artifacts` | List and download build artifacts. | +| `client.apps.build_sources` | Register and download native build sources. | +| `client.apps.certificates` | Manage signing certificates. | +| `client.apps.destinations` | Manage app store publishing destinations. | +| `client.apps.environments` | Manage environments, secrets and variables. | +| `client.apps.automations` | Manage build automations. | +| `client.apps.devices` | Manage registered devices. | +| `client.apps.webhooks` | Manage app webhooks. | +| `client.jobs` | Inspect background jobs and their logs. | + +## Error handling + +Any non-2xx response is raised as a `CapawesomeCloudError`. HTTP errors map to specific subclasses so you can catch exactly what you need: + +```python +from capawesome_cloud import CapawesomeCloud, NotFoundError + +client = CapawesomeCloud(token="cap_...") + +try: + client.apps.get("unknown") +except NotFoundError as error: + print(error.status_code) # 404 + print(error.message) # "App not found." + print(error.body) # {"message": "App not found."} +``` + +| Status | Exception | +| ------ | -------------------------- | +| 400 | `BadRequestError` | +| 401 | `AuthenticationError` | +| 403 | `PermissionDeniedError` | +| 404 | `NotFoundError` | +| 409 | `ConflictError` | +| 422 | `UnprocessableEntityError` | +| 429 | `RateLimitError` | +| 5xx | `InternalServerError` | + +All of the above derive from `APIStatusError`, which in turn derives from `CapawesomeCloudError`. Network failures raise `APIConnectionError` / `APITimeoutError`. + +## Development + +### Setup + +Clone the repository, then create a virtual environment and install the package with its development dependencies: + +```bash +python -m venv .venv && source .venv/bin/activate +pip install -e ".[dev]" +``` + +Commit messages follow [Conventional Commits](https://www.conventionalcommits.org), enforced by [Commitizen](https://commitizen-tools.github.io/commitizen/) — run `cz commit` for a guided prompt. Common commands during development: + +| Command | Description | +| ------------------------------------------ | -------------------------------------- | +| `python -m build` | Build the package into `dist/`. | +| `ruff check src tests examples` | Run the linter. | +| `ruff format src tests examples` | Auto-format the code. | +| `ruff format --check src tests examples` | Check formatting without writing. | +| `mypy` | Type-check the package. | +| `cz commit` | Create a Conventional Commit. | + +### Testing + +Tests are written with [pytest](https://docs.pytest.org) and live in the `tests/` directory. HTTP is mocked with [RESPX](https://lundberg.github.io/respx/), so the suite makes no network calls: + +```bash +pytest # run the suite once +pytest --cov=capawesome_cloud # run with a coverage report +``` + +### Publishing + +Releases are driven by [Commitizen](https://commitizen-tools.github.io/commitizen/), which derives the next version and changelog entries from [Conventional Commits](https://www.conventionalcommits.org). + +1. Make sure `main` is up to date and CI is green. +2. Run `cz bump`. This bumps the version (in `[tool.commitizen]` and `src/capawesome_cloud/_version.py`), regenerates `CHANGELOG.md`, and creates a release commit plus a matching `vX.Y.Z` tag — all locally, nothing is pushed yet. Pass `--dry-run` first if you want to preview the result. +3. Review the generated commit and changelog, then push everything: `git push --follow-tags origin main`. +4. Build the distributions with `python -m build`, then publish to PyPI with `python -m twine upload dist/*` (requires `pip install build twine`). + +## License + +See [LICENSE](./LICENSE). diff --git a/examples/manage_live_updates.py b/examples/manage_live_updates.py new file mode 100644 index 0000000..4d59573 --- /dev/null +++ b/examples/manage_live_updates.py @@ -0,0 +1,35 @@ +"""Manage live-update channels and devices. + +Run with: + CAPAWESOME_CLOUD_TOKEN=cap_... python examples/manage_live_updates.py +""" + +from __future__ import annotations + +import sys + +from capawesome_cloud import CapawesomeCloud + + +def main(app_id: str) -> None: + with CapawesomeCloud() as client: + # Create (or reuse) a "staging" channel. + channel = client.apps.channels.create(app_id, name="staging") + print(f"Created channel {channel.name} ({channel.id})") + + # Pause and resume update delivery. + client.apps.channels.pause(app_id, channel.id) + print("Channel paused") + client.apps.channels.resume(app_id, channel.id) + print("Channel resumed") + + # List the most recently seen devices. + print("Devices:") + for device in client.apps.devices.list(app_id): + print(f" {device.id} - {device.app_version_name} ({device.os_version})") + + +if __name__ == "__main__": + if len(sys.argv) != 2: + raise SystemExit("usage: manage_live_updates.py ") + main(sys.argv[1]) diff --git a/examples/trigger_native_build.py b/examples/trigger_native_build.py new file mode 100644 index 0000000..c0684df --- /dev/null +++ b/examples/trigger_native_build.py @@ -0,0 +1,44 @@ +"""Trigger a native build, wait for it, and download its artifacts. + +Run with: + CAPAWESOME_CLOUD_TOKEN=cap_... python examples/trigger_native_build.py +""" + +from __future__ import annotations + +import sys + +from capawesome_cloud import CapawesomeCloud, JobTimeoutError + + +def main(app_id: str) -> None: + with CapawesomeCloud() as client: + build = client.apps.builds.create(app_id, platform="android") + print(f"Started build {build.number_as_string} (job {build.job_id})") + + if not build.job_id: + return + + try: + job = client.jobs.wait(build.job_id, poll_interval=10, timeout=1800) + except JobTimeoutError as error: + print(error) + return + + print(f"Build finished with status: {job.status}") + if job.status != "succeeded": + print(client.jobs.logs(build.job_id)) + return + + for artifact in client.apps.builds.artifacts.list(app_id, build.id): + data = client.apps.builds.artifacts.download(app_id, build.id, artifact.id) + filename = f"{artifact.type}-{artifact.id}.bin" + with open(filename, "wb") as file: + file.write(data) + print(f"Downloaded {filename} ({len(data)} bytes)") + + +if __name__ == "__main__": + if len(sys.argv) != 2: + raise SystemExit("usage: trigger_native_build.py ") + main(sys.argv[1]) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..ee28b0b --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,86 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "capawesome-cloud" +dynamic = ["version"] +description = "Python SDK for the Capawesome Cloud API" +readme = "README.md" +requires-python = ">=3.9" +license = "MIT" +authors = [{ name = "Capawesome Team", email = "support@capawesome.io" }] +keywords = ["capawesome", "capacitor", "live-updates", "sdk", "api"] +classifiers = [ + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Operating System :: OS Independent", + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Typing :: Typed", +] +dependencies = [ + "httpx>=0.27,<1", + "pydantic>=2.7,<3", +] + +[project.urls] +Homepage = "https://capawesome.io/cloud" +Documentation = "https://cloud.capawesome.io/docs" +Repository = "https://github.com/capawesome-team/cloud-python" +"API Reference" = "https://api.cloud.capawesome.io/openapi" + +[project.optional-dependencies] +dev = [ + "pytest>=8", + "pytest-cov>=5", + "respx>=0.21", + "ruff>=0.6", + "mypy>=1.11", + "commitizen>=3.29", +] + +[tool.hatch.version] +path = "src/capawesome_cloud/_version.py" + +[tool.hatch.build.targets.wheel] +packages = ["src/capawesome_cloud"] + +[tool.ruff] +line-length = 100 +target-version = "py39" +src = ["src", "tests"] + +[tool.ruff.lint] +select = ["E", "F", "I", "UP", "B", "W", "N", "C4", "SIM"] +# UP007/UP035/UP045 push 3.10+ syntax (X | None, collections.abc generics) that +# breaks pydantic annotation evaluation on Python 3.9, which we still support. +ignore = ["E501", "UP006", "UP007", "UP035", "UP045"] + +[tool.ruff.lint.isort] +known-first-party = ["capawesome_cloud"] + +[tool.mypy] +# Analysis target; the runtime code remains compatible down to Python 3.9. +python_version = "3.10" +strict = true +warn_unused_ignores = true +disallow_any_generics = true +files = ["src"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +addopts = "-ra" + +[tool.commitizen] +version = "0.1.0" +version_files = ["src/capawesome_cloud/_version.py:__version__"] +tag_format = "v$version" +update_changelog_on_bump = true +major_version_zero = true diff --git a/src/capawesome_cloud/__init__.py b/src/capawesome_cloud/__init__.py new file mode 100644 index 0000000..8be3950 --- /dev/null +++ b/src/capawesome_cloud/__init__.py @@ -0,0 +1,76 @@ +"""Capawesome Cloud Python SDK. + +A typed, synchronous client for the Capawesome Cloud API. +""" + +from ._version import __version__ +from .client import CapawesomeCloud +from .exceptions import ( + APIConnectionError, + APIStatusError, + APITimeoutError, + AuthenticationError, + BadRequestError, + CapawesomeCloudError, + ConflictError, + InternalServerError, + NotFoundError, + PermissionDeniedError, + RateLimitError, + UnprocessableEntityError, +) +from .models import ( + App, + AppAutomation, + AppBuild, + AppBuildArtifact, + AppBuildSource, + AppCertificate, + AppChannel, + AppDeployment, + AppDestination, + AppDevice, + AppEnvironment, + AppEnvironmentSecret, + AppEnvironmentVariable, + AppWebhook, + CapawesomeModel, + Job, +) +from .resources.jobs import JobTimeoutError + +__all__ = [ + "__version__", + "CapawesomeCloud", + # Exceptions + "CapawesomeCloudError", + "APIConnectionError", + "APITimeoutError", + "APIStatusError", + "BadRequestError", + "AuthenticationError", + "PermissionDeniedError", + "NotFoundError", + "ConflictError", + "UnprocessableEntityError", + "RateLimitError", + "InternalServerError", + "JobTimeoutError", + # Models + "CapawesomeModel", + "App", + "AppAutomation", + "AppBuild", + "AppBuildArtifact", + "AppBuildSource", + "AppCertificate", + "AppChannel", + "AppDeployment", + "AppDestination", + "AppDevice", + "AppEnvironment", + "AppEnvironmentSecret", + "AppEnvironmentVariable", + "AppWebhook", + "Job", +] diff --git a/src/capawesome_cloud/_http.py b/src/capawesome_cloud/_http.py new file mode 100644 index 0000000..ec77a63 --- /dev/null +++ b/src/capawesome_cloud/_http.py @@ -0,0 +1,159 @@ +"""Low-level HTTP transport used by every resource. + +Wraps an :class:`httpx.Client` and adds: + +* bearer-token authentication, +* automatic retries with exponential backoff on ``429`` and ``5xx`` responses, +* mapping of error responses to the SDK exception hierarchy. +""" + +from __future__ import annotations + +import time +from typing import Any, Mapping, Optional + +import httpx + +from ._version import __version__ +from .exceptions import ( + APIConnectionError, + APITimeoutError, + exception_from_response, +) + +DEFAULT_BASE_URL = "https://api.cloud.capawesome.io" +DEFAULT_TIMEOUT = 30.0 +DEFAULT_MAX_RETRIES = 2 +DEFAULT_BACKOFF_FACTOR = 0.5 + +_RETRY_STATUS_CODES = frozenset({429, 500, 502, 503, 504}) + +# A small JSON-ish type. Responses are intentionally loosely typed because the +# API does not publish response schemas. +JSON = Any + + +class HttpClient: + """Thin, retrying wrapper around :class:`httpx.Client`.""" + + def __init__( + self, + *, + token: str, + base_url: str = DEFAULT_BASE_URL, + timeout: float = DEFAULT_TIMEOUT, + max_retries: int = DEFAULT_MAX_RETRIES, + backoff_factor: float = DEFAULT_BACKOFF_FACTOR, + http_client: Optional[httpx.Client] = None, + ) -> None: + self._max_retries = max_retries + self._backoff_factor = backoff_factor + self._owns_client = http_client is None + self._client = http_client or httpx.Client( + base_url=base_url.rstrip("/"), + timeout=timeout, + ) + # The token must always apply; identify ourselves on clients we create + # without clobbering a caller-supplied client's User-Agent. + self._client.headers["Authorization"] = f"Bearer {token}" + self._client.headers.setdefault("Accept", "application/json") + if self._owns_client: + self._client.headers["User-Agent"] = f"capawesome-cloud-python/{__version__}" + + # -- public API --------------------------------------------------------- + + def request( + self, + method: str, + path: str, + *, + params: Optional[Mapping[str, Any]] = None, + json: Optional[Any] = None, + files: Optional[Mapping[str, Any]] = None, + data: Optional[Mapping[str, Any]] = None, + ) -> httpx.Response: + """Send a request, retrying transient failures, and validate the status.""" + clean_params = _drop_none(params) if params else None + attempt = 0 + while True: + try: + response = self._client.request( + method, + path, + params=clean_params, + json=json, + files=files, + data=data, + ) + except httpx.TimeoutException as exc: + if attempt < self._max_retries: + self._sleep(attempt, None) + attempt += 1 + continue + raise APITimeoutError(request=exc.request) from exc + except httpx.TransportError as exc: + if attempt < self._max_retries: + self._sleep(attempt, None) + attempt += 1 + continue + raise APIConnectionError(str(exc), request=exc.request) from exc + + if response.status_code in _RETRY_STATUS_CODES and attempt < self._max_retries: + self._sleep(attempt, response) + attempt += 1 + continue + + if response.is_success: + return response + + raise exception_from_response(response) + + def request_json( + self, + method: str, + path: str, + *, + params: Optional[Mapping[str, Any]] = None, + json: Optional[Any] = None, + files: Optional[Mapping[str, Any]] = None, + data: Optional[Mapping[str, Any]] = None, + ) -> JSON: + """Send a request and return the parsed JSON body (``None`` if empty).""" + response = self.request(method, path, params=params, json=json, files=files, data=data) + if not response.content: + return None + return response.json() + + def close(self) -> None: + if self._owns_client: + self._client.close() + + def __enter__(self) -> HttpClient: + return self + + def __exit__(self, *exc: object) -> None: + self.close() + + # -- internals ---------------------------------------------------------- + + def _sleep(self, attempt: int, response: Optional[httpx.Response]) -> None: + delay = self._backoff_factor * (2**attempt) + if response is not None: + retry_after = _parse_retry_after(response.headers.get("Retry-After")) + if retry_after is not None: + delay = max(delay, retry_after) + time.sleep(delay) + + +def _drop_none(mapping: Mapping[str, Any]) -> dict[str, Any]: + """Return a copy of ``mapping`` without ``None`` values.""" + return {key: value for key, value in mapping.items() if value is not None} + + +def _parse_retry_after(value: Optional[str]) -> Optional[float]: + if not value: + return None + try: + return float(value) + except ValueError: + return None diff --git a/src/capawesome_cloud/_types.py b/src/capawesome_cloud/_types.py new file mode 100644 index 0000000..18e97af --- /dev/null +++ b/src/capawesome_cloud/_types.py @@ -0,0 +1,23 @@ +"""Shared sentinel types.""" + +from __future__ import annotations + +from typing import Literal + + +class NotGiven: + """Sentinel marking an omitted argument. + + Distinguishes "argument not provided" from an explicit ``None``. This lets a + caller send ``null`` to clear a field (pass ``None``) versus leaving it + untouched (pass nothing). + """ + + def __bool__(self) -> Literal[False]: + return False + + def __repr__(self) -> str: + return "NOT_GIVEN" + + +NOT_GIVEN = NotGiven() diff --git a/src/capawesome_cloud/_version.py b/src/capawesome_cloud/_version.py new file mode 100644 index 0000000..3dc1f76 --- /dev/null +++ b/src/capawesome_cloud/_version.py @@ -0,0 +1 @@ +__version__ = "0.1.0" diff --git a/src/capawesome_cloud/client.py b/src/capawesome_cloud/client.py new file mode 100644 index 0000000..ffc2664 --- /dev/null +++ b/src/capawesome_cloud/client.py @@ -0,0 +1,78 @@ +"""The main Capawesome Cloud client.""" + +from __future__ import annotations + +import os +from typing import Optional + +import httpx + +from ._http import ( + DEFAULT_BACKOFF_FACTOR, + DEFAULT_BASE_URL, + DEFAULT_MAX_RETRIES, + DEFAULT_TIMEOUT, + HttpClient, +) +from .exceptions import CapawesomeCloudError +from .resources import AppsResource, JobsResource + +ENV_TOKEN = "CAPAWESOME_CLOUD_TOKEN" + + +class CapawesomeCloud: + """Synchronous client for the Capawesome Cloud API. + + Create an API token in the Capawesome Cloud Console + (https://console.cloud.capawesome.io/settings/tokens) and pass it as + ``token`` or via the ``CAPAWESOME_CLOUD_TOKEN`` environment variable. + + Example:: + + from capawesome_cloud import CapawesomeCloud + + client = CapawesomeCloud(token="...") + for app in client.apps.list(): + print(app.id, app.name) + """ + + def __init__( + self, + token: Optional[str] = None, + *, + base_url: str = DEFAULT_BASE_URL, + timeout: float = DEFAULT_TIMEOUT, + max_retries: int = DEFAULT_MAX_RETRIES, + backoff_factor: float = DEFAULT_BACKOFF_FACTOR, + http_client: Optional[httpx.Client] = None, + ) -> None: + resolved_token = token or os.environ.get(ENV_TOKEN) + if not resolved_token: + raise CapawesomeCloudError( + "No API token provided. Pass token=... or set the " + f"{ENV_TOKEN} environment variable." + ) + + self._http = HttpClient( + token=resolved_token, + base_url=base_url, + timeout=timeout, + max_retries=max_retries, + backoff_factor=backoff_factor, + http_client=http_client, + ) + + # App-scoped resources are nested under ``apps`` (e.g. + # ``client.apps.channels``); ``jobs`` is organization-scoped. + self.apps = AppsResource(self._http) + self.jobs = JobsResource(self._http) + + def close(self) -> None: + """Close the underlying HTTP connection pool.""" + self._http.close() + + def __enter__(self) -> CapawesomeCloud: + return self + + def __exit__(self, *exc: object) -> None: + self.close() diff --git a/src/capawesome_cloud/exceptions.py b/src/capawesome_cloud/exceptions.py new file mode 100644 index 0000000..e25d722 --- /dev/null +++ b/src/capawesome_cloud/exceptions.py @@ -0,0 +1,138 @@ +"""Exception hierarchy for the Capawesome Cloud SDK. + +All errors raised by the SDK derive from :class:`CapawesomeCloudError`, so a single +``except CapawesomeCloudError`` will catch everything the SDK can raise. +""" + +from __future__ import annotations + +from typing import Any, Optional + +import httpx + +__all__ = [ + "CapawesomeCloudError", + "APIConnectionError", + "APITimeoutError", + "APIStatusError", + "BadRequestError", + "AuthenticationError", + "PermissionDeniedError", + "NotFoundError", + "ConflictError", + "UnprocessableEntityError", + "RateLimitError", + "InternalServerError", +] + + +class CapawesomeCloudError(Exception): + """Base class for every error raised by the SDK.""" + + +class APIConnectionError(CapawesomeCloudError): + """Raised when the request could not reach the API (network failure).""" + + def __init__( + self, + message: str = "Could not connect to the Capawesome Cloud API.", + *, + request: Optional[httpx.Request] = None, + ) -> None: + super().__init__(message) + self.request = request + + +class APITimeoutError(APIConnectionError): + """Raised when the request timed out.""" + + def __init__(self, *, request: Optional[httpx.Request] = None) -> None: + super().__init__("Request to the Capawesome Cloud API timed out.", request=request) + + +class APIStatusError(CapawesomeCloudError): + """Raised when the API returns a non-success HTTP status code. + + The ``message`` is taken from the API response body (``{"message": ...}``) + when available, otherwise the HTTP reason phrase is used. + """ + + def __init__( + self, + message: str, + *, + status_code: int, + response: httpx.Response, + body: Optional[Any] = None, + ) -> None: + super().__init__(message) + self.message = message + self.status_code = status_code + self.response = response + self.body = body + + +class BadRequestError(APIStatusError): + """HTTP 400.""" + + +class AuthenticationError(APIStatusError): + """HTTP 401 - missing or invalid API token.""" + + +class PermissionDeniedError(APIStatusError): + """HTTP 403.""" + + +class NotFoundError(APIStatusError): + """HTTP 404.""" + + +class ConflictError(APIStatusError): + """HTTP 409.""" + + +class UnprocessableEntityError(APIStatusError): + """HTTP 422 - request validation failed.""" + + +class RateLimitError(APIStatusError): + """HTTP 429 - too many requests.""" + + +class InternalServerError(APIStatusError): + """HTTP 5xx.""" + + +_STATUS_EXCEPTIONS: dict[int, type[APIStatusError]] = { + 400: BadRequestError, + 401: AuthenticationError, + 403: PermissionDeniedError, + 404: NotFoundError, + 409: ConflictError, + 422: UnprocessableEntityError, + 429: RateLimitError, +} + + +def exception_from_response(response: httpx.Response) -> APIStatusError: + """Build the most specific :class:`APIStatusError` for an HTTP response.""" + status = response.status_code + body: Optional[Any] = None + message: Optional[str] = None + try: + body = response.json() + if isinstance(body, dict): + raw = body.get("message") + if isinstance(raw, str): + message = raw + except ValueError: + body = response.text or None + + if not message: + message = f"HTTP {status} {response.reason_phrase}".strip() + + exc_class = _STATUS_EXCEPTIONS.get(status) + if exc_class is None: + exc_class = InternalServerError if status >= 500 else APIStatusError + return exc_class(message, status_code=status, response=response, body=body) diff --git a/src/capawesome_cloud/models.py b/src/capawesome_cloud/models.py new file mode 100644 index 0000000..c9569d3 --- /dev/null +++ b/src/capawesome_cloud/models.py @@ -0,0 +1,190 @@ +"""Response models. + +The Capawesome Cloud API does not publish response schemas and is still evolving, +so these models intentionally declare only the **important, stable** fields. Every +model is configured with ``extra="allow"``: any additional (including internal) +fields the API returns are preserved and accessible as attributes, but they are +**not part of the SDK's public contract** and may change or disappear without +notice -- do not rely on them. + +App-scoped resources are prefixed with ``App`` (``AppChannel``, ``AppWebhook``, +...) to mirror the API's entity names and avoid future clashes with +organization-scoped resources. Field names are exposed in Pythonic +``snake_case`` while the API's ``camelCase`` keys are accepted transparently. +""" + +from __future__ import annotations + +from datetime import datetime +from typing import Optional + +from pydantic import BaseModel, ConfigDict +from pydantic.alias_generators import to_camel + +__all__ = [ + "CapawesomeModel", + "App", + "AppChannel", + "AppBuild", + "AppBuildArtifact", + "AppBuildSource", + "AppDeployment", + "AppDestination", + "AppDevice", + "AppEnvironment", + "AppEnvironmentVariable", + "AppEnvironmentSecret", + "AppCertificate", + "AppWebhook", + "AppAutomation", + "Job", +] + + +class CapawesomeModel(BaseModel): + """Base model: maps ``camelCase`` API keys and keeps unknown fields.""" + + model_config = ConfigDict( + alias_generator=to_camel, + populate_by_name=True, + extra="allow", + ) + + id: Optional[str] = None + + +class App(CapawesomeModel): + name: Optional[str] = None + type: Optional[str] = None + created_at: Optional[datetime] = None + updated_at: Optional[datetime] = None + + +class AppChannel(CapawesomeModel): + name: Optional[str] = None + app_id: Optional[str] = None + paused_at: Optional[datetime] = None + protected_at: Optional[datetime] = None + created_at: Optional[datetime] = None + updated_at: Optional[datetime] = None + + +class AppBuild(CapawesomeModel): + number_as_string: Optional[str] = None + platform: Optional[str] = None + type: Optional[str] = None + display_name: Optional[str] = None + app_id: Optional[str] = None + job_id: Optional[str] = None + created_at: Optional[datetime] = None + updated_at: Optional[datetime] = None + + +class AppBuildArtifact(CapawesomeModel): + type: Optional[str] = None + status: Optional[str] = None + app_build_id: Optional[str] = None + total_size_in_bytes: Optional[int] = None + download_url: Optional[str] = None + created_at: Optional[datetime] = None + + +class AppBuildSource(CapawesomeModel): + type: Optional[str] = None + status: Optional[str] = None + file_url: Optional[str] = None + file_size_in_bytes: Optional[int] = None + created_at: Optional[datetime] = None + + +class AppDeployment(CapawesomeModel): + app_build_id: Optional[str] = None + app_channel_id: Optional[str] = None + app_destination_id: Optional[str] = None + app_id: Optional[str] = None + job_id: Optional[str] = None + rollout_percentage: Optional[float] = None + created_at: Optional[datetime] = None + updated_at: Optional[datetime] = None + + +class AppDestination(CapawesomeModel): + name: Optional[str] = None + platform: Optional[str] = None + app_id: Optional[str] = None + created_at: Optional[datetime] = None + updated_at: Optional[datetime] = None + + +class AppDevice(CapawesomeModel): + app_id: Optional[str] = None + app_version_code: Optional[str] = None + app_version_name: Optional[str] = None + os_version: Optional[str] = None + plugin_version: Optional[str] = None + custom_id: Optional[str] = None + last_seen_at: Optional[datetime] = None + created_at: Optional[datetime] = None + updated_at: Optional[datetime] = None + + +class AppEnvironment(CapawesomeModel): + name: Optional[str] = None + app_id: Optional[str] = None + created_at: Optional[datetime] = None + updated_at: Optional[datetime] = None + + +class AppEnvironmentVariable(CapawesomeModel): + key: Optional[str] = None + value: Optional[str] = None + app_environment_id: Optional[str] = None + created_at: Optional[datetime] = None + updated_at: Optional[datetime] = None + + +class AppEnvironmentSecret(CapawesomeModel): + # The secret ``value`` is encrypted and not returned by the API. + key: Optional[str] = None + app_environment_id: Optional[str] = None + created_at: Optional[datetime] = None + updated_at: Optional[datetime] = None + + +class AppCertificate(CapawesomeModel): + name: Optional[str] = None + platform: Optional[str] = None + type: Optional[str] = None + app_id: Optional[str] = None + created_at: Optional[datetime] = None + updated_at: Optional[datetime] = None + + +class AppWebhook(CapawesomeModel): + name: Optional[str] = None + url: Optional[str] = None + events: Optional[list[str]] = None + format: Optional[str] = None + enabled: Optional[bool] = None + app_id: Optional[str] = None + created_at: Optional[datetime] = None + updated_at: Optional[datetime] = None + + +class AppAutomation(CapawesomeModel): + name: Optional[str] = None + platform: Optional[str] = None + trigger_type: Optional[str] = None + enabled: Optional[bool] = None + app_id: Optional[str] = None + created_at: Optional[datetime] = None + updated_at: Optional[datetime] = None + + +class Job(CapawesomeModel): + status: Optional[str] = None + app_id: Optional[str] = None + app_build_id: Optional[str] = None + app_deployment_id: Optional[str] = None + finished_at: Optional[datetime] = None + created_at: Optional[datetime] = None diff --git a/src/capawesome_cloud/pagination.py b/src/capawesome_cloud/pagination.py new file mode 100644 index 0000000..1a97a6d --- /dev/null +++ b/src/capawesome_cloud/pagination.py @@ -0,0 +1,71 @@ +"""Offset-based pagination. + +List endpoints return a bare JSON array with no pagination metadata. Pages are +walked using ``limit`` / ``offset`` query parameters: a page shorter than the +requested ``page_size`` (or an empty page) marks the end. +""" + +from __future__ import annotations + +from typing import Any, Callable, Iterator, TypeVar + +from .models import CapawesomeModel + +T = TypeVar("T", bound=CapawesomeModel) + +DEFAULT_PAGE_SIZE = 100 + +# A callable that fetches one raw page given (limit, offset). +PageFetcher = Callable[[int, int], list[Any]] + + +class Paginator(Iterator[T]): + """Lazily iterates over every item across all pages. + + Iterate it directly to transparently page through the full result set:: + + for channel in client.channels.list(app_id="..."): + ... + """ + + def __init__( + self, + fetch_page: PageFetcher, + model: type[T], + *, + page_size: int = DEFAULT_PAGE_SIZE, + start_offset: int = 0, + ) -> None: + if page_size < 1: + raise ValueError("page_size must be >= 1") + self._fetch_page = fetch_page + self._model = model + self._page_size = page_size + self._offset = start_offset + self._buffer: list[Any] = [] + self._exhausted = False + + def __iter__(self) -> Paginator[T]: + return self + + def __next__(self) -> T: + while not self._buffer: + if self._exhausted: + raise StopIteration + self._load_next_page() + item = self._buffer.pop(0) + return self._model.model_validate(item) + + def _load_next_page(self) -> None: + page = self._fetch_page(self._page_size, self._offset) + if not page: + self._exhausted = True + return + self._buffer.extend(page) + self._offset += len(page) + if len(page) < self._page_size: + self._exhausted = True + + def to_list(self) -> list[T]: + """Eagerly fetch and return every item as a list.""" + return list(self) diff --git a/src/capawesome_cloud/py.typed b/src/capawesome_cloud/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/src/capawesome_cloud/resources/__init__.py b/src/capawesome_cloud/resources/__init__.py new file mode 100644 index 0000000..47018b4 --- /dev/null +++ b/src/capawesome_cloud/resources/__init__.py @@ -0,0 +1,36 @@ +"""Resource clients.""" + +from .apps import AppsResource +from .automations import AutomationsResource +from .build_sources import BuildSourcesResource +from .builds import BuildArtifactsResource, BuildsResource +from .certificates import CertificatesResource +from .channels import ChannelsResource +from .deployments import DeploymentsResource +from .destinations import DestinationsResource +from .devices import DevicesResource +from .environments import ( + EnvironmentSecretsResource, + EnvironmentsResource, + EnvironmentVariablesResource, +) +from .jobs import JobsResource +from .webhooks import WebhooksResource + +__all__ = [ + "AppsResource", + "AutomationsResource", + "BuildArtifactsResource", + "BuildSourcesResource", + "BuildsResource", + "CertificatesResource", + "ChannelsResource", + "DeploymentsResource", + "DestinationsResource", + "DevicesResource", + "EnvironmentSecretsResource", + "EnvironmentVariablesResource", + "EnvironmentsResource", + "JobsResource", + "WebhooksResource", +] diff --git a/src/capawesome_cloud/resources/_base.py b/src/capawesome_cloud/resources/_base.py new file mode 100644 index 0000000..482f905 --- /dev/null +++ b/src/capawesome_cloud/resources/_base.py @@ -0,0 +1,100 @@ +"""Shared functionality for resource clients.""" + +from __future__ import annotations + +from typing import Any, List, Mapping, Optional, TypeVar + +from .._http import HttpClient +from .._types import NotGiven +from ..models import CapawesomeModel +from ..pagination import DEFAULT_PAGE_SIZE, Paginator + +T = TypeVar("T", bound=CapawesomeModel) + + +def build_body(values: Mapping[str, Any]) -> dict[str, Any]: + """Drop :data:`NOT_GIVEN` entries, keeping explicit ``None`` values.""" + return {key: value for key, value in values.items() if not isinstance(value, NotGiven)} + + +def build_params(values: Mapping[str, Any]) -> dict[str, Any]: + """Drop both :data:`NOT_GIVEN` and ``None`` query parameters.""" + return { + key: value + for key, value in values.items() + if value is not None and not isinstance(value, NotGiven) + } + + +class BaseResource: + def __init__(self, http: HttpClient) -> None: + self._http = http + + # -- single object ------------------------------------------------------ + + def _request_model( + self, + method: str, + path: str, + model: type[T], + *, + params: Optional[Mapping[str, Any]] = None, + json: Optional[Any] = None, + files: Optional[Mapping[str, Any]] = None, + data: Optional[Mapping[str, Any]] = None, + ) -> T: + payload = self._http.request_json( + method, path, params=params, json=json, files=files, data=data + ) + return model.model_validate(payload) + + def _request_none( + self, + method: str, + path: str, + *, + params: Optional[Mapping[str, Any]] = None, + json: Optional[Any] = None, + ) -> None: + self._http.request(method, path, params=params, json=json) + + # -- collections -------------------------------------------------------- + + def _list_page( + self, + path: str, + model: type[T], + *, + params: Optional[Mapping[str, Any]] = None, + ) -> List[T]: + payload = self._http.request_json("GET", path, params=params) + items = payload if isinstance(payload, list) else [] + return [model.model_validate(item) for item in items] + + def _paginate( + self, + path: str, + model: type[T], + *, + params: Optional[Mapping[str, Any]] = None, + page_size: int = DEFAULT_PAGE_SIZE, + ) -> Paginator[T]: + base_params = dict(params or {}) + + def fetch(limit: int, offset: int) -> List[Any]: + page_params = {**base_params, "limit": limit, "offset": offset} + payload = self._http.request_json("GET", path, params=page_params) + return payload if isinstance(payload, list) else [] + + return Paginator(fetch, model, page_size=page_size) + + # -- binary ------------------------------------------------------------- + + def _download( + self, + path: str, + *, + params: Optional[Mapping[str, Any]] = None, + ) -> bytes: + response = self._http.request("GET", path, params=params) + return response.content diff --git a/src/capawesome_cloud/resources/apps.py b/src/capawesome_cloud/resources/apps.py new file mode 100644 index 0000000..49fb641 --- /dev/null +++ b/src/capawesome_cloud/resources/apps.py @@ -0,0 +1,135 @@ +"""Apps resource and its app-scoped sub-resources.""" + +from __future__ import annotations + +from typing import List, Optional, Union + +from .._http import HttpClient +from .._types import NOT_GIVEN, NotGiven +from ..models import App +from ..pagination import DEFAULT_PAGE_SIZE, Paginator +from ._base import BaseResource, build_body, build_params +from .automations import AutomationsResource +from .build_sources import BuildSourcesResource +from .builds import BuildsResource +from .certificates import CertificatesResource +from .channels import ChannelsResource +from .deployments import DeploymentsResource +from .destinations import DestinationsResource +from .devices import DevicesResource +from .environments import EnvironmentsResource +from .webhooks import WebhooksResource + + +class AppsResource(BaseResource): + """Apps, plus all resources scoped under ``/v1/apps/{appId}/...``. + + App-scoped resources are exposed as attributes (``client.apps.channels``, + ``client.apps.builds``, ...). Each of their methods takes ``app_id`` as the + first argument. + """ + + _path = "/v1/apps" + + def __init__(self, http: HttpClient) -> None: + super().__init__(http) + self.channels = ChannelsResource(http) + self.builds = BuildsResource(http) + self.build_sources = BuildSourcesResource(http) + self.deployments = DeploymentsResource(http) + self.destinations = DestinationsResource(http) + self.devices = DevicesResource(http) + self.environments = EnvironmentsResource(http) + self.certificates = CertificatesResource(http) + self.webhooks = WebhooksResource(http) + self.automations = AutomationsResource(http) + + def create( + self, + *, + name: str, + type: Union[str, NotGiven] = NOT_GIVEN, + organization_id: Union[str, NotGiven] = NOT_GIVEN, + ) -> App: + """Create a new app. ``type`` is one of ``android``, ``capacitor``, ``cordova``, ``ios``.""" + body = build_body({"name": name, "type": type}) + params = build_params({"organizationId": organization_id}) + return self._request_model("POST", self._path, App, params=params, json=body) + + def list( + self, + *, + organization_id: Optional[str] = None, + query: Optional[str] = None, + relations: Optional[str] = None, + page_size: int = DEFAULT_PAGE_SIZE, + ) -> Paginator[App]: + """Iterate over all apps, transparently paging through the result set.""" + params = build_params( + {"organizationId": organization_id, "query": query, "relations": relations} + ) + return self._paginate(self._path, App, params=params, page_size=page_size) + + def list_page( + self, + *, + limit: Optional[int] = None, + offset: Optional[int] = None, + organization_id: Optional[str] = None, + query: Optional[str] = None, + relations: Optional[str] = None, + ) -> List[App]: + """Fetch a single page of apps.""" + params = build_params( + { + "limit": limit, + "offset": offset, + "organizationId": organization_id, + "query": query, + "relations": relations, + } + ) + return self._list_page(self._path, App, params=params) + + def get(self, app_id: str, *, relations: Optional[str] = None) -> App: + """Retrieve a single app by id.""" + params = build_params({"relations": relations}) + return self._request_model("GET", f"{self._path}/{app_id}", App, params=params) + + def update( + self, + app_id: str, + *, + name: Union[str, NotGiven] = NOT_GIVEN, + type: Union[str, NotGiven] = NOT_GIVEN, + app_channel_id: Union[str, None, NotGiven] = NOT_GIVEN, + app_environment_id: Union[str, None, NotGiven] = NOT_GIVEN, + app_channel_discovery_enabled: Union[bool, NotGiven] = NOT_GIVEN, + git_repository_url: Union[str, None, NotGiven] = NOT_GIVEN, + next_app_build_number: Union[int, NotGiven] = NOT_GIVEN, + ) -> App: + """Update an app.""" + body = build_body( + { + "name": name, + "type": type, + "appChannelId": app_channel_id, + "appEnvironmentId": app_environment_id, + "appChannelDiscoveryEnabled": app_channel_discovery_enabled, + "gitRepositoryUrl": git_repository_url, + "nextAppBuildNumber": next_app_build_number, + } + ) + return self._request_model("PATCH", f"{self._path}/{app_id}", App, json=body) + + def delete(self, app_id: str) -> None: + """Delete an app.""" + self._request_none("DELETE", f"{self._path}/{app_id}") + + def transfer(self, app_id: str, *, organization_id: str) -> None: + """Transfer an app to another organization.""" + self._request_none( + "POST", + f"{self._path}/{app_id}/transfer", + json={"organizationId": organization_id}, + ) diff --git a/src/capawesome_cloud/resources/automations.py b/src/capawesome_cloud/resources/automations.py new file mode 100644 index 0000000..ed22735 --- /dev/null +++ b/src/capawesome_cloud/resources/automations.py @@ -0,0 +1,109 @@ +"""Automations resource.""" + +from __future__ import annotations + +from typing import List, Optional + +from .._types import NotGiven +from ..models import AppAutomation +from ..pagination import DEFAULT_PAGE_SIZE, Paginator +from ._base import BaseResource, build_params + +_WRITE_FIELDS = { + "name": "name", + "trigger_type": "triggerType", + "trigger_pattern": "triggerPattern", + "commit_message_pattern": "commitMessagePattern", + "platform": "platform", + "build_type": "buildType", + "build_stack": "buildStack", + "enabled": "enabled", + "app_certificate_id": "appCertificateId", + "app_channel_id": "appChannelId", + "app_destination_id": "appDestinationId", + "app_environment_id": "appEnvironmentId", +} + + +class AutomationsResource(BaseResource): + def _base(self, app_id: str) -> str: + return f"/v1/apps/{app_id}/automations" + + def create( + self, app_id: str, *, name: str, trigger_type: str, **fields: object + ) -> AppAutomation: + """Create an automation. + + ``name`` and ``trigger_type`` (``branch`` or ``tag``) are required. Provide + further fields by their snake_case names (e.g. ``platform``, + ``trigger_pattern``, ``app_channel_id``). + """ + body = _map_write_fields({"name": name, "trigger_type": trigger_type, **fields}) + return self._request_model("POST", self._base(app_id), AppAutomation, json=body) + + def list( + self, + app_id: str, + *, + platform: Optional[str] = None, + query: Optional[str] = None, + relations: Optional[str] = None, + page_size: int = DEFAULT_PAGE_SIZE, + ) -> Paginator[AppAutomation]: + """Iterate over all automations of an app.""" + params = build_params({"platform": platform, "query": query, "relations": relations}) + return self._paginate(self._base(app_id), AppAutomation, params=params, page_size=page_size) + + def list_page( + self, + app_id: str, + *, + limit: Optional[int] = None, + offset: Optional[int] = None, + platform: Optional[str] = None, + query: Optional[str] = None, + relations: Optional[str] = None, + ) -> List[AppAutomation]: + """Fetch a single page of automations.""" + params = build_params( + { + "limit": limit, + "offset": offset, + "platform": platform, + "query": query, + "relations": relations, + } + ) + return self._list_page(self._base(app_id), AppAutomation, params=params) + + def get( + self, app_id: str, automation_id: str, *, relations: Optional[str] = None + ) -> AppAutomation: + """Retrieve an automation by id.""" + params = build_params({"relations": relations}) + return self._request_model( + "GET", f"{self._base(app_id)}/{automation_id}", AppAutomation, params=params + ) + + def update(self, app_id: str, automation_id: str, **fields: object) -> AppAutomation: + """Update an automation. Provide fields by their snake_case names.""" + body = _map_write_fields(fields) + return self._request_model( + "PATCH", f"{self._base(app_id)}/{automation_id}", AppAutomation, json=body + ) + + def delete(self, app_id: str, automation_id: str) -> None: + """Delete an automation.""" + self._request_none("DELETE", f"{self._base(app_id)}/{automation_id}") + + +def _map_write_fields(fields: dict[str, object]) -> dict[str, object]: + body: dict[str, object] = {} + for key, value in fields.items(): + if isinstance(value, NotGiven): + continue + api_key = _WRITE_FIELDS.get(key) + if api_key is None: + raise TypeError(f"Unknown automation field: {key!r}") + body[api_key] = value + return body diff --git a/src/capawesome_cloud/resources/build_sources.py b/src/capawesome_cloud/resources/build_sources.py new file mode 100644 index 0000000..6b8c686 --- /dev/null +++ b/src/capawesome_cloud/resources/build_sources.py @@ -0,0 +1,29 @@ +"""Build sources resource.""" + +from __future__ import annotations + +from typing import Union + +from .._types import NOT_GIVEN, NotGiven +from ..models import AppBuildSource +from ._base import BaseResource, build_body + + +class BuildSourcesResource(BaseResource): + def _base(self, app_id: str) -> str: + return f"/v1/apps/{app_id}/build-sources" + + def create( + self, + app_id: str, + *, + file_url: Union[str, None, NotGiven] = NOT_GIVEN, + file_size_in_bytes: Union[int, None, NotGiven] = NOT_GIVEN, + ) -> AppBuildSource: + """Create a build source (e.g. register an uploaded archive).""" + body = build_body({"fileUrl": file_url, "fileSizeInBytes": file_size_in_bytes}) + return self._request_model("POST", self._base(app_id), AppBuildSource, json=body) + + def download(self, app_id: str, build_source_id: str) -> bytes: + """Download a build source archive.""" + return self._download(f"{self._base(app_id)}/{build_source_id}/download") diff --git a/src/capawesome_cloud/resources/builds.py b/src/capawesome_cloud/resources/builds.py new file mode 100644 index 0000000..78c7a42 --- /dev/null +++ b/src/capawesome_cloud/resources/builds.py @@ -0,0 +1,195 @@ +"""Builds resource (Native Builds) and build artifacts.""" + +from __future__ import annotations + +from typing import Any, List, Mapping, Optional, Union + +from .._http import HttpClient +from .._types import NOT_GIVEN, NotGiven +from ..models import AppBuild, AppBuildArtifact +from ..pagination import DEFAULT_PAGE_SIZE, Paginator +from ._base import BaseResource, build_body, build_params + + +class BuildsResource(BaseResource): + def __init__(self, http: HttpClient) -> None: + super().__init__(http) + self.artifacts = BuildArtifactsResource(http) + + def _base(self, app_id: str) -> str: + return f"/v1/apps/{app_id}/builds" + + def create( + self, + app_id: str, + *, + platform: Union[str, None, NotGiven] = NOT_GIVEN, + type: Union[str, None, NotGiven] = NOT_GIVEN, + stack: Union[str, NotGiven] = NOT_GIVEN, + git_ref: Union[str, None, NotGiven] = NOT_GIVEN, + app_build_source_id: Union[str, None, NotGiven] = NOT_GIVEN, + app_certificate_id: Union[str, None, NotGiven] = NOT_GIVEN, + app_certificate_name: Union[str, None, NotGiven] = NOT_GIVEN, + app_channel_id: Union[str, None, NotGiven] = NOT_GIVEN, + app_destination_id: Union[str, None, NotGiven] = NOT_GIVEN, + app_environment_id: Union[str, None, NotGiven] = NOT_GIVEN, + app_environment_name: Union[str, None, NotGiven] = NOT_GIVEN, + ad_hoc_environment_variables: Union[Mapping[str, str], None, NotGiven] = NOT_GIVEN, + ) -> AppBuild: + """Trigger a native build. Returns the build (poll its ``job_id`` for progress).""" + body = build_body( + { + "platform": platform, + "type": type, + "stack": stack, + "gitRef": git_ref, + "appBuildSourceId": app_build_source_id, + "appCertificateId": app_certificate_id, + "appCertificateName": app_certificate_name, + "appChannelId": app_channel_id, + "appDestinationId": app_destination_id, + "appEnvironmentId": app_environment_id, + "appEnvironmentName": app_environment_name, + "adHocEnvironmentVariables": ad_hoc_environment_variables, + } + ) + return self._request_model("POST", self._base(app_id), AppBuild, json=body) + + def list( + self, + app_id: str, + *, + platform: Optional[str] = None, + app_automation_id: Optional[str] = None, + app_git_commit: Optional[str] = None, + query: Optional[str] = None, + relations: Optional[str] = None, + page_size: int = DEFAULT_PAGE_SIZE, + ) -> Paginator[AppBuild]: + """Iterate over all builds of an app.""" + params = build_params( + { + "platform": platform, + "appAutomationId": app_automation_id, + "appGitCommit": app_git_commit, + "query": query, + "relations": relations, + } + ) + return self._paginate(self._base(app_id), AppBuild, params=params, page_size=page_size) + + def list_page( + self, + app_id: str, + *, + limit: Optional[int] = None, + offset: Optional[int] = None, + platform: Optional[str] = None, + app_automation_id: Optional[str] = None, + app_git_commit: Optional[str] = None, + query: Optional[str] = None, + relations: Optional[str] = None, + ) -> List[AppBuild]: + """Fetch a single page of builds.""" + params = build_params( + { + "limit": limit, + "offset": offset, + "platform": platform, + "appAutomationId": app_automation_id, + "appGitCommit": app_git_commit, + "query": query, + "relations": relations, + } + ) + return self._list_page(self._base(app_id), AppBuild, params=params) + + def get(self, app_id: str, build_id: str, *, relations: Optional[str] = None) -> AppBuild: + """Retrieve a build by id.""" + params = build_params({"relations": relations}) + return self._request_model( + "GET", f"{self._base(app_id)}/{build_id}", AppBuild, params=params + ) + + def update( + self, + app_id: str, + build_id: str, + *, + display_name: Union[str, None, NotGiven] = NOT_GIVEN, + package_name: Union[str, None, NotGiven] = NOT_GIVEN, + package_version: Union[str, None, NotGiven] = NOT_GIVEN, + custom_properties: Union[Mapping[str, Any], None, NotGiven] = NOT_GIVEN, + ) -> AppBuild: + """Update build metadata.""" + body = build_body( + { + "displayName": display_name, + "packageName": package_name, + "packageVersion": package_version, + "customProperties": custom_properties, + } + ) + return self._request_model("PATCH", f"{self._base(app_id)}/{build_id}", AppBuild, json=body) + + +class BuildArtifactsResource(BaseResource): + def _base(self, app_id: str, build_id: str) -> str: + return f"/v1/apps/{app_id}/builds/{build_id}/artifacts" + + def list( + self, + app_id: str, + build_id: str, + *, + relations: Optional[str] = None, + page_size: int = DEFAULT_PAGE_SIZE, + ) -> Paginator[AppBuildArtifact]: + """Iterate over all artifacts of a build.""" + params = build_params({"relations": relations}) + return self._paginate( + self._base(app_id, build_id), AppBuildArtifact, params=params, page_size=page_size + ) + + def list_page( + self, + app_id: str, + build_id: str, + *, + limit: Optional[int] = None, + offset: Optional[int] = None, + relations: Optional[str] = None, + ) -> List[AppBuildArtifact]: + """Fetch a single page of build artifacts.""" + params = build_params({"limit": limit, "offset": offset, "relations": relations}) + return self._list_page(self._base(app_id, build_id), AppBuildArtifact, params=params) + + def get_download_url( + self, + app_id: str, + build_id: str, + artifact_id: str, + *, + file_name: Optional[str] = None, + ) -> Any: + """Return the signed download URL payload for an artifact.""" + params = build_params({"fileName": file_name}) + return self._http.request_json( + "GET", + f"{self._base(app_id, build_id)}/{artifact_id}/signed-download-url", + params=params, + ) + + def download( + self, + app_id: str, + build_id: str, + artifact_id: str, + *, + file_name: Optional[str] = None, + ) -> bytes: + """Download an artifact's bytes.""" + params = build_params({"fileName": file_name}) + return self._download( + f"{self._base(app_id, build_id)}/{artifact_id}/download", params=params + ) diff --git a/src/capawesome_cloud/resources/certificates.py b/src/capawesome_cloud/resources/certificates.py new file mode 100644 index 0000000..89e04ac --- /dev/null +++ b/src/capawesome_cloud/resources/certificates.py @@ -0,0 +1,153 @@ +"""Certificates resource (signing certificates / keystores).""" + +from __future__ import annotations + +import os +from pathlib import Path +from typing import IO, List, Optional, Union + +from .._types import NOT_GIVEN, NotGiven +from ..models import AppCertificate +from ..pagination import DEFAULT_PAGE_SIZE, Paginator +from ._base import BaseResource, build_body, build_params + +FileSource = Union[str, "os.PathLike[str]", bytes, IO[bytes]] + + +class CertificatesResource(BaseResource): + def _base(self, app_id: str) -> str: + return f"/v1/apps/{app_id}/certificates" + + def create( + self, + app_id: str, + *, + name: str, + file: FileSource, + file_name: Optional[str] = None, + platform: Union[str, None, NotGiven] = NOT_GIVEN, + type: Union[str, NotGiven] = NOT_GIVEN, + password: Union[str, NotGiven] = NOT_GIVEN, + key_alias: Union[str, NotGiven] = NOT_GIVEN, + key_password: Union[str, NotGiven] = NOT_GIVEN, + ) -> AppCertificate: + """Upload a signing certificate. + + ``file`` may be a path, raw ``bytes``, or an open binary file object. + ``type`` is ``development`` or ``production``. + """ + content, resolved_name = _read_file(file, file_name) + data = build_body( + { + "name": name, + "platform": platform, + "type": type, + "password": password, + "keyAlias": key_alias, + "keyPassword": key_password, + } + ) + files = {"file": (resolved_name, content)} + return self._request_model( + "POST", self._base(app_id), AppCertificate, data=data, files=files + ) + + def list( + self, + app_id: str, + *, + name: Optional[str] = None, + platform: Optional[str] = None, + type: Optional[str] = None, + query: Optional[str] = None, + relations: Optional[str] = None, + page_size: int = DEFAULT_PAGE_SIZE, + ) -> Paginator[AppCertificate]: + """Iterate over all certificates of an app.""" + params = build_params( + { + "name": name, + "platform": platform, + "type": type, + "query": query, + "relations": relations, + } + ) + return self._paginate( + self._base(app_id), AppCertificate, params=params, page_size=page_size + ) + + def list_page( + self, + app_id: str, + *, + limit: Optional[int] = None, + offset: Optional[int] = None, + name: Optional[str] = None, + platform: Optional[str] = None, + type: Optional[str] = None, + query: Optional[str] = None, + relations: Optional[str] = None, + ) -> List[AppCertificate]: + """Fetch a single page of certificates.""" + params = build_params( + { + "limit": limit, + "offset": offset, + "name": name, + "platform": platform, + "type": type, + "query": query, + "relations": relations, + } + ) + return self._list_page(self._base(app_id), AppCertificate, params=params) + + def get( + self, app_id: str, certificate_id: str, *, relations: Optional[str] = None + ) -> AppCertificate: + """Retrieve a certificate by id.""" + params = build_params({"relations": relations}) + return self._request_model( + "GET", f"{self._base(app_id)}/{certificate_id}", AppCertificate, params=params + ) + + def update( + self, + app_id: str, + certificate_id: str, + *, + name: Union[str, NotGiven] = NOT_GIVEN, + type: Union[str, NotGiven] = NOT_GIVEN, + password: Union[str, None, NotGiven] = NOT_GIVEN, + key_alias: Union[str, None, NotGiven] = NOT_GIVEN, + key_password: Union[str, None, NotGiven] = NOT_GIVEN, + ) -> AppCertificate: + """Update a certificate's metadata.""" + body = build_body( + { + "name": name, + "type": type, + "password": password, + "keyAlias": key_alias, + "keyPassword": key_password, + } + ) + return self._request_model( + "PATCH", f"{self._base(app_id)}/{certificate_id}", AppCertificate, json=body + ) + + def delete(self, app_id: str, certificate_id: str) -> None: + """Delete a certificate.""" + self._request_none("DELETE", f"{self._base(app_id)}/{certificate_id}") + + +def _read_file(file: FileSource, file_name: Optional[str]) -> tuple[bytes, str]: + if isinstance(file, (str, os.PathLike)): + path = Path(file) + return path.read_bytes(), file_name or path.name + if isinstance(file, bytes): + return file, file_name or "certificate" + content = file.read() + name = file_name or getattr(file, "name", None) or "certificate" + return content, os.path.basename(str(name)) diff --git a/src/capawesome_cloud/resources/channels.py b/src/capawesome_cloud/resources/channels.py new file mode 100644 index 0000000..5408e65 --- /dev/null +++ b/src/capawesome_cloud/resources/channels.py @@ -0,0 +1,104 @@ +"""Channels resource (Live Updates).""" + +from __future__ import annotations + +from datetime import datetime +from typing import List, Optional, Union + +from .._types import NOT_GIVEN, NotGiven +from ..models import AppChannel +from ..pagination import DEFAULT_PAGE_SIZE, Paginator +from ._base import BaseResource, build_body, build_params + + +class ChannelsResource(BaseResource): + def _base(self, app_id: str) -> str: + return f"/v1/apps/{app_id}/channels" + + def create( + self, + app_id: str, + *, + name: str, + protected: Union[bool, NotGiven] = NOT_GIVEN, + expires_at: Union[datetime, str, None, NotGiven] = NOT_GIVEN, + ) -> AppChannel: + """Create a channel.""" + body = build_body( + {"name": name, "protected": protected, "expiresAt": _isoformat(expires_at)} + ) + return self._request_model("POST", self._base(app_id), AppChannel, json=body) + + def list( + self, + app_id: str, + *, + name: Optional[str] = None, + query: Optional[str] = None, + relations: Optional[str] = None, + page_size: int = DEFAULT_PAGE_SIZE, + ) -> Paginator[AppChannel]: + """Iterate over all channels of an app.""" + params = build_params({"name": name, "query": query, "relations": relations}) + return self._paginate(self._base(app_id), AppChannel, params=params, page_size=page_size) + + def list_page( + self, + app_id: str, + *, + limit: Optional[int] = None, + offset: Optional[int] = None, + name: Optional[str] = None, + query: Optional[str] = None, + relations: Optional[str] = None, + ) -> List[AppChannel]: + """Fetch a single page of channels.""" + params = build_params( + { + "limit": limit, + "offset": offset, + "name": name, + "query": query, + "relations": relations, + } + ) + return self._list_page(self._base(app_id), AppChannel, params=params) + + def get(self, app_id: str, channel_id: str) -> AppChannel: + """Retrieve a channel by id.""" + return self._request_model("GET", f"{self._base(app_id)}/{channel_id}", AppChannel) + + def update( + self, + app_id: str, + channel_id: str, + *, + name: Union[str, NotGiven] = NOT_GIVEN, + protected: Union[bool, NotGiven] = NOT_GIVEN, + expires_at: Union[datetime, str, None, NotGiven] = NOT_GIVEN, + ) -> AppChannel: + """Update a channel.""" + body = build_body( + {"name": name, "protected": protected, "expiresAt": _isoformat(expires_at)} + ) + return self._request_model( + "PATCH", f"{self._base(app_id)}/{channel_id}", AppChannel, json=body + ) + + def delete(self, app_id: str, channel_id: str) -> None: + """Delete a channel.""" + self._request_none("DELETE", f"{self._base(app_id)}/{channel_id}") + + def pause(self, app_id: str, channel_id: str) -> None: + """Pause update delivery on a channel.""" + self._request_none("POST", f"{self._base(app_id)}/{channel_id}/pause") + + def resume(self, app_id: str, channel_id: str) -> None: + """Resume update delivery on a channel.""" + self._request_none("POST", f"{self._base(app_id)}/{channel_id}/resume") + + +def _isoformat(value: object) -> object: + if isinstance(value, datetime): + return value.isoformat() + return value diff --git a/src/capawesome_cloud/resources/deployments.py b/src/capawesome_cloud/resources/deployments.py new file mode 100644 index 0000000..bff29bc --- /dev/null +++ b/src/capawesome_cloud/resources/deployments.py @@ -0,0 +1,112 @@ +"""Deployments resource (publish a build to a channel or app-store destination).""" + +from __future__ import annotations + +from typing import List, Optional, Union + +from .._types import NOT_GIVEN, NotGiven +from ..models import AppDeployment +from ..pagination import DEFAULT_PAGE_SIZE, Paginator +from ._base import BaseResource, build_body, build_params + + +class DeploymentsResource(BaseResource): + def _base(self, app_id: str) -> str: + return f"/v1/apps/{app_id}/deployments" + + def create( + self, + app_id: str, + *, + app_build_id: str, + app_channel_id: Union[str, None, NotGiven] = NOT_GIVEN, + app_channel_name: Union[str, None, NotGiven] = NOT_GIVEN, + app_destination_id: Union[str, None, NotGiven] = NOT_GIVEN, + app_destination_name: Union[str, None, NotGiven] = NOT_GIVEN, + rollout_percentage: Union[float, None, NotGiven] = NOT_GIVEN, + ) -> AppDeployment: + """Create a deployment. Target a live-update channel and/or a destination.""" + body = build_body( + { + "appBuildId": app_build_id, + "appChannelId": app_channel_id, + "appChannelName": app_channel_name, + "appDestinationId": app_destination_id, + "appDestinationName": app_destination_name, + "rolloutPercentage": rollout_percentage, + } + ) + return self._request_model("POST", self._base(app_id), AppDeployment, json=body) + + def list( + self, + app_id: str, + *, + app_build_id: Optional[str] = None, + app_channel_id: Optional[str] = None, + app_destination_id: Optional[str] = None, + app_git_commit: Optional[str] = None, + query: Optional[str] = None, + relations: Optional[str] = None, + page_size: int = DEFAULT_PAGE_SIZE, + ) -> Paginator[AppDeployment]: + """Iterate over all deployments of an app.""" + params = build_params( + { + "appBuildId": app_build_id, + "appChannelId": app_channel_id, + "appDestinationId": app_destination_id, + "appGitCommit": app_git_commit, + "query": query, + "relations": relations, + } + ) + return self._paginate(self._base(app_id), AppDeployment, params=params, page_size=page_size) + + def list_page( + self, + app_id: str, + *, + limit: Optional[int] = None, + offset: Optional[int] = None, + app_build_id: Optional[str] = None, + app_channel_id: Optional[str] = None, + app_destination_id: Optional[str] = None, + app_git_commit: Optional[str] = None, + query: Optional[str] = None, + relations: Optional[str] = None, + ) -> List[AppDeployment]: + """Fetch a single page of deployments.""" + params = build_params( + { + "limit": limit, + "offset": offset, + "appBuildId": app_build_id, + "appChannelId": app_channel_id, + "appDestinationId": app_destination_id, + "appGitCommit": app_git_commit, + "query": query, + "relations": relations, + } + ) + return self._list_page(self._base(app_id), AppDeployment, params=params) + + def get( + self, app_id: str, deployment_id: str, *, relations: Optional[str] = None + ) -> AppDeployment: + """Retrieve a deployment by id.""" + params = build_params({"relations": relations}) + return self._request_model( + "GET", f"{self._base(app_id)}/{deployment_id}", AppDeployment, params=params + ) + + def update( + self, app_id: str, deployment_id: str, *, rollout_percentage: float + ) -> AppDeployment: + """Update a deployment's rollout percentage (0-1).""" + return self._request_model( + "PATCH", + f"{self._base(app_id)}/{deployment_id}", + AppDeployment, + json={"rolloutPercentage": rollout_percentage}, + ) diff --git a/src/capawesome_cloud/resources/destinations.py b/src/capawesome_cloud/resources/destinations.py new file mode 100644 index 0000000..3ae797d --- /dev/null +++ b/src/capawesome_cloud/resources/destinations.py @@ -0,0 +1,117 @@ +"""Destinations resource (App Store Publishing targets).""" + +from __future__ import annotations + +from typing import List, Optional + +from .._types import NotGiven +from ..models import AppDestination +from ..pagination import DEFAULT_PAGE_SIZE, Paginator +from ._base import BaseResource, build_params + +# Fields shared by create and update. +_WRITE_FIELDS = { + "name": "name", + "platform": "platform", + "android_build_artifact_type": "androidBuildArtifactType", + "android_package_name": "androidPackageName", + "android_release_status": "androidReleaseStatus", + "google_play_track": "googlePlayTrack", + "app_apple_api_key_id": "appAppleApiKeyId", + "app_google_service_account_key_id": "appGoogleServiceAccountKeyId", + "apple_api_key_id": "appleApiKeyId", + "apple_issuer_id": "appleIssuerId", + "apple_id": "appleId", + "apple_app_password": "appleAppPassword", + "apple_app_id": "appleAppId", + "apple_team_id": "appleTeamId", +} + + +class DestinationsResource(BaseResource): + def _base(self, app_id: str) -> str: + return f"/v1/apps/{app_id}/destinations" + + def create(self, app_id: str, *, name: str, **fields: object) -> AppDestination: + """Create a destination. + + ``name`` is required. Provide further fields by their snake_case names, + e.g. ``platform``, ``android_package_name``, ``apple_app_id``, + ``google_play_track`` (see the API reference for the full list). + """ + body = _map_write_fields({"name": name, **fields}) + return self._request_model("POST", self._base(app_id), AppDestination, json=body) + + def list( + self, + app_id: str, + *, + name: Optional[str] = None, + platform: Optional[str] = None, + query: Optional[str] = None, + relations: Optional[str] = None, + page_size: int = DEFAULT_PAGE_SIZE, + ) -> Paginator[AppDestination]: + """Iterate over all destinations of an app.""" + params = build_params( + {"name": name, "platform": platform, "query": query, "relations": relations} + ) + return self._paginate( + self._base(app_id), AppDestination, params=params, page_size=page_size + ) + + def list_page( + self, + app_id: str, + *, + limit: Optional[int] = None, + offset: Optional[int] = None, + name: Optional[str] = None, + platform: Optional[str] = None, + query: Optional[str] = None, + relations: Optional[str] = None, + ) -> List[AppDestination]: + """Fetch a single page of destinations.""" + params = build_params( + { + "limit": limit, + "offset": offset, + "name": name, + "platform": platform, + "query": query, + "relations": relations, + } + ) + return self._list_page(self._base(app_id), AppDestination, params=params) + + def get( + self, app_id: str, destination_id: str, *, relations: Optional[str] = None + ) -> AppDestination: + """Retrieve a destination by id.""" + params = build_params({"relations": relations}) + return self._request_model( + "GET", f"{self._base(app_id)}/{destination_id}", AppDestination, params=params + ) + + def update(self, app_id: str, destination_id: str, **fields: object) -> AppDestination: + """Update a destination. Provide fields by their snake_case names.""" + body = _map_write_fields(fields) + return self._request_model( + "PATCH", f"{self._base(app_id)}/{destination_id}", AppDestination, json=body + ) + + def delete(self, app_id: str, destination_id: str) -> None: + """Delete a destination.""" + self._request_none("DELETE", f"{self._base(app_id)}/{destination_id}") + + +def _map_write_fields(fields: dict[str, object]) -> dict[str, object]: + body: dict[str, object] = {} + for key, value in fields.items(): + if isinstance(value, NotGiven): + continue + api_key = _WRITE_FIELDS.get(key) + if api_key is None: + raise TypeError(f"Unknown destination field: {key!r}") + body[api_key] = value + return body diff --git a/src/capawesome_cloud/resources/devices.py b/src/capawesome_cloud/resources/devices.py new file mode 100644 index 0000000..b360d65 --- /dev/null +++ b/src/capawesome_cloud/resources/devices.py @@ -0,0 +1,68 @@ +"""Devices resource (Live Updates).""" + +from __future__ import annotations + +from typing import List, Optional, Union + +from .._types import NOT_GIVEN, NotGiven +from ..models import AppDevice +from ..pagination import DEFAULT_PAGE_SIZE, Paginator +from ._base import BaseResource, build_body, build_params + + +class DevicesResource(BaseResource): + def _base(self, app_id: str) -> str: + return f"/v1/apps/{app_id}/devices" + + def list( + self, + app_id: str, + *, + id: Optional[str] = None, + query: Optional[str] = None, + relations: Optional[str] = None, + page_size: int = DEFAULT_PAGE_SIZE, + ) -> Paginator[AppDevice]: + """Iterate over all devices of an app.""" + params = build_params({"id": id, "query": query, "relations": relations}) + return self._paginate(self._base(app_id), AppDevice, params=params, page_size=page_size) + + def list_page( + self, + app_id: str, + *, + limit: Optional[int] = None, + offset: Optional[int] = None, + id: Optional[str] = None, + query: Optional[str] = None, + relations: Optional[str] = None, + ) -> List[AppDevice]: + """Fetch a single page of devices.""" + params = build_params( + {"limit": limit, "offset": offset, "id": id, "query": query, "relations": relations} + ) + return self._list_page(self._base(app_id), AppDevice, params=params) + + def get(self, app_id: str, device_id: str, *, relations: Optional[str] = None) -> AppDevice: + """Retrieve a device by id.""" + params = build_params({"relations": relations}) + return self._request_model( + "GET", f"{self._base(app_id)}/{device_id}", AppDevice, params=params + ) + + def update( + self, + app_id: str, + device_id: str, + *, + forced_app_channel_id: Union[str, None, NotGiven] = NOT_GIVEN, + ) -> AppDevice: + """Update a device (e.g. pin it to a channel, or pass ``None`` to unpin).""" + body = build_body({"forcedAppChannelId": forced_app_channel_id}) + return self._request_model( + "PATCH", f"{self._base(app_id)}/{device_id}", AppDevice, json=body + ) + + def delete(self, app_id: str, device_id: str) -> None: + """Delete a device.""" + self._request_none("DELETE", f"{self._base(app_id)}/{device_id}") diff --git a/src/capawesome_cloud/resources/environments.py b/src/capawesome_cloud/resources/environments.py new file mode 100644 index 0000000..d0aed29 --- /dev/null +++ b/src/capawesome_cloud/resources/environments.py @@ -0,0 +1,153 @@ +"""Environments resource, including variables and secrets.""" + +from __future__ import annotations + +from typing import List, Optional, Union + +from .._http import HttpClient +from .._types import NOT_GIVEN, NotGiven +from ..models import AppEnvironment, AppEnvironmentSecret, AppEnvironmentVariable +from ..pagination import DEFAULT_PAGE_SIZE, Paginator +from ._base import BaseResource, build_body, build_params + + +class EnvironmentsResource(BaseResource): + def __init__(self, http: HttpClient) -> None: + super().__init__(http) + self.variables = EnvironmentVariablesResource(http) + self.secrets = EnvironmentSecretsResource(http) + + def _base(self, app_id: str) -> str: + return f"/v1/apps/{app_id}/environments" + + def create(self, app_id: str, *, name: str) -> AppEnvironment: + """Create an environment.""" + return self._request_model("POST", self._base(app_id), AppEnvironment, json={"name": name}) + + def list( + self, + app_id: str, + *, + query: Optional[str] = None, + relations: Optional[str] = None, + page_size: int = DEFAULT_PAGE_SIZE, + ) -> Paginator[AppEnvironment]: + """Iterate over all environments of an app.""" + params = build_params({"query": query, "relations": relations}) + return self._paginate( + self._base(app_id), AppEnvironment, params=params, page_size=page_size + ) + + def list_page( + self, + app_id: str, + *, + limit: Optional[int] = None, + offset: Optional[int] = None, + query: Optional[str] = None, + relations: Optional[str] = None, + ) -> List[AppEnvironment]: + """Fetch a single page of environments.""" + params = build_params( + {"limit": limit, "offset": offset, "query": query, "relations": relations} + ) + return self._list_page(self._base(app_id), AppEnvironment, params=params) + + def get( + self, app_id: str, environment_id: str, *, relations: Optional[str] = None + ) -> AppEnvironment: + """Retrieve an environment by id.""" + params = build_params({"relations": relations}) + return self._request_model( + "GET", f"{self._base(app_id)}/{environment_id}", AppEnvironment, params=params + ) + + def update(self, app_id: str, environment_id: str, *, name: str) -> AppEnvironment: + """Update an environment.""" + return self._request_model( + "PATCH", f"{self._base(app_id)}/{environment_id}", AppEnvironment, json={"name": name} + ) + + +class EnvironmentVariablesResource(BaseResource): + def _base(self, app_id: str, environment_id: str) -> str: + return f"/v1/apps/{app_id}/environments/{environment_id}/variables" + + def create( + self, app_id: str, environment_id: str, *, key: str, value: str + ) -> AppEnvironmentVariable: + """Create an environment variable.""" + return self._request_model( + "POST", + self._base(app_id, environment_id), + AppEnvironmentVariable, + json={"key": key, "value": value}, + ) + + def list(self, app_id: str, environment_id: str) -> List[AppEnvironmentVariable]: + """List all variables of an environment.""" + return self._list_page(self._base(app_id, environment_id), AppEnvironmentVariable) + + def update( + self, + app_id: str, + environment_id: str, + variable_id: str, + *, + key: Union[str, NotGiven] = NOT_GIVEN, + value: Union[str, NotGiven] = NOT_GIVEN, + ) -> AppEnvironmentVariable: + """Update an environment variable.""" + body = build_body({"key": key, "value": value}) + return self._request_model( + "PATCH", + f"{self._base(app_id, environment_id)}/{variable_id}", + AppEnvironmentVariable, + json=body, + ) + + def delete(self, app_id: str, environment_id: str, variable_id: str) -> None: + """Delete an environment variable.""" + self._request_none("DELETE", f"{self._base(app_id, environment_id)}/{variable_id}") + + +class EnvironmentSecretsResource(BaseResource): + def _base(self, app_id: str, environment_id: str) -> str: + return f"/v1/apps/{app_id}/environments/{environment_id}/secrets" + + def create( + self, app_id: str, environment_id: str, *, key: str, value: str + ) -> AppEnvironmentSecret: + """Create an environment secret.""" + return self._request_model( + "POST", + self._base(app_id, environment_id), + AppEnvironmentSecret, + json={"key": key, "value": value}, + ) + + def list(self, app_id: str, environment_id: str) -> List[AppEnvironmentSecret]: + """List all secrets of an environment (values are not returned).""" + return self._list_page(self._base(app_id, environment_id), AppEnvironmentSecret) + + def update( + self, + app_id: str, + environment_id: str, + secret_id: str, + *, + key: Union[str, NotGiven] = NOT_GIVEN, + value: Union[str, NotGiven] = NOT_GIVEN, + ) -> AppEnvironmentSecret: + """Update an environment secret.""" + body = build_body({"key": key, "value": value}) + return self._request_model( + "PATCH", + f"{self._base(app_id, environment_id)}/{secret_id}", + AppEnvironmentSecret, + json=body, + ) + + def delete(self, app_id: str, environment_id: str, secret_id: str) -> None: + """Delete an environment secret.""" + self._request_none("DELETE", f"{self._base(app_id, environment_id)}/{secret_id}") diff --git a/src/capawesome_cloud/resources/jobs.py b/src/capawesome_cloud/resources/jobs.py new file mode 100644 index 0000000..a52d2c5 --- /dev/null +++ b/src/capawesome_cloud/resources/jobs.py @@ -0,0 +1,91 @@ +"""Jobs resource (track async builds and deployments).""" + +from __future__ import annotations + +import time +from typing import List, Optional + +from ..exceptions import CapawesomeCloudError +from ..models import Job +from ..pagination import DEFAULT_PAGE_SIZE, Paginator +from ._base import BaseResource, build_params + +#: Statuses that mean a job has stopped running. +TERMINAL_STATUSES = frozenset({"succeeded", "failed", "canceled", "rejected", "timed_out"}) + + +class JobTimeoutError(CapawesomeCloudError): + """Raised when :meth:`JobsResource.wait` exceeds its timeout.""" + + +class JobsResource(BaseResource): + _path = "/v1/jobs" + + def list( + self, + *, + organization_id: str, + status: Optional[str] = None, + relations: Optional[str] = None, + page_size: int = DEFAULT_PAGE_SIZE, + ) -> Paginator[Job]: + """Iterate over all jobs of an organization.""" + params = build_params( + {"organizationId": organization_id, "status": status, "relations": relations} + ) + return self._paginate(self._path, Job, params=params, page_size=page_size) + + def list_page( + self, + *, + organization_id: str, + limit: Optional[int] = None, + offset: Optional[int] = None, + status: Optional[str] = None, + relations: Optional[str] = None, + ) -> List[Job]: + """Fetch a single page of jobs.""" + params = build_params( + { + "organizationId": organization_id, + "limit": limit, + "offset": offset, + "status": status, + "relations": relations, + } + ) + return self._list_page(self._path, Job, params=params) + + def get(self, job_id: str, *, relations: Optional[str] = None) -> Job: + """Retrieve a job by id.""" + params = build_params({"relations": relations}) + return self._request_model("GET", f"{self._path}/{job_id}", Job, params=params) + + def logs(self, job_id: str) -> str: + """Return the job's logs as text.""" + response = self._http.request("GET", f"{self._path}/{job_id}/logs") + return response.text + + def wait( + self, + job_id: str, + *, + poll_interval: float = 5.0, + timeout: Optional[float] = 600.0, + ) -> Job: + """Poll a job until it reaches a terminal status and return it. + + Raises :class:`JobTimeoutError` if ``timeout`` seconds elapse first. + Pass ``timeout=None`` to wait indefinitely. + """ + deadline = None if timeout is None else time.monotonic() + timeout + while True: + job = self.get(job_id) + if job.status in TERMINAL_STATUSES: + return job + if deadline is not None and time.monotonic() + poll_interval > deadline: + raise JobTimeoutError( + f"Job {job_id} did not finish within {timeout} seconds " + f"(last status: {job.status})." + ) + time.sleep(poll_interval) diff --git a/src/capawesome_cloud/resources/webhooks.py b/src/capawesome_cloud/resources/webhooks.py new file mode 100644 index 0000000..0dc5703 --- /dev/null +++ b/src/capawesome_cloud/resources/webhooks.py @@ -0,0 +1,95 @@ +"""Webhooks resource.""" + +from __future__ import annotations + +from typing import List, Optional, Sequence, Union + +from .._types import NOT_GIVEN, NotGiven +from ..models import AppWebhook +from ..pagination import DEFAULT_PAGE_SIZE, Paginator +from ._base import BaseResource, build_body, build_params + + +class WebhooksResource(BaseResource): + def _base(self, app_id: str) -> str: + return f"/v1/apps/{app_id}/webhooks" + + def create( + self, + app_id: str, + *, + name: str, + url: str, + events: Sequence[str], + format: Union[str, NotGiven] = NOT_GIVEN, + signing_secret: Union[str, NotGiven] = NOT_GIVEN, + ) -> AppWebhook: + """Create a webhook. ``format`` is one of ``raw``, ``discord``, ``slack``, ``teams``.""" + body = build_body( + { + "name": name, + "url": url, + "events": list(events), + "format": format, + "signingSecret": signing_secret, + } + ) + return self._request_model("POST", self._base(app_id), AppWebhook, json=body) + + def list( + self, + app_id: str, + *, + query: Optional[str] = None, + page_size: int = DEFAULT_PAGE_SIZE, + ) -> Paginator[AppWebhook]: + """Iterate over all webhooks of an app.""" + params = build_params({"query": query}) + return self._paginate(self._base(app_id), AppWebhook, params=params, page_size=page_size) + + def list_page( + self, + app_id: str, + *, + limit: Optional[int] = None, + offset: Optional[int] = None, + query: Optional[str] = None, + ) -> List[AppWebhook]: + """Fetch a single page of webhooks.""" + params = build_params({"limit": limit, "offset": offset, "query": query}) + return self._list_page(self._base(app_id), AppWebhook, params=params) + + def get(self, app_id: str, webhook_id: str) -> AppWebhook: + """Retrieve a webhook by id.""" + return self._request_model("GET", f"{self._base(app_id)}/{webhook_id}", AppWebhook) + + def update( + self, + app_id: str, + webhook_id: str, + *, + name: Union[str, NotGiven] = NOT_GIVEN, + url: Union[str, NotGiven] = NOT_GIVEN, + events: Union[Sequence[str], NotGiven] = NOT_GIVEN, + format: Union[str, NotGiven] = NOT_GIVEN, + enabled: Union[bool, NotGiven] = NOT_GIVEN, + signing_secret: Union[str, None, NotGiven] = NOT_GIVEN, + ) -> AppWebhook: + """Update a webhook.""" + body = build_body( + { + "name": name, + "url": url, + "events": list(events) if not isinstance(events, NotGiven) else events, + "format": format, + "enabled": enabled, + "signingSecret": signing_secret, + } + ) + return self._request_model( + "PATCH", f"{self._base(app_id)}/{webhook_id}", AppWebhook, json=body + ) + + def delete(self, app_id: str, webhook_id: str) -> None: + """Delete a webhook.""" + self._request_none("DELETE", f"{self._base(app_id)}/{webhook_id}") diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..b938f69 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,17 @@ +from __future__ import annotations + +import pytest + +from capawesome_cloud import CapawesomeCloud +from capawesome_cloud._http import DEFAULT_BASE_URL + +BASE_URL = DEFAULT_BASE_URL + + +@pytest.fixture +def client() -> CapawesomeCloud: + return CapawesomeCloud( + token="test-token", + max_retries=0, + backoff_factor=0.0, + ) diff --git a/tests/test_client.py b/tests/test_client.py new file mode 100644 index 0000000..da0d8f2 --- /dev/null +++ b/tests/test_client.py @@ -0,0 +1,31 @@ +from __future__ import annotations + +import httpx +import pytest + +from capawesome_cloud import CapawesomeCloud, CapawesomeCloudError + + +def test_token_from_env(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("CAPAWESOME_CLOUD_TOKEN", "from-env") + client = CapawesomeCloud() + assert client._http._client.headers["Authorization"] == "Bearer from-env" + + +def test_missing_token_raises(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("CAPAWESOME_CLOUD_TOKEN", raising=False) + with pytest.raises(CapawesomeCloudError, match="No API token"): + CapawesomeCloud() + + +def test_sets_auth_and_user_agent_headers() -> None: + client = CapawesomeCloud(token="abc") + headers = client._http._client.headers + assert headers["Authorization"] == "Bearer abc" + assert headers["User-Agent"].startswith("capawesome-cloud-python/") + + +def test_context_manager_closes() -> None: + with CapawesomeCloud(token="abc") as client: + underlying: httpx.Client = client._http._client + assert underlying.is_closed diff --git a/tests/test_errors_and_retries.py b/tests/test_errors_and_retries.py new file mode 100644 index 0000000..78558ac --- /dev/null +++ b/tests/test_errors_and_retries.py @@ -0,0 +1,57 @@ +from __future__ import annotations + +import httpx +import pytest +import respx + +from capawesome_cloud import ( + AuthenticationError, + CapawesomeCloud, + InternalServerError, + NotFoundError, +) +from capawesome_cloud._http import DEFAULT_BASE_URL as BASE_URL + + +@respx.mock +def test_error_message_is_extracted(client: CapawesomeCloud) -> None: + respx.get(f"{BASE_URL}/v1/apps/missing").mock( + return_value=httpx.Response(404, json={"message": "App not found."}) + ) + with pytest.raises(NotFoundError) as excinfo: + client.apps.get("missing") + assert excinfo.value.message == "App not found." + assert excinfo.value.status_code == 404 + + +@respx.mock +def test_401_maps_to_authentication_error(client: CapawesomeCloud) -> None: + respx.get(f"{BASE_URL}/v1/apps").mock( + return_value=httpx.Response(401, json={"message": "Not authenticated."}) + ) + with pytest.raises(AuthenticationError): + client.apps.list_page() + + +@respx.mock +def test_retries_on_429_then_succeeds() -> None: + client = CapawesomeCloud(token="t", max_retries=2, backoff_factor=0.0) + route = respx.get(f"{BASE_URL}/v1/apps") + route.side_effect = [ + httpx.Response(429, json={"message": "slow down"}), + httpx.Response(200, json=[{"id": "a1", "name": "App"}]), + ] + apps = client.apps.list_page() + assert route.call_count == 2 + assert [a.id for a in apps] == ["a1"] + + +@respx.mock +def test_gives_up_after_max_retries() -> None: + client = CapawesomeCloud(token="t", max_retries=1, backoff_factor=0.0) + route = respx.get(f"{BASE_URL}/v1/apps").mock( + return_value=httpx.Response(503, json={"message": "unavailable"}) + ) + with pytest.raises(InternalServerError): + client.apps.list_page() + assert route.call_count == 2 # initial + 1 retry diff --git a/tests/test_pagination.py b/tests/test_pagination.py new file mode 100644 index 0000000..756b24d --- /dev/null +++ b/tests/test_pagination.py @@ -0,0 +1,51 @@ +from __future__ import annotations + +import httpx +import respx + +from capawesome_cloud import CapawesomeCloud +from capawesome_cloud._http import DEFAULT_BASE_URL as BASE_URL + + +@respx.mock +def test_paginator_walks_all_pages(client: CapawesomeCloud) -> None: + def responder(request: httpx.Request) -> httpx.Response: + offset = int(request.url.params.get("offset", "0")) + if offset == 0: + items = [{"id": f"a{i}"} for i in range(2)] + elif offset == 2: + items = [{"id": "a2"}] # short page -> last + else: + items = [] + return httpx.Response(200, json=items) + + respx.get(f"{BASE_URL}/v1/apps").mock(side_effect=responder) + + ids = [app.id for app in client.apps.list(page_size=2)] + assert ids == ["a0", "a1", "a2"] + + +@respx.mock +def test_paginator_stops_on_empty_page(client: CapawesomeCloud) -> None: + def responder(request: httpx.Request) -> httpx.Response: + offset = int(request.url.params.get("offset", "0")) + # Exactly page_size on first page, then empty -> must stop. + items = [{"id": "a0"}, {"id": "a1"}] if offset == 0 else [] + return httpx.Response(200, json=items) + + route = respx.get(f"{BASE_URL}/v1/apps").mock(side_effect=responder) + + ids = [app.id for app in client.apps.list(page_size=2)] + assert ids == ["a0", "a1"] + assert route.call_count == 2 + + +@respx.mock +def test_list_page_single_request(client: CapawesomeCloud) -> None: + route = respx.get(f"{BASE_URL}/v1/apps").mock( + return_value=httpx.Response(200, json=[{"id": "a0"}, {"id": "a1"}]) + ) + apps = client.apps.list_page(limit=2) + assert [a.id for a in apps] == ["a0", "a1"] + assert route.call_count == 1 + assert route.calls.last.request.url.params["limit"] == "2" diff --git a/tests/test_resources.py b/tests/test_resources.py new file mode 100644 index 0000000..40f71c6 --- /dev/null +++ b/tests/test_resources.py @@ -0,0 +1,92 @@ +from __future__ import annotations + +import httpx +import respx + +from capawesome_cloud import CapawesomeCloud +from capawesome_cloud._http import DEFAULT_BASE_URL as BASE_URL + + +@respx.mock +def test_create_channel_sends_body_and_parses(client: CapawesomeCloud) -> None: + route = respx.post(f"{BASE_URL}/v1/apps/app1/channels").mock( + return_value=httpx.Response(201, json={"id": "ch1", "name": "production", "appId": "app1"}) + ) + channel = client.apps.channels.create("app1", name="production", protected=True) + assert route.calls.last.request.read() == b'{"name":"production","protected":true}' + assert channel.id == "ch1" + assert channel.name == "production" + assert channel.app_id == "app1" + + +@respx.mock +def test_not_given_fields_are_omitted_but_none_is_sent(client: CapawesomeCloud) -> None: + route = respx.patch(f"{BASE_URL}/v1/apps/app1/devices/dev1").mock( + return_value=httpx.Response(200, json={"id": "dev1", "appId": "app1"}) + ) + # Explicit None must be sent (to clear the field). + client.apps.devices.update("app1", "dev1", forced_app_channel_id=None) + assert route.calls.last.request.read() == b'{"forcedAppChannelId":null}' + + +@respx.mock +def test_update_with_no_args_sends_empty_body(client: CapawesomeCloud) -> None: + route = respx.patch(f"{BASE_URL}/v1/apps/app1/devices/dev1").mock( + return_value=httpx.Response(200, json={"id": "dev1"}) + ) + client.apps.devices.update("app1", "dev1") + assert route.calls.last.request.read() == b"{}" + + +@respx.mock +def test_pause_channel_no_content(client: CapawesomeCloud) -> None: + route = respx.post(f"{BASE_URL}/v1/apps/app1/channels/ch1/pause").mock( + return_value=httpx.Response(204) + ) + assert client.apps.channels.pause("app1", "ch1") is None + assert route.called + + +@respx.mock +def test_delete_returns_none(client: CapawesomeCloud) -> None: + respx.delete(f"{BASE_URL}/v1/apps/app1").mock(return_value=httpx.Response(204)) + assert client.apps.delete("app1") is None + + +@respx.mock +def test_deployment_targets_channel(client: CapawesomeCloud) -> None: + route = respx.post(f"{BASE_URL}/v1/apps/app1/deployments").mock( + return_value=httpx.Response(201, json={"id": "dep1", "appBuildId": "b1"}) + ) + deployment = client.apps.deployments.create( + "app1", app_build_id="b1", app_channel_name="production" + ) + body = route.calls.last.request.read() + assert b'"appBuildId":"b1"' in body + assert b'"appChannelName":"production"' in body + assert deployment.app_build_id == "b1" + + +@respx.mock +def test_artifact_download_returns_bytes(client: CapawesomeCloud) -> None: + respx.get(f"{BASE_URL}/v1/apps/app1/builds/b1/artifacts/a1/download").mock( + return_value=httpx.Response(200, content=b"BINARY") + ) + data = client.apps.builds.artifacts.download("app1", "b1", "a1") + assert data == b"BINARY" + + +@respx.mock +def test_certificate_upload_is_multipart(client: CapawesomeCloud) -> None: + route = respx.post(f"{BASE_URL}/v1/apps/app1/certificates").mock( + return_value=httpx.Response(201, json={"id": "cert1", "name": "Prod"}) + ) + cert = client.apps.certificates.create( + "app1", name="Prod", file=b"KEYSTORE", file_name="keystore.jks", type="production" + ) + request = route.calls.last.request + assert request.headers["content-type"].startswith("multipart/form-data") + body = request.read() + assert b"KEYSTORE" in body + assert b'name="name"' in body + assert cert.id == "cert1" From ce84eabd546ff219fe171b6201d190e06f96a9a3 Mon Sep 17 00:00:00 2001 From: Robin Genz Date: Tue, 2 Jun 2026 08:13:12 +0200 Subject: [PATCH 2/5] Add GH issue templates --- .github/ISSUE_TEMPLATE/bug_report.yml | 54 ++++++++++++++++++++++ .github/ISSUE_TEMPLATE/config.yml | 4 ++ .github/ISSUE_TEMPLATE/feature_request.yml | 37 +++++++++++++++ 3 files changed, 95 insertions(+) create mode 100644 .github/ISSUE_TEMPLATE/bug_report.yml create mode 100644 .github/ISSUE_TEMPLATE/config.yml create mode 100644 .github/ISSUE_TEMPLATE/feature_request.yml diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..ee78af2 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,54 @@ +name: 🚨 Bug report +title: 'bug: ' +description: Create a bug report to help us improve +labels: ['bug/fix', 'needs: triage'] + +body: + - type: input + attributes: + label: Version + description: | + Let us know which version of the SDK you are using. You can find it by running `pip show capawesome-cloud`. Please make sure you are using the latest version before reporting an issue. Chances are that the bug you discovered has already been fixed in a subsequent version. + placeholder: 0.1.0 + validations: + required: true + - type: textarea + attributes: + label: Current behavior + description: A concise description of what you're experiencing. + validations: + required: true + - type: textarea + attributes: + label: Expected behavior + description: A concise description of what you expected to happen. + validations: + required: true + - type: textarea + attributes: + label: Steps to reproduce + description: Steps to reproduce the behaviour using the provided example. + placeholder: | + 1. In this environment... + 2. With this config... + 3. Run '...' + 4. See error... + validations: + required: true + - type: textarea + attributes: + label: Other information + description: List any other information that is relevant to your issue. Operating system, Python version, etc. + - type: checkboxes + attributes: + label: Before submitting + description: | + A well-written bug report allows the maintainers to quickly recreate the necessary conditions to inspect the bug and quickly find its root cause. + Please ensure your bug report fulfills all of the following requirements. + options: + - label: I have read and followed the [bug report guidelines](https://capawesome.io/contributing/bug-reports/). + required: true + - label: I have attached links to possibly related issues and discussions. + required: true + - label: I understand that incomplete issues (e.g. without reproduction) are closed. + required: true diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..8d4127c --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,4 @@ +contact_links: + - name: ❓ Support question + url: https://github.com/capawesome-team/cloud-python/discussions + about: This issue tracker is not for support questions. Please post your question on GitHub Discussions. diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..51aac22 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,37 @@ +name: ⚡️ Feature request +title: 'feat: ' +description: Suggest an idea for this project +labels: ['feature', 'needs: triage'] + +body: + - type: textarea + attributes: + label: Current problem + description: A clear and concise description of what the problem is. + placeholder: I'm always frustrated when [...] + validations: + required: true + - type: textarea + attributes: + label: Preferred solution + description: A clear and concise description of what you want to happen. + validations: + required: true + - type: textarea + attributes: + label: Alternative options + description: A clear and concise description of any alternative solutions or features you've considered. + - type: textarea + attributes: + label: Additional context + description: Add any other context or screenshots about the feature request here. + - type: checkboxes + attributes: + label: Before submitting + description: | + Please ensure your idea fulfills all of the following requirements. + options: + - label: I have read and followed the [feature request guidelines](https://capawesome.io/contributing/feature-requests/). + required: true + - label: I have attached links to possibly related issues and discussions. + required: true From 5f99c2ade0230a2fcf9a6e38ed29ad8dbd7dd2b0 Mon Sep 17 00:00:00 2001 From: Robin Genz Date: Tue, 2 Jun 2026 08:22:48 +0200 Subject: [PATCH 3/5] Resolve comments --- src/capawesome_cloud/_http.py | 19 +++++++++++++++- src/capawesome_cloud/pagination.py | 7 +++--- src/capawesome_cloud/resources/jobs.py | 4 ++++ tests/test_errors_and_retries.py | 27 +++++++++++++++++++++++ tests/test_jobs.py | 30 ++++++++++++++++++++++++++ 5 files changed, 83 insertions(+), 4 deletions(-) create mode 100644 tests/test_jobs.py diff --git a/src/capawesome_cloud/_http.py b/src/capawesome_cloud/_http.py index ec77a63..3e6197e 100644 --- a/src/capawesome_cloud/_http.py +++ b/src/capawesome_cloud/_http.py @@ -10,6 +10,8 @@ from __future__ import annotations import time +from datetime import datetime, timezone +from email.utils import parsedate_to_datetime from typing import Any, Mapping, Optional import httpx @@ -151,9 +153,24 @@ def _drop_none(mapping: Mapping[str, Any]) -> dict[str, Any]: def _parse_retry_after(value: Optional[str]) -> Optional[float]: + """Parse a ``Retry-After`` header value into a delay in seconds. + + Per RFC 9110 the value is either a number of seconds or an HTTP-date. + """ if not value: return None + value = value.strip() try: - return float(value) + return max(0.0, float(value)) except ValueError: + pass + try: + retry_at = parsedate_to_datetime(value) + except (TypeError, ValueError): + return None + if retry_at is None: return None + if retry_at.tzinfo is None: + retry_at = retry_at.replace(tzinfo=timezone.utc) + delay = (retry_at - datetime.now(timezone.utc)).total_seconds() + return max(0.0, delay) diff --git a/src/capawesome_cloud/pagination.py b/src/capawesome_cloud/pagination.py index 1a97a6d..2c8e587 100644 --- a/src/capawesome_cloud/pagination.py +++ b/src/capawesome_cloud/pagination.py @@ -7,6 +7,7 @@ from __future__ import annotations +from collections import deque from typing import Any, Callable, Iterator, TypeVar from .models import CapawesomeModel @@ -24,7 +25,7 @@ class Paginator(Iterator[T]): Iterate it directly to transparently page through the full result set:: - for channel in client.channels.list(app_id="..."): + for channel in client.apps.channels.list(app_id="..."): ... """ @@ -42,7 +43,7 @@ def __init__( self._model = model self._page_size = page_size self._offset = start_offset - self._buffer: list[Any] = [] + self._buffer: deque[Any] = deque() self._exhausted = False def __iter__(self) -> Paginator[T]: @@ -53,7 +54,7 @@ def __next__(self) -> T: if self._exhausted: raise StopIteration self._load_next_page() - item = self._buffer.pop(0) + item = self._buffer.popleft() return self._model.model_validate(item) def _load_next_page(self) -> None: diff --git a/src/capawesome_cloud/resources/jobs.py b/src/capawesome_cloud/resources/jobs.py index a52d2c5..d3d337a 100644 --- a/src/capawesome_cloud/resources/jobs.py +++ b/src/capawesome_cloud/resources/jobs.py @@ -78,6 +78,10 @@ def wait( Raises :class:`JobTimeoutError` if ``timeout`` seconds elapse first. Pass ``timeout=None`` to wait indefinitely. """ + if poll_interval <= 0: + raise ValueError("poll_interval must be > 0") + if timeout is not None and timeout < 0: + raise ValueError("timeout must be >= 0 or None") deadline = None if timeout is None else time.monotonic() + timeout while True: job = self.get(job_id) diff --git a/tests/test_errors_and_retries.py b/tests/test_errors_and_retries.py index 78558ac..a713633 100644 --- a/tests/test_errors_and_retries.py +++ b/tests/test_errors_and_retries.py @@ -1,5 +1,8 @@ from __future__ import annotations +from datetime import datetime, timedelta, timezone +from email.utils import format_datetime + import httpx import pytest import respx @@ -11,6 +14,7 @@ NotFoundError, ) from capawesome_cloud._http import DEFAULT_BASE_URL as BASE_URL +from capawesome_cloud._http import _parse_retry_after @respx.mock @@ -55,3 +59,26 @@ def test_gives_up_after_max_retries() -> None: with pytest.raises(InternalServerError): client.apps.list_page() assert route.call_count == 2 # initial + 1 retry + + +def test_parse_retry_after_seconds() -> None: + assert _parse_retry_after("5") == 5.0 + assert _parse_retry_after(" 5 ") == 5.0 + # Negative seconds are clamped to zero. + assert _parse_retry_after("-3") == 0.0 + + +def test_parse_retry_after_http_date() -> None: + future = datetime.now(timezone.utc) + timedelta(seconds=30) + delay = _parse_retry_after(format_datetime(future)) + assert delay is not None + assert 25 <= delay <= 31 + # A date in the past clamps to zero rather than going negative. + past = datetime.now(timezone.utc) - timedelta(seconds=30) + assert _parse_retry_after(format_datetime(past)) == 0.0 + + +def test_parse_retry_after_invalid() -> None: + assert _parse_retry_after(None) is None + assert _parse_retry_after("") is None + assert _parse_retry_after("not-a-date") is None diff --git a/tests/test_jobs.py b/tests/test_jobs.py new file mode 100644 index 0000000..b4cae81 --- /dev/null +++ b/tests/test_jobs.py @@ -0,0 +1,30 @@ +from __future__ import annotations + +import httpx +import pytest +import respx + +from capawesome_cloud import CapawesomeCloud +from capawesome_cloud._http import DEFAULT_BASE_URL as BASE_URL + + +def test_wait_rejects_non_positive_poll_interval(client: CapawesomeCloud) -> None: + with pytest.raises(ValueError, match="poll_interval"): + client.jobs.wait("job1", poll_interval=0) + + +def test_wait_rejects_negative_timeout(client: CapawesomeCloud) -> None: + with pytest.raises(ValueError, match="timeout"): + client.jobs.wait("job1", timeout=-1) + + +@respx.mock +def test_wait_returns_on_terminal_status(client: CapawesomeCloud) -> None: + route = respx.get(f"{BASE_URL}/v1/jobs/job1") + route.side_effect = [ + httpx.Response(200, json={"id": "job1", "status": "in_progress"}), + httpx.Response(200, json={"id": "job1", "status": "succeeded"}), + ] + job = client.jobs.wait("job1", poll_interval=0.01, timeout=5) + assert job.status == "succeeded" + assert route.call_count == 2 From 645aed990aeb923b331d6ea7922f561934755035 Mon Sep 17 00:00:00 2001 From: Robin Genz Date: Tue, 2 Jun 2026 09:08:16 +0200 Subject: [PATCH 4/5] feat: simplify error model and harden request handling - Replace the per-status exception subclasses with a single `CapawesomeCloudError` (status, status_text, message, body), matching the Node SDK; keep `APIConnectionError`/`APITimeoutError` for transport failures. - Extract messages from validation (`error[]` / `error.issues`) and plain-text error bodies. - Retry network and `5xx` failures only for idempotent methods; retry `429` for any method. - Trim the API token and accept `CAPAWESOME_TOKEN` as a fallback env var. --- README.md | 26 ++--- src/capawesome_cloud/__init__.py | 18 --- src/capawesome_cloud/_http.py | 32 +++++- src/capawesome_cloud/client.py | 18 ++- src/capawesome_cloud/exceptions.py | 176 ++++++++++++++--------------- tests/test_client.py | 23 ++++ tests/test_errors_and_retries.py | 121 ++++++++++++++++++-- 7 files changed, 268 insertions(+), 146 deletions(-) diff --git a/README.md b/README.md index b6398ff..741b93a 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ pip install capawesome-cloud ## Getting started -Create an API token in the [Capawesome Cloud Console](https://console.cloud.capawesome.io/settings/tokens) and pass it to the client (or set the `CAPAWESOME_CLOUD_TOKEN` environment variable): +Create an API token in the [Capawesome Cloud Console](https://console.cloud.capawesome.io/settings/tokens) and pass it to the client (or set the `CAPAWESOME_CLOUD_TOKEN` environment variable, with `CAPAWESOME_TOKEN` accepted as a fallback): ```python from capawesome_cloud import CapawesomeCloud @@ -50,7 +50,7 @@ with CapawesomeCloud(token="cap_...") as client: | `token` | `str` | `CAPAWESOME_CLOUD_TOKEN` env var | API token used to authenticate. | | `base_url` | `str` | `https://api.cloud.capawesome.io` | Base URL of the API (for self-hosting/testing). | | `timeout` | `float` | `30.0` | Request timeout in seconds. | -| `max_retries` | `int` | `2` | Retries for `429` / `5xx` responses (exponential backoff). | +| `max_retries` | `int` | `2` | Retries with exponential backoff. `429` is retried for any request; network/`5xx` failures are retried only for idempotent methods (GET/PUT/DELETE), never `POST`/`PATCH`. | | `backoff_factor` | `float` | `0.5` | Base delay for the retry backoff. | | `http_client` | `httpx.Client` | `None` | Bring your own pre-configured `httpx.Client`. | @@ -218,33 +218,23 @@ client.apps.devices.update(app_id, device_id, forced_app_channel_id=None) ## Error handling -Any non-2xx response is raised as a `CapawesomeCloudError`. HTTP errors map to specific subclasses so you can catch exactly what you need: +Any non-2xx response is raised as a `CapawesomeCloudError`, carrying the HTTP `status`, the `message` from the API, and the raw `body`: ```python -from capawesome_cloud import CapawesomeCloud, NotFoundError +from capawesome_cloud import CapawesomeCloud, CapawesomeCloudError client = CapawesomeCloud(token="cap_...") try: client.apps.get("unknown") -except NotFoundError as error: - print(error.status_code) # 404 +except CapawesomeCloudError as error: + print(error.status) # 404 + print(error.status_text) # "Not Found" print(error.message) # "App not found." print(error.body) # {"message": "App not found."} ``` -| Status | Exception | -| ------ | -------------------------- | -| 400 | `BadRequestError` | -| 401 | `AuthenticationError` | -| 403 | `PermissionDeniedError` | -| 404 | `NotFoundError` | -| 409 | `ConflictError` | -| 422 | `UnprocessableEntityError` | -| 429 | `RateLimitError` | -| 5xx | `InternalServerError` | - -All of the above derive from `APIStatusError`, which in turn derives from `CapawesomeCloudError`. Network failures raise `APIConnectionError` / `APITimeoutError`. +Network failures raise `APIConnectionError` / `APITimeoutError`, which also derive from `CapawesomeCloudError` — so a single `except CapawesomeCloudError` catches every error the SDK can raise. For those, `status` is `None`. ## Development diff --git a/src/capawesome_cloud/__init__.py b/src/capawesome_cloud/__init__.py index 8be3950..15980d2 100644 --- a/src/capawesome_cloud/__init__.py +++ b/src/capawesome_cloud/__init__.py @@ -7,17 +7,8 @@ from .client import CapawesomeCloud from .exceptions import ( APIConnectionError, - APIStatusError, APITimeoutError, - AuthenticationError, - BadRequestError, CapawesomeCloudError, - ConflictError, - InternalServerError, - NotFoundError, - PermissionDeniedError, - RateLimitError, - UnprocessableEntityError, ) from .models import ( App, @@ -46,15 +37,6 @@ "CapawesomeCloudError", "APIConnectionError", "APITimeoutError", - "APIStatusError", - "BadRequestError", - "AuthenticationError", - "PermissionDeniedError", - "NotFoundError", - "ConflictError", - "UnprocessableEntityError", - "RateLimitError", - "InternalServerError", "JobTimeoutError", # Models "CapawesomeModel", diff --git a/src/capawesome_cloud/_http.py b/src/capawesome_cloud/_http.py index 3e6197e..b10c362 100644 --- a/src/capawesome_cloud/_http.py +++ b/src/capawesome_cloud/_http.py @@ -28,7 +28,14 @@ DEFAULT_MAX_RETRIES = 2 DEFAULT_BACKOFF_FACTOR = 0.5 -_RETRY_STATUS_CODES = frozenset({429, 500, 502, 503, 504}) +# Statuses retried for any request method (the server rejected the request +# before processing it, so a retry cannot duplicate side effects). +_RETRY_STATUS_ALWAYS = frozenset({429}) +# Statuses retried only for idempotent methods, since a 5xx may mean the request +# was already (partially) processed -- retrying a POST could duplicate it. +_RETRY_STATUS_IDEMPOTENT = frozenset({500, 502, 503, 504}) +# Methods that are safe to retry on network/timeout/5xx failures. +_IDEMPOTENT_METHODS = frozenset({"GET", "HEAD", "OPTIONS", "PUT", "DELETE"}) # A small JSON-ish type. Responses are intentionally loosely typed because the # API does not publish response schemas. @@ -74,8 +81,15 @@ def request( files: Optional[Mapping[str, Any]] = None, data: Optional[Mapping[str, Any]] = None, ) -> httpx.Response: - """Send a request, retrying transient failures, and validate the status.""" + """Send a request, retrying transient failures, and validate the status. + + Network/timeout failures and ``5xx`` responses are only retried for + idempotent methods (a ``POST`` may have already been processed, so + retrying it could duplicate side effects). ``429`` is retried for any + method, since the request was rejected before processing. + """ clean_params = _drop_none(params) if params else None + idempotent = method.upper() in _IDEMPOTENT_METHODS attempt = 0 while True: try: @@ -88,19 +102,21 @@ def request( data=data, ) except httpx.TimeoutException as exc: - if attempt < self._max_retries: + if idempotent and attempt < self._max_retries: self._sleep(attempt, None) attempt += 1 continue raise APITimeoutError(request=exc.request) from exc except httpx.TransportError as exc: - if attempt < self._max_retries: + if idempotent and attempt < self._max_retries: self._sleep(attempt, None) attempt += 1 continue raise APIConnectionError(str(exc), request=exc.request) from exc - if response.status_code in _RETRY_STATUS_CODES and attempt < self._max_retries: + if attempt < self._max_retries and self._should_retry_status( + response.status_code, idempotent + ): self._sleep(attempt, response) attempt += 1 continue @@ -138,6 +154,12 @@ def __exit__(self, *exc: object) -> None: # -- internals ---------------------------------------------------------- + @staticmethod + def _should_retry_status(status_code: int, idempotent: bool) -> bool: + if status_code in _RETRY_STATUS_ALWAYS: + return True + return idempotent and status_code in _RETRY_STATUS_IDEMPOTENT + def _sleep(self, attempt: int, response: Optional[httpx.Response]) -> None: delay = self._backoff_factor * (2**attempt) if response is not None: diff --git a/src/capawesome_cloud/client.py b/src/capawesome_cloud/client.py index ffc2664..91e7735 100644 --- a/src/capawesome_cloud/client.py +++ b/src/capawesome_cloud/client.py @@ -17,7 +17,9 @@ from .exceptions import CapawesomeCloudError from .resources import AppsResource, JobsResource -ENV_TOKEN = "CAPAWESOME_CLOUD_TOKEN" +# Token environment variables, in order of precedence. CAPAWESOME_TOKEN is +# accepted for consistency with the Capawesome CLI. +ENV_TOKENS = ("CAPAWESOME_CLOUD_TOKEN", "CAPAWESOME_TOKEN") class CapawesomeCloud: @@ -25,7 +27,8 @@ class CapawesomeCloud: Create an API token in the Capawesome Cloud Console (https://console.cloud.capawesome.io/settings/tokens) and pass it as - ``token`` or via the ``CAPAWESOME_CLOUD_TOKEN`` environment variable. + ``token`` or via the ``CAPAWESOME_CLOUD_TOKEN`` (or ``CAPAWESOME_TOKEN``) + environment variable. Example:: @@ -46,11 +49,18 @@ def __init__( backoff_factor: float = DEFAULT_BACKOFF_FACTOR, http_client: Optional[httpx.Client] = None, ) -> None: - resolved_token = token or os.environ.get(ENV_TOKEN) + resolved_token = token + for env_name in ENV_TOKENS: + if resolved_token: + break + resolved_token = os.environ.get(env_name) + # Trim surrounding whitespace/newlines (common with secrets piped from + # files or CI) so they don't break the Authorization header. + resolved_token = (resolved_token or "").strip() if not resolved_token: raise CapawesomeCloudError( "No API token provided. Pass token=... or set the " - f"{ENV_TOKEN} environment variable." + f"{ENV_TOKENS[0]} environment variable." ) self._http = HttpClient( diff --git a/src/capawesome_cloud/exceptions.py b/src/capawesome_cloud/exceptions.py index e25d722..ddbb5d5 100644 --- a/src/capawesome_cloud/exceptions.py +++ b/src/capawesome_cloud/exceptions.py @@ -1,7 +1,10 @@ -"""Exception hierarchy for the Capawesome Cloud SDK. +"""Errors raised by the SDK. -All errors raised by the SDK derive from :class:`CapawesomeCloudError`, so a single -``except CapawesomeCloudError`` will catch everything the SDK can raise. +Every error derives from :class:`CapawesomeCloudError`, so a single +``except CapawesomeCloudError`` catches everything the SDK can raise. HTTP +errors (non-2xx responses) are raised as ``CapawesomeCloudError`` directly and +carry ``status`` / ``status_text`` / ``message`` / ``body``. Network failures +raise :class:`APIConnectionError` / :class:`APITimeoutError`. """ from __future__ import annotations @@ -14,20 +17,32 @@ "CapawesomeCloudError", "APIConnectionError", "APITimeoutError", - "APIStatusError", - "BadRequestError", - "AuthenticationError", - "PermissionDeniedError", - "NotFoundError", - "ConflictError", - "UnprocessableEntityError", - "RateLimitError", - "InternalServerError", ] class CapawesomeCloudError(Exception): - """Base class for every error raised by the SDK.""" + """Base error, and the error raised for non-2xx API responses. + + For HTTP errors, ``status``, ``status_text``, ``response`` and ``body`` are + populated. For non-HTTP errors (e.g. connection failures or configuration + problems) ``status`` is ``None``. + """ + + def __init__( + self, + message: str, + *, + status: Optional[int] = None, + status_text: Optional[str] = None, + response: Optional[httpx.Response] = None, + body: Optional[Any] = None, + ) -> None: + super().__init__(message) + self.message = message + self.status = status + self.status_text = status_text + self.response = response + self.body = body class APIConnectionError(CapawesomeCloudError): @@ -50,89 +65,72 @@ def __init__(self, *, request: Optional[httpx.Request] = None) -> None: super().__init__("Request to the Capawesome Cloud API timed out.", request=request) -class APIStatusError(CapawesomeCloudError): - """Raised when the API returns a non-success HTTP status code. - - The ``message`` is taken from the API response body (``{"message": ...}``) - when available, otherwise the HTTP reason phrase is used. - """ - - def __init__( - self, - message: str, - *, - status_code: int, - response: httpx.Response, - body: Optional[Any] = None, - ) -> None: - super().__init__(message) - self.message = message - self.status_code = status_code - self.response = response - self.body = body - - -class BadRequestError(APIStatusError): - """HTTP 400.""" - - -class AuthenticationError(APIStatusError): - """HTTP 401 - missing or invalid API token.""" - - -class PermissionDeniedError(APIStatusError): - """HTTP 403.""" - - -class NotFoundError(APIStatusError): - """HTTP 404.""" - - -class ConflictError(APIStatusError): - """HTTP 409.""" - - -class UnprocessableEntityError(APIStatusError): - """HTTP 422 - request validation failed.""" - - -class RateLimitError(APIStatusError): - """HTTP 429 - too many requests.""" - - -class InternalServerError(APIStatusError): - """HTTP 5xx.""" - - -_STATUS_EXCEPTIONS: dict[int, type[APIStatusError]] = { - 400: BadRequestError, - 401: AuthenticationError, - 403: PermissionDeniedError, - 404: NotFoundError, - 409: ConflictError, - 422: UnprocessableEntityError, - 429: RateLimitError, -} - - -def exception_from_response(response: httpx.Response) -> APIStatusError: - """Build the most specific :class:`APIStatusError` for an HTTP response.""" +def exception_from_response(response: httpx.Response) -> CapawesomeCloudError: + """Build a :class:`CapawesomeCloudError` for a non-2xx HTTP response.""" status = response.status_code body: Optional[Any] = None message: Optional[str] = None try: body = response.json() - if isinstance(body, dict): - raw = body.get("message") - if isinstance(raw, str): - message = raw + message = _message_from_body(body) except ValueError: - body = response.text or None + text = (response.text or "").strip() + body = text or None + # Surface a plain-text body as the message, but not HTML/XML error + # pages (e.g. from a gateway), which would be noise. + if text and not text.startswith("<"): + message = text if not message: message = f"HTTP {status} {response.reason_phrase}".strip() - exc_class = _STATUS_EXCEPTIONS.get(status) - if exc_class is None: - exc_class = InternalServerError if status >= 500 else APIStatusError - return exc_class(message, status_code=status, response=response, body=body) + return CapawesomeCloudError( + message, + status=status, + status_text=response.reason_phrase or None, + response=response, + body=body, + ) + + +def _message_from_body(body: Any) -> Optional[str]: + """Extract a human-readable message from an error response body. + + Handles the shapes the API (and its validation layer) can return: + + * a plain string body, + * ``{"message": "..."}``, + * ``{"error": "..."}``, + * ``{"error": [{"path": [...], "message": "..."}]}``, + * ``{"error": {"issues": [{"path": [...], "message": "..."}]}}``. + """ + if isinstance(body, str): + return body or None + if not isinstance(body, dict): + return None + + raw = body.get("message") + if isinstance(raw, str) and raw: + return raw + + errors = body.get("error") + if isinstance(errors, str) and errors: + return errors + if isinstance(errors, dict): + errors = errors.get("issues") + if isinstance(errors, list): + parts = [] + for item in errors: + if not isinstance(item, dict): + continue + text = item.get("message") + if not isinstance(text, str) or not text: + continue + path = item.get("path") + if isinstance(path, list) and path: + text = f"{'.'.join(str(segment) for segment in path)}: {text}" + parts.append(text) + if parts: + return "; ".join(parts) + + return None diff --git a/tests/test_client.py b/tests/test_client.py index da0d8f2..43b5a28 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -12,10 +12,33 @@ def test_token_from_env(monkeypatch: pytest.MonkeyPatch) -> None: assert client._http._client.headers["Authorization"] == "Bearer from-env" +def test_token_from_capawesome_token_fallback(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("CAPAWESOME_CLOUD_TOKEN", raising=False) + monkeypatch.setenv("CAPAWESOME_TOKEN", "fallback-token") + client = CapawesomeCloud() + assert client._http._client.headers["Authorization"] == "Bearer fallback-token" + + +def test_cloud_token_takes_precedence(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("CAPAWESOME_CLOUD_TOKEN", "primary") + monkeypatch.setenv("CAPAWESOME_TOKEN", "fallback") + client = CapawesomeCloud() + assert client._http._client.headers["Authorization"] == "Bearer primary" + + +def test_token_is_trimmed() -> None: + client = CapawesomeCloud(token=" abc\n") + assert client._http._client.headers["Authorization"] == "Bearer abc" + + def test_missing_token_raises(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.delenv("CAPAWESOME_CLOUD_TOKEN", raising=False) + monkeypatch.delenv("CAPAWESOME_TOKEN", raising=False) with pytest.raises(CapawesomeCloudError, match="No API token"): CapawesomeCloud() + # A whitespace-only token is treated as missing too. + with pytest.raises(CapawesomeCloudError, match="No API token"): + CapawesomeCloud(token=" ") def test_sets_auth_and_user_agent_headers() -> None: diff --git a/tests/test_errors_and_retries.py b/tests/test_errors_and_retries.py index a713633..4630b98 100644 --- a/tests/test_errors_and_retries.py +++ b/tests/test_errors_and_retries.py @@ -7,34 +7,102 @@ import pytest import respx -from capawesome_cloud import ( - AuthenticationError, - CapawesomeCloud, - InternalServerError, - NotFoundError, -) +from capawesome_cloud import CapawesomeCloud, CapawesomeCloudError from capawesome_cloud._http import DEFAULT_BASE_URL as BASE_URL from capawesome_cloud._http import _parse_retry_after @respx.mock -def test_error_message_is_extracted(client: CapawesomeCloud) -> None: +def test_error_message_and_status_are_extracted(client: CapawesomeCloud) -> None: respx.get(f"{BASE_URL}/v1/apps/missing").mock( return_value=httpx.Response(404, json={"message": "App not found."}) ) - with pytest.raises(NotFoundError) as excinfo: + with pytest.raises(CapawesomeCloudError) as excinfo: client.apps.get("missing") assert excinfo.value.message == "App not found." - assert excinfo.value.status_code == 404 + assert excinfo.value.status == 404 + assert excinfo.value.status_text == "Not Found" @respx.mock -def test_401_maps_to_authentication_error(client: CapawesomeCloud) -> None: +def test_validation_error_message_is_extracted(client: CapawesomeCloud) -> None: + respx.post(f"{BASE_URL}/v1/apps/app1/builds").mock( + return_value=httpx.Response( + 400, + json={ + "success": False, + "data": {"platform": "android"}, + "error": [ + { + "code": "custom", + "path": [], + "message": "Either gitRef or appBuildSourceId must be provided.", + } + ], + }, + ) + ) + with pytest.raises(CapawesomeCloudError) as excinfo: + client.apps.builds.create("app1", platform="android") + assert excinfo.value.message == "Either gitRef or appBuildSourceId must be provided." + assert excinfo.value.status == 400 + # The full raw body is still available. + assert excinfo.value.body["success"] is False + + +@respx.mock +def test_validation_error_includes_field_path(client: CapawesomeCloud) -> None: + respx.post(f"{BASE_URL}/v1/apps/app1/builds").mock( + return_value=httpx.Response( + 400, + json={"error": [{"path": ["platform"], "message": "Invalid value."}]}, + ) + ) + with pytest.raises(CapawesomeCloudError) as excinfo: + client.apps.builds.create("app1", platform="bogus") + assert excinfo.value.message == "platform: Invalid value." + + +@respx.mock +def test_zod_issues_error_message(client: CapawesomeCloud) -> None: + respx.get(f"{BASE_URL}/v1/apps/x").mock( + return_value=httpx.Response( + 400, json={"error": {"issues": [{"path": ["name"], "message": "Required."}]}} + ) + ) + with pytest.raises(CapawesomeCloudError) as excinfo: + client.apps.get("x") + assert excinfo.value.message == "name: Required." + + +@respx.mock +def test_plain_text_error_body_is_used_as_message(client: CapawesomeCloud) -> None: + respx.get(f"{BASE_URL}/v1/apps/x").mock( + return_value=httpx.Response(400, text="Something went wrong") + ) + with pytest.raises(CapawesomeCloudError) as excinfo: + client.apps.get("x") + assert excinfo.value.message == "Something went wrong" + + +@respx.mock +def test_html_error_body_is_not_used_as_message(client: CapawesomeCloud) -> None: + respx.get(f"{BASE_URL}/v1/apps/x").mock( + return_value=httpx.Response(502, html="Bad Gateway") + ) + with pytest.raises(CapawesomeCloudError) as excinfo: + client.apps.get("x") + assert excinfo.value.message == "HTTP 502 Bad Gateway" + + +@respx.mock +def test_401_carries_status(client: CapawesomeCloud) -> None: respx.get(f"{BASE_URL}/v1/apps").mock( return_value=httpx.Response(401, json={"message": "Not authenticated."}) ) - with pytest.raises(AuthenticationError): + with pytest.raises(CapawesomeCloudError) as excinfo: client.apps.list_page() + assert excinfo.value.status == 401 @respx.mock @@ -56,11 +124,40 @@ def test_gives_up_after_max_retries() -> None: route = respx.get(f"{BASE_URL}/v1/apps").mock( return_value=httpx.Response(503, json={"message": "unavailable"}) ) - with pytest.raises(InternalServerError): + with pytest.raises(CapawesomeCloudError) as excinfo: client.apps.list_page() + assert excinfo.value.status == 503 assert route.call_count == 2 # initial + 1 retry +@respx.mock +def test_post_not_retried_on_5xx() -> None: + # A non-idempotent POST must not be retried on 5xx (it may have been + # processed server-side -- retrying could duplicate the side effect). + client = CapawesomeCloud(token="t", max_retries=3, backoff_factor=0.0) + route = respx.post(f"{BASE_URL}/v1/apps").mock( + return_value=httpx.Response(503, json={"message": "unavailable"}) + ) + with pytest.raises(CapawesomeCloudError): + client.apps.create(name="App") + assert route.call_count == 1 + + +@respx.mock +def test_post_is_retried_on_429() -> None: + # 429 means the request was rejected before processing, so it is safe to + # retry even a POST. + client = CapawesomeCloud(token="t", max_retries=2, backoff_factor=0.0) + route = respx.post(f"{BASE_URL}/v1/apps") + route.side_effect = [ + httpx.Response(429, json={"message": "slow down"}), + httpx.Response(201, json={"id": "a1", "name": "App"}), + ] + app = client.apps.create(name="App") + assert app.id == "a1" + assert route.call_count == 2 + + def test_parse_retry_after_seconds() -> None: assert _parse_retry_after("5") == 5.0 assert _parse_retry_after(" 5 ") == 5.0 From 5105ab20bac0f3db82dc86216df1930342a11cc4 Mon Sep 17 00:00:00 2001 From: Robin Genz Date: Tue, 2 Jun 2026 09:08:23 +0200 Subject: [PATCH 5/5] docs(examples): add examples README and error-handling/list-apps scripts - Add `examples/README.md`, `examples/list_apps.py` and `examples/error_handling.py`. - Fix `examples/trigger_native_build.py` to pass `git_ref` so the build request isn't rejected. --- examples/README.md | 31 +++++++++++++++++++++++++++++++ examples/error_handling.py | 27 +++++++++++++++++++++++++++ examples/list_apps.py | 27 +++++++++++++++++++++++++++ examples/trigger_native_build.py | 4 +++- 4 files changed, 88 insertions(+), 1 deletion(-) create mode 100644 examples/README.md create mode 100644 examples/error_handling.py create mode 100644 examples/list_apps.py diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..342fa89 --- /dev/null +++ b/examples/README.md @@ -0,0 +1,31 @@ +# Examples + +Runnable scripts to try the SDK against the live Capawesome Cloud API. + +## Prerequisites + +- Python 3.9 or later +- The SDK installed — from the repository root run `pip install -e .` (or `pip install capawesome-cloud`) +- An API token from the [Capawesome Cloud Console](https://console.cloud.capawesome.io/settings/tokens) + +## Running + +Set your token, then run any script with Python: + +```bash +export CAPAWESOME_CLOUD_TOKEN= +python examples/list_apps.py +``` + +Each script takes the id it operates on as its argument (see the table below). + +| Script | Description | Argument | +| -------------------------- | ------------------------------------------------------------------- | ------------------- | +| `list_apps.py` | Lists all apps of an organization. | `` | +| `manage_live_updates.py` | Creates a live update channel, pauses/resumes it, and lists devices. | `` | +| `trigger_native_build.py` | Triggers a native build, waits for it, and downloads its artifacts. | `` | +| `error_handling.py` | Shows how `CapawesomeCloudError` is raised for a failed request. | — | + +> `list_apps.py` and `error_handling.py` are read-only. `manage_live_updates.py` and `trigger_native_build.py` perform real, billable operations (creating a channel, starting a native build). + +> These scripts import the installed `capawesome_cloud` package. An editable install (`pip install -e .`) runs them against the local source, so changes are picked up without reinstalling. diff --git a/examples/error_handling.py b/examples/error_handling.py new file mode 100644 index 0000000..db5dbd0 --- /dev/null +++ b/examples/error_handling.py @@ -0,0 +1,27 @@ +"""Demonstrates error handling by requesting a non-existent app. + +Run with: + CAPAWESOME_CLOUD_TOKEN=cap_... python examples/error_handling.py +""" + +from __future__ import annotations + +from capawesome_cloud import CapawesomeCloud, CapawesomeCloudError + + +def main() -> None: + # CapawesomeCloud() reads the token from the CAPAWESOME_CLOUD_TOKEN + # environment variable and raises if it is missing. + with CapawesomeCloud() as client: + try: + client.apps.get("00000000-0000-0000-0000-000000000000") + print("Unexpected success — the app should not exist.") + except CapawesomeCloudError as error: + # Non-2xx responses carry the HTTP status and the API's message. + # (Network failures raise APIConnectionError / APITimeoutError, + # which also derive from CapawesomeCloudError.) + print(f"Request failed as expected: {error.status} {error.message}") + + +if __name__ == "__main__": + main() diff --git a/examples/list_apps.py b/examples/list_apps.py new file mode 100644 index 0000000..05671ea --- /dev/null +++ b/examples/list_apps.py @@ -0,0 +1,27 @@ +"""List all apps of an organization. + +Run with: + CAPAWESOME_CLOUD_TOKEN=cap_... python examples/list_apps.py +""" + +from __future__ import annotations + +import sys + +from capawesome_cloud import CapawesomeCloud + + +def main(organization_id: str) -> None: + with CapawesomeCloud() as client: + count = 0 + # `list` transparently pages through every app of the organization. + for app in client.apps.list(organization_id=organization_id): + count += 1 + print(f"{app.id} {app.type or '-':<10} {app.name}") + print(f"\n{count} app(s) found.") + + +if __name__ == "__main__": + if len(sys.argv) != 2: + raise SystemExit("usage: list_apps.py ") + main(sys.argv[1]) diff --git a/examples/trigger_native_build.py b/examples/trigger_native_build.py index c0684df..f663004 100644 --- a/examples/trigger_native_build.py +++ b/examples/trigger_native_build.py @@ -13,7 +13,9 @@ def main(app_id: str) -> None: with CapawesomeCloud() as client: - build = client.apps.builds.create(app_id, platform="android") + # Builds need a source: a connected Git repo (pass git_ref) or an + # uploaded build source (pass app_build_source_id). + build = client.apps.builds.create(app_id, platform="android", git_ref="main") print(f"Started build {build.number_as_string} (job {build.job_id})") if not build.job_id: