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.
@@ -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
+ ```
@@ -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}"