clovrix 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- clovrix-0.1.0/.gitignore +22 -0
- clovrix-0.1.0/PKG-INFO +159 -0
- clovrix-0.1.0/README.md +135 -0
- clovrix-0.1.0/pyproject.toml +38 -0
- clovrix-0.1.0/src/clovrix/__init__.py +39 -0
- clovrix-0.1.0/src/clovrix/_models.py +42 -0
- clovrix-0.1.0/src/clovrix/client.py +280 -0
- clovrix-0.1.0/src/clovrix/errors.py +92 -0
- clovrix-0.1.0/src/clovrix/py.typed +0 -0
- clovrix-0.1.0/tests/test_client.py +175 -0
clovrix-0.1.0/.gitignore
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# Node
|
|
2
|
+
node_modules/
|
|
3
|
+
node/dist/
|
|
4
|
+
*.tsbuildinfo
|
|
5
|
+
npm-debug.log*
|
|
6
|
+
|
|
7
|
+
# Go
|
|
8
|
+
go/bin/
|
|
9
|
+
|
|
10
|
+
# Python
|
|
11
|
+
__pycache__/
|
|
12
|
+
*.py[cod]
|
|
13
|
+
python/.venv/
|
|
14
|
+
python/dist/
|
|
15
|
+
python/build/
|
|
16
|
+
python/*.egg-info/
|
|
17
|
+
.pytest_cache/
|
|
18
|
+
|
|
19
|
+
# Editors / OS
|
|
20
|
+
.DS_Store
|
|
21
|
+
.idea/
|
|
22
|
+
.vscode/
|
clovrix-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: clovrix
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Official Python SDK for the Clovrix Public API
|
|
5
|
+
Project-URL: Homepage, https://github.com/clovrix/sdk/tree/main/python
|
|
6
|
+
Project-URL: Repository, https://github.com/clovrix/sdk
|
|
7
|
+
Author: Clovrix
|
|
8
|
+
License: MIT
|
|
9
|
+
Keywords: clovrix,config,environment-variables,sdk,secrets
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Typing :: Typed
|
|
19
|
+
Requires-Python: >=3.9
|
|
20
|
+
Requires-Dist: httpx>=0.24
|
|
21
|
+
Provides-Extra: dev
|
|
22
|
+
Requires-Dist: pytest>=7; extra == 'dev'
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
|
|
25
|
+
# clovrix
|
|
26
|
+
|
|
27
|
+
Official Python SDK for the [Clovrix](https://clovrix.com) Public API. Sync and async clients
|
|
28
|
+
built on [httpx](https://www.python-httpx.org/), fully typed.
|
|
29
|
+
|
|
30
|
+
## Install
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
pip install clovrix
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Requires Python 3.9+.
|
|
37
|
+
|
|
38
|
+
## Authentication
|
|
39
|
+
|
|
40
|
+
Create an API token from your organization's **Settings → API tokens** page, then either pass it
|
|
41
|
+
to the client or expose it as an environment variable:
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
export CLOVRIX_TOKEN="icr_xxxxxxxx…"
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Resolution order: `token=` argument → `CLOVRIX_TOKEN` env var → otherwise a `ClovrixConfigError`
|
|
48
|
+
is raised. The host defaults to `https://app.clovrix.com`; override with `base_url=` or
|
|
49
|
+
`CLOVRIX_API_URL` (scheme + host only — the `/api/public/v1` path is added for you).
|
|
50
|
+
|
|
51
|
+
## Usage
|
|
52
|
+
|
|
53
|
+
```python
|
|
54
|
+
from clovrix import Client
|
|
55
|
+
|
|
56
|
+
clovrix = Client() # token from CLOVRIX_TOKEN
|
|
57
|
+
|
|
58
|
+
# Fetch every entry in an environment as a dict.
|
|
59
|
+
config = clovrix.get_entries("backend", "production")
|
|
60
|
+
print(config["DATABASE_URL"])
|
|
61
|
+
|
|
62
|
+
# Fetch a single entry (raises NotFoundError if unset).
|
|
63
|
+
entry = clovrix.get_entry("backend", "production", "DATABASE_URL")
|
|
64
|
+
print(entry.key, entry.value, entry.is_secret)
|
|
65
|
+
|
|
66
|
+
# Write one entry. is_secret applies only when the key is first created.
|
|
67
|
+
result = clovrix.set_entry("backend", "production", "API_KEY", "s3cr3t", is_secret=True)
|
|
68
|
+
print(result.created) # True if newly created, False if a new version
|
|
69
|
+
|
|
70
|
+
# Write up to 100 entries atomically; returns the number written.
|
|
71
|
+
from clovrix import WriteItem
|
|
72
|
+
|
|
73
|
+
written = clovrix.set_entries("backend", "production", [
|
|
74
|
+
WriteItem(key="FEATURE_X", value="on"),
|
|
75
|
+
{"key": "API_KEY", "value": "s3cr3t", "is_secret": True}, # mappings work too
|
|
76
|
+
])
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Use it as a context manager to close the underlying HTTP connection pool:
|
|
80
|
+
|
|
81
|
+
```python
|
|
82
|
+
with Client() as clovrix:
|
|
83
|
+
config = clovrix.get_entries("backend", "production")
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Load straight into the process environment:
|
|
87
|
+
|
|
88
|
+
```python
|
|
89
|
+
import os
|
|
90
|
+
os.environ.update(clovrix.get_entries("backend", "production"))
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
### Async
|
|
94
|
+
|
|
95
|
+
```python
|
|
96
|
+
import asyncio
|
|
97
|
+
from clovrix import AsyncClient
|
|
98
|
+
|
|
99
|
+
async def main():
|
|
100
|
+
async with AsyncClient() as clovrix:
|
|
101
|
+
config = await clovrix.get_entries("backend", "production")
|
|
102
|
+
print(config)
|
|
103
|
+
|
|
104
|
+
asyncio.run(main())
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
## Configuration
|
|
108
|
+
|
|
109
|
+
```python
|
|
110
|
+
Client(
|
|
111
|
+
token="icr_…", # default: CLOVRIX_TOKEN
|
|
112
|
+
base_url="https://…", # default: CLOVRIX_API_URL or https://app.clovrix.com
|
|
113
|
+
timeout=30.0, # per-request timeout (seconds)
|
|
114
|
+
user_agent="my-app/1.0",
|
|
115
|
+
http_client=my_httpx_client, # bring your own httpx.Client / httpx.AsyncClient
|
|
116
|
+
)
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
## Error handling
|
|
120
|
+
|
|
121
|
+
Every failed request raises a subclass of `ClovrixError`. API errors expose `.status_code`; a
|
|
122
|
+
billing block also exposes `.billing_status`.
|
|
123
|
+
|
|
124
|
+
| Exception | Cause |
|
|
125
|
+
| --- | --- |
|
|
126
|
+
| `ClovrixConfigError` | Missing token / misconfiguration |
|
|
127
|
+
| `ClovrixConnectionError` | Network failure or timeout (no HTTP response) |
|
|
128
|
+
| `AuthenticationError` | `401` — missing/invalid/expired token |
|
|
129
|
+
| `BillingError` | `402` — billing pending or restricted (`.billing_status`) |
|
|
130
|
+
| `ForbiddenError` | `403` — token role lacks access |
|
|
131
|
+
| `NotFoundError` | `404` — project/environment/key not found |
|
|
132
|
+
| `ValidationError` | `400` / `413` / `422` — invalid request, nothing written |
|
|
133
|
+
| `ServerError` | `5xx` |
|
|
134
|
+
|
|
135
|
+
```python
|
|
136
|
+
from clovrix import NotFoundError, BillingError
|
|
137
|
+
|
|
138
|
+
try:
|
|
139
|
+
entry = clovrix.get_entry("backend", "production", "MAYBE")
|
|
140
|
+
except NotFoundError:
|
|
141
|
+
... # key isn't set
|
|
142
|
+
except BillingError as err:
|
|
143
|
+
print("billing:", err.billing_status)
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
## Limits
|
|
147
|
+
|
|
148
|
+
- Keys match `^[A-Z_][A-Z0-9_]*$`, max 128 chars.
|
|
149
|
+
- Values are UTF-8 up to 32 KiB.
|
|
150
|
+
- Bulk write ≤ 100 keys (atomic); bulk fetch ≤ 500 entries per project.
|
|
151
|
+
- `is_secret` is fixed at creation and cannot be changed afterwards.
|
|
152
|
+
|
|
153
|
+
## Development
|
|
154
|
+
|
|
155
|
+
```bash
|
|
156
|
+
python -m venv .venv && source .venv/bin/activate
|
|
157
|
+
pip install -e ".[dev]"
|
|
158
|
+
pytest
|
|
159
|
+
```
|
clovrix-0.1.0/README.md
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
# clovrix
|
|
2
|
+
|
|
3
|
+
Official Python SDK for the [Clovrix](https://clovrix.com) Public API. Sync and async clients
|
|
4
|
+
built on [httpx](https://www.python-httpx.org/), fully typed.
|
|
5
|
+
|
|
6
|
+
## Install
|
|
7
|
+
|
|
8
|
+
```bash
|
|
9
|
+
pip install clovrix
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
Requires Python 3.9+.
|
|
13
|
+
|
|
14
|
+
## Authentication
|
|
15
|
+
|
|
16
|
+
Create an API token from your organization's **Settings → API tokens** page, then either pass it
|
|
17
|
+
to the client or expose it as an environment variable:
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
export CLOVRIX_TOKEN="icr_xxxxxxxx…"
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Resolution order: `token=` argument → `CLOVRIX_TOKEN` env var → otherwise a `ClovrixConfigError`
|
|
24
|
+
is raised. The host defaults to `https://app.clovrix.com`; override with `base_url=` or
|
|
25
|
+
`CLOVRIX_API_URL` (scheme + host only — the `/api/public/v1` path is added for you).
|
|
26
|
+
|
|
27
|
+
## Usage
|
|
28
|
+
|
|
29
|
+
```python
|
|
30
|
+
from clovrix import Client
|
|
31
|
+
|
|
32
|
+
clovrix = Client() # token from CLOVRIX_TOKEN
|
|
33
|
+
|
|
34
|
+
# Fetch every entry in an environment as a dict.
|
|
35
|
+
config = clovrix.get_entries("backend", "production")
|
|
36
|
+
print(config["DATABASE_URL"])
|
|
37
|
+
|
|
38
|
+
# Fetch a single entry (raises NotFoundError if unset).
|
|
39
|
+
entry = clovrix.get_entry("backend", "production", "DATABASE_URL")
|
|
40
|
+
print(entry.key, entry.value, entry.is_secret)
|
|
41
|
+
|
|
42
|
+
# Write one entry. is_secret applies only when the key is first created.
|
|
43
|
+
result = clovrix.set_entry("backend", "production", "API_KEY", "s3cr3t", is_secret=True)
|
|
44
|
+
print(result.created) # True if newly created, False if a new version
|
|
45
|
+
|
|
46
|
+
# Write up to 100 entries atomically; returns the number written.
|
|
47
|
+
from clovrix import WriteItem
|
|
48
|
+
|
|
49
|
+
written = clovrix.set_entries("backend", "production", [
|
|
50
|
+
WriteItem(key="FEATURE_X", value="on"),
|
|
51
|
+
{"key": "API_KEY", "value": "s3cr3t", "is_secret": True}, # mappings work too
|
|
52
|
+
])
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Use it as a context manager to close the underlying HTTP connection pool:
|
|
56
|
+
|
|
57
|
+
```python
|
|
58
|
+
with Client() as clovrix:
|
|
59
|
+
config = clovrix.get_entries("backend", "production")
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Load straight into the process environment:
|
|
63
|
+
|
|
64
|
+
```python
|
|
65
|
+
import os
|
|
66
|
+
os.environ.update(clovrix.get_entries("backend", "production"))
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
### Async
|
|
70
|
+
|
|
71
|
+
```python
|
|
72
|
+
import asyncio
|
|
73
|
+
from clovrix import AsyncClient
|
|
74
|
+
|
|
75
|
+
async def main():
|
|
76
|
+
async with AsyncClient() as clovrix:
|
|
77
|
+
config = await clovrix.get_entries("backend", "production")
|
|
78
|
+
print(config)
|
|
79
|
+
|
|
80
|
+
asyncio.run(main())
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
## Configuration
|
|
84
|
+
|
|
85
|
+
```python
|
|
86
|
+
Client(
|
|
87
|
+
token="icr_…", # default: CLOVRIX_TOKEN
|
|
88
|
+
base_url="https://…", # default: CLOVRIX_API_URL or https://app.clovrix.com
|
|
89
|
+
timeout=30.0, # per-request timeout (seconds)
|
|
90
|
+
user_agent="my-app/1.0",
|
|
91
|
+
http_client=my_httpx_client, # bring your own httpx.Client / httpx.AsyncClient
|
|
92
|
+
)
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
## Error handling
|
|
96
|
+
|
|
97
|
+
Every failed request raises a subclass of `ClovrixError`. API errors expose `.status_code`; a
|
|
98
|
+
billing block also exposes `.billing_status`.
|
|
99
|
+
|
|
100
|
+
| Exception | Cause |
|
|
101
|
+
| --- | --- |
|
|
102
|
+
| `ClovrixConfigError` | Missing token / misconfiguration |
|
|
103
|
+
| `ClovrixConnectionError` | Network failure or timeout (no HTTP response) |
|
|
104
|
+
| `AuthenticationError` | `401` — missing/invalid/expired token |
|
|
105
|
+
| `BillingError` | `402` — billing pending or restricted (`.billing_status`) |
|
|
106
|
+
| `ForbiddenError` | `403` — token role lacks access |
|
|
107
|
+
| `NotFoundError` | `404` — project/environment/key not found |
|
|
108
|
+
| `ValidationError` | `400` / `413` / `422` — invalid request, nothing written |
|
|
109
|
+
| `ServerError` | `5xx` |
|
|
110
|
+
|
|
111
|
+
```python
|
|
112
|
+
from clovrix import NotFoundError, BillingError
|
|
113
|
+
|
|
114
|
+
try:
|
|
115
|
+
entry = clovrix.get_entry("backend", "production", "MAYBE")
|
|
116
|
+
except NotFoundError:
|
|
117
|
+
... # key isn't set
|
|
118
|
+
except BillingError as err:
|
|
119
|
+
print("billing:", err.billing_status)
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
## Limits
|
|
123
|
+
|
|
124
|
+
- Keys match `^[A-Z_][A-Z0-9_]*$`, max 128 chars.
|
|
125
|
+
- Values are UTF-8 up to 32 KiB.
|
|
126
|
+
- Bulk write ≤ 100 keys (atomic); bulk fetch ≤ 500 entries per project.
|
|
127
|
+
- `is_secret` is fixed at creation and cannot be changed afterwards.
|
|
128
|
+
|
|
129
|
+
## Development
|
|
130
|
+
|
|
131
|
+
```bash
|
|
132
|
+
python -m venv .venv && source .venv/bin/activate
|
|
133
|
+
pip install -e ".[dev]"
|
|
134
|
+
pytest
|
|
135
|
+
```
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "clovrix"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Official Python SDK for the Clovrix Public API"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [{ name = "Clovrix" }]
|
|
13
|
+
keywords = ["clovrix", "secrets", "config", "environment-variables", "sdk"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 4 - Beta",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"License :: OSI Approved :: MIT License",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Programming Language :: Python :: 3.9",
|
|
20
|
+
"Programming Language :: Python :: 3.10",
|
|
21
|
+
"Programming Language :: Python :: 3.11",
|
|
22
|
+
"Programming Language :: Python :: 3.12",
|
|
23
|
+
"Typing :: Typed",
|
|
24
|
+
]
|
|
25
|
+
dependencies = ["httpx>=0.24"]
|
|
26
|
+
|
|
27
|
+
[project.urls]
|
|
28
|
+
Homepage = "https://github.com/clovrix/sdk/tree/main/python"
|
|
29
|
+
Repository = "https://github.com/clovrix/sdk"
|
|
30
|
+
|
|
31
|
+
[project.optional-dependencies]
|
|
32
|
+
dev = ["pytest>=7"]
|
|
33
|
+
|
|
34
|
+
[tool.hatch.build.targets.wheel]
|
|
35
|
+
packages = ["src/clovrix"]
|
|
36
|
+
|
|
37
|
+
[tool.pytest.ini_options]
|
|
38
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Official Python SDK for the Clovrix Public API."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from ._models import Entry, WriteItem, WriteResult
|
|
6
|
+
from .client import AsyncClient, Client
|
|
7
|
+
from .errors import (
|
|
8
|
+
APIError,
|
|
9
|
+
AuthenticationError,
|
|
10
|
+
BillingError,
|
|
11
|
+
ClovrixConfigError,
|
|
12
|
+
ClovrixConnectionError,
|
|
13
|
+
ClovrixError,
|
|
14
|
+
ForbiddenError,
|
|
15
|
+
NotFoundError,
|
|
16
|
+
ServerError,
|
|
17
|
+
ValidationError,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
__version__ = "0.1.0"
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
"Client",
|
|
24
|
+
"AsyncClient",
|
|
25
|
+
"Entry",
|
|
26
|
+
"WriteItem",
|
|
27
|
+
"WriteResult",
|
|
28
|
+
"ClovrixError",
|
|
29
|
+
"ClovrixConfigError",
|
|
30
|
+
"ClovrixConnectionError",
|
|
31
|
+
"APIError",
|
|
32
|
+
"AuthenticationError",
|
|
33
|
+
"BillingError",
|
|
34
|
+
"ForbiddenError",
|
|
35
|
+
"NotFoundError",
|
|
36
|
+
"ValidationError",
|
|
37
|
+
"ServerError",
|
|
38
|
+
"__version__",
|
|
39
|
+
]
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Data models returned by and submitted to the Clovrix API."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(frozen=True)
|
|
10
|
+
class Entry:
|
|
11
|
+
"""A single configuration/secret entry.
|
|
12
|
+
|
|
13
|
+
``value`` is the current value for the requested environment; secret values
|
|
14
|
+
are returned decrypted.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
key: str
|
|
18
|
+
value: str
|
|
19
|
+
is_secret: bool
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class WriteItem:
|
|
24
|
+
"""One key/value pair to write.
|
|
25
|
+
|
|
26
|
+
``is_secret`` is honoured only when the key is first created; it cannot be
|
|
27
|
+
changed on an existing key. Leave it ``None`` to default a new key to a plain
|
|
28
|
+
config value.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
key: str
|
|
32
|
+
value: str
|
|
33
|
+
is_secret: Optional[bool] = None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True)
|
|
37
|
+
class WriteResult:
|
|
38
|
+
"""The result of writing a single entry."""
|
|
39
|
+
|
|
40
|
+
key: str
|
|
41
|
+
#: True if a new entry was created; False if a new version was appended.
|
|
42
|
+
created: bool
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
"""Synchronous and asynchronous clients for the Clovrix Public API."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from typing import Any, Dict, Iterable, List, Mapping, Optional, Sequence, Tuple, Union
|
|
7
|
+
from urllib.parse import quote
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
10
|
+
|
|
11
|
+
from ._models import Entry, WriteItem, WriteResult
|
|
12
|
+
from .errors import ClovrixConfigError, ClovrixConnectionError, error_from_response
|
|
13
|
+
|
|
14
|
+
DEFAULT_BASE_URL = "https://app.clovrix.com"
|
|
15
|
+
API_PREFIX = "/api/public/v1"
|
|
16
|
+
DEFAULT_TIMEOUT = 30.0
|
|
17
|
+
SDK_VERSION = "0.1.0"
|
|
18
|
+
|
|
19
|
+
#: A write item may be a WriteItem or a plain mapping with key/value/is_secret.
|
|
20
|
+
WriteInput = Union[WriteItem, Mapping[str, Any]]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _resolve_token(token: Optional[str]) -> str:
|
|
24
|
+
token = token or os.environ.get("CLOVRIX_TOKEN")
|
|
25
|
+
if not token:
|
|
26
|
+
raise ClovrixConfigError(
|
|
27
|
+
"No Clovrix API token provided. Pass token=... or set the CLOVRIX_TOKEN "
|
|
28
|
+
"environment variable."
|
|
29
|
+
)
|
|
30
|
+
return token
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _resolve_base_url(base_url: Optional[str]) -> str:
|
|
34
|
+
base = (base_url or os.environ.get("CLOVRIX_API_URL") or DEFAULT_BASE_URL).rstrip("/")
|
|
35
|
+
if not base.endswith(API_PREFIX):
|
|
36
|
+
base += API_PREFIX
|
|
37
|
+
return base
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _make_headers(token: str, user_agent: Optional[str]) -> Dict[str, str]:
|
|
41
|
+
return {
|
|
42
|
+
"Authorization": f"Bearer {token}",
|
|
43
|
+
"Accept": "application/json",
|
|
44
|
+
"User-Agent": user_agent or f"clovrix-sdk-python/{SDK_VERSION}",
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _entry_segments(
|
|
49
|
+
project: str, environment: str, key: Optional[str] = None
|
|
50
|
+
) -> Tuple[str, ...]:
|
|
51
|
+
segments = ("projects", project, "environments", environment, "entries")
|
|
52
|
+
if key is not None:
|
|
53
|
+
segments += (key,)
|
|
54
|
+
return segments
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _write_body(value: str, is_secret: Optional[bool]) -> Dict[str, Any]:
|
|
58
|
+
body: Dict[str, Any] = {"value": value}
|
|
59
|
+
if is_secret is not None:
|
|
60
|
+
body["is_secret"] = is_secret
|
|
61
|
+
return body
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _serialize_items(items: Iterable[WriteInput]) -> List[Dict[str, Any]]:
|
|
65
|
+
out: List[Dict[str, Any]] = []
|
|
66
|
+
for item in items:
|
|
67
|
+
if isinstance(item, WriteItem):
|
|
68
|
+
key, value, is_secret = item.key, item.value, item.is_secret
|
|
69
|
+
elif isinstance(item, Mapping):
|
|
70
|
+
key, value, is_secret = item["key"], item["value"], item.get("is_secret")
|
|
71
|
+
else: # pragma: no cover - defensive
|
|
72
|
+
raise TypeError("write items must be WriteItem instances or mappings")
|
|
73
|
+
entry: Dict[str, Any] = {"key": key, "value": value}
|
|
74
|
+
if is_secret is not None:
|
|
75
|
+
entry["is_secret"] = is_secret
|
|
76
|
+
out.append(entry)
|
|
77
|
+
return out
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _entry_from_data(data: Mapping[str, Any]) -> Entry:
|
|
81
|
+
return Entry(key=data["key"], value=data["value"], is_secret=data["is_secret"])
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _result_from_data(data: Mapping[str, Any]) -> WriteResult:
|
|
85
|
+
return WriteResult(key=data["key"], created=data["created"])
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _encode_segment(segment: str) -> str:
|
|
89
|
+
return quote(segment, safe="")
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _build_url(api_root: str, segments: Sequence[str]) -> str:
|
|
93
|
+
path = "/".join(_encode_segment(s) for s in segments)
|
|
94
|
+
return f"{api_root}/{path}"
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _parse_error(response: httpx.Response) -> Exception:
|
|
98
|
+
try:
|
|
99
|
+
body: Any = response.json()
|
|
100
|
+
except ValueError:
|
|
101
|
+
body = None
|
|
102
|
+
return error_from_response(response.status_code, body, response.text)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class Client:
|
|
106
|
+
"""Synchronous client for the Clovrix Public API.
|
|
107
|
+
|
|
108
|
+
The token resolves from ``token`` then the ``CLOVRIX_TOKEN`` environment
|
|
109
|
+
variable; the host from ``base_url`` then ``CLOVRIX_API_URL`` then
|
|
110
|
+
``https://app.clovrix.com``.
|
|
111
|
+
|
|
112
|
+
Usable as a context manager to close the underlying HTTP client::
|
|
113
|
+
|
|
114
|
+
with Client() as clovrix:
|
|
115
|
+
config = clovrix.get_entries("backend", "production")
|
|
116
|
+
"""
|
|
117
|
+
|
|
118
|
+
def __init__(
|
|
119
|
+
self,
|
|
120
|
+
token: Optional[str] = None,
|
|
121
|
+
base_url: Optional[str] = None,
|
|
122
|
+
*,
|
|
123
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
124
|
+
user_agent: Optional[str] = None,
|
|
125
|
+
http_client: Optional[httpx.Client] = None,
|
|
126
|
+
) -> None:
|
|
127
|
+
self._api_root = _resolve_base_url(base_url)
|
|
128
|
+
resolved_token = _resolve_token(token)
|
|
129
|
+
self._headers = _make_headers(resolved_token, user_agent)
|
|
130
|
+
self._owns_client = http_client is None
|
|
131
|
+
self._http = http_client or httpx.Client(timeout=timeout)
|
|
132
|
+
|
|
133
|
+
def _request(
|
|
134
|
+
self,
|
|
135
|
+
method: str,
|
|
136
|
+
segments: Sequence[str],
|
|
137
|
+
json_body: Optional[Mapping[str, Any]] = None,
|
|
138
|
+
) -> Dict[str, Any]:
|
|
139
|
+
url = _build_url(self._api_root, segments)
|
|
140
|
+
try:
|
|
141
|
+
response = self._http.request(method, url, headers=self._headers, json=json_body)
|
|
142
|
+
except httpx.TimeoutException as exc:
|
|
143
|
+
raise ClovrixConnectionError(f"Request to {url} timed out") from exc
|
|
144
|
+
except httpx.HTTPError as exc:
|
|
145
|
+
raise ClovrixConnectionError(f"Request to {url} failed") from exc
|
|
146
|
+
|
|
147
|
+
if not response.is_success:
|
|
148
|
+
raise _parse_error(response)
|
|
149
|
+
if not response.content:
|
|
150
|
+
return {}
|
|
151
|
+
return response.json()
|
|
152
|
+
|
|
153
|
+
def get_entry(self, project: str, environment: str, key: str) -> Entry:
|
|
154
|
+
"""Fetch a single entry's value (raises ``NotFoundError`` if unset)."""
|
|
155
|
+
data = self._request("GET", _entry_segments(project, environment, key))
|
|
156
|
+
return _entry_from_data(data)
|
|
157
|
+
|
|
158
|
+
def get_entries(self, project: str, environment: str) -> Dict[str, str]:
|
|
159
|
+
"""Fetch every entry for an environment as a ``key -> value`` dict."""
|
|
160
|
+
data = self._request("GET", _entry_segments(project, environment))
|
|
161
|
+
entries = data.get("entries")
|
|
162
|
+
return entries if isinstance(entries, dict) else {}
|
|
163
|
+
|
|
164
|
+
def set_entry(
|
|
165
|
+
self,
|
|
166
|
+
project: str,
|
|
167
|
+
environment: str,
|
|
168
|
+
key: str,
|
|
169
|
+
value: str,
|
|
170
|
+
*,
|
|
171
|
+
is_secret: Optional[bool] = None,
|
|
172
|
+
) -> WriteResult:
|
|
173
|
+
"""Write a single entry, creating it if it does not exist."""
|
|
174
|
+
body = _write_body(value, is_secret)
|
|
175
|
+
data = self._request("POST", _entry_segments(project, environment, key), body)
|
|
176
|
+
return _result_from_data(data)
|
|
177
|
+
|
|
178
|
+
def set_entries(
|
|
179
|
+
self, project: str, environment: str, items: Iterable[WriteInput]
|
|
180
|
+
) -> int:
|
|
181
|
+
"""Write up to 100 entries atomically; returns the number written."""
|
|
182
|
+
body = {"entries": _serialize_items(items)}
|
|
183
|
+
data = self._request("POST", _entry_segments(project, environment), body)
|
|
184
|
+
return data["written"]
|
|
185
|
+
|
|
186
|
+
def close(self) -> None:
|
|
187
|
+
if self._owns_client:
|
|
188
|
+
self._http.close()
|
|
189
|
+
|
|
190
|
+
def __enter__(self) -> "Client":
|
|
191
|
+
return self
|
|
192
|
+
|
|
193
|
+
def __exit__(self, *exc: object) -> None:
|
|
194
|
+
self.close()
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
class AsyncClient:
|
|
198
|
+
"""Asynchronous client for the Clovrix Public API.
|
|
199
|
+
|
|
200
|
+
Mirrors :class:`Client` with ``async`` methods::
|
|
201
|
+
|
|
202
|
+
async with AsyncClient() as clovrix:
|
|
203
|
+
config = await clovrix.get_entries("backend", "production")
|
|
204
|
+
"""
|
|
205
|
+
|
|
206
|
+
def __init__(
|
|
207
|
+
self,
|
|
208
|
+
token: Optional[str] = None,
|
|
209
|
+
base_url: Optional[str] = None,
|
|
210
|
+
*,
|
|
211
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
212
|
+
user_agent: Optional[str] = None,
|
|
213
|
+
http_client: Optional[httpx.AsyncClient] = None,
|
|
214
|
+
) -> None:
|
|
215
|
+
self._api_root = _resolve_base_url(base_url)
|
|
216
|
+
resolved_token = _resolve_token(token)
|
|
217
|
+
self._headers = _make_headers(resolved_token, user_agent)
|
|
218
|
+
self._owns_client = http_client is None
|
|
219
|
+
self._http = http_client or httpx.AsyncClient(timeout=timeout)
|
|
220
|
+
|
|
221
|
+
async def _request(
|
|
222
|
+
self,
|
|
223
|
+
method: str,
|
|
224
|
+
segments: Sequence[str],
|
|
225
|
+
json_body: Optional[Mapping[str, Any]] = None,
|
|
226
|
+
) -> Dict[str, Any]:
|
|
227
|
+
url = _build_url(self._api_root, segments)
|
|
228
|
+
try:
|
|
229
|
+
response = await self._http.request(
|
|
230
|
+
method, url, headers=self._headers, json=json_body
|
|
231
|
+
)
|
|
232
|
+
except httpx.TimeoutException as exc:
|
|
233
|
+
raise ClovrixConnectionError(f"Request to {url} timed out") from exc
|
|
234
|
+
except httpx.HTTPError as exc:
|
|
235
|
+
raise ClovrixConnectionError(f"Request to {url} failed") from exc
|
|
236
|
+
|
|
237
|
+
if not response.is_success:
|
|
238
|
+
raise _parse_error(response)
|
|
239
|
+
if not response.content:
|
|
240
|
+
return {}
|
|
241
|
+
return response.json()
|
|
242
|
+
|
|
243
|
+
async def get_entry(self, project: str, environment: str, key: str) -> Entry:
|
|
244
|
+
data = await self._request("GET", _entry_segments(project, environment, key))
|
|
245
|
+
return _entry_from_data(data)
|
|
246
|
+
|
|
247
|
+
async def get_entries(self, project: str, environment: str) -> Dict[str, str]:
|
|
248
|
+
data = await self._request("GET", _entry_segments(project, environment))
|
|
249
|
+
entries = data.get("entries")
|
|
250
|
+
return entries if isinstance(entries, dict) else {}
|
|
251
|
+
|
|
252
|
+
async def set_entry(
|
|
253
|
+
self,
|
|
254
|
+
project: str,
|
|
255
|
+
environment: str,
|
|
256
|
+
key: str,
|
|
257
|
+
value: str,
|
|
258
|
+
*,
|
|
259
|
+
is_secret: Optional[bool] = None,
|
|
260
|
+
) -> WriteResult:
|
|
261
|
+
body = _write_body(value, is_secret)
|
|
262
|
+
data = await self._request("POST", _entry_segments(project, environment, key), body)
|
|
263
|
+
return _result_from_data(data)
|
|
264
|
+
|
|
265
|
+
async def set_entries(
|
|
266
|
+
self, project: str, environment: str, items: Iterable[WriteInput]
|
|
267
|
+
) -> int:
|
|
268
|
+
body = {"entries": _serialize_items(items)}
|
|
269
|
+
data = await self._request("POST", _entry_segments(project, environment), body)
|
|
270
|
+
return data["written"]
|
|
271
|
+
|
|
272
|
+
async def aclose(self) -> None:
|
|
273
|
+
if self._owns_client:
|
|
274
|
+
await self._http.aclose()
|
|
275
|
+
|
|
276
|
+
async def __aenter__(self) -> "AsyncClient":
|
|
277
|
+
return self
|
|
278
|
+
|
|
279
|
+
async def __aexit__(self, *exc: object) -> None:
|
|
280
|
+
await self.aclose()
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""Exception hierarchy for the Clovrix SDK."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Optional
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ClovrixError(Exception):
|
|
9
|
+
"""Base class for every error raised by the SDK."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ClovrixConfigError(ClovrixError):
|
|
13
|
+
"""The client is misconfigured (e.g. no API token could be resolved)."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ClovrixConnectionError(ClovrixError):
|
|
17
|
+
"""A transport-level failure (DNS, connection, timeout) with no HTTP response."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class APIError(ClovrixError):
|
|
21
|
+
"""A non-2xx response from the API.
|
|
22
|
+
|
|
23
|
+
``billing_status`` is populated only for a 402 response
|
|
24
|
+
(``"pending_payment"`` or ``"restricted"``).
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
def __init__(
|
|
28
|
+
self,
|
|
29
|
+
message: str,
|
|
30
|
+
*,
|
|
31
|
+
status_code: int,
|
|
32
|
+
billing_status: Optional[str] = None,
|
|
33
|
+
) -> None:
|
|
34
|
+
super().__init__(message)
|
|
35
|
+
self.message = message
|
|
36
|
+
self.status_code = status_code
|
|
37
|
+
self.billing_status = billing_status
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class AuthenticationError(APIError):
|
|
41
|
+
"""401 — the token is missing, malformed, or expired."""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class BillingError(APIError):
|
|
45
|
+
"""402 — the organization's billing is pending setup or restricted."""
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class ForbiddenError(APIError):
|
|
49
|
+
"""403 — the token's role lacks the required read/write access."""
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class NotFoundError(APIError):
|
|
53
|
+
"""404 — the project, environment, or key was not found (or is out of scope)."""
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class ValidationError(APIError):
|
|
57
|
+
"""400 / 413 / 422 — the request was rejected; nothing was written."""
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class ServerError(APIError):
|
|
61
|
+
"""5xx — the server failed to process an otherwise valid request."""
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
_STATUS_TO_CLASS = {
|
|
65
|
+
401: AuthenticationError,
|
|
66
|
+
402: BillingError,
|
|
67
|
+
403: ForbiddenError,
|
|
68
|
+
404: NotFoundError,
|
|
69
|
+
400: ValidationError,
|
|
70
|
+
413: ValidationError,
|
|
71
|
+
422: ValidationError,
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def error_from_response(status_code: int, body: Any, raw: str) -> APIError:
|
|
76
|
+
"""Map an HTTP status + parsed body onto the most specific error class."""
|
|
77
|
+
message: Optional[str] = None
|
|
78
|
+
billing_status: Optional[str] = None
|
|
79
|
+
if isinstance(body, dict):
|
|
80
|
+
err = body.get("error")
|
|
81
|
+
if isinstance(err, str) and err:
|
|
82
|
+
message = err
|
|
83
|
+
bs = body.get("billing_status")
|
|
84
|
+
if isinstance(bs, str):
|
|
85
|
+
billing_status = bs
|
|
86
|
+
if not message:
|
|
87
|
+
message = raw or f"HTTP {status_code}"
|
|
88
|
+
|
|
89
|
+
cls = _STATUS_TO_CLASS.get(status_code)
|
|
90
|
+
if cls is None:
|
|
91
|
+
cls = ServerError if status_code >= 500 else APIError
|
|
92
|
+
return cls(message, status_code=status_code, billing_status=billing_status)
|
|
File without changes
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
|
|
6
|
+
import httpx
|
|
7
|
+
import pytest
|
|
8
|
+
|
|
9
|
+
from clovrix import (
|
|
10
|
+
AsyncClient,
|
|
11
|
+
BillingError,
|
|
12
|
+
Client,
|
|
13
|
+
ClovrixConfigError,
|
|
14
|
+
NotFoundError,
|
|
15
|
+
ValidationError,
|
|
16
|
+
WriteItem,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
TOKEN = "icr_test_token"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class MockAPI:
|
|
23
|
+
"""A configurable httpx MockTransport handler that records requests."""
|
|
24
|
+
|
|
25
|
+
def __init__(self, status: int = 200, body: object | None = None) -> None:
|
|
26
|
+
self.status = status
|
|
27
|
+
self.body = {} if body is None else body
|
|
28
|
+
self.requests: list[httpx.Request] = []
|
|
29
|
+
|
|
30
|
+
def __call__(self, request: httpx.Request) -> httpx.Response:
|
|
31
|
+
self.requests.append(request)
|
|
32
|
+
return httpx.Response(self.status, json=self.body)
|
|
33
|
+
|
|
34
|
+
def sync_client(self, token: str | None = TOKEN, **kwargs: object) -> Client:
|
|
35
|
+
http = httpx.Client(transport=httpx.MockTransport(self))
|
|
36
|
+
return Client(token=token, base_url="https://app.clovrix.com", http_client=http, **kwargs)
|
|
37
|
+
|
|
38
|
+
def async_client(self, token: str | None = TOKEN) -> AsyncClient:
|
|
39
|
+
http = httpx.AsyncClient(transport=httpx.MockTransport(self))
|
|
40
|
+
return AsyncClient(token=token, base_url="https://app.clovrix.com", http_client=http)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def test_requires_token(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
44
|
+
monkeypatch.delenv("CLOVRIX_TOKEN", raising=False)
|
|
45
|
+
with pytest.raises(ClovrixConfigError):
|
|
46
|
+
Client()
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def test_reads_env_token(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
50
|
+
monkeypatch.setenv("CLOVRIX_TOKEN", TOKEN)
|
|
51
|
+
api = MockAPI(body={"entries": {}})
|
|
52
|
+
http = httpx.Client(transport=httpx.MockTransport(api))
|
|
53
|
+
client = Client(base_url="https://app.clovrix.com", http_client=http)
|
|
54
|
+
client.get_entries("backend", "production")
|
|
55
|
+
assert api.requests[0].headers["authorization"] == f"Bearer {TOKEN}"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def test_default_base_url(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
59
|
+
monkeypatch.delenv("CLOVRIX_API_URL", raising=False)
|
|
60
|
+
api = MockAPI(body={"entries": {}})
|
|
61
|
+
http = httpx.Client(transport=httpx.MockTransport(api))
|
|
62
|
+
client = Client(token=TOKEN, http_client=http)
|
|
63
|
+
client.get_entries("backend", "production")
|
|
64
|
+
assert str(api.requests[0].url) == (
|
|
65
|
+
"https://app.clovrix.com/api/public/v1/projects/backend/environments/production/entries"
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def test_get_entry() -> None:
|
|
70
|
+
api = MockAPI(body={"key": "DATABASE_URL", "value": "postgres://x", "is_secret": True})
|
|
71
|
+
client = api.sync_client()
|
|
72
|
+
entry = client.get_entry("backend", "production", "DATABASE_URL")
|
|
73
|
+
assert (entry.key, entry.value, entry.is_secret) == ("DATABASE_URL", "postgres://x", True)
|
|
74
|
+
req = api.requests[0]
|
|
75
|
+
assert req.method == "GET"
|
|
76
|
+
assert req.url.path == (
|
|
77
|
+
"/api/public/v1/projects/backend/environments/production/entries/DATABASE_URL"
|
|
78
|
+
)
|
|
79
|
+
assert req.headers["authorization"] == f"Bearer {TOKEN}"
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def test_get_entries() -> None:
|
|
83
|
+
api = MockAPI(body={"entries": {"A": "1", "B": "2"}})
|
|
84
|
+
client = api.sync_client()
|
|
85
|
+
assert client.get_entries("backend", "production") == {"A": "1", "B": "2"}
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def test_get_entries_tolerates_missing_field() -> None:
|
|
89
|
+
api = MockAPI(body={})
|
|
90
|
+
client = api.sync_client()
|
|
91
|
+
assert client.get_entries("backend", "production") == {}
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def test_set_entry_created() -> None:
|
|
95
|
+
api = MockAPI(status=201, body={"key": "API_KEY", "created": True})
|
|
96
|
+
client = api.sync_client()
|
|
97
|
+
result = client.set_entry("backend", "production", "API_KEY", "secret", is_secret=True)
|
|
98
|
+
assert result.key == "API_KEY"
|
|
99
|
+
assert result.created is True
|
|
100
|
+
sent = json.loads(api.requests[0].content)
|
|
101
|
+
assert sent == {"value": "secret", "is_secret": True}
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def test_set_entry_update_omits_is_secret() -> None:
|
|
105
|
+
api = MockAPI(status=200, body={"key": "API_KEY", "created": False})
|
|
106
|
+
client = api.sync_client()
|
|
107
|
+
result = client.set_entry("backend", "production", "API_KEY", "v2")
|
|
108
|
+
assert result.created is False
|
|
109
|
+
assert json.loads(api.requests[0].content) == {"value": "v2"}
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def test_set_entries() -> None:
|
|
113
|
+
api = MockAPI(body={"written": 2})
|
|
114
|
+
client = api.sync_client()
|
|
115
|
+
written = client.set_entries(
|
|
116
|
+
"backend",
|
|
117
|
+
"production",
|
|
118
|
+
[WriteItem(key="A", value="1"), WriteItem(key="B", value="2", is_secret=True)],
|
|
119
|
+
)
|
|
120
|
+
assert written == 2
|
|
121
|
+
assert json.loads(api.requests[0].content) == {
|
|
122
|
+
"entries": [
|
|
123
|
+
{"key": "A", "value": "1"},
|
|
124
|
+
{"key": "B", "value": "2", "is_secret": True},
|
|
125
|
+
]
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def test_set_entries_accepts_mappings() -> None:
|
|
130
|
+
api = MockAPI(body={"written": 1})
|
|
131
|
+
client = api.sync_client()
|
|
132
|
+
assert client.set_entries("backend", "production", [{"key": "A", "value": "1"}]) == 1
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def test_url_encodes_segments() -> None:
|
|
136
|
+
api = MockAPI(body={"key": "A_KEY", "value": "1", "is_secret": False})
|
|
137
|
+
client = api.sync_client()
|
|
138
|
+
client.get_entry("my project", "production", "A_KEY")
|
|
139
|
+
assert b"/projects/my%20project/environments/production/entries/A_KEY" in api.requests[0].url.raw_path
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def test_not_found() -> None:
|
|
143
|
+
api = MockAPI(status=404, body={"error": "not found"})
|
|
144
|
+
client = api.sync_client()
|
|
145
|
+
with pytest.raises(NotFoundError) as exc:
|
|
146
|
+
client.get_entry("backend", "production", "MISSING")
|
|
147
|
+
assert exc.value.status_code == 404
|
|
148
|
+
assert str(exc.value) == "not found"
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def test_billing_error() -> None:
|
|
152
|
+
api = MockAPI(status=402, body={"error": "billing restricted", "billing_status": "restricted"})
|
|
153
|
+
client = api.sync_client()
|
|
154
|
+
with pytest.raises(BillingError) as exc:
|
|
155
|
+
client.get_entries("backend", "production")
|
|
156
|
+
assert exc.value.status_code == 402
|
|
157
|
+
assert exc.value.billing_status == "restricted"
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def test_validation_error() -> None:
|
|
161
|
+
api = MockAPI(status=422, body={"error": "invalid key"})
|
|
162
|
+
client = api.sync_client()
|
|
163
|
+
with pytest.raises(ValidationError):
|
|
164
|
+
client.set_entries("backend", "production", [WriteItem("bad key", "x")])
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def test_async_round_trip() -> None:
|
|
168
|
+
api = MockAPI(body={"entries": {"A": "1"}})
|
|
169
|
+
|
|
170
|
+
async def run() -> dict[str, str]:
|
|
171
|
+
async with api.async_client() as client:
|
|
172
|
+
return await client.get_entries("backend", "production")
|
|
173
|
+
|
|
174
|
+
assert asyncio.run(run()) == {"A": "1"}
|
|
175
|
+
assert api.requests[0].headers["authorization"] == f"Bearer {TOKEN}"
|