ohapi 0.1.0__py3-none-any.whl
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.
- ohapi/__init__.py +31 -0
- ohapi/_client.py +259 -0
- ohapi/_types.py +213 -0
- ohapi/_validate.py +74 -0
- ohapi/client.py +287 -0
- ohapi/py.typed +0 -0
- ohapi-0.1.0.dist-info/METADATA +124 -0
- ohapi-0.1.0.dist-info/RECORD +11 -0
- ohapi-0.1.0.dist-info/WHEEL +5 -0
- ohapi-0.1.0.dist-info/licenses/LICENSE +21 -0
- ohapi-0.1.0.dist-info/top_level.txt +1 -0
ohapi/__init__.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Typed client for the ohapi secrets API. Zero dependencies, `urllib` only."""
|
|
2
|
+
|
|
3
|
+
__version__ = "0.1.0" # keep equal to pyproject.toml `version`; the tests assert it
|
|
4
|
+
|
|
5
|
+
from ._client import OhapiError # noqa: E402
|
|
6
|
+
from ._types import ( # noqa: E402
|
|
7
|
+
ChangeRequest,
|
|
8
|
+
Collection,
|
|
9
|
+
Deleted,
|
|
10
|
+
Environment,
|
|
11
|
+
EnvironmentRef,
|
|
12
|
+
EnvironmentSummary,
|
|
13
|
+
Me,
|
|
14
|
+
MeApiKey,
|
|
15
|
+
OrganizationRef,
|
|
16
|
+
Patched,
|
|
17
|
+
Pending,
|
|
18
|
+
Project,
|
|
19
|
+
ProjectSummary,
|
|
20
|
+
Scope,
|
|
21
|
+
SecretMetadata,
|
|
22
|
+
SecretValue,
|
|
23
|
+
Written,
|
|
24
|
+
)
|
|
25
|
+
from .client import UNSET, Ohapi # noqa: E402
|
|
26
|
+
|
|
27
|
+
__all__ = [
|
|
28
|
+
"ChangeRequest", "Collection", "Deleted", "Environment", "EnvironmentRef", "EnvironmentSummary",
|
|
29
|
+
"Me", "MeApiKey", "Ohapi", "OhapiError", "OrganizationRef", "Patched", "Pending", "Project",
|
|
30
|
+
"ProjectSummary", "Scope", "SecretMetadata", "SecretValue", "UNSET", "Written", "__version__",
|
|
31
|
+
]
|
ohapi/_client.py
ADDED
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
"""The one place a request is made: urllib with no redirects, a timeout and an error mapping."""
|
|
2
|
+
|
|
3
|
+
import http.client
|
|
4
|
+
import json
|
|
5
|
+
import re
|
|
6
|
+
import socket
|
|
7
|
+
import threading
|
|
8
|
+
import urllib.error
|
|
9
|
+
import urllib.request
|
|
10
|
+
from urllib.parse import urlencode, urlsplit
|
|
11
|
+
|
|
12
|
+
from . import __version__
|
|
13
|
+
|
|
14
|
+
DEFAULT_API_URL = "https://ohapi.dev"
|
|
15
|
+
DEFAULT_TIMEOUT_S = 30.0
|
|
16
|
+
_LOOPBACK_HOSTS = ("127.0.0.1", "localhost", "::1")
|
|
17
|
+
_DEFAULT_PORTS = {"http": 80, "https": 443}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class OhapiError(Exception):
|
|
21
|
+
"""Every failure of this package.
|
|
22
|
+
|
|
23
|
+
`code` is one of: invalid, unauthenticated, forbidden, not_found, conflict, unprocessable,
|
|
24
|
+
rate_limited, server, network, timeout, rejected. `status` is the HTTP status, or 0 when no
|
|
25
|
+
response was involved. `change_request` is set on `rejected`: the terminal request.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
def __init__(self, code, message, *, status=0, retry_after_seconds=None, change_request=None):
|
|
29
|
+
super().__init__(message)
|
|
30
|
+
self.code = code
|
|
31
|
+
self.status = status
|
|
32
|
+
self.retry_after_seconds = retry_after_seconds
|
|
33
|
+
self.change_request = change_request
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def normalize_api_url(raw):
|
|
37
|
+
"""The server: its origin, plus a sub-path if it is hosted under one (no trailing slash).
|
|
38
|
+
|
|
39
|
+
Plain `http://` only for loopback, and no userinfo: the same rule as the CLI's `normalizeApiUrl`.
|
|
40
|
+
"""
|
|
41
|
+
try:
|
|
42
|
+
parts = urlsplit(raw)
|
|
43
|
+
host, port = parts.hostname, parts.port
|
|
44
|
+
except (ValueError, AttributeError):
|
|
45
|
+
raise OhapiError("invalid", "apiUrl is not a URL") from None
|
|
46
|
+
scheme = parts.scheme.lower()
|
|
47
|
+
if not scheme or not host:
|
|
48
|
+
raise OhapiError("invalid", "apiUrl is not a URL")
|
|
49
|
+
if scheme != "https" and not (scheme == "http" and host in _LOOPBACK_HOSTS):
|
|
50
|
+
raise OhapiError("invalid", "apiUrl must be https:// (http:// is allowed only for loopback)")
|
|
51
|
+
if parts.username is not None or parts.password is not None:
|
|
52
|
+
raise OhapiError("invalid", "apiUrl must not carry credentials")
|
|
53
|
+
shown = "[%s]" % host if ":" in host else host
|
|
54
|
+
if port is not None and port != _DEFAULT_PORTS[scheme]:
|
|
55
|
+
shown = "%s:%d" % (shown, port)
|
|
56
|
+
return "%s://%s%s" % (scheme, shown, parts.path.rstrip("/"))
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _error_code_for(status):
|
|
60
|
+
if status == 401:
|
|
61
|
+
return "unauthenticated"
|
|
62
|
+
if status == 403:
|
|
63
|
+
return "forbidden"
|
|
64
|
+
if status == 404:
|
|
65
|
+
return "not_found"
|
|
66
|
+
if status == 409:
|
|
67
|
+
return "conflict"
|
|
68
|
+
if status == 422:
|
|
69
|
+
return "unprocessable"
|
|
70
|
+
if status == 429:
|
|
71
|
+
return "rate_limited"
|
|
72
|
+
if 400 <= status < 500:
|
|
73
|
+
return "invalid"
|
|
74
|
+
return "server"
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _retry_after(headers):
|
|
78
|
+
match = re.match(r"\s*([+-]?\d+)", headers.get("retry-after") or "")
|
|
79
|
+
if match and int(match.group(1)) >= 0:
|
|
80
|
+
return int(match.group(1))
|
|
81
|
+
return None
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class _NoRedirect(urllib.request.HTTPRedirectHandler):
|
|
85
|
+
# The base `http_error_30x` reads `Location` and builds the new URL before it asks
|
|
86
|
+
# `redirect_request`, so a malformed `Location` would raise from there. Returning None here
|
|
87
|
+
# means `Location` is never read: `OpenerDirector.error` falls through to
|
|
88
|
+
# `HTTPDefaultErrorHandler`, which raises `HTTPError` with the 3xx code.
|
|
89
|
+
def http_error_301(self, req, fp, code, msg, headers):
|
|
90
|
+
return None
|
|
91
|
+
|
|
92
|
+
http_error_302 = http_error_303 = http_error_307 = http_error_308 = http_error_301
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class _Deadline:
|
|
96
|
+
"""A total deadline for one request, enforced by shutting its sockets down.
|
|
97
|
+
|
|
98
|
+
A socket timeout bounds one read. A server that sends a byte every few seconds never trips it.
|
|
99
|
+
"""
|
|
100
|
+
|
|
101
|
+
def __init__(self, seconds):
|
|
102
|
+
self.expired = False
|
|
103
|
+
self._sockets = []
|
|
104
|
+
self._lock = threading.Lock()
|
|
105
|
+
self._timer = threading.Timer(seconds, self._fire)
|
|
106
|
+
self._timer.daemon = True
|
|
107
|
+
|
|
108
|
+
def start(self):
|
|
109
|
+
self._timer.start()
|
|
110
|
+
|
|
111
|
+
def stop(self):
|
|
112
|
+
self._timer.cancel()
|
|
113
|
+
|
|
114
|
+
def add(self, sock):
|
|
115
|
+
with self._lock:
|
|
116
|
+
self._sockets.append(sock)
|
|
117
|
+
expired = self.expired
|
|
118
|
+
if expired:
|
|
119
|
+
self._kill(sock)
|
|
120
|
+
|
|
121
|
+
def _fire(self):
|
|
122
|
+
with self._lock:
|
|
123
|
+
self.expired = True
|
|
124
|
+
sockets = list(self._sockets)
|
|
125
|
+
for sock in sockets:
|
|
126
|
+
self._kill(sock)
|
|
127
|
+
|
|
128
|
+
@staticmethod
|
|
129
|
+
def _kill(sock):
|
|
130
|
+
try:
|
|
131
|
+
sock.shutdown(socket.SHUT_RDWR)
|
|
132
|
+
except OSError:
|
|
133
|
+
pass
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _connection(base):
|
|
137
|
+
class Connection(base):
|
|
138
|
+
def __init__(self, *args, deadline=None, **kwargs):
|
|
139
|
+
super().__init__(*args, **kwargs)
|
|
140
|
+
self._deadline = deadline
|
|
141
|
+
|
|
142
|
+
def connect(self):
|
|
143
|
+
super().connect()
|
|
144
|
+
self._deadline.add(self.sock)
|
|
145
|
+
|
|
146
|
+
return Connection
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
_HTTPConnection = _connection(http.client.HTTPConnection)
|
|
150
|
+
_HTTPSConnection = _connection(http.client.HTTPSConnection)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
class _HTTPHandler(urllib.request.HTTPHandler):
|
|
154
|
+
def http_open(self, req):
|
|
155
|
+
return self.do_open(lambda host, **kw: _HTTPConnection(host, deadline=req.ohapi_deadline, **kw), req)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
class _HTTPSHandler(urllib.request.HTTPSHandler):
|
|
159
|
+
def https_open(self, req):
|
|
160
|
+
return self.do_open(
|
|
161
|
+
lambda host, **kw: _HTTPSConnection(host, deadline=req.ohapi_deadline, **kw), req, context=self._context
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
class Client:
|
|
166
|
+
def __init__(self, token, api_url=None, timeout_s=DEFAULT_TIMEOUT_S, user_agent=None):
|
|
167
|
+
if not isinstance(token, str) or token == "":
|
|
168
|
+
raise OhapiError("invalid", "token is required")
|
|
169
|
+
# Never sent: http.client would raise, and its message quotes the header value.
|
|
170
|
+
if re.search(r"[\r\n\0]", token):
|
|
171
|
+
raise OhapiError("invalid", "token must not contain control characters")
|
|
172
|
+
# Name-mangled, and kept out of repr(): a logger that prints the client must not print the bearer.
|
|
173
|
+
self.__token = token
|
|
174
|
+
self.api_url = normalize_api_url(DEFAULT_API_URL if api_url is None else api_url)
|
|
175
|
+
self.timeout_s = timeout_s
|
|
176
|
+
self.user_agent = user_agent or "ohapi-sdk-python/%s" % __version__
|
|
177
|
+
self.__opener = urllib.request.build_opener(_NoRedirect, _HTTPHandler, _HTTPSHandler)
|
|
178
|
+
|
|
179
|
+
def __repr__(self):
|
|
180
|
+
return "Client(api_url=%r)" % self.api_url
|
|
181
|
+
|
|
182
|
+
__str__ = __repr__
|
|
183
|
+
|
|
184
|
+
def request(self, method, path, *, query=None, headers=None, body=None, timeout_s=None):
|
|
185
|
+
"""Returns `(status, parsed JSON or None)`. A 202 is not an error; everything else outside 2xx raises.
|
|
186
|
+
|
|
187
|
+
`timeout_s` is a total deadline for the whole exchange, headers and body included.
|
|
188
|
+
"""
|
|
189
|
+
url = self.api_url + path
|
|
190
|
+
pairs = [(name, value) for name, value in (query or {}).items() if value is not None]
|
|
191
|
+
if pairs:
|
|
192
|
+
url += "?" + urlencode(pairs)
|
|
193
|
+
data = None
|
|
194
|
+
if body is not None:
|
|
195
|
+
try:
|
|
196
|
+
data = json.dumps(body, separators=(",", ":"), ensure_ascii=False).encode()
|
|
197
|
+
except UnicodeEncodeError:
|
|
198
|
+
raise OhapiError("invalid", "body: must be valid Unicode") from None
|
|
199
|
+
seconds = timeout_s or self.timeout_s
|
|
200
|
+
|
|
201
|
+
# The headers are built inside this call and bound to no local: a traceback (or a logger
|
|
202
|
+
# that prints frame locals) must not find the bearer in this frame. For the same reason a
|
|
203
|
+
# failure is recorded in the `except` as a code only, and raised after it with no cause:
|
|
204
|
+
# the original error's frames (urllib's `do_open` holds the headers) must not be reachable.
|
|
205
|
+
request = urllib.request.Request(
|
|
206
|
+
url, data=data, method=method,
|
|
207
|
+
headers={
|
|
208
|
+
"authorization": "Bearer " + self.__token,
|
|
209
|
+
"user-agent": self.user_agent,
|
|
210
|
+
"accept": "application/json",
|
|
211
|
+
# urllib would say form-encoded: it does so for any request with data.
|
|
212
|
+
**({"content-type": "application/json"} if data is not None else {}),
|
|
213
|
+
**(headers or {}),
|
|
214
|
+
},
|
|
215
|
+
)
|
|
216
|
+
request.ohapi_deadline = deadline = _Deadline(seconds)
|
|
217
|
+
deadline.start()
|
|
218
|
+
|
|
219
|
+
failure = None
|
|
220
|
+
status, raw, response_headers = 0, b"", {}
|
|
221
|
+
try:
|
|
222
|
+
with self.__opener.open(request, timeout=seconds) as response:
|
|
223
|
+
status, raw = response.status, response.read()
|
|
224
|
+
except urllib.error.HTTPError as error:
|
|
225
|
+
status = error.code
|
|
226
|
+
response_headers = {name.lower(): value for name, value in error.headers.items()}
|
|
227
|
+
try:
|
|
228
|
+
raw = error.read()
|
|
229
|
+
except (OSError, http.client.HTTPException):
|
|
230
|
+
raw = b""
|
|
231
|
+
finally:
|
|
232
|
+
error.close()
|
|
233
|
+
except (OSError, http.client.HTTPException):
|
|
234
|
+
failure = "network"
|
|
235
|
+
finally:
|
|
236
|
+
deadline.stop()
|
|
237
|
+
|
|
238
|
+
if deadline.expired:
|
|
239
|
+
raise OhapiError("timeout", "No response within %s s" % seconds)
|
|
240
|
+
if failure is not None:
|
|
241
|
+
raise OhapiError(failure, "Could not reach the ohapi server")
|
|
242
|
+
|
|
243
|
+
try:
|
|
244
|
+
parsed = json.loads(raw) if raw else None
|
|
245
|
+
except ValueError:
|
|
246
|
+
parsed = None
|
|
247
|
+
if 200 <= status < 300:
|
|
248
|
+
raise OhapiError("server", "HTTP %d" % status, status=status) from None
|
|
249
|
+
|
|
250
|
+
if 200 <= status < 300:
|
|
251
|
+
return status, parsed
|
|
252
|
+
|
|
253
|
+
# A 3xx is never followed (the bearer would go with it) and is never a JSON error.
|
|
254
|
+
text = "HTTP %d" % status
|
|
255
|
+
if status >= 400 and isinstance(parsed, dict) and isinstance(parsed.get("error"), str):
|
|
256
|
+
text = parsed["error"]
|
|
257
|
+
raise OhapiError(
|
|
258
|
+
_error_code_for(status), text, status=status, retry_after_seconds=_retry_after(response_headers)
|
|
259
|
+
)
|
ohapi/_types.py
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
"""Response types, hand-written to mirror `sdk/src/types.ts`.
|
|
2
|
+
|
|
3
|
+
Each is built from the fields it knows and ignores the rest, so a field the server adds later is
|
|
4
|
+
invisible here (the additive contract of `v1`). A field the server stops sending is a `server`
|
|
5
|
+
error, not a `TypeError` from a constructor.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import dataclasses
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from typing import Generic, List, Optional, TypeVar
|
|
11
|
+
|
|
12
|
+
from ._client import OhapiError
|
|
13
|
+
|
|
14
|
+
T = TypeVar("T")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _camel(name):
|
|
18
|
+
head, *rest = name.split("_")
|
|
19
|
+
return head + "".join(word.capitalize() for word in rest)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def make(cls, data, nested=None):
|
|
23
|
+
"""Build `cls` from a camelCase JSON object. `nested` maps a field name to a converter."""
|
|
24
|
+
if not isinstance(data, dict):
|
|
25
|
+
raise OhapiError("server", "Response is not a JSON object")
|
|
26
|
+
values = {}
|
|
27
|
+
for field in dataclasses.fields(cls):
|
|
28
|
+
name = _camel(field.name)
|
|
29
|
+
if name in data:
|
|
30
|
+
value = data[name]
|
|
31
|
+
if nested and field.name in nested and value is not None:
|
|
32
|
+
value = nested[field.name](value)
|
|
33
|
+
values[field.name] = value
|
|
34
|
+
try:
|
|
35
|
+
return cls(**values)
|
|
36
|
+
except TypeError:
|
|
37
|
+
raise OhapiError("server", "Response is missing a field of %s" % cls.__name__) from None
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(frozen=True)
|
|
41
|
+
class Scope:
|
|
42
|
+
project_id: Optional[str]
|
|
43
|
+
environments: List[str]
|
|
44
|
+
access: str # "READ" or "WRITE"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass(frozen=True)
|
|
48
|
+
class MeApiKey:
|
|
49
|
+
id: str
|
|
50
|
+
name: str
|
|
51
|
+
expires_at: Optional[str]
|
|
52
|
+
oidc_trust_id: Optional[str]
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass(frozen=True)
|
|
56
|
+
class Me:
|
|
57
|
+
id: str
|
|
58
|
+
email: Optional[str]
|
|
59
|
+
name: Optional[str]
|
|
60
|
+
is_service_account: bool
|
|
61
|
+
api_key: MeApiKey
|
|
62
|
+
scope: Optional[Scope]
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dataclass(frozen=True)
|
|
66
|
+
class OrganizationRef:
|
|
67
|
+
id: str
|
|
68
|
+
name: str
|
|
69
|
+
slug: str
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass(frozen=True)
|
|
73
|
+
class EnvironmentRef:
|
|
74
|
+
id: str
|
|
75
|
+
name: str
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dataclass(frozen=True)
|
|
79
|
+
class ProjectSummary:
|
|
80
|
+
id: str
|
|
81
|
+
name: str
|
|
82
|
+
slug: str
|
|
83
|
+
organization: OrganizationRef
|
|
84
|
+
role: str # "ADMIN", "MEMBER" or "VIEWER"
|
|
85
|
+
created_at: str
|
|
86
|
+
environments: List[EnvironmentRef]
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@dataclass(frozen=True)
|
|
90
|
+
class Project:
|
|
91
|
+
id: str
|
|
92
|
+
name: str
|
|
93
|
+
slug: str
|
|
94
|
+
organization_id: str
|
|
95
|
+
role: str
|
|
96
|
+
environments: List[EnvironmentRef]
|
|
97
|
+
created_at: str
|
|
98
|
+
updated_at: str
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@dataclass(frozen=True)
|
|
102
|
+
class EnvironmentSummary:
|
|
103
|
+
id: str
|
|
104
|
+
name: str
|
|
105
|
+
secret_count: int
|
|
106
|
+
created_at: str
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@dataclass(frozen=True)
|
|
110
|
+
class Environment:
|
|
111
|
+
id: str
|
|
112
|
+
name: str
|
|
113
|
+
project_id: str
|
|
114
|
+
secret_count: int
|
|
115
|
+
created_at: str
|
|
116
|
+
updated_at: str
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
@dataclass(frozen=True)
|
|
120
|
+
class SecretMetadata:
|
|
121
|
+
"""Metadata only. There is no value on this type, and reading it writes no audit row."""
|
|
122
|
+
|
|
123
|
+
id: str
|
|
124
|
+
key: str
|
|
125
|
+
version: int # bumps once per value change and never for a metadata change: the drift signal
|
|
126
|
+
value_type: str
|
|
127
|
+
format_regex: Optional[str]
|
|
128
|
+
rotation_due_at: Optional[str]
|
|
129
|
+
expires_at: Optional[str]
|
|
130
|
+
is_reference: bool
|
|
131
|
+
updated_at: str
|
|
132
|
+
updated_by_id: Optional[str]
|
|
133
|
+
environment: str
|
|
134
|
+
project_id: str
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
@dataclass(frozen=True)
|
|
138
|
+
class SecretValue:
|
|
139
|
+
key: str
|
|
140
|
+
value: str
|
|
141
|
+
is_reference: bool
|
|
142
|
+
resolved: bool
|
|
143
|
+
value_type: str
|
|
144
|
+
expires_at: Optional[str]
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
@dataclass(frozen=True)
|
|
148
|
+
class ChangeRequest:
|
|
149
|
+
id: str
|
|
150
|
+
status: str # "PENDING", "APPLIED", "REJECTED" or "CANCELLED"
|
|
151
|
+
action: str # "CREATE", "UPDATE" or "DELETE"
|
|
152
|
+
secret_key: str
|
|
153
|
+
environment_name: str
|
|
154
|
+
project_id: str
|
|
155
|
+
required_approvals: int
|
|
156
|
+
approvals: int
|
|
157
|
+
created_at: str
|
|
158
|
+
resolved_at: Optional[str]
|
|
159
|
+
note: Optional[str]
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
@dataclass(frozen=True)
|
|
163
|
+
class Collection(Generic[T]):
|
|
164
|
+
data: List[T]
|
|
165
|
+
page: int
|
|
166
|
+
page_size: int
|
|
167
|
+
total: int
|
|
168
|
+
total_pages: int
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
@dataclass(frozen=True)
|
|
172
|
+
class Pending:
|
|
173
|
+
"""Dual control holds the change: nothing was written yet. Check for this before reading `version`."""
|
|
174
|
+
|
|
175
|
+
change_request_id: str
|
|
176
|
+
key: str
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
@dataclass(frozen=True)
|
|
180
|
+
class Written:
|
|
181
|
+
key: str
|
|
182
|
+
version: int
|
|
183
|
+
created: bool
|
|
184
|
+
id: Optional[str] = None
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
@dataclass(frozen=True)
|
|
188
|
+
class Patched:
|
|
189
|
+
key: str
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
@dataclass(frozen=True)
|
|
193
|
+
class Deleted:
|
|
194
|
+
key: str
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _list_of(cls):
|
|
198
|
+
return lambda rows: [make(cls, row) for row in rows]
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _collection(cls):
|
|
202
|
+
def build(body):
|
|
203
|
+
collection = make(Collection, body, {"data": _list_of(cls)})
|
|
204
|
+
return collection
|
|
205
|
+
|
|
206
|
+
return build
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
# Converters used by `client.py`.
|
|
210
|
+
ME_NESTED = {"api_key": lambda v: make(MeApiKey, v), "scope": lambda v: make(Scope, v)}
|
|
211
|
+
PROJECT_SUMMARY_NESTED = {"organization": lambda v: make(OrganizationRef, v), "environments": _list_of(EnvironmentRef)}
|
|
212
|
+
PROJECT_NESTED = {"environments": _list_of(EnvironmentRef)}
|
|
213
|
+
collection_of = _collection
|
ohapi/_validate.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""Name and value rules, checked before a URL is composed.
|
|
2
|
+
|
|
3
|
+
The patterns are the Node SDK's (`sdk/src/index.ts`), which are the server's
|
|
4
|
+
(`src/lib/api/paths.ts`). A name that fails raises `invalid` and nothing is sent.
|
|
5
|
+
`fullmatch` is deliberate: `$` in Python matches before a trailing newline.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import re
|
|
9
|
+
|
|
10
|
+
from ._client import OhapiError
|
|
11
|
+
|
|
12
|
+
# cuid is Zod 4's `z.cuid()`; keep it as lax as the server, never stricter.
|
|
13
|
+
_CUID = re.compile(r"[cC][0-9a-z]{6,}")
|
|
14
|
+
_ENVIRONMENT = re.compile(r"[a-z0-9][a-z0-9_-]*")
|
|
15
|
+
_KEY = re.compile(r"[A-Za-z_][A-Za-z0-9_]*")
|
|
16
|
+
# The server counts UTF-16 code units (`z.string().max(64 * 1024)`), so this does too.
|
|
17
|
+
MAX_VALUE_LENGTH = 64 * 1024
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _invalid(field, rule):
|
|
21
|
+
# Names the field and the rule, never the offending value.
|
|
22
|
+
raise OhapiError("invalid", "%s: %s" % (field, rule))
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def project_id(value):
|
|
26
|
+
if isinstance(value, str) and _CUID.fullmatch(value):
|
|
27
|
+
return value
|
|
28
|
+
_invalid("projectId", "must be a project id")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def change_request_id(value):
|
|
32
|
+
if isinstance(value, str) and _CUID.fullmatch(value):
|
|
33
|
+
return value
|
|
34
|
+
_invalid("changeRequestId", "must be a change request id")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def environment(value):
|
|
38
|
+
if isinstance(value, str) and len(value) <= 64 and _ENVIRONMENT.fullmatch(value):
|
|
39
|
+
return value
|
|
40
|
+
_invalid("environment", "use lowercase letters, numbers, dashes and underscores, at most 64")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def key(value):
|
|
44
|
+
if isinstance(value, str) and len(value) <= 255 and _KEY.fullmatch(value):
|
|
45
|
+
return value
|
|
46
|
+
_invalid("key", "must look like an env var, e.g. DATABASE_URL")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def text(field, value):
|
|
50
|
+
"""A string that can be sent as UTF-8. A lone surrogate cannot, and would raise deep in the encoder."""
|
|
51
|
+
try:
|
|
52
|
+
value.encode("utf-8")
|
|
53
|
+
except UnicodeEncodeError:
|
|
54
|
+
_invalid(field, "must be valid Unicode")
|
|
55
|
+
return value
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def secret_value(value):
|
|
59
|
+
if isinstance(value, str) and len(text("value", value).encode("utf-16-le")) // 2 <= MAX_VALUE_LENGTH:
|
|
60
|
+
return value
|
|
61
|
+
_invalid("value", "must be a string of at most 64 KB")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def user_agent(value):
|
|
65
|
+
# A header value: printable ASCII only, so it can never carry a line break.
|
|
66
|
+
if isinstance(value, str) and 0 < len(value) <= 256 and value.isascii() and value.isprintable():
|
|
67
|
+
return value
|
|
68
|
+
_invalid("userAgent", "must be 1 to 256 printable ASCII characters")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def timeout_s(value):
|
|
72
|
+
if isinstance(value, (int, float)) and not isinstance(value, bool) and 0 < value < float("inf"):
|
|
73
|
+
return value
|
|
74
|
+
_invalid("timeoutS", "must be a number of seconds above 0")
|
ohapi/client.py
ADDED
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
"""`Ohapi`: the typed client. Names are the Node SDK's (`sdk/src/index.ts`) in snake_case."""
|
|
2
|
+
|
|
3
|
+
import datetime
|
|
4
|
+
import threading
|
|
5
|
+
import time
|
|
6
|
+
|
|
7
|
+
from . import _validate as check
|
|
8
|
+
from ._client import DEFAULT_TIMEOUT_S, Client, OhapiError
|
|
9
|
+
from ._types import (
|
|
10
|
+
PROJECT_NESTED,
|
|
11
|
+
PROJECT_SUMMARY_NESTED,
|
|
12
|
+
ME_NESTED,
|
|
13
|
+
ChangeRequest,
|
|
14
|
+
Deleted,
|
|
15
|
+
Environment,
|
|
16
|
+
EnvironmentSummary,
|
|
17
|
+
Me,
|
|
18
|
+
Patched,
|
|
19
|
+
Pending,
|
|
20
|
+
Project,
|
|
21
|
+
ProjectSummary,
|
|
22
|
+
SecretMetadata,
|
|
23
|
+
SecretValue,
|
|
24
|
+
Written,
|
|
25
|
+
collection_of,
|
|
26
|
+
make,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
# "Omitted" and "None" are different things on the wire: an omitted field leaves the column alone,
|
|
30
|
+
# an explicit None (JSON null) clears it. A `None` default would collapse the two.
|
|
31
|
+
UNSET = object()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _date(value):
|
|
35
|
+
if isinstance(value, datetime.datetime):
|
|
36
|
+
utc = value.astimezone(datetime.timezone.utc)
|
|
37
|
+
return utc.strftime("%Y-%m-%dT%H:%M:%S.") + "%03dZ" % (utc.microsecond // 1000)
|
|
38
|
+
if value is None or isinstance(value, str):
|
|
39
|
+
return value
|
|
40
|
+
raise OhapiError("invalid", "date: use an ISO 8601 string or a datetime")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _given(**fields):
|
|
44
|
+
"""The fields the caller set, as the JSON names the server uses."""
|
|
45
|
+
names = {
|
|
46
|
+
"format_regex": "formatRegex", "rotation_due_at": "rotationDueAt", "expires_at": "expiresAt",
|
|
47
|
+
"value_type": "valueType", "note": "note",
|
|
48
|
+
}
|
|
49
|
+
body = {}
|
|
50
|
+
for name, value in fields.items():
|
|
51
|
+
if value is UNSET:
|
|
52
|
+
continue
|
|
53
|
+
if isinstance(value, str):
|
|
54
|
+
check.text(name, value)
|
|
55
|
+
body[names[name]] = _date(value) if name in ("rotation_due_at", "expires_at") else value
|
|
56
|
+
return body
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _env_path(project_id, environment):
|
|
60
|
+
return "/api/v1/projects/%s/environments/%s" % (check.project_id(project_id), check.environment(environment))
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _secret_path(project_id, environment, key):
|
|
64
|
+
return "%s/secrets/%s" % (_env_path(project_id, environment), check.key(key))
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _target_headers(project_id, environment):
|
|
68
|
+
return {"x-project-id": check.project_id(project_id), "x-environment": check.environment(environment)}
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _confirm(confirm_production):
|
|
72
|
+
return {"x-confirm-production": "true"} if confirm_production else {}
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _written(status, body, key, cls):
|
|
76
|
+
# By status, not by "the request worked": a 202 succeeds and nothing was written.
|
|
77
|
+
if status == 202:
|
|
78
|
+
if not isinstance(body, dict) or not isinstance(body.get("changeRequestId"), str):
|
|
79
|
+
raise OhapiError("server", "Response has no change request id")
|
|
80
|
+
return Pending(change_request_id=body["changeRequestId"], key=key)
|
|
81
|
+
return make(cls, body)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _walk(fetch_page):
|
|
85
|
+
page = 1
|
|
86
|
+
while True:
|
|
87
|
+
result = fetch_page(page)
|
|
88
|
+
# A missing count would loop forever.
|
|
89
|
+
if not isinstance(result.total_pages, int):
|
|
90
|
+
raise OhapiError("server", "Response has no page count")
|
|
91
|
+
yield from result.data
|
|
92
|
+
if page >= result.total_pages:
|
|
93
|
+
return
|
|
94
|
+
page += 1
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class _Projects:
|
|
98
|
+
def __init__(self, client):
|
|
99
|
+
self._c = client
|
|
100
|
+
|
|
101
|
+
def list(self):
|
|
102
|
+
"""`GET /api/v1/projects` (the frozen shape, unwrapped). Names only; no audit row."""
|
|
103
|
+
_, body = self._c.request("GET", "/api/v1/projects")
|
|
104
|
+
rows = body.get("projects") if isinstance(body, dict) else None
|
|
105
|
+
return [make(ProjectSummary, row, PROJECT_SUMMARY_NESTED) for row in rows or []]
|
|
106
|
+
|
|
107
|
+
def get(self, project_id):
|
|
108
|
+
_, body = self._c.request("GET", "/api/v1/projects/" + check.project_id(project_id))
|
|
109
|
+
return make(Project, body, PROJECT_NESTED)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class _Environments:
|
|
113
|
+
def __init__(self, client):
|
|
114
|
+
self._c = client
|
|
115
|
+
|
|
116
|
+
def list(self, project_id, *, page=None, page_size=None):
|
|
117
|
+
_, body = self._c.request(
|
|
118
|
+
"GET", "/api/v1/projects/%s/environments" % check.project_id(project_id),
|
|
119
|
+
query={"page": page, "pageSize": page_size},
|
|
120
|
+
)
|
|
121
|
+
return collection_of(EnvironmentSummary)(body)
|
|
122
|
+
|
|
123
|
+
def iterate(self, project_id, *, page_size=None):
|
|
124
|
+
return _walk(lambda page: self.list(project_id, page=page, page_size=page_size))
|
|
125
|
+
|
|
126
|
+
def get(self, project_id, environment):
|
|
127
|
+
_, body = self._c.request("GET", _env_path(project_id, environment))
|
|
128
|
+
return make(Environment, body)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class _Secrets:
|
|
132
|
+
def __init__(self, client):
|
|
133
|
+
self._c = client
|
|
134
|
+
|
|
135
|
+
def list(self, project_id, environment, *, page=None, page_size=None):
|
|
136
|
+
"""Metadata only. **Writes no audit row.**"""
|
|
137
|
+
_, body = self._c.request(
|
|
138
|
+
"GET", _env_path(project_id, environment) + "/secrets", query={"page": page, "pageSize": page_size}
|
|
139
|
+
)
|
|
140
|
+
return collection_of(SecretMetadata)(body)
|
|
141
|
+
|
|
142
|
+
def iterate(self, project_id, environment, *, page_size=None):
|
|
143
|
+
return _walk(lambda page: self.list(project_id, environment, page=page, page_size=page_size))
|
|
144
|
+
|
|
145
|
+
def get(self, project_id, environment, key):
|
|
146
|
+
"""One secret's metadata. **Writes no audit row**, and has no value. `version` is the drift signal."""
|
|
147
|
+
_, body = self._c.request("GET", _secret_path(project_id, environment, key))
|
|
148
|
+
return make(SecretMetadata, body)
|
|
149
|
+
|
|
150
|
+
def value(self, project_id, environment, key):
|
|
151
|
+
"""The plaintext value. **Writes a `READ` audit row naming the key**, every call.
|
|
152
|
+
|
|
153
|
+
Prefer `get()` when you only need to know the secret exists or changed.
|
|
154
|
+
"""
|
|
155
|
+
_, body = self._c.request("GET", _secret_path(project_id, environment, key) + "/value")
|
|
156
|
+
return make(SecretValue, body)
|
|
157
|
+
|
|
158
|
+
def set(self, project_id, environment, key, value, *, format_regex=UNSET, rotation_due_at=UNSET,
|
|
159
|
+
expires_at=UNSET, value_type=UNSET, note=UNSET, confirm_production=False):
|
|
160
|
+
"""`PUT`: create or update. Returns `Written`, or `Pending` when dual control holds the change."""
|
|
161
|
+
return self._write("PUT", project_id, environment, key, value, confirm_production, _given(
|
|
162
|
+
format_regex=format_regex, rotation_due_at=rotation_due_at, expires_at=expires_at,
|
|
163
|
+
value_type=value_type, note=note,
|
|
164
|
+
))
|
|
165
|
+
|
|
166
|
+
def create(self, project_id, environment, key, value, *, format_regex=UNSET, rotation_due_at=UNSET,
|
|
167
|
+
expires_at=UNSET, value_type=UNSET, note=UNSET, confirm_production=False):
|
|
168
|
+
"""`POST`: create only. An existing key raises `OhapiError` with code `conflict`."""
|
|
169
|
+
return self._write("POST", project_id, environment, key, value, confirm_production, _given(
|
|
170
|
+
format_regex=format_regex, rotation_due_at=rotation_due_at, expires_at=expires_at,
|
|
171
|
+
value_type=value_type, note=note,
|
|
172
|
+
))
|
|
173
|
+
|
|
174
|
+
def _write(self, method, project_id, environment, key, value, confirm_production, extra):
|
|
175
|
+
check.secret_value(value)
|
|
176
|
+
status, body = self._c.request(
|
|
177
|
+
method, "/api/v1/secrets",
|
|
178
|
+
headers={**_target_headers(project_id, environment), **_confirm(confirm_production)},
|
|
179
|
+
body={"key": check.key(key), "value": value, **extra},
|
|
180
|
+
)
|
|
181
|
+
return _written(status, body, key, Written)
|
|
182
|
+
|
|
183
|
+
def patch(self, project_id, environment, key, *, rotation_due_at=UNSET, expires_at=UNSET,
|
|
184
|
+
format_regex=UNSET, note=UNSET, confirm_production=False):
|
|
185
|
+
"""`PATCH`: dates and `formatRegex` only. Never appends a version.
|
|
186
|
+
|
|
187
|
+
An omitted field is left alone. `None` clears it.
|
|
188
|
+
"""
|
|
189
|
+
status, body = self._c.request(
|
|
190
|
+
"PATCH", _secret_path(project_id, environment, key), headers=_confirm(confirm_production),
|
|
191
|
+
body=_given(rotation_due_at=rotation_due_at, expires_at=expires_at, format_regex=format_regex, note=note),
|
|
192
|
+
)
|
|
193
|
+
return _written(status, body, key, Patched)
|
|
194
|
+
|
|
195
|
+
def delete(self, project_id, environment, key, *, confirm_production=False):
|
|
196
|
+
status, body = self._c.request(
|
|
197
|
+
"DELETE", "/api/v1/secrets",
|
|
198
|
+
headers={**_target_headers(project_id, environment), **_confirm(confirm_production)},
|
|
199
|
+
body={"key": check.key(key)},
|
|
200
|
+
)
|
|
201
|
+
return _written(status, body, key, Deleted)
|
|
202
|
+
|
|
203
|
+
def pull(self, project_id, environment):
|
|
204
|
+
"""The whole environment as `{KEY: VALUE}`, expired keys omitted.
|
|
205
|
+
|
|
206
|
+
**Writes one `READ` audit row for the environment.** Meant for an app at boot; do not call
|
|
207
|
+
it to check a key.
|
|
208
|
+
"""
|
|
209
|
+
_, body = self._c.request("GET", "/api/v1/secrets", headers=_target_headers(project_id, environment))
|
|
210
|
+
return body if isinstance(body, dict) else {}
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
class _ChangeRequests:
|
|
214
|
+
def __init__(self, client):
|
|
215
|
+
self._c = client
|
|
216
|
+
|
|
217
|
+
def get(self, change_request_id):
|
|
218
|
+
_, body = self._c.request("GET", "/api/v1/change-requests/" + check.change_request_id(change_request_id))
|
|
219
|
+
return make(ChangeRequest, body)
|
|
220
|
+
|
|
221
|
+
def wait(self, change_request_id, *, interval_s=5.0, timeout_s=600.0, cancel=None):
|
|
222
|
+
"""Poll until the request is `APPLIED`.
|
|
223
|
+
|
|
224
|
+
`REJECTED` and `CANCELLED` raise `OhapiError` with code `rejected` (the terminal request is
|
|
225
|
+
on `error.change_request`): returning would let a caller believe a rejected production
|
|
226
|
+
change landed. A 429 waits `Retry-After` and never tightens the loop. Time runs out, or
|
|
227
|
+
`cancel` (a `threading.Event`) is set: code `timeout`. A request already in flight is not
|
|
228
|
+
interrupted by `cancel`; it ends at its own timeout.
|
|
229
|
+
"""
|
|
230
|
+
path = "/api/v1/change-requests/" + check.change_request_id(change_request_id)
|
|
231
|
+
deadline = time.monotonic() + timeout_s
|
|
232
|
+
while True:
|
|
233
|
+
if cancel is not None and cancel.is_set():
|
|
234
|
+
raise OhapiError("timeout", "wait() was cancelled")
|
|
235
|
+
pause = interval_s
|
|
236
|
+
try:
|
|
237
|
+
remaining = max(deadline - time.monotonic(), 0.001)
|
|
238
|
+
_, body = self._c.request("GET", path, timeout_s=min(self._c.timeout_s, remaining))
|
|
239
|
+
request = make(ChangeRequest, body)
|
|
240
|
+
if request.status == "APPLIED":
|
|
241
|
+
return request
|
|
242
|
+
if request.status in ("REJECTED", "CANCELLED"):
|
|
243
|
+
raise OhapiError(
|
|
244
|
+
"rejected", "Change request was %s" % request.status.lower(), change_request=request
|
|
245
|
+
)
|
|
246
|
+
except OhapiError as error:
|
|
247
|
+
if error.code != "rate_limited":
|
|
248
|
+
raise
|
|
249
|
+
pause = max(interval_s, error.retry_after_seconds or 0)
|
|
250
|
+
remaining = deadline - time.monotonic()
|
|
251
|
+
if remaining <= 0:
|
|
252
|
+
raise OhapiError("timeout", "Change request still pending when wait() gave up")
|
|
253
|
+
pause = min(pause, remaining, threading.TIMEOUT_MAX)
|
|
254
|
+
if cancel is not None:
|
|
255
|
+
if cancel.wait(pause):
|
|
256
|
+
raise OhapiError("timeout", "wait() was cancelled")
|
|
257
|
+
else:
|
|
258
|
+
time.sleep(pause)
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
class Ohapi:
|
|
262
|
+
"""Typed client for the ohapi machine API.
|
|
263
|
+
|
|
264
|
+
No retries, no logging, no globals: a thin client does not decide retry policy.
|
|
265
|
+
`change_requests.wait()` is the one polling helper, and it is explicit.
|
|
266
|
+
"""
|
|
267
|
+
|
|
268
|
+
def __init__(self, token, *, api_url=None, timeout_s=DEFAULT_TIMEOUT_S, user_agent=None):
|
|
269
|
+
self.__client = Client(token, api_url, check.timeout_s(timeout_s), None if user_agent is None else check.user_agent(user_agent))
|
|
270
|
+
self.projects = _Projects(self.__client)
|
|
271
|
+
self.environments = _Environments(self.__client)
|
|
272
|
+
self.secrets = _Secrets(self.__client)
|
|
273
|
+
self.change_requests = _ChangeRequests(self.__client)
|
|
274
|
+
|
|
275
|
+
@property
|
|
276
|
+
def api_url(self):
|
|
277
|
+
return self.__client.api_url
|
|
278
|
+
|
|
279
|
+
def me(self):
|
|
280
|
+
"""`GET /api/v1/me`: who this key acts as. No audit row."""
|
|
281
|
+
_, body = self.__client.request("GET", "/api/v1/me")
|
|
282
|
+
return make(Me, body, ME_NESTED)
|
|
283
|
+
|
|
284
|
+
def __repr__(self):
|
|
285
|
+
return "Ohapi(api_url=%r)" % self.api_url
|
|
286
|
+
|
|
287
|
+
__str__ = __repr__
|
ohapi/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ohapi
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python client for the ohapi secrets API.
|
|
5
|
+
Author: Obsidian Heron
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://ohapi.dev
|
|
8
|
+
Project-URL: Issues, https://github.com/Obsidian-Heron/ohapi/issues
|
|
9
|
+
Project-URL: Source, https://github.com/Obsidian-Heron/ohapi/tree/main/sdk-python
|
|
10
|
+
Keywords: secrets,env,environment-variables,secrets-management,sdk
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
13
|
+
Classifier: Typing :: Typed
|
|
14
|
+
Requires-Python: >=3.10
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
License-File: LICENSE
|
|
17
|
+
Dynamic: license-file
|
|
18
|
+
|
|
19
|
+
# ohapi
|
|
20
|
+
|
|
21
|
+
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.
|
|
22
|
+
|
|
23
|
+
## Install
|
|
24
|
+
|
|
25
|
+
```sh
|
|
26
|
+
pip install ohapi
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
The package is not on PyPI yet.
|
|
30
|
+
|
|
31
|
+
## Example
|
|
32
|
+
|
|
33
|
+
```python
|
|
34
|
+
import os
|
|
35
|
+
|
|
36
|
+
from ohapi import Ohapi, OhapiError, Pending
|
|
37
|
+
|
|
38
|
+
ohapi = Ohapi(os.environ["OHAPI_TOKEN"])
|
|
39
|
+
|
|
40
|
+
result = ohapi.secrets.set(project_id, "production", "API_URL", "https://api.example.com", confirm_production=True)
|
|
41
|
+
|
|
42
|
+
if isinstance(result, Pending):
|
|
43
|
+
# Dual control holds the change. Nothing is written yet.
|
|
44
|
+
try:
|
|
45
|
+
ohapi.change_requests.wait(result.change_request_id)
|
|
46
|
+
except OhapiError as error:
|
|
47
|
+
if error.code != "rejected":
|
|
48
|
+
raise
|
|
49
|
+
print("A reviewer refused the change:", error.change_request.status)
|
|
50
|
+
else:
|
|
51
|
+
print("Wrote version", result.version)
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## Methods
|
|
55
|
+
|
|
56
|
+
| Method | Endpoint | Audit row |
|
|
57
|
+
| --- | --- | --- |
|
|
58
|
+
| `me()` | `GET /api/v1/me` | No |
|
|
59
|
+
| `projects.list()`, `projects.get(id)` | `GET /api/v1/projects`, `…/{id}` | No |
|
|
60
|
+
| `environments.list()`, `.iterate()`, `.get()` | `GET …/environments` | No |
|
|
61
|
+
| `secrets.list()`, `.iterate()`, `.get()` | `GET …/secrets` | **No.** Metadata only. |
|
|
62
|
+
| `secrets.value()` | `GET …/secrets/{key}/value` | **Yes.** One `READ` row for each call. |
|
|
63
|
+
| `secrets.pull()` | `GET /api/v1/secrets` | **Yes.** One `READ` row for the environment. Expired keys are omitted. |
|
|
64
|
+
| `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. |
|
|
65
|
+
| `change_requests.get()`, `.wait()` | `GET /api/v1/change-requests/{id}` | No |
|
|
66
|
+
|
|
67
|
+
`list()` takes `page` and `page_size`. `iterate()` takes `page_size`. The server accepts page numbers up to 10000 and page sizes up to 500.
|
|
68
|
+
|
|
69
|
+
The client is synchronous. There is no `asyncio` client.
|
|
70
|
+
|
|
71
|
+
## Pending writes
|
|
72
|
+
|
|
73
|
+
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`.
|
|
74
|
+
|
|
75
|
+
A write returns one of these types:
|
|
76
|
+
|
|
77
|
+
| Call | Result |
|
|
78
|
+
| --- | --- |
|
|
79
|
+
| `set()`, `create()` | `Written(key, version, created, id)` |
|
|
80
|
+
| `patch()` | `Patched(key)` |
|
|
81
|
+
| `delete()` | `Deleted(key)` |
|
|
82
|
+
| Any of them, when dual control holds the change | `Pending(change_request_id, key)` |
|
|
83
|
+
|
|
84
|
+
`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`.
|
|
85
|
+
|
|
86
|
+
## Omitted and `None`
|
|
87
|
+
|
|
88
|
+
`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.
|
|
89
|
+
|
|
90
|
+
## Errors
|
|
91
|
+
|
|
92
|
+
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`.
|
|
93
|
+
|
|
94
|
+
| Code | Cause |
|
|
95
|
+
| --- | --- |
|
|
96
|
+
| `invalid` | Bad input. The SDK refused it before any request, or the server answered `400`. |
|
|
97
|
+
| `unauthenticated` | `401`. The key is missing, wrong, or expired. |
|
|
98
|
+
| `forbidden` | `403`. The key has a read-only role, the route is for admins only, or the key scope does not cover the target. |
|
|
99
|
+
| `not_found` | `404`. The thing does not exist, or the key cannot reach it. |
|
|
100
|
+
| `conflict` | `409`. `create()` found the key already there. |
|
|
101
|
+
| `unprocessable` | `422`. The server refused the value. |
|
|
102
|
+
| `rate_limited` | `429`. `retry_after_seconds` holds the wait. |
|
|
103
|
+
| `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. |
|
|
104
|
+
| `network` | No connection. |
|
|
105
|
+
| `timeout` | The whole request, headers and body included, did not finish within `timeout_s` (30 seconds by default), or `wait()` ended. |
|
|
106
|
+
| `rejected` | `wait()` found a rejected or cancelled change request. |
|
|
107
|
+
|
|
108
|
+
The SDK does not retry. You decide the retry policy. The SDK does not log.
|
|
109
|
+
|
|
110
|
+
## The key
|
|
111
|
+
|
|
112
|
+
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.
|
|
113
|
+
|
|
114
|
+
## Not included
|
|
115
|
+
|
|
116
|
+
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.
|
|
117
|
+
|
|
118
|
+
## Tests
|
|
119
|
+
|
|
120
|
+
```sh
|
|
121
|
+
sdk-python/scripts/test.sh
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
The script runs the unit tests in Docker. The tests need no server.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
ohapi/__init__.py,sha256=de4nnEjRCaZb8w0yq_WtI2f5SpwUEk1sxPQ13BYeXGY,882
|
|
2
|
+
ohapi/_client.py,sha256=x93iSU9cw2ufN2DIluVOHNJMCoUEIiITxmfrlEror6I,9647
|
|
3
|
+
ohapi/_types.py,sha256=LdRr-drYVswQtQx94YOX4KT3S9XWu5nTfhp_CySDK24,4732
|
|
4
|
+
ohapi/_validate.py,sha256=QZKPMH_gK2oRE3fVS1xjdVlwuSQiAapnaryRebmGuRY,2559
|
|
5
|
+
ohapi/client.py,sha256=sNdbecITr5niKwiDg-TVFKxV30yJD2fMK0L-RVhzNRQ,11856
|
|
6
|
+
ohapi/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
7
|
+
ohapi-0.1.0.dist-info/licenses/LICENSE,sha256=_BE4gs94_TGtLEEtSjI-bj64vFPEM8VmZDseXJubqqU,1071
|
|
8
|
+
ohapi-0.1.0.dist-info/METADATA,sha256=lJaWEnPfxF0MQBdxRzIp8QYJg3NhOa6NTunDupzn09M,6012
|
|
9
|
+
ohapi-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
10
|
+
ohapi-0.1.0.dist-info/top_level.txt,sha256=uUMR0w0FfsvUN-uVB69bEDPDXH0wmaCyW-kc3Q-z9tM,6
|
|
11
|
+
ohapi-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Obsidian Heron
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
ohapi
|