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 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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -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