Metadata-Version: 2.4
Name: ohapi
Version: 0.1.0
Summary: Python client for the ohapi secrets API.
Author: Obsidian Heron
License-Expression: MIT
Project-URL: Homepage, https://ohapi.dev
Project-URL: Issues, https://github.com/Obsidian-Heron/ohapi/issues
Project-URL: Source, https://github.com/Obsidian-Heron/ohapi/tree/main/sdk-python
Keywords: secrets,env,environment-variables,secrets-management,sdk
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Typing :: Typed
Requires-Python: >=3.10
Description-Content-Type: text/markdown
License-File: LICENSE
Dynamic: license-file

# ohapi

A typed Python client for the ohapi secrets API. It has no dependencies. It uses only the standard library. It needs Python 3.10 or later.

## Install

```sh
pip install ohapi
```

The package is not on PyPI yet.

## Example

```python
import os

from ohapi import Ohapi, OhapiError, Pending

ohapi = Ohapi(os.environ["OHAPI_TOKEN"])

result = ohapi.secrets.set(project_id, "production", "API_URL", "https://api.example.com", confirm_production=True)

if isinstance(result, Pending):
    # Dual control holds the change. Nothing is written yet.
    try:
        ohapi.change_requests.wait(result.change_request_id)
    except OhapiError as error:
        if error.code != "rejected":
            raise
        print("A reviewer refused the change:", error.change_request.status)
else:
    print("Wrote version", result.version)
```

## Methods

| Method | Endpoint | Audit row |
| --- | --- | --- |
| `me()` | `GET /api/v1/me` | No |
| `projects.list()`, `projects.get(id)` | `GET /api/v1/projects`, `…/{id}` | No |
| `environments.list()`, `.iterate()`, `.get()` | `GET …/environments` | No |
| `secrets.list()`, `.iterate()`, `.get()` | `GET …/secrets` | **No.** Metadata only. |
| `secrets.value()` | `GET …/secrets/{key}/value` | **Yes.** One `READ` row for each call. |
| `secrets.pull()` | `GET /api/v1/secrets` | **Yes.** One `READ` row for the environment. Expired keys are omitted. |
| `secrets.set()`, `.create()`, `.patch()`, `.delete()` | `PUT`, `POST`, `PATCH`, `DELETE` | Yes, when the write lands. A `Pending` result has no row yet. The row is written when an admin approves the change. |
| `change_requests.get()`, `.wait()` | `GET /api/v1/change-requests/{id}` | No |

`list()` takes `page` and `page_size`. `iterate()` takes `page_size`. The server accepts page numbers up to 10000 and page sizes up to 500.

The client is synchronous. There is no `asyncio` client.

## Pending writes

Dual control can hold a write to `production`. In that case a write returns `Pending` with a `change_request_id`. The value is not written. Check `isinstance(result, Pending)` before you read `version`.

A write returns one of these types:

| Call | Result |
| --- | --- |
| `set()`, `create()` | `Written(key, version, created, id)` |
| `patch()` | `Patched(key)` |
| `delete()` | `Deleted(key)` |
| Any of them, when dual control holds the change | `Pending(change_request_id, key)` |

`wait(change_request_id, interval_s=5.0, timeout_s=600.0, cancel=None)` returns only when the request is `APPLIED`. It raises `OhapiError` with code `rejected` when a reviewer rejects or cancels the request. The terminal request is in `error.change_request`. To stop a wait from another thread, pass a `threading.Event` as `cancel` and set it. A request that is already in flight ends at its own deadline, which is never later than the end of `timeout_s`.

## Omitted and `None`

`set()` and `create()` take `format_regex`, `rotation_due_at`, `expires_at`, `value_type` and `note` as optional arguments. `value_type` is `STRING`, `JSON`, `PASSWORD` or `CERTIFICATE`. `patch()` takes the same arguments except `value_type`. An argument you leave out does not change the stored value. An argument you pass as `None` clears the stored value. Dates are ISO 8601 strings or `datetime` objects.

## Errors

Every failure of a request, and every input that the SDK refuses, is an `OhapiError` with a `code`, a `status` (0 when there was no response), and a message. A wrong call, for example a missing argument, is still a normal Python `TypeError`.

| Code | Cause |
| --- | --- |
| `invalid` | Bad input. The SDK refused it before any request, or the server answered `400`. |
| `unauthenticated` | `401`. The key is missing, wrong, or expired. |
| `forbidden` | `403`. The key has a read-only role, the route is for admins only, or the key scope does not cover the target. |
| `not_found` | `404`. The thing does not exist, or the key cannot reach it. |
| `conflict` | `409`. `create()` found the key already there. |
| `unprocessable` | `422`. The server refused the value. |
| `rate_limited` | `429`. `retry_after_seconds` holds the wait. |
| `server` | Any other HTTP failure. A redirect is also `server`, and so is a `2xx` answer that is not JSON. The SDK never follows a redirect and never reads its `Location` header. |
| `network` | No connection. |
| `timeout` | The whole request, headers and body included, did not finish within `timeout_s` (30 seconds by default), or `wait()` ended. |
| `rejected` | `wait()` found a rejected or cancelled change request. |

The SDK does not retry. You decide the retry policy. The SDK does not log.

## The key

The client keeps the key in a name-mangled attribute. `repr()` and `str()` of the client do not print it. A traceback that shows local variables does not contain it either. This protects logs. It does not hide the key from other code in the same process. The SDK refuses a key that contains a carriage return, a line feed or a null character. It also refuses a custom `user_agent` that is not printable ASCII.

## Not included

The SDK does not create, rename, or delete projects and environments. It does not wrap the organization endpoints. Use the dashboard or the HTTP API for those.

## Tests

```sh
sdk-python/scripts/test.sh
```

The script runs the unit tests in Docker. The tests need no server.
